Skip to content

Commit a169591

Browse files
committed
perf: reduce per-frame allocations in sync hot path
注意不要破坏公开api和现有测试 建议先生成新的性能测试以对比效果
1 parent 1701435 commit a169591

File tree

4 files changed

+485
-27
lines changed

4 files changed

+485
-27
lines changed
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { component, relation, type EntityId } from "../core/entity";
3+
import { World } from "../core/world";
4+
5+
function benchmark(label: string, warmupRounds: number, measuredRounds: number, fn: (round: number) => void): number {
6+
const durations: number[] = [];
7+
8+
const totalRounds = warmupRounds + measuredRounds;
9+
for (let round = 0; round < totalRounds; round++) {
10+
const start = performance.now();
11+
fn(round);
12+
const duration = performance.now() - start;
13+
if (round >= warmupRounds) {
14+
durations.push(duration);
15+
}
16+
}
17+
18+
const average = durations.reduce((sum, duration) => sum + duration, 0) / durations.length;
19+
console.log(
20+
`${label}: avg ${average.toFixed(2)}ms after ${warmupRounds} warmup rounds (${durations
21+
.map((d) => d.toFixed(2))
22+
.join("ms, ")}ms per measured round)`,
23+
);
24+
return average;
25+
}
26+
27+
describe("Comprehensive ECS performance benchmarks", () => {
28+
/**
29+
* Benchmark 1: Component set (no structural change) - hot path for data updates
30+
* This is the most common operation: updating a component value without archetype migration
31+
*/
32+
it("should handle many component value updates efficiently", () => {
33+
const world = new World();
34+
const Position = component<{ x: number; y: number }>();
35+
const Velocity = component<{ vx: number; vy: number }>();
36+
37+
const entityCount = 10_000;
38+
const entities: EntityId[] = [];
39+
for (let i = 0; i < entityCount; i++) {
40+
const entity = world.new();
41+
entities.push(entity);
42+
world.set(entity, Position, { x: i, y: i });
43+
world.set(entity, Velocity, { vx: 1, vy: 1 });
44+
}
45+
world.sync();
46+
47+
// Single component update
48+
const singleCompAvg = benchmark("10k entities: single component update + sync", 2, 6, (round) => {
49+
for (let i = 0; i < entities.length; i++) {
50+
world.set(entities[i]!, Position, { x: round, y: i });
51+
}
52+
world.sync();
53+
});
54+
55+
// Two component update
56+
const twoCompAvg = benchmark("10k entities: two component updates + sync", 2, 6, (round) => {
57+
for (let i = 0; i < entities.length; i++) {
58+
world.set(entities[i]!, Position, { x: round, y: i });
59+
world.set(entities[i]!, Velocity, { vx: round, vy: i });
60+
}
61+
world.sync();
62+
});
63+
64+
expect(singleCompAvg).toBeLessThan(300);
65+
expect(twoCompAvg).toBeLessThan(500);
66+
});
67+
68+
/**
69+
* Benchmark 2: Structural archetype migrations - entities moving between archetypes
70+
* These are more expensive than value updates because of array manipulation
71+
*/
72+
it("should handle archetype migrations efficiently", () => {
73+
const world = new World();
74+
const Alive = component<void>();
75+
const Dead = component<void>();
76+
77+
const entityCount = 4000;
78+
const entities: EntityId[] = [];
79+
for (let i = 0; i < entityCount; i++) {
80+
const entity = world.new();
81+
entities.push(entity);
82+
world.set(entity, Alive);
83+
}
84+
world.sync();
85+
86+
// Add/remove components causing archetype migration
87+
const migrationAvg = benchmark("4k entities: archetype migration (add/remove) + sync", 2, 6, (round) => {
88+
if (round % 2 === 0) {
89+
for (let i = 0; i < entities.length; i++) {
90+
world.set(entities[i]!, Dead);
91+
}
92+
} else {
93+
for (let i = 0; i < entities.length; i++) {
94+
world.remove(entities[i]!, Dead);
95+
}
96+
}
97+
world.sync();
98+
});
99+
100+
expect(migrationAvg).toBeLessThan(300);
101+
});
102+
103+
/**
104+
* Benchmark 3: Query iteration - the inner loop of ECS systems
105+
* This is the absolute hot path - should be very fast
106+
*/
107+
it("should iterate over queries efficiently", () => {
108+
const world = new World();
109+
const Position = component<{ x: number; y: number }>();
110+
const Velocity = component<{ vx: number; vy: number }>();
111+
112+
const entityCount = 10_000;
113+
for (let i = 0; i < entityCount; i++) {
114+
const entity = world.new();
115+
world.set(entity, Position, { x: i, y: i });
116+
world.set(entity, Velocity, { vx: 1, vy: 1 });
117+
}
118+
world.sync();
119+
120+
const movementQuery = world.createQuery([Position, Velocity]);
121+
122+
// Pure iteration (no writes)
123+
const readAvg = benchmark("10k entities: forEach read-only query", 2, 6, () => {
124+
let count = 0;
125+
movementQuery.forEach([Position, Velocity], (_entity, _pos, _vel) => {
126+
count++;
127+
});
128+
expect(count).toBe(entityCount);
129+
});
130+
131+
// Read and modify in place (no sync needed for non-structural)
132+
let sumX = 0;
133+
const updateAvg = benchmark("10k entities: forEach query with accumulation", 2, 6, () => {
134+
sumX = 0;
135+
movementQuery.forEach([Position, Velocity], (_entity, pos, vel) => {
136+
sumX += pos.x + vel.vx;
137+
});
138+
});
139+
140+
movementQuery.dispose();
141+
142+
console.log(`Sum X (to prevent optimization): ${sumX}`);
143+
expect(readAvg).toBeLessThan(20);
144+
expect(updateAvg).toBeLessThan(20);
145+
});
146+
147+
/**
148+
* Benchmark 4: Entity spawn and sync - creating entities
149+
*/
150+
it("should spawn and sync entities efficiently", () => {
151+
const world = new World();
152+
const Position = component<{ x: number; y: number }>();
153+
const Velocity = component<{ vx: number; vy: number }>();
154+
155+
const entityCount = 1000;
156+
157+
const spawnAvg = benchmark("1k entity spawn + 2 components + sync", 2, 6, () => {
158+
const entities: EntityId[] = [];
159+
for (let i = 0; i < entityCount; i++) {
160+
const entity = world.new();
161+
entities.push(entity);
162+
world.set(entity, Position, { x: i, y: i });
163+
world.set(entity, Velocity, { vx: 1, vy: 1 });
164+
}
165+
world.sync();
166+
// Cleanup
167+
for (const entity of entities) {
168+
world.delete(entity);
169+
}
170+
world.sync();
171+
});
172+
173+
expect(spawnAvg).toBeLessThan(150);
174+
});
175+
176+
/**
177+
* Benchmark 5: Mixed operations - realistic game loop simulation
178+
* Some entities update, some spawn, some die - typical game scenario
179+
*/
180+
it("should handle mixed operations in a realistic game loop", () => {
181+
const world = new World();
182+
const Position = component<{ x: number; y: number }>();
183+
const Health = component<number>();
184+
const Alive = component<void>();
185+
186+
const initialCount = 2000;
187+
const entities: EntityId[] = [];
188+
189+
for (let i = 0; i < initialCount; i++) {
190+
const entity = world.new();
191+
entities.push(entity);
192+
world.set(entity, Position, { x: i, y: i });
193+
world.set(entity, Health, 100);
194+
world.set(entity, Alive);
195+
}
196+
world.sync();
197+
198+
const movementQuery = world.createQuery([Position, Health]);
199+
200+
const mixedAvg = benchmark("2k entities: mixed ops (update 90%, spawn 5%, delete 5%) + sync", 2, 6, (round) => {
201+
const deleteCount = Math.floor(entities.length * 0.05);
202+
const spawnCount = deleteCount;
203+
204+
// Update most entities
205+
movementQuery.forEach([Position, Health], (entity, pos, health) => {
206+
world.set(entity, Position, { x: pos.x + 1, y: pos.y + 1 });
207+
world.set(entity, Health, health - 1);
208+
});
209+
210+
// Delete some
211+
for (let i = 0; i < deleteCount && entities.length > 0; i++) {
212+
const idx = (round * deleteCount + i) % entities.length;
213+
world.delete(entities[idx]!);
214+
entities.splice(idx, 1);
215+
}
216+
217+
// Spawn some
218+
for (let i = 0; i < spawnCount; i++) {
219+
const entity = world.new();
220+
entities.push(entity);
221+
world.set(entity, Position, { x: i, y: i });
222+
world.set(entity, Health, 100);
223+
world.set(entity, Alive);
224+
}
225+
226+
world.sync();
227+
});
228+
229+
movementQuery.dispose();
230+
expect(mixedAvg).toBeLessThan(300);
231+
});
232+
233+
/**
234+
* Benchmark 6: CommandBuffer grouping overhead
235+
* Tests the overhead of the Map grouping in execute()
236+
* This specifically targets the new Map() allocation per sync call
237+
*/
238+
it("should execute command buffer efficiently with many commands", () => {
239+
const world = new World();
240+
const A = component<number>();
241+
const B = component<number>();
242+
const C = component<number>();
243+
244+
const entityCount = 5000;
245+
const entities: EntityId[] = [];
246+
for (let i = 0; i < entityCount; i++) {
247+
const entity = world.new();
248+
entities.push(entity);
249+
world.set(entity, A, i);
250+
world.set(entity, B, i * 2);
251+
}
252+
world.sync();
253+
254+
// Many commands per sync - tests command buffer grouping
255+
const manyCommandsAvg = benchmark("5k entities: 3 commands each + sync (15k total commands)", 2, 6, (round) => {
256+
for (let i = 0; i < entities.length; i++) {
257+
world.set(entities[i]!, A, round + i);
258+
world.set(entities[i]!, B, round - i);
259+
world.set(entities[i]!, C, round * i);
260+
}
261+
world.sync();
262+
});
263+
264+
expect(manyCommandsAvg).toBeLessThan(600);
265+
});
266+
267+
/**
268+
* Benchmark 7: dontFragment relation updates (the existing benchmark scenario)
269+
*/
270+
it("should handle dontFragment exclusive relation flips efficiently", () => {
271+
const world = new World();
272+
const Position = component<{ x: number; y: number }>();
273+
const ChildOf = component({ dontFragment: true, exclusive: true });
274+
275+
const parentA = world.new();
276+
const parentB = world.new();
277+
278+
const entityCount = 4000;
279+
const entities: EntityId[] = [];
280+
for (let i = 0; i < entityCount; i++) {
281+
const entity = world.new();
282+
entities.push(entity);
283+
world.set(entity, Position, { x: i, y: i });
284+
world.set(entity, relation(ChildOf, parentA));
285+
}
286+
world.sync();
287+
288+
const relationFlipAvg = benchmark("4k entities: exclusive dontFragment relation flip + sync", 2, 8, (round) => {
289+
const target = round % 2 === 0 ? parentB : parentA;
290+
for (let i = 0; i < entities.length; i++) {
291+
world.set(entities[i]!, relation(ChildOf, target));
292+
}
293+
world.sync();
294+
});
295+
296+
expect(world.query([Position]).length).toBe(entityCount);
297+
expect(world.query([relation(ChildOf, "*")]).length).toBe(entityCount);
298+
expect(relationFlipAvg).toBeLessThan(350);
299+
});
300+
});

src/commands/command-buffer.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export type Command =
2020
export class CommandBuffer {
2121
private commands: Command[] = [];
2222
private swapBuffer: Command[] = [];
23+
/** Reusable map to group commands by entity, avoids per-sync allocations */
24+
private entityCommands: Map<EntityId, Command[]> = new Map();
2325
private executeEntityCommands: (entityId: EntityId, commands: Command[]) => void;
2426

2527
/**
@@ -68,23 +70,27 @@ export class CommandBuffer {
6870
const currentCommands = this.commands;
6971
this.commands = this.swapBuffer;
7072

71-
// Group commands by entity
72-
const entityCommands = new Map<EntityId, Command[]>();
73+
// Group commands by entity, reusing the persistent Map
74+
const entityCommands = this.entityCommands;
7375
for (const cmd of currentCommands) {
74-
if (!entityCommands.has(cmd.entityId)) {
75-
entityCommands.set(cmd.entityId, []);
76+
const existing = entityCommands.get(cmd.entityId);
77+
if (existing !== undefined) {
78+
existing.push(cmd);
79+
} else {
80+
entityCommands.set(cmd.entityId, [cmd]);
7681
}
77-
entityCommands.get(cmd.entityId)!.push(cmd);
7882
}
7983

8084
// Clear the consumed buffer for reuse
8185
currentCommands.length = 0;
8286
this.swapBuffer = currentCommands;
8387

84-
// Process each entity's commands with optimization
88+
// Process each entity's commands and clear the map (but not the arrays,
89+
// as callers may hold references to them after the executor returns)
8590
for (const [entityId, commands] of entityCommands) {
8691
this.executeEntityCommands(entityId, commands);
8792
}
93+
entityCommands.clear();
8894
}
8995
}
9096

0 commit comments

Comments
 (0)