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:
1 parent
5fbc6eb088
commit
a7e793f6de
4 files changed
+187
-16
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user