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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 10:36:10 +02:00
1 parent 5fbc6eb088
commit a7e793f6de
4 files changed
+187 -16

No files matched your search

+23 -15
View File
@@ -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<string, Record<string, Value>>;
dataCache: Map<string, Record<string, Value>>;
}
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<string, Value> = {}, 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);
}
}
+90
View File
@@ -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<T>(items: readonly T[], key: (item: T) => string): Map<string, T[]> {
const groups = new Map<string, T[]>();
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<string, Map<string, GraphEdge[]>> {
const nodes = new Map<string, Map<string, GraphEdge[]>>();
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<string, NodeDef | undefined>();
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<Graph, GraphIndex>();
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();
}
}
+5 -1
View File
@@ -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<string, PropertyBag> = {};
/** 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<string, number>(), nodes: new Map<string, number>() };
const values = new Map<Graph, Map<string, Produced>>();
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),
+69
View File
@@ -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']);
});
});