@colyseus/schema 4.0.19 → 5.0.0
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/README.md +2 -0
- package/build/Metadata.d.ts +55 -2
- package/build/Reflection.d.ts +24 -30
- package/build/Schema.d.ts +70 -9
- package/build/annotations.d.ts +56 -13
- package/build/codegen/cli.cjs +84 -44
- package/build/codegen/cli.cjs.map +1 -1
- package/build/decoder/DecodeOperation.d.ts +48 -5
- package/build/decoder/Decoder.d.ts +2 -2
- package/build/decoder/strategy/Callbacks.d.ts +1 -1
- package/build/encoder/ChangeRecorder.d.ts +107 -0
- package/build/encoder/ChangeTree.d.ts +218 -69
- package/build/encoder/EncodeDescriptor.d.ts +63 -0
- package/build/encoder/EncodeOperation.d.ts +25 -2
- package/build/encoder/Encoder.d.ts +59 -3
- package/build/encoder/MapJournal.d.ts +62 -0
- package/build/encoder/RefIdAllocator.d.ts +35 -0
- package/build/encoder/Root.d.ts +94 -13
- package/build/encoder/StateView.d.ts +116 -8
- package/build/encoder/changeTree/inheritedFlags.d.ts +34 -0
- package/build/encoder/changeTree/liveIteration.d.ts +3 -0
- package/build/encoder/changeTree/parentChain.d.ts +24 -0
- package/build/encoder/changeTree/treeAttachment.d.ts +13 -0
- package/build/encoder/streaming.d.ts +73 -0
- package/build/encoder/subscriptions.d.ts +25 -0
- package/build/index.cjs +5202 -1552
- package/build/index.cjs.map +1 -1
- package/build/index.d.ts +7 -3
- package/build/index.js +5202 -1552
- package/build/index.mjs +5193 -1552
- package/build/index.mjs.map +1 -1
- package/build/input/InputDecoder.d.ts +32 -0
- package/build/input/InputEncoder.d.ts +117 -0
- package/build/input/index.cjs +7429 -0
- package/build/input/index.cjs.map +1 -0
- package/build/input/index.d.ts +3 -0
- package/build/input/index.mjs +7426 -0
- package/build/input/index.mjs.map +1 -0
- package/build/types/HelperTypes.d.ts +22 -8
- package/build/types/TypeContext.d.ts +9 -0
- package/build/types/builder.d.ts +162 -0
- package/build/types/custom/ArraySchema.d.ts +25 -4
- package/build/types/custom/CollectionSchema.d.ts +30 -2
- package/build/types/custom/MapSchema.d.ts +52 -3
- package/build/types/custom/SetSchema.d.ts +32 -2
- package/build/types/custom/StreamSchema.d.ts +114 -0
- package/build/types/symbols.d.ts +48 -5
- package/package.json +8 -2
- package/src/Metadata.ts +258 -31
- package/src/Reflection.ts +15 -13
- package/src/Schema.ts +176 -134
- package/src/annotations.ts +308 -236
- package/src/bench_bloat.ts +173 -0
- package/src/bench_decode.ts +221 -0
- package/src/bench_decode_mem.ts +165 -0
- package/src/bench_encode.ts +108 -0
- package/src/bench_init.ts +150 -0
- package/src/bench_static.ts +109 -0
- package/src/bench_stream.ts +295 -0
- package/src/bench_view_cmp.ts +142 -0
- package/src/codegen/parser.ts +83 -61
- package/src/decoder/DecodeOperation.ts +168 -63
- package/src/decoder/Decoder.ts +20 -10
- package/src/decoder/ReferenceTracker.ts +4 -0
- package/src/decoder/strategy/Callbacks.ts +30 -26
- package/src/decoder/strategy/getDecoderStateCallbacks.ts +16 -13
- package/src/encoder/ChangeRecorder.ts +276 -0
- package/src/encoder/ChangeTree.ts +674 -519
- package/src/encoder/EncodeDescriptor.ts +213 -0
- package/src/encoder/EncodeOperation.ts +107 -65
- package/src/encoder/Encoder.ts +630 -119
- package/src/encoder/MapJournal.ts +124 -0
- package/src/encoder/RefIdAllocator.ts +68 -0
- package/src/encoder/Root.ts +247 -120
- package/src/encoder/StateView.ts +592 -121
- package/src/encoder/changeTree/inheritedFlags.ts +217 -0
- package/src/encoder/changeTree/liveIteration.ts +74 -0
- package/src/encoder/changeTree/parentChain.ts +131 -0
- package/src/encoder/changeTree/treeAttachment.ts +171 -0
- package/src/encoder/streaming.ts +232 -0
- package/src/encoder/subscriptions.ts +71 -0
- package/src/index.ts +15 -3
- package/src/input/InputDecoder.ts +57 -0
- package/src/input/InputEncoder.ts +303 -0
- package/src/input/index.ts +3 -0
- package/src/types/HelperTypes.ts +21 -9
- package/src/types/TypeContext.ts +14 -2
- package/src/types/builder.ts +285 -0
- package/src/types/custom/ArraySchema.ts +210 -197
- package/src/types/custom/CollectionSchema.ts +115 -35
- package/src/types/custom/MapSchema.ts +162 -58
- package/src/types/custom/SetSchema.ts +128 -39
- package/src/types/custom/StreamSchema.ts +310 -0
- package/src/types/symbols.ts +54 -6
- package/src/utils.ts +4 -6
|
@@ -0,0 +1,173 @@
|
|
|
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)`);
|
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
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();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { nanoid } from "nanoid";
|
|
2
|
+
import { Schema, type, MapSchema, ArraySchema, Encoder } from "./index";
|
|
3
|
+
|
|
4
|
+
class Attribute extends Schema {
|
|
5
|
+
@type("string") name: string;
|
|
6
|
+
@type("number") value: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
class Item extends Schema {
|
|
10
|
+
@type("number") price: number;
|
|
11
|
+
@type([ Attribute ]) attributes = new ArraySchema<Attribute>();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class Position extends Schema {
|
|
15
|
+
@type("number") x: number;
|
|
16
|
+
@type("number") y: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class Player extends Schema {
|
|
20
|
+
@type(Position) position = new Position();
|
|
21
|
+
@type({ map: Item }) items = new MapSchema<Item>();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class State extends Schema {
|
|
25
|
+
@type({ map: Player }) players = new MapSchema<Player>();
|
|
26
|
+
@type("string") currentTurn: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const state = new State();
|
|
30
|
+
|
|
31
|
+
Encoder.BUFFER_SIZE = 4096 * 4096;
|
|
32
|
+
const encoder = new Encoder(state);
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
let now = Date.now();
|
|
36
|
+
|
|
37
|
+
// for (let i = 0; i < 10000; i++) {
|
|
38
|
+
// const player = new Player();
|
|
39
|
+
// state.players.set(`p-${nanoid()}`, player);
|
|
40
|
+
//
|
|
41
|
+
// player.position.x = (i + 1) * 100;
|
|
42
|
+
// player.position.y = (i + 1) * 100;
|
|
43
|
+
// for (let j = 0; j < 10; j++) {
|
|
44
|
+
// const item = new Item();
|
|
45
|
+
// player.items.set(`item-${j}`, item);
|
|
46
|
+
// item.price = (i + 1) * 50;
|
|
47
|
+
// for (let k = 0; k < 5; k++) {
|
|
48
|
+
// const attr = new Attribute();
|
|
49
|
+
// attr.name = `Attribute ${k}`;
|
|
50
|
+
// attr.value = k;
|
|
51
|
+
// item.attributes.push(attr);
|
|
52
|
+
// }
|
|
53
|
+
// }
|
|
54
|
+
// }
|
|
55
|
+
// console.log("time to make changes:", Date.now() - now);
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
// measure time to .encodeAll()
|
|
59
|
+
|
|
60
|
+
now = Date.now();
|
|
61
|
+
// for (let i = 0; i < 1000; i++) {
|
|
62
|
+
// encoder.encodeAll();
|
|
63
|
+
// }
|
|
64
|
+
// console.log(Date.now() - now);
|
|
65
|
+
|
|
66
|
+
const total = 100;
|
|
67
|
+
const allEncodes = Date.now();
|
|
68
|
+
|
|
69
|
+
let avgTimeToEncode = 0;
|
|
70
|
+
let avgTimeToMakeChanges = 0;
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < total; i++) {
|
|
73
|
+
now = Date.now();
|
|
74
|
+
for (let j = 0; j < 50; j++) {
|
|
75
|
+
const player = new Player();
|
|
76
|
+
state.players.set(`p-${nanoid()}`, player);
|
|
77
|
+
|
|
78
|
+
player.position.x = (j + 1) * 100;
|
|
79
|
+
player.position.y = (j + 1) * 100;
|
|
80
|
+
for (let k = 0; k < 10; k++) {
|
|
81
|
+
const item = new Item();
|
|
82
|
+
item.price = (j + 1) * 50;
|
|
83
|
+
for (let l = 0; l < 5; l++) {
|
|
84
|
+
const attr = new Attribute();
|
|
85
|
+
attr.name = `Attribute ${l}`;
|
|
86
|
+
attr.value = l;
|
|
87
|
+
item.attributes.push(attr);
|
|
88
|
+
}
|
|
89
|
+
player.items.set(`item-${k}`, item);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const timeToMakeChanges = Date.now() - now;
|
|
93
|
+
console.log("time to make changes:", timeToMakeChanges);
|
|
94
|
+
avgTimeToMakeChanges += timeToMakeChanges;
|
|
95
|
+
|
|
96
|
+
now = Date.now();
|
|
97
|
+
encoder.encode();
|
|
98
|
+
encoder.discardChanges();
|
|
99
|
+
|
|
100
|
+
const timeToEncode = Date.now() - now;
|
|
101
|
+
console.log("time to encode:", timeToEncode);
|
|
102
|
+
avgTimeToEncode += timeToEncode;
|
|
103
|
+
}
|
|
104
|
+
console.log("avg time to encode:", (avgTimeToEncode) / total);
|
|
105
|
+
console.log("avg time to make changes:", (avgTimeToMakeChanges) / total);
|
|
106
|
+
console.log("time for all encodes:", Date.now() - allEncodes);
|
|
107
|
+
|
|
108
|
+
console.log(Array.from(encoder.encodeAll()).length, "bytes");
|