@colyseus/schema 5.0.11 → 5.0.13

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.
Files changed (62) hide show
  1. package/build/Metadata.d.ts +20 -12
  2. package/build/annotations.d.ts +23 -10
  3. package/build/codegen/cli.cjs +615 -204
  4. package/build/codegen/cli.cjs.map +1 -1
  5. package/build/codegen/languages/dart.d.ts +20 -0
  6. package/build/codegen/types.d.ts +20 -0
  7. package/build/decoder/Resync.d.ts +3 -3
  8. package/build/encoder/ChangeTree.d.ts +25 -9
  9. package/build/encoder/EncodeDescriptor.d.ts +11 -12
  10. package/build/encoder/StateView.d.ts +26 -2
  11. package/build/encoder/changeTree/inheritedFlags.d.ts +1 -1
  12. package/build/encoder/changeTree/parentChain.d.ts +9 -0
  13. package/build/encoder/streaming.d.ts +7 -0
  14. package/build/index.cjs +449 -233
  15. package/build/index.cjs.map +1 -1
  16. package/build/index.d.ts +1 -1
  17. package/build/index.js +449 -233
  18. package/build/index.mjs +448 -232
  19. package/build/index.mjs.map +1 -1
  20. package/build/types/builder.d.ts +31 -22
  21. package/build/types/custom/ArraySchema.d.ts +17 -0
  22. package/build/types/custom/StreamSchema.d.ts +1 -1
  23. package/build/types/symbols.d.ts +4 -10
  24. package/package.json +1 -1
  25. package/src/Metadata.ts +58 -31
  26. package/src/annotations.ts +56 -32
  27. package/src/codegen/api.ts +2 -1
  28. package/src/codegen/languages/c.ts +21 -3
  29. package/src/codegen/languages/csharp.ts +7 -1
  30. package/src/codegen/languages/dart.ts +274 -0
  31. package/src/codegen/languages/haxe.ts +7 -1
  32. package/src/codegen/languages/lua.ts +16 -4
  33. package/src/codegen/languages/ts.ts +5 -0
  34. package/src/codegen/parser.ts +97 -3
  35. package/src/codegen/types.ts +24 -0
  36. package/src/decoder/Resync.ts +8 -8
  37. package/src/encoder/ChangeRecorder.ts +1 -1
  38. package/src/encoder/ChangeTree.ts +46 -26
  39. package/src/encoder/EncodeDescriptor.ts +17 -38
  40. package/src/encoder/EncodeOperation.ts +3 -1
  41. package/src/encoder/Encoder.ts +97 -21
  42. package/src/encoder/Root.ts +18 -20
  43. package/src/encoder/StateView.ts +102 -12
  44. package/src/encoder/changeTree/inheritedFlags.ts +10 -10
  45. package/src/encoder/changeTree/liveIteration.ts +9 -9
  46. package/src/encoder/changeTree/parentChain.ts +29 -0
  47. package/src/encoder/streaming.ts +8 -0
  48. package/src/encoding/spec.ts +1 -1
  49. package/src/index.ts +2 -2
  50. package/src/types/builder.ts +35 -31
  51. package/src/types/custom/ArraySchema.ts +40 -1
  52. package/src/types/custom/StreamSchema.ts +1 -1
  53. package/src/types/symbols.ts +4 -11
  54. package/src/bench_bloat.ts +0 -173
  55. package/src/bench_churn.ts +0 -121
  56. package/src/bench_decode.ts +0 -221
  57. package/src/bench_decode_mem.ts +0 -165
  58. package/src/bench_encode.ts +0 -108
  59. package/src/bench_init.ts +0 -150
  60. package/src/bench_static.ts +0 -109
  61. package/src/bench_stream.ts +0 -295
  62. package/src/bench_view_cmp.ts +0 -142
@@ -1,173 +0,0 @@
1
- import { Encoder, Schema, type, MapSchema, ArraySchema } from "./index";
2
-
3
- // Must be set before constructing any Encoder — static BUFFER_SIZE is
4
- // read at Encoder construction time. Default 8KB overflows on the first
5
- // full-state encode (1000 entities), which used to skew measurements 2/3/6
6
- // by leaving the initial ADD wave half-encoded.
7
- Encoder.BUFFER_SIZE = 4 * 1024 * 1024;
8
-
9
- class Position extends Schema {
10
- @type("number") x: number;
11
- @type("number") y: number;
12
- }
13
-
14
- class Player extends Schema {
15
- @type("string") name: string;
16
- @type(Position) position = new Position();
17
- @type(["number"]) scores = new ArraySchema<number>();
18
- }
19
-
20
- class State extends Schema {
21
- @type({ map: Player }) players = new MapSchema<Player>();
22
- }
23
-
24
- // --- Measure 1: Memory ---
25
- globalThis.gc?.();
26
- const heapBefore = process.memoryUsage().heapUsed;
27
-
28
- const state = new State();
29
- const encoder = new Encoder(state);
30
-
31
- for (let i = 0; i < 1000; i++) {
32
- const p = new Player();
33
- p.name = `Player ${i}`;
34
- p.position.x = i;
35
- p.position.y = i;
36
- for (let j = 0; j < 5; j++) p.scores.push(j);
37
- state.players.set(`p${i}`, p);
38
- }
39
-
40
- globalThis.gc?.();
41
- const heapAfter = process.memoryUsage().heapUsed;
42
- console.log(`Heap delta (1000 entities): ${((heapAfter - heapBefore) / 1024 / 1024).toFixed(2)} MB`);
43
-
44
- // Initial encode
45
- encoder.encode();
46
- encoder.discardChanges();
47
-
48
- // --- Measure 2: Encode tick speed (small mutations) ---
49
- const iterations = 5000;
50
- const start = performance.now();
51
- for (let i = 0; i < iterations; i++) {
52
- // Mutate 10 players (small tick)
53
- for (let j = 0; j < 10; j++) {
54
- const p = state.players.get(`p${j}`);
55
- p.position.x++;
56
- p.position.y++;
57
- }
58
- encoder.encode();
59
- encoder.discardChanges();
60
- }
61
- const elapsed = performance.now() - start;
62
- console.log(`${iterations} encode ticks (10 mutations each): ${elapsed.toFixed(1)}ms (${(elapsed/iterations).toFixed(4)}ms/tick)`);
63
-
64
- // --- Measure 3: Encode tick speed (large mutations) ---
65
- const iterations2 = 1000;
66
- const start2 = performance.now();
67
- for (let i = 0; i < iterations2; i++) {
68
- for (let j = 0; j < 100; j++) {
69
- const p = state.players.get(`p${j}`);
70
- p.position.x++;
71
- p.position.y++;
72
- }
73
- encoder.encode();
74
- encoder.discardChanges();
75
- }
76
- const elapsed2 = performance.now() - start2;
77
- console.log(`${iterations2} encode ticks (100 mutations each): ${elapsed2.toFixed(1)}ms (${(elapsed2/iterations2).toFixed(3)}ms/tick)`);
78
-
79
- // --- Measure 4: Entity creation speed ---
80
- const state2 = new State();
81
- const encoder2 = new Encoder(state2);
82
- const createStart = performance.now();
83
- for (let i = 0; i < 5000; i++) {
84
- const p = new Player();
85
- p.name = `P${i}`;
86
- p.position.x = i;
87
- p.position.y = i;
88
- state2.players.set(`p${i}`, p);
89
- }
90
- const createElapsed = performance.now() - createStart;
91
- console.log(`Create 5000 entities: ${createElapsed.toFixed(1)}ms`);
92
-
93
- // --- Measure 5: encodeAll speed ---
94
- const encoder3 = new Encoder(state2);
95
- const encodeAllStart = performance.now();
96
- for (let i = 0; i < 100; i++) {
97
- encoder3.encodeAll();
98
- }
99
- const encodeAllElapsed = performance.now() - encodeAllStart;
100
- console.log(`100x encodeAll (5000 entities): ${encodeAllElapsed.toFixed(1)}ms`);
101
-
102
- // --- Measure 6: GC pressure (heap growth over many ticks) ---
103
- globalThis.gc?.();
104
- const gcBefore = process.memoryUsage().heapUsed;
105
- for (let i = 0; i < 10000; i++) {
106
- const p = state.players.get(`p${i % 100}`);
107
- p.position.x++;
108
- p.position.y++;
109
- if (i % 10 === 0) {
110
- encoder.encode();
111
- encoder.discardChanges();
112
- }
113
- }
114
- globalThis.gc?.();
115
- const gcAfter = process.memoryUsage().heapUsed;
116
- console.log(`GC pressure (10k mutations, 1k encode ticks): heap delta ${((gcAfter - gcBefore) / 1024).toFixed(1)} KB`);
117
-
118
- // --- Measure 7: Entity add/remove churn (exercises Root linked list) ---
119
- const churnState = new State();
120
- const churnEncoder = new Encoder(churnState);
121
- // Pre-populate
122
- for (let i = 0; i < 100; i++) {
123
- const p = new Player();
124
- p.name = `P${i}`;
125
- p.position.x = i;
126
- p.position.y = i;
127
- churnState.players.set(`p${i}`, p);
128
- }
129
- churnEncoder.encode();
130
- churnEncoder.discardChanges();
131
-
132
- const churnStart = performance.now();
133
- for (let i = 0; i < 1000; i++) {
134
- // Remove and re-add 10 entities
135
- for (let j = 0; j < 10; j++) {
136
- const key = `p${(i * 10 + j) % 100}`;
137
- churnState.players.delete(key);
138
- }
139
- churnEncoder.encode();
140
- churnEncoder.discardChanges();
141
- for (let j = 0; j < 10; j++) {
142
- const key = `p${(i * 10 + j) % 100}`;
143
- const p = new Player();
144
- p.name = key;
145
- p.position.x = i;
146
- p.position.y = j;
147
- churnState.players.set(key, p);
148
- }
149
- churnEncoder.encode();
150
- churnEncoder.discardChanges();
151
- }
152
- const churnElapsed = performance.now() - churnStart;
153
- console.log(`1000 entity churn cycles (10 remove+add each): ${churnElapsed.toFixed(1)}ms (${(churnElapsed/1000).toFixed(3)}ms/cycle)`);
154
-
155
- // --- Measure 9: Array push/pop churn ---
156
- class ArrayState extends Schema {
157
- @type(["number"]) items = new ArraySchema<number>();
158
- }
159
- const arrayState = new ArrayState();
160
- const arrayEncoder = new Encoder(arrayState);
161
- for (let i = 0; i < 100; i++) arrayState.items.push(i);
162
- arrayEncoder.encode();
163
- arrayEncoder.discardChanges();
164
-
165
- const arrayStart = performance.now();
166
- for (let i = 0; i < 5000; i++) {
167
- arrayState.items.push(100 + i);
168
- arrayState.items.pop();
169
- arrayEncoder.encode();
170
- arrayEncoder.discardChanges();
171
- }
172
- const arrayElapsed = performance.now() - arrayStart;
173
- console.log(`5000 array push/pop ticks: ${arrayElapsed.toFixed(1)}ms (${(arrayElapsed/5000).toFixed(4)}ms/tick)`);
@@ -1,121 +0,0 @@
1
- /**
2
- * Benchmark: entity churn (spawn → despawn → respawn) with and without the
3
- * native SchemaPool. Existing benches (bench_encode/bench_bloat) are
4
- * monotonic-growth and never return instances to a pool, so they can't show
5
- * pooling ROI — this one models the ECS/projectile/bot workload the pool
6
- * targets.
7
- *
8
- * Usage: npx tsx --tsconfig tsconfig.test.json src/bench_churn.ts
9
- */
10
- import v8 from "node:v8";
11
- import vm from "node:vm";
12
- import { Schema, type, MapSchema, Encoder, createPool, type SchemaPool } from "./index";
13
-
14
- // gc() handle (works under the tsx CLI, where `node --expose-gc` doesn't reach
15
- // the forked child).
16
- v8.setFlagsFromString("--expose-gc");
17
- const gc = (globalThis.gc as undefined | (() => void)) ?? (vm.runInNewContext("gc") as () => void);
18
-
19
- class Vector extends Schema {
20
- @type("number") x: number = 0;
21
- @type("number") y: number = 0;
22
- @type("number") z: number = 0;
23
- }
24
- // A nested entity: Entity + 2 child Vectors = 3 Schema instances per spawn,
25
- // matching the miniplex profiling scenario.
26
- class Entity extends Schema {
27
- @type("string") name: string = "";
28
- @type(Vector) position = new Vector();
29
- @type(Vector) velocity = new Vector();
30
- }
31
- class State extends Schema {
32
- @type({ map: Entity }) entities = new MapSchema<Entity>();
33
- }
34
-
35
- Encoder.BUFFER_SIZE = 4096 * 4096;
36
-
37
- const N = 2_000; // entities alive per tick
38
- const TICKS = 200; // churn cycles
39
- const WARMUP = 10;
40
- const INSTANCES_PER_ENTITY = 3; // Entity + position + velocity
41
- const spawns = N * TICKS;
42
-
43
- const heap = () => {
44
- gc?.();
45
- gc?.();
46
- return process.memoryUsage().heapUsed;
47
- };
48
-
49
- function runChurn(usePool: boolean) {
50
- const state = new State();
51
- const encoder = new Encoder(state);
52
- encoder.encode();
53
- encoder.discardChanges();
54
-
55
- const pool: SchemaPool<Entity> | undefined = usePool ? createPool(Entity, { preallocate: N }) : undefined;
56
- const live: Entity[] = new Array(N);
57
-
58
- const spawnTick = (timeConstruct: boolean): number => {
59
- // CONSTRUCTION phase (the differentiator): new vs acquire
60
- const c0 = performance.now();
61
- for (let i = 0; i < N; i++) {
62
- live[i] = pool ? pool.acquire() : new Entity();
63
- }
64
- const c1 = performance.now();
65
-
66
- // init + attach + encode
67
- for (let i = 0; i < N; i++) {
68
- const e = live[i];
69
- e.name = "e" + i;
70
- e.position.x = i; e.position.y = i; e.position.z = i;
71
- e.velocity.x = 1;
72
- state.entities.set(String(i), e);
73
- }
74
- encoder.encode();
75
- encoder.discardChanges();
76
-
77
- // despawn + release
78
- for (let i = 0; i < N; i++) state.entities.delete(String(i));
79
- encoder.encode();
80
- encoder.discardChanges();
81
- if (pool) for (let i = 0; i < N; i++) pool.release(live[i]);
82
-
83
- return timeConstruct ? c1 - c0 : 0;
84
- };
85
-
86
- for (let t = 0; t < WARMUP; t++) spawnTick(false);
87
-
88
- const before = heap();
89
- let constructMs = 0;
90
- const t0 = performance.now();
91
- for (let t = 0; t < TICKS; t++) constructMs += spawnTick(true);
92
- const t1 = performance.now();
93
- const after = heap();
94
-
95
- return {
96
- cycleMs: t1 - t0,
97
- constructMs,
98
- constructNsPerEntity: (constructMs * 1e6) / spawns,
99
- heapDelta: after - before,
100
- poolSize: pool?.size ?? 0
101
- };
102
- }
103
-
104
- console.log(`\nEntity churn benchmark — ${N} entities × ${TICKS} ticks = ${spawns.toLocaleString("en-US")} spawns`);
105
- console.log(`(each entity = ${INSTANCES_PER_ENTITY} Schema instances; gc=${gc ? "on" : "off"})\n`);
106
-
107
- const noPool = runChurn(false);
108
- const pooled = runChurn(true);
109
-
110
- const row = (label: string, r: ReturnType<typeof runChurn>) => {
111
- console.log(`${label.padEnd(14)} construct: ${r.constructMs.toFixed(1).padStart(8)} ms ` +
112
- `${r.constructNsPerEntity.toFixed(0).padStart(6)} ns/entity ` +
113
- `full cycle: ${r.cycleMs.toFixed(1).padStart(8)} ms ` +
114
- `heapΔ: ${(r.heapDelta / 1024 / 1024).toFixed(1).padStart(7)} MB`);
115
- };
116
-
117
- row("new Entity()", noPool);
118
- row("pool.acquire()", pooled);
119
- console.log(`\nConstruction speedup: ${(noPool.constructNsPerEntity / pooled.constructNsPerEntity).toFixed(1)}x faster` +
120
- ` (full-cycle: ${(noPool.cycleMs / pooled.cycleMs).toFixed(2)}x — diluted by identical encode/decode cost)`);
121
- console.log(`Pool retained ${pooled.poolSize} instances after the run.\n`);
@@ -1,221 +0,0 @@
1
- import { Encoder, Decoder, Schema, type, MapSchema, ArraySchema } from "./index";
2
-
3
- class Position extends Schema {
4
- @type("number") x: number;
5
- @type("number") y: number;
6
- }
7
-
8
- class Player extends Schema {
9
- @type("string") name: string;
10
- @type(Position) position = new Position();
11
- @type(["number"]) scores = new ArraySchema<number>();
12
- }
13
-
14
- class State extends Schema {
15
- @type({ map: Player }) players = new MapSchema<Player>();
16
- }
17
-
18
- function freshDecoder() {
19
- return new Decoder(new State());
20
- }
21
-
22
- function clone(bytes: Uint8Array) {
23
- return bytes.slice();
24
- }
25
-
26
- Encoder.BUFFER_SIZE = 4096 * 4096;
27
-
28
- // --- Build a server state with N entities ---
29
- const state = new State();
30
- const encoder = new Encoder(state);
31
-
32
- const N = 1000;
33
- for (let i = 0; i < N; i++) {
34
- const p = new Player();
35
- p.name = `Player ${i}`;
36
- p.position.x = i;
37
- p.position.y = i;
38
- for (let j = 0; j < 5; j++) p.scores.push(j);
39
- state.players.set(`p${i}`, p);
40
- }
41
-
42
- // --- Measure 1: Initial bootstrap decode (encodeAll on a fresh client) ---
43
- {
44
- const bootstrapBytes = clone(encoder.encodeAll());
45
- encoder.discardChanges();
46
-
47
- const iterations = 200;
48
- const start = performance.now();
49
- for (let i = 0; i < iterations; i++) {
50
- const decoder = freshDecoder();
51
- decoder.decode(bootstrapBytes);
52
- }
53
- const elapsed = performance.now() - start;
54
- console.log(
55
- `Bootstrap decode (${N} entities) x${iterations}: ${elapsed.toFixed(1)}ms ` +
56
- `(${(elapsed / iterations).toFixed(3)}ms/run, ${bootstrapBytes.length} bytes)`
57
- );
58
- }
59
-
60
- // --- Measure 2: Steady-state small-tick decode (10 players per tick) ---
61
- {
62
- const decoder = freshDecoder();
63
- decoder.decode(clone(encoder.encodeAll()));
64
- encoder.discardChanges();
65
-
66
- const ticks = 5000;
67
- // Pre-produce the byte frames so we measure decode only.
68
- const frames: Uint8Array[] = new Array(ticks);
69
- for (let i = 0; i < ticks; i++) {
70
- for (let j = 0; j < 10; j++) {
71
- const p = state.players.get(`p${j}`)!;
72
- p.position.x++;
73
- p.position.y++;
74
- }
75
- frames[i] = clone(encoder.encode());
76
- encoder.discardChanges();
77
- }
78
-
79
- const start = performance.now();
80
- for (let i = 0; i < ticks; i++) decoder.decode(frames[i]);
81
- const elapsed = performance.now() - start;
82
- const totalBytes = frames.reduce((s, f) => s + f.length, 0);
83
- console.log(
84
- `Small-tick decode (10 mutations/tick) x${ticks}: ${elapsed.toFixed(1)}ms ` +
85
- `(${(elapsed / ticks).toFixed(4)}ms/tick, avg ${(totalBytes / ticks).toFixed(1)} bytes/tick)`
86
- );
87
- }
88
-
89
- // --- Measure 3: Steady-state large-tick decode (100 players per tick) ---
90
- {
91
- const decoder = freshDecoder();
92
- decoder.decode(clone(encoder.encodeAll()));
93
- encoder.discardChanges();
94
-
95
- const ticks = 1000;
96
- const frames: Uint8Array[] = new Array(ticks);
97
- for (let i = 0; i < ticks; i++) {
98
- for (let j = 0; j < 100; j++) {
99
- const p = state.players.get(`p${j}`)!;
100
- p.position.x++;
101
- p.position.y++;
102
- }
103
- frames[i] = clone(encoder.encode());
104
- encoder.discardChanges();
105
- }
106
-
107
- const start = performance.now();
108
- for (let i = 0; i < ticks; i++) decoder.decode(frames[i]);
109
- const elapsed = performance.now() - start;
110
- const totalBytes = frames.reduce((s, f) => s + f.length, 0);
111
- console.log(
112
- `Large-tick decode (100 mutations/tick) x${ticks}: ${elapsed.toFixed(1)}ms ` +
113
- `(${(elapsed / ticks).toFixed(3)}ms/tick, avg ${(totalBytes / ticks).toFixed(1)} bytes/tick)`
114
- );
115
- }
116
-
117
- // --- Measure 4: Add/remove churn decode ---
118
- {
119
- const churnState = new State();
120
- const churnEncoder = new Encoder(churnState);
121
- for (let i = 0; i < 100; i++) {
122
- const p = new Player();
123
- p.name = `P${i}`;
124
- p.position.x = i;
125
- p.position.y = i;
126
- churnState.players.set(`p${i}`, p);
127
- }
128
-
129
- const decoder = new Decoder(new State());
130
- decoder.decode(clone(churnEncoder.encodeAll()));
131
- churnEncoder.discardChanges();
132
-
133
- const cycles = 1000;
134
- const frames: Uint8Array[] = [];
135
- for (let i = 0; i < cycles; i++) {
136
- for (let j = 0; j < 10; j++) {
137
- const key = `p${(i * 10 + j) % 100}`;
138
- churnState.players.delete(key);
139
- }
140
- frames.push(clone(churnEncoder.encode()));
141
- churnEncoder.discardChanges();
142
- for (let j = 0; j < 10; j++) {
143
- const key = `p${(i * 10 + j) % 100}`;
144
- const p = new Player();
145
- p.name = key;
146
- p.position.x = i;
147
- p.position.y = j;
148
- churnState.players.set(key, p);
149
- }
150
- frames.push(clone(churnEncoder.encode()));
151
- churnEncoder.discardChanges();
152
- }
153
-
154
- const start = performance.now();
155
- for (let i = 0; i < frames.length; i++) decoder.decode(frames[i]);
156
- const elapsed = performance.now() - start;
157
- console.log(
158
- `Churn decode (${cycles} cycles, 10 remove+add each, ${frames.length} frames): ` +
159
- `${elapsed.toFixed(1)}ms (${(elapsed / cycles).toFixed(3)}ms/cycle)`
160
- );
161
- }
162
-
163
- // --- Measure 5: Array push/pop churn decode ---
164
- {
165
- class ArrayState extends Schema {
166
- @type(["number"]) items = new ArraySchema<number>();
167
- }
168
- const arrayState = new ArrayState();
169
- const arrayEncoder = new Encoder(arrayState);
170
- for (let i = 0; i < 100; i++) arrayState.items.push(i);
171
-
172
- const decoder = new Decoder(new ArrayState());
173
- decoder.decode(clone(arrayEncoder.encodeAll()));
174
- arrayEncoder.discardChanges();
175
-
176
- const ticks = 5000;
177
- const frames: Uint8Array[] = new Array(ticks);
178
- for (let i = 0; i < ticks; i++) {
179
- arrayState.items.push(100 + i);
180
- arrayState.items.pop();
181
- frames[i] = clone(arrayEncoder.encode());
182
- arrayEncoder.discardChanges();
183
- }
184
-
185
- const start = performance.now();
186
- for (let i = 0; i < ticks; i++) decoder.decode(frames[i]);
187
- const elapsed = performance.now() - start;
188
- console.log(
189
- `Array push/pop decode x${ticks}: ${elapsed.toFixed(1)}ms ` +
190
- `(${(elapsed / ticks).toFixed(4)}ms/tick)`
191
- );
192
- }
193
-
194
- // --- Measure 6: Heavy mutation per tick (every player + array items) ---
195
- {
196
- const decoder = freshDecoder();
197
- decoder.decode(clone(encoder.encodeAll()));
198
- encoder.discardChanges();
199
-
200
- const ticks = 200;
201
- const frames: Uint8Array[] = new Array(ticks);
202
- for (let i = 0; i < ticks; i++) {
203
- for (let j = 0; j < N; j++) {
204
- const p = state.players.get(`p${j}`)!;
205
- p.position.x++;
206
- p.position.y++;
207
- p.scores[0] = i;
208
- }
209
- frames[i] = clone(encoder.encode());
210
- encoder.discardChanges();
211
- }
212
-
213
- const start = performance.now();
214
- for (let i = 0; i < ticks; i++) decoder.decode(frames[i]);
215
- const elapsed = performance.now() - start;
216
- const totalBytes = frames.reduce((s, f) => s + f.length, 0);
217
- console.log(
218
- `Heavy-tick decode (${N} players touched) x${ticks}: ${elapsed.toFixed(1)}ms ` +
219
- `(${(elapsed / ticks).toFixed(3)}ms/tick, avg ${(totalBytes / ticks).toFixed(0)} bytes/tick)`
220
- );
221
- }
@@ -1,165 +0,0 @@
1
- import { PerformanceObserver, constants } from "node:perf_hooks";
2
- import { Encoder, Decoder, Schema, type, MapSchema, ArraySchema } from "./index";
3
-
4
- class Position extends Schema {
5
- @type("number") x: number;
6
- @type("number") y: number;
7
- }
8
-
9
- class Player extends Schema {
10
- @type("string") name: string;
11
- @type(Position) position = new Position();
12
- @type(["number"]) scores = new ArraySchema<number>();
13
- }
14
-
15
- class State extends Schema {
16
- @type({ map: Player }) players = new MapSchema<Player>();
17
- }
18
-
19
- if (!globalThis.gc) {
20
- console.error("Run with --expose-gc: npx tsx --expose-gc ...");
21
- process.exit(1);
22
- }
23
-
24
- Encoder.BUFFER_SIZE = 4096 * 4096;
25
-
26
- // --- GC observer ---
27
- const gcStats = { count: 0, totalMs: 0, majorMs: 0, minorMs: 0 };
28
- const gcObserver = new PerformanceObserver((list) => {
29
- for (const e of list.getEntries() as any) {
30
- gcStats.count++;
31
- gcStats.totalMs += e.duration;
32
- if (e.detail?.kind === constants.NODE_PERFORMANCE_GC_MAJOR) gcStats.majorMs += e.duration;
33
- else gcStats.minorMs += e.duration;
34
- }
35
- });
36
- gcObserver.observe({ entryTypes: ["gc"], buffered: false });
37
-
38
- function resetGc() { gcStats.count = 0; gcStats.totalMs = 0; gcStats.majorMs = 0; gcStats.minorMs = 0; }
39
- function snapshotGc() { return { ...gcStats }; }
40
- function heap() {
41
- globalThis.gc!();
42
- globalThis.gc!();
43
- return process.memoryUsage().heapUsed;
44
- }
45
- function mb(n: number) { return (n / 1024 / 1024).toFixed(2); }
46
- function kb(n: number) { return (n / 1024).toFixed(1); }
47
-
48
- // --- Build an encoder-side state once ---
49
- const state = new State();
50
- const encoder = new Encoder(state);
51
- const N = 1000;
52
- for (let i = 0; i < N; i++) {
53
- const p = new Player();
54
- p.name = `Player ${i}`;
55
- p.position.x = i;
56
- p.position.y = i;
57
- for (let j = 0; j < 5; j++) p.scores.push(j);
58
- state.players.set(`p${i}`, p);
59
- }
60
- const bootstrapBytes = encoder.encodeAll().slice();
61
- encoder.discardChanges();
62
-
63
- // Pre-produce steady-state frames (outside the timed region).
64
- const heavyFrames: Uint8Array[] = [];
65
- const HEAVY_TICKS = 200;
66
- for (let i = 0; i < HEAVY_TICKS; i++) {
67
- for (let j = 0; j < N; j++) {
68
- const p = state.players.get(`p${j}`)!;
69
- p.position.x++;
70
- p.position.y++;
71
- p.scores[0] = i;
72
- }
73
- heavyFrames.push(encoder.encode().slice());
74
- encoder.discardChanges();
75
- }
76
-
77
- type Stats = {
78
- decoderHeapKb: number;
79
- steadyHeapDeltaKb: number;
80
- steadyGcCount: number;
81
- steadyGcMs: number;
82
- bootstrapMs: number;
83
- heavyMs: number;
84
- };
85
-
86
- function run(): Stats {
87
- const baseHeap = heap();
88
-
89
- const decoder = new Decoder(new State());
90
- const tBoot0 = performance.now();
91
- decoder.decode(bootstrapBytes);
92
- const bootstrapMs = performance.now() - tBoot0;
93
-
94
- const decoderHeap = heap() - baseHeap;
95
-
96
- resetGc();
97
- const beforeSteady = process.memoryUsage().heapUsed;
98
- const tHeavy0 = performance.now();
99
- for (let i = 0; i < HEAVY_TICKS; i++) decoder.decode(heavyFrames[i]);
100
- const heavyMs = performance.now() - tHeavy0;
101
- const afterSteady = process.memoryUsage().heapUsed;
102
- const gcSnap = snapshotGc();
103
-
104
- return {
105
- decoderHeapKb: decoderHeap / 1024,
106
- steadyHeapDeltaKb: (afterSteady - beforeSteady) / 1024,
107
- steadyGcCount: gcSnap.count,
108
- steadyGcMs: gcSnap.totalMs,
109
- bootstrapMs,
110
- heavyMs,
111
- };
112
- }
113
-
114
- // Warm (JIT seeding), discard result
115
- run();
116
-
117
- // Best-of-3 to get stable numbers
118
- let best: Stats | undefined;
119
- for (let i = 0; i < 3; i++) {
120
- const s = run();
121
- if (!best || s.heavyMs < best.heavyMs) best = s;
122
- }
123
-
124
- console.log("\n=== decoder memory + GC (best of 3) ===");
125
- console.log(
126
- `bootstrap ${best!.bootstrapMs.toFixed(1)}ms | ` +
127
- `heavy ${HEAVY_TICKS}t ${best!.heavyMs.toFixed(1)}ms (${(best!.heavyMs / HEAVY_TICKS).toFixed(3)}ms/tick) | ` +
128
- `heap(decoder state, ${N} entities) ${kb(best!.decoderHeapKb * 1024)} KB | ` +
129
- `heap-growth/steady ${kb(best!.steadyHeapDeltaKb * 1024)} KB | ` +
130
- `GCs ${best!.steadyGcCount} (${best!.steadyGcMs.toFixed(1)}ms)`
131
- );
132
-
133
- // --- Multi-client scenario: 100 fresh decoders, each bootstrapped on the same bytes.
134
- // Answers "how much heap does N simultaneous clients cost?".
135
- function multiClient(numClients: number) {
136
- const baseHeap = heap();
137
- resetGc();
138
- const decoders: Decoder[] = [];
139
- const t0 = performance.now();
140
- for (let i = 0; i < numClients; i++) {
141
- const d = new Decoder(new State());
142
- d.decode(bootstrapBytes);
143
- decoders.push(d);
144
- }
145
- const elapsed = performance.now() - t0;
146
- const gcSnap = snapshotGc();
147
- const heapAfter = heap();
148
- return {
149
- decoders, // keep alive so GC can't reclaim during measurement
150
- totalHeapKb: (heapAfter - baseHeap) / 1024,
151
- elapsedMs: elapsed,
152
- gcCount: gcSnap.count,
153
- gcMs: gcSnap.totalMs,
154
- };
155
- }
156
-
157
- console.log(`\n=== multi-client bootstrap (100 decoders, ${N} entities each) ===`);
158
- const mc = multiClient(100);
159
- console.log(
160
- `${mc.elapsedMs.toFixed(0)}ms | ` +
161
- `heap ${mb(mc.totalHeapKb * 1024)} MB | ` +
162
- `GCs ${mc.gcCount} (${mc.gcMs.toFixed(1)}ms)`
163
- );
164
-
165
- gcObserver.disconnect();