@lagless/desync-diagnostics 0.0.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +26 -0
- package/out-tsc/index.d.ts +13 -0
- package/out-tsc/index.d.ts.map +1 -0
- package/out-tsc/lib/attach.d.ts +5 -0
- package/out-tsc/lib/attach.d.ts.map +1 -0
- package/out-tsc/lib/diagnostics-collector.d.ts +41 -0
- package/out-tsc/lib/diagnostics-collector.d.ts.map +1 -0
- package/out-tsc/lib/diagnostics-protocol.d.ts +52 -0
- package/out-tsc/lib/diagnostics-protocol.d.ts.map +1 -0
- package/out-tsc/lib/divergence-analysis.d.ts +21 -0
- package/out-tsc/lib/divergence-analysis.d.ts.map +1 -0
- package/out-tsc/lib/hash-bytes.d.ts +6 -0
- package/out-tsc/lib/hash-bytes.d.ts.map +1 -0
- package/out-tsc/lib/performance-profiler.d.ts +43 -0
- package/out-tsc/lib/performance-profiler.d.ts.map +1 -0
- package/out-tsc/lib/report-generator.d.ts +55 -0
- package/out-tsc/lib/report-generator.d.ts.map +1 -0
- package/out-tsc/lib/types.d.ts +39 -0
- package/out-tsc/lib/types.d.ts.map +1 -0
- package/out-tsc/lib/use-desync-diagnostics.d.ts +9 -0
- package/out-tsc/lib/use-desync-diagnostics.d.ts.map +1 -0
- package/package.json +38 -0
- package/src/index.ts +26 -0
- package/src/lib/attach.ts +10 -0
- package/src/lib/diagnostics-collector.spec.ts +354 -0
- package/src/lib/diagnostics-collector.ts +210 -0
- package/src/lib/diagnostics-protocol.ts +44 -0
- package/src/lib/divergence-analysis.spec.ts +178 -0
- package/src/lib/divergence-analysis.ts +179 -0
- package/src/lib/hash-bytes.spec.ts +29 -0
- package/src/lib/hash-bytes.ts +11 -0
- package/src/lib/performance-profiler.spec.ts +412 -0
- package/src/lib/performance-profiler.ts +245 -0
- package/src/lib/report-generator.spec.ts +147 -0
- package/src/lib/report-generator.ts +117 -0
- package/src/lib/types.ts +41 -0
- package/src/lib/use-desync-diagnostics.ts +101 -0
- package/tsconfig.json +21 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +20 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { PerformanceProfiler } from './performance-profiler.js';
|
|
2
|
+
import type { IECSSystem } from '@lagless/core';
|
|
3
|
+
|
|
4
|
+
function createMockSystems(): IECSSystem[] {
|
|
5
|
+
return [
|
|
6
|
+
{
|
|
7
|
+
update(_tick: number) {
|
|
8
|
+
// Simulate ~0.1ms of work
|
|
9
|
+
const start = performance.now();
|
|
10
|
+
while (performance.now() - start < 0.1) { /* spin */ }
|
|
11
|
+
},
|
|
12
|
+
constructor: { name: 'PhysicsSystem' } as unknown as { name: string },
|
|
13
|
+
} as unknown as IECSSystem,
|
|
14
|
+
{
|
|
15
|
+
update(_tick: number) {
|
|
16
|
+
// Simulate ~0.05ms of work
|
|
17
|
+
const start = performance.now();
|
|
18
|
+
while (performance.now() - start < 0.05) { /* spin */ }
|
|
19
|
+
},
|
|
20
|
+
constructor: { name: 'MovementSystem' } as unknown as { name: string },
|
|
21
|
+
} as unknown as IECSSystem,
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates a mock runner that simulates the ECSSimulation tick loop.
|
|
27
|
+
* The simulate() method runs all systems, and saveSnapshot() is a no-op.
|
|
28
|
+
* addTickHandler() registers handlers called at the end of each tick.
|
|
29
|
+
*/
|
|
30
|
+
function createMockRunner(systems: IECSSystem[], opts?: { snapshotRate?: number }) {
|
|
31
|
+
const tickHandlers = new Set<(tick: number) => void>();
|
|
32
|
+
const snapshotRate = opts?.snapshotRate ?? 5;
|
|
33
|
+
|
|
34
|
+
const simulation = {
|
|
35
|
+
registeredSystems: systems as ReadonlyArray<IECSSystem>,
|
|
36
|
+
addTickHandler(handler: (tick: number) => void): () => void {
|
|
37
|
+
tickHandlers.add(handler);
|
|
38
|
+
return () => { tickHandlers.delete(handler); };
|
|
39
|
+
},
|
|
40
|
+
simulate(tick: number): void {
|
|
41
|
+
for (const sys of systems) {
|
|
42
|
+
sys.update(tick);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
saveSnapshot(_tick: number): void {
|
|
46
|
+
// Simulate ~0.05ms of snapshot work
|
|
47
|
+
const start = performance.now();
|
|
48
|
+
while (performance.now() - start < 0.05) { /* spin */ }
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Runs one full tick: simulate → extra work → conditionally saveSnapshot → tick handlers.
|
|
54
|
+
* Mirrors ECSSimulation.simulationTicks() loop body.
|
|
55
|
+
*/
|
|
56
|
+
function runTick(tick: number): void {
|
|
57
|
+
(simulation as any).simulate(tick);
|
|
58
|
+
// Simulate non-system work (hash tracking, signals) ~0.02ms
|
|
59
|
+
const start = performance.now();
|
|
60
|
+
while (performance.now() - start < 0.02) { /* spin */ }
|
|
61
|
+
// Conditionally save snapshot
|
|
62
|
+
if (snapshotRate > 0 && tick % snapshotRate === 0) {
|
|
63
|
+
(simulation as any).saveSnapshot(tick);
|
|
64
|
+
}
|
|
65
|
+
// Call tick handlers
|
|
66
|
+
for (const handler of tickHandlers) handler(tick);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
Simulation: simulation,
|
|
71
|
+
runTick,
|
|
72
|
+
} as any;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('PerformanceProfiler', () => {
|
|
76
|
+
it('should attach and wrap system update methods', () => {
|
|
77
|
+
const systems = createMockSystems();
|
|
78
|
+
const originalUpdates = systems.map((s) => s.update);
|
|
79
|
+
const profiler = new PerformanceProfiler();
|
|
80
|
+
const runner = createMockRunner(systems);
|
|
81
|
+
|
|
82
|
+
profiler.attach(runner);
|
|
83
|
+
|
|
84
|
+
// update methods should be wrapped (different references)
|
|
85
|
+
for (let i = 0; i < systems.length; i++) {
|
|
86
|
+
expect(systems[i].update).not.toBe(originalUpdates[i]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
profiler.dispose();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should detach and restore original update methods', () => {
|
|
93
|
+
const systems = createMockSystems();
|
|
94
|
+
const originalUpdates = systems.map((s) => s.update);
|
|
95
|
+
const profiler = new PerformanceProfiler();
|
|
96
|
+
const runner = createMockRunner(systems);
|
|
97
|
+
|
|
98
|
+
profiler.attach(runner);
|
|
99
|
+
profiler.detach();
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < systems.length; i++) {
|
|
102
|
+
expect(systems[i].update).toBe(originalUpdates[i]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
profiler.dispose();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should collect per-system timing stats', () => {
|
|
109
|
+
const systems = createMockSystems();
|
|
110
|
+
const profiler = new PerformanceProfiler();
|
|
111
|
+
const runner = createMockRunner(systems);
|
|
112
|
+
|
|
113
|
+
profiler.attach(runner);
|
|
114
|
+
|
|
115
|
+
// Run full ticks through the mock tick loop
|
|
116
|
+
for (let tick = 1; tick <= 5; tick++) {
|
|
117
|
+
runner.runTick(tick);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const stats = profiler.getStats();
|
|
121
|
+
expect(stats.systems).toHaveLength(2);
|
|
122
|
+
expect(stats.systems[0].name).toBe('PhysicsSystem');
|
|
123
|
+
expect(stats.systems[1].name).toBe('MovementSystem');
|
|
124
|
+
|
|
125
|
+
// Each system should have positive timing values
|
|
126
|
+
for (const sys of stats.systems) {
|
|
127
|
+
expect(sys.latest).toBeGreaterThan(0);
|
|
128
|
+
expect(sys.min).toBeGreaterThan(0);
|
|
129
|
+
expect(sys.max).toBeGreaterThanOrEqual(sys.min);
|
|
130
|
+
expect(sys.avg).toBeGreaterThan(0);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
profiler.dispose();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should compute aggregate tick time', () => {
|
|
137
|
+
const systems = createMockSystems();
|
|
138
|
+
const profiler = new PerformanceProfiler();
|
|
139
|
+
const runner = createMockRunner(systems);
|
|
140
|
+
|
|
141
|
+
profiler.attach(runner);
|
|
142
|
+
|
|
143
|
+
for (let tick = 1; tick <= 3; tick++) {
|
|
144
|
+
runner.runTick(tick);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const stats = profiler.getStats();
|
|
148
|
+
// Tick time should be positive
|
|
149
|
+
expect(stats.tickTime.latest).toBeGreaterThan(0);
|
|
150
|
+
expect(stats.tickTime.avg).toBeGreaterThan(0);
|
|
151
|
+
expect(stats.tickTime.min).toBeGreaterThan(0);
|
|
152
|
+
expect(stats.tickTime.max).toBeGreaterThanOrEqual(stats.tickTime.min);
|
|
153
|
+
|
|
154
|
+
profiler.dispose();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should return zeroed stats when no ticks have run', () => {
|
|
158
|
+
const systems = createMockSystems();
|
|
159
|
+
const profiler = new PerformanceProfiler();
|
|
160
|
+
const runner = createMockRunner(systems);
|
|
161
|
+
|
|
162
|
+
profiler.attach(runner);
|
|
163
|
+
|
|
164
|
+
const stats = profiler.getStats();
|
|
165
|
+
expect(stats.systems).toHaveLength(2);
|
|
166
|
+
for (const sys of stats.systems) {
|
|
167
|
+
expect(sys.latest).toBe(0);
|
|
168
|
+
expect(sys.min).toBe(0);
|
|
169
|
+
expect(sys.max).toBe(0);
|
|
170
|
+
expect(sys.avg).toBe(0);
|
|
171
|
+
}
|
|
172
|
+
expect(stats.tickTime.latest).toBe(0);
|
|
173
|
+
|
|
174
|
+
profiler.dispose();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should still call original update when wrapped', () => {
|
|
178
|
+
let callCount = 0;
|
|
179
|
+
const systems: IECSSystem[] = [
|
|
180
|
+
{
|
|
181
|
+
update() { callCount++; },
|
|
182
|
+
constructor: { name: 'TestSystem' } as unknown as { name: string },
|
|
183
|
+
} as unknown as IECSSystem,
|
|
184
|
+
];
|
|
185
|
+
const profiler = new PerformanceProfiler();
|
|
186
|
+
const runner = createMockRunner(systems);
|
|
187
|
+
|
|
188
|
+
profiler.attach(runner);
|
|
189
|
+
systems[0].update(1);
|
|
190
|
+
systems[0].update(2);
|
|
191
|
+
|
|
192
|
+
expect(callCount).toBe(2);
|
|
193
|
+
|
|
194
|
+
profiler.dispose();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should handle rolling window correctly', () => {
|
|
198
|
+
let duration = 0.1;
|
|
199
|
+
const systems: IECSSystem[] = [
|
|
200
|
+
{
|
|
201
|
+
update() {
|
|
202
|
+
const start = performance.now();
|
|
203
|
+
while (performance.now() - start < duration) { /* spin */ }
|
|
204
|
+
},
|
|
205
|
+
constructor: { name: 'VaryingSystem' } as unknown as { name: string },
|
|
206
|
+
} as unknown as IECSSystem,
|
|
207
|
+
];
|
|
208
|
+
const profiler = new PerformanceProfiler(8); // small window
|
|
209
|
+
const runner = createMockRunner(systems);
|
|
210
|
+
|
|
211
|
+
profiler.attach(runner);
|
|
212
|
+
|
|
213
|
+
// Run 8 ticks with 0.1ms duration
|
|
214
|
+
for (let i = 1; i <= 8; i++) {
|
|
215
|
+
runner.runTick(i);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const stats1 = profiler.getStats();
|
|
219
|
+
const avg1 = stats1.systems[0].avg;
|
|
220
|
+
|
|
221
|
+
// Run 8 more ticks with 0.3ms duration — old values should be overwritten
|
|
222
|
+
duration = 0.3;
|
|
223
|
+
for (let i = 9; i <= 16; i++) {
|
|
224
|
+
runner.runTick(i);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const stats2 = profiler.getStats();
|
|
228
|
+
// Average should now be higher since all window entries are ~0.3ms
|
|
229
|
+
expect(stats2.systems[0].avg).toBeGreaterThan(avg1);
|
|
230
|
+
|
|
231
|
+
profiler.dispose();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// --- NEW TESTS for tick time and snapshot time ---
|
|
235
|
+
|
|
236
|
+
it('should measure real total tick time greater than sum of system times', () => {
|
|
237
|
+
const systems = createMockSystems();
|
|
238
|
+
const profiler = new PerformanceProfiler();
|
|
239
|
+
const runner = createMockRunner(systems, { snapshotRate: 1 });
|
|
240
|
+
|
|
241
|
+
profiler.attach(runner);
|
|
242
|
+
|
|
243
|
+
// Run 10 ticks (with snapshot every tick to include snapshot overhead)
|
|
244
|
+
for (let tick = 1; tick <= 10; tick++) {
|
|
245
|
+
runner.runTick(tick);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const stats = profiler.getStats();
|
|
249
|
+
const systemSum = stats.systems.reduce((sum, s) => sum + s.avg, 0);
|
|
250
|
+
|
|
251
|
+
// Real tick time should be greater than system sum (includes non-system work + snapshot)
|
|
252
|
+
expect(stats.tickTime.avg).toBeGreaterThan(systemSum);
|
|
253
|
+
|
|
254
|
+
profiler.dispose();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should include snapshotTime in stats', () => {
|
|
258
|
+
const systems = createMockSystems();
|
|
259
|
+
const profiler = new PerformanceProfiler();
|
|
260
|
+
const runner = createMockRunner(systems, { snapshotRate: 1 });
|
|
261
|
+
|
|
262
|
+
profiler.attach(runner);
|
|
263
|
+
|
|
264
|
+
// Run ticks — snapshot happens every tick with snapshotRate=1
|
|
265
|
+
for (let tick = 1; tick <= 5; tick++) {
|
|
266
|
+
runner.runTick(tick);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const stats = profiler.getStats();
|
|
270
|
+
expect(stats.snapshotTime).toBeDefined();
|
|
271
|
+
expect(stats.snapshotTime.latest).toBeGreaterThan(0);
|
|
272
|
+
expect(stats.snapshotTime.min).toBeGreaterThan(0);
|
|
273
|
+
expect(stats.snapshotTime.max).toBeGreaterThanOrEqual(stats.snapshotTime.min);
|
|
274
|
+
expect(stats.snapshotTime.avg).toBeGreaterThan(0);
|
|
275
|
+
|
|
276
|
+
profiler.dispose();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should record snapshot time only when snapshot happens', () => {
|
|
280
|
+
const systems = createMockSystems();
|
|
281
|
+
const profiler = new PerformanceProfiler();
|
|
282
|
+
// snapshotRate=5 means snapshot on tick 5, 10, 15, ...
|
|
283
|
+
const runner = createMockRunner(systems, { snapshotRate: 5 });
|
|
284
|
+
|
|
285
|
+
profiler.attach(runner);
|
|
286
|
+
|
|
287
|
+
// Run 4 ticks — no snapshot should happen
|
|
288
|
+
for (let tick = 1; tick <= 4; tick++) {
|
|
289
|
+
runner.runTick(tick);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let stats = profiler.getStats();
|
|
293
|
+
expect(stats.snapshotTime.latest).toBe(0);
|
|
294
|
+
expect(stats.snapshotTime.avg).toBe(0);
|
|
295
|
+
|
|
296
|
+
// Run tick 5 — snapshot should happen
|
|
297
|
+
runner.runTick(5);
|
|
298
|
+
|
|
299
|
+
stats = profiler.getStats();
|
|
300
|
+
expect(stats.snapshotTime.latest).toBeGreaterThan(0);
|
|
301
|
+
expect(stats.snapshotTime.avg).toBeGreaterThan(0);
|
|
302
|
+
|
|
303
|
+
profiler.dispose();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should detach and restore simulate() and saveSnapshot()', () => {
|
|
307
|
+
const systems = createMockSystems();
|
|
308
|
+
const profiler = new PerformanceProfiler();
|
|
309
|
+
const runner = createMockRunner(systems);
|
|
310
|
+
|
|
311
|
+
const originalSimulate = runner.Simulation.simulate;
|
|
312
|
+
const originalSaveSnapshot = runner.Simulation.saveSnapshot;
|
|
313
|
+
|
|
314
|
+
profiler.attach(runner);
|
|
315
|
+
|
|
316
|
+
// Methods should be monkey-patched
|
|
317
|
+
expect(runner.Simulation.simulate).not.toBe(originalSimulate);
|
|
318
|
+
expect(runner.Simulation.saveSnapshot).not.toBe(originalSaveSnapshot);
|
|
319
|
+
|
|
320
|
+
profiler.detach();
|
|
321
|
+
|
|
322
|
+
// Methods should be restored
|
|
323
|
+
expect(runner.Simulation.simulate).toBe(originalSimulate);
|
|
324
|
+
expect(runner.Simulation.saveSnapshot).toBe(originalSaveSnapshot);
|
|
325
|
+
|
|
326
|
+
profiler.dispose();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// --- overheadTime tests ---
|
|
330
|
+
|
|
331
|
+
it('should include overheadTime in stats reflecting non-system non-snapshot work', () => {
|
|
332
|
+
const systems = createMockSystems();
|
|
333
|
+
const profiler = new PerformanceProfiler();
|
|
334
|
+
// snapshotRate=0 means no snapshots — overhead = tickTime - simulateTime
|
|
335
|
+
const runner = createMockRunner(systems, { snapshotRate: 0 });
|
|
336
|
+
|
|
337
|
+
profiler.attach(runner);
|
|
338
|
+
|
|
339
|
+
for (let tick = 1; tick <= 10; tick++) {
|
|
340
|
+
runner.runTick(tick);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const stats = profiler.getStats();
|
|
344
|
+
// The mock tick loop has ~0.02ms of non-system work between simulate and tick handlers
|
|
345
|
+
expect(stats.overheadTime).toBeDefined();
|
|
346
|
+
expect(stats.overheadTime.avg).toBeGreaterThan(0);
|
|
347
|
+
expect(stats.overheadTime.min).toBeGreaterThanOrEqual(0);
|
|
348
|
+
expect(stats.overheadTime.max).toBeGreaterThanOrEqual(stats.overheadTime.min);
|
|
349
|
+
|
|
350
|
+
profiler.dispose();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should return zeroed overheadTime when no ticks have run', () => {
|
|
354
|
+
const systems = createMockSystems();
|
|
355
|
+
const profiler = new PerformanceProfiler();
|
|
356
|
+
const runner = createMockRunner(systems);
|
|
357
|
+
|
|
358
|
+
profiler.attach(runner);
|
|
359
|
+
|
|
360
|
+
const stats = profiler.getStats();
|
|
361
|
+
expect(stats.overheadTime.latest).toBe(0);
|
|
362
|
+
expect(stats.overheadTime.avg).toBe(0);
|
|
363
|
+
|
|
364
|
+
profiler.dispose();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('should reset overheadTime state on detach', () => {
|
|
368
|
+
const systems = createMockSystems();
|
|
369
|
+
const profiler = new PerformanceProfiler();
|
|
370
|
+
const runner = createMockRunner(systems);
|
|
371
|
+
|
|
372
|
+
profiler.attach(runner);
|
|
373
|
+
|
|
374
|
+
for (let tick = 1; tick <= 5; tick++) {
|
|
375
|
+
runner.runTick(tick);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
profiler.detach();
|
|
379
|
+
|
|
380
|
+
const stats = profiler.getStats();
|
|
381
|
+
expect(stats.overheadTime.latest).toBe(0);
|
|
382
|
+
expect(stats.overheadTime.avg).toBe(0);
|
|
383
|
+
|
|
384
|
+
profiler.dispose();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('should remove tick handler on detach', () => {
|
|
388
|
+
const systems = createMockSystems();
|
|
389
|
+
const profiler = new PerformanceProfiler();
|
|
390
|
+
const runner = createMockRunner(systems);
|
|
391
|
+
|
|
392
|
+
profiler.attach(runner);
|
|
393
|
+
|
|
394
|
+
// Run some ticks
|
|
395
|
+
for (let tick = 1; tick <= 3; tick++) {
|
|
396
|
+
runner.runTick(tick);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
profiler.detach();
|
|
400
|
+
|
|
401
|
+
// Run more ticks — tick time should NOT accumulate
|
|
402
|
+
for (let tick = 4; tick <= 6; tick++) {
|
|
403
|
+
runner.runTick(tick);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// After detach, getStats should return zeroed (entries cleared)
|
|
407
|
+
const stats = profiler.getStats();
|
|
408
|
+
expect(stats.tickTime.latest).toBe(0);
|
|
409
|
+
|
|
410
|
+
profiler.dispose();
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { ECSRunner, ECSSimulation, IECSSystem } from '@lagless/core';
|
|
2
|
+
|
|
3
|
+
export interface TimingStats {
|
|
4
|
+
latest: number;
|
|
5
|
+
min: number;
|
|
6
|
+
max: number;
|
|
7
|
+
avg: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SystemTimingStats extends TimingStats {
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PerformanceStats {
|
|
15
|
+
tickTime: TimingStats;
|
|
16
|
+
snapshotTime: TimingStats;
|
|
17
|
+
overheadTime: TimingStats;
|
|
18
|
+
systems: SystemTimingStats[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SystemEntry {
|
|
22
|
+
name: string;
|
|
23
|
+
original: (tick: number) => void;
|
|
24
|
+
system: IECSSystem;
|
|
25
|
+
buffer: Float64Array;
|
|
26
|
+
writeIndex: number;
|
|
27
|
+
count: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Runtime shape of ECSSimulation with protected methods accessible via monkey-patching */
|
|
31
|
+
interface SimulationRuntime {
|
|
32
|
+
simulate: (tick: number) => void;
|
|
33
|
+
saveSnapshot: (tick: number) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_WINDOW_SIZE = 600;
|
|
37
|
+
const ZERO_TIMING: TimingStats = { latest: 0, min: 0, max: 0, avg: 0 };
|
|
38
|
+
|
|
39
|
+
function computeBufferStats(buffer: Float64Array, writeIndex: number, count: number, windowSize: number): TimingStats {
|
|
40
|
+
if (count === 0) return { ...ZERO_TIMING };
|
|
41
|
+
let min = Infinity;
|
|
42
|
+
let max = -Infinity;
|
|
43
|
+
let sum = 0;
|
|
44
|
+
for (let i = 0; i < count; i++) {
|
|
45
|
+
const idx = (writeIndex - count + i + windowSize) % windowSize;
|
|
46
|
+
const v = buffer[idx];
|
|
47
|
+
if (v < min) min = v;
|
|
48
|
+
if (v > max) max = v;
|
|
49
|
+
sum += v;
|
|
50
|
+
}
|
|
51
|
+
const latestIdx = (writeIndex - 1 + windowSize) % windowSize;
|
|
52
|
+
return {
|
|
53
|
+
latest: buffer[latestIdx],
|
|
54
|
+
min,
|
|
55
|
+
max,
|
|
56
|
+
avg: sum / count,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class PerformanceProfiler {
|
|
61
|
+
private readonly _windowSize: number;
|
|
62
|
+
private _entries: SystemEntry[] = [];
|
|
63
|
+
private _attached = false;
|
|
64
|
+
|
|
65
|
+
// Tick time ring buffer
|
|
66
|
+
private _tickTimeBuffer!: Float64Array;
|
|
67
|
+
private _tickTimeWriteIndex = 0;
|
|
68
|
+
private _tickTimeCount = 0;
|
|
69
|
+
private _tickStartTime = 0;
|
|
70
|
+
|
|
71
|
+
// Snapshot time ring buffer
|
|
72
|
+
private _snapshotTimeBuffer!: Float64Array;
|
|
73
|
+
private _snapshotTimeWriteIndex = 0;
|
|
74
|
+
private _snapshotTimeCount = 0;
|
|
75
|
+
|
|
76
|
+
// Overhead time ring buffer (tickTime - simulateElapsed - snapshotElapsed)
|
|
77
|
+
private _overheadTimeBuffer!: Float64Array;
|
|
78
|
+
private _overheadTimeWriteIndex = 0;
|
|
79
|
+
private _overheadTimeCount = 0;
|
|
80
|
+
|
|
81
|
+
// Per-tick elapsed tracking for overhead computation
|
|
82
|
+
private _lastSimulateElapsed = -1; // sentinel: -1 = no data yet (first-tick guard)
|
|
83
|
+
private _lastSnapshotElapsed = 0;
|
|
84
|
+
|
|
85
|
+
// Cleanup references
|
|
86
|
+
private _originalSimulate: ((tick: number) => void) | null = null;
|
|
87
|
+
private _originalSaveSnapshot: ((tick: number) => void) | null = null;
|
|
88
|
+
private _removeTickHandler: (() => void) | null = null;
|
|
89
|
+
private _simulation: ECSSimulation | null = null;
|
|
90
|
+
|
|
91
|
+
constructor(windowSize = DEFAULT_WINDOW_SIZE) {
|
|
92
|
+
this._windowSize = windowSize;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public attach(runner: ECSRunner): void {
|
|
96
|
+
if (this._attached) return;
|
|
97
|
+
this._attached = true;
|
|
98
|
+
|
|
99
|
+
const simulation = runner.Simulation;
|
|
100
|
+
this._simulation = simulation;
|
|
101
|
+
const sim = simulation as unknown as SimulationRuntime;
|
|
102
|
+
const systems = simulation.registeredSystems;
|
|
103
|
+
this._entries = [];
|
|
104
|
+
|
|
105
|
+
// Initialize ring buffers
|
|
106
|
+
this._tickTimeBuffer = new Float64Array(this._windowSize);
|
|
107
|
+
this._tickTimeWriteIndex = 0;
|
|
108
|
+
this._tickTimeCount = 0;
|
|
109
|
+
this._snapshotTimeBuffer = new Float64Array(this._windowSize);
|
|
110
|
+
this._snapshotTimeWriteIndex = 0;
|
|
111
|
+
this._snapshotTimeCount = 0;
|
|
112
|
+
this._overheadTimeBuffer = new Float64Array(this._windowSize);
|
|
113
|
+
this._overheadTimeWriteIndex = 0;
|
|
114
|
+
this._overheadTimeCount = 0;
|
|
115
|
+
this._lastSimulateElapsed = -1;
|
|
116
|
+
this._lastSnapshotElapsed = 0;
|
|
117
|
+
|
|
118
|
+
// Monkey-patch per-system update methods
|
|
119
|
+
for (const system of systems) {
|
|
120
|
+
const name = system.constructor.name;
|
|
121
|
+
const original = system.update;
|
|
122
|
+
const entry: SystemEntry = {
|
|
123
|
+
name,
|
|
124
|
+
original,
|
|
125
|
+
system,
|
|
126
|
+
buffer: new Float64Array(this._windowSize),
|
|
127
|
+
writeIndex: 0,
|
|
128
|
+
count: 0,
|
|
129
|
+
};
|
|
130
|
+
this._entries.push(entry);
|
|
131
|
+
|
|
132
|
+
system.update = (tick: number) => {
|
|
133
|
+
const start = performance.now();
|
|
134
|
+
original.call(system, tick);
|
|
135
|
+
const elapsed = performance.now() - start;
|
|
136
|
+
entry.buffer[entry.writeIndex % this._windowSize] = elapsed;
|
|
137
|
+
entry.writeIndex++;
|
|
138
|
+
entry.count = Math.min(entry.count + 1, this._windowSize);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Monkey-patch simulate() to record tick start time
|
|
143
|
+
const originalSimulate = sim.simulate;
|
|
144
|
+
this._originalSimulate = originalSimulate;
|
|
145
|
+
sim.simulate = (tick: number) => {
|
|
146
|
+
this._lastSnapshotElapsed = 0; // Reset before saveSnapshot() might or might not run this tick
|
|
147
|
+
this._tickStartTime = performance.now();
|
|
148
|
+
originalSimulate.call(simulation, tick);
|
|
149
|
+
this._lastSimulateElapsed = performance.now() - this._tickStartTime;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Monkey-patch saveSnapshot() to measure snapshot time
|
|
153
|
+
const originalSaveSnapshot = sim.saveSnapshot;
|
|
154
|
+
this._originalSaveSnapshot = originalSaveSnapshot;
|
|
155
|
+
sim.saveSnapshot = (tick: number) => {
|
|
156
|
+
const start = performance.now();
|
|
157
|
+
originalSaveSnapshot.call(simulation, tick);
|
|
158
|
+
const elapsed = performance.now() - start;
|
|
159
|
+
this._lastSnapshotElapsed = elapsed;
|
|
160
|
+
this._snapshotTimeBuffer[this._snapshotTimeWriteIndex % this._windowSize] = elapsed;
|
|
161
|
+
this._snapshotTimeWriteIndex++;
|
|
162
|
+
this._snapshotTimeCount = Math.min(this._snapshotTimeCount + 1, this._windowSize);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Add tick handler to compute total tick time and overhead
|
|
166
|
+
this._removeTickHandler = simulation.addTickHandler(() => {
|
|
167
|
+
const elapsed = performance.now() - this._tickStartTime;
|
|
168
|
+
this._tickTimeBuffer[this._tickTimeWriteIndex % this._windowSize] = elapsed;
|
|
169
|
+
this._tickTimeWriteIndex++;
|
|
170
|
+
this._tickTimeCount = Math.min(this._tickTimeCount + 1, this._windowSize);
|
|
171
|
+
|
|
172
|
+
// Compute overhead: tickTime - simulateElapsed - snapshotElapsed
|
|
173
|
+
// Skip first tick after attach (sentinel: _lastSimulateElapsed === -1)
|
|
174
|
+
if (this._lastSimulateElapsed >= 0) {
|
|
175
|
+
const overhead = Math.max(0, elapsed - this._lastSimulateElapsed - this._lastSnapshotElapsed);
|
|
176
|
+
this._overheadTimeBuffer[this._overheadTimeWriteIndex % this._windowSize] = overhead;
|
|
177
|
+
this._overheadTimeWriteIndex++;
|
|
178
|
+
this._overheadTimeCount = Math.min(this._overheadTimeCount + 1, this._windowSize);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
public detach(): void {
|
|
184
|
+
if (!this._attached) return;
|
|
185
|
+
|
|
186
|
+
// Restore per-system update methods
|
|
187
|
+
for (const entry of this._entries) {
|
|
188
|
+
entry.system.update = entry.original;
|
|
189
|
+
}
|
|
190
|
+
this._entries = [];
|
|
191
|
+
|
|
192
|
+
// Restore simulate() and saveSnapshot()
|
|
193
|
+
const sim = this._simulation as unknown as SimulationRuntime;
|
|
194
|
+
if (sim && this._originalSimulate) {
|
|
195
|
+
sim.simulate = this._originalSimulate;
|
|
196
|
+
}
|
|
197
|
+
if (sim && this._originalSaveSnapshot) {
|
|
198
|
+
sim.saveSnapshot = this._originalSaveSnapshot;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Remove tick handler
|
|
202
|
+
if (this._removeTickHandler) {
|
|
203
|
+
this._removeTickHandler();
|
|
204
|
+
this._removeTickHandler = null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this._originalSimulate = null;
|
|
208
|
+
this._originalSaveSnapshot = null;
|
|
209
|
+
this._simulation = null;
|
|
210
|
+
this._tickTimeWriteIndex = 0;
|
|
211
|
+
this._tickTimeCount = 0;
|
|
212
|
+
this._snapshotTimeWriteIndex = 0;
|
|
213
|
+
this._snapshotTimeCount = 0;
|
|
214
|
+
this._overheadTimeWriteIndex = 0;
|
|
215
|
+
this._overheadTimeCount = 0;
|
|
216
|
+
this._lastSimulateElapsed = -1;
|
|
217
|
+
this._lastSnapshotElapsed = 0;
|
|
218
|
+
this._attached = false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
public getStats(): PerformanceStats {
|
|
222
|
+
const systems: SystemTimingStats[] = this._entries.map((entry) => {
|
|
223
|
+
const stats = computeBufferStats(entry.buffer, entry.writeIndex, entry.count, this._windowSize);
|
|
224
|
+
return { name: entry.name, ...stats };
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const tickTime = computeBufferStats(
|
|
228
|
+
this._tickTimeBuffer, this._tickTimeWriteIndex, this._tickTimeCount, this._windowSize,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const snapshotTime = computeBufferStats(
|
|
232
|
+
this._snapshotTimeBuffer, this._snapshotTimeWriteIndex, this._snapshotTimeCount, this._windowSize,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const overheadTime = computeBufferStats(
|
|
236
|
+
this._overheadTimeBuffer, this._overheadTimeWriteIndex, this._overheadTimeCount, this._windowSize,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
return { tickTime, snapshotTime, overheadTime, systems };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
public dispose(): void {
|
|
243
|
+
this.detach();
|
|
244
|
+
}
|
|
245
|
+
}
|