Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/ecs/followups.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions packages/ecs/src/implementation/world/indexes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions packages/ecs/src/implementation/world/perf.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 6 additions & 23 deletions packages/ecs/src/implementation/world/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -611,28 +613,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<readonly ChunkResult[]> | 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;
Expand Down
27 changes: 27 additions & 0 deletions packages/ecs/src/implementation/world/up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>): Promise<{ [name: string]: number | null }> =>
Object.fromEntries(await Promise.all(Object.entries(entities).map(async ([name, entity]) => [name, await value(entity)])));
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading