@zakkster/lite-gpu-profiler-signal
v1.0.0
Published
Reactive boundary for @zakkster/lite-gpu-profiler: GPU command counters and timing as lite-signal signals, with a live baseline gate. The hot path stays allocation-free; a throttled pulse lifts coarse telemetry into signals.
Maintainers
Readme
@zakkster/lite-gpu-profiler-signal
A reactive boundary for @zakkster/lite-gpu-profiler.
It lifts GPU command counters (draw calls, instances, floats uploaded) and GPU-time
timing into @zakkster/lite-signal
signals through a throttled pulse, and exposes a live baseline gate for regression.
The profiler's per-frame path stays exactly as it was: allocation-free, writing into zero-GC ring buffers, touching no signal. This wrapper is the seam between that imperative hot loop and a reactive UI.
The reactive-profiler trap
The naive way to make a profiler reactive is to write a signal per metric every frame. That inverts the cost you were trying to measure: the graph now runs inside the frame it is profiling, and allocates as it goes.
This bridge does the opposite. pulse(), called once per frame, does one thing: it
increments an internal integer signal. A lite-throttle window over that integer
gates the recompute, which at most once per window (~10Hz by default) calls
profiler.summary() once and writes a fixed, bounded set of output signals inside
batch().
- Per-frame graph cost is O(1), independent of counter count or frame rate.
- No graph nodes are created per frame.
- The recompute -- the one place that allocates -- runs ~10x a second, off the frame.
test/01-signals.test.js drives 200 frames and asserts summary() ran no more than
three times: the throttle coalesced the rest.
Install
npm i @zakkster/lite-gpu-profiler-signal @zakkster/lite-signal@zakkster/lite-signal is a peer dependency (>=1.3.0).
@zakkster/lite-gpu-profiler, @zakkster/lite-throttle, and @zakkster/lite-watch-ex
are dependencies.
Quick start
import { GpuProfiler } from '@zakkster/lite-gpu-profiler';
import { effect } from '@zakkster/lite-signal';
import { createGpuProfilerView } from '@zakkster/lite-gpu-profiler-signal';
const gpu = new GpuProfiler(1024); // default counters: drawCalls, instances, floatsUploaded
const view = createGpuProfilerView(gpu, { label: 'scene', engine: '[email protected]' });
// one effect binds the coarse telemetry to the DOM; it runs at the pulse cadence, not per frame
effect(() => {
hud.textContent =
`${view.gpuP99.get().toFixed(2)} ms p99 | ` +
`${view.counters.floatsUploaded.max.get()} floats/frame | ` +
`${view.counters.drawCalls.max.get()} draws`;
});
function frame() {
gpu.beginFrame();
gpu.recordDraw(instanceCount); // 1 draw, N instances
gpu.recordUpload(floatCount); // bytes-worth of vertex/uniform data, in floats
gpu.recordGpuTime(lastQueryMs); // optional: a resolved GPU timer query
gpu.endFrame();
view.pulse(); // <- one integer set; the throttle does the rest
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);In the browser you can let the view drive its own pulse:
const detach = view.attach(); // pulses on requestAnimationFrame
// ... later
detach();Signals
Read-only. Never call .set() on them yourself.
| Signal | Type | Meaning |
| --- | --- | --- |
| gpuAvg, gpuP99, gpuMax | Signal<number> | GPU time (ms) over the window |
| gpuSamples | Signal<number> | GPU-time samples in the window |
| framesDrawn, framesSkipped | Signal<number> | frames that did / didn't record a draw |
| regressed | Signal<boolean> | live gate vs the armed baseline / ceilings |
| counters[tag] | { sum, max, avg, last } | one Signal<number> bundle per counter |
counter(tag) returns a bundle or null for an unknown tag.
Regression gating
Counters are gated exactly, not by tolerance. GPU command counts are deterministic: a fixed scene uploads the same floats and issues the same draws every frame. So a counter regression isn't "10% slower" -- it's "this build touches the bus more than the baseline did, at all". The default rules:
{
'counter.floatsUploaded.max': { exact: true }, // any drift up OR down trips
'counter.drawCalls.max': { exact: true },
'gpu.p99': { tolerance: 0.15 } // timing is noisy -> a tolerance
}view.captureBaseline(); // snapshot the current window as the baseline
view.onRegression((report) => { // fires once each time `regressed` goes true
console.warn('GPU regression:', report.regressions);
});
// or pull a full report on demand:
const report = view.checkAgainstBaseline(); // GpuGateResult, or null if no baselineRule kinds (from @zakkster/lite-gpu-profiler):
{ exact: true }-- deterministic counters; the live value must equal the baseline. A decrease is still drift from an exact baseline, so it trips -- recapture on purpose.{ max: N }-- an absolute ceiling. Arms without a baseline.{ tolerance: 0.15 }-- lower-is-better; trips when(live - base) / base > tolerance.
A vanished exact-gated counter is a regression (metric missing in candidate), not a
silent pass. A vanished max/tolerance metric is skipped -- there is no basis to
compare against.
Replace the rule set at runtime with setRules(rules); it re-arms the live gate.
Why no counter-spike watcher? A rolling-baseline "spiked 3x above recent average" watcher is the right tool for a noisy signal that moves both ways (phase p99). Exact counts don't move for a fixed workload, so there is no rolling baseline to spike against -- the exact gate is the correct mechanism, and it's the one shipped here.
Environments without requestAnimationFrame
raf: true drives the recompute off requestAnimationFrame. When rAF isn't available
(node, workers), the view falls back to an intervalMs timer instead of throwing at
construction, so the same code runs in tests and headless harnesses.
Testing
npm test # node --test16 tests across signal mirroring, the anti-trap coalescing proof, the exact/tolerance/max gate (including vanished-metric integrity and the arms-without-baseline ceiling), and watcher/lifecycle teardown.
License
MIT (c) Zahary Shinikchiev
