@aethervtt/shard-core 0.1.0 → 0.2.1
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/dist/scheduler/capture/capture.d.ts +192 -0
- package/dist/scheduler/capture/capture.js +410 -0
- package/dist/scheduler/capture/index.d.ts +3 -0
- package/dist/scheduler/capture/index.js +17 -0
- package/dist/scheduler/capture/recorder.d.ts +83 -0
- package/dist/scheduler/capture/recorder.js +224 -0
- package/dist/scheduler/capture/samples.d.ts +49 -0
- package/dist/scheduler/capture/samples.js +91 -0
- package/dist/scheduler/index.d.ts +3 -1
- package/dist/scheduler/index.js +29 -2
- package/dist/scheduler/profiler.d.ts +180 -5
- package/dist/scheduler/profiler.js +430 -17
- package/dist/scheduler/schedule.d.ts +5 -1
- package/dist/scheduler/schedule.js +33 -3
- package/dist/test-env.d.ts +95 -1
- package/dist/test-env.js +70 -2
- package/package.json +6 -2
|
@@ -1,22 +1,281 @@
|
|
|
1
|
+
import { ShardError } from "../error.js";
|
|
1
2
|
import { defineResource } from "../schema/resource.js";
|
|
3
|
+
function defineSpan(name) {
|
|
4
|
+
return { name, id: -1 };
|
|
5
|
+
}
|
|
6
|
+
const TRACK = { main: 0, gpu: 1, async: 2, gc: 3, worker: 4 };
|
|
7
|
+
function trackName(track) {
|
|
8
|
+
if (track === TRACK.main) return "main";
|
|
9
|
+
if (track === TRACK.gpu) return "gpu";
|
|
10
|
+
if (track === TRACK.async) return "async";
|
|
11
|
+
if (track === TRACK.gc) return "gc";
|
|
12
|
+
return "worker";
|
|
13
|
+
}
|
|
14
|
+
function spanCovers(key, name) {
|
|
15
|
+
if (name === key) return true;
|
|
16
|
+
const n = key.length;
|
|
17
|
+
if (n === 0 || name.length <= n || !name.startsWith(key)) return false;
|
|
18
|
+
return key.charCodeAt(n - 1) === SLASH || name.charCodeAt(n) === SLASH;
|
|
19
|
+
}
|
|
20
|
+
const SLASH = 47;
|
|
21
|
+
const MAX_SPANS = 65536;
|
|
22
|
+
const MAX_DEPTH = 64;
|
|
23
|
+
const ASYNC_SLOTS = 256;
|
|
24
|
+
const spanNames = [];
|
|
25
|
+
const spanTracks = [];
|
|
26
|
+
const spanIds = /* @__PURE__ */ new Map();
|
|
27
|
+
function internSpan(name, track = defaultTrack(name)) {
|
|
28
|
+
const known = spanIds.get(name);
|
|
29
|
+
if (known !== void 0) return known;
|
|
30
|
+
const id = spanNames.length;
|
|
31
|
+
if (id >= MAX_SPANS) {
|
|
32
|
+
throw new ShardError("perf/too-many-spans", `More than ${MAX_SPANS} span names`, {
|
|
33
|
+
hint: 'Span names are fixed strings (e.g. "terrain/encode"), not per-entity or per-frame text.'
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
spanNames.push(name);
|
|
37
|
+
spanTracks.push(track);
|
|
38
|
+
spanIds.set(name, id);
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
function spanCount() {
|
|
42
|
+
return spanNames.length;
|
|
43
|
+
}
|
|
44
|
+
function spanId(name) {
|
|
45
|
+
return spanIds.get(name) ?? -1;
|
|
46
|
+
}
|
|
47
|
+
function spanName(id) {
|
|
48
|
+
return spanNames[id] ?? `span#${id}`;
|
|
49
|
+
}
|
|
50
|
+
function spanTrack(id) {
|
|
51
|
+
return spanTracks[id] ?? TRACK.main;
|
|
52
|
+
}
|
|
53
|
+
function defaultTrack(name) {
|
|
54
|
+
if (name.startsWith("gpu:")) return TRACK.gpu;
|
|
55
|
+
if (name.startsWith("worker/")) return TRACK.worker;
|
|
56
|
+
if (name === "gc" || name.startsWith("gc/")) return TRACK.gc;
|
|
57
|
+
return TRACK.main;
|
|
58
|
+
}
|
|
59
|
+
function idOf(span, track) {
|
|
60
|
+
if (typeof span === "string") return spanIds.get(span) ?? internSpan(span, track);
|
|
61
|
+
let id = span.id;
|
|
62
|
+
if (id < 0) {
|
|
63
|
+
id = internSpan(span.name, track);
|
|
64
|
+
span.id = id;
|
|
65
|
+
}
|
|
66
|
+
return id;
|
|
67
|
+
}
|
|
68
|
+
const ProfilerSettings = defineResource("core/ProfilerSettings", {
|
|
69
|
+
description: "Turns the profiler (0074) on or off, and how many runs its aggregates cover. Off, spans cost a branch.",
|
|
70
|
+
hostWritable: true,
|
|
71
|
+
init: () => ({ enabled: true, window: 120 })
|
|
72
|
+
});
|
|
73
|
+
const host = globalThis;
|
|
74
|
+
function defaultNow() {
|
|
75
|
+
return host.performance ? host.performance.now() : Date.now();
|
|
76
|
+
}
|
|
77
|
+
let measuredClock;
|
|
78
|
+
function clockInfo() {
|
|
79
|
+
if (measuredClock) return measuredClock;
|
|
80
|
+
let resolution = Number.POSITIVE_INFINITY;
|
|
81
|
+
let prev = defaultNow();
|
|
82
|
+
let steps = 0;
|
|
83
|
+
for (let i = 0; i < 2e6 && steps < 20; i++) {
|
|
84
|
+
const t = defaultNow();
|
|
85
|
+
if (t !== prev) {
|
|
86
|
+
if (t - prev < resolution) resolution = t - prev;
|
|
87
|
+
prev = t;
|
|
88
|
+
steps++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const isolated = host.document === void 0 ? true : host.crossOriginIsolated === true;
|
|
92
|
+
measuredClock = {
|
|
93
|
+
resolutionMs: Number.isFinite(resolution) ? Math.round(resolution * 1e6) / 1e6 : 0,
|
|
94
|
+
isolated
|
|
95
|
+
};
|
|
96
|
+
return measuredClock;
|
|
97
|
+
}
|
|
2
98
|
class Profiler {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
99
|
+
settings;
|
|
100
|
+
/** The clock, in ms. */
|
|
101
|
+
now;
|
|
102
|
+
/** Receives every span while a capture runs. */
|
|
103
|
+
sink;
|
|
104
|
+
/** Gets `perf/span-mismatch` and similar warnings; the app logs them. */
|
|
105
|
+
onWarning;
|
|
106
|
+
/** `Time.frame` of the frame running now (or that ran last). */
|
|
107
|
+
frame = 0;
|
|
108
|
+
/** The last span `end` measured, in ms. */
|
|
109
|
+
lastMs = 0;
|
|
110
|
+
/** What the renderer said about GPU timing: `unavailable` without timestamp queries. */
|
|
111
|
+
gpu = {
|
|
112
|
+
status: "unknown",
|
|
113
|
+
quantized: false
|
|
114
|
+
};
|
|
115
|
+
windowSize;
|
|
116
|
+
series = [];
|
|
117
|
+
stackIds = new Int32Array(MAX_DEPTH);
|
|
118
|
+
stackStarts = new Float64Array(MAX_DEPTH);
|
|
119
|
+
depth = 0;
|
|
120
|
+
frameStartAt = 0;
|
|
121
|
+
warned = new Uint8Array(256);
|
|
122
|
+
asyncIds = new Int32Array(ASYNC_SLOTS).fill(-1);
|
|
123
|
+
asyncKeys = new Array(ASYNC_SLOTS);
|
|
124
|
+
asyncStarts = new Float64Array(ASYNC_SLOTS);
|
|
125
|
+
asyncCount = 0;
|
|
126
|
+
frameSpan = defineSpan("frame");
|
|
127
|
+
constructor(options = {}) {
|
|
128
|
+
const o = typeof options === "number" ? { settings: { enabled: true, window: options } } : options;
|
|
129
|
+
this.settings = o.settings ?? { enabled: true, window: 120 };
|
|
130
|
+
this.now = o.now ?? defaultNow;
|
|
131
|
+
this.windowSize = Math.max(1, this.settings.window | 0);
|
|
132
|
+
}
|
|
133
|
+
get window() {
|
|
134
|
+
return this.windowSize;
|
|
135
|
+
}
|
|
136
|
+
get enabled() {
|
|
137
|
+
return this.settings.enabled;
|
|
7
138
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
139
|
+
// --- spans -------------------------------------------------------------------
|
|
140
|
+
/** Starts a span on the main track. Returns a token for `end`; -1 when the profiler is off. */
|
|
141
|
+
begin(span) {
|
|
142
|
+
if (!this.settings.enabled) return -1;
|
|
143
|
+
const id = span.id >= 0 ? span.id : idOf(span, TRACK.main);
|
|
144
|
+
const d = this.depth;
|
|
145
|
+
if (d >= MAX_DEPTH) {
|
|
146
|
+
this.warn(id, WARN_DEPTH);
|
|
147
|
+
return -1;
|
|
13
148
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
149
|
+
this.stackIds[d] = id;
|
|
150
|
+
this.depth = d + 1;
|
|
151
|
+
this.stackStarts[d] = this.now();
|
|
152
|
+
return d * MAX_SPANS + id;
|
|
153
|
+
}
|
|
154
|
+
/** Ends the span `begin` returned `token` for, recording it. */
|
|
155
|
+
end(token) {
|
|
156
|
+
if (token < 0) return;
|
|
157
|
+
const at = this.now();
|
|
158
|
+
const d = token / MAX_SPANS | 0;
|
|
159
|
+
const id = token - d * MAX_SPANS;
|
|
160
|
+
if (d !== this.depth - 1 || this.stackIds[d] !== id) {
|
|
161
|
+
this.warn(id, WARN_ORDER);
|
|
162
|
+
if (d >= this.depth || this.stackIds[d] !== id) return;
|
|
163
|
+
}
|
|
164
|
+
this.depth = d;
|
|
165
|
+
const start = this.stackStarts[d];
|
|
166
|
+
const ms = at - start;
|
|
167
|
+
this.lastMs = ms;
|
|
168
|
+
this.push(id, ms);
|
|
169
|
+
const sink = this.sink;
|
|
170
|
+
if (sink !== void 0) sink.event(id, TRACK.main, d, start, ms, -1);
|
|
17
171
|
}
|
|
172
|
+
/** Drops the span `token` and everything begun inside it, recording nothing (an exception unwound it). */
|
|
173
|
+
cancel(token) {
|
|
174
|
+
if (token < 0) return;
|
|
175
|
+
const d = token / MAX_SPANS | 0;
|
|
176
|
+
if (d < this.depth) this.depth = d;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Time measured elsewhere (a GPU pass, a worker job): its aggregate, and a timeline event on
|
|
180
|
+
* `track` that ended now, or started at `start`. `frame`: the frame it belongs to, when it lands
|
|
181
|
+
* later (GPU timings).
|
|
182
|
+
*/
|
|
183
|
+
record(span, ms, track, start, frame = -1) {
|
|
184
|
+
if (!this.settings.enabled) return;
|
|
185
|
+
const id = idOf(span, track);
|
|
186
|
+
this.push(id, ms);
|
|
187
|
+
const sink = this.sink;
|
|
188
|
+
if (sink !== void 0) {
|
|
189
|
+
const t = track ?? spanTracks[id];
|
|
190
|
+
sink.event(id, t, t === TRACK.main ? this.depth : 0, start ?? this.now() - ms, ms, frame);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Only the aggregate (`render/<node>` sums its views into one sample a frame). */
|
|
194
|
+
sample(span, ms) {
|
|
195
|
+
if (!this.settings.enabled) return;
|
|
196
|
+
this.push(idOf(span), ms);
|
|
197
|
+
}
|
|
198
|
+
/** Only the timeline event, when a capture runs. */
|
|
199
|
+
event(span, track, start, ms, frame = -1) {
|
|
200
|
+
const sink = this.sink;
|
|
201
|
+
if (sink === void 0 || !this.settings.enabled) return;
|
|
202
|
+
sink.event(idOf(span, track), track, track === TRACK.main ? this.depth : 0, start, ms, frame);
|
|
203
|
+
}
|
|
204
|
+
/** Whether a capture is listening (callers skip work that only feeds the timeline). */
|
|
205
|
+
get capturing() {
|
|
206
|
+
return this.sink !== void 0;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Work that starts in one frame and ends in another (an asset load, a page fetch): an async span,
|
|
210
|
+
* matched to its `endAsync` by span and key.
|
|
211
|
+
*/
|
|
212
|
+
beginAsync(span, key) {
|
|
213
|
+
if (!this.settings.enabled) return;
|
|
214
|
+
const id = span.id >= 0 ? span.id : idOf(span, TRACK.async);
|
|
215
|
+
if (this.asyncCount >= ASYNC_SLOTS) {
|
|
216
|
+
this.warn(id, WARN_ASYNC);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (let i = 0; i < ASYNC_SLOTS; i++) {
|
|
220
|
+
if (this.asyncIds[i] !== -1) continue;
|
|
221
|
+
this.asyncIds[i] = id;
|
|
222
|
+
this.asyncKeys[i] = key;
|
|
223
|
+
this.asyncStarts[i] = this.now();
|
|
224
|
+
this.asyncCount++;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
endAsync(span, key) {
|
|
229
|
+
if (!this.settings.enabled || span.id < 0 || this.asyncCount === 0) return;
|
|
230
|
+
const id = span.id;
|
|
231
|
+
for (let i = 0; i < ASYNC_SLOTS; i++) {
|
|
232
|
+
if (this.asyncIds[i] !== id || this.asyncKeys[i] !== key) continue;
|
|
233
|
+
const start = this.asyncStarts[i];
|
|
234
|
+
const ms = this.now() - start;
|
|
235
|
+
this.asyncIds[i] = -1;
|
|
236
|
+
this.asyncKeys[i] = void 0;
|
|
237
|
+
this.asyncCount--;
|
|
238
|
+
this.push(id, ms);
|
|
239
|
+
const sink = this.sink;
|
|
240
|
+
if (sink !== void 0) sink.event(id, TRACK.async, 0, start, ms, -1);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// --- frames ------------------------------------------------------------------
|
|
245
|
+
/**
|
|
246
|
+
* A frame starts: spans left open from before are dropped (and reported once), and the `frame`
|
|
247
|
+
* span begins. Returns its token for `endFrame`.
|
|
248
|
+
*/
|
|
249
|
+
beginFrame(frame) {
|
|
250
|
+
if (!this.settings.enabled) return -1;
|
|
251
|
+
if (this.depth > 0) {
|
|
252
|
+
const id = this.stackIds[this.depth - 1];
|
|
253
|
+
this.warn(id, WARN_OPEN_AT_START);
|
|
254
|
+
this.depth = 0;
|
|
255
|
+
}
|
|
256
|
+
if (this.settings.window !== this.windowSize) this.resize();
|
|
257
|
+
this.frame = frame;
|
|
258
|
+
const token = this.begin(this.frameSpan);
|
|
259
|
+
this.frameStartAt = this.stackStarts[0];
|
|
260
|
+
const sink = this.sink;
|
|
261
|
+
if (sink !== void 0) sink.frameStart(frame, this.frameStartAt);
|
|
262
|
+
return token;
|
|
263
|
+
}
|
|
264
|
+
endFrame(token) {
|
|
265
|
+
if (token < 0) return;
|
|
266
|
+
if (this.depth > 1) {
|
|
267
|
+
const id = this.stackIds[this.depth - 1];
|
|
268
|
+
this.warn(id, WARN_OPEN_AT_END);
|
|
269
|
+
this.depth = 1;
|
|
270
|
+
}
|
|
271
|
+
this.end(token);
|
|
272
|
+
const sink = this.sink;
|
|
273
|
+
if (sink !== void 0) sink.frameEnd(this.frame, this.lastMs);
|
|
274
|
+
}
|
|
275
|
+
// --- reading -----------------------------------------------------------------
|
|
18
276
|
timing(name) {
|
|
19
|
-
const
|
|
277
|
+
const id = spanIds.get(name);
|
|
278
|
+
const series = id === void 0 ? void 0 : this.series[id];
|
|
20
279
|
if (!series || series.count === 0) return void 0;
|
|
21
280
|
let sum = 0;
|
|
22
281
|
let max = 0;
|
|
@@ -25,20 +284,174 @@ class Profiler {
|
|
|
25
284
|
sum += v;
|
|
26
285
|
if (v > max) max = v;
|
|
27
286
|
}
|
|
28
|
-
const last = series.samples[(series.cursor - 1 + this.
|
|
287
|
+
const last = series.samples[(series.cursor - 1 + this.windowSize) % this.windowSize];
|
|
29
288
|
return { last, avg: sum / series.count, max, samples: series.count };
|
|
30
289
|
}
|
|
290
|
+
/** A span's samples over the window, in ring order (not by age); undefined if it never ran. */
|
|
291
|
+
samples(name) {
|
|
292
|
+
const id = spanIds.get(name);
|
|
293
|
+
const series = id === void 0 ? void 0 : this.series[id];
|
|
294
|
+
return series?.count ? series.samples.subarray(0, series.count) : void 0;
|
|
295
|
+
}
|
|
296
|
+
/** Every span that ran, by name. */
|
|
31
297
|
all() {
|
|
32
298
|
const out = {};
|
|
33
|
-
for (
|
|
299
|
+
for (let id = 0; id < this.series.length; id++) {
|
|
300
|
+
if (!this.series[id]?.count) continue;
|
|
301
|
+
const name = spanNames[id];
|
|
302
|
+
out[name] = this.timing(name);
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* How many samples a span has had since the profiler started (or its aggregates were reset), by
|
|
308
|
+
* id: 0 if none. Allocates nothing, for per-frame readers such as feature budgets (0075).
|
|
309
|
+
*/
|
|
310
|
+
runsOf(id) {
|
|
311
|
+
return this.series[id]?.total ?? 0;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The span that was open around a span's latest sample (its parent on the main track), by id, or
|
|
315
|
+
* -1: what lets aggregates count time two covering keys share once (0075's slices).
|
|
316
|
+
*/
|
|
317
|
+
parentOf(id) {
|
|
318
|
+
return this.series[id]?.parent ?? -1;
|
|
319
|
+
}
|
|
320
|
+
/** A span's latest sample in ms, by id, or 0 if it has none. Allocates nothing. */
|
|
321
|
+
lastOf(id) {
|
|
322
|
+
const series = this.series[id];
|
|
323
|
+
if (series === void 0 || series.count === 0) return 0;
|
|
324
|
+
return series.samples[(series.cursor - 1 + this.windowSize) % this.windowSize];
|
|
325
|
+
}
|
|
326
|
+
/** Names of every span with samples. */
|
|
327
|
+
names() {
|
|
328
|
+
const out = [];
|
|
329
|
+
for (let id = 0; id < this.series.length; id++)
|
|
330
|
+
if (this.series[id]?.count) out.push(spanNames[id]);
|
|
34
331
|
return out;
|
|
35
332
|
}
|
|
333
|
+
/** Forgets every aggregate (`perf.reset`). */
|
|
334
|
+
reset() {
|
|
335
|
+
for (const series of this.series) {
|
|
336
|
+
if (!series) continue;
|
|
337
|
+
series.count = 0;
|
|
338
|
+
series.cursor = 0;
|
|
339
|
+
series.total = 0;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// --- internals ---------------------------------------------------------------
|
|
343
|
+
push(id, ms) {
|
|
344
|
+
let series = this.series[id];
|
|
345
|
+
if (series === void 0) series = this.createSeries(id);
|
|
346
|
+
series.samples[series.cursor] = ms;
|
|
347
|
+
series.cursor = series.cursor + 1 === this.windowSize ? 0 : series.cursor + 1;
|
|
348
|
+
if (series.count < this.windowSize) series.count++;
|
|
349
|
+
series.total++;
|
|
350
|
+
series.parent = this.depth > 0 ? this.stackIds[this.depth - 1] : -1;
|
|
351
|
+
}
|
|
352
|
+
createSeries(id) {
|
|
353
|
+
while (this.series.length <= id) this.series.push(void 0);
|
|
354
|
+
const series = {
|
|
355
|
+
samples: new Float64Array(this.windowSize),
|
|
356
|
+
cursor: 0,
|
|
357
|
+
count: 0,
|
|
358
|
+
total: 0,
|
|
359
|
+
parent: -1
|
|
360
|
+
};
|
|
361
|
+
this.series[id] = series;
|
|
362
|
+
return series;
|
|
363
|
+
}
|
|
364
|
+
resize() {
|
|
365
|
+
this.windowSize = Math.max(1, this.settings.window | 0);
|
|
366
|
+
for (let id = 0; id < this.series.length; id++) {
|
|
367
|
+
if (this.series[id])
|
|
368
|
+
this.series[id] = {
|
|
369
|
+
samples: new Float64Array(this.windowSize),
|
|
370
|
+
cursor: 0,
|
|
371
|
+
count: 0,
|
|
372
|
+
total: this.series[id].total,
|
|
373
|
+
parent: this.series[id].parent
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Reports a problem once per span name. */
|
|
378
|
+
warn(id, kind) {
|
|
379
|
+
if (id >= this.warned.length) {
|
|
380
|
+
const grown = new Uint8Array(Math.max(id + 1, this.warned.length * 2));
|
|
381
|
+
grown.set(this.warned);
|
|
382
|
+
this.warned = grown;
|
|
383
|
+
}
|
|
384
|
+
if (this.warned[id]) return;
|
|
385
|
+
this.warned[id] = 1;
|
|
386
|
+
this.onWarning?.(warningOf(kind, spanName(id)));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const WARN_ORDER = 0;
|
|
390
|
+
const WARN_OPEN_AT_START = 1;
|
|
391
|
+
const WARN_OPEN_AT_END = 2;
|
|
392
|
+
const WARN_DEPTH = 3;
|
|
393
|
+
const WARN_ASYNC = 4;
|
|
394
|
+
const MISMATCH = [
|
|
395
|
+
"ended out of order: spans end in the reverse order they began",
|
|
396
|
+
"was still open when a frame began",
|
|
397
|
+
"was still open when its frame ended"
|
|
398
|
+
];
|
|
399
|
+
function warningOf(kind, name) {
|
|
400
|
+
if (kind === WARN_DEPTH) {
|
|
401
|
+
return new ShardError(
|
|
402
|
+
"perf/span-overflow",
|
|
403
|
+
`Spans nest deeper than ${MAX_DEPTH} at "${name}"`,
|
|
404
|
+
{
|
|
405
|
+
hint: "A span that begins in a loop needs its end inside the loop too.",
|
|
406
|
+
path: name
|
|
407
|
+
}
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
if (kind === WARN_ASYNC) {
|
|
411
|
+
return new ShardError("perf/async-overflow", `More than ${ASYNC_SLOTS} async spans are open`, {
|
|
412
|
+
hint: "Every beginAsync(span, key) needs an endAsync(span, key), even when the work fails.",
|
|
413
|
+
path: name
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return new ShardError("perf/span-mismatch", `Span "${name}" ${MISMATCH[kind]}`, {
|
|
417
|
+
hint: "Every profiler.begin(span) needs one profiler.end(token), in the same frame.",
|
|
418
|
+
path: name
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
function spanStats(profiler, name) {
|
|
422
|
+
const timing = profiler.timing(name);
|
|
423
|
+
const samples = profiler.samples(name);
|
|
424
|
+
if (!timing || !samples) return void 0;
|
|
425
|
+
const sorted = Float64Array.from(samples).sort();
|
|
426
|
+
const at = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
|
|
427
|
+
const out = {
|
|
428
|
+
span: name,
|
|
429
|
+
track: trackName(spanTrack(spanIds.get(name))),
|
|
430
|
+
...timing,
|
|
431
|
+
p50: at(0.5),
|
|
432
|
+
p95: at(0.95)
|
|
433
|
+
};
|
|
434
|
+
const resolution = clockInfo().resolutionMs;
|
|
435
|
+
if (out.track !== "gpu" && resolution > 0 && timing.avg < 4 * resolution) out.coarse = true;
|
|
436
|
+
return out;
|
|
36
437
|
}
|
|
37
438
|
const ProfilerResource = defineResource("core/Profiler", {
|
|
38
|
-
description: "
|
|
439
|
+
description: "Span timings (0074): systems, schedules, frames, render passes, GPU and workers.",
|
|
39
440
|
init: () => new Profiler()
|
|
40
441
|
});
|
|
41
442
|
export {
|
|
42
443
|
Profiler,
|
|
43
|
-
ProfilerResource
|
|
444
|
+
ProfilerResource,
|
|
445
|
+
ProfilerSettings,
|
|
446
|
+
TRACK,
|
|
447
|
+
clockInfo,
|
|
448
|
+
defineSpan,
|
|
449
|
+
internSpan,
|
|
450
|
+
spanCount,
|
|
451
|
+
spanCovers,
|
|
452
|
+
spanId,
|
|
453
|
+
spanName,
|
|
454
|
+
spanStats,
|
|
455
|
+
spanTrack,
|
|
456
|
+
trackName
|
|
44
457
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { World } from '../ecs/world.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type Profiler } from './profiler.js';
|
|
3
3
|
import { SystemConfig, type SystemDef, type SystemSetConfig } from './system.js';
|
|
4
4
|
export interface ScheduleLabel {
|
|
5
5
|
readonly kind: 'schedule';
|
|
@@ -33,6 +33,10 @@ export interface SystemDescription {
|
|
|
33
33
|
*/
|
|
34
34
|
export declare class Schedule {
|
|
35
35
|
readonly label: ScheduleLabel;
|
|
36
|
+
/** `schedule/<Label>`: one run of the schedule (0074). */
|
|
37
|
+
private readonly span;
|
|
38
|
+
/** `commands/<Label>`: command application over one run. */
|
|
39
|
+
private readonly commandsSpan;
|
|
36
40
|
private readonly entries;
|
|
37
41
|
private readonly setConfigs;
|
|
38
42
|
private order;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Commands } from "../ecs/commands.js";
|
|
2
2
|
import { ShardError } from "../error.js";
|
|
3
|
+
import { defineSpan, TRACK } from "./profiler.js";
|
|
3
4
|
import {
|
|
4
5
|
conditionLabel,
|
|
5
6
|
SystemConfig
|
|
@@ -35,16 +36,23 @@ class Context {
|
|
|
35
36
|
}
|
|
36
37
|
class Schedule {
|
|
37
38
|
label;
|
|
39
|
+
/** `schedule/<Label>`: one run of the schedule (0074). */
|
|
40
|
+
span;
|
|
41
|
+
/** `commands/<Label>`: command application over one run. */
|
|
42
|
+
commandsSpan;
|
|
38
43
|
entries = [];
|
|
39
44
|
setConfigs = [];
|
|
40
45
|
order;
|
|
41
46
|
constructor(label) {
|
|
42
47
|
this.label = label;
|
|
48
|
+
this.span = defineSpan(`schedule/${label.name}`);
|
|
49
|
+
this.commandsSpan = defineSpan(`commands/${label.name}`);
|
|
43
50
|
}
|
|
44
51
|
add(system) {
|
|
45
52
|
const config = system instanceof SystemConfig ? system : new SystemConfig(system);
|
|
46
53
|
this.entries.push({
|
|
47
54
|
config,
|
|
55
|
+
span: defineSpan(config.system.name),
|
|
48
56
|
index: this.entries.length,
|
|
49
57
|
state: void 0,
|
|
50
58
|
ctx: void 0,
|
|
@@ -77,6 +85,9 @@ class Schedule {
|
|
|
77
85
|
}
|
|
78
86
|
run(world, options) {
|
|
79
87
|
const order = this.resolve();
|
|
88
|
+
const profiler = options.profiler;
|
|
89
|
+
const scheduleToken = profiler !== void 0 ? profiler.begin(this.span) : -1;
|
|
90
|
+
let commandsMs = 0;
|
|
80
91
|
for (let i = 0; i < order.length; i++) {
|
|
81
92
|
const entry = order[i];
|
|
82
93
|
const conditions = entry.conditions;
|
|
@@ -95,10 +106,11 @@ class Schedule {
|
|
|
95
106
|
}
|
|
96
107
|
const ctx = entry.ctx;
|
|
97
108
|
ctx.thisRunTick = world.incrementTick();
|
|
98
|
-
const
|
|
109
|
+
const token = profiler !== void 0 ? profiler.begin(entry.span) : -1;
|
|
99
110
|
try {
|
|
100
111
|
system.run(entry.state, world, ctx);
|
|
101
112
|
} catch (err) {
|
|
113
|
+
profiler?.cancel(scheduleToken);
|
|
102
114
|
const message = err instanceof Error ? err.message : String(err);
|
|
103
115
|
const code = err instanceof ShardError ? ` [${err.code}]` : "";
|
|
104
116
|
throw new ShardError(
|
|
@@ -110,10 +122,28 @@ class Schedule {
|
|
|
110
122
|
}
|
|
111
123
|
);
|
|
112
124
|
}
|
|
113
|
-
|
|
125
|
+
if (profiler !== void 0) profiler.end(token);
|
|
114
126
|
ctx.lastRunTick = ctx.thisRunTick;
|
|
115
127
|
world.incrementTick();
|
|
116
|
-
if (ctx.commands.length > 0)
|
|
128
|
+
if (ctx.commands.length > 0) {
|
|
129
|
+
if (profiler === void 0) ctx.commands.apply();
|
|
130
|
+
else {
|
|
131
|
+
const start = profiler.now();
|
|
132
|
+
try {
|
|
133
|
+
ctx.commands.apply();
|
|
134
|
+
} catch (err) {
|
|
135
|
+
profiler.cancel(scheduleToken);
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
const ms = profiler.now() - start;
|
|
139
|
+
commandsMs += ms;
|
|
140
|
+
profiler.event(this.commandsSpan, TRACK.main, start, ms);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (profiler !== void 0) {
|
|
145
|
+
profiler.sample(this.commandsSpan, commandsMs);
|
|
146
|
+
profiler.end(scheduleToken);
|
|
117
147
|
}
|
|
118
148
|
}
|
|
119
149
|
describe() {
|
package/dist/test-env.d.ts
CHANGED
|
@@ -9,12 +9,106 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Write every timing assertion through `budget()`, `slack` or `allocationChecks`, and it lands in
|
|
11
11
|
* the right tier.
|
|
12
|
+
*
|
|
13
|
+
* Budgets are data (spec 0075): `bench/perf/budgets.json` holds a number per key and named machine
|
|
14
|
+
* (`bench/perf/machines.json`), and `budget(key)` reads it. Core can't read files, so the numbers
|
|
15
|
+
* arrive resolved, as JSON in the `SHARD_BUDGETS` environment variable:
|
|
16
|
+
*
|
|
17
|
+
* - `pnpm bench` (`scripts/bench.mjs`) detects the machine, resolves every key for it, and passes
|
|
18
|
+
* the result (`ResolvedBudgets`) to the test processes. On an unknown machine it passes the
|
|
19
|
+
* closest machine's numbers with `enforce: false`: budgets stay unlimited, and the report
|
|
20
|
+
* (`bench/perf/report.json`) compares against them.
|
|
21
|
+
* - `pnpm test` gets the keys with no machine from `testFiles()` (`scripts/test-shard.mjs`), so an
|
|
22
|
+
* unknown key fails there too (`perf/unknown-budget`) while every budget is unlimited.
|
|
23
|
+
*
|
|
24
|
+
* Under `pnpm bench`, `SHARD_PERF_REPORT` names a folder: each `budget(key)` call is noted on
|
|
25
|
+
* `globalThis.__shardBudget`, and `scripts/perf-report.setup.mjs` records the value the next
|
|
26
|
+
* comparison matcher (`toBeLessThan` and the like) checks against it.
|
|
12
27
|
*/
|
|
13
28
|
export type TimingMode = 'bench' | 'test' | 'ci';
|
|
14
29
|
export declare const timingMode: TimingMode;
|
|
15
30
|
/** The factor budgets are multiplied by (and rate floors divided by): 1 under `pnpm bench`, else ∞. */
|
|
16
31
|
export declare const slack: number;
|
|
17
|
-
/**
|
|
32
|
+
/** One key of `budgets.json`, resolved for a machine. `value` is null without one. */
|
|
33
|
+
export interface ResolvedSpan {
|
|
34
|
+
kind: 'target' | 'unit' | 'rate' | 'guard';
|
|
35
|
+
value: number | null;
|
|
36
|
+
/** Guards: the limit is `value × (1 + margin)`. */
|
|
37
|
+
margin?: number;
|
|
38
|
+
/** Units and rates: what one item is ("prop", "point"). */
|
|
39
|
+
per?: string;
|
|
40
|
+
/** What the number measures when it isn't ms: 'frames', 'ratio'. */
|
|
41
|
+
in?: string;
|
|
42
|
+
}
|
|
43
|
+
export type Track = 'gpu' | 'cpu';
|
|
44
|
+
export interface ResolvedScenario {
|
|
45
|
+
/** The frame budget per track, in ms. */
|
|
46
|
+
frame: Record<Track, number | null>;
|
|
47
|
+
/** Each slice in ms (its share of the frame, or the machine's override). */
|
|
48
|
+
slices: Record<Track, Record<string, number | null>>;
|
|
49
|
+
}
|
|
50
|
+
/** What `SHARD_BUDGETS` carries. */
|
|
51
|
+
export interface ResolvedBudgets {
|
|
52
|
+
/** The machine the numbers are for; null outside `pnpm bench`. */
|
|
53
|
+
machine: string | null;
|
|
54
|
+
/** False on an unknown machine and outside `pnpm bench`: every budget is unlimited. */
|
|
55
|
+
enforce: boolean;
|
|
56
|
+
spans: Record<string, ResolvedSpan>;
|
|
57
|
+
scenarios: Record<string, ResolvedScenario>;
|
|
58
|
+
/**
|
|
59
|
+
* How the machine checks GPU slices (machines.json): per-pass timestamps, or ablation where
|
|
60
|
+
* passes overlap (tile-based GPUs). Absent outside `pnpm bench`.
|
|
61
|
+
*/
|
|
62
|
+
passTiming?: 'ablation' | 'timestamps' | null;
|
|
63
|
+
}
|
|
64
|
+
export interface BudgetOptions {
|
|
65
|
+
/** Unit costs: how many items the measurement covered (the budget is the unit cost × this). */
|
|
66
|
+
count?: number;
|
|
67
|
+
/** Scenarios: a slice's budget instead of the whole frame's. */
|
|
68
|
+
slice?: string;
|
|
69
|
+
/** Scenarios: 'gpu' or 'cpu'. Default: 'gpu' for a `gpu:` slice, else 'cpu'. */
|
|
70
|
+
track?: Track;
|
|
71
|
+
}
|
|
72
|
+
/** A key's number on the resolved machine, and the limit a test checks: ∞ (0 for rates) when off. */
|
|
73
|
+
export interface ResolvedLimit {
|
|
74
|
+
/** What the test compares against. */
|
|
75
|
+
limit: number;
|
|
76
|
+
/** The machine's number for the comparison (null without one), enforced or not. */
|
|
77
|
+
reference: number | null;
|
|
78
|
+
kind: ResolvedSpan['kind'] | 'slice';
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The limit for `key` from resolved budgets: a span's number (a unit's times `count`, a guard's
|
|
82
|
+
* plus its margin), or a scenario's frame or slice in ms. Unlimited unless `enforce`: ∞, or 0 for a
|
|
83
|
+
* rate (a floor). Throws `perf/unknown-budget` for a key, scenario or slice budgets.json lacks.
|
|
84
|
+
*/
|
|
85
|
+
export declare function resolveLimit(budgets: ResolvedBudgets | undefined, key: string, opts?: BudgetOptions, enforce?: boolean): ResolvedLimit;
|
|
86
|
+
/** The last `budget(key)` call, for `scripts/perf-report.setup.mjs` (under `pnpm bench` only). */
|
|
87
|
+
export interface BudgetCall extends ResolvedLimit {
|
|
88
|
+
key: string;
|
|
89
|
+
opts: BudgetOptions;
|
|
90
|
+
used: boolean;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A scenario's slice keys per track, `headroom` aside (0075): what a scenario test measures. Throws
|
|
94
|
+
* `perf/unknown-budget` for a scenario budgets.json lacks, in `pnpm test` too.
|
|
95
|
+
*/
|
|
96
|
+
export declare function scenarioSlices(name: string): Record<Track, string[]>;
|
|
97
|
+
/**
|
|
98
|
+
* How GPU slices are measured on this machine (0075): `'ablation'` under `pnpm bench` on a machine
|
|
99
|
+
* whose passes overlap (machines.json's `passTiming`), else `'timestamps'`.
|
|
100
|
+
*/
|
|
101
|
+
export declare function passTiming(): 'ablation' | 'timestamps';
|
|
102
|
+
/**
|
|
103
|
+
* A time budget from `bench/perf/budgets.json`: the detected machine's number under `pnpm bench`,
|
|
104
|
+
* unlimited in `pnpm test`, CI, and on an unknown machine (∞; 0 for a rate, which is a floor).
|
|
105
|
+
* `{ count }` scales a unit cost; `{ slice, track }` reads a scenario's slice. An unknown key throws
|
|
106
|
+
* `perf/unknown-budget`.
|
|
107
|
+
*
|
|
108
|
+
* `budget(ms)`, a literal, still works (exact under `pnpm bench`): `scripts/budget-literals.mjs`
|
|
109
|
+
* lists those calls, and the goal is none.
|
|
110
|
+
*/
|
|
111
|
+
export declare function budget(key: string, opts?: BudgetOptions): number;
|
|
18
112
|
export declare function budget(ms: number): number;
|
|
19
113
|
/** A test or hook timeout in ms: 5× in CI, unchanged everywhere else. */
|
|
20
114
|
export declare function timeout(ms: number): number;
|