@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.
- package/build/Metadata.d.ts +20 -12
- package/build/annotations.d.ts +23 -10
- package/build/codegen/cli.cjs +615 -204
- package/build/codegen/cli.cjs.map +1 -1
- package/build/codegen/languages/dart.d.ts +20 -0
- package/build/codegen/types.d.ts +20 -0
- package/build/decoder/Resync.d.ts +3 -3
- package/build/encoder/ChangeTree.d.ts +25 -9
- package/build/encoder/EncodeDescriptor.d.ts +11 -12
- package/build/encoder/StateView.d.ts +26 -2
- package/build/encoder/changeTree/inheritedFlags.d.ts +1 -1
- package/build/encoder/changeTree/parentChain.d.ts +9 -0
- package/build/encoder/streaming.d.ts +7 -0
- package/build/index.cjs +449 -233
- package/build/index.cjs.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.js +449 -233
- package/build/index.mjs +448 -232
- package/build/index.mjs.map +1 -1
- package/build/types/builder.d.ts +31 -22
- package/build/types/custom/ArraySchema.d.ts +17 -0
- package/build/types/custom/StreamSchema.d.ts +1 -1
- package/build/types/symbols.d.ts +4 -10
- package/package.json +1 -1
- package/src/Metadata.ts +58 -31
- package/src/annotations.ts +56 -32
- package/src/codegen/api.ts +2 -1
- package/src/codegen/languages/c.ts +21 -3
- package/src/codegen/languages/csharp.ts +7 -1
- package/src/codegen/languages/dart.ts +274 -0
- package/src/codegen/languages/haxe.ts +7 -1
- package/src/codegen/languages/lua.ts +16 -4
- package/src/codegen/languages/ts.ts +5 -0
- package/src/codegen/parser.ts +97 -3
- package/src/codegen/types.ts +24 -0
- package/src/decoder/Resync.ts +8 -8
- package/src/encoder/ChangeRecorder.ts +1 -1
- package/src/encoder/ChangeTree.ts +46 -26
- package/src/encoder/EncodeDescriptor.ts +17 -38
- package/src/encoder/EncodeOperation.ts +3 -1
- package/src/encoder/Encoder.ts +97 -21
- package/src/encoder/Root.ts +18 -20
- package/src/encoder/StateView.ts +102 -12
- package/src/encoder/changeTree/inheritedFlags.ts +10 -10
- package/src/encoder/changeTree/liveIteration.ts +9 -9
- package/src/encoder/changeTree/parentChain.ts +29 -0
- package/src/encoder/streaming.ts +8 -0
- package/src/encoding/spec.ts +1 -1
- package/src/index.ts +2 -2
- package/src/types/builder.ts +35 -31
- package/src/types/custom/ArraySchema.ts +40 -1
- package/src/types/custom/StreamSchema.ts +1 -1
- package/src/types/symbols.ts +4 -11
- package/src/bench_bloat.ts +0 -173
- package/src/bench_churn.ts +0 -121
- package/src/bench_decode.ts +0 -221
- package/src/bench_decode_mem.ts +0 -165
- package/src/bench_encode.ts +0 -108
- package/src/bench_init.ts +0 -150
- package/src/bench_static.ts +0 -109
- package/src/bench_stream.ts +0 -295
- package/src/bench_view_cmp.ts +0 -142
package/src/bench_bloat.ts
DELETED
|
@@ -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)`);
|
package/src/bench_churn.ts
DELETED
|
@@ -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`);
|
package/src/bench_decode.ts
DELETED
|
@@ -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
|
-
}
|
package/src/bench_decode_mem.ts
DELETED
|
@@ -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();
|