orbit-studio / src /modules /benchmark /benchmarkRunner.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
8.83 kB
// The sweep loop, over an injected target. Nothing here knows about Cesium,
// Vue or the DOM: it decides what to measure and in what order, and the target
// decides what "apply this scene" means.
import { buildPlan, type BenchmarkStep, type PlanSpec } from "./benchmarkPlan";
import type { FrameSample } from "./frameSampler";
export interface SceneRequest {
satelliteCount: number;
components: readonly string[];
clockMultiplier: number;
}
/**
* What the app actually built, as against what was asked for. Recorded rather
* than assumed because the two genuinely differ: a name can match two catalog
* entries, and a component is only created for satellites it applies to (no
* sensor cone without a swath, no model without a model url). A row that
* claimed 5,000 labels when 200 were drawn would be worse than no row.
*/
export interface SceneApplied {
satellitesRequested: number;
satellitesVisible: number;
componentsRequested: string[];
componentsDrawn: string[];
componentInstances: Record<string, number>;
/** Recorded rather than read back off the step: the clock is the app's to refuse. */
clockMultiplier: number;
entities: number;
primitives: number;
/** Tearing the previous scene down, so a build is never a diff from one. */
clearMs: number;
/** Wall time of the synchronous build — instantiation plus component creation. */
buildMs: number;
}
export interface MeasureOptions {
warmupMs: number;
sampleMs: number;
signal: AbortSignal;
}
/**
* An absolute memory measurement, garbage excluded — as against the relative
* slope `memoryFits` derives from sampled heap floors.
*
* From `performance.measureUserAgentSpecificMemory()`, which is why it is
* optional in every sense: the API needs a cross-origin isolated context and
* exists only in Chromium, and it resolves only when a collection happens, which
* measured at 14-19 s a call. That cost is why capturing this is a choice rather
* than something every sweep does.
*/
export interface FootprintSample {
/** The whole agent: JavaScript, DOM and shared memory, across every scope. */
totalMb: number;
/**
* Just this window's JavaScript. The figure comparable with `memoryFits` and
* with a forced collection — measured 0.2% apart from one.
*/
jsMb: number;
/**
* Every worker scope's JavaScript.
*
* Separated because `totalMb` counts it and nothing else does, so moving work
* into a worker makes the total go up while the main thread gets cheaper — and a
* comparison against a revision that has no workers reads that move as a
* regression unless the two parts can be told apart.
*/
workerMb: number;
/** How long the call took, so a row records what it cost to have this number. */
elapsedMs: number;
}
export interface BenchmarkTarget {
/** Facts about the machine, so a result set can be compared with another. */
environment(): Record<string, string | number>;
/** Everything that has to be true before the first step, once per run. */
prepare(): Promise<void>;
/** How many satellites are available to draw at all. */
catalogSize(): number;
apply(request: SceneRequest): Promise<SceneApplied>;
/**
* Sample the window. The heap is part of the returned frame sample rather than
* a separate reading taken afterwards — see `FrameSample.heap`.
*/
measure(options: MeasureOptions): Promise<FrameSample>;
/**
* The absolute footprint of the scene currently up, or undefined where this
* browser cannot answer. Called after the sample window and before the next
* step tears the scene down, so the figure belongs to the scene it is filed
* under — and never during the window, since a 17 s wait inside a 4 s sample
* would not be a sample.
*/
measureFootprint(): Promise<FootprintSample | undefined>;
/** Put the app back the way it was found. */
restore(): Promise<void>;
}
export interface BenchmarkOptions {
warmupMs: number;
sampleMs: number;
/**
* Capture an absolute footprint per step. Off by default because it is the
* most expensive thing in the framework — see `FootprintSample`.
*/
captureFootprint?: boolean;
}
/**
* What one footprint capture costs, for the duration estimate. Measured over six
* calls: 14, 16, 19, 16, 18 and 19 s. It is a wait for a collection rather than
* work, so it does not scale with the scene.
*/
export const FOOTPRINT_CAPTURE_MS = 17_000;
/**
* Long enough that a step is a measurement rather than a glance. The warmup has
* to outlast the shader compiles and buffer uploads that follow a build, and the
* sample has to span several of the sampled-trajectory refreshes that arrive on
* a schedule of their own — a short sample either catches one or misses it, and
* the row swings either way.
*/
export const DEFAULT_OPTIONS: BenchmarkOptions = { warmupMs: 2000, sampleMs: 4000 };
export interface BenchmarkResult {
step: BenchmarkStep;
applied: SceneApplied;
frames: FrameSample;
/** Present only when asked for, and only where the browser can answer. */
footprint: FootprintSample | undefined;
}
export interface BenchmarkRun {
startedAtIso: string;
/**
* The sweep that was asked for. Recorded because a run started from the console
* has to be legible in the panel: without it the controls would go on showing
* whatever was last typed while a different sweep ran.
*/
spec: PlanSpec;
environment: Record<string, string | number>;
options: BenchmarkOptions;
catalogSize: number;
results: BenchmarkResult[];
cancelled: boolean;
}
export interface RunnerHooks {
onLog?(message: string): void;
onProgress?(progress: { done: number; total: number; step: BenchmarkStep }): void;
/** Called as each row lands, so a live table does not wait for the sweep. */
onResult?(result: BenchmarkResult, run: BenchmarkRun): void;
}
export class BenchmarkRunner {
readonly #target: BenchmarkTarget;
#abort: AbortController | undefined;
#current: BenchmarkRun | undefined;
constructor(target: BenchmarkTarget) {
this.#target = target;
}
get running(): boolean {
return this.#current !== undefined;
}
/** The run in progress, or the last one to finish. */
get run(): BenchmarkRun | undefined {
return this.#current ?? this.#last;
}
#last: BenchmarkRun | undefined;
cancel(): void {
this.#abort?.abort();
}
async start(spec: PlanSpec, options: BenchmarkOptions = DEFAULT_OPTIONS, hooks: RunnerHooks = {}): Promise<BenchmarkRun> {
if (this.#current) {
throw new Error("A benchmark is already running");
}
const abort = new AbortController();
this.#abort = abort;
await this.#target.prepare();
const steps = buildPlan(spec);
const run: BenchmarkRun = {
startedAtIso: new Date().toISOString(),
spec,
environment: this.#target.environment(),
options,
catalogSize: this.#target.catalogSize(),
results: [],
cancelled: false,
};
this.#current = run;
hooks.onLog?.(`${steps.length} steps over a catalog of ${run.catalogSize} satellites`);
try {
for (const step of steps) {
if (abort.signal.aborted) {
run.cancelled = true;
break;
}
hooks.onProgress?.({ done: run.results.length, total: steps.length, step });
// Sequential is the whole point: two steps measured at once would be
// measuring each other.
// eslint-disable-next-line no-await-in-loop
const applied = await this.#target.apply({ satelliteCount: step.satelliteCount, components: step.components, clockMultiplier: step.clockMultiplier });
// eslint-disable-next-line no-await-in-loop
const frames = await this.#target.measure({ ...options, signal: abort.signal });
if (abort.signal.aborted) {
// A sample cut short is not a sample. Drop it rather than record a
// fast-looking row that only measured the moment before the stop.
run.cancelled = true;
break;
}
// After the sample window, with the scene still up: this is the one
// measurement that must not happen inside the window it describes.
// eslint-disable-next-line no-await-in-loop
const footprint = options.captureFootprint ? await this.#target.measureFootprint() : undefined;
const result: BenchmarkResult = { step, applied, frames, footprint };
run.results.push(result);
hooks.onResult?.(result, run);
}
} finally {
await this.#target.restore();
this.#last = run;
this.#current = undefined;
this.#abort = undefined;
}
hooks.onLog?.(run.cancelled ? `cancelled after ${run.results.length} steps` : `finished ${run.results.length} steps`);
return run;
}
}