From a7e793f6de4c5fe878a36f7f968ad473ae5defeb Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 29 Sep 2026 10:36:10 +0200 Subject: [PATCH] Keep many spawned behaviors from eating the frame A scene with 200 spawned gold pickups, each with a Pickup behavior of 97 nodes, dropped the game to about 14 fps. The graphs themselves ran only a dozen nodes per pickup and frame. The cost was the lookup: for every data input the interpreter searched all edges and all nodes of the graph linearly and computed the port shape of the node again. - New module core/graph/graphIndex.ts builds tables once per graph: nodes by id, edges by node and port, node types. Port shapes are cached as well. - BehaviorSystem shares one cache between all its instances and clears it in syncFromModel. A graph changed during a run applies from the next frame on, as before. - An interpreter without a cache, as in most tests, searches as before. Measured on the scene Performance test (headless per frame, browser fps on a throwaway copy of the project): - Headless: about 21 ms down to about 3.6 ms per frame. - Browser, about 505 entities: 13.6 fps up to 23 fps. - Browser, about 320 entities: 32.3 fps up to 48 fps. - After the fix behaviors take about 15 % of the browser profile. The rest is the editor mirroring the game into the model and rebuilding its panels, which is a separate change. Tests: - ./check.sh: 192 files, 2786 tests, CHECK EXIT 0. - Full e2e run (local): ALL PASSED, 1551 checks. - tests/core/graph-index.test.ts: 2 tests. The tables return the same as the search, in the same edge order. A graph changed during a run applies from the next frame. - Counter-check: without clearing the cache in syncFromModel the second test fails with ['before']. Co-Authored-By: Claude Opus 5.5 --- src/core/graph/GraphInterpreter.ts | 38 ++++++----- src/core/graph/graphIndex.ts | 90 +++++++++++++++++++++++++++ src/runtime/systems/BehaviorSystem.ts | 6 +- tests/core/graph-index.test.ts | 69 ++++++++++++++++++++ 4 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 src/core/graph/graphIndex.ts create mode 100644 tests/core/graph-index.test.ts diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 90eccedb..fce99ceb 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -1,7 +1,8 @@ import { blockProblem, findMode, MATCH_INPUT, matchOf, searchOutcome, stopsAtHit } from './findBlock'; -import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; +import type { Graph, GraphNode } from './Graph'; +import { type GraphCache, type GraphIndex, searchingIndex } from './graphIndex'; import { type GraphLookup, NO_LOOKUP } from './lookup'; -import { BEHAVIOR_END, callEntries, runDef, terminalNode } from './nested'; +import { BEHAVIOR_END, callEntries, terminalNode } from './nested'; import { fieldValue, splitField, withSplitValues } from './portSplits'; import type { PropertyBag, PropertyValue } from '../model/Properties'; import type { NodeContext } from './NodeContext'; @@ -37,14 +38,11 @@ export interface TraceListener { /** One graph's pulse state. A nested behavior runs in its own – node ids are unique per graph only. */ interface Frame { graph: Graph; + index: GraphIndex; outputCache: Map>; dataCache: Map>; } -function emptyFrame(graph: Graph): Frame { - return { graph, outputCache: new Map(), dataCache: new Map() }; -} - /** * The variables a call starts with: today's declarations at their defaults, except that a kept one * resumes at the value the last call from the same site left – unless that value is gone or no @@ -89,12 +87,22 @@ export class GraphInterpreter { constructor( readonly graph: Graph, private readonly lookup: GraphLookup = NO_LOOKUP, + /** Without one the graph is searched every time, so it may change between two pulses. */ + private readonly cache: GraphCache | null = null, ) { - this.frame = emptyFrame(graph); + this.frame = this.emptyFrame(graph); } - eventNodes(type: string): GraphNode[] { - return this.graph.nodes.filter((n) => n.type === type); + private indexOf(graph: Graph): GraphIndex { + return this.cache ? this.cache.indexOf(graph, this.lookup) : searchingIndex(graph, this.lookup); + } + + private emptyFrame(graph: Graph): Frame { + return { graph, index: this.indexOf(graph), outputCache: new Map(), dataCache: new Map() }; + } + + eventNodes(type: string): readonly GraphNode[] { + return this.indexOf(this.graph).ofType(type); } fire( @@ -116,7 +124,7 @@ export class GraphInterpreter { /** Runs the flow of one event node, whichever it is; `fire` is this for every node of a type. */ pulse(node: GraphNode, ctx: NodeContext, outputs: Record = {}, port = 'exec'): void { - this.frame = emptyFrame(this.graph); + this.frame = this.emptyFrame(this.graph); this.callStack = []; this.callSites = []; this.steps = 0; @@ -139,7 +147,7 @@ export class GraphInterpreter { } private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void { - for (const edge of edgesOutOf(this.frame.graph, node.id, port)) { + for (const edge of this.frame.index.outOf(node.id, port)) { const target = this.node(edge.to.node); if (!target) continue; this.trace?.onEdge(edge.id); @@ -185,7 +193,7 @@ export class GraphInterpreter { const variables = resumedVariables(program.defaults, this.keptCalls.get(sites), kept); if (kept.length > 0) this.keptCalls.set(sites, variables); const outer = this.frame; - this.frame = emptyFrame(program.graph); + this.frame = this.emptyFrame(program.graph); for (const entry of entries) { this.frame.outputCache.set(entry.id, { ...call.inputs }); this.record(entry.id, call.inputs); @@ -296,7 +304,7 @@ export class GraphInterpreter { } private inputValue(node: GraphNode, port: PortDef, ctx: NodeContext): Value { - const edge = edgesInto(this.frame.graph, node.id, port.key)[0]; + const edge = this.frame.index.into(node.id, port.key)[0]; if (!edge) return inlineValue(node, port); const source = this.node(edge.from.node); const split = source && splitField(source, edge.from.port); @@ -352,10 +360,10 @@ export class GraphInterpreter { /** Without split field ports: a node produces its wholes, and `inputValue` picks the fields. */ private def(node: GraphNode) { - return runDef(node, this.frame.graph, this.lookup); + return this.frame.index.def(node); } private node(id: string): GraphNode | undefined { - return this.frame.graph.nodes.find((n) => n.id === id); + return this.frame.index.node(id); } } diff --git a/src/core/graph/graphIndex.ts b/src/core/graph/graphIndex.ts new file mode 100644 index 00000000..ae9e8229 --- /dev/null +++ b/src/core/graph/graphIndex.ts @@ -0,0 +1,90 @@ +import { edgesInto, edgesOutOf, findNode, type Graph, type GraphEdge, type GraphNode } from './Graph'; +import type { GraphLookup } from './lookup'; +import { runDef } from './nested'; +import type { NodeDef } from './NodeRegistry'; + +/** What the interpreter asks of a graph while it runs one, over and over. */ +export interface GraphIndex { + node(id: string): GraphNode | undefined; + into(nodeId: string, port: string): readonly GraphEdge[]; + outOf(nodeId: string, port: string): readonly GraphEdge[]; + /** The definition as the interpreter runs it (see `runDef`). */ + def(node: GraphNode): NodeDef | undefined; + ofType(type: string): readonly GraphNode[]; +} + +/** Answers by searching the graph each time – right for a graph that may change under the interpreter. */ +export function searchingIndex(graph: Graph, lookup: GraphLookup): GraphIndex { + return { + node: (id) => findNode(graph, id), + into: (nodeId, port) => edgesInto(graph, nodeId, port), + outOf: (nodeId, port) => edgesOutOf(graph, nodeId, port), + def: (node) => runDef(node, graph, lookup), + ofType: (type) => graph.nodes.filter((n) => n.type === type), + }; +} + +const NONE: readonly never[] = []; + +function grouped(items: readonly T[], key: (item: T) => string): Map { + const groups = new Map(); + for (const item of items) { + const k = key(item); + const group = groups.get(k); + if (group) group.push(item); + else groups.set(k, [item]); + } + return groups; +} + +/** Edges by the node, then the port they meet it at. */ +function byPort(edges: readonly GraphEdge[], end: 'from' | 'to'): Map> { + const nodes = new Map>(); + for (const [nodeId, ofNode] of grouped(edges, (e) => e[end].node)) + nodes.set( + nodeId, + grouped(ofNode, (e) => e[end].port), + ); + return nodes; +} + +/** Answers from tables built once. Only valid until the project is edited – see `GraphCache`. */ +export function tabledIndex(graph: Graph, lookup: GraphLookup): GraphIndex { + const nodes = new Map(graph.nodes.map((n) => [n.id, n])); + const into = byPort(graph.edges, 'to'); + const outOf = byPort(graph.edges, 'from'); + const ofType = grouped(graph.nodes, (n) => n.type); + const defs = new Map(); + return { + node: (id) => nodes.get(id), + into: (nodeId, port) => into.get(nodeId)?.get(port) ?? NONE, + outOf: (nodeId, port) => outOf.get(nodeId)?.get(port) ?? NONE, + def: (node) => { + if (defs.has(node.id)) return defs.get(node.id); + const def = runDef(node, graph, lookup); + defs.set(node.id, def); + return def; + }, + ofType: (type) => ofType.get(type) ?? NONE, + }; +} + +/** + * The tabled indexes of a run, one per graph. A running game asks the same questions of the same + * graphs every frame, and searching for them was most of what a frame cost. Whoever hands one to + * the interpreters has to `clear` it on every edit to the project: a node's ports depend on other + * assets too (a machine's states, a called behavior's signature). + */ +export class GraphCache { + private indexes = new WeakMap(); + + indexOf(graph: Graph, lookup: GraphLookup): GraphIndex { + let index = this.indexes.get(graph); + if (!index) this.indexes.set(graph, (index = tabledIndex(graph, lookup))); + return index; + } + + clear(): void { + this.indexes = new WeakMap(); + } +} diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 2c7b5954..fe4c4370 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -23,6 +23,7 @@ import { type TraceListener, type Value, } from '../../core/graph'; +import { GraphCache } from '../../core/graph/graphIndex'; import { stepKeyRepeat } from '../../core/graph/keyRepeat'; import { keyName } from '../../core/keys'; import { graphLookup } from '../../core/model/graphLookup'; @@ -194,6 +195,8 @@ export class BehaviorSystem implements System { private unranked = true; /** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */ private declared: Record = {}; + /** Shared by every instance; an edit to the project empties it (see `syncFromModel`). */ + private readonly graphs = new GraphCache(); private readonly collisions: CollisionSource | null; private readonly animations: AnimationSystem | null; private readonly audio: AudioSink; @@ -231,6 +234,7 @@ export class BehaviorSystem implements System { /** An edit to the project while the run is on: declarations and the hierarchy may have changed. */ syncFromModel(): void { + this.graphs.clear(); this.syncDeclarations(); this.unranked = true; } @@ -323,7 +327,7 @@ export class BehaviorSystem implements System { if (!asset) continue; const trace = { edges: new Map(), nodes: new Map() }; const values = new Map>(); - const interpreter = new GraphInterpreter(asset.graph, graphLookup(this.project)); + const interpreter = new GraphInterpreter(asset.graph, graphLookup(this.project), this.graphs); interpreter.trace = { onEdge: (id) => trace.edges.set(id, world.time), onNode: (id) => trace.nodes.set(id, world.time), diff --git a/tests/core/graph-index.test.ts b/tests/core/graph-index.test.ts new file mode 100644 index 00000000..24296c96 --- /dev/null +++ b/tests/core/graph-index.test.ts @@ -0,0 +1,69 @@ +/** + * The tables a running game looks its graphs up in: they answer as searching the graph does, and an + * edit to the project reaches the run once the run is told about it. + */ +import { describe, expect, it } from 'vitest'; +import { AddPortsCommand } from '../../src/core/commands/portCommands'; +import { searchingIndex, tabledIndex } from '../../src/core/graph/graphIndex'; +import { graphLookup } from '../../src/core/model/graphLookup'; +import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers'; + +describe('the tabled index', () => { + it('answers as searching the graph does, edge order included', () => { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'B', ['n']); + const edit = graphEditor(f, behavior); + const update = edit.addNode('OnUpdate'); + const first = edit.addNode('Log', { message: 'first' }); + const second = edit.addNode('Log', { message: 'second' }); + const read = edit.variables(['n']); + edit.wire([ + [update, 'exec', first, 'exec'], + [update, 'exec', second, 'exec'], + [first, 'exec', second, 'exec'], + [read, 'n', second, 'message'], + ]); + const lookup = graphLookup(f.project); + const graph = behavior.graph; + const searched = searchingIndex(graph, lookup); + const tabled = tabledIndex(graph, lookup); + for (const node of graph.nodes) { + expect(tabled.node(node.id)).toBe(node); + expect(tabled.def(node)).toEqual(searched.def(node)); + for (const port of ['exec', 'message', 'n', 'nothing']) { + expect(tabled.into(node.id, port)).toEqual(searched.into(node.id, port)); + expect(tabled.outOf(node.id, port)).toEqual(searched.outOf(node.id, port)); + } + } + expect(tabled.outOf(update.id, 'exec').map((e) => e.to.node)).toEqual([first.id, second.id]); + expect(tabled.into(second.id, 'exec')).toHaveLength(2); + expect(tabled.ofType('Log')).toEqual([first, second]); + expect(tabled.ofType('OnStart')).toEqual([]); + expect(tabled.node('gone')).toBeUndefined(); + }); +}); + +describe('a running game', () => { + it('follows an edit to a graph from the frame after the run is told', () => { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'B', ['n']); + place(f, behavior.id, { x: 0, y: 0 }); + const edit = graphEditor(f, behavior); + const update = edit.addNode('OnUpdate'); + const log = edit.addNode('Log', { message: 'before' }); + edit.connect(update, 'exec', log, 'exec'); + const read = edit.variables([]); + const { world, frame, behaviors } = startWorld(f, { behaviors: true }); + frame(); + expect(world.log).toEqual(['before']); + + // A new edge, and a port the Variables node did not have when the run first looked at it. + f.stack.execute(new AddPortsCommand(behavior.id, read.id, [{ key: 'n', kind: 'number' }])); + edit.connect(read, 'n', log, 'message'); + edit.connect(update, 'exec', edit.addNode('Log', { message: 'added' }), 'exec'); + behaviors!.syncFromModel(); + world.log.length = 0; + frame(); + expect(world.log).toEqual(['0', 'added']); + }); +});