@aethervtt/shard-runtime 0.0.1 → 0.2.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/dist/app.d.ts +145 -1
- package/dist/app.js +333 -17
- package/dist/budgets.d.ts +197 -0
- package/dist/budgets.js +256 -0
- package/dist/control.d.ts +2 -0
- package/dist/control.js +7 -1
- package/dist/demand.d.ts +47 -0
- package/dist/demand.js +68 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +55 -1
- package/dist/perf.d.ts +157 -0
- package/dist/perf.js +245 -0
- package/dist/plugin.d.ts +12 -0
- package/dist/runners.d.ts +17 -2
- package/dist/runners.js +130 -48
- package/dist/scenario.d.ts +9 -0
- package/dist/scenario.js +9 -0
- package/dist/testing.d.ts +22 -0
- package/dist/testing.js +64 -0
- package/dist/time.d.ts +4 -0
- package/dist/time.js +5 -0
- package/dist/write-check.d.ts +22 -0
- package/dist/write-check.js +54 -0
- package/package.json +7 -3
package/dist/budgets.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineResource,
|
|
3
|
+
ProfilerResource,
|
|
4
|
+
ShardError,
|
|
5
|
+
spanCovers,
|
|
6
|
+
spanId,
|
|
7
|
+
spanName,
|
|
8
|
+
spanStats
|
|
9
|
+
} from "@aethervtt/shard-core";
|
|
10
|
+
import { PerfProviders } from "./perf.js";
|
|
11
|
+
import { PerfScenario } from "./scenario.js";
|
|
12
|
+
const PERF_TRACKS = ["gpu", "cpu"];
|
|
13
|
+
const PerfBudgets = defineResource("runtime/PerfBudgets", {
|
|
14
|
+
description: "budgets.json and machines.json (0075), as the host loaded them, with the CPU model and a machine override."
|
|
15
|
+
});
|
|
16
|
+
const has = (text, part) => !!part && (text ?? "").toLowerCase().includes(part.toLowerCase());
|
|
17
|
+
function backendOf(description = "") {
|
|
18
|
+
const d = description.toLowerCase();
|
|
19
|
+
if (d.includes("metal")) return "metal";
|
|
20
|
+
if (d.includes("d3d12")) return "d3d12";
|
|
21
|
+
if (d.includes("d3d11")) return "d3d11";
|
|
22
|
+
if (d.includes("vulkan")) return "vulkan";
|
|
23
|
+
if (d.includes("opengl")) return "opengl";
|
|
24
|
+
return void 0;
|
|
25
|
+
}
|
|
26
|
+
function detectPerfMachine(machines, o = {}) {
|
|
27
|
+
const names = Object.keys(machines.machines);
|
|
28
|
+
const gpuText = o.adapter ? [o.adapter.vendor, o.adapter.architecture, o.adapter.device, o.adapter.description].join(" ") : "";
|
|
29
|
+
const backend = backendOf(o.adapter?.description);
|
|
30
|
+
let best = names[0] ?? null;
|
|
31
|
+
let bestScore = -1;
|
|
32
|
+
let detected = null;
|
|
33
|
+
for (const name of names) {
|
|
34
|
+
const m = machines.machines[name];
|
|
35
|
+
const cpu = has(o.cpu, m.cpu);
|
|
36
|
+
const gpu = has(gpuText, m.gpu);
|
|
37
|
+
const score = (cpu ? 2 : 0) + (gpu ? 2 : 0) + (backend && backend === m.backend ? 1 : 0);
|
|
38
|
+
if (score > bestScore) {
|
|
39
|
+
bestScore = score;
|
|
40
|
+
best = name;
|
|
41
|
+
}
|
|
42
|
+
if (cpu && gpu && detected === null) detected = name;
|
|
43
|
+
}
|
|
44
|
+
const warnings = [];
|
|
45
|
+
if (o.override) {
|
|
46
|
+
if (!names.includes(o.override)) {
|
|
47
|
+
warnings.push({
|
|
48
|
+
code: "perf/unknown-machine",
|
|
49
|
+
message: `Machine "${o.override}" isn't in machines.json (${names.join(", ")})`
|
|
50
|
+
});
|
|
51
|
+
return { machine: null, source: "unknown", closest: best, warnings };
|
|
52
|
+
}
|
|
53
|
+
if (detected && detected !== o.override) {
|
|
54
|
+
warnings.push({
|
|
55
|
+
code: "perf/machine-mismatch",
|
|
56
|
+
message: `The override says "${o.override}", but this looks like "${detected}"`
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return { machine: o.override, source: "override", closest: o.override, warnings };
|
|
60
|
+
}
|
|
61
|
+
if (detected) return { machine: detected, source: "detected", closest: detected, warnings };
|
|
62
|
+
warnings.push({
|
|
63
|
+
code: "perf/unknown-machine",
|
|
64
|
+
message: o.cpu ? `No machine in machines.json matches (CPU "${o.cpu}", GPU "${gpuText.trim() || "unknown"}")` : `This host doesn't expose its CPU model, so it can't tell which machine it is (GPU "${gpuText.trim() || "unknown"}")`,
|
|
65
|
+
hint: "Name it: PerfBudgets.machine (SHARD_MACHINE in Node, ?machine= on playground pages)."
|
|
66
|
+
});
|
|
67
|
+
return { machine: null, source: "unknown", closest: best, warnings };
|
|
68
|
+
}
|
|
69
|
+
function resolvePerfScenario(scenario, machine) {
|
|
70
|
+
const frame = { gpu: null, cpu: null };
|
|
71
|
+
const slices = { gpu: {}, cpu: {} };
|
|
72
|
+
const shares = { gpu: {}, cpu: {} };
|
|
73
|
+
for (const track of PERF_TRACKS) {
|
|
74
|
+
const ms = machine !== null ? scenario.frame?.[track]?.[machine] : void 0;
|
|
75
|
+
frame[track] = typeof ms === "number" ? ms : null;
|
|
76
|
+
const overrides = machine !== null ? scenario.overrides?.[machine]?.[track] : void 0;
|
|
77
|
+
for (const [slice, share] of Object.entries(scenario.slices?.[track] ?? {})) {
|
|
78
|
+
shares[track][slice] = share;
|
|
79
|
+
const absolute = overrides?.[slice];
|
|
80
|
+
slices[track][slice] = typeof absolute === "number" ? absolute : frame[track] === null ? null : frame[track] * share;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { frame, slices, shares };
|
|
84
|
+
}
|
|
85
|
+
const stateCache = /* @__PURE__ */ new WeakMap();
|
|
86
|
+
function perfBudgetState(world, adapter) {
|
|
87
|
+
const data = world.tryResource(PerfBudgets);
|
|
88
|
+
if (!data?.budgets || !data.machines) return void 0;
|
|
89
|
+
adapter ??= world.tryResource(PerfProviders)?.adapter?.(world);
|
|
90
|
+
const key = `${data.cpu ?? ""}|${data.machine ?? ""}|${adapter ? JSON.stringify(adapter) : ""}`;
|
|
91
|
+
const cached = stateCache.get(world);
|
|
92
|
+
if (cached && cached.data === data && cached.key === key) return cached.state;
|
|
93
|
+
const detection = detectPerfMachine(data.machines, {
|
|
94
|
+
cpu: data.cpu,
|
|
95
|
+
adapter,
|
|
96
|
+
override: data.machine
|
|
97
|
+
});
|
|
98
|
+
const state = { data, detection };
|
|
99
|
+
stateCache.set(world, { key, data, state });
|
|
100
|
+
return state;
|
|
101
|
+
}
|
|
102
|
+
function scenarioSliceMs(world, scenario, track, slice, adapter) {
|
|
103
|
+
const state = perfBudgetState(world, adapter);
|
|
104
|
+
const entry = state?.data.budgets.scenarios[scenario];
|
|
105
|
+
if (!state || !entry || state.detection.machine === null) return void 0;
|
|
106
|
+
const ms = resolvePerfScenario(entry, state.detection.machine).slices[track][slice];
|
|
107
|
+
return ms ?? void 0;
|
|
108
|
+
}
|
|
109
|
+
function coveredMs(profiler, key) {
|
|
110
|
+
let ms = 0;
|
|
111
|
+
let any = false;
|
|
112
|
+
for (const name of profiler.names()) {
|
|
113
|
+
if (!spanCovers(key, name)) continue;
|
|
114
|
+
const timing = profiler.timing(name);
|
|
115
|
+
if (!timing) continue;
|
|
116
|
+
any = true;
|
|
117
|
+
let parent = profiler.parentOf(spanId(name));
|
|
118
|
+
let inside = false;
|
|
119
|
+
for (let depth = 0; parent >= 0 && depth < 64; depth++) {
|
|
120
|
+
if (spanCovers(key, spanName(parent))) {
|
|
121
|
+
inside = true;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
parent = profiler.parentOf(parent);
|
|
125
|
+
}
|
|
126
|
+
if (!inside) ms += timing.avg;
|
|
127
|
+
}
|
|
128
|
+
return any ? ms : void 0;
|
|
129
|
+
}
|
|
130
|
+
function measureSlices(profiler, scenario, machine) {
|
|
131
|
+
const resolved = resolvePerfScenario(scenario, machine);
|
|
132
|
+
const r3 = (ms) => Math.round(ms * 1e3) / 1e3;
|
|
133
|
+
const frame = {};
|
|
134
|
+
const slices = [];
|
|
135
|
+
for (const track of PERF_TRACKS) {
|
|
136
|
+
const frameMs = profiler.timing(track === "gpu" ? "gpu:frame" : "frame")?.avg ?? null;
|
|
137
|
+
frame[track] = {
|
|
138
|
+
budgetMs: resolved.frame[track],
|
|
139
|
+
measuredMs: frameMs === null ? null : r3(frameMs)
|
|
140
|
+
};
|
|
141
|
+
let used = 0;
|
|
142
|
+
const keys = Object.keys(resolved.shares[track]);
|
|
143
|
+
for (const slice of keys) {
|
|
144
|
+
if (slice === "headroom") continue;
|
|
145
|
+
const ms = coveredMs(profiler, slice) ?? null;
|
|
146
|
+
if (ms !== null) used += ms;
|
|
147
|
+
slices.push(sliceMeasure(slice, track, resolved, ms, frameMs));
|
|
148
|
+
}
|
|
149
|
+
if ("headroom" in resolved.shares[track]) {
|
|
150
|
+
const left = frameMs === null ? null : Math.max(0, frameMs - used);
|
|
151
|
+
const m = sliceMeasure("headroom", track, resolved, left, frameMs);
|
|
152
|
+
m.verdict = m.measuredShare === null ? "unmeasured" : m.measuredShare < m.share ? "over" : "pass";
|
|
153
|
+
slices.push(m);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { frame, slices };
|
|
157
|
+
}
|
|
158
|
+
function sliceMeasure(slice, track, resolved, ms, frameMs) {
|
|
159
|
+
const share = resolved.shares[track][slice];
|
|
160
|
+
const measuredShare = ms !== null && frameMs !== null && frameMs > 0 ? ms / frameMs : null;
|
|
161
|
+
const r3 = (v) => Math.round(v * 1e3) / 1e3;
|
|
162
|
+
return {
|
|
163
|
+
slice,
|
|
164
|
+
track,
|
|
165
|
+
share,
|
|
166
|
+
budgetMs: resolved.slices[track][slice] ?? null,
|
|
167
|
+
measuredMs: ms === null ? null : r3(ms),
|
|
168
|
+
measuredShare: measuredShare === null ? null : r3(measuredShare),
|
|
169
|
+
verdict: measuredShare === null ? "unmeasured" : measuredShare > share ? "over" : "pass"
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function describeBudgets(world, options = {}) {
|
|
173
|
+
const state = perfBudgetState(world, options.adapter);
|
|
174
|
+
const scenarioName = options.scenario ?? world.tryResource(PerfScenario)?.name ?? null;
|
|
175
|
+
if (!state) {
|
|
176
|
+
return {
|
|
177
|
+
machine: null,
|
|
178
|
+
source: "unknown",
|
|
179
|
+
scenario: null,
|
|
180
|
+
warnings: [
|
|
181
|
+
{
|
|
182
|
+
code: "perf/no-budgets",
|
|
183
|
+
message: "This app has no budgets: the host gave it no budgets.json",
|
|
184
|
+
hint: "The CLI loads bench/perf/budgets.json from the repo (or perf/budgets.json in the project); shard dev serves it to its page."
|
|
185
|
+
}
|
|
186
|
+
],
|
|
187
|
+
budgets: []
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const { data, detection } = state;
|
|
191
|
+
const machine = detection.machine;
|
|
192
|
+
const on = machine ?? detection.closest;
|
|
193
|
+
const profiler = world.resource(ProfilerResource);
|
|
194
|
+
const budgets = [];
|
|
195
|
+
for (const [key, entry] of Object.entries(data.budgets.spans ?? {})) {
|
|
196
|
+
const value = on !== null && typeof entry[on] === "number" ? entry[on] : null;
|
|
197
|
+
const limit = value === null ? null : entry.kind === "guard" ? value * (1 + (entry.margin ?? 0)) : value;
|
|
198
|
+
const stats = entry.kind === "unit" || entry.kind === "rate" || entry.in ? void 0 : spanStats(profiler, key);
|
|
199
|
+
const measured = stats ? Math.round(stats.p95 * 1e3) / 1e3 : null;
|
|
200
|
+
budgets.push({
|
|
201
|
+
key,
|
|
202
|
+
kind: entry.kind,
|
|
203
|
+
...entry.per ? { per: entry.per } : {},
|
|
204
|
+
...entry.in ? { in: entry.in } : {},
|
|
205
|
+
budget: value,
|
|
206
|
+
limit,
|
|
207
|
+
measured,
|
|
208
|
+
verdict: measured === null ? "unmeasured" : limit === null || machine === null ? "unbudgeted" : measured > limit ? "over" : "pass"
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const rank = { over: 0, pass: 1, unbudgeted: 2, unmeasured: 3 };
|
|
212
|
+
budgets.sort((a, b) => rank[a.verdict] - rank[b.verdict] || (a.key < b.key ? -1 : 1));
|
|
213
|
+
let scenario = null;
|
|
214
|
+
if (scenarioName !== null) {
|
|
215
|
+
const entry = data.budgets.scenarios[scenarioName];
|
|
216
|
+
if (!entry) {
|
|
217
|
+
throw new ShardError("perf/unknown-budget", `No scenario "${scenarioName}" in budgets.json`, {
|
|
218
|
+
path: scenarioName,
|
|
219
|
+
hint: `Scenarios: ${Object.keys(data.budgets.scenarios).join(", ")}.`
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const measured = measureSlices(profiler, entry, machine ?? detection.closest);
|
|
223
|
+
const order = { over: 0, pass: 1, unmeasured: 2 };
|
|
224
|
+
measured.slices.sort((a, b) => order[a.verdict] - order[b.verdict]);
|
|
225
|
+
scenario = {
|
|
226
|
+
name: scenarioName,
|
|
227
|
+
what: entry.what,
|
|
228
|
+
resolution: entry.resolution,
|
|
229
|
+
renderScale: entry.renderScale,
|
|
230
|
+
fps: entry.fps,
|
|
231
|
+
frame: measured.frame,
|
|
232
|
+
slices: measured.slices
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
machine,
|
|
237
|
+
source: detection.source,
|
|
238
|
+
...machine === null ? { closest: detection.closest } : {},
|
|
239
|
+
...data.source ? { from: data.source } : {},
|
|
240
|
+
scenario,
|
|
241
|
+
warnings: detection.warnings,
|
|
242
|
+
budgets
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
export {
|
|
246
|
+
PERF_TRACKS,
|
|
247
|
+
PerfBudgets,
|
|
248
|
+
backendOf,
|
|
249
|
+
coveredMs,
|
|
250
|
+
describeBudgets,
|
|
251
|
+
detectPerfMachine,
|
|
252
|
+
measureSlices,
|
|
253
|
+
perfBudgetState,
|
|
254
|
+
resolvePerfScenario,
|
|
255
|
+
scenarioSliceMs
|
|
256
|
+
};
|
package/dist/control.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export declare class AppControl {
|
|
|
6
6
|
paused: boolean;
|
|
7
7
|
/** Set when a failing frame paused the app, so a fix (e.g. a hot reload) can resume it. */
|
|
8
8
|
pausedByError: boolean;
|
|
9
|
+
/** Called when steps are queued, so an idle on-demand runner wakes to run them (0052). */
|
|
10
|
+
onRequest: (() => void) | undefined;
|
|
9
11
|
/** Stepped frames ever requested / ever run. Both only grow; the difference is what's pending. */
|
|
10
12
|
private requested;
|
|
11
13
|
private completed;
|
package/dist/control.js
CHANGED
|
@@ -3,6 +3,8 @@ class AppControl {
|
|
|
3
3
|
paused = false;
|
|
4
4
|
/** Set when a failing frame paused the app, so a fix (e.g. a hot reload) can resume it. */
|
|
5
5
|
pausedByError = false;
|
|
6
|
+
/** Called when steps are queued, so an idle on-demand runner wakes to run them (0052). */
|
|
7
|
+
onRequest = void 0;
|
|
6
8
|
/** Stepped frames ever requested / ever run. Both only grow; the difference is what's pending. */
|
|
7
9
|
requested = 0;
|
|
8
10
|
completed = 0;
|
|
@@ -16,7 +18,11 @@ class AppControl {
|
|
|
16
18
|
if (frames <= 0) return Promise.resolve();
|
|
17
19
|
this.requested += frames;
|
|
18
20
|
const target = this.requested;
|
|
19
|
-
|
|
21
|
+
const done = new Promise(
|
|
22
|
+
(resolve, reject) => this.waiters.push({ target, resolve, reject })
|
|
23
|
+
);
|
|
24
|
+
this.onRequest?.();
|
|
25
|
+
return done;
|
|
20
26
|
}
|
|
21
27
|
/** A stepped frame threw: drops the pending steps and rejects everyone waiting on them. */
|
|
22
28
|
abort(error) {
|
package/dist/demand.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame demand a renderer holds while a frame skipped draws (pipelines compiling, meshes or
|
|
3
|
+
* materials still loading, 0052). Such a frame isn't complete: traces and the first usable frame
|
|
4
|
+
* wait for one that is (0062).
|
|
5
|
+
*/
|
|
6
|
+
export declare const LOADING_DEMAND = "render/loading";
|
|
7
|
+
/** How the app's frames are driven: every display refresh, only when needed, or by hand. */
|
|
8
|
+
export type FrameMode = 'continuous' | 'on-demand' | 'manual';
|
|
9
|
+
/**
|
|
10
|
+
* What keeps an on-demand runner rendering (0052). Holders are named, so `render.describe` can say
|
|
11
|
+
* who is keeping frames running: awake physics bodies, live particles, playing animations, the
|
|
12
|
+
* RenderScale controller while probing, or anything a game holds itself.
|
|
13
|
+
*/
|
|
14
|
+
export declare class FrameDemandState {
|
|
15
|
+
/** Set by the runner. 'manual' until a loop runner starts. */
|
|
16
|
+
mode: FrameMode;
|
|
17
|
+
/** Clock time (ms, the app's clock) of the earliest frame `after` asked for; Infinity if none. */
|
|
18
|
+
dueAt: number;
|
|
19
|
+
/** Set by the on-demand runner: called when a new demand appears, so an idle runner wakes. */
|
|
20
|
+
onDemand: (() => void) | undefined;
|
|
21
|
+
private readonly holders;
|
|
22
|
+
private readonly now;
|
|
23
|
+
constructor(now?: () => number);
|
|
24
|
+
/** Keeps frames running until `release(key)`. Holding a key twice is one hold. */
|
|
25
|
+
hold(key: string): void;
|
|
26
|
+
release(key: string): void;
|
|
27
|
+
/** Holds `key` while `on` is true: one call per frame from a system that tracks a condition. */
|
|
28
|
+
set(key: string, on: boolean): void;
|
|
29
|
+
/** Asks for one frame `ms` from now, for ambient loops at low rates. The earliest ask wins. */
|
|
30
|
+
after(ms: number): void;
|
|
31
|
+
/** Ms until the frame `after` asked for (0 if overdue); Infinity if none was asked for. */
|
|
32
|
+
dueIn(): number;
|
|
33
|
+
/** @internal The runner calls this as a frame starts: a frame that was due is now running. */
|
|
34
|
+
takeDue(): void;
|
|
35
|
+
/** Whether anything holds frames running. */
|
|
36
|
+
get active(): boolean;
|
|
37
|
+
isHeld(key: string): boolean;
|
|
38
|
+
/** The keys holding frames running, sorted. */
|
|
39
|
+
held(): string[];
|
|
40
|
+
/** The runner's mode, what holds frames, and when the next timed frame is due. For agents. */
|
|
41
|
+
describe(): {
|
|
42
|
+
mode: FrameMode;
|
|
43
|
+
demands: string[];
|
|
44
|
+
dueInMs: number | null;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export declare const FrameDemand: import("@aethervtt/shard-core").ResourceDef<FrameDemandState>;
|
package/dist/demand.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { defineResource } from "@aethervtt/shard-core";
|
|
2
|
+
const LOADING_DEMAND = "render/loading";
|
|
3
|
+
class FrameDemandState {
|
|
4
|
+
/** Set by the runner. 'manual' until a loop runner starts. */
|
|
5
|
+
mode = "manual";
|
|
6
|
+
/** Clock time (ms, the app's clock) of the earliest frame `after` asked for; Infinity if none. */
|
|
7
|
+
dueAt = Number.POSITIVE_INFINITY;
|
|
8
|
+
/** Set by the on-demand runner: called when a new demand appears, so an idle runner wakes. */
|
|
9
|
+
onDemand = void 0;
|
|
10
|
+
holders = /* @__PURE__ */ new Set();
|
|
11
|
+
now;
|
|
12
|
+
constructor(now = () => performance.now()) {
|
|
13
|
+
this.now = now;
|
|
14
|
+
}
|
|
15
|
+
/** Keeps frames running until `release(key)`. Holding a key twice is one hold. */
|
|
16
|
+
hold(key) {
|
|
17
|
+
if (this.holders.has(key)) return;
|
|
18
|
+
this.holders.add(key);
|
|
19
|
+
this.onDemand?.();
|
|
20
|
+
}
|
|
21
|
+
release(key) {
|
|
22
|
+
this.holders.delete(key);
|
|
23
|
+
}
|
|
24
|
+
/** Holds `key` while `on` is true: one call per frame from a system that tracks a condition. */
|
|
25
|
+
set(key, on) {
|
|
26
|
+
if (on) this.hold(key);
|
|
27
|
+
else this.holders.delete(key);
|
|
28
|
+
}
|
|
29
|
+
/** Asks for one frame `ms` from now, for ambient loops at low rates. The earliest ask wins. */
|
|
30
|
+
after(ms) {
|
|
31
|
+
const at = this.now() + Math.max(0, ms);
|
|
32
|
+
if (at >= this.dueAt) return;
|
|
33
|
+
this.dueAt = at;
|
|
34
|
+
this.onDemand?.();
|
|
35
|
+
}
|
|
36
|
+
/** Ms until the frame `after` asked for (0 if overdue); Infinity if none was asked for. */
|
|
37
|
+
dueIn() {
|
|
38
|
+
return this.dueAt === Number.POSITIVE_INFINITY ? this.dueAt : Math.max(0, this.dueAt - this.now());
|
|
39
|
+
}
|
|
40
|
+
/** @internal The runner calls this as a frame starts: a frame that was due is now running. */
|
|
41
|
+
takeDue() {
|
|
42
|
+
if (this.dueAt <= this.now() + 1) this.dueAt = Number.POSITIVE_INFINITY;
|
|
43
|
+
}
|
|
44
|
+
/** Whether anything holds frames running. */
|
|
45
|
+
get active() {
|
|
46
|
+
return this.holders.size > 0;
|
|
47
|
+
}
|
|
48
|
+
isHeld(key) {
|
|
49
|
+
return this.holders.has(key);
|
|
50
|
+
}
|
|
51
|
+
/** The keys holding frames running, sorted. */
|
|
52
|
+
held() {
|
|
53
|
+
return [...this.holders].sort();
|
|
54
|
+
}
|
|
55
|
+
/** The runner's mode, what holds frames, and when the next timed frame is due. For agents. */
|
|
56
|
+
describe() {
|
|
57
|
+
const due = this.dueIn();
|
|
58
|
+
return { mode: this.mode, demands: this.held(), dueInMs: due === Infinity ? null : due };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const FrameDemand = defineResource("core/FrameDemand", {
|
|
62
|
+
description: "What keeps an on-demand runner rendering: hold(key) / release(key) while something must run each frame, after(ms) for one frame later."
|
|
63
|
+
});
|
|
64
|
+
export {
|
|
65
|
+
FrameDemand,
|
|
66
|
+
FrameDemandState,
|
|
67
|
+
LOADING_DEMAND
|
|
68
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
export { App, type AppDescription, type AppMethod, type AppOptions } from './app.js';
|
|
1
|
+
export { App, type AppDescription, type AppMethod, type AppOptions, type AppScope, type FrameDriver, type FramePresenter, type StartupTimings, } from './app.js';
|
|
2
|
+
export { type BudgetMeasure, type BudgetsDescription, backendOf, coveredMs, type DescribeBudgetsOptions, describeBudgets, detectPerfMachine, type MachineDetection, measureSlices, PERF_TRACKS, type PerfAdapterInfo, type PerfBudgetEntry, type PerfBudgetState, PerfBudgets, type PerfBudgetsData, type PerfBudgetsFile, type PerfMachine, type PerfMachinesFile, type PerfScenarioEntry, type PerfTrack, type PerfWarning, perfBudgetState, type ResolvedPerfScenario, resolvePerfScenario, type ScenarioMeasure, type SliceMeasure, scenarioSliceMs, } from './budgets.js';
|
|
2
3
|
export { AppControl, AppControlResource } from './control.js';
|
|
4
|
+
export { FrameDemand, FrameDemandState, type FrameMode, LOADING_DEMAND } from './demand.js';
|
|
3
5
|
export { COMMON_RATES, RefreshMeter, rateFromIntervals, snapRate } from './display.js';
|
|
4
6
|
export { Log, type LogEntry, type LogLevel, LogResource } from './log.js';
|
|
7
|
+
export { capturePerf, captureStamp, describePerf, gpuPassOverlap, isGpuPass, OVERLAP_RATIO, type PerfCaptureOptions, type PerfCaptureResult, type PerfDescribeOptions, type PerfHost, PerfHostResource, PerfProviders, type PerfProvidersData, type PerfSampler, type PerfSamples, perfBreakdown, perfMemory, } from './perf.js';
|
|
5
8
|
export { definePlugin, type Plugin } from './plugin.js';
|
|
6
9
|
export { type AnimationFrameOptions, animationFrameRunner, type HeadlessOptions, headlessRunner, type Runner, } from './runners.js';
|
|
10
|
+
export { PerfScenario, type PerfScenarioData } from './scenario.js';
|
|
7
11
|
export { defineState, inState, OnEnter, OnExit, type StateDef, type StateValue, setState, } from './state.js';
|
|
8
|
-
export { DisplayRate, type DisplayRateData, FixedTime, type FixedTimeData, GlobalRng, Time, type TimeData, } from './time.js';
|
|
12
|
+
export { DevMode, DisplayRate, type DisplayRateData, FixedTime, type FixedTimeData, GlobalRng, Time, type TimeData, } from './time.js';
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
App
|
|
3
|
+
} from "./app.js";
|
|
4
|
+
import {
|
|
5
|
+
backendOf,
|
|
6
|
+
coveredMs,
|
|
7
|
+
describeBudgets,
|
|
8
|
+
detectPerfMachine,
|
|
9
|
+
measureSlices,
|
|
10
|
+
PERF_TRACKS,
|
|
11
|
+
PerfBudgets,
|
|
12
|
+
perfBudgetState,
|
|
13
|
+
resolvePerfScenario,
|
|
14
|
+
scenarioSliceMs
|
|
15
|
+
} from "./budgets.js";
|
|
2
16
|
import { AppControl, AppControlResource } from "./control.js";
|
|
17
|
+
import { FrameDemand, FrameDemandState, LOADING_DEMAND } from "./demand.js";
|
|
3
18
|
import { COMMON_RATES, RefreshMeter, rateFromIntervals, snapRate } from "./display.js";
|
|
4
19
|
import { Log, LogResource } from "./log.js";
|
|
20
|
+
import {
|
|
21
|
+
capturePerf,
|
|
22
|
+
captureStamp,
|
|
23
|
+
describePerf,
|
|
24
|
+
gpuPassOverlap,
|
|
25
|
+
isGpuPass,
|
|
26
|
+
OVERLAP_RATIO,
|
|
27
|
+
PerfHostResource,
|
|
28
|
+
PerfProviders,
|
|
29
|
+
perfBreakdown,
|
|
30
|
+
perfMemory
|
|
31
|
+
} from "./perf.js";
|
|
5
32
|
import { definePlugin } from "./plugin.js";
|
|
6
33
|
import {
|
|
7
34
|
animationFrameRunner,
|
|
8
35
|
headlessRunner
|
|
9
36
|
} from "./runners.js";
|
|
37
|
+
import { PerfScenario } from "./scenario.js";
|
|
10
38
|
import {
|
|
11
39
|
defineState,
|
|
12
40
|
inState,
|
|
@@ -15,6 +43,7 @@ import {
|
|
|
15
43
|
setState
|
|
16
44
|
} from "./state.js";
|
|
17
45
|
import {
|
|
46
|
+
DevMode,
|
|
18
47
|
DisplayRate,
|
|
19
48
|
FixedTime,
|
|
20
49
|
GlobalRng,
|
|
@@ -25,21 +54,46 @@ export {
|
|
|
25
54
|
AppControl,
|
|
26
55
|
AppControlResource,
|
|
27
56
|
COMMON_RATES,
|
|
57
|
+
DevMode,
|
|
28
58
|
DisplayRate,
|
|
29
59
|
FixedTime,
|
|
60
|
+
FrameDemand,
|
|
61
|
+
FrameDemandState,
|
|
30
62
|
GlobalRng,
|
|
63
|
+
LOADING_DEMAND,
|
|
31
64
|
Log,
|
|
32
65
|
LogResource,
|
|
66
|
+
OVERLAP_RATIO,
|
|
33
67
|
OnEnter,
|
|
34
68
|
OnExit,
|
|
69
|
+
PERF_TRACKS,
|
|
70
|
+
PerfBudgets,
|
|
71
|
+
PerfHostResource,
|
|
72
|
+
PerfProviders,
|
|
73
|
+
PerfScenario,
|
|
35
74
|
RefreshMeter,
|
|
36
75
|
Time,
|
|
37
76
|
animationFrameRunner,
|
|
77
|
+
backendOf,
|
|
78
|
+
capturePerf,
|
|
79
|
+
captureStamp,
|
|
80
|
+
coveredMs,
|
|
38
81
|
definePlugin,
|
|
39
82
|
defineState,
|
|
83
|
+
describeBudgets,
|
|
84
|
+
describePerf,
|
|
85
|
+
detectPerfMachine,
|
|
86
|
+
gpuPassOverlap,
|
|
40
87
|
headlessRunner,
|
|
41
88
|
inState,
|
|
89
|
+
isGpuPass,
|
|
90
|
+
measureSlices,
|
|
91
|
+
perfBreakdown,
|
|
92
|
+
perfBudgetState,
|
|
93
|
+
perfMemory,
|
|
42
94
|
rateFromIntervals,
|
|
95
|
+
resolvePerfScenario,
|
|
96
|
+
scenarioSliceMs,
|
|
43
97
|
setState,
|
|
44
98
|
snapRate
|
|
45
99
|
};
|
package/dist/perf.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type CaptureOptions, type CaptureSummary, type ChromeTrace, type HeapStats, type HotFunction, type Profiler, type SpanStats, type World } from '@aethervtt/shard-core';
|
|
2
|
+
import type { Capture } from '@aethervtt/shard-core/capture';
|
|
3
|
+
/** A sampling profiler the host runs for a capture's window. */
|
|
4
|
+
export interface PerfSampler {
|
|
5
|
+
stop(): Promise<PerfSamples>;
|
|
6
|
+
}
|
|
7
|
+
export interface PerfSamples {
|
|
8
|
+
hottest: HotFunction[];
|
|
9
|
+
/** Stretches of the same top function, for the trace's samples track (browsers). */
|
|
10
|
+
samples?: {
|
|
11
|
+
name: string;
|
|
12
|
+
start: number;
|
|
13
|
+
ms: number;
|
|
14
|
+
}[];
|
|
15
|
+
/** A V8 `.cpuprofile` (Node), written next to the trace. */
|
|
16
|
+
cpuprofile?: unknown;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* What the host can measure for the profiler. The asset server fills it from the platform
|
|
20
|
+
* (`HostPerformance` and the file system) when it's configured.
|
|
21
|
+
*/
|
|
22
|
+
export interface PerfHost {
|
|
23
|
+
/** JS heap: `v8.getHeapStatistics()` in Node, the page's measurements in browsers. */
|
|
24
|
+
heap?(): HeapStats | undefined;
|
|
25
|
+
/** Garbage collections as they happen (Node): start on the app's clock, pause, and kind. */
|
|
26
|
+
onGc?(listener: (start: number, ms: number, kind: string) => void): () => void;
|
|
27
|
+
/** Starts sampling; undefined where the host can't (`perf/sampling-unavailable`). */
|
|
28
|
+
startSampler?(): Promise<PerfSampler | undefined>;
|
|
29
|
+
/** Emits a span to the host's own profiler (Chrome's Performance panel). */
|
|
30
|
+
measure?(name: string, track: string, start: number, ms: number): void;
|
|
31
|
+
/** Writes a project file (traces go to `.shard/captures/`). Absent on read-only hosts. */
|
|
32
|
+
writeText?(path: string, text: string): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export declare const PerfHostResource: import("@aethervtt/shard-core").ResourceDef<PerfHost>;
|
|
35
|
+
export interface PerfProvidersData {
|
|
36
|
+
/** GPU memory by category (the render plugin's ledger). */
|
|
37
|
+
gpuMemory?: (world: World) => {
|
|
38
|
+
bytes: number;
|
|
39
|
+
byCategory: Record<string, number>;
|
|
40
|
+
};
|
|
41
|
+
/** More `perf.describe` sections by name (0075 adds budgets). */
|
|
42
|
+
sections: Map<string, (world: World) => unknown>;
|
|
43
|
+
/** The GPU adapter's identity (the render plugin), for telling which machine this is (0075). */
|
|
44
|
+
adapter?: (world: World) => {
|
|
45
|
+
vendor?: string;
|
|
46
|
+
architecture?: string;
|
|
47
|
+
device?: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
} | undefined;
|
|
50
|
+
}
|
|
51
|
+
export declare const PerfProviders: import("@aethervtt/shard-core").ResourceDef<PerfProvidersData>;
|
|
52
|
+
export interface PerfDescribeOptions {
|
|
53
|
+
/** Only spans these keys cover (`spanCovers`: equal, or a prefix ending at "/"). */
|
|
54
|
+
spans?: readonly string[];
|
|
55
|
+
/** Most spans per track. Default 30. */
|
|
56
|
+
top?: number;
|
|
57
|
+
}
|
|
58
|
+
/** GPU memory from the ledger, and the JS heap. */
|
|
59
|
+
export declare function perfMemory(world: World): CaptureSummary['memory'];
|
|
60
|
+
/** Per-pass GPU times summed past this share of `gpu:frame` mean the passes overlap (0075). */
|
|
61
|
+
export declare const OVERLAP_RATIO = 1.1;
|
|
62
|
+
/** Whether a span is a single GPU pass (`gpu:<node>`), not the frame or a group of passes. */
|
|
63
|
+
export declare function isGpuPass(name: string): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Whether per-pass GPU times overlap (0075): their averages sum to more than 110% of `gpu:frame`'s.
|
|
66
|
+
* On tile-based GPUs (Apple, most mobile) passes run concurrently and a pass's timestamps include
|
|
67
|
+
* waiting for earlier work, so only `gpu:frame` is reliable there, and pass costs come from
|
|
68
|
+
* ablation (`perf.ablate`). Undefined without GPU timings.
|
|
69
|
+
*/
|
|
70
|
+
export declare function gpuPassOverlap(profiler: Profiler): {
|
|
71
|
+
passesMs: number;
|
|
72
|
+
frameMs: number;
|
|
73
|
+
overlapping: boolean;
|
|
74
|
+
} | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* `perf.describe` (0074): per-frame, per-schedule, per-system CPU time, GPU passes, worker and
|
|
77
|
+
* async spans, memory (GPU ledger, JS heap, ECS tables), and the clock.
|
|
78
|
+
*/
|
|
79
|
+
export declare function describePerf(world: World, options?: PerfDescribeOptions): {
|
|
80
|
+
enabled: boolean;
|
|
81
|
+
window: number;
|
|
82
|
+
clock: {
|
|
83
|
+
resolutionMs: number;
|
|
84
|
+
isolated: boolean;
|
|
85
|
+
/** Spans that average under this read mostly the clock's step (`coarse`). */
|
|
86
|
+
coarseBelowMs: number;
|
|
87
|
+
};
|
|
88
|
+
frame: Record<string, unknown> | null;
|
|
89
|
+
schedules: Record<string, unknown>;
|
|
90
|
+
commands: Record<string, unknown>;
|
|
91
|
+
systems: Record<string, unknown>[];
|
|
92
|
+
gpu: "unavailable" | {
|
|
93
|
+
frame: Record<string, unknown> | null;
|
|
94
|
+
quantized: boolean;
|
|
95
|
+
overlapping: boolean;
|
|
96
|
+
passesMs?: number | undefined;
|
|
97
|
+
note?: string | undefined;
|
|
98
|
+
passes: Record<string, unknown>[];
|
|
99
|
+
};
|
|
100
|
+
workers: Record<string, unknown>[];
|
|
101
|
+
async: Record<string, unknown>[];
|
|
102
|
+
memory: {
|
|
103
|
+
gpu: {
|
|
104
|
+
bytes: number;
|
|
105
|
+
byCategory: Record<string, number>;
|
|
106
|
+
};
|
|
107
|
+
heap?: HeapStats;
|
|
108
|
+
ecs: {
|
|
109
|
+
entities: number;
|
|
110
|
+
archetypes: number;
|
|
111
|
+
bytes: number;
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
export interface PerfCaptureOptions extends Omit<CaptureOptions, 'devtools'> {
|
|
116
|
+
/** Samples with the host's profiler (V8's in Node, JS Self-Profiling in browsers). */
|
|
117
|
+
sample?: boolean;
|
|
118
|
+
/** Also shows spans in Chrome's Performance panel (`performance.measure`). Allocates. */
|
|
119
|
+
devtools?: boolean;
|
|
120
|
+
/** Writes the trace to `.shard/captures/` when the host can (default true); else it's inline. */
|
|
121
|
+
write?: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Runs one frame, for hosts without a frame loop (headless, tests). Without it the capture waits
|
|
124
|
+
* for the loop's frames.
|
|
125
|
+
*/
|
|
126
|
+
step?: () => void | Promise<void>;
|
|
127
|
+
/** Builds the Chrome trace (default true). Scenario tests (0075) read the capture instead. */
|
|
128
|
+
trace?: boolean;
|
|
129
|
+
/** Returns the capture itself (`result.capture`), for `spanTime` (0075). In-process only. */
|
|
130
|
+
keep?: boolean;
|
|
131
|
+
}
|
|
132
|
+
export interface PerfCaptureResult {
|
|
133
|
+
summary: CaptureSummary;
|
|
134
|
+
/** The capture, with `keep`: per-frame time a budget key covers (`spanTime`), its events. */
|
|
135
|
+
capture?: Capture;
|
|
136
|
+
/** Where the trace went, project-relative. */
|
|
137
|
+
tracePath?: string;
|
|
138
|
+
/** The trace itself, when it wasn't written. */
|
|
139
|
+
trace?: ChromeTrace;
|
|
140
|
+
/** The sampling profile (Node), next to the trace. */
|
|
141
|
+
profilePath?: string;
|
|
142
|
+
}
|
|
143
|
+
/** A file-name stamp: 2026-10-07T12-30-05-123Z. */
|
|
144
|
+
export declare function captureStamp(date?: Date): string;
|
|
145
|
+
/**
|
|
146
|
+
* The spans with the highest p95 over the profiler's window, for perf records (0062's version 2):
|
|
147
|
+
* CPU (main thread, workers, async) and GPU, `frame` and `gpu:frame` aside.
|
|
148
|
+
*/
|
|
149
|
+
export declare function perfBreakdown(world: World, limit?: number): {
|
|
150
|
+
cpu: SpanStats[];
|
|
151
|
+
gpu: SpanStats[];
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* Captures N frames, or a flight recorder's window around a slow frame (0074). Resolves with the
|
|
155
|
+
* summary, and the trace's path (or the trace) once it has them.
|
|
156
|
+
*/
|
|
157
|
+
export declare function capturePerf(world: World, options?: PerfCaptureOptions): Promise<PerfCaptureResult>;
|