// 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; /** 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; /** Everything that has to be true before the first step, once per run. */ prepare(): Promise; /** How many satellites are available to draw at all. */ catalogSize(): number; apply(request: SceneRequest): Promise; /** * 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; /** * 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; /** Put the app back the way it was found. */ restore(): Promise; } 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; 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 { 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; } }