Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3479083
Run the GPU capacity probe after start so a batch's first frames stream
kube Sep 11, 2026
d49354d
Carry a sweep's swept net parameters in every batch's run plan so poi…
kube Sep 11, 2026
276b1cf
Share one capacity probe between rungs that start on the same marking
kube Sep 11, 2026
94d4316
Probe afresh when a shared calibration still overflows after growth
kube Sep 11, 2026
1db4f1f
Grow a shared calibration once by the probe's factor before probing a…
kube Sep 11, 2026
36c2061
Close the review gaps in the GPU first-run streaming fix
kube Sep 11, 2026
0260e11
Drop the flag-gated changeset, fix the shader claim in the sweeps pag…
kube Sep 11, 2026
9c80c1b
Re-read the calibration share after each wait so only the first woken…
kube Sep 12, 2026
119b545
Skip the fresh probe when a cached calibration's attempt halted on a …
kube Sep 12, 2026
f0f76cd
Check a GPU probe for a halted metric before remembering its calibrat…
kube Sep 12, 2026
62b43ff
Lay out the metric card's chart options as two aggregate dimensions i…
kube Sep 11, 2026
b733832
Add an enlarge button to metric cards that spans the full row at twic…
kube Sep 11, 2026
268b140
Close the review gaps in the metric view menu
kube Sep 11, 2026
0a76934
Shorten the metric card changeset to one sentence
kube Sep 11, 2026
0208ed5
Let Tab follow the card grid's packed order and mark the enlarged car…
kube Sep 12, 2026
2687919
Let the popover body's own padding frame the metric view menu instead…
kube Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/metric-card-chart-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Metric cards lay out their chart options as runs and time aggregates and gain an Enlarge button.
7 changes: 7 additions & 0 deletions libs/@hashintel/petrinaut-core/src/webgpu/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ export type GpuBackend = {
* through the same escape/overflow re-runs that calibrate from scratch.
*/
calibration: Map<string, GpuCalibration>;
/**
* 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<string, Promise<void>>;
framesPerDispatch: number;
/** Notes that did not prevent use, e.g. user code that fell back to a default. */
warnings: string[];
Expand Down Expand Up @@ -236,6 +242,7 @@ export async function requestGpuExperimentBackend(
derivedCapacities: probeCapacities,
recompile: compileWith,
calibration: new Map(),
calibrating: new Map(),
framesPerDispatch,
warnings,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
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 { GpuExperimentRequest, GpuExperimentResult } from "./runner";

vi.mock("./backend", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
requestGpuExperimentBackend: vi.fn(),
}));

vi.mock("./runner", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
runGpuExperiment: vi.fn(),
}));

const emptyNet: SDCPN = {
places: [],
transitions: [],
types: [],
differentialEquations: [],
parameters: [],
};

/** A backend with a derived-capacity place per slab and no device behind it. */
const fakeBackend = (capacities: Record<string, number>): 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, { metricIds: [] }),
profile: {
places: [...derived].map((entry) => placeAt(entry)),
uncolouredOnly: derived.size === 0,
bytesPerRun: 16,
},
derivedCapacities: derived,
recompile: (next) => ({
ok: true,
shader: shaderAt(next, { metricIds: [] }),
}),
calibration: new Map(),
calibrating: new Map(),
framesPerDispatch: 16,
warnings: [],
};
};

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("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 }));

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,
});
});
});
Loading
Loading