@aethervtt/shard-runtime 0.1.0 → 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 +21 -1
- package/dist/app.js +30 -1
- package/dist/budgets.d.ts +197 -0
- package/dist/budgets.js +256 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +46 -0
- package/dist/perf.d.ts +157 -0
- package/dist/perf.js +245 -0
- package/dist/scenario.d.ts +9 -0
- package/dist/scenario.js +9 -0
- package/package.json +3 -3
package/dist/app.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ComponentDef, type ResourceDef, type ScheduleLabel, type SystemConfig, type SystemDef, type SystemDescription, type SystemSetConfig, World } from '@aethervtt/shard-core';
|
|
1
|
+
import { type ComponentDef, type ProfilerSettingsData, type ResourceDef, type ScheduleLabel, type SystemConfig, type SystemDef, type SystemDescription, type SystemSetConfig, World } from '@aethervtt/shard-core';
|
|
2
2
|
import type { Plugin } from './plugin.js';
|
|
3
3
|
import type { Runner } from './runners.js';
|
|
4
4
|
import { type StateDef } from './state.js';
|
|
@@ -17,6 +17,18 @@ export interface AppOptions {
|
|
|
17
17
|
* Default false.
|
|
18
18
|
*/
|
|
19
19
|
dev?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* The profiler (0074): `enabled: false` turns spans off for builds that want every microsecond;
|
|
22
|
+
* `window` is how many runs aggregates cover. Also `ProfilerSettings` at runtime. Default on, 120.
|
|
23
|
+
*/
|
|
24
|
+
profiler?: Partial<ProfilerSettingsData>;
|
|
25
|
+
/**
|
|
26
|
+
* The budget scenario the app runs as (0075, `bench/perf/budgets.json`): features that adapt
|
|
27
|
+
* (0045's foliage) default their target to their slice of its frame, and the `perf` overlay shows
|
|
28
|
+
* its slices. Default none: nothing adapts. Also `App.perfScenario` and the `PerfScenario`
|
|
29
|
+
* resource at runtime.
|
|
30
|
+
*/
|
|
31
|
+
perfScenario?: string | null;
|
|
20
32
|
}
|
|
21
33
|
export interface AppDescription {
|
|
22
34
|
plugins: {
|
|
@@ -118,6 +130,12 @@ export declare class App {
|
|
|
118
130
|
addPlugin(...plugins: Plugin[]): this;
|
|
119
131
|
/** Adds protocol methods (see `AppMethod`). A second method with a taken name throws. */
|
|
120
132
|
addMethod(...methods: AppMethod[]): this;
|
|
133
|
+
/**
|
|
134
|
+
* The budget scenario the app declares (0075): features with a budget resource adapt to their
|
|
135
|
+
* slice of its frame, and the `perf` overlay shows its slices. null (the default): none.
|
|
136
|
+
*/
|
|
137
|
+
get perfScenario(): string | null;
|
|
138
|
+
set perfScenario(name: string | null);
|
|
121
139
|
/** Methods plugins added, in the order they were added. */
|
|
122
140
|
get methods(): readonly AppMethod[];
|
|
123
141
|
addSystems(schedule: ScheduleLabel, ...systems: SystemInput[]): this;
|
|
@@ -204,7 +222,9 @@ export declare class App {
|
|
|
204
222
|
resetFixedTime(): void;
|
|
205
223
|
/** Runs one frame. `delta` is in seconds. */
|
|
206
224
|
update(delta: number): void;
|
|
225
|
+
/** One frame, timed as the `frame` span (0074). */
|
|
207
226
|
private frame;
|
|
227
|
+
private frameBody;
|
|
208
228
|
/** Resolves traces and the usable mark once this frame is on screen. */
|
|
209
229
|
private present;
|
|
210
230
|
/** Called after every frame with the number of frames completed. Returns an unsubscribe function. */
|
package/dist/app.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
PreUpdate,
|
|
7
7
|
Profiler,
|
|
8
8
|
ProfilerResource,
|
|
9
|
+
ProfilerSettings,
|
|
9
10
|
Rng,
|
|
10
11
|
Schedule,
|
|
11
12
|
ShardError,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
import { AppControl, AppControlResource } from "./control.js";
|
|
17
18
|
import { FrameDemand, FrameDemandState, LOADING_DEMAND } from "./demand.js";
|
|
18
19
|
import { Log, LogResource } from "./log.js";
|
|
20
|
+
import { PerfScenario } from "./scenario.js";
|
|
19
21
|
import { OnEnter, OnExit } from "./state.js";
|
|
20
22
|
import {
|
|
21
23
|
DevMode,
|
|
@@ -49,7 +51,7 @@ class App {
|
|
|
49
51
|
schedules = /* @__PURE__ */ new Map();
|
|
50
52
|
systemNames = /* @__PURE__ */ new Set();
|
|
51
53
|
states = [];
|
|
52
|
-
profiler
|
|
54
|
+
profiler;
|
|
53
55
|
runner;
|
|
54
56
|
initialized = false;
|
|
55
57
|
startupDone = false;
|
|
@@ -74,12 +76,17 @@ class App {
|
|
|
74
76
|
this.fixedHz = options.fixedHz ?? 60;
|
|
75
77
|
this.maxFixedSteps = options.maxFixedSteps ?? 5;
|
|
76
78
|
this.now = options.now ?? (() => performance.now());
|
|
79
|
+
const settings = { enabled: true, window: 120, ...options.profiler };
|
|
80
|
+
this.profiler = new Profiler({ now: this.now, settings });
|
|
81
|
+
this.world.insertResource(ProfilerSettings, settings);
|
|
77
82
|
this.world.insertResource(ProfilerResource, this.profiler);
|
|
78
83
|
this.world.insertResource(GlobalRng, new Rng(options.seed ?? 0));
|
|
79
84
|
this.world.insertResource(DevMode, { enabled: options.dev ?? false });
|
|
85
|
+
this.world.insertResource(PerfScenario, { name: options.perfScenario ?? null });
|
|
80
86
|
const log = new Log();
|
|
81
87
|
log.now = () => this.world.tryResource(Time)?.elapsed ?? 0;
|
|
82
88
|
this.world.insertResource(LogResource, log);
|
|
89
|
+
this.profiler.onWarning = (w) => log.log("warn", w.message, { code: w.code, hint: w.hint, path: w.path });
|
|
83
90
|
const control = new AppControl();
|
|
84
91
|
control.onRequest = () => this.driver?.requestFrame();
|
|
85
92
|
this.world.insertResource(AppControlResource, control);
|
|
@@ -110,6 +117,16 @@ class App {
|
|
|
110
117
|
}
|
|
111
118
|
return this;
|
|
112
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* The budget scenario the app declares (0075): features with a budget resource adapt to their
|
|
122
|
+
* slice of its frame, and the `perf` overlay shows its slices. null (the default): none.
|
|
123
|
+
*/
|
|
124
|
+
get perfScenario() {
|
|
125
|
+
return this.world.resource(PerfScenario).name;
|
|
126
|
+
}
|
|
127
|
+
set perfScenario(name) {
|
|
128
|
+
this.world.resource(PerfScenario).name = name;
|
|
129
|
+
}
|
|
113
130
|
/** Methods plugins added, in the order they were added. */
|
|
114
131
|
get methods() {
|
|
115
132
|
return [...this.appMethods.values()];
|
|
@@ -369,7 +386,19 @@ class App {
|
|
|
369
386
|
this.exit(scoped);
|
|
370
387
|
}
|
|
371
388
|
}
|
|
389
|
+
/** One frame, timed as the `frame` span (0074). */
|
|
372
390
|
frame(delta) {
|
|
391
|
+
const profiler = this.profiler;
|
|
392
|
+
const token = profiler.beginFrame(this.world.resource(Time).frame);
|
|
393
|
+
try {
|
|
394
|
+
this.frameBody(delta);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
profiler.cancel(token);
|
|
397
|
+
throw err;
|
|
398
|
+
}
|
|
399
|
+
profiler.endFrame(token);
|
|
400
|
+
}
|
|
401
|
+
frameBody(delta) {
|
|
373
402
|
const world = this.world;
|
|
374
403
|
const traces = this.traces.length > 0 ? this.traces.splice(0) : void 0;
|
|
375
404
|
const usable = Number.isNaN(this.startup_.usable) && !Number.isNaN(this.startup_.usableMarked);
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { type Profiler, type World } from '@aethervtt/shard-core';
|
|
2
|
+
export type PerfTrack = 'gpu' | 'cpu';
|
|
3
|
+
export declare const PERF_TRACKS: readonly PerfTrack[];
|
|
4
|
+
export interface PerfMachine {
|
|
5
|
+
/** Part of the CPU model string (`node:os`). */
|
|
6
|
+
cpu: string;
|
|
7
|
+
/** Part of the GPU adapter's vendor, architecture, device or description. */
|
|
8
|
+
gpu: string;
|
|
9
|
+
backend?: string;
|
|
10
|
+
display?: [number, number];
|
|
11
|
+
/** How GPU slices are checked: per-pass timestamps, or ablation where passes overlap. */
|
|
12
|
+
passTiming?: 'ablation' | 'timestamps';
|
|
13
|
+
note?: string;
|
|
14
|
+
}
|
|
15
|
+
/** `bench/perf/machines.json`. */
|
|
16
|
+
export interface PerfMachinesFile {
|
|
17
|
+
machines: Record<string, PerfMachine>;
|
|
18
|
+
}
|
|
19
|
+
export interface PerfBudgetEntry {
|
|
20
|
+
kind: 'target' | 'unit' | 'rate' | 'guard';
|
|
21
|
+
per?: string;
|
|
22
|
+
in?: string;
|
|
23
|
+
margin?: number;
|
|
24
|
+
note?: string;
|
|
25
|
+
/** A number per machine name. */
|
|
26
|
+
[machine: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
export interface PerfScenarioEntry {
|
|
29
|
+
what?: string;
|
|
30
|
+
resolution?: [number, number];
|
|
31
|
+
renderScale?: number;
|
|
32
|
+
fps?: number;
|
|
33
|
+
/** The frame budget per track, per machine, in ms. */
|
|
34
|
+
frame: Record<PerfTrack, Record<string, number>>;
|
|
35
|
+
/** Each track's slices as shares of the frame, `headroom` included; they sum to 1. */
|
|
36
|
+
slices: Record<PerfTrack, Record<string, number>>;
|
|
37
|
+
/** Absolute slices in ms per machine, where hardware differs unevenly. */
|
|
38
|
+
overrides?: Record<string, Partial<Record<PerfTrack, Record<string, number>>>>;
|
|
39
|
+
note?: string;
|
|
40
|
+
}
|
|
41
|
+
/** `bench/perf/budgets.json`. */
|
|
42
|
+
export interface PerfBudgetsFile {
|
|
43
|
+
spans: Record<string, PerfBudgetEntry>;
|
|
44
|
+
scenarios: Record<string, PerfScenarioEntry>;
|
|
45
|
+
}
|
|
46
|
+
export interface PerfBudgetsData {
|
|
47
|
+
machines: PerfMachinesFile;
|
|
48
|
+
budgets: PerfBudgetsFile;
|
|
49
|
+
/** Where they came from (a path or URL), for `perf.budgets`. */
|
|
50
|
+
source?: string;
|
|
51
|
+
/** The CPU model, where the host knows it (Node: `os.cpus()[0].model`). Browsers don't. */
|
|
52
|
+
cpu?: string;
|
|
53
|
+
/** A machine name that overrides detection (`SHARD_MACHINE`, `?machine=`). */
|
|
54
|
+
machine?: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Budgets for the running app (0075), handed over by the host: the CLI loads `bench/perf/` from
|
|
58
|
+
* the repo (or `perf/` in the project), `shard dev` serves them to its page. Absent: no budgets.
|
|
59
|
+
*/
|
|
60
|
+
export declare const PerfBudgets: import("@aethervtt/shard-core").ResourceDef<PerfBudgetsData>;
|
|
61
|
+
export interface PerfAdapterInfo {
|
|
62
|
+
vendor?: string;
|
|
63
|
+
architecture?: string;
|
|
64
|
+
device?: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
}
|
|
67
|
+
export interface PerfWarning {
|
|
68
|
+
code: string;
|
|
69
|
+
message: string;
|
|
70
|
+
hint?: string;
|
|
71
|
+
}
|
|
72
|
+
export interface MachineDetection {
|
|
73
|
+
/** The named machine, or null when this one can't be told (or isn't named). */
|
|
74
|
+
machine: string | null;
|
|
75
|
+
source: 'detected' | 'override' | 'unknown';
|
|
76
|
+
/** The machine that matched best, which unknown machines report against. */
|
|
77
|
+
closest: string | null;
|
|
78
|
+
warnings: PerfWarning[];
|
|
79
|
+
}
|
|
80
|
+
/** The graphics API an adapter's description names. */
|
|
81
|
+
export declare function backendOf(description?: string): string | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Which machine of machines.json this is (the rule `pnpm bench` uses, bench/perf/src/perf.mjs): a
|
|
84
|
+
* machine matches when both its CPU and GPU strings do. A host that can't tell the CPU model (a
|
|
85
|
+
* browser) never matches, and gets `perf/unknown-machine`; `override` names the machine instead.
|
|
86
|
+
*/
|
|
87
|
+
export declare function detectPerfMachine(machines: PerfMachinesFile, o?: {
|
|
88
|
+
cpu?: string;
|
|
89
|
+
adapter?: PerfAdapterInfo;
|
|
90
|
+
override?: string;
|
|
91
|
+
}): MachineDetection;
|
|
92
|
+
/** A scenario's frame and slices on one machine, in ms (null without a number). */
|
|
93
|
+
export interface ResolvedPerfScenario {
|
|
94
|
+
frame: Record<PerfTrack, number | null>;
|
|
95
|
+
/** Each slice in ms: its share of the frame, or the machine's override. */
|
|
96
|
+
slices: Record<PerfTrack, Record<string, number | null>>;
|
|
97
|
+
/** Each slice's share of the frame, as budgets.json states it. */
|
|
98
|
+
shares: Record<PerfTrack, Record<string, number>>;
|
|
99
|
+
}
|
|
100
|
+
/** A scenario of budgets.json on `machine` (null: shares only). */
|
|
101
|
+
export declare function resolvePerfScenario(scenario: PerfScenarioEntry, machine: string | null): ResolvedPerfScenario;
|
|
102
|
+
/** The detected machine and the app's budgets data, cached until either changes. */
|
|
103
|
+
export interface PerfBudgetState {
|
|
104
|
+
data: PerfBudgetsData;
|
|
105
|
+
detection: MachineDetection;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The app's budgets and which machine it runs on, or undefined without `PerfBudgets`. Cached per
|
|
109
|
+
* world until the data, the override or the adapter changes; not for per-frame use.
|
|
110
|
+
*/
|
|
111
|
+
export declare function perfBudgetState(world: World, adapter?: PerfAdapterInfo): PerfBudgetState | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* A scenario slice in ms on the detected machine, or undefined (no budgets, no scenario, no such
|
|
114
|
+
* slice, or an unknown machine). What 0045's foliage defaults its target to.
|
|
115
|
+
*/
|
|
116
|
+
export declare function scenarioSliceMs(world: World, scenario: string, track: PerfTrack, slice: string, adapter?: PerfAdapterInfo): number | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* Time a key covers in an average frame, from the profiler's aggregates: the averages of every span
|
|
119
|
+
* the key covers (`spanCovers`), a span inside another covered span (its `parentOf` chain) counted
|
|
120
|
+
* once, as `Capture.spanTime` does. A span that doesn't run every frame counts its average run.
|
|
121
|
+
*/
|
|
122
|
+
export declare function coveredMs(profiler: Profiler, key: string): number | undefined;
|
|
123
|
+
export interface SliceMeasure {
|
|
124
|
+
slice: string;
|
|
125
|
+
track: PerfTrack;
|
|
126
|
+
/** Its share of the frame in budgets.json. */
|
|
127
|
+
share: number;
|
|
128
|
+
/** Its budget on this machine (ms), or null. */
|
|
129
|
+
budgetMs: number | null;
|
|
130
|
+
/** Measured: the covered spans' average, and its share of the measured frame. */
|
|
131
|
+
measuredMs: number | null;
|
|
132
|
+
measuredShare: number | null;
|
|
133
|
+
verdict: 'over' | 'pass' | 'unmeasured';
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Each slice of a scenario against the profiler's aggregates: measured shares of the frame (`frame`
|
|
137
|
+
* for CPU, `gpu:frame` for GPU) next to the budget's. `headroom` is what the slices leave: the frame
|
|
138
|
+
* less every slice.
|
|
139
|
+
*/
|
|
140
|
+
export declare function measureSlices(profiler: Profiler, scenario: PerfScenarioEntry, machine: string | null): {
|
|
141
|
+
frame: Record<PerfTrack, {
|
|
142
|
+
budgetMs: number | null;
|
|
143
|
+
measuredMs: number | null;
|
|
144
|
+
}>;
|
|
145
|
+
slices: SliceMeasure[];
|
|
146
|
+
};
|
|
147
|
+
export interface BudgetMeasure {
|
|
148
|
+
key: string;
|
|
149
|
+
kind: PerfBudgetEntry['kind'];
|
|
150
|
+
per?: string;
|
|
151
|
+
in?: string;
|
|
152
|
+
/** The machine's number (null on an unknown machine). */
|
|
153
|
+
budget: number | null;
|
|
154
|
+
/** What a measurement is compared with: a guard's plus its margin. */
|
|
155
|
+
limit: number | null;
|
|
156
|
+
/** The span's p95 over the profiler's window, where a span by this name ran. */
|
|
157
|
+
measured: number | null;
|
|
158
|
+
verdict: 'over' | 'pass' | 'unmeasured' | 'unbudgeted';
|
|
159
|
+
}
|
|
160
|
+
export interface DescribeBudgetsOptions {
|
|
161
|
+
/** A scenario to measure; default the app's (`PerfScenario`). */
|
|
162
|
+
scenario?: string;
|
|
163
|
+
/** The GPU adapter, for detection. Default: the render plugin's (`PerfProviders.adapter`). */
|
|
164
|
+
adapter?: PerfAdapterInfo;
|
|
165
|
+
}
|
|
166
|
+
/** A scenario's frame and slices against the profiler's aggregates, over budget first. */
|
|
167
|
+
export interface ScenarioMeasure {
|
|
168
|
+
name: string;
|
|
169
|
+
what?: string;
|
|
170
|
+
resolution?: [number, number];
|
|
171
|
+
renderScale?: number;
|
|
172
|
+
fps?: number;
|
|
173
|
+
frame: Record<PerfTrack, {
|
|
174
|
+
budgetMs: number | null;
|
|
175
|
+
measuredMs: number | null;
|
|
176
|
+
}>;
|
|
177
|
+
slices: SliceMeasure[];
|
|
178
|
+
}
|
|
179
|
+
/** What `perf.budgets` returns. */
|
|
180
|
+
export interface BudgetsDescription {
|
|
181
|
+
/** The named machine, or null when this host can't tell (`closest` is then what numbers use). */
|
|
182
|
+
machine: string | null;
|
|
183
|
+
source: MachineDetection['source'];
|
|
184
|
+
closest?: string | null;
|
|
185
|
+
/** Where the budgets came from. */
|
|
186
|
+
from?: string;
|
|
187
|
+
/** The scenario measured (the app's, or the one asked for); null without one. */
|
|
188
|
+
scenario: ScenarioMeasure | null;
|
|
189
|
+
warnings: PerfWarning[];
|
|
190
|
+
budgets: BudgetMeasure[];
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* `perf.budgets` (0075): the detected machine, every budget resolved for it, and the latest
|
|
194
|
+
* measurements against each from the profiler's aggregates (span keys by their p95; the scenario's
|
|
195
|
+
* slices as shares of the frame), over budget first.
|
|
196
|
+
*/
|
|
197
|
+
export declare function describeBudgets(world: World, options?: DescribeBudgetsOptions): BudgetsDescription;
|
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/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
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';
|
|
3
4
|
export { FrameDemand, FrameDemandState, type FrameMode, LOADING_DEMAND } from './demand.js';
|
|
4
5
|
export { COMMON_RATES, RefreshMeter, rateFromIntervals, snapRate } from './display.js';
|
|
5
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';
|
|
6
8
|
export { definePlugin, type Plugin } from './plugin.js';
|
|
7
9
|
export { type AnimationFrameOptions, animationFrameRunner, type HeadlessOptions, headlessRunner, type Runner, } from './runners.js';
|
|
10
|
+
export { PerfScenario, type PerfScenarioData } from './scenario.js';
|
|
8
11
|
export { defineState, inState, OnEnter, OnExit, type StateDef, type StateValue, setState, } from './state.js';
|
|
9
12
|
export { DevMode, DisplayRate, type DisplayRateData, FixedTime, type FixedTimeData, GlobalRng, Time, type TimeData, } from './time.js';
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
import {
|
|
2
2
|
App
|
|
3
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";
|
|
4
16
|
import { AppControl, AppControlResource } from "./control.js";
|
|
5
17
|
import { FrameDemand, FrameDemandState, LOADING_DEMAND } from "./demand.js";
|
|
6
18
|
import { COMMON_RATES, RefreshMeter, rateFromIntervals, snapRate } from "./display.js";
|
|
7
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";
|
|
8
32
|
import { definePlugin } from "./plugin.js";
|
|
9
33
|
import {
|
|
10
34
|
animationFrameRunner,
|
|
11
35
|
headlessRunner
|
|
12
36
|
} from "./runners.js";
|
|
37
|
+
import { PerfScenario } from "./scenario.js";
|
|
13
38
|
import {
|
|
14
39
|
defineState,
|
|
15
40
|
inState,
|
|
@@ -38,16 +63,37 @@ export {
|
|
|
38
63
|
LOADING_DEMAND,
|
|
39
64
|
Log,
|
|
40
65
|
LogResource,
|
|
66
|
+
OVERLAP_RATIO,
|
|
41
67
|
OnEnter,
|
|
42
68
|
OnExit,
|
|
69
|
+
PERF_TRACKS,
|
|
70
|
+
PerfBudgets,
|
|
71
|
+
PerfHostResource,
|
|
72
|
+
PerfProviders,
|
|
73
|
+
PerfScenario,
|
|
43
74
|
RefreshMeter,
|
|
44
75
|
Time,
|
|
45
76
|
animationFrameRunner,
|
|
77
|
+
backendOf,
|
|
78
|
+
capturePerf,
|
|
79
|
+
captureStamp,
|
|
80
|
+
coveredMs,
|
|
46
81
|
definePlugin,
|
|
47
82
|
defineState,
|
|
83
|
+
describeBudgets,
|
|
84
|
+
describePerf,
|
|
85
|
+
detectPerfMachine,
|
|
86
|
+
gpuPassOverlap,
|
|
48
87
|
headlessRunner,
|
|
49
88
|
inState,
|
|
89
|
+
isGpuPass,
|
|
90
|
+
measureSlices,
|
|
91
|
+
perfBreakdown,
|
|
92
|
+
perfBudgetState,
|
|
93
|
+
perfMemory,
|
|
50
94
|
rateFromIntervals,
|
|
95
|
+
resolvePerfScenario,
|
|
96
|
+
scenarioSliceMs,
|
|
51
97
|
setState,
|
|
52
98
|
snapRate
|
|
53
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>;
|
package/dist/perf.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clockInfo,
|
|
3
|
+
defineResource,
|
|
4
|
+
ProfilerResource,
|
|
5
|
+
ShardError,
|
|
6
|
+
spanCovers,
|
|
7
|
+
spanStats,
|
|
8
|
+
TRACK,
|
|
9
|
+
trackName
|
|
10
|
+
} from "@aethervtt/shard-core";
|
|
11
|
+
const PerfHostResource = defineResource("runtime/PerfHost", {
|
|
12
|
+
description: "The host's profiling instruments (0074): heap, GC, sampling, and where traces go."
|
|
13
|
+
});
|
|
14
|
+
const PerfProviders = defineResource("runtime/PerfProviders", {
|
|
15
|
+
description: "What packages add to perf.describe and capture summaries: GPU memory, sections.",
|
|
16
|
+
init: () => ({ sections: /* @__PURE__ */ new Map() })
|
|
17
|
+
});
|
|
18
|
+
const r3 = (ms) => Math.round(ms * 1e3) / 1e3;
|
|
19
|
+
function rounded(a) {
|
|
20
|
+
const out = {
|
|
21
|
+
span: a.span,
|
|
22
|
+
last: r3(a.last),
|
|
23
|
+
avg: r3(a.avg),
|
|
24
|
+
p50: r3(a.p50),
|
|
25
|
+
p95: r3(a.p95),
|
|
26
|
+
max: r3(a.max),
|
|
27
|
+
samples: a.samples
|
|
28
|
+
};
|
|
29
|
+
if (a.coarse) out.coarse = true;
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
function heapOf(world) {
|
|
33
|
+
const fromHost = world.tryResource(PerfHostResource)?.heap?.();
|
|
34
|
+
if (fromHost) return fromHost;
|
|
35
|
+
const g = globalThis;
|
|
36
|
+
const usage = g.process?.memoryUsage?.();
|
|
37
|
+
if (usage) return { usedBytes: usage.heapUsed, totalBytes: usage.heapTotal, source: "v8" };
|
|
38
|
+
const memory = g.performance?.memory;
|
|
39
|
+
if (memory) {
|
|
40
|
+
return {
|
|
41
|
+
usedBytes: memory.usedJSHeapSize,
|
|
42
|
+
totalBytes: memory.totalJSHeapSize,
|
|
43
|
+
limitBytes: memory.jsHeapSizeLimit,
|
|
44
|
+
source: "performance.memory"
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return void 0;
|
|
48
|
+
}
|
|
49
|
+
function perfMemory(world) {
|
|
50
|
+
const gpu = world.tryResource(PerfProviders)?.gpuMemory?.(world) ?? { bytes: 0, byCategory: {} };
|
|
51
|
+
const heap = heapOf(world);
|
|
52
|
+
return heap ? { gpu, heap } : { gpu };
|
|
53
|
+
}
|
|
54
|
+
const OVERLAP_RATIO = 1.1;
|
|
55
|
+
function isGpuPass(name) {
|
|
56
|
+
return name.startsWith("gpu:") && name !== "gpu:frame" && !name.startsWith("gpu:span/");
|
|
57
|
+
}
|
|
58
|
+
function gpuPassOverlap(profiler) {
|
|
59
|
+
const frame = profiler.timing("gpu:frame");
|
|
60
|
+
if (!frame || frame.avg <= 0) return void 0;
|
|
61
|
+
let passes = 0;
|
|
62
|
+
for (const name of profiler.names()) {
|
|
63
|
+
if (isGpuPass(name)) passes += profiler.timing(name).avg;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
passesMs: r3(passes),
|
|
67
|
+
frameMs: r3(frame.avg),
|
|
68
|
+
overlapping: passes > frame.avg * OVERLAP_RATIO
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function describePerf(world, options = {}) {
|
|
72
|
+
const profiler = world.resource(ProfilerResource);
|
|
73
|
+
const keys = options.spans;
|
|
74
|
+
const top = options.top ?? 30;
|
|
75
|
+
const wanted = (name) => !keys || keys.some((k) => spanCovers(k, name));
|
|
76
|
+
const byTrack = { main: [], gpu: [], worker: [], async: [] };
|
|
77
|
+
const schedules = {};
|
|
78
|
+
const commands = {};
|
|
79
|
+
for (const name of profiler.names()) {
|
|
80
|
+
if (!wanted(name)) continue;
|
|
81
|
+
const stats = spanStats(profiler, name);
|
|
82
|
+
if (name.startsWith("schedule/")) schedules[name] = rounded(stats);
|
|
83
|
+
else if (name.startsWith("commands/")) commands[name] = rounded(stats);
|
|
84
|
+
else if (name !== "frame" && name !== "gpu:frame") byTrack[stats.track]?.push(stats);
|
|
85
|
+
}
|
|
86
|
+
const list = (track) => (byTrack[track] ?? []).sort((a, b) => b.avg - a.avg || (a.span < b.span ? -1 : 1)).slice(0, top).map(rounded);
|
|
87
|
+
const frame = spanStats(profiler, "frame");
|
|
88
|
+
const gpuFrame = spanStats(profiler, "gpu:frame");
|
|
89
|
+
const overlap = gpuPassOverlap(profiler);
|
|
90
|
+
const clock = clockInfo();
|
|
91
|
+
const ecs = world.stats();
|
|
92
|
+
let ecsBytes = 0;
|
|
93
|
+
for (const t of ecs.tables) ecsBytes += t.bytes;
|
|
94
|
+
const providers = world.tryResource(PerfProviders);
|
|
95
|
+
const sections = {};
|
|
96
|
+
for (const [name, fn] of providers?.sections ?? []) sections[name] = fn(world);
|
|
97
|
+
return {
|
|
98
|
+
enabled: profiler.enabled,
|
|
99
|
+
window: profiler.window,
|
|
100
|
+
clock: {
|
|
101
|
+
resolutionMs: clock.resolutionMs,
|
|
102
|
+
isolated: clock.isolated,
|
|
103
|
+
/** Spans that average under this read mostly the clock's step (`coarse`). */
|
|
104
|
+
coarseBelowMs: r3(clock.resolutionMs * 4)
|
|
105
|
+
},
|
|
106
|
+
frame: frame ? rounded(frame) : null,
|
|
107
|
+
schedules,
|
|
108
|
+
commands,
|
|
109
|
+
systems: list("main"),
|
|
110
|
+
gpu: profiler.gpu.status === "unavailable" ? "unavailable" : {
|
|
111
|
+
frame: gpuFrame ? rounded(gpuFrame) : null,
|
|
112
|
+
quantized: profiler.gpu.quantized,
|
|
113
|
+
// 0075: pass times that add up to more than the frame overlap, and don't rank passes.
|
|
114
|
+
overlapping: overlap?.overlapping ?? false,
|
|
115
|
+
...overlap?.overlapping ? {
|
|
116
|
+
passesMs: overlap.passesMs,
|
|
117
|
+
note: "Pass times overlap (a tile-based GPU): each includes waiting for earlier work, so they sum past gpu:frame and do not rank passes. perf.ablate measures what each pass costs."
|
|
118
|
+
} : {},
|
|
119
|
+
passes: list("gpu")
|
|
120
|
+
},
|
|
121
|
+
workers: list("worker"),
|
|
122
|
+
async: list("async"),
|
|
123
|
+
memory: {
|
|
124
|
+
...perfMemory(world),
|
|
125
|
+
ecs: { entities: ecs.entities, archetypes: ecs.archetypes, bytes: ecsBytes }
|
|
126
|
+
},
|
|
127
|
+
...sections
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function captureStamp(date = /* @__PURE__ */ new Date()) {
|
|
131
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
132
|
+
}
|
|
133
|
+
function perfBreakdown(world, limit = 10) {
|
|
134
|
+
const profiler = world.resource(ProfilerResource);
|
|
135
|
+
const cpu = [];
|
|
136
|
+
const gpu = [];
|
|
137
|
+
for (const name of profiler.names()) {
|
|
138
|
+
if (name === "frame" || name === "gpu:frame") continue;
|
|
139
|
+
const a = spanStats(profiler, name);
|
|
140
|
+
const stats = {
|
|
141
|
+
span: name,
|
|
142
|
+
track: a.track,
|
|
143
|
+
calls: a.samples,
|
|
144
|
+
total: r3(a.avg * a.samples),
|
|
145
|
+
p50: r3(a.p50),
|
|
146
|
+
p95: r3(a.p95),
|
|
147
|
+
max: r3(a.max)
|
|
148
|
+
};
|
|
149
|
+
(a.track === "gpu" ? gpu : cpu).push(stats);
|
|
150
|
+
}
|
|
151
|
+
const order = (x, y) => y.p95 - x.p95 || (x.span < y.span ? -1 : 1);
|
|
152
|
+
return { cpu: cpu.sort(order).slice(0, limit), gpu: gpu.sort(order).slice(0, limit) };
|
|
153
|
+
}
|
|
154
|
+
const gcNames = /* @__PURE__ */ new Map();
|
|
155
|
+
async function capturePerf(world, options = {}) {
|
|
156
|
+
const profiler = world.resource(ProfilerResource);
|
|
157
|
+
if (profiler.sink) {
|
|
158
|
+
throw new ShardError("perf/capture-running", "A capture is already running on this app", {
|
|
159
|
+
hint: "Wait for it to finish, then capture again."
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const { startCapture } = await import("@aethervtt/shard-core/capture");
|
|
163
|
+
const host = world.tryResource(PerfHostResource);
|
|
164
|
+
const warnings = [];
|
|
165
|
+
let sampler;
|
|
166
|
+
if (options.sample) {
|
|
167
|
+
sampler = await host?.startSampler?.();
|
|
168
|
+
if (!sampler) {
|
|
169
|
+
warnings.push(
|
|
170
|
+
new ShardError("perf/sampling-unavailable", "This host can't sample JavaScript", {
|
|
171
|
+
hint: "Node samples with node:inspector; browsers need the JS Self-Profiling API and a Document-Policy: js-profiling header (shard dev and the playground send it)."
|
|
172
|
+
}).toJSON()
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const measure = host?.measure;
|
|
177
|
+
const { step, sample: _, devtools, write, trace: wantTrace, keep, ...captureOptions } = options;
|
|
178
|
+
const recorder = startCapture(profiler, {
|
|
179
|
+
...captureOptions,
|
|
180
|
+
devtools: devtools && measure ? (name, track, start, ms) => measure(name, trackName(track), start, ms) : void 0
|
|
181
|
+
});
|
|
182
|
+
const offGc = host?.onGc?.((start, ms, kind) => {
|
|
183
|
+
let name = gcNames.get(kind);
|
|
184
|
+
if (!name) {
|
|
185
|
+
name = `gc/${kind}`;
|
|
186
|
+
gcNames.set(kind, name);
|
|
187
|
+
}
|
|
188
|
+
profiler.event(name, TRACK.gc, start, ms);
|
|
189
|
+
});
|
|
190
|
+
let capture;
|
|
191
|
+
try {
|
|
192
|
+
if (step) {
|
|
193
|
+
while (recorder.running) await step();
|
|
194
|
+
} else {
|
|
195
|
+
const limitMs = ((options.timeout ?? 60) + (options.frames ?? 300) / 10) * 1e3;
|
|
196
|
+
const timer = setTimeout(() => recorder.stop(), limitMs);
|
|
197
|
+
try {
|
|
198
|
+
await recorder.done;
|
|
199
|
+
} finally {
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
capture = await recorder.done;
|
|
204
|
+
} catch (err) {
|
|
205
|
+
recorder.stop();
|
|
206
|
+
throw err;
|
|
207
|
+
} finally {
|
|
208
|
+
offGc?.();
|
|
209
|
+
}
|
|
210
|
+
const sampled = sampler ? await sampler.stop() : void 0;
|
|
211
|
+
const summary = capture.summary({
|
|
212
|
+
clock: { ...clockInfo(), gpuQuantized: profiler.gpu.quantized },
|
|
213
|
+
memory: perfMemory(world),
|
|
214
|
+
hottest: sampled?.hottest,
|
|
215
|
+
warnings
|
|
216
|
+
});
|
|
217
|
+
const result = { summary };
|
|
218
|
+
if (keep) result.capture = capture;
|
|
219
|
+
if (wantTrace === false) return result;
|
|
220
|
+
const trace = capture.trace({ samples: sampled?.samples });
|
|
221
|
+
if (write !== false && host?.writeText) {
|
|
222
|
+
const stamp = captureStamp();
|
|
223
|
+
result.tracePath = `.shard/captures/${stamp}.trace.json`;
|
|
224
|
+
await host.writeText(result.tracePath, JSON.stringify(trace));
|
|
225
|
+
if (sampled?.cpuprofile) {
|
|
226
|
+
result.profilePath = `.shard/captures/${stamp}.cpuprofile`;
|
|
227
|
+
await host.writeText(result.profilePath, JSON.stringify(sampled.cpuprofile));
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
result.trace = trace;
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
export {
|
|
235
|
+
OVERLAP_RATIO,
|
|
236
|
+
PerfHostResource,
|
|
237
|
+
PerfProviders,
|
|
238
|
+
capturePerf,
|
|
239
|
+
captureStamp,
|
|
240
|
+
describePerf,
|
|
241
|
+
gpuPassOverlap,
|
|
242
|
+
isGpuPass,
|
|
243
|
+
perfBreakdown,
|
|
244
|
+
perfMemory
|
|
245
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface PerfScenarioData {
|
|
2
|
+
/** A scenario of budgets.json, or null: no scenario, so nothing adapts to a slice. */
|
|
3
|
+
name: string | null;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The scenario the app declares (`App.perfScenario`): features that adapt (0045's foliage) take
|
|
7
|
+
* their slice of its frame as their target, and the `perf` overlay shows its slices.
|
|
8
|
+
*/
|
|
9
|
+
export declare const PerfScenario: import("@aethervtt/shard-core").ResourceDef<PerfScenarioData>;
|
package/dist/scenario.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineResource } from "@aethervtt/shard-core";
|
|
2
|
+
const PerfScenario = defineResource("runtime/PerfScenario", {
|
|
3
|
+
description: "The budget scenario the app runs as (0075): features adapt to its slices, and the perf overlay shows them. null: none.",
|
|
4
|
+
hostWritable: true,
|
|
5
|
+
init: () => ({ name: null })
|
|
6
|
+
});
|
|
7
|
+
export {
|
|
8
|
+
PerfScenario
|
|
9
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aethervtt/shard-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"README.md"
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@aethervtt/shard-core": "0.
|
|
23
|
+
"@aethervtt/shard-core": "0.2.0"
|
|
24
24
|
},
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|
|
@@ -32,6 +32,6 @@
|
|
|
32
32
|
"provenance": true
|
|
33
33
|
},
|
|
34
34
|
"shard": {
|
|
35
|
-
"buildSha": "
|
|
35
|
+
"buildSha": "32c22a3944ed23f5d1baa90bf7cc9fae724f5fd8"
|
|
36
36
|
}
|
|
37
37
|
}
|