// The sweep matrix. Pure: a spec in, an ordered list of steps out, so what is // going to be measured can be read (and argued with) before anything renders. import { SATELLITE_COMPONENTS } from "../../config/components"; export interface BenchmarkStep { index: number; satelliteCount: number; components: string[]; /** The clock rate the step runs at. See DEFAULT_CLOCK_MULTIPLIERS. */ clockMultiplier: number; /** * True for the closing re-run of the first step. Excluded from every derived * table — it is a second sample of a scene already measured, and its job is to * be compared against the original rather than averaged into it. */ repeat: boolean; /** * What the step varies within — its component set and clock rate. This is the * key the report groups by, so the fit for one series is never contaminated by * rows measured under a different clock. */ series: string; label: string; } export interface PlanSpec { satelliteCounts: readonly number[]; componentSets: readonly (readonly string[])[]; /** Omitted means real time only — a one-value axis, and no wasted steps. */ clockMultipliers?: readonly number[]; /** Close by re-running the first step. Defaults to true; see appendRepeat. */ repeatFirstStep?: boolean; } /** * Roughly log-spaced, and 0 is a step rather than an omission: it is the only * row that says what the globe costs on its own, which every other row is * measured against. * * Five, not the nine this used to be. Each count costs the better part of ten * seconds, and the ones that were dropped sat between neighbours close enough * that the fit barely moved — a sweep short enough to actually be run beats a * denser one nobody waits out. Widen it by hand when a particular stretch of the * curve is the question. */ export const DEFAULT_SATELLITE_COUNTS: readonly number[] = [0, 100, 500, 1000, 5000]; /** * Clock rates for the propagation axis. * * Propagation is not paid per frame, it is paid per *simulated* quarter orbit: * `SampledTrajectory.start` refreshes its window on a simulation-time callback, * and each refresh re-propagates 120 SGP4 samples per orbit for that satellite. * So the number of refreshes per wall second is proportional to the multiplier — * at ×1000 a quarter orbit goes by in about a second and a half, where at ×1 it * takes a quarter of an orbit. Sweeping the multiplier at a fixed satellite * count is therefore how the cost of propagation is separated from the cost of * drawing, which does not care what the clock is doing. */ export const DEFAULT_CLOCK_MULTIPLIERS: readonly number[] = [1, 10, 100, 1000]; /** * Each set adds one component to the set before it, so the difference between * two consecutive rows is the cost of the component that was added — on top of * everything already being drawn. */ export const CUMULATIVE_COMPONENT_SETS: readonly (readonly string[])[] = ((): string[][] => { const sets: string[][] = [[]]; for (const component of SATELLITE_COMPONENTS) { sets.push([...(sets[sets.length - 1] as string[]), component]); } return sets; })(); /** * Every set is Point plus exactly one other component, so no component's cost * is hiding behind another's. Point is the baseline rather than nothing at all * because a satellite with no point still has to exist, and this way the delta * is the drawing rather than the satellite. */ export const ISOLATED_COMPONENT_SETS: readonly (readonly string[])[] = [ ["Point"], ...SATELLITE_COMPONENTS.filter((component) => component !== "Point").map((component) => ["Point", component]), ]; export const formatComponents = (components: readonly string[]): string => (components.length === 0 ? "(none)" : components.join(" + ")); /** `×1` is left off: it is the default, and saying it would be noise on every row. */ export const formatSeries = (components: readonly string[], clockMultiplier: number): string => clockMultiplier === 1 ? formatComponents(components) : `${formatComponents(components)} @ ×${clockMultiplier}`; /** * Component sets outermost, then clock rates, then counts ascending, so a sweep * cancelled half way through has finished whole series rather than leaving every * one of them with a hole in it. * * The first step is then re-run as the last one. A sweep is minutes long and the * app it measures gets warmer as it goes — shader caches fill, the JIT settles, * the heap grows — so the only way to know whether a rising line is the scene or * the clock is to measure one scene twice, far apart. `repeatChecks` reports the * difference; whether it is small is what says the rest of the run means * anything. */ export function buildPlan(spec: PlanSpec): BenchmarkStep[] { // eslint-disable-next-line unicorn/no-array-sort -- already a fresh array const counts = [...new Set(spec.satelliteCounts)].filter((count) => Number.isInteger(count) && count >= 0).sort((a, b) => a - b); const multipliers = [...new Set(spec.clockMultipliers ?? [1])].filter((value) => Number.isFinite(value) && value > 0); const steps: BenchmarkStep[] = []; const push = (satelliteCount: number, components: readonly string[], clockMultiplier: number, repeat: boolean): void => { const series = formatSeries(components, clockMultiplier); steps.push({ index: steps.length, satelliteCount, components: [...components], clockMultiplier, repeat, series, label: `${satelliteCount} sats · ${series}${repeat ? " (repeat)" : ""}`, }); }; for (const components of spec.componentSets) { for (const clockMultiplier of multipliers) { for (const satelliteCount of counts) { push(satelliteCount, components, clockMultiplier, false); } } } const first = steps[0]; // Nothing to compare a lone step against, and repeating it would only double // the wait for the same one answer. if (first && steps.length > 1 && (spec.repeatFirstStep ?? true)) { push(first.satelliteCount, first.components, first.clockMultiplier, true); } return steps; } /** * What to tell the user before they start something that takes minutes. * * `footprintMs` is not a rounding term: a footprint capture waits about 17 s for a * collection, so on the default sweep it is the difference between four minutes * and fourteen. Showing that before the run is what lets someone choose three * counts instead of five rather than discovering the cost half way through. */ export function estimateDurationMs(steps: readonly BenchmarkStep[], perStepMs: number, footprintMs = 0): number { // A step costs its warmup and sample period plus the build, which is the part // that grows with the count and is not worth modelling here beyond a nudge. return steps.length * (perStepMs + 400 + footprintMs); }