From a635ff222f9f766f088c04b9fce871930c218a23 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 01:12:31 -0700 Subject: [PATCH 1/4] ecs: build `up` trees and orders in bulk after a load New benchmark `editor load tick 50K`: a 2D editor's first tick after spawning a 50,201-node tree, with two filtered `up` row systems, a hash index on the parent and a sorted paint-order index. 32 ms before. - A filtered run after its tree was built again runs the whole order, rather than marking every row and scanning for the marks: the tick 32 -> 25.1 ms. - The tree's build reads each archetype's parent column once: the opacity `up` 5.35 -> 4.5 ms. - The build sets every level in one walk per entity: the opacity `up` 4.77 -> 4.49 ms. - An order build's scratch is shared by every `up` of a thread: the tick 20.6 -> 20.0 ms. `up load` 222 -> 184 ms. Co-Authored-By: Claude Opus 5.5 --- .../ecs/src/implementation/world/runner.ts | 25 +--- .../ecs/src/implementation/world/up.test.ts | 27 ++++ packages/ecs/src/implementation/world/up.ts | 132 +++++++++++++---- packages/ecs/src/perftest/benchmarks.ts | 2 + packages/ecs/src/perftest/editor.ts | 139 ++++++++++++++++++ 5 files changed, 277 insertions(+), 48 deletions(-) create mode 100644 packages/ecs/src/perftest/editor.ts diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index c0ce8cd4..f0509343 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -611,28 +611,9 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const runUp = (since: number, values: readonly unknown[], args: unknown, tick: number, workers: Workers | null, index: number): Promise | null => { if (up === null) return null; const slot = partialCount === 0 ? 0 : takeSlots(1); - let next: () => readonly Run[] | null; - let clear = (): void => {}; - if (since < 0) { - const order = up.levels(matches); - let depth = 0; - next = () => { - if (depth >= order.levels) return null; - const runs: Run[] = []; - for (let group = order.depthGroups[depth]; group < order.depthGroups[depth + 1]; group++) { - if (order.groupEnds[group] === order.groupStarts[group]) continue; - runs.push({ owner: order.groupOwners[group], parent: order.groupParents[group], rows: order.rows, parents: order.parents, start: order.groupStarts[group], end: order.groupEnds[group] }); - } - depth++; - return runs; - }; - } - else { - const pass = up.filtered(matches, addedIds, changedIds, since); - if (pass === null) return null; - next = pass.next; - clear = pass.clear; - } + const pass = since < 0 ? up.wholePass(up.levels(matches)) : up.filtered(matches, addedIds, changedIds, since); + if (pass === null) return null; + const { next, clear } = pass; const runLocally = (run: Run): void => runUpRows(matches[run.owner], run.parent, run.rows, run.parents, run.start, run.end, values, args, tick, slot); const parallel = workers !== null && kernel !== null && kernel.wasm !== null && kernel.compiled() !== null && partialCount === 0 && kernel.lookupParameters().length + indexParameters.length + 3 + (kernel.description.upColumns?.length ?? 0) + kernel.wasm.scalars.length <= maximumScalars; diff --git a/packages/ecs/src/implementation/world/up.test.ts b/packages/ecs/src/implementation/world/up.test.ts index e021ed25..0d4ebac7 100644 --- a/packages/ecs/src/implementation/world/up.test.ts +++ b/packages/ecs/src/implementation/world/up.test.ts @@ -101,6 +101,12 @@ const loop = System.create({ name: "upTest/loop" }, ({ commands }) => { const close = System.create({ name: "upTest/close" }, ({ commands }) => { commands.add(entities.root, parent, entities.grandchild); }); const open = System.create({ name: "upTest/open" }, ({ commands }) => { commands.add(entities.root, parent, entities.other); }); const orphan = System.create({ name: "upTest/orphan" }, ({ commands }) => { commands.remove(entities.grandchild, parent); }); +/** Spawns `count` leaves under the grandchild, more than the journal keeps for a world this small. */ +const bulk: Entity[] = []; +const bulkUnder = System.create({ name: "upTest/bulkUnder", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + bulk.length = 0; + for (let i = 0; i < input.count; i++) bulk.push(commands.spawn([label, local, world, parent])({ [label]: "bulk", [local]: 1, [world]: 0, [parent]: entities.grandchild })); +}); const worlds = async (value: (entity: Entity) => Promise): Promise<{ [name: string]: number | null }> => Object.fromEntries(await Promise.all(Object.entries(entities).map(async ([name, entity]) => [name, await value(entity)]))); @@ -198,6 +204,27 @@ describe("up", () => { expect(await read(ecs, entities.grandchild, world)).toBe(100); }); + it("runs every row after a spawn outruns the journal, with change filters, then only the rows that changed", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawn); + await ecs.run(nest); + await ecs.run(propagateChanged); + await ecs.run(bulkUnder, { count: 5000 }); + ran.length = 0; + await ecs.run(propagateChanged); + expect(ran.length).toBe(4 + 5000); + expect(ran.slice(0, 4).sort()).toEqual(["child", "grandchild", "other", "root"]); + expect(await readEach(ecs, bulk.slice(0, 3), world)).toEqual([112, 112, 112]); + ran.length = 0; + await ecs.run(bump, { entity: entities.grandchild }); + await ecs.run(propagateChanged); + expect(ran.length).toBe(1 + 5000); + expect(await readEach(ecs, [entities.grandchild, bulk[4999]], world)).toEqual([13, 14]); + ran.length = 0; + await ecs.run(propagateChanged); + expect(ran).toEqual([]); + }); + it("finds a cycle a reparent closes after the tree was built, and runs its rows again once it opens", async () => { await using ecs = createWorld({ [workers]: 0 }); await ecs.run(spawn); diff --git a/packages/ecs/src/implementation/world/up.ts b/packages/ecs/src/implementation/world/up.ts index 369a0221..da846c3a 100644 --- a/packages/ecs/src/implementation/world/up.ts +++ b/packages/ecs/src/implementation/world/up.ts @@ -44,6 +44,29 @@ const extend = (array: Int32Array, length: number, value: number): return grown; }; +// Scratch of an order build, shared by every `up` of a thread, since a build runs start to end without yielding: a +// second system reuses the first one's arrays rather than allocating its own. + +/** By index among the matched rows: its entity index, depth, archetype, parent row, and parent's archetype id when readable or -1. */ +let rowEntities = new Int32Array(0); +let depths = new Int32Array(0); +let rowParents = new Int32Array(0); +let keys = new Int32Array(0); +let owners = new Int32Array(0); +/** The matched rows' indexes, by depth, and where each depth ends. */ +let sortedRows = new Int32Array(0); +let counts = new Int32Array(0); +/** By index in `matches`: the index of its first row among the matched rows. */ +let bases = new Int32Array(0); +/** The groups of a build, laid out one after another, before the order lays them out with spare slots. */ +let builtRows = new Int32Array(0); +let builtParents = new Int32Array(0); +let builtEntities = new Int32Array(0); +let groupEnds = new Int32Array(0); +let groupDepths = new Int32Array(0); +let groupOwners = new Int32Array(0); +let groupParents = new Int32Array(0); + /** * Orders an `up` system's rows by depth through its `through` reference. A row's depth is one more than its parent's * when its parent is among the matched rows, and 0 otherwise, so rows of one depth never read each other's writes. @@ -254,15 +277,67 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { for (const archetype of matches) count += archetype.count; const all = new Int32Array(count); let at = 0; + const levels = levelOf; for (const archetype of matches) { - for (let row = 0; row < archetype.count; row++) { - const index = archetype.entities[row] >>> 0; - levelOf[index] = 1; + const rows = archetype.entities; + for (let row = 0, end = archetype.count; row < end; row++) { + const index = rows[row] >>> 0; + levels[index] = 1; all[at++] = index; } } - for (let k = 0; k < count; k++) link(all[k], parentAt(all[k])); - settle(all, count); + // Each archetype's parent column is found once, as `parentAt` finds it per entity. + const { generation, archetype: archetypeOf, length: entityCount } = state.entities; + const parents = parentOf; + at = 0; + for (const archetype of matches) { + const storage: Storage | undefined = archetype.storageById[through]; + const layout = storage?.layout; + const leaf = layout?.kind === "linear" ? layout.leaves[0] : undefined; + if (storage === undefined || leaf === undefined) { + for (let row = 0; row < archetype.count; row++) parents[all[at++]] = -1; + continue; + } + const view = storage.views[leaf.view]; + const stride = leaf.unitStride, offset = leaf.unitOffset; + for (let row = 0, end = archetype.count; row < end; row++) { + const index = all[at++]; + const parent = view[row * stride + offset]; + const target = parent >>> 0; + link(index, parent > 0 && target < entityCount && archetypeOf[target] >= 0 && generation[target] === generationOf(parent) ? target : -1); + } + } + settleAll(all, count); + }; + + /** + * Sets the level of every matched entity in `all`, the whole tree: each walks up to a root or to an entity already + * set, then sets the levels on its way back down, so each entity is walked once. A walk meeting itself is a cycle, + * and it and everything below it on the walk is cyclic. + */ + const settleAll = (all: Int32Array, count: number): void => { + const walking = ++pass; + const done = ++pass; + stack = extend(stack, count, 0); + const levels = levelOf, parents = parentOf, seen = stamps, path = stack; + for (let k = 0; k < count; k++) { + let current = all[k]; + if (seen[current] === done) continue; + let height = 0; + for (; current >= 0 && levels[current] !== unmatched; current = parents[current]) { + const stamp = seen[current]; + if (stamp === done || stamp === walking) break; + seen[current] = walking; + path[height++] = current; + } + let level = current < 0 || levels[current] === unmatched ? 0 : seen[current] === done ? levels[current] : cyclic; + for (let h = height - 1; h >= 0; h--) { + const node = path[h]; + if (level !== cyclic) level++; + levels[node] = level; + seen[node] = done; + } + } }; /** Brings the tree up to date with the journal. */ @@ -326,26 +401,6 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { settle(pending, pending.length); }; - /** By index among the matched rows: its entity index, depth, archetype, parent row, and parent's archetype id when readable or -1. */ - let rowEntities = new Int32Array(0); - let depths = new Int32Array(0); - let rowParents = new Int32Array(0); - let keys = new Int32Array(0); - let owners = new Int32Array(0); - /** The matched rows' indexes, by depth, and where each depth ends. */ - let sortedRows = new Int32Array(0); - let counts = new Int32Array(0); - /** By index in `matches`: the index of its first row among the matched rows. */ - let bases = new Int32Array(0); - /** The groups of a build, laid out one after another, before the order lays them out with spare slots. */ - let builtRows = new Int32Array(0); - let builtParents = new Int32Array(0); - let builtEntities = new Int32Array(0); - let groupEnds = new Int32Array(0); - let groupDepths = new Int32Array(0); - let groupOwners = new Int32Array(0); - let groupParents = new Int32Array(0); - /** The matched rows in depth order from the roots. Rows on or under a parent cycle are in none. */ const levels = (matches: readonly Archetype[]): Order => { update(matches); @@ -566,6 +621,13 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { */ const filtered = (matches: readonly Archetype[], added: readonly number[], changed: readonly number[], since: number): Pass | null => { update(matches); + // Every row passes after a rebuild, so the pass is the whole order, with nothing to mark. + if (rebuilt) { + touched = []; + rebuilt = false; + const ordered = levels(matches); + return ordered.levels === 0 ? null : wholePass(ordered); + } const length = state.entities.length; if (dirty.length < length) dirty = new Uint8Array(Math.max(length, 2 * dirty.length)); for (const bucket of buckets) if (bucket !== undefined) bucket.length = 0; @@ -672,6 +734,22 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { return { next, clear }; }; + /** Every row of `ordered`, a depth at a time. */ + const wholePass = (ordered: Order): Pass => { + let depth = 0; + const next = (): readonly Run[] | null => { + if (depth >= ordered.levels) return null; + const runs: Run[] = []; + for (let group = ordered.depthGroups[depth]; group < ordered.depthGroups[depth + 1]; group++) { + if (ordered.groupEnds[group] === ordered.groupStarts[group]) continue; + runs.push({ owner: ordered.groupOwners[group], parent: ordered.groupParents[group], rows: ordered.rows, parents: ordered.parents, start: ordered.groupStarts[group], end: ordered.groupEnds[group] }); + } + depth++; + return runs; + }; + return { next, clear: noClear }; + }; + /** Frees what it holds in the arena and stops its reading of the journal, once nothing runs it again. */ const release = (): void => { state.journal.readers--; @@ -681,5 +759,7 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { list = parentList = new Int32Array(0); }; - return { levels, filtered, release }; + return { levels, filtered, wholePass, release }; }; + +const noClear = (): void => {}; diff --git a/packages/ecs/src/perftest/benchmarks.ts b/packages/ecs/src/perftest/benchmarks.ts index e0cea84b..100d4edf 100644 --- a/packages/ecs/src/perftest/benchmarks.ts +++ b/packages/ecs/src/perftest/benchmarks.ts @@ -21,6 +21,7 @@ import { readBenchmarks } from "./reads.js"; import { observedBenchmarks } from "./observed.js"; import { systemBenchmarks } from "./systems.js"; import { indexBenchmarks } from "./indexes.js"; +import { editorBenchmarks } from "./editor.js"; import { stringBenchmarks } from "./strings.js"; import { structReplace, valueBenchmarks } from "./values.js"; import { createWorld } from "../implementation/index.js"; @@ -687,6 +688,7 @@ export const benchmarks: readonly Benchmark[] = [ ...observedBenchmarks, ...systemBenchmarks, ...indexBenchmarks, + ...editorBenchmarks, ...stringBenchmarks, ...valueBenchmarks, structReplace("main", 0), diff --git a/packages/ecs/src/perftest/editor.ts b/packages/ecs/src/perftest/editor.ts new file mode 100644 index 00000000..c5821ab8 --- /dev/null +++ b/packages/ecs/src/perftest/editor.ts @@ -0,0 +1,139 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Archetype } from "../interface/archetype/archetype.js"; +import { Component } from "../interface/component/component.js"; +import { Entity } from "../interface/entity/entity.js"; +import { Index } from "../interface/indexes/index-type.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import { createWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; + +// A 2D editor loading a document: a tree of groups and leaves, a sorted paint-order index, a hash index of children +// by parent, and `up` systems with change filters propagating transforms and opacity down the tree. + +const localX = Symbol("editor/localX"); +const localY = Symbol("editor/localY"); +const worldX = Symbol("editor/worldX"); +const worldY = Symbol("editor/worldY"); +const opacity = Symbol("editor/opacity"); +const worldOpacity = Symbol("editor/worldOpacity"); +const paintDepth = Symbol("editor/paintDepth"); +const parent = Symbol("editor/parent"); +const group = Symbol("editor/group"); +const shape = Symbol("editor/shape"); +const image = Symbol("editor/image"); +declare module "@adobe/ecs" { + export interface Components { + [localX]: { type: number }; + [localY]: { type: number }; + [worldX]: { type: number }; + [worldY]: { type: number }; + [opacity]: { type: number }; + [worldOpacity]: { type: number }; + [paintDepth]: { type: number }; + [parent]: { type: Entity; reference: "owner" }; + [group]: { type: true }; + [shape]: { type: true }; + [image]: { type: true }; + } +} +Component.register(localX, { type: { type: "number" } }); +Component.register(localY, { type: { type: "number" } }); +Component.register(worldX, { type: { type: "number" } }); +Component.register(worldY, { type: { type: "number" } }); +Component.register(opacity, { type: { type: "number" } }); +Component.register(worldOpacity, { type: { type: "number" } }); +Component.register(paintDepth, { type: { type: "number" } }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(group, { type: { const: true } }); +Component.register(shape, { type: { const: true } }); +Component.register(image, { type: { const: true } }); + +const node = [localX, localY, worldX, worldY, opacity, worldOpacity, paintDepth] as const; +const Document = Archetype.create([...node, group]); +const Group = Archetype.create([...node, parent, group]); +const Shape = Archetype.create([...node, parent, shape]); +const Image = Archetype.create([...node, parent, image]); + +const byPaintDepth = Index.create({ name: "editor/byPaintDepth", key: [paintDepth], sorted: true }); +const childrenOf = Index.create({ name: "editor/childrenOf", key: [parent] }); + +/** Groups under the document, subgroups in each, and leaves in each subgroup: 50,201 nodes. */ +const groups = 200; +const subgroups = 2; +const leaves = 124; +const nodes = 1 + groups * (1 + subgroups * (1 + leaves)); + +/** Spawns the document depth first, as a file lists it, its paint depth its place in that order. */ +const load = System.create({ name: "editor/load" }, ({ commands }) => { + let depth = 0; + const values = (x: number) => ({ [localX]: x, [localY]: 1, [worldX]: 0, [worldY]: 0, [opacity]: 0.99, [worldOpacity]: 1, [paintDepth]: depth++ }); + const document = commands.spawn(Document)(values(0)); + for (let g = 0; g < groups; g++) { + const outer = commands.spawn(Group)({ ...values(g), [parent]: document, [group]: true }); + for (let s = 0; s < subgroups; s++) { + const inner = commands.spawn(Group)({ ...values(s), [parent]: outer, [group]: true }); + for (let l = 0; l < leaves; l++) { + if (l % 2 === 0) commands.spawn(Shape)({ ...values(l), [parent]: inner, [shape]: true }); + else commands.spawn(Image)({ ...values(l), [parent]: inner, [image]: true }); + } + } + } +}); + +/** Each node's world position is its parent's plus its own. */ +const place = System.create({ + name: "editor/place", + components: { read: [localX, localY], write: [worldX, worldY], changed: [localX, localY] }, + up: { through: parent, read: [worldX, worldY] }, +}, (row, { up }) => { + row[worldX] = (up === null ? 0 : up[worldX]) + row[localX]; + row[worldY] = (up === null ? 0 : up[worldY]) + row[localY]; +}); +/** Each node's world opacity is its parent's times its own. */ +const fade = System.create({ + name: "editor/fade", + components: { read: [opacity], write: [worldOpacity], changed: [opacity] }, + up: { through: parent, read: [worldOpacity] }, +}, (row, { up }) => { + row[worldOpacity] = (up === null ? 1 : up[worldOpacity]) * row[opacity]; +}); +let checksum = 0; +/** Each group counts its children, as a layout does. */ +const layout = System.create({ name: "editor/layout", components: { has: [group] }, lookup: { children: { index: childrenOf } } }, (row, { lookup }) => { + checksum += lookup.children(row.entity).length; +}); +/** Visits every node in paint order, as drawing does. */ +const paint = System.create({ name: "editor/paint", components: { read: [worldX, worldOpacity] }, order: byPaintDepth, after: [place, fade] }, row => { + checksum = (checksum * 31 + row[worldX] * row[worldOpacity]) % 1000003; +}); +const frame = System.create({ name: "editor/frame", systems: [place, fade, layout, paint] }); + +const check = (condition: boolean, message: string): void => { + if (!condition) throw new Error(message); +}; + +export const editorBenchmarks: readonly Benchmark[] = [{ + name: "editor load tick 50K", + operation: "A 2D editor's first tick after loading a 50K-node document of groups and leaves: two `up` systems with change filters propagate transforms and opacity down the tree, a layout looks up children through a hash index on the parent, and painting runs in a sorted index's order, so every `up` order and index is built from scratch.", + per: "nodes", + n: nodes, + typical: [1_000, 100_000], + best: { bytes: 100, basis: "Each node reads its parent link, its 7 values and its parent's 3, writes 3, and enters two indexes and two orders." }, + run: async () => { + let best = Infinity; + for (let i = 0; i < 8; i++) { + const world = createWorld({ [workers]: 0 }); + await world.run(frame); + await world.run(load); + checksum = 0; + const start = performance.now(); + await world.run(frame); + best = Math.min(best, performance.now() - start); + check(checksum !== 0, "editor load tick: nothing ran"); + await world.dispose(); + } + return best; + }, +}]; + From 0115b1354dd6733ad8a7079b71290895e37bae7d Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 01:12:38 -0700 Subject: [PATCH 2/4] ecs: skip a sorted index rebuild's sort when entries are in key order A load in document order lists a paint-order index's entries in key order already; one pass that stops at the first pair out of order checks it. `editor load tick 50K`'s paint system 5.6-5.8 -> 5.1-5.3 ms. Co-Authored-By: Claude Opus 5.5 --- .../ecs/src/implementation/world/indexes.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/ecs/src/implementation/world/indexes.ts b/packages/ecs/src/implementation/world/indexes.ts index 82c804e2..1d1cab2b 100644 --- a/packages/ecs/src/implementation/world/indexes.ts +++ b/packages/ecs/src/implementation/world/indexes.ts @@ -1067,6 +1067,8 @@ const sortedOrder = (index: WorldIndex, count: number, keys: Uint32Array): Int32 order.set(ordered); return order; } + // Entries already in key order, as a load in document order lists them, need no sort. + if (inKeyOrder(keys, count, words)) return order; const histogram = built.histogram; for (let w = words - 1; w >= 0; w--) { for (let shift = 0; shift < 32; shift += 16) { @@ -1094,6 +1096,21 @@ const sortedOrder = (index: WorldIndex, count: number, keys: Uint32Array): Int32 return order; }; +/** Whether each of `count` entries' keys, `words` each, is at least the one before, comparing words from the first. */ +const inKeyOrder = (keys: Uint32Array, count: number, words: number): boolean => { + for (let k = 1; k < count; k++) { + const previous = (k - 1) * words; + const next = k * words; + for (let w = 0; w < words; w++) { + const a = keys[previous + w]; + const b = keys[next + w]; + if (a < b) break; + if (a > b) return false; + } + } + return true; +}; + /** Stamps the buckets holding rows whose watched component changed since it was last read, with `tick`. */ const scanWatched = (state: State, index: WorldIndex, component: number, tick: number): void => { const since = index.scanned.get(component) ?? tick; From 044f78f61f7778921522f156a5f694d7dd97c72a Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 01:12:47 -0700 Subject: [PATCH 3/4] ecs: bind an ordered run's row in place when the archetype changes A main-thread system run in an index's order looked its shared row up again whenever the archetype changed, at every row when archetypes interleave in paint order. `editor load tick 50K`'s paint system 4.85 -> 4.5 ms. perf.md records the editor load work: `editor load tick 50K` 31.4-33.2 -> 20.0-20.4 ms and `up load` 221-223 -> 182-186 ms against 826c45ad, alternating, with the attempts not kept. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/implementation/world/perf.md | 13 +++++++++++++ packages/ecs/src/implementation/world/runner.ts | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 78e66f7e..550c7d2a 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -146,6 +146,14 @@ Render systems with `order`, against `9daaa3f8`, chromium, three runs each: new - **The order block is listed and written only when the index's entries changed or moved rows,** by the index's `changed` tick and a `moved` tick its refresh stamps when it relocates an entry with its key unchanged, or when the matches grew; the tables block only when a column's address changed; the record only when one of its four words did. An unchanged run writes nothing to the queue and allocates nothing: the table, its `bind`, the tables list and the context are reused. - **An entry is one u32:** the matching archetype's slot in the high bits, as few as the matches need and at least one, and its row in the rest, so the shader masks by the record's shift. +A 2D editor's first tick after loading a document, against `826c45ad`, node, alternating, three rounds: new `editor load tick 50K` 31.4–33.2 → 20.0–20.4 ms, and `up load` 221–223 → 182–186 ms; within noise: `up depth 10`, `up depth 1000`, `up after a spawn`, `up filtered after churn`, `up filtered after a reparent`, `up small tree in a big world`, `index rebuild 1M main`, `order run 100K`, `order filtered 100K 1%`, `children lookup`, `sorted range 1M`, `sorted page 100K`. The benchmark spawns 50,201 nodes depth first, a document, 200 groups, two subgroups in each and 124 shapes and images in each subgroup, after a first tick over the empty world, then times the next tick: two `up` row systems with `changed` filters writing a position and an opacity, a layout looking up each group's children through a hash index on the parent, and a paint system run in a sorted index's paint order. Before, each system's part of the tick, run alone after the load: the position `up` 13.1 ms, the opacity `up` 10.4, the layout 2.45 (its index's build), the paint 5.43 (its index's build and 50K ordered rows). Of the opacity `up`'s profile, building the tree 22%, marking every row as passing the filters (`changedRows`, `mark`, clearing the marks) 25%, building the order 19%, listing the marked rows from it 10%, and the body 20%. After: 6.4, 4.4, 2.15 and 4.4 ms. +- **A filtered run after its tree was built again runs the whole order,** as an unfiltered one does, since every row passes then: the tick 32 → 25.1 ms, the position `up` 13.1 → 8.5 and the opacity `up` 10.4 → 7.1. Marking 50K rows, each in a bucket of its depth, and scanning the order for the marks cost as much as the run. A later sparse run is cold for it: `up one root changed` 28 → 33 µs a run over the benchmark's 100 rounds, the marking path no longer warmed by a first full pass, and 14 µs either way after 1,000 rounds. +- **The tree's build reads each archetype's parent column once,** rather than finding the archetype, column and layout for every entity: the opacity `up` 5.35 → 4.5 ms. +- **The build sets every level in one walk per entity,** up to a root or an entity already set and back down, rather than settling the entities as journaled changes do, which walk each one's children again: the opacity `up` 4.77 → 4.49 ms. +- **An order build's scratch is shared by every `up` of a thread:** the second system reuses the first one's arrays rather than allocating its own, 13 of them 50K entries each: the tick 20.6 → 20.0 ms, medians of five rounds. +- **A sorted index's rebuild skips the sort when its entries are in key order already,** as a load in document order lists them, checked in one pass that stops at the first pair out of order: the paint 5.6–5.8 → 5.1–5.3 ms. +- **A main-thread system run in an index's order binds its row in place when the archetype changes,** rather than looking the shared row up again, which happens at every row when archetypes interleave in the order: the paint 4.85 → 4.5 ms. + ## Where the gains came from Step figures marked (vitest) are from the node vitest lane: read them as relative. @@ -341,11 +349,16 @@ move, chromium, by worker count and entity count (a quarter of the entities move - **Reading a patch's keys, hashes and buckets ahead in batches of 64,** so their misses overlap: `index refresh 1M 1%` 165 → 173 ns and `rekey 1M` 14.7 → 23.7 ms. A move in cache costs the same, so there was little latency to hide. - **A list's pool in three banks, its first, last and middle positions,** for hash lists too: `grid neighbors 100K main` 3.63 → 3.84 µs a row from choosing the bank in `at`. Only sorted lists keep a second pool for their last positions. - **Making a pool's rows for every position a list holds:** a 100K-row sorted list made 100K rows for a page of 20; rows are made ahead for the first and last 256 positions only. +- **The tree's build linking parents over locals,** rather than through `link`: the opacity `up` 4.44–4.65 against 4.55–4.76 ms, about 2%, within noise. +- **An index rebuild listing its entities in one loop over locals,** rather than a closure per entity: `index rebuild 1M main` 28.3–30 against 28.7–34.7 ms, and the editor's layout 2.33–2.84 against 2.35–2.56 ms, no gain. +- **Binding a row without making a slot object per component:** the editor's paint 5.02–5.06 against 4.99–5.07 ms, no gain; V8 removes the allocation. +- **Presizing a hash index's table for a rebuild,** measured rather than tried: the editor's 601 parents rehash and place their buckets in 0.2 of 34 ms of profile, since a table is sized for the buckets it last held. Worth revisiting for a first build of an index with as many keys as entities. ## Next - `grid neighbors 100K main`, 3.66 µs a row against about 1.6 µs for the kernel on one worker: each neighbor costs about 10 ns in loads of the list's state, the pool and the row's slots; rows whose values carry their index and views as their own fields would save a few of them. - Index refreshes, 141 ns a moved entity: two ordered bucket edits and a probe, in cache. +- `editor load tick 50K`, 20 ms: each `up` system builds its own tree and order, about 1.3 ms each over 50K rows, though systems with the same `through` and matches build the same ones; sharing them would need each system's own journal position, touched rows and order patches. Of the rest, the `up` bodies through accessor rows take about 2 ms a system, the 50K ordered paint rows about 2 ms, and each index's rebuild about 1.5 ms, 28 ns an entity listing and encoding its key. - `loop tick add remove`, a new system each tick: about 2 µs more a tick since indexes (above), in `System.create` and `add`, as more garbage; the paths a run of an existing system takes measure as before. - Index rebuilds across the pool: `index rebuild 1M workers` 6.7 ms, nine workers 4.3× one, every phase scattering or gathering by partition; an estimated 2–3 ms if the scatters reached memory bandwidth. Rebuilds are rare, a refresh moving more than an eighth of an index. diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index f0509343..3f4f7c5a 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -572,7 +572,9 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const owner = archetype < owners.length ? owners[archetype] : -1; if (owner < 0) continue; if (owner !== current || row === null || value === null) { - row = bindRow(matches[owner]); + // The row is found once; after, it's bound in place, as often as every row when archetypes interleave. + if (row === null) row = bindRow(matches[owner]); + else row.bind(matches[owner]); value = row.value; current = owner; } From 6d49b50cc8c73be7703799889888cb5af2aabbae Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 01:16:13 -0700 Subject: [PATCH 4/4] ecs: followups: descending sorted indexes Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index a0067d87..5a627749 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -35,6 +35,9 @@ Open items awaiting a decision or work. Remove an item once it's decided or done per-row object such as a texture's bind group, and a row body can't learn its GPU row. A consumer drawing an image per row keeps its own instance buffer, 4,000 draws in 2.1 ms a tick. Possible: tables giving their entities in GPU row order. Not needed until a consumer's profile says so. +- **Descending sorted indexes:** an ordered render reads its index front to back, so a consumer painting back to front + maps `instance_index` to `ecs_count() - 1u - instance_index`, which costs nothing. Possible: a `descending` option on + sorted indexes. Not needed until a consumer's profile or readability says so. - **Report retention:** the last 256 runs keep their reports (`retainedRuns` in `reports.ts`), a fixed internal constant; a later run takes the oldest one's entity. Recommended: keep it internal until a consumer needs another; then a world option or resource.