From 3479083029f204628ad2a4429cec4a67674bd7a8 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 04:58:52 +0200 Subject: [PATCH 01/10] Run the GPU capacity probe after start so a batch's first frames stream --- .changeset/gpu-first-run-streams.md | 5 + .../src/webgpu/gpu-experiment-handle.ts | 147 +++------- .../gpu-experiment-handle/calibration.ts | 20 +- .../gpu-experiment-handle/run-phase.test.ts | 274 ++++++++++++++++++ .../webgpu/gpu-experiment-handle/run-phase.ts | 143 +++++++++ libs/@hashintel/petrinaut/docs/experiments.md | 2 +- .../diagrams/gpu-capacity-calibration.d2 | 2 +- .../content/experiments/backend-selection.mdx | 4 +- .../simulation/gpu-capacity-calibration.mdx | 37 ++- 9 files changed, 490 insertions(+), 144 deletions(-) create mode 100644 .changeset/gpu-first-run-streams.md create mode 100644 libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts create mode 100644 libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts diff --git a/.changeset/gpu-first-run-streams.md b/.changeset/gpu-first-run-streams.md new file mode 100644 index 00000000000..2f224229bac --- /dev/null +++ b/.changeset/gpu-first-run-streams.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +The GPU backend's capacity probe runs after the experiment starts, so a batch's first frames stream instead of arriving once the probe is done. diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts index d59aab509dd..8ec95fbea32 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -5,12 +5,14 @@ * care which backend produced them. Keeping that contract identical is what lets * the GPU path be a setting rather than a parallel UI. * - * Supportability is resolved *before* the handle exists — eligibility, HIR - * lowering, shader generation and the capacity probe all happen in `create...`, - * which returns a reason instead of a handle when the net cannot run. A handle - * that could fail on `start()` would leave the caller unable to fall back - * cleanly, because by then the experiment is already registered and showing as - * running. + * Supportability that can be decided statically — eligibility, HIR lowering, + * shader generation, a marking against declared capacities — is resolved + * *before* the handle exists: `create...` returns a reason instead of a handle + * when the net cannot run, so the caller can still fall back. What only a run + * can tell — the slab a derived-capacity place needs, a metric's window — is + * calibrated as the first phase of `start()`, so the probe's chunks stream to + * the charts like every other attempt's instead of arriving en bloc once the + * handle exists. A probe that concedes ends the run with its reason. */ import { resolveNetParameterValues } from "../parameter-values"; import { @@ -24,23 +26,13 @@ import { requestGpuExperimentBackend } from "./backend"; import { encodeInitialTokenWords } from "./compile-net-shader"; import { placeCountCeiling } from "./eligibility"; import { gpuBackendSetupKey } from "./gpu-backend-cache"; -import { - probeDerivedCapacities, - probeRunCount, - probeWindows, - rememberCalibration, - RUN_POLICY, - runUntilCalibrated, -} from "./gpu-experiment-handle/calibration"; +import { rememberCalibration } from "./gpu-experiment-handle/calibration"; import { createFrameMerger } from "./gpu-experiment-handle/frame-merge"; import { metricFailure } from "./gpu-experiment-handle/metric-failure"; import { deriveRunParameters } from "./gpu-experiment-handle/run-parameters"; +import { runCalibratedExperiment } from "./gpu-experiment-handle/run-phase"; import { toGpuMetricFrames, toGpuMetricSpecs } from "./gpu-metric-frames"; -import { - anyEscapes, - calibrationKey, - planInitialWindows, -} from "./metric-windows"; +import { anyEscapes, calibrationKey } from "./metric-windows"; import { GPU_PREVIEW_RUNS, runGpuExperiment } from "./runner"; import type { AbortSignalLike } from "../environment"; @@ -101,10 +93,10 @@ export type CreateGpuMonteCarloExperimentConfig = { /** Caps runs per tile below the device's limit. For tests and benchmarks. */ maxRunsPerTile?: number; /** - * Abandons creation: checked after acquiring the backend and between the - * capacity probe's attempts, each of which is a GPU round-trip. Creation - * then throws an `AbortError`, which the selection walk rethrows rather - * than treating as a refusal that sends the experiment to the CPU. + * Abandons creation: checked once the backend is acquired, when creation + * throws an `AbortError`, which the selection walk rethrows rather than + * treating as a refusal that sends the experiment to the CPU. A handle + * already created stops at its next chunk instead. */ signal?: AbortSignalLike; /** @@ -273,7 +265,7 @@ export async function createGpuMonteCarloExperiment( let aborted = false; // A minimal `AbortSignalLike`: the runner only reads `aborted`, and building // a real AbortController would pull a DOM global into this package. The - // creation signal folds in so an abandoned probe stops at its next chunk. + // creation signal folds in so an abandoned run stops at its next chunk. const signal = { get aborted() { return aborted || creationAborted(); @@ -476,109 +468,40 @@ export async function createGpuMonteCarloExperiment( runCount, }); - let calibratedWindows: MetricWindow[] | null = null; - /** The capacity probe's halted-metric failure; run() reports it before its first attempt. */ - let probedFailure: string | null = null; + const calibratedWindows: MetricWindow[] | null = cachedCalibration + ? [...cachedCalibration.windows] + : null; if (cachedCalibration) { session.shader = cachedCalibration.shader; for (const [placeId, capacity] of cachedCalibration.capacities) { session.capacities.set(placeId, capacity); } - calibratedWindows = [...cachedCalibration.windows]; - } else if (session.capacities.size > 0) { - const probed = await probeDerivedCapacities({ - session, - runCount: config.runCount, - windowInputs, - placeCounts, - execute: executeAttempt, - stopped: creationAborted, - }); - if (creationAborted()) { - disposed = true; - releaseBackend(); - throw abortError(); - } - if (!probed.ok) { - disposed = true; - releaseBackend({ evict: true }); - return { - supported: false, - cause: "net-unsupported", - reason: probed.reason, - }; - } - probedFailure = metricFailureIn(probed.metricErrors, probed.probeRuns); - calibratedWindows = probed.windows; - // A halted probe's calibration is not stored: the next batch on this - // marking would adopt it, skip its probe and meet the halt only after a - // full attempt. - if (probedFailure === null) { - storeCalibration(calibratedWindows); - } } const run = async () => { - if (probedFailure !== null) { - fail(probedFailure); - return; - } - // Blind windows (any metric without a ceiling) probe with a prefix of the - // runs first, unless the capacity probe already calibrated them at - // creation. - let windows = - calibratedWindows ?? - planInitialWindows(windowInputs, session.shader.histogramBins); - const blindWindows = windowInputs.some((input) => input.ceiling === null); - if ( - calibratedWindows === null && - blindWindows && - metricIds.length > 0 && - !aborted - ) { - const probeRuns = probeRunCount(session.shader, config.runCount); - const probe = await probeWindows({ - session, - windows, - execute: executeAttempt, - runCount: probeRuns, - }); - if (isDisposed()) { - return; - } - if (!probe.ok) { - fail(probe.reason); - return; - } - if (probe.result.cancelled) { - finish("cancelled"); - return; - } - const probeFailure = metricFailureIn( - probe.result.metricErrors, - probeRuns, - ); - if (probeFailure !== null) { - fail(probeFailure); - return; - } - windows = probe.windows; - storeCalibration(windows); - } - - const calibrated = await runUntilCalibrated({ + // The probes run here, after `start()`, so their chunks stream like + // every other attempt's: the first picture a batch shows is the probe's, + // overwritten progressively as the full attempt lands. + const calibrated = await runCalibratedExperiment({ session, - runsFor: () => config.runCount, - windows, + calibratedWindows, + windowInputs, + placeCounts, + runCount: config.runCount, execute: executeAttempt, - policy: RUN_POLICY, - stopped: () => isDisposed() || aborted, + stopped: () => isDisposed() || signal.aborted, + remember: storeCalibration, + metricFailure: metricFailureIn, }); if (isDisposed()) { return; } - if (!calibrated.ok) { + if (calibrated.kind === "stopped") { + finish("cancelled"); + return; + } + if (calibrated.kind === "failed") { fail(calibrated.reason); return; } diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts index b13e01444fa..b914564b4b6 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts @@ -12,8 +12,8 @@ * once. * * A slab stops growing at its place's `derivedSlabCeiling`; an attempt that - * still overflows there is handed back as it stands, and the caller sends the - * experiment to the CPU. + * still overflows there is handed back as it stands, and the caller reports + * why. */ import { derivedSlabCeiling, tokenWordCount } from "../eligibility"; import { @@ -42,8 +42,8 @@ export const GPU_PROBE_MEMORY_BYTES = 128 * 1024 * 1024; * its probe shows a heavy tail — outlier runs far past the typical maximum. * Below it, sizing for the outlier is cheap enough to just do; past it, the * right structure is a per-run token arena (shared place-tagged slots sized - * by the simultaneous total), and until that exists the experiment runs on - * the CPU, which sizes its buffers dynamically. + * by the simultaneous total), and until that exists the run is refused with + * a reason that points at the CPU, which sizes its buffers dynamically. */ export const GPU_ARENA_SLAB_BYTES = 64 * 1024; @@ -281,7 +281,7 @@ export const slabsFromProbe = ( ) { return { ok: false, - reason: `Probing \`${place.name}\` saw outlier runs reach ${stats.max} tokens against a typical per-run maximum of ${Math.round(stats.meanRunMax)}. Sizing every run for the outlier would take ${Math.round(slabBytes / 1024)} KB per run — that heavy-tailed shape needs a per-run token arena, so this experiment runs on the CPU.`, + reason: `Probing \`${place.name}\` saw outlier runs reach ${stats.max} tokens against a typical per-run maximum of ${Math.round(stats.meanRunMax)}. Sizing every run for the outlier would take ${Math.round(slabBytes / 1024)} KB per run — that heavy-tailed shape needs a per-run token arena. Switch this experiment to the CPU backend, which sizes its buffers dynamically.`, }; } capacities.set(place.id, capacity); @@ -290,16 +290,16 @@ export const slabsFromProbe = ( }; /** - * Calibrates derived capacities before the handle exists, so the arena case - * can refuse cleanly and the caller falls back to the CPU: probes a small + * Calibrates derived capacities as a run's first attempts: probes a small * prefix of the runs at generous slabs (growing on overflow), sizes each * place's slab from the observed maxima, and recompiles at those. The same * probe observes the metric ranges, seeding the histogram windows, and counts - * the runs a non-finite metric sample halted, which the handle reports as the + * the runs a non-finite metric sample halted, which the caller reports as the * full run would. A halted probe is handed back before its slabs are sized: * the CPU would fail on the same sample, so neither an overflow nor the arena * case the same probe shows pre-empts the halt, and nothing is recompiled for - * a run the handle will not start. + * a run the caller will not start. The arena case refuses with a reason the + * caller reports. */ export const probeDerivedCapacities = async (options: { session: CalibrationSession; @@ -367,7 +367,7 @@ export const probeDerivedCapacities = async (options: { const largest = Math.max(0, ...session.capacities.values()); return { ok: false, - reason: `Probing this net's token counts kept overflowing past ${largest.toLocaleString()} tokens per place; running on the CPU, which sizes its buffers dynamically.`, + reason: `Probing this net's token counts kept overflowing past ${largest.toLocaleString()} tokens per place. Switch this experiment to the CPU backend, which sizes its buffers dynamically.`, }; } const slabs = slabsFromProbe(session, probe.result, placeCounts); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts new file mode 100644 index 00000000000..488c3440be2 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; + +import { runCalibratedExperiment } from "./run-phase"; + +import type { CompiledNetShader } from "../compile-net-shader"; +import type { GpuExperimentResult } from "../runner"; +import type { + AttemptResult, + CalibrationSession, + ExecuteAttempt, +} from "./calibration"; + +/** A shader whose only relevant facts are its size and its derived places. */ +const shaderAt = ( + capacities: ReadonlyMap, +): CompiledNetShader => { + const slabWords = [...capacities.values()].reduce( + (sum, capacity) => sum + capacity * 2, + 0, + ); + return { + wgsl: "", + stateWordsPerRun: 4 + slabWords, + summaryWordsPerRun: 2 + capacities.size, + placeCountOffsets: [0], + placeTokenOffsets: [4], + placeTokenStrides: [2], + summaryStatusOffset: 1, + rngOffset: 2, + statusOffset: 3, + derivedCapacityPlaceIndices: [...capacities.keys()].map(() => 0), + metricIds: ["m0"], + histogramBins: 64, + runParameterIds: [], + compiledLambdas: [], + }; +}; + +const session = (capacities: Record): CalibrationSession => { + const initial = new Map(Object.entries(capacities)); + return { + backend: { + recompile: (next) => ({ ok: true, shader: shaderAt(next) }), + profile: { + places: [ + { + id: "p", + name: "P", + capacity: initial.get("p") ?? 0, + capacitySource: "derived", + declaredCapacity: 0xffffffff, + realFields: ["x", "y"], + discreteFields: [], + colored: true, + pairConsumed: false, + }, + ], + uncolouredOnly: false, + bytesPerRun: 16, + }, + }, + shader: shaderAt(initial), + capacities: initial, + }; +}; + +const outcome = ( + overrides: Partial = {}, +): GpuExperimentResult => ({ + cancelled: false, + frames: [], + finalPlaceCounts: new Uint32Array(0), + deadlockedRuns: 0, + completedRuns: 0, + overflowRuns: 0, + derivedPlaceMaxes: [{ max: 10, meanRunMax: 8 }], + dispatchMs: 0, + metricRanges: [{ min: 20, max: 40, below: 0, above: 0 }], + metricErrors: [], + ...overrides, +}); + +/** Replays scripted results and records what each attempt asked for. */ +const scripted = (results: AttemptResult[]) => { + const attempts: Parameters[0][] = []; + const execute: ExecuteAttempt = (attempt) => { + attempts.push(attempt); + return Promise.resolve( + results.shift() ?? { ok: false, reason: "script exhausted" }, + ); + }; + return { execute, attempts }; +}; + +const blind = [{ integer: true, ceiling: null }]; + +const runWith = ( + current: CalibrationSession, + execute: ExecuteAttempt, + overrides: Partial[0]> = {}, +) => { + const remembered: (readonly { lo: number; stride: number }[])[] = []; + const run = runCalibratedExperiment({ + session: current, + calibratedWindows: null, + windowInputs: blind, + placeCounts: [3], + runCount: 1000, + execute, + stopped: () => false, + remember: (windows) => remembered.push(windows), + metricFailure: () => null, + ...overrides, + }); + return { run, remembered }; +}; + +/** Reports the first halted metric over the attempt's runs, as the handle's message does. */ +const haltedMetric = ( + metricErrors: readonly number[], + runCount: number, +): string | null => { + const halted = metricErrors.find((runs) => runs > 0); + return halted === undefined ? null : `halted ${halted} of ${runCount} runs`; +}; + +describe("runCalibratedExperiment", () => { + it("probes the derived capacities through the same execute before the full attempt", async () => { + const current = session({ p: 64 }); + const { execute, attempts } = scripted([ + { ok: true, result: outcome() }, + { ok: true, result: outcome({ completedRuns: 1000 }) }, + ]); + + const { run, remembered } = runWith(current, execute); + const result = await run; + + // The probe runs a prefix without a preview tile; the full attempt runs + // everything with one, at the slab the probe sized. + expect( + attempts.map(({ runCount, preview }) => ({ runCount, preview })), + ).toEqual([ + { runCount: 128, preview: false }, + { runCount: 1000, preview: true }, + ]); + expect(attempts[1]?.shader.stateWordsPerRun).toBe(4 + 19 * 2); + expect(remembered).toEqual([[{ lo: 14, stride: 1, integer: true }]]); + expect(result).toMatchObject({ + kind: "calibrated", + result: { completedRuns: 1000 }, + }); + }); + + it("runs a cached calibration as a single attempt", async () => { + const current = session({ p: 19 }); + const { execute, attempts } = scripted([{ ok: true, result: outcome() }]); + + const { run, remembered } = runWith(current, execute, { + calibratedWindows: [{ lo: 14, stride: 1, integer: true }], + }); + await run; + + expect(attempts).toEqual([ + expect.objectContaining({ + runCount: 1000, + preview: true, + windows: [{ lo: 14, stride: 1, integer: true }], + }), + ]); + expect(remembered).toEqual([]); + }); + + it("probes blind windows alone when no place needs a slab", async () => { + const current = session({}); + const { execute, attempts } = scripted([ + { ok: true, result: outcome() }, + { ok: true, result: outcome() }, + ]); + + const { run, remembered } = runWith(current, execute); + await run; + + expect( + attempts.map(({ runCount, preview }) => ({ runCount, preview })), + ).toEqual([ + { runCount: 128, preview: false }, + { runCount: 1000, preview: true }, + ]); + expect(remembered).toHaveLength(1); + }); + + it("skips every probe when the windows have ceilings and no slab is derived", async () => { + const current = session({}); + const { execute, attempts } = scripted([{ ok: true, result: outcome() }]); + + const { run } = runWith(current, execute, { + windowInputs: [{ integer: true, ceiling: 200 }], + }); + await run; + + expect(attempts).toHaveLength(1); + expect(attempts[0]?.preview).toBe(true); + }); + + it("reports a probe that concedes as failed, telling the user what to do", async () => { + const current = session({ p: 64 }); + const { execute } = scripted([ + { + ok: true, + result: outcome({ + derivedPlaceMaxes: [{ max: 20_000, meanRunMax: 100 }], + }), + }, + ]); + + const { run } = runWith(current, execute); + + expect(await run).toMatchObject({ + kind: "failed", + reason: /outlier runs.*Switch this experiment to the CPU backend/, + }); + }); + + it("ends the run on a metric the capacity probe halted, before the full attempt", async () => { + const current = session({ p: 64 }); + const { execute, attempts } = scripted([ + { ok: true, result: outcome({ metricErrors: [2] }) }, + { ok: true, result: outcome({ completedRuns: 1000 }) }, + ]); + + const { run, remembered } = runWith(current, execute, { + metricFailure: haltedMetric, + }); + + expect(await run).toEqual({ + kind: "failed", + reason: "halted 2 of 128 runs", + }); + expect(attempts).toHaveLength(1); + // The slabs and windows the probe settled still serve the next batch. + expect(remembered).toHaveLength(1); + }); + + it("ends the run on a metric the window probe halted, before the full attempt", async () => { + const current = session({}); + const { execute, attempts } = scripted([ + { ok: true, result: outcome({ metricErrors: [1] }) }, + { ok: true, result: outcome() }, + ]); + + const { run } = runWith(current, execute, { metricFailure: haltedMetric }); + + expect(await run).toEqual({ + kind: "failed", + reason: "halted 1 of 128 runs", + }); + expect(attempts).toHaveLength(1); + }); + + it("reports a stop during the probe as stopped, not as an error", async () => { + const current = session({ p: 64 }); + const { execute, attempts } = scripted([ + { ok: true, result: outcome() }, + { ok: true, result: outcome() }, + ]); + + const { run } = runWith(current, execute, { + stopped: () => attempts.length > 0, + }); + + expect(await run).toEqual({ kind: "stopped" }); + expect(attempts).toHaveLength(1); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts new file mode 100644 index 00000000000..bf3fd6d362e --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts @@ -0,0 +1,143 @@ +/** + * The attempts one GPU experiment makes once `start()` is called, in order: + * calibrate what the shader cannot know up front, then run in full. + * + * Every attempt streams its chunks through the same `execute`, so the first + * probe's frames reach the charts as early as any later chunk. Keeping the + * ordering here, over a plain `ExecuteAttempt`, is what lets it be tested + * without a device. + */ +import { planInitialWindows } from "../metric-windows"; +import { + probeDerivedCapacities, + probeRunCount, + probeWindows, + RUN_POLICY, + runUntilCalibrated, +} from "./calibration"; + +import type { MetricWindow, MetricWindowInput } from "../metric-windows"; +import type { GpuExperimentResult } from "../runner"; +import type { CalibrationSession, ExecuteAttempt } from "./calibration"; + +export type RunPhaseOutcome = + /** The caller cancelled or disposed the experiment before the full attempt. */ + | { kind: "stopped" } + | { kind: "failed"; reason: string } + /** + * The full attempt's last result — possibly cancelled midway, possibly + * still overflowing — with the windows it ran at. + */ + | { + kind: "calibrated"; + result: GpuExperimentResult; + windows: MetricWindow[]; + }; + +/** + * Probes when nothing is calibrated yet — derived capacities first, which + * also observes the metric ranges; else the blind windows alone — then runs + * the full attempt under `RUN_POLICY`. + */ +export const runCalibratedExperiment = async (options: { + session: CalibrationSession; + /** Windows an earlier batch calibrated on this marking; null probes afresh. */ + calibratedWindows: readonly MetricWindow[] | null; + windowInputs: readonly MetricWindowInput[]; + placeCounts: readonly number[]; + runCount: number; + execute: ExecuteAttempt; + /** Whether the caller has abandoned the run, checked between attempts. */ + stopped: () => boolean; + /** Hears each calibration a probe settles, for later batches on this marking. */ + remember: (windows: readonly MetricWindow[]) => void; + /** + * The failure an attempt's halted-metric counts amount to over the runs it + * executed, or null. Seeds derive from the run index, so a run a probe + * halted halts again in full: the probe's counts end the run before the + * full attempt executes. + */ + metricFailure: ( + metricErrors: readonly number[], + runCount: number, + ) => string | null; +}): Promise => { + const { + session, + calibratedWindows, + windowInputs, + placeCounts, + runCount, + execute, + stopped, + remember, + metricFailure, + } = options; + + let windows: readonly MetricWindow[]; + if (calibratedWindows !== null) { + windows = calibratedWindows; + } else if (session.capacities.size > 0) { + const probed = await probeDerivedCapacities({ + session, + runCount, + windowInputs, + placeCounts, + execute, + stopped, + }); + if (stopped()) { + return { kind: "stopped" }; + } + if (!probed.ok) { + return { kind: "failed", reason: probed.reason }; + } + windows = probed.windows; + remember(windows); + const probedFailure = metricFailure(probed.metricErrors, probed.probeRuns); + if (probedFailure !== null) { + return { kind: "failed", reason: probedFailure }; + } + } else { + windows = planInitialWindows(windowInputs, session.shader.histogramBins); + const blindWindows = windowInputs.some((input) => input.ceiling === null); + if (blindWindows) { + const probeRuns = probeRunCount(session.shader, runCount); + const probe = await probeWindows({ + session, + windows, + execute, + runCount: probeRuns, + }); + if (!probe.ok) { + return { kind: "failed", reason: probe.reason }; + } + if (probe.result.cancelled || stopped()) { + return { kind: "stopped" }; + } + const probeFailure = metricFailure(probe.result.metricErrors, probeRuns); + if (probeFailure !== null) { + return { kind: "failed", reason: probeFailure }; + } + windows = probe.windows; + remember(windows); + } + } + + const calibrated = await runUntilCalibrated({ + session, + runsFor: () => runCount, + windows, + execute, + policy: RUN_POLICY, + stopped, + }); + if (!calibrated.ok) { + return { kind: "failed", reason: calibrated.reason }; + } + return { + kind: "calibrated", + result: calibrated.result, + windows: calibrated.windows, + }; +}; diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 15c56e12086..0aaddace93c 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -96,7 +96,7 @@ The switch is greyed out when the current model cannot run on the GPU; hover it The GPU backend handles a **subset** of nets, and it tells you when it cannot take one rather than guessing. It needs: - **metric values that fit the histogram.** Metrics are reduced on the device into a histogram whose bins cover a window of values — exact integers for counts, a calibrated range for real-valued metrics, whose bin width the heatmap shows. The window calibrates itself: a short probe observes each measured place's range, the full run uses that range, and a run that escapes its window is recalibrated and re-run automatically — no refusals or warnings based on absolute counts. Calibration is remembered while an experiment is open, so moving the sweep's sliders does not re-probe what an earlier selection already measured. Only the _span_ is limited: up to three metrics get 1,024 distinct values each (more metrics share the budget), and a wider span is binned at reduced resolution; -- typed places to have _measurable_ token counts. A declared [token capacity](drawing-a-net.md#token-capacity) is used directly; without one, a short probe measures each place's real maximum and sizes the buffers from it (growing and re-running automatically if a run later outgrows the estimate). A place whose probe shows rare extreme outliers runs on the CPU instead — sizing every run for the outlier would waste the GPU's memory; +- typed places to have _measurable_ token counts. A declared [token capacity](drawing-a-net.md#token-capacity) is used directly; without one, a short probe measures each place's real maximum and sizes the buffers from it (growing and re-running automatically if a run later outgrows the estimate). A place whose probe shows rare extreme outliers stops the experiment with a message asking you to run it on the CPU instead — sizing every run for the outlier would waste the GPU's memory; - no `string` or `uuid` token attributes, which need more than the 32 bits WebGPU offers; - **arcs consuming at most two typed tokens per place.** A condition that reads token attributes runs on the GPU at weight 1 and at weight 2 — a pairwise condition like a collision test is scanned over every pair — but not beyond; - typed tokens consumed from at most one place per transition, since two would be a cross-product enumeration across arcs (the one gate a bundled example — Production Machines — still hits); diff --git a/libs/@local/petrinaut-arch-docs/content/diagrams/gpu-capacity-calibration.d2 b/libs/@local/petrinaut-arch-docs/content/diagrams/gpu-capacity-calibration.d2 index c53ee42d6a4..52a3ea3fd42 100644 --- a/libs/@local/petrinaut-arch-docs/content/diagrams/gpu-capacity-calibration.d2 +++ b/libs/@local/petrinaut-arch-docs/content/diagrams/gpu-capacity-calibration.d2 @@ -11,7 +11,7 @@ device: "GPU device" { decide: "decide per place" {style.fill: "#dcecff"; style.stroke: "#3676b8"} grow: "overflowed?\nquadruple the slabs,\nrecompile, probe again" {style.fill: "#dcecff"; style.stroke: "#3676b8"} fit: "max ≈ typical\nslab = 1.5 × observed max\nrecompile, run in full" {style.fill: "#dcecff"; style.stroke: "#3676b8"} -arena: "max ≫ typical and\nslab too big — the token\narena's case (planned):\nrun on the CPU instead" {style.fill: "#fdeaea"; style.stroke: "#b83636"} +arena: "max ≫ typical and\nslab too big — the token\narena's case (planned):\nthe run stops, asking for the CPU" {style.fill: "#fdeaea"; style.stroke: "#b83636"} rerun: "overflow mid-run?\ngrow, recompile,\nre-run (same seeds)" {style.fill: "#dcecff"; style.stroke: "#3676b8"} probe -> device.track diff --git a/libs/@local/petrinaut-arch-docs/content/experiments/backend-selection.mdx b/libs/@local/petrinaut-arch-docs/content/experiments/backend-selection.mdx index 6df94ca8512..e6bbf4e09c2 100644 --- a/libs/@local/petrinaut-arch-docs/content/experiments/backend-selection.mdx +++ b/libs/@local/petrinaut-arch-docs/content/experiments/backend-selection.mdx @@ -18,7 +18,9 @@ closure, so work already done (a compiled shader, a shard plan) flows into the run and the verdict can never be about a different thing. Instantiation can still fail, but only for `environment` or `capacity` reasons. A -device that will not allocate is not the net's fault. +device that will not allocate is not the net's fault. What only a run can tell — +a derived token slab the GPU's probe cannot size — surfaces as that run's error, +not as a refusal: the probe runs after `start()`, so its frames stream. ![Choosing a backend for one experiment](@diagrams/backend-selection.svg) diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx index a8cb9025b00..b29e3d106f0 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx @@ -30,14 +30,15 @@ never blocks a firing over it. ![Probe, decide, grow](@diagrams/gpu-capacity-calibration.svg) -1. **Probe.** Before the experiment handle exists, a small prefix of the runs - executes at generous slabs (four times each place's initial tokens). Few - runs afford big slabs, the probe's frames stream to the charts, and its - shader tracks each derived place's **per-run maximum count** (a register - per place, folded into the summary). A probe that overflows quadruples the - slabs, recompiles, and probes again — shedding runs to stay inside a - 128 MB probe budget, so seven growth attempts reach counts past a million - (64 × 4⁷) before conceding to the CPU. +1. **Probe.** As the first phase of the run — after `start()`, so its + chunks stream to the charts like every other attempt's — a small prefix of + the runs executes at generous slabs (four times each place's initial + tokens). Few runs afford big slabs, and the probe's shader tracks each + derived place's **per-run maximum count** (a register per place, folded + into the summary). A probe that overflows quadruples the slabs, + recompiles, and probes again — shedding runs to stay inside a 128 MB + probe budget, so seven growth attempts reach counts past a million + (64 × 4⁷) before conceding. 2. **Decide, per place.** The probe yields the largest per-run maximum and the mean of the per-run maxima. When the largest is close to typical, the slab becomes 1.5 × the observed maximum plus headroom — recompile, run in @@ -45,10 +46,9 @@ never blocks a firing over it. the outlier-sized slab would be big, that heavy-tailed shape is the planned **per-run token arena**'s case — shared place-tagged slots sized by the simultaneous total, paying indirection only where nothing else - works. Until the arena exists, that branch refuses cleanly during - creation, so the backend-selection walk falls back to the CPU, which - sizes its buffers dynamically. The experiment runs on the other backend, - with the reason surfaced only as the backend indicator's tooltip. + works. Until the arena exists, that branch ends the run with a reason + that asks the user to switch the experiment to the CPU, which sizes its + buffers dynamically. 3. **Detect and grow, in a loop.** The full run can still outgrow the probe's estimate. A frame whose post-fold count exceeds a derived slab sets the run's status to _overflowed_ — an exact check, since the highest slot a @@ -57,13 +57,12 @@ never blocks a firing over it. discards the attempt. The handle doubles the slabs, recompiles, and re-runs. Seeds derive from absolute run indices, so a re-run reproduces the same trajectories and each retry strictly grows — the loop converges - or hands the experiment to the CPU. A run a non-finite metric sample - halted would halt again on the same seeds, so an attempt with one is - handed back at once and the handle reports the metric, ahead of any - overflow the attempt also carries. A probe a metric halted is handed back - before its slabs are sized, so neither an overflow nor the arena case - pre-empts the halt, and its calibration is not kept for the next batch on - the marking. + or ends the run with its reason. A run a non-finite metric sample halted + would halt again on the same seeds, so an attempt with one is handed back + at once and the handle reports the metric, ahead of any overflow the + attempt also carries. A probe a metric halted is handed back before its + slabs are sized, so neither an overflow nor the arena case pre-empts the + halt, and its calibration is not kept for the next batch on the marking. Derived and declared capacities keep distinct semantics, recorded as `capacitySource` on the profile: declared blocks firings (CPU parity), From d49354dd5be606835de71efd64bb7174ff512718 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 05:20:16 +0200 Subject: [PATCH 02/10] Carry a sweep's swept net parameters in every batch's run plan so point selections share the GPU setup --- .changeset/gpu-first-run-streams.md | 3 +- .../src/webgpu/gpu-backend-cache.test.ts | 8 ++ .../src/react/experiments/provider.tsx | 1 + .../provider/sweep-batch-instantiation.ts | 28 ++++- .../sweep-run-overrides.test.ts | 92 ++++++++++++++- .../sweep-run-overrides.ts | 105 ++++++++++++++++-- .../experiments/sweep-orchestration.mdx | 10 +- .../simulation/gpu-capacity-calibration.mdx | 15 ++- 8 files changed, 238 insertions(+), 24 deletions(-) diff --git a/.changeset/gpu-first-run-streams.md b/.changeset/gpu-first-run-streams.md index 2f224229bac..7fd8fba65bd 100644 --- a/.changeset/gpu-first-run-streams.md +++ b/.changeset/gpu-first-run-streams.md @@ -1,5 +1,6 @@ --- "@hashintel/petrinaut-core": patch +"@hashintel/petrinaut": patch --- -The GPU backend's capacity probe runs after the experiment starts, so a batch's first frames stream instead of arriving once the probe is done. +A sweep's first frames on the GPU stream from the start of every selection: the capacity probe runs after the experiment starts, and every selection shares one compiled GPU setup. diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-backend-cache.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-backend-cache.test.ts index 90429633344..4f20ee0d88c 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-backend-cache.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-backend-cache.test.ts @@ -245,8 +245,16 @@ describe("gpuBackendSetupKey", () => { }; it("ignores the values of per-run-buffered parameters", () => { + // A point batch at one rate and a range batch at another both carry the + // rate in the buffer, so every selection of a sweep shares one setup. const other = { ...base, parameterValues: { rate: "3.9", size: "10" } }; expect(gpuBackendSetupKey(base)).toBe(gpuBackendSetupKey(other)); + expect(gpuBackendSetupKey(base)).toBe( + gpuBackendSetupKey({ + ...base, + parameterValues: { rate: "0.25", size: "10" }, + }), + ); }); it("keys on baked values, marking, net identity, and metric set", () => { diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx index 6ac82eafd5c..4ab2e80e0c6 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx @@ -361,6 +361,7 @@ export const ExperimentsProvider: React.FC = ({ // and leave the rest of the UI most of each frame's budget. publishThrottleMs: 100, instantiateBatch: createSweepBatchInstantiator({ + axes, registrations, buildRequest, compiler, diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation.ts b/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation.ts index d18af69ed46..383b56348bf 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation.ts @@ -1,8 +1,13 @@ import { selectExperimentBackend } from "@hashintel/petrinaut-core/experiments"; import { instantiateOnBackend } from "./shared/instantiate-on-backend"; -import { translateRangeDraws } from "./sweep-batch-instantiation/sweep-run-overrides"; +import { + constantRunPlan, + sweptNetParameterIds, + translateRangeDraws, +} from "./sweep-batch-instantiation/sweep-run-overrides"; +import type { ExperimentParameterAxis } from "../parameter-grid"; import type { InstantiateSweepBatch } from "../sweep-session"; import type { BuildExperimentRequest, @@ -21,10 +26,15 @@ import type { * * The first batch walks the backend selection — so GPU-vs-CPU choice and * fallback reporting behave as for a plain experiment — and later batches - * re-assess the chosen backend with their own request (the GPU backend - * regenerates its shader for the new parameter values there). + * re-assess the chosen backend with their own request. + * + * Every batch carries the swept net parameters in its run plan, a point + * selection as one constant row per run: the values then ride the per-run + * buffer rather than being baked into the request, so the GPU backend keeps + * one compiled setup — and its calibration — across every selection. */ export const createSweepBatchInstantiator = ({ + axes, registrations, buildRequest, compiler, @@ -32,6 +42,7 @@ export const createSweepBatchInstantiator = ({ onBackendChosen, onNote, }: { + axes: readonly ExperimentParameterAxis[]; registrations: readonly ExperimentBackendRegistration[]; buildRequest: BuildExperimentRequest; compiler: SweptScenarioCompiler; @@ -43,8 +54,16 @@ export const createSweepBatchInstantiator = ({ onNote: (note: ExperimentNote) => void; }): InstantiateSweepBatch => { let chosenBackend: ExperimentBackend | null = null; + // Found on the first batch, where a compile error fails that batch rather + // than the session's creation. + let sweptIds: readonly string[] | null = null; return async ({ parameterValues, draws, seed, runCount, signal }) => { + sweptIds ??= sweptNetParameterIds({ + axes, + compileRunNumbers: compiler.compileRunNumbers, + netParameterVariableNames, + }); const compiled = compiler.compileForValues(parameterValues); const baseParameters = compiler.compileRunNumbers(parameterValues).parameters; @@ -53,7 +72,7 @@ export const createSweepBatchInstantiator = ({ // gives every backend net-keyed per-run values. const runPlan = draws === undefined - ? undefined + ? constantRunPlan(sweptIds, baseParameters, runCount) : await translateRangeDraws({ draws, signal, @@ -61,6 +80,7 @@ export const createSweepBatchInstantiator = ({ baseParameters, compileRunNumbers: compiler.compileRunNumbers, netParameterVariableNames, + ids: sweptIds, }); const override: ExperimentRequestOverride = { parameterValues: compiled.result.parameterValues, diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.test.ts b/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.test.ts index c0e77e4f87c..4d4328010ff 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.test.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { translateRangeDraws } from "./sweep-run-overrides"; +import { + constantRunPlan, + sweptNetParameterIds, + translateRangeDraws, +} from "./sweep-run-overrides"; + +import type { ExperimentParameterAxis } from "../../parameter-grid"; /** Overrides: net `rate` = scenario `speed` × 2; net `size` is untouched. */ const compileRunNumbers = (swept: Readonly>) => ({ @@ -16,6 +22,7 @@ describe("translateRangeDraws", () => { baseParameters, compileRunNumbers, netParameterVariableNames: new Set(["rate", "size"]), + ids: [], }); expect(plan?.ids).toEqual(["rate"]); @@ -31,6 +38,7 @@ describe("translateRangeDraws", () => { baseParameters, compileRunNumbers, netParameterVariableNames: new Set(["rate", "size"]), + ids: [], }); expect(plan?.ids).toEqual(["rate"]); @@ -44,6 +52,7 @@ describe("translateRangeDraws", () => { baseParameters, compileRunNumbers: () => ({ parameters: { ...baseParameters } }), netParameterVariableNames: new Set(["rate", "size"]), + ids: [], }); expect(plan?.ids).toEqual(["size"]); @@ -57,6 +66,7 @@ describe("translateRangeDraws", () => { baseParameters, compileRunNumbers: () => ({ parameters: { ...baseParameters } }), netParameterVariableNames: new Set(["rate", "size"]), + ids: [], }); expect(plan).toBeUndefined(); @@ -71,6 +81,7 @@ describe("translateRangeDraws", () => { parameters: { rate: 3, armed: (swept.speed ?? 0) > 2 }, }), netParameterVariableNames: new Set(["rate", "armed"]), + ids: [], }); expect(plan?.ids).toEqual(["armed"]); @@ -87,6 +98,7 @@ describe("translateRangeDraws", () => { baseParameters: { rate: 3, armed: false }, compileRunNumbers: () => ({ parameters: { rate: 3, armed: false } }), netParameterVariableNames: new Set(["rate", "armed"]), + ids: [], }); expect(plan?.ids).toEqual(["armed"]); @@ -107,9 +119,87 @@ describe("translateRangeDraws", () => { baseParameters: compileNumbers({ speed: 1.5 }).parameters, compileRunNumbers: compileNumbers, netParameterVariableNames: new Set(["rate", "size"]), + ids: [], }); expect(plan?.ids).toEqual(["rate", "size"]); expect([...plan!.values]).toEqual([6, 7, 3, 9]); }); }); + +describe("sweptNetParameterIds", () => { + const axis = ( + identifier: string, + min: number, + max: number, + ): ExperimentParameterAxis => ({ + identifier, + min, + max, + stepCount: 50, + integer: false, + }); + + it("finds the names an override computes, a direct net-name axis, and skips untouched names", () => { + const ids = sweptNetParameterIds({ + axes: [axis("speed", 1, 2), axis("size", 5, 9), axis("irrelevant", 0, 1)], + compileRunNumbers: (swept) => ({ + parameters: { + rate: (swept.speed ?? 1) * 2, + size: swept.size ?? 7, + fixed: 3, + }, + }), + netParameterVariableNames: new Set(["rate", "size", "fixed"]), + }); + + expect(ids).toEqual(["rate", "size"]); + }); + + it("is empty when no axis reaches a net parameter", () => { + const ids = sweptNetParameterIds({ + axes: [axis("irrelevant", 0, 1)], + compileRunNumbers: () => ({ parameters: { rate: 3 } }), + netParameterVariableNames: new Set(["rate"]), + }); + + expect(ids).toEqual([]); + }); +}); + +describe("translateRangeDraws with a supplied id set", () => { + it("carries every supplied id even when no run moves it", async () => { + // Both runs draw the midpoint: nothing differs from the base, but the + // column still rides so this batch lays out the same buffer as any other. + const plan = await translateRangeDraws({ + draws: { identifiers: ["speed"], values: new Float64Array([1.5, 1.5]) }, + midValues: { speed: 1.5 }, + baseParameters, + compileRunNumbers, + netParameterVariableNames: new Set(["rate", "size"]), + ids: ["rate"], + }); + + expect(plan?.ids).toEqual(["rate"]); + expect([...plan!.values]).toEqual([3, 3]); + }); +}); + +describe("constantRunPlan", () => { + it("repeats the point's values for every run, run-major", () => { + const plan = constantRunPlan(["rate", "size"], { rate: 3, size: 7 }, 3); + + expect(plan?.ids).toEqual(["rate", "size"]); + expect([...plan!.values]).toEqual([3, 7, 3, 7, 3, 7]); + }); + + it("carries a boolean as 1/0", () => { + const plan = constantRunPlan(["armed"], { armed: true }, 2); + + expect([...plan!.values]).toEqual([1, 1]); + }); + + it("is undefined when there is nothing to carry", () => { + expect(constantRunPlan([], { rate: 3 }, 4)).toBeUndefined(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.ts b/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.ts index d2475a5847c..d5ad52e24ed 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider/sweep-batch-instantiation/sweep-run-overrides.ts @@ -1,5 +1,5 @@ /** - * Translates a range batch's per-run scenario-parameter draws into a per-run + * Translates a sweep batch's swept scenario parameters into a per-run * net-parameter plan. * * A sweep draws values for *scenario* parameters, but a run's simulation @@ -11,12 +11,24 @@ * A scenario identifier that IS a net variable name passes through as a * direct override where no override expression computed that name, matching * how the engine merges run values by variable name. + * + * Every batch of one experiment carries the same net names — the ones any + * swept axis can reach, found once from the axes — whether its runs vary + * them or not, a point selection included. A backend that keys its compiled + * setup on which parameters ride in the per-run buffer then keeps one setup, + * and the calibration learned with it, across every selection of the sweep. */ import { createCooperativeYielder } from "../../cooperative-yield"; +import { axisValueAt } from "../../parameter-grid"; +import type { ExperimentParameterAxis } from "../../parameter-grid"; import type { SweepRunDraws } from "../../sweep-session"; import type { ExperimentRunPlan } from "@hashintel/petrinaut-core/experiments"; +type CompileRunNumbers = (swept: Readonly>) => { + parameters: Readonly>; +}; + export type TranslateRangeDrawsOptions = { /** The batch's per-run draws, keyed by scenario parameter identifier. */ draws: SweepRunDraws; @@ -27,11 +39,14 @@ export type TranslateRangeDrawsOptions = { /** Net parameter values the batch compiled at `midValues`, as numbers. */ baseParameters: Readonly>; /** Compiles the scenario for one concrete swept assignment, as numbers. */ - compileRunNumbers: (swept: Readonly>) => { - parameters: Readonly>; - }; + compileRunNumbers: CompileRunNumbers; /** The net's parameter variable names, for direct-override passthrough. */ netParameterVariableNames: ReadonlySet; + /** + * Net names the plan carries whatever the runs draw — + * `sweptNetParameterIds` — so every batch lays out the same buffer. + */ + ids: readonly string[]; }; /** Booleans ride the plan as 1/0; the engine parses them back by type. */ @@ -39,9 +54,77 @@ const planNumber = (value: number | boolean): number => typeof value === "boolean" ? (value ? 1 : 0) : value; /** - * Returns a run-major plan over the union of every net name any run - * changes, or undefined when no run changes anything (the whole batch - * behaves as the midpoint compilation). + * The net parameter names a sweep's axes can move, sorted: every name whose + * compiled value at an axis's end differs from the all-midpoint compile, and + * every axis that names a net parameter directly. Found once per experiment, + * this is the id set every batch's plan carries. + */ +export const sweptNetParameterIds = (options: { + axes: readonly ExperimentParameterAxis[]; + compileRunNumbers: CompileRunNumbers; + netParameterVariableNames: ReadonlySet; +}): readonly string[] => { + const { axes, compileRunNumbers, netParameterVariableNames } = options; + const midpoints: Record = {}; + for (const axis of axes) { + midpoints[axis.identifier] = axisValueAt(axis, axis.stepCount / 2); + } + const base = compileRunNumbers(midpoints).parameters; + const ids = new Set(); + for (const axis of axes) { + if ( + netParameterVariableNames.has(axis.identifier) && + base[axis.identifier] !== undefined + ) { + ids.add(axis.identifier); + } + for (const position of [0, axis.stepCount]) { + const { parameters } = compileRunNumbers({ + ...midpoints, + [axis.identifier]: axisValueAt(axis, position), + }); + for (const [name, value] of Object.entries(parameters)) { + if (value !== base[name]) { + ids.add(name); + } + } + } + } + return [...ids].sort(); +}; + +/** + * A point selection's plan: every run carries the point's compiled values + * for `ids`, so the batch lays out the same buffer as a range batch would. + * Undefined when there is nothing to carry. + */ +export const constantRunPlan = ( + ids: readonly string[], + baseParameters: Readonly>, + runCount: number, +): ExperimentRunPlan | undefined => { + const row: number[] = []; + for (const id of ids) { + const base = baseParameters[id]; + if (base === undefined) { + return undefined; + } + row.push(planNumber(base)); + } + if (row.length === 0) { + return undefined; + } + const values = new Float64Array(runCount * row.length); + for (let run = 0; run < runCount; run++) { + values.set(row, run * row.length); + } + return { ids, values }; +}; + +/** + * Returns a run-major plan over `ids` plus every net name any run changes, + * or undefined when there is nothing to carry (the whole batch behaves as + * the midpoint compilation). * * Every run carries every id in the plan — a run whose draw compiles back * to a base value carries that base value explicitly. Backends lay per-run @@ -66,7 +149,7 @@ export const translateRangeDraws = async ( return undefined; } - // One column per net name some run changes, pre-filled with the base value + // One column per net name the plan carries, pre-filled with the base value // so runs that draw the base carry it without a separate fill pass. const columns = new Map(); const columnFor = (name: string, base: number): Float64Array => { @@ -77,6 +160,12 @@ export const translateRangeDraws = async ( } return column; }; + for (const id of options.ids) { + const base = baseParameters[id]; + if (base !== undefined) { + columnFor(id, planNumber(base)); + } + } // The parameter record's key set is identical across compiles (the same // defaults template seeds every call), so the keys are read once. diff --git a/libs/@local/petrinaut-arch-docs/content/experiments/sweep-orchestration.mdx b/libs/@local/petrinaut-arch-docs/content/experiments/sweep-orchestration.mdx index d67c8477a22..8dd2342615a 100644 --- a/libs/@local/petrinaut-arch-docs/content/experiments/sweep-orchestration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/experiments/sweep-orchestration.mdx @@ -25,10 +25,12 @@ stay in rung order. Run ranges are disjoint and per-run draws and seeds are prefix-stable, which is what makes overlapping rungs sound. Each rung asks the provider to instantiate a batch. The provider builds an -`ExperimentRequest` (per-run parameter draws travel as a typed-array plan) -and lets the backend selection choose -between the CPU worker pool and the WebGPU backend the first time; later -rungs reuse the chosen backend. +`ExperimentRequest` (per-run parameter draws travel as a typed-array plan +over the net parameters the sweep's axes can reach — a point selection as a +constant plan, so every batch lays out the same per-run buffer and the GPU +backend keeps one compiled setup across selections) and lets the backend +selection choose between the CPU worker pool and the WebGPU backend the first +time; later rungs reuse the chosen backend. Frames come back through the experiment handle's stores. The session merges the selection's cached frames with the live rungs' frames and publishes on a diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx index b29e3d106f0..fb6806aeca9 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx @@ -92,12 +92,15 @@ whole setup: request a device, generate and compile the WGSL, probe. The experiment backend now holds a `gpu-backend-cache`: one entry keyed by what actually shapes the shader — net and artifact identity, baked parameter values, metric set, `dt`, integration method, initial marking. Values of -parameters carried in the per-run buffer are deliberately NOT in the key, so -range batches across different selections share one backend, and with it the -calibration (windows, derived capacities, the shader compiled at them) -learned by earlier batches. A stale calibration heals through the same -escape/overflow re-runs that calibrate from scratch, and the cache is -updated with what each batch learned. +parameters carried in the per-run buffer are deliberately NOT in the key, and +a sweep carries every net parameter its axes can reach in that buffer for +every batch — a point selection as one constant row per run — so all of a +sweep's selections share one backend, and with it the calibration (windows, +derived capacities, the shader compiled at them) learned by earlier batches. +A stale calibration heals through the same escape/overflow re-runs that +calibrate from scratch, and the cache is updated with what each batch +learned. Only a selection that changes the initial marking (a scenario axis +that shapes the initial state) still builds a new setup. Devices need explicit destruction and pipelined rungs lease the backend concurrently, so entries count leases: a displaced (or unsupported) entry From 276b1cfbc95786336d8aae64053c2402c1207fa6 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 05:32:24 +0200 Subject: [PATCH 03/10] Share one capacity probe between rungs that start on the same marking --- .../petrinaut-core/src/webgpu/backend.ts | 7 +++ .../src/webgpu/gpu-experiment-handle.ts | 49 +++++++++++++------ .../shared-calibration.test.ts | 42 ++++++++++++++++ .../shared-calibration.ts | 40 +++++++++++++++ .../simulation/gpu-capacity-calibration.mdx | 7 +++ 5 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.test.ts create mode 100644 libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.ts diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/backend.ts b/libs/@hashintel/petrinaut-core/src/webgpu/backend.ts index 087bfde99d6..4a64383759f 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/backend.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/backend.ts @@ -106,6 +106,12 @@ export type GpuBackend = { * through the same escape/overflow re-runs that calibrate from scratch. */ calibration: Map; + /** + * Probes in flight, keyed like `calibration`: a batch that starts while + * another still probes its marking awaits the entry instead of probing + * too (`gpu-experiment-handle/shared-calibration`). + */ + calibrating: Map>; framesPerDispatch: number; /** Notes that did not prevent use, e.g. user code that fell back to a default. */ warnings: string[]; @@ -236,6 +242,7 @@ export async function requestGpuExperimentBackend( derivedCapacities: probeCapacities, recompile: compileWith, calibration: new Map(), + calibrating: new Map(), framesPerDispatch, warnings, }; diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts index 8ec95fbea32..3756711b009 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -31,6 +31,7 @@ import { createFrameMerger } from "./gpu-experiment-handle/frame-merge"; import { metricFailure } from "./gpu-experiment-handle/metric-failure"; import { deriveRunParameters } from "./gpu-experiment-handle/run-parameters"; import { runCalibratedExperiment } from "./gpu-experiment-handle/run-phase"; +import { shareCalibration } from "./gpu-experiment-handle/shared-calibration"; import { toGpuMetricFrames, toGpuMetricSpecs } from "./gpu-metric-frames"; import { anyEscapes, calibrationKey } from "./metric-windows"; import { GPU_PREVIEW_RUNS, runGpuExperiment } from "./runner"; @@ -378,7 +379,17 @@ export async function createGpuMonteCarloExperiment( placeTokenWords, metricIds, }); - const cachedCalibration = backend.calibration.get(batchCalibrationKey); + const adoptCalibration = (): MetricWindow[] | null => { + const cached = backend.calibration.get(batchCalibrationKey); + if (cached === undefined) { + return null; + } + session.shader = cached.shader; + for (const [placeId, capacity] of cached.capacities) { + session.capacities.set(placeId, capacity); + } + return [...cached.windows]; + }; const storeCalibration = (windows: readonly MetricWindow[]) => { if (metricIds.length === 0 && session.capacities.size === 0) { return; @@ -468,20 +479,27 @@ export async function createGpuMonteCarloExperiment( runCount, }); - const calibratedWindows: MetricWindow[] | null = cachedCalibration - ? [...cachedCalibration.windows] - : null; - if (cachedCalibration) { - session.shader = cachedCalibration.shader; - for (const [placeId, capacity] of cachedCalibration.capacities) { - session.capacities.set(placeId, capacity); - } - } - const run = async () => { // The probes run here, after `start()`, so their chunks stream like // every other attempt's: the first picture a batch shows is the probe's, - // overwritten progressively as the full attempt lands. + // overwritten progressively as the full attempt lands. A batch that + // starts while another on this marking still probes waits for that + // calibration rather than probing too. + let calibratedWindows = adoptCalibration(); + let settle = () => {}; + if (calibratedWindows === null) { + const share = shareCalibration(backend.calibrating, batchCalibrationKey); + if (share.inFlight !== undefined) { + await share.inFlight; + if (isDisposed()) { + return; + } + calibratedWindows = adoptCalibration(); + } + if (calibratedWindows === null) { + settle = share.claim(); + } + } const calibrated = await runCalibratedExperiment({ session, calibratedWindows, @@ -490,9 +508,12 @@ export async function createGpuMonteCarloExperiment( runCount: config.runCount, execute: executeAttempt, stopped: () => isDisposed() || signal.aborted, - remember: storeCalibration, + remember: (windows) => { + storeCalibration(windows); + settle(); + }, metricFailure: metricFailureIn, - }); + }).finally(settle); if (isDisposed()) { return; diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.test.ts new file mode 100644 index 00000000000..196b7169142 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { shareCalibration } from "./shared-calibration"; + +describe("shareCalibration", () => { + it("lets a later batch wait for the batch that claimed the key", async () => { + const calibrating = new Map>(); + const first = shareCalibration(calibrating, "marking"); + expect(first.inFlight).toBeUndefined(); + const settle = first.claim(); + + const second = shareCalibration(calibrating, "marking"); + expect(second.inFlight).toBeDefined(); + let woke = false; + void second.inFlight!.then(() => { + woke = true; + }); + await Promise.resolve(); + expect(woke).toBe(false); + + settle(); + await Promise.resolve(); + expect(woke).toBe(true); + expect(calibrating.has("marking")).toBe(false); + }); + + it("settles idempotently and never drops a newer claim", () => { + const calibrating = new Map>(); + const stale = shareCalibration(calibrating, "marking").claim(); + stale(); + const fresh = shareCalibration(calibrating, "marking"); + expect(fresh.inFlight).toBeUndefined(); + const settleFresh = fresh.claim(); + + stale(); + expect(calibrating.has("marking")).toBe(true); + + settleFresh(); + expect(calibrating.has("marking")).toBe(false); + expect(shareCalibration(calibrating, "other").inFlight).toBeUndefined(); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.ts new file mode 100644 index 00000000000..400e6186f01 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.ts @@ -0,0 +1,40 @@ +/** + * One probe per marking, however many batches start on it at once. + * + * The ladder pipelines its rungs off the first streamed chunk, and the first + * chunk a fresh marking streams is its probe's — so the next rung starts + * while the calibration it could reuse is still being measured. The batch + * that probes claims the key; batches arriving meanwhile wait for its + * settle and read the stored calibration instead of probing too. + */ +export type CalibrationShare = { + /** Another batch's probe of this key, settled once it stored or gave up. */ + inFlight: Promise | undefined; + /** + * Registers this batch as the one probing the key. The returned settle is + * idempotent and releases the waiters; call it once the calibration is + * stored, and again from a `finally` so a failed or cancelled probe + * releases them too. + */ + claim: () => () => void; +}; + +export const shareCalibration = ( + calibrating: Map>, + key: string, +): CalibrationShare => ({ + inFlight: calibrating.get(key), + claim: () => { + let release = () => {}; + const pending = new Promise((resolve) => { + release = resolve; + }); + calibrating.set(key, pending); + return () => { + if (calibrating.get(key) === pending) { + calibrating.delete(key); + } + release(); + }; + }, +}); diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx index fb6806aeca9..4e7b20eddfa 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx @@ -102,6 +102,13 @@ calibrate from scratch, and the cache is updated with what each batch learned. Only a selection that changes the initial marking (a scenario axis that shapes the initial state) still builds a new setup. +Pipelined rungs lease the backend concurrently, and the ladder starts the +next rung off the first streamed chunk — which, on a fresh marking, is the +probe's. The backend therefore also tracks probes in flight per calibration +key: a rung that starts while another still probes its marking waits for +that calibration and adopts it instead of probing too, so one marking is +probed once however the rungs overlap. + Devices need explicit destruction and pipelined rungs lease the backend concurrently, so entries count leases: a displaced (or unsupported) entry destroys its device once the last lease ends. On the SIR bench story this From 94d4316ee64f1a1761f3f1073ab798bcf9744835 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 05:43:27 +0200 Subject: [PATCH 04/10] Probe afresh when a shared calibration still overflows after growth --- .../gpu-experiment-handle/run-phase.test.ts | 37 +++++++++++++++++++ .../webgpu/gpu-experiment-handle/run-phase.ts | 14 ++++++- .../simulation/gpu-capacity-calibration.mdx | 6 ++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts index 488c3440be2..ae688232099 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { RUN_POLICY } from "./calibration"; import { runCalibratedExperiment } from "./run-phase"; import type { CompiledNetShader } from "../compile-net-shader"; @@ -170,6 +171,42 @@ describe("runCalibratedExperiment", () => { expect(remembered).toEqual([]); }); + it("probes afresh when a cached calibration still overflows after growth", async () => { + // Another selection's slabs undersize this one past RUN_POLICY's budget: + // rather than failing, the run probes as a first batch would. + const current = session({ p: 10 }); + const overflowing = Array.from( + { length: 1 + RUN_POLICY.maxSlabGrowths }, + () => ({ ok: true as const, result: outcome({ overflowRuns: 1 }) }), + ); + const { execute, attempts } = scripted([ + ...overflowing, + { + ok: true, + result: outcome({ derivedPlaceMaxes: [{ max: 100, meanRunMax: 90 }] }), + }, + { ok: true, result: outcome({ completedRuns: 1000 }) }, + ]); + + const { run, remembered } = runWith(current, execute, { + calibratedWindows: [{ lo: 0, stride: 1, integer: true }], + }); + const result = await run; + + expect(attempts.map(({ preview }) => preview)).toEqual([ + ...overflowing.map(() => true), + false, + true, + ]); + // The probe starts from the grown slabs, never below them. + expect(current.capacities.get("p")).toBe(154); + expect(remembered).toHaveLength(1); + expect(result).toMatchObject({ + kind: "calibrated", + result: { completedRuns: 1000, overflowRuns: 0 }, + }); + }); + it("probes blind windows alone when no place needs a slab", async () => { const current = session({}); const { execute, attempts } = scripted([ diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts index bf3fd6d362e..e8758275c34 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts @@ -37,7 +37,8 @@ export type RunPhaseOutcome = /** * Probes when nothing is calibrated yet — derived capacities first, which * also observes the metric ranges; else the blind windows alone — then runs - * the full attempt under `RUN_POLICY`. + * the full attempt under `RUN_POLICY`. A cached calibration the full attempt + * outgrows even after growth sends the run back through the probe. */ export const runCalibratedExperiment = async (options: { session: CalibrationSession; @@ -135,6 +136,17 @@ export const runCalibratedExperiment = async (options: { if (!calibrated.ok) { return { kind: "failed", reason: calibrated.reason }; } + if ( + calibratedWindows !== null && + calibrated.result.overflowRuns > 0 && + !calibrated.result.cancelled && + !stopped() + ) { + // A calibration learned on another selection undersizes this one past + // what growth covers: probe afresh, as a first batch would, from the + // grown slabs. The probe's result outranks the stale entry. + return runCalibratedExperiment({ ...options, calibratedWindows: null }); + } return { kind: "calibrated", result: calibrated.result, diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx index 4e7b20eddfa..ca666ba075c 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx @@ -98,8 +98,10 @@ every batch — a point selection as one constant row per run — so all of a sweep's selections share one backend, and with it the calibration (windows, derived capacities, the shader compiled at them) learned by earlier batches. A stale calibration heals through the same escape/overflow re-runs that -calibrate from scratch, and the cache is updated with what each batch -learned. Only a selection that changes the initial marking (a scenario axis +calibrate from scratch — and a calibration the full attempt outgrows even +after its growth budget sends the batch back through the probe, from the +grown slabs, as a first batch would — and the cache is updated with what +each batch learned. Only a selection that changes the initial marking (a scenario axis that shapes the initial state) still builds a new setup. Pipelined rungs lease the backend concurrently, and the ladder starts the From 1db4f1f7a62e8fdef706802814e75db0343c7ae6 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 05:52:07 +0200 Subject: [PATCH 05/10] Grow a shared calibration once by the probe's factor before probing afresh --- .../src/webgpu/gpu-experiment-handle/calibration.ts | 13 +++++++++++++ .../webgpu/gpu-experiment-handle/run-phase.test.ts | 13 ++++++++----- .../src/webgpu/gpu-experiment-handle/run-phase.ts | 8 +++++--- .../content/simulation/gpu-capacity-calibration.mdx | 8 ++++---- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts index b914564b4b6..813f3fdd40d 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts @@ -92,6 +92,19 @@ export const RUN_POLICY: CalibrationPolicy = { preview: true, }; +/** + * A full attempt on a calibration another batch measured grows once, by the + * probe's factor: a small overflow is a tail that one step covers, and a + * larger one means this batch's dynamics differ from the measured ones, so + * the caller probes afresh rather than doubling towards them. + */ +export const CACHED_RUN_POLICY: CalibrationPolicy = { + slabGrowth: 4, + maxSlabGrowths: 1, + maxWindowReplans: 1, + preview: true, +}; + /** The shader in force and the derived slabs it was compiled at. */ export type CalibrationSession = { backend: Pick; diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts index ae688232099..169e094e203 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { RUN_POLICY } from "./calibration"; +import { CACHED_RUN_POLICY } from "./calibration"; import { runCalibratedExperiment } from "./run-phase"; import type { CompiledNetShader } from "../compile-net-shader"; @@ -172,11 +172,12 @@ describe("runCalibratedExperiment", () => { }); it("probes afresh when a cached calibration still overflows after growth", async () => { - // Another selection's slabs undersize this one past RUN_POLICY's budget: - // rather than failing, the run probes as a first batch would. + // Another selection's slabs undersize this one past the one growth a + // cached calibration gets: rather than failing, the run probes as a + // first batch would. const current = session({ p: 10 }); const overflowing = Array.from( - { length: 1 + RUN_POLICY.maxSlabGrowths }, + { length: 1 + CACHED_RUN_POLICY.maxSlabGrowths }, () => ({ ok: true as const, result: outcome({ overflowRuns: 1 }) }), ); const { execute, attempts } = scripted([ @@ -198,7 +199,9 @@ describe("runCalibratedExperiment", () => { false, true, ]); - // The probe starts from the grown slabs, never below them. + // The one growth is the probe's factor; the probe then sizes from what + // it observed. + expect(attempts[1]?.shader.stateWordsPerRun).toBe(4 + 40 * 2); expect(current.capacities.get("p")).toBe(154); expect(remembered).toHaveLength(1); expect(result).toMatchObject({ diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts index e8758275c34..56661081144 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts @@ -9,6 +9,7 @@ */ import { planInitialWindows } from "../metric-windows"; import { + CACHED_RUN_POLICY, probeDerivedCapacities, probeRunCount, probeWindows, @@ -37,8 +38,9 @@ export type RunPhaseOutcome = /** * Probes when nothing is calibrated yet — derived capacities first, which * also observes the metric ranges; else the blind windows alone — then runs - * the full attempt under `RUN_POLICY`. A cached calibration the full attempt - * outgrows even after growth sends the run back through the probe. + * the full attempt under `RUN_POLICY`. A cached calibration runs under + * `CACHED_RUN_POLICY` instead, and outgrowing it sends the run back through + * the probe. */ export const runCalibratedExperiment = async (options: { session: CalibrationSession; @@ -130,7 +132,7 @@ export const runCalibratedExperiment = async (options: { runsFor: () => runCount, windows, execute, - policy: RUN_POLICY, + policy: calibratedWindows === null ? RUN_POLICY : CACHED_RUN_POLICY, stopped, }); if (!calibrated.ok) { diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx index ca666ba075c..4cdd3123e76 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx @@ -98,10 +98,10 @@ every batch — a point selection as one constant row per run — so all of a sweep's selections share one backend, and with it the calibration (windows, derived capacities, the shader compiled at them) learned by earlier batches. A stale calibration heals through the same escape/overflow re-runs that -calibrate from scratch — and a calibration the full attempt outgrows even -after its growth budget sends the batch back through the probe, from the -grown slabs, as a first batch would — and the cache is updated with what -each batch learned. Only a selection that changes the initial marking (a scenario axis +calibrate from scratch — a full attempt on a calibration another batch +measured grows once, by the probe's factor, and a calibration it outgrows +even then sends the batch back through the probe, from the grown slabs, as a +first batch would — and the cache is updated with what each batch learned. Only a selection that changes the initial marking (a scenario axis that shapes the initial state) still builds a new setup. Pipelined rungs lease the backend concurrently, and the ladder starts the From 36c2061747af06e6d1bc74f6649b73a620e57904 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 06:31:23 +0200 Subject: [PATCH 06/10] Close the review gaps in the GPU first-run streaming fix --- .../src/webgpu/gpu-experiment-handle.test.ts | 246 ++++++++++++++++++ .../src/webgpu/gpu-experiment-handle.ts | 15 +- .../gpu-experiment-handle/calibration.test.ts | 21 +- .../gpu-experiment-handle/calibration.ts | 40 ++- .../gpu-experiment-handle/run-phase.test.ts | 103 ++++++-- .../webgpu/gpu-experiment-handle/run-phase.ts | 71 +++-- libs/@hashintel/petrinaut/docs/experiments.md | 4 +- .../simulation/gpu-capacity-calibration.mdx | 27 +- 8 files changed, 458 insertions(+), 69 deletions(-) create mode 100644 libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts new file mode 100644 index 00000000000..d1df7de4f00 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { requestGpuExperimentBackend } from "./backend"; +import { createGpuMonteCarloExperiment } from "./gpu-experiment-handle"; +import { runGpuExperiment } from "./runner"; + +import type { HirArtifacts } from "../hir-runtime"; +import type { SDCPN } from "../types/sdcpn"; +import type { GpuBackend } from "./backend"; +import type { CompiledNetShader } from "./compile-net-shader"; +import type { GpuNetProfile } from "./eligibility"; +import type { GpuExperimentRequest, GpuExperimentResult } from "./runner"; + +vi.mock("./backend", async (importOriginal) => ({ + ...(await importOriginal>()), + requestGpuExperimentBackend: vi.fn(), +})); + +vi.mock("./runner", async (importOriginal) => ({ + ...(await importOriginal>()), + runGpuExperiment: vi.fn(), +})); + +const emptyNet: SDCPN = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +/** A shader whose only relevant facts are its size and its derived places. */ +const shaderAt = ( + capacities: ReadonlyMap, +): CompiledNetShader => { + const slabWords = [...capacities.values()].reduce( + (sum, capacity) => sum + capacity * 2, + 0, + ); + return { + wgsl: "", + stateWordsPerRun: 4 + slabWords, + summaryWordsPerRun: 2 + capacities.size, + placeCountOffsets: [0], + placeTokenOffsets: [4], + placeTokenStrides: [2], + summaryStatusOffset: 1, + rngOffset: 2, + statusOffset: 3, + derivedCapacityPlaceIndices: [...capacities.keys()].map( + (_, index) => index, + ), + metricIds: [], + histogramBins: 64, + runParameterIds: [], + compiledLambdas: [], + }; +}; + +const placeAt = ([id, capacity]: [ + string, + number, +]): GpuNetProfile["places"][number] => ({ + id, + name: id.toUpperCase(), + capacity, + capacitySource: "derived", + declaredCapacity: 0xffffffff, + realFields: ["x", "y"], + discreteFields: [], + colored: true, + pairConsumed: false, +}); + +/** A backend with a derived-capacity place per slab and no device behind it. */ +const fakeBackend = (capacities: Record): GpuBackend => { + const derived = new Map(Object.entries(capacities)); + return { + supported: true, + handle: { + device: { destroy: () => {}, lost: new Promise(() => {}) }, + info: "fake adapter", + } as unknown as GpuBackend["handle"], + shader: shaderAt(derived), + profile: { + places: [...derived].map(placeAt), + uncolouredOnly: derived.size === 0, + bytesPerRun: 16, + }, + derivedCapacities: derived, + recompile: (next) => ({ ok: true, shader: shaderAt(next) }), + calibration: new Map(), + calibrating: new Map(), + framesPerDispatch: 16, + warnings: [], + }; +}; + +const outcome = ( + overrides: Partial = {}, +): GpuExperimentResult => ({ + cancelled: false, + frames: [], + finalPlaceCounts: new Uint32Array(0), + deadlockedRuns: 0, + completedRuns: 0, + overflowRuns: 0, + derivedPlaceMaxes: [], + dispatchMs: 0, + metricRanges: [], + metricErrors: [], + ...overrides, +}); + +type PendingRun = { + shader: CompiledNetShader; + request: GpuExperimentRequest; + resolve: (result: GpuExperimentResult) => void; +}; + +/** Every attempt the handles under test asked the runner for, unresolved until the test says. */ +const pendingRuns: PendingRun[] = []; + +const createHandle = async (backend: GpuBackend) => { + vi.mocked(requestGpuExperimentBackend).mockResolvedValue(backend); + const created = await createGpuMonteCarloExperiment({ + sdcpn: emptyNet, + hirArtifacts: {} as unknown as HirArtifacts, + initialMarking: {}, + parameterValues: {}, + seed: 1, + dt: 0.1, + maxTime: 1, + runCount: 1000, + metricSpecs: [], + }); + if (!created.supported) { + throw new Error(created.reason); + } + return created.handle; +}; + +/** Lets the handles' attempt chains settle: each await in them costs a microtask. */ +const flush = async () => { + for (let tick = 0; tick < 32; tick += 1) { + await Promise.resolve(); + } +}; + +describe("createGpuMonteCarloExperiment", () => { + beforeEach(() => { + pendingRuns.length = 0; + vi.mocked(runGpuExperiment).mockImplementation( + (_handle, shader, request) => + new Promise((resolve) => { + pendingRuns.push({ + shader, + request, + resolve: (result) => resolve({ ok: true, result }), + }); + }), + ); + }); + + it("runs two batches on one marking at once when neither needs a probe", async () => { + const backend = fakeBackend({}); + const first = await createHandle(backend); + const second = await createHandle(backend); + + first.start(); + second.start(); + await flush(); + + expect(pendingRuns.map(({ request }) => request.runCount)).toEqual([ + 1000, 1000, + ]); + + for (const pending of pendingRuns) { + pending.resolve(outcome({ completedRuns: 1000 })); + } + await flush(); + expect([first.status.get(), second.status.get()]).toEqual([ + "Complete", + "Complete", + ]); + }); + + it("makes a batch wait for the probe another runs on its marking, then adopt it", async () => { + const backend = fakeBackend({ p: 64 }); + const first = await createHandle(backend); + const second = await createHandle(backend); + + first.start(); + await flush(); + second.start(); + await flush(); + + expect(pendingRuns.map(({ request }) => request.runCount)).toEqual([128]); + + pendingRuns[0]!.resolve( + outcome({ derivedPlaceMaxes: [{ max: 10, meanRunMax: 8 }] }), + ); + await flush(); + + // Both full attempts run at the slab the one probe sized. + expect(pendingRuns.slice(1).map(({ request }) => request.runCount)).toEqual( + [1000, 1000], + ); + expect( + pendingRuns.slice(1).map(({ shader }) => shader.stateWordsPerRun), + ).toEqual([4 + 19 * 2, 4 + 19 * 2]); + expect(backend.calibration.size).toBe(1); + }); + + it("publishes no progress for a probe's chunks", async () => { + const handle = await createHandle(fakeBackend({ p: 64 })); + + handle.start(); + await flush(); + + pendingRuns[0]!.request.onChunk?.({ + framesDone: 10, + frameLimit: 10, + runsCompleted: 128, + runsInTile: 0, + runCount: 128, + }); + expect(handle.progress.get()?.completedRuns).toBe(0); + + pendingRuns[0]!.resolve( + outcome({ derivedPlaceMaxes: [{ max: 10, meanRunMax: 8 }] }), + ); + await flush(); + pendingRuns[1]!.request.onChunk?.({ + framesDone: 5, + frameLimit: 10, + runsCompleted: 0, + runsInTile: 128, + runCount: 1000, + }); + expect(handle.progress.get()).toMatchObject({ + completedRuns: 0, + advancedRuns: 128, + }); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts index 3756711b009..003581743ca 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -30,7 +30,10 @@ import { rememberCalibration } from "./gpu-experiment-handle/calibration"; import { createFrameMerger } from "./gpu-experiment-handle/frame-merge"; import { metricFailure } from "./gpu-experiment-handle/metric-failure"; import { deriveRunParameters } from "./gpu-experiment-handle/run-parameters"; -import { runCalibratedExperiment } from "./gpu-experiment-handle/run-phase"; +import { + needsProbe, + runCalibratedExperiment, +} from "./gpu-experiment-handle/run-phase"; import { shareCalibration } from "./gpu-experiment-handle/shared-calibration"; import { toGpuMetricFrames, toGpuMetricSpecs } from "./gpu-metric-frames"; import { anyEscapes, calibrationKey } from "./metric-windows"; @@ -407,6 +410,7 @@ export async function createGpuMonteCarloExperiment( runCount: attemptRunCount, windows, preview, + probe, }) => runGpuExperiment(backend.handle, shader, { runCount: attemptRunCount, @@ -443,7 +447,9 @@ export async function createGpuMonteCarloExperiment( }), signal, onChunk: ({ framesDone, runsCompleted, runsInTile }) => { - if (disposed) { + // A probe's runs are a prefix the full attempt runs again, so + // reporting them would show progress that then falls back to zero. + if (disposed || probe) { return; } // Overall position, monotone across tiles: finished tiles count as @@ -484,10 +490,11 @@ export async function createGpuMonteCarloExperiment( // every other attempt's: the first picture a batch shows is the probe's, // overwritten progressively as the full attempt lands. A batch that // starts while another on this marking still probes waits for that - // calibration rather than probing too. + // calibration rather than probing too; one that needs no probe has + // nothing to wait for and runs at once. let calibratedWindows = adoptCalibration(); let settle = () => {}; - if (calibratedWindows === null) { + if (calibratedWindows === null && needsProbe(session, windowInputs)) { const share = shareCalibration(backend.calibrating, batchCalibrationKey); if (share.inFlight !== undefined) { await share.inFlight; diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts index 47d72b2ff07..c81e63d9798 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts @@ -130,7 +130,9 @@ describe("runUntilCalibrated", () => { expect(attempts.map((attempt) => attempt.shader.stateWordsPerRun)).toEqual([ 24, 44, ]); - expect(attempts.every((attempt) => attempt.preview)).toBe(true); + expect(attempts.every((attempt) => attempt.preview && !attempt.probe)).toBe( + true, + ); }); it("gives up growing after the policy's budget and hands back the overflow", async () => { @@ -332,6 +334,19 @@ describe("slabsFromProbe", () => { ).toEqual({ ok: true, capacities: new Map([["p", 40]]) }); }); + it("never sizes a slab below its floor", () => { + const current = session({ p: 64 }); + + expect( + slabsFromProbe( + current, + outcome({ derivedPlaceMaxes: [{ max: 20, meanRunMax: 15 }] }), + [3], + new Map([["p", 200]]), + ), + ).toEqual({ ok: true, capacities: new Map([["p", 200]]) }); + }); + it("refuses a heavy tail whose slab would exceed the arena threshold", () => { const current = session({ p: 64 }); @@ -367,7 +382,7 @@ describe("probeDerivedCapacities", () => { }); expect(attempts).toEqual([ - expect.objectContaining({ runCount: 128, preview: false }), + expect.objectContaining({ runCount: 128, preview: false, probe: true }), ]); expect(current.capacities).toEqual(new Map([["p", 19]])); expect(current.shader.stateWordsPerRun).toBe(4 + 19 * 2); @@ -525,7 +540,7 @@ describe("probeWindows", () => { // The caller sizes the prefix (`probeRunCount`), so an experiment of five // runs probes five, never a preview tile's worth it does not have. expect(attempts).toEqual([ - expect.objectContaining({ runCount: 5, preview: false }), + expect.objectContaining({ runCount: 5, preview: false, probe: true }), ]); // 21 counts observed, margin ceil(21 × 0.25) = 6 → [14, 46] over 64 bins. expect(probed).toMatchObject({ diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts index 813f3fdd40d..f73c893fe6b 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.ts @@ -67,6 +67,8 @@ export type CalibrationPolicy = { maxWindowReplans: number; /** Whether attempts open with a preview tile. */ preview: boolean; + /** Whether the attempts calibrate rather than run the experiment itself. */ + probe: boolean; }; /** @@ -79,6 +81,7 @@ export const PROBE_POLICY: CalibrationPolicy = { maxSlabGrowths: 7, maxWindowReplans: 0, preview: false, + probe: true, }; /** @@ -90,6 +93,7 @@ export const RUN_POLICY: CalibrationPolicy = { maxSlabGrowths: 3, maxWindowReplans: 1, preview: true, + probe: false, }; /** @@ -103,6 +107,7 @@ export const CACHED_RUN_POLICY: CalibrationPolicy = { maxSlabGrowths: 1, maxWindowReplans: 1, preview: true, + probe: false, }; /** The shader in force and the derived slabs it was compiled at. */ @@ -121,6 +126,11 @@ export type ExecuteAttempt = (attempt: { runCount: number; windows: readonly MetricWindow[]; preview: boolean; + /** + * A calibration attempt over a prefix of the runs: its frames stream like + * any other's, but its run counts are not the experiment's progress. + */ + probe: boolean; }) => Promise; export type CalibratedRun = @@ -215,6 +225,7 @@ export const runUntilCalibrated = async (options: { runCount: runsFor(session.shader), windows, preview: policy.preview, + probe: policy.probe, }); if (!attempt.ok) { return attempt; @@ -267,12 +278,15 @@ export const runUntilCalibrated = async (options: { * The slab each derived place gets for the full run, from the probe's per-run * maxima: the observed maximum plus margin, unless a heavy-tailed outlier * would need a slab past `GPU_ARENA_SLAB_BYTES` — that shape belongs on the - * CPU. + * CPU. A slab never shrinks below its place's `slabFloor`: a re-probe after + * a full attempt overflowed at grown slabs observes only a prefix of the + * runs, and sizing below what already overflowed would overflow again. */ export const slabsFromProbe = ( session: CalibrationSession, probe: GpuExperimentResult, placeCounts: readonly number[], + slabFloor?: ReadonlyMap, ): | { ok: true; capacities: Map } | { ok: false; reason: string } => { @@ -283,11 +297,12 @@ export const slabsFromProbe = ( ] of session.shader.derivedCapacityPlaceIndices.entries()) { const place = session.backend.profile.places[placeIndex]!; const stats = probe.derivedPlaceMaxes[slot] ?? { max: 0, meanRunMax: 0 }; - const capacity = Math.min( - Math.max(8, Math.ceil(stats.max * 1.5) + 4, placeCounts[placeIndex] ?? 0), - derivedSlabCeiling(place), + const observed = Math.max( + 8, + Math.ceil(stats.max * 1.5) + 4, + placeCounts[placeIndex] ?? 0, ); - const slabBytes = capacity * Math.max(1, tokenWordCount(place)) * 4; + const slabBytes = observed * Math.max(1, tokenWordCount(place)) * 4; if ( stats.max > 4 * Math.max(1, stats.meanRunMax) && slabBytes > GPU_ARENA_SLAB_BYTES @@ -297,7 +312,13 @@ export const slabsFromProbe = ( reason: `Probing \`${place.name}\` saw outlier runs reach ${stats.max} tokens against a typical per-run maximum of ${Math.round(stats.meanRunMax)}. Sizing every run for the outlier would take ${Math.round(slabBytes / 1024)} KB per run — that heavy-tailed shape needs a per-run token arena. Switch this experiment to the CPU backend, which sizes its buffers dynamically.`, }; } - capacities.set(place.id, capacity); + capacities.set( + place.id, + Math.min( + Math.max(observed, slabFloor?.get(place.id) ?? 0), + derivedSlabCeiling(place), + ), + ); } return { ok: true, capacities }; }; @@ -325,6 +346,8 @@ export const probeDerivedCapacities = async (options: { * attempts and before the recompile at the probed slabs. */ stopped?: () => boolean; + /** Slabs the probed ones may not shrink below — see `slabsFromProbe`. */ + slabFloor?: ReadonlyMap; }): Promise< | { ok: true; @@ -336,7 +359,7 @@ export const probeDerivedCapacities = async (options: { } | { ok: false; reason: string } > => { - const { session, runCount, placeCounts, execute } = options; + const { session, runCount, placeCounts, execute, slabFloor } = options; const stopped = options.stopped ?? (() => false); const probeWindows = planInitialWindows( options.windowInputs, @@ -383,7 +406,7 @@ export const probeDerivedCapacities = async (options: { reason: `Probing this net's token counts kept overflowing past ${largest.toLocaleString()} tokens per place. Switch this experiment to the CPU backend, which sizes its buffers dynamically.`, }; } - const slabs = slabsFromProbe(session, probe.result, placeCounts); + const slabs = slabsFromProbe(session, probe.result, placeCounts, slabFloor); if (!slabs.ok) { return slabs; } @@ -415,6 +438,7 @@ export const probeWindows = async (options: { runCount, windows, preview: false, + probe: true, }); if (!attempt.ok) { return attempt; diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts index 169e094e203..542234e0255 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; -import { CACHED_RUN_POLICY } from "./calibration"; +import { CACHED_RUN_POLICY, rememberCalibration } from "./calibration"; import { runCalibratedExperiment } from "./run-phase"; +import type { GpuCalibration } from "../backend"; import type { CompiledNetShader } from "../compile-net-shader"; +import type { GpuNetProfile } from "../eligibility"; import type { GpuExperimentResult } from "../runner"; import type { AttemptResult, @@ -29,7 +31,9 @@ const shaderAt = ( summaryStatusOffset: 1, rngOffset: 2, statusOffset: 3, - derivedCapacityPlaceIndices: [...capacities.keys()].map(() => 0), + derivedCapacityPlaceIndices: [...capacities.keys()].map( + (_, index) => index, + ), metricIds: ["m0"], histogramBins: 64, runParameterIds: [], @@ -37,25 +41,29 @@ const shaderAt = ( }; }; +/** A derived-capacity place per slab, in slab order. */ +const placeAt = ([id, capacity]: [ + string, + number, +]): GpuNetProfile["places"][number] => ({ + id, + name: id.toUpperCase(), + capacity, + capacitySource: "derived", + declaredCapacity: 0xffffffff, + realFields: ["x", "y"], + discreteFields: [], + colored: true, + pairConsumed: false, +}); + const session = (capacities: Record): CalibrationSession => { const initial = new Map(Object.entries(capacities)); return { backend: { recompile: (next) => ({ ok: true, shader: shaderAt(next) }), profile: { - places: [ - { - id: "p", - name: "P", - capacity: initial.get("p") ?? 0, - capacitySource: "derived", - declaredCapacity: 0xffffffff, - realFields: ["x", "y"], - discreteFields: [], - colored: true, - pairConsumed: false, - }, - ], + places: [...initial].map(placeAt), uncolouredOnly: false, bytesPerRun: 16, }, @@ -139,10 +147,14 @@ describe("runCalibratedExperiment", () => { // The probe runs a prefix without a preview tile; the full attempt runs // everything with one, at the slab the probe sized. expect( - attempts.map(({ runCount, preview }) => ({ runCount, preview })), + attempts.map(({ runCount, preview, probe }) => ({ + runCount, + preview, + probe, + })), ).toEqual([ - { runCount: 128, preview: false }, - { runCount: 1000, preview: true }, + { runCount: 128, preview: false, probe: true }, + { runCount: 1000, preview: true, probe: false }, ]); expect(attempts[1]?.shader.stateWordsPerRun).toBe(4 + 19 * 2); expect(remembered).toEqual([[{ lo: 14, stride: 1, integer: true }]]); @@ -210,6 +222,61 @@ describe("runCalibratedExperiment", () => { }); }); + it("floors the re-probe at the grown slabs and replaces the stale entry", async () => { + // The re-probe sees a prefix of the runs, not necessarily the one that + // overflowed: a place it observes small keeps the grown slab, while the + // place it observes large is sized from the observation — so the fresh + // calibration is no smaller than the stale one anywhere and replaces it. + const current = session({ p: 10, q: 50 }); + const key = "marking"; + const calibrations = new Map(); + rememberCalibration(calibrations, key, session({ p: 10, q: 50 }), []); + const overflowing = Array.from( + { length: 1 + CACHED_RUN_POLICY.maxSlabGrowths }, + () => ({ ok: true as const, result: outcome({ overflowRuns: 1 }) }), + ); + const { execute, attempts } = scripted([ + ...overflowing, + { + ok: true, + result: outcome({ + derivedPlaceMaxes: [ + { max: 100, meanRunMax: 90 }, + { max: 10, meanRunMax: 8 }, + ], + }), + }, + { ok: true, result: outcome({ completedRuns: 1000 }) }, + ]); + + const { run } = runWith(current, execute, { + calibratedWindows: [{ lo: 0, stride: 1, integer: true }], + placeCounts: [3, 3], + remember: (windows) => + rememberCalibration(calibrations, key, current, windows), + }); + const result = await run; + + const grown = new Map([ + ["p", 40], + ["q", 200], + ]); + const probed = new Map([ + ["p", 154], + ["q", 200], + ]); + expect(current.capacities).toEqual(probed); + expect(attempts.at(-1)?.shader.stateWordsPerRun).toBe(4 + (154 + 200) * 2); + for (const [placeId, capacity] of probed) { + expect(capacity).toBeGreaterThanOrEqual(grown.get(placeId)!); + } + expect(calibrations.get(key)?.capacities).toEqual(probed); + expect(result).toMatchObject({ + kind: "calibrated", + result: { completedRuns: 1000, overflowRuns: 0 }, + }); + }); + it("probes blind windows alone when no place needs a slab", async () => { const current = session({}); const { execute, attempts } = scripted([ diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts index 56661081144..506f5d79b31 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts @@ -35,6 +35,19 @@ export type RunPhaseOutcome = windows: MetricWindow[]; }; +/** + * Whether an uncalibrated run must probe before its full attempt: a derived + * slab needs measuring, or a metric has no declared ceiling to plan its + * window from. A run that needs neither has nothing another batch on the + * same marking could wait for. + */ +export const needsProbe = ( + session: Pick, + windowInputs: readonly MetricWindowInput[], +): boolean => + session.capacities.size > 0 || + windowInputs.some((input) => input.ceiling === null); + /** * Probes when nothing is calibrated yet — derived capacities first, which * also observes the metric ranges; else the blind windows alone — then runs @@ -64,6 +77,11 @@ export const runCalibratedExperiment = async (options: { metricErrors: readonly number[], runCount: number, ) => string | null; + /** + * Slabs a fresh probe may not size below: the grown ones a cached + * calibration's full attempt still overflowed at. + */ + slabFloor?: ReadonlyMap; }): Promise => { const { session, @@ -75,11 +93,14 @@ export const runCalibratedExperiment = async (options: { stopped, remember, metricFailure, + slabFloor, } = options; let windows: readonly MetricWindow[]; if (calibratedWindows !== null) { windows = calibratedWindows; + } else if (!needsProbe(session, windowInputs)) { + windows = planInitialWindows(windowInputs, session.shader.histogramBins); } else if (session.capacities.size > 0) { const probed = await probeDerivedCapacities({ session, @@ -88,6 +109,7 @@ export const runCalibratedExperiment = async (options: { placeCounts, execute, stopped, + slabFloor, }); if (stopped()) { return { kind: "stopped" }; @@ -102,29 +124,25 @@ export const runCalibratedExperiment = async (options: { return { kind: "failed", reason: probedFailure }; } } else { - windows = planInitialWindows(windowInputs, session.shader.histogramBins); - const blindWindows = windowInputs.some((input) => input.ceiling === null); - if (blindWindows) { - const probeRuns = probeRunCount(session.shader, runCount); - const probe = await probeWindows({ - session, - windows, - execute, - runCount: probeRuns, - }); - if (!probe.ok) { - return { kind: "failed", reason: probe.reason }; - } - if (probe.result.cancelled || stopped()) { - return { kind: "stopped" }; - } - const probeFailure = metricFailure(probe.result.metricErrors, probeRuns); - if (probeFailure !== null) { - return { kind: "failed", reason: probeFailure }; - } - windows = probe.windows; - remember(windows); + const probeRuns = probeRunCount(session.shader, runCount); + const probe = await probeWindows({ + session, + windows: planInitialWindows(windowInputs, session.shader.histogramBins), + execute, + runCount: probeRuns, + }); + if (!probe.ok) { + return { kind: "failed", reason: probe.reason }; + } + if (probe.result.cancelled || stopped()) { + return { kind: "stopped" }; } + const probeFailure = metricFailure(probe.result.metricErrors, probeRuns); + if (probeFailure !== null) { + return { kind: "failed", reason: probeFailure }; + } + windows = probe.windows; + remember(windows); } const calibrated = await runUntilCalibrated({ @@ -146,8 +164,13 @@ export const runCalibratedExperiment = async (options: { ) { // A calibration learned on another selection undersizes this one past // what growth covers: probe afresh, as a first batch would, from the - // grown slabs. The probe's result outranks the stale entry. - return runCalibratedExperiment({ ...options, calibratedWindows: null }); + // grown slabs — and never below them, so the probe's result is at least + // the stale entry's on every place and replaces it. + return runCalibratedExperiment({ + ...options, + calibratedWindows: null, + slabFloor: new Map(session.capacities), + }); } return { kind: "calibrated", diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 0aaddace93c..fd31bea11fb 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -96,13 +96,13 @@ The switch is greyed out when the current model cannot run on the GPU; hover it The GPU backend handles a **subset** of nets, and it tells you when it cannot take one rather than guessing. It needs: - **metric values that fit the histogram.** Metrics are reduced on the device into a histogram whose bins cover a window of values — exact integers for counts, a calibrated range for real-valued metrics, whose bin width the heatmap shows. The window calibrates itself: a short probe observes each measured place's range, the full run uses that range, and a run that escapes its window is recalibrated and re-run automatically — no refusals or warnings based on absolute counts. Calibration is remembered while an experiment is open, so moving the sweep's sliders does not re-probe what an earlier selection already measured. Only the _span_ is limited: up to three metrics get 1,024 distinct values each (more metrics share the budget), and a wider span is binned at reduced resolution; -- typed places to have _measurable_ token counts. A declared [token capacity](drawing-a-net.md#token-capacity) is used directly; without one, a short probe measures each place's real maximum and sizes the buffers from it (growing and re-running automatically if a run later outgrows the estimate). A place whose probe shows rare extreme outliers stops the experiment with a message asking you to run it on the CPU instead — sizing every run for the outlier would waste the GPU's memory; +- typed places to have _measurable_ token counts. A declared [token capacity](drawing-a-net.md#token-capacity) is used directly; without one, a short probe measures each place's real maximum and sizes the buffers from it (growing and re-running automatically if a run later outgrows the estimate). A place whose probe shows rare extreme outliers, or whose counts keep outgrowing the buffers however far the probe grows them, stops the experiment with a message asking you to run it on the CPU instead — sizing every run for the outlier would waste the GPU's memory; - no `string` or `uuid` token attributes, which need more than the 32 bits WebGPU offers; - **arcs consuming at most two typed tokens per place.** A condition that reads token attributes runs on the GPU at weight 1 and at weight 2 — a pairwise condition like a collision test is scanned over every pair — but not beyond; - typed tokens consumed from at most one place per transition, since two would be a cross-product enumeration across arcs (the one gate a bundled example — Production Machines — still hits); - metrics the shader can compute: place token counts, and expression metrics that read counts, parameters and one place's tokens (`.length`, `.reduce`). Metrics using `.concat`, indexing a token by position, string or uuid attributes, or a time aggregation run on the CPU, and the message names the metric. Each metric samples the same runs on either backend: the runs still active in a frame by default, or, for a metric the experiment form defines, every run, with a finished run keeping its final value. -When an experiment does not qualify, it runs on the CPU instead and a message explains which requirement was not met. Nothing fails, and you do not need to check in advance. To see the full picture for the net you are editing — including which individual conditions and equations compiled — turn on [Compilation Output](compilation-output.md). +When an experiment misses a requirement the model alone decides — attributes, arcs, metrics — it runs on the CPU instead and a message explains which one, so you do not need to check in advance. The two requirements only a run can measure — a heavy-tailed place, counts that keep outgrowing their buffers — surface as that experiment's error once it has started, with a message asking you to switch it to the CPU backend. To see the full picture for the net you are editing — including which individual conditions and equations compiled — turn on [Compilation Output](compilation-output.md). Run count has no ceiling of its own: runs beyond what your GPU can hold at once execute as sequential tiles. What still falls back to the CPU is a single run whose own state exceeds the device's buffer limits, or a metric histogram too large for the device; the message says which. diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx index 4cdd3123e76..8eb10f3701a 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/gpu-capacity-calibration.mdx @@ -31,13 +31,14 @@ never blocks a firing over it. ![Probe, decide, grow](@diagrams/gpu-capacity-calibration.svg) 1. **Probe.** As the first phase of the run — after `start()`, so its - chunks stream to the charts like every other attempt's — a small prefix of - the runs executes at generous slabs (four times each place's initial - tokens). Few runs afford big slabs, and the probe's shader tracks each - derived place's **per-run maximum count** (a register per place, folded - into the summary). A probe that overflows quadruples the slabs, - recompiles, and probes again — shedding runs to stay inside a 128 MB - probe budget, so seven growth attempts reach counts past a million + chunks stream to the charts like every other attempt's, though its run + counts are not reported as progress, being a prefix the full attempt runs + again — a small prefix of the runs executes at generous slabs (four times + each place's initial tokens). Few runs afford big slabs, and the probe's + shader tracks each derived place's **per-run maximum count** (a register + per place, folded into the summary). A probe that overflows quadruples + the slabs, recompiles, and probes again — shedding runs to stay inside a + 128 MB probe budget, so seven growth attempts reach counts past a million (64 × 4⁷) before conceding. 2. **Decide, per place.** The probe yields the largest per-run maximum and the mean of the per-run maxima. When the largest is close to typical, the @@ -101,15 +102,21 @@ A stale calibration heals through the same escape/overflow re-runs that calibrate from scratch — a full attempt on a calibration another batch measured grows once, by the probe's factor, and a calibration it outgrows even then sends the batch back through the probe, from the grown slabs, as a -first batch would — and the cache is updated with what each batch learned. Only a selection that changes the initial marking (a scenario axis -that shapes the initial state) still builds a new setup. +first batch would. That re-probe never sizes a slab below the grown ones: it +sees only a prefix of the runs, possibly not the run that overflowed, so its +result is at least the stale entry's on every place and replaces it. The +cache is updated with what each batch learned. Only a selection that changes +the initial marking (a scenario axis that shapes the initial state) still +builds a new setup. Pipelined rungs lease the backend concurrently, and the ladder starts the next rung off the first streamed chunk — which, on a fresh marking, is the probe's. The backend therefore also tracks probes in flight per calibration key: a rung that starts while another still probes its marking waits for that calibration and adopts it instead of probing too, so one marking is -probed once however the rungs overlap. +probed once however the rungs overlap. A marking that needs no probe — every +slab declared, every window bounded by a ceiling — is never claimed, so its +rungs overlap as the ladder intends. Devices need explicit destruction and pipelined rungs lease the backend concurrently, so entries count leases: a displaced (or unsupported) entry From 0260e11c71522ad10291d62124849633f9ed469d Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 20:18:40 +0200 Subject: [PATCH 07/10] Drop the flag-gated changeset, fix the shader claim in the sweeps page and share the GPU handle test builders --- .changeset/gpu-first-run-streams.md | 6 -- .../src/webgpu/gpu-experiment-handle.test.ts | 74 +++------------ .../calibration.test-helpers.ts | 92 +++++++++++++++++++ .../gpu-experiment-handle/calibration.test.ts | 83 +---------------- .../gpu-experiment-handle/run-phase.test.ts | 83 ++--------------- .../content/experiments/parameter-sweeps.mdx | 10 +- 6 files changed, 119 insertions(+), 229 deletions(-) delete mode 100644 .changeset/gpu-first-run-streams.md create mode 100644 libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test-helpers.ts diff --git a/.changeset/gpu-first-run-streams.md b/.changeset/gpu-first-run-streams.md deleted file mode 100644 index 7fd8fba65bd..00000000000 --- a/.changeset/gpu-first-run-streams.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@hashintel/petrinaut-core": patch -"@hashintel/petrinaut": patch ---- - -A sweep's first frames on the GPU stream from the start of every selection: the capacity probe runs after the experiment starts, and every selection shares one compiled GPU setup. diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts index d1df7de4f00..2baf4d1ef71 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts @@ -2,13 +2,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { requestGpuExperimentBackend } from "./backend"; import { createGpuMonteCarloExperiment } from "./gpu-experiment-handle"; +import { + outcome, + placeAt, + shaderAt, +} from "./gpu-experiment-handle/calibration.test-helpers"; import { runGpuExperiment } from "./runner"; import type { HirArtifacts } from "../hir-runtime"; import type { SDCPN } from "../types/sdcpn"; import type { GpuBackend } from "./backend"; import type { CompiledNetShader } from "./compile-net-shader"; -import type { GpuNetProfile } from "./eligibility"; import type { GpuExperimentRequest, GpuExperimentResult } from "./runner"; vi.mock("./backend", async (importOriginal) => ({ @@ -29,49 +33,6 @@ const emptyNet: SDCPN = { parameters: [], }; -/** A shader whose only relevant facts are its size and its derived places. */ -const shaderAt = ( - capacities: ReadonlyMap, -): CompiledNetShader => { - const slabWords = [...capacities.values()].reduce( - (sum, capacity) => sum + capacity * 2, - 0, - ); - return { - wgsl: "", - stateWordsPerRun: 4 + slabWords, - summaryWordsPerRun: 2 + capacities.size, - placeCountOffsets: [0], - placeTokenOffsets: [4], - placeTokenStrides: [2], - summaryStatusOffset: 1, - rngOffset: 2, - statusOffset: 3, - derivedCapacityPlaceIndices: [...capacities.keys()].map( - (_, index) => index, - ), - metricIds: [], - histogramBins: 64, - runParameterIds: [], - compiledLambdas: [], - }; -}; - -const placeAt = ([id, capacity]: [ - string, - number, -]): GpuNetProfile["places"][number] => ({ - id, - name: id.toUpperCase(), - capacity, - capacitySource: "derived", - declaredCapacity: 0xffffffff, - realFields: ["x", "y"], - discreteFields: [], - colored: true, - pairConsumed: false, -}); - /** A backend with a derived-capacity place per slab and no device behind it. */ const fakeBackend = (capacities: Record): GpuBackend => { const derived = new Map(Object.entries(capacities)); @@ -81,14 +42,17 @@ const fakeBackend = (capacities: Record): GpuBackend => { device: { destroy: () => {}, lost: new Promise(() => {}) }, info: "fake adapter", } as unknown as GpuBackend["handle"], - shader: shaderAt(derived), + shader: shaderAt(derived, { metricIds: [] }), profile: { - places: [...derived].map(placeAt), + places: [...derived].map((entry) => placeAt(entry)), uncolouredOnly: derived.size === 0, bytesPerRun: 16, }, derivedCapacities: derived, - recompile: (next) => ({ ok: true, shader: shaderAt(next) }), + recompile: (next) => ({ + ok: true, + shader: shaderAt(next, { metricIds: [] }), + }), calibration: new Map(), calibrating: new Map(), framesPerDispatch: 16, @@ -96,22 +60,6 @@ const fakeBackend = (capacities: Record): GpuBackend => { }; }; -const outcome = ( - overrides: Partial = {}, -): GpuExperimentResult => ({ - cancelled: false, - frames: [], - finalPlaceCounts: new Uint32Array(0), - deadlockedRuns: 0, - completedRuns: 0, - overflowRuns: 0, - derivedPlaceMaxes: [], - dispatchMs: 0, - metricRanges: [], - metricErrors: [], - ...overrides, -}); - type PendingRun = { shader: CompiledNetShader; request: GpuExperimentRequest; diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test-helpers.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test-helpers.ts new file mode 100644 index 00000000000..702847d25ea --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test-helpers.ts @@ -0,0 +1,92 @@ +/** + * Test builders for the GPU handle's calibration sessions: shaders whose + * only relevant facts are their size and derived places, profiles with one + * derived-capacity place per slab, and runner outcomes. Not shipped — only + * imported from `*.test.ts` files. + */ +import type { CompiledNetShader } from "../compile-net-shader"; +import type { GpuNetProfile } from "../eligibility"; +import type { GpuExperimentResult } from "../runner"; +import type { CalibrationSession } from "./calibration"; + +/** A shader whose only relevant facts are its size and its derived places. */ +export const shaderAt = ( + capacities: ReadonlyMap, + { metricIds = ["m0"] }: { metricIds?: string[] } = {}, +): CompiledNetShader => { + const slabWords = [...capacities.values()].reduce( + (sum, capacity) => sum + capacity * 2, + 0, + ); + return { + wgsl: "", + stateWordsPerRun: 4 + slabWords, + summaryWordsPerRun: 2 + capacities.size, + placeCountOffsets: [0], + placeTokenOffsets: [4], + placeTokenStrides: [2], + summaryStatusOffset: 1, + rngOffset: 2, + statusOffset: 3, + derivedCapacityPlaceIndices: [...capacities.keys()].map( + (_, index) => index, + ), + metricIds, + histogramBins: 64, + runParameterIds: [], + compiledLambdas: [], + }; +}; + +/** A derived-capacity place per slab, in slab order. */ +export const placeAt = ( + [id, capacity]: [string, number], + { pairConsumed = false }: { pairConsumed?: boolean } = {}, +): GpuNetProfile["places"][number] => ({ + id, + name: id.toUpperCase(), + capacity, + capacitySource: "derived", + declaredCapacity: 0xffffffff, + realFields: ["x", "y"], + discreteFields: [], + colored: true, + pairConsumed, +}); + +/** A session over a derived-capacity place per slab, recompiling at any size. */ +export const session = ( + capacities: Record, + { pairConsumed = false }: { pairConsumed?: boolean } = {}, +): CalibrationSession => { + const initial = new Map(Object.entries(capacities)); + return { + backend: { + recompile: (next) => ({ ok: true, shader: shaderAt(next) }), + profile: { + places: [...initial].map((entry) => placeAt(entry, { pairConsumed })), + uncolouredOnly: false, + bytesPerRun: 16, + }, + }, + shader: shaderAt(initial), + capacities: initial, + }; +}; + +/** A finished runner result with nothing observed, overridable per test. */ +export const outcome = ( + overrides: Partial = {}, +): GpuExperimentResult => ({ + cancelled: false, + frames: [], + finalPlaceCounts: new Uint32Array(0), + deadlockedRuns: 0, + completedRuns: 0, + overflowRuns: 0, + derivedPlaceMaxes: [], + dispatchMs: 0, + metricRanges: [], + metricErrors: [], + ...overrides, +}); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts index c81e63d9798..5aeac1e2be1 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/calibration.test.ts @@ -11,90 +11,11 @@ import { runUntilCalibrated, slabsFromProbe, } from "./calibration"; +import { outcome, session, shaderAt } from "./calibration.test-helpers"; import { metricFailure } from "./metric-failure"; import type { GpuCalibration } from "../backend"; -import type { CompiledNetShader } from "../compile-net-shader"; -import type { GpuExperimentResult } from "../runner"; -import type { - AttemptResult, - CalibrationSession, - ExecuteAttempt, -} from "./calibration"; - -/** A shader whose only relevant facts are its size and its derived places. */ -const shaderAt = ( - capacities: ReadonlyMap, - metricCount = 1, -): CompiledNetShader => { - const slabWords = [...capacities.values()].reduce( - (sum, capacity) => sum + capacity * 2, - 0, - ); - return { - wgsl: "", - stateWordsPerRun: 4 + slabWords, - summaryWordsPerRun: 2 + capacities.size, - placeCountOffsets: [0], - placeTokenOffsets: [4], - placeTokenStrides: [2], - summaryStatusOffset: 1, - rngOffset: 2, - statusOffset: 3, - derivedCapacityPlaceIndices: [...capacities.keys()].map(() => 0), - metricIds: Array.from({ length: metricCount }, (_, index) => `m${index}`), - histogramBins: 64, - runParameterIds: [], - compiledLambdas: [], - }; -}; - -const session = ( - capacities: Record, - { pairConsumed = false }: { pairConsumed?: boolean } = {}, -): CalibrationSession => { - const initial = new Map(Object.entries(capacities)); - return { - backend: { - recompile: (next) => ({ ok: true, shader: shaderAt(next) }), - profile: { - places: [ - { - id: "p", - name: "P", - capacity: initial.get("p") ?? 0, - capacitySource: "derived", - declaredCapacity: 0xffffffff, - realFields: ["x", "y"], - discreteFields: [], - colored: true, - pairConsumed, - }, - ], - uncolouredOnly: false, - bytesPerRun: 16, - }, - }, - shader: shaderAt(initial), - capacities: initial, - }; -}; - -const outcome = ( - overrides: Partial = {}, -): GpuExperimentResult => ({ - cancelled: false, - frames: [], - finalPlaceCounts: new Uint32Array(0), - deadlockedRuns: 0, - completedRuns: 0, - overflowRuns: 0, - derivedPlaceMaxes: [], - dispatchMs: 0, - metricRanges: [{ min: 3, max: 9, below: 0, above: 0 }], - metricErrors: [], - ...overrides, -}); +import type { AttemptResult, ExecuteAttempt } from "./calibration"; /** Replays scripted results and records what each attempt asked for. */ const scripted = (results: AttemptResult[]) => { diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts index 542234e0255..1db4c757acf 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts @@ -1,11 +1,10 @@ import { describe, expect, it } from "vitest"; import { CACHED_RUN_POLICY, rememberCalibration } from "./calibration"; +import { outcome as emptyOutcome, session } from "./calibration.test-helpers"; import { runCalibratedExperiment } from "./run-phase"; import type { GpuCalibration } from "../backend"; -import type { CompiledNetShader } from "../compile-net-shader"; -import type { GpuNetProfile } from "../eligibility"; import type { GpuExperimentResult } from "../runner"; import type { AttemptResult, @@ -13,81 +12,15 @@ import type { ExecuteAttempt, } from "./calibration"; -/** A shader whose only relevant facts are its size and its derived places. */ -const shaderAt = ( - capacities: ReadonlyMap, -): CompiledNetShader => { - const slabWords = [...capacities.values()].reduce( - (sum, capacity) => sum + capacity * 2, - 0, - ); - return { - wgsl: "", - stateWordsPerRun: 4 + slabWords, - summaryWordsPerRun: 2 + capacities.size, - placeCountOffsets: [0], - placeTokenOffsets: [4], - placeTokenStrides: [2], - summaryStatusOffset: 1, - rngOffset: 2, - statusOffset: 3, - derivedCapacityPlaceIndices: [...capacities.keys()].map( - (_, index) => index, - ), - metricIds: ["m0"], - histogramBins: 64, - runParameterIds: [], - compiledLambdas: [], - }; -}; - -/** A derived-capacity place per slab, in slab order. */ -const placeAt = ([id, capacity]: [ - string, - number, -]): GpuNetProfile["places"][number] => ({ - id, - name: id.toUpperCase(), - capacity, - capacitySource: "derived", - declaredCapacity: 0xffffffff, - realFields: ["x", "y"], - discreteFields: [], - colored: true, - pairConsumed: false, -}); - -const session = (capacities: Record): CalibrationSession => { - const initial = new Map(Object.entries(capacities)); - return { - backend: { - recompile: (next) => ({ ok: true, shader: shaderAt(next) }), - profile: { - places: [...initial].map(placeAt), - uncolouredOnly: false, - bytesPerRun: 16, - }, - }, - shader: shaderAt(initial), - capacities: initial, - }; -}; - +/** A runner result whose probe observed one derived place and one metric. */ const outcome = ( overrides: Partial = {}, -): GpuExperimentResult => ({ - cancelled: false, - frames: [], - finalPlaceCounts: new Uint32Array(0), - deadlockedRuns: 0, - completedRuns: 0, - overflowRuns: 0, - derivedPlaceMaxes: [{ max: 10, meanRunMax: 8 }], - dispatchMs: 0, - metricRanges: [{ min: 20, max: 40, below: 0, above: 0 }], - metricErrors: [], - ...overrides, -}); +): GpuExperimentResult => + emptyOutcome({ + derivedPlaceMaxes: [{ max: 10, meanRunMax: 8 }], + metricRanges: [{ min: 20, max: 40, below: 0, above: 0 }], + ...overrides, + }); /** Replays scripted results and records what each attempt asked for. */ const scripted = (results: AttemptResult[]) => { diff --git a/libs/@local/petrinaut-arch-docs/content/experiments/parameter-sweeps.mdx b/libs/@local/petrinaut-arch-docs/content/experiments/parameter-sweeps.mdx index 588de3535a6..722753bad1d 100644 --- a/libs/@local/petrinaut-arch-docs/content/experiments/parameter-sweeps.mdx +++ b/libs/@local/petrinaut-arch-docs/content/experiments/parameter-sweeps.mdx @@ -114,10 +114,12 @@ backend-agnostic — it consumes an injected `instantiateBatch` returning a `MonteCarloExperiment`, so the CPU worker pool and the WebGPU backend behave identically. The provider wires that seam: a sweep's first batch runs the same [backend-selection walk](doc:experiments/backend-selection) as a plain -experiment, and later batches re-assess the chosen backend with each batch's -request — which is where the GPU backend regenerates its shader for the new -parameter values. Scenario compilation also happens per batch, because a -cell's swept values change the compiled initial state. +experiment, and later batches re-assess the chosen backend with their own +request. The swept net parameters ride the per-run plan for every batch — a +point selection as a constant plan — so the GPU backend keeps one compiled +setup and its calibration across selections. Scenario compilation still +happens per batch, because a cell's swept values change the compiled initial +state. The navigator (`sweep-navigator.tsx`) renders one range slider per parameter in the drawer section's sticky band, so the controls stay pinned while the From 9c80c1b28ec68af7dc46e459e479082970940bde Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 12 Sep 2026 02:37:56 +0200 Subject: [PATCH 08/10] Re-read the calibration share after each wait so only the first woken batch probes again --- .../src/webgpu/gpu-experiment-handle.test.ts | 42 +++++++++++++++++++ .../src/webgpu/gpu-experiment-handle.ts | 21 +++++----- .../shared-calibration.ts | 4 +- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts index 2baf4d1ef71..1221bf0b05d 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.test.ts @@ -160,6 +160,48 @@ describe("createGpuMonteCarloExperiment", () => { expect(backend.calibration.size).toBe(1); }); + it("lets one waiter probe again when the shared probe stored nothing", async () => { + const backend = fakeBackend({ p: 64 }); + const first = await createHandle(backend); + const second = await createHandle(backend); + const third = await createHandle(backend); + + first.start(); + await flush(); + second.start(); + third.start(); + await flush(); + + expect(pendingRuns.map(({ request }) => request.runCount)).toEqual([128]); + + // An abandoned probe releases both waiters without a calibration to + // adopt; only the first to wake may probe, the other waits on it. + pendingRuns[0]!.resolve(outcome({ cancelled: true })); + await flush(); + + expect(first.status.get()).toBe("Error"); + expect(pendingRuns.slice(1).map(({ request }) => request.runCount)).toEqual( + [128], + ); + expect(backend.calibrating.size).toBe(1); + + pendingRuns[1]!.resolve( + outcome({ derivedPlaceMaxes: [{ max: 10, meanRunMax: 8 }] }), + ); + await flush(); + + expect(pendingRuns.slice(2).map(({ request }) => request.runCount)).toEqual( + [1000, 1000], + ); + expect( + pendingRuns.slice(2).map(({ shader }) => shader.stateWordsPerRun), + ).toEqual([4 + 19 * 2, 4 + 19 * 2]); + expect([second.status.get(), third.status.get()]).toEqual([ + "Running", + "Running", + ]); + }); + it("publishes no progress for a probe's chunks", async () => { const handle = await createHandle(fakeBackend({ p: 64 })); diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts index 003581743ca..3e7a363573a 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle.ts @@ -491,21 +491,22 @@ export async function createGpuMonteCarloExperiment( // overwritten progressively as the full attempt lands. A batch that // starts while another on this marking still probes waits for that // calibration rather than probing too; one that needs no probe has - // nothing to wait for and runs at once. + // nothing to wait for and runs at once. A probe that stores nothing + // (failed, cancelled) wakes every waiter at once, so each re-reads the + // map after waiting: the first to wake claims, the rest wait on it. let calibratedWindows = adoptCalibration(); let settle = () => {}; - if (calibratedWindows === null && needsProbe(session, windowInputs)) { + while (calibratedWindows === null && needsProbe(session, windowInputs)) { const share = shareCalibration(backend.calibrating, batchCalibrationKey); - if (share.inFlight !== undefined) { - await share.inFlight; - if (isDisposed()) { - return; - } - calibratedWindows = adoptCalibration(); - } - if (calibratedWindows === null) { + if (share.inFlight === undefined) { settle = share.claim(); + break; + } + await share.inFlight; + if (isDisposed()) { + return; } + calibratedWindows = adoptCalibration(); } const calibrated = await runCalibratedExperiment({ session, diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.ts index 400e6186f01..853ee8f3619 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/shared-calibration.ts @@ -5,7 +5,9 @@ * chunk a fresh marking streams is its probe's — so the next rung starts * while the calibration it could reuse is still being measured. The batch * that probes claims the key; batches arriving meanwhile wait for its - * settle and read the stored calibration instead of probing too. + * settle and read the stored calibration instead of probing too. When the + * settle stored none, the waiters look the key up again: the first finds it + * free and claims, the others find that claim and wait on it. */ export type CalibrationShare = { /** Another batch's probe of this key, settled once it stored or gave up. */ From 119b545850a33f42de0799518294ac9bbd3b0695 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 12 Sep 2026 09:10:50 +0200 Subject: [PATCH 09/10] Skip the fresh probe when a cached calibration's attempt halted on a metric --- .../gpu-experiment-handle/run-phase.test.ts | 23 +++++++++++++++++++ .../webgpu/gpu-experiment-handle/run-phase.ts | 7 ++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts index 1db4c757acf..9cefe6e611e 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts @@ -210,6 +210,29 @@ describe("runCalibratedExperiment", () => { }); }); + it("hands back a cached calibration's attempt a metric halted instead of probing afresh", async () => { + // The same seeds halt the same run at any slab, so the re-probe could + // only repeat the failure the handle is about to report. + const current = session({ p: 10 }); + const { execute, attempts } = scripted([ + { ok: true, result: outcome({ overflowRuns: 1, metricErrors: [2] }) }, + { ok: true, result: outcome({ completedRuns: 1000 }) }, + ]); + + const { run, remembered } = runWith(current, execute, { + calibratedWindows: [{ lo: 0, stride: 1, integer: true }], + }); + const result = await run; + + expect(attempts).toHaveLength(1); + expect(current.capacities.get("p")).toBe(10); + expect(remembered).toHaveLength(0); + expect(result).toMatchObject({ + kind: "calibrated", + result: { overflowRuns: 1, metricErrors: [2] }, + }); + }); + it("probes blind windows alone when no place needs a slab", async () => { const current = session({}); const { execute, attempts } = scripted([ diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts index 506f5d79b31..2bb67acc115 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts @@ -9,6 +9,7 @@ */ import { planInitialWindows } from "../metric-windows"; import { + anyMetricHalted, CACHED_RUN_POLICY, probeDerivedCapacities, probeRunCount, @@ -27,7 +28,7 @@ export type RunPhaseOutcome = | { kind: "failed"; reason: string } /** * The full attempt's last result — possibly cancelled midway, possibly - * still overflowing — with the windows it ran at. + * still overflowing or halted by a metric — with the windows it ran at. */ | { kind: "calibrated"; @@ -53,7 +54,8 @@ export const needsProbe = ( * also observes the metric ranges; else the blind windows alone — then runs * the full attempt under `RUN_POLICY`. A cached calibration runs under * `CACHED_RUN_POLICY` instead, and outgrowing it sends the run back through - * the probe. + * the probe — unless a metric halted a run, which a fresh probe would only + * halt again. */ export const runCalibratedExperiment = async (options: { session: CalibrationSession; @@ -159,6 +161,7 @@ export const runCalibratedExperiment = async (options: { if ( calibratedWindows !== null && calibrated.result.overflowRuns > 0 && + !anyMetricHalted(calibrated.result) && !calibrated.result.cancelled && !stopped() ) { From f0f76cd81e20b13da654d07e407680815e20ca67 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 12 Sep 2026 10:08:17 +0200 Subject: [PATCH 10/10] Check a GPU probe for a halted metric before remembering its calibration for later batches --- .../src/webgpu/gpu-experiment-handle/run-phase.test.ts | 7 ++++--- .../src/webgpu/gpu-experiment-handle/run-phase.ts | 10 +++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts index 9cefe6e611e..512c905ce62 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.test.ts @@ -284,7 +284,7 @@ describe("runCalibratedExperiment", () => { }); }); - it("ends the run on a metric the capacity probe halted, before the full attempt", async () => { + it("ends the run on a metric the capacity probe halted, before the full attempt and without remembering the probe", async () => { const current = session({ p: 64 }); const { execute, attempts } = scripted([ { ok: true, result: outcome({ metricErrors: [2] }) }, @@ -300,8 +300,9 @@ describe("runCalibratedExperiment", () => { reason: "halted 2 of 128 runs", }); expect(attempts).toHaveLength(1); - // The slabs and windows the probe settled still serve the next batch. - expect(remembered).toHaveLength(1); + // A halted probe is not remembered: the next batch on the marking would + // adopt it, skip its probe and meet the halt only after a full attempt. + expect(remembered).toHaveLength(0); }); it("ends the run on a metric the window probe halted, before the full attempt", async () => { diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts index 2bb67acc115..6143a5db415 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/gpu-experiment-handle/run-phase.ts @@ -67,7 +67,11 @@ export const runCalibratedExperiment = async (options: { execute: ExecuteAttempt; /** Whether the caller has abandoned the run, checked between attempts. */ stopped: () => boolean; - /** Hears each calibration a probe settles, for later batches on this marking. */ + /** + * Hears each calibration a probe settles, for later batches on this + * marking. A probe a metric halted settles none: a batch adopting it would + * skip its own probe and meet the halt only after a full attempt. + */ remember: (windows: readonly MetricWindow[]) => void; /** * The failure an attempt's halted-metric counts amount to over the runs it @@ -119,12 +123,12 @@ export const runCalibratedExperiment = async (options: { if (!probed.ok) { return { kind: "failed", reason: probed.reason }; } - windows = probed.windows; - remember(windows); const probedFailure = metricFailure(probed.metricErrors, probed.probeRuns); if (probedFailure !== null) { return { kind: "failed", reason: probedFailure }; } + windows = probed.windows; + remember(windows); } else { const probeRuns = probeRunCount(session.shader, runCount); const probe = await probeWindows({