@zakkster/lite-hud
v2.5.0
Published
SPP-native zero-GC canvas overlay for the @zakkster profiler suite.
Downloads
748
Maintainers
Readme
@zakkster/lite-hud
SPP-native zero-GC canvas overlay for the
@zakksterprofiler suite.
Single-file ESM, no runtime dependencies, phosphor-green oscilloscope aesthetic.
Designed as the read-side consumer of @zakkster/lite-scope's mux registry.
Also works as a drop-in stats.js replacement via hud.channel().
v2.0.0 is a breaking rewrite. See the migration guide below.
Install
npm install @zakkster/lite-hudScope-driven mode (primary)
import { createHud } from '@zakkster/lite-hud';
import { createScope } from '@zakkster/lite-scope';
const scope = createScope();
const hud = createHud(document.body, { windowSec: 5, position: 'top-right' });
hud.attach(scope, {
budgets: [
{ channel: 'frame', threshold: 16.67, label: '60fps budget' },
],
});
// Rendering loop -- call at ~10-15 Hz
function loop() {
requestAnimationFrame(loop);
if (frameCount++ % 4 === 0) hud.render();
}
loop();attach() reads scope.streams(), builds an O(1) LUT per stream, and registers the HUD as a live mux sink via scope.addSink(). From that point every SPP record emitted by any probe flows directly into the HUD with no copy.
Manual channel mode (drop-in stats.js replacement)
No scope setup required.
const hud = createHud(document.body);
const fps = hud.channel({ name: 'fps', kind: 0 }); // LEVEL
const gc = hud.channel({ name: 'gc', kind: 1 }); // INSTANT
const frame = hud.channel({ name: 'frame', kind: 2 }); // SPAN (complete)
const draws = hud.channel({ name: 'draw calls', kind: 3 }); // COUNTER
// Anywhere in your code:
fps.push(59.8);
gc.push();
frame.push(12.4); // duration in ms; t_start = now - 12.4 (D5 layout)
draws.push(312);push() synthesizes a valid SPP record and calls hud.write() directly -- the
same demux path used by scope probes. There is no separate ring or draw path.
API reference
createHud(mountEl, opts?): Hud
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| windowSec | number | 5 | Scrolling time window width in seconds |
| position | 'top-right' \| 'top-left' \| 'bottom-right' \| 'bottom-left' | 'top-right' | Canvas corner |
| hotkey | string | '`' | Key that toggles visibility. '' to disable |
| zIndex | number | 9999 | CSS z-index of the canvas |
| viewport | Viewport CLASS | undefined | Optional DPR-aware renderer (see below). Must be a constructor function, else throws |
| maxDpr | number | Infinity | Cap devicePixelRatio for the backing store. Finite >= 1 or Infinity, else throws |
| stats | { quantiles?, rolling?, crossCheck?, dedup?, topk?, distinct?, freq? } | undefined | Optional injected analytics factories: quantiles (M2, see Percentiles), rolling (M4), crossCheck / dedup (M5), topk / distinct / freq (M3, see Hot spots + cardinality). Each must be a function (or the dedup bag), else throws |
Pass null as mountEl for headless / test mode. All DOM and canvas operations are skipped; the full state layer remains active.
DPR-correct rendering (viewport + maxDpr)
By default the HUD sizes its own canvas from devicePixelRatio (capped at
maxDpr) and resets the transform before scaling on every resize, so repeated
resizes never compound.
Inject a @zakkster/lite-viewport
Viewport class for a resize-aware, DPR-change-aware backing store:
import { createHud } from '@zakkster/lite-hud';
import { Viewport } from '@zakkster/lite-viewport';
const hud = createHud(document.body, { viewport: Viewport, maxDpr: 2 });The HUD owns a sized wrapper <div> (positioning moves to it, the canvas sits
inside) so the viewport measures the HUD, not the page; it constructs
new Viewport({ canvas, maxDpr, onResize }), renders through vp.ctx / vp.dpr,
calls vp.resize() synchronously when the row count grows, and tears the viewport
down in destroy(). @zakkster/lite-viewport is an optional peer dependency:
with none installed, the inline fallback path above is used and nothing regresses.
viewport must be a function (a class); maxDpr must be a finite number >= 1
or Infinity. Either violation throws with the @zakkster/lite-hud: prefix
before any DOM work (fail-closed).
hud.attach(scope, opts?)
Reads scope.streams(), builds the LUT, and calls scope.addSink(hud).
opts.budgets is an array of inline threshold lines applied at attach time:
{ channel: 'fps', threshold: 60, label: 'min fps' }Budget thresholds are also transported at runtime via the BUDGET_SET (0x0F41) meta opcode: a = interned_name_id, b = threshold_value.
hud.channel(desc): { push(v?) }
Creates a manual channel. Synthetic stream IDs are assigned from 0x8000 and never conflict with scope-registered stream IDs (dense from 1).
| Field | Type | Default |
|-------|------|---------|
| name | string | 'ch<n>' |
| unit | string | '' |
| hz | number | null (256-record ring) |
| kind | 0\|1\|2\|3 | 0 (LEVEL) |
| quantiles | (() => DDSketch) \| false | inherit default | Per-channel analytics override: a factory (opt IN; SPAN or LEVEL) or false (opt OUT). See Percentiles |
| topk | (() => SpaceSaving) \| false | inherit default | Per-channel top-k override (M3): a zero-arg factory or false. Rides TAG channels only (a manual INSTANT, never manual LEVEL/COUNTER/SPAN). See Hot spots + cardinality |
| distinct | (() => HyperLogLog) \| false | inherit default | Per-channel distinct-count override (M3): a zero-arg factory or false. Rides a TAG channel or a paired SPAN (its correlId) |
| freq | (() => CountMinSketch) \| false | inherit default | Per-channel per-tag frequency override (M3): a zero-arg factory or false. Requires topk on the same channel |
push() semantics by kind:
| Kind | Call | What is stored |
|------|------|----------------|
| LEVEL (0) | push(value) | t=now, a=value |
| INSTANT (1) | push() | t=now, a=0 |
| SPAN (2) | push(durationMs) | t=now-dur, a=dur (D5) |
| COUNTER (3) | push(value) | t=now, a=value |
hud.write(packed, t, a, b)
Duck-typed SPP sink entry point. Called automatically by the scope mux after attach(); also used directly by push() for manual channels.
Packed field decoding uses arithmetic (not bitwise) to handle the full u16 stream ID range:
const sid = (packed / 65536) | 0;
const op = (packed - sid * 65536) | 0;hud.stats()
{
drops: number; // unrouted records (LUT miss or pool eviction)
channels: number;
epoch: number | null; // t from last EPOCH (0x0F00) meta record
verdicts: number; // gate verdicts stored (capped at 64)
budgets: number; // total threshold lines across all channels
quantileDrops: number; // values rejected by the quantile pre-check (0 if off)
rollingDrops: number; // records rejected by the rolling pre-check (0 if off)
channelStats: Array<{ name: string; count: number; head: number }>;
dedupFirst?: number; // M5: first-seen events marked NEW (wired only)
dedupSuppressed?: number; // M5: repeats suppressed from drawing (wired only)
dedupSkips?: number; // M5: dedup records drawn but not deduped (wired only)
topkDrops?: number; // M3: records dropped by the top-k pre-check (wired only)
distinctDrops?: number; // M3: records dropped by the distinct pre-check (wired only)
freqDrops?: number; // M3: records dropped before the freq add (wired only)
}The three dedup* keys appear ONLY once a dedup channel has wired the peers (a stats.dedup bag with no dedup channel adds none), so a no-factory stats() is byte-identical to 2.3.0. The three M3 keys (topkDrops / distinctDrops / freqDrops) appear ONLY once that peer is wired on a channel, so a no-factory stats() is byte-identical to 2.4.0.
hud.inspect(name): { count, last: { t, a, b }, quantiles?, rolling?, crossCheck?, dedup?, topk?, distinct? } | null
Returns the most recent ring record for a named channel. Cold path -- allocates. Do not call from a frame loop. For a sketched channel it also returns quantiles: { p50, p90, p99, p999, n } (n:0 + NaN quantiles on an empty window). For a rolling channel it also returns rolling: { count, sum, mean, min, max, rate } (count:0, sum:0, mean/min/max NaN, rate:0 on an empty covered window). With a WaveletTree cross-check wired it returns crossCheck: { exact, sketch, relErr, n } | null (M5); with dedup wired it returns dedup: { firstSeen, suppressed, skips } (M5). For a top-k channel it returns topk: { rows: [{ key, label, weight, count?, err }], n } and for a distinct channel distinct: { n, se } (M3; n:0 on an empty window).
hud.range(name, t0, t1): { count, sum, mean, min, max } | null
EXACT sub-window aggregate (M5), by a cold ring scan in sequence order: O(ring capacity), no peer, no hot-path cost, no memory, and correct with out-of-order record times. The per-kind key is LEVEL/COUNTER t, a complete SPAN's end time t + a, a paired SPAN's close time; the value is the level/counter value or the span duration. A record counts iff t0 <= key <= t1 and the value is finite with |v| <= 1e150. An empty window reads count 0, sum 0, mean/min/max NaN. An INSTANT channel reports only count (the rest NaN). Returns null for a CONT-wide channel, an unknown name, or a HUD with no such channel. Throws (@zakkster/lite-hud: prefix) on a non-number, a NaN, or t0 > t1. It always exists -- no factory, no peer, no stats() key.
hud.render()
Draws all visible channels. Caller-throttled to ~10--15 Hz. No-op when mountEl is null or overlay is hidden.
hud.show() / hud.hide() / hud.destroy()
destroy() removes the canvas from the DOM, removes the keydown listener, and calls scope.removeSink() if the scope provides it.
Percentiles (DDSketch, optional peer)
Inject a () => DDSketch factory and the HUD draws per-channel p50 / p90 / p99
/ p99.9 + N tiles and a shaded p50-p99 band behind LEVEL traces -- the
number a profiler HUD exists to show. The HUD never imports
@zakkster/lite-sketch;
you inject it (an optional peer dependency).
import { createHud } from '@zakkster/lite-hud';
import { DDSketch } from '@zakkster/lite-sketch';
const hud = createHud(document.body, { stats: { quantiles: () => new DDSketch(0.01) } });
// per-channel override: a factory opts IN (SPAN or LEVEL), false opts OUT
const rtt = hud.channel({ name: 'rtt', kind: 2, quantiles: () => new DDSketch(0.005) });
// read from code (cold): { p50, p90, p99, p999, n }
hud.inspect('rtt').quantiles;What is sketched: complete + paired SPAN durations always (when a factory
is injected); LEVEL opt-in (a per-channel quantiles factory, or a scope op
with quantiles: true); COUNTER / INSTANT never.
Zero-box hot path (addFrom). lite-sketch >= 1.1.0 exposes
DDSketch.addFrom(buf, i): the value is read UNBOXED from a caller-owned
Float64Array inside the peer, so a fractional value never boxes at the call
boundary (add(fractionalDouble) would box a ~16 B transient when the call is not
inlined). The HUD stores the value into a per-channel Float64Array(1) in
write()'s own frame, then feeds the peer via addFrom -- so the analytics write
path adds no allocation over the caller's own baseline, for integer AND
fractional values.
Pre-check law (peers never throw into the write path). addFrom throws on a
NaN / negative / +-Infinity / out-of-range value. The hot path rejects
exactly that set -- plus v>0 outside the accepted band minIndexable < v <=
maxIndexable (EXCLUSIVE low floor, INCLUSIVE high ceiling) -- counts a
stats().quantileDrops, and never lets addFrom throw into write(). There is no
try/catch on the hot path. v === 0 is legal; -0 sums as 0. The bounds and
the strict check come from the sketch's own getters (strict, minIndexable,
maxIndexable) read once, cold, when the channel sketch is created (no probe).
A strict-range DDSketch (new DDSketch(a, { range })) has strict === true
and is rejected @zakkster/lite-hud: fail-closed -- inject a default
collapsing sketch (no range), so out-of-range values collapse instead of throw.
A factory missing addFrom or with non-finite indexable getters also fails closed.
Window. Two sketches A/B rotate every windowSec/2 by record time (O(1) on
the hot path; the retiring sketch is clear()ed); render/inspect clear() a
scratch and merge() A+B into it, then quantile(). merge / clear are
0-alloc. Coverage is between windowSec/2 and windowSec.
Memory cost: 3 x maxBins x 8 B per sketched channel (the A/B window pair +
the render scratch) -- 48 KB at 2048 bins (the DDSketch default), owned by the
injected factory. With no factory injected, none of this exists and behavior +
stats() + the render call trace are byte-identical to 2.1.0.
Rolling aggregates + partial redraw (SlidingAggregate, optional peer)
Inject a (W) => SlidingAggregate factory (@zakkster/lite-adaptive >= 1.11.0)
and the HUD maintains EXACT time-windowed count / sum / mean / min / max per
channel, drawing a rolling tile (mean / min / max + rate) and driving autoscale --
replacing the O(n) render-time ring rescan. The HUD never imports lite-adaptive.
import { createHud } from '@zakkster/lite-hud';
import { SlidingAggregate } from '@zakkster/lite-adaptive';
const hud = createHud(document.body, {
windowSec: 5,
stats: { rolling: (W) => new SlidingAggregate(W, { panes: 32 }) },
});
hud.attach(scope);
hud.inspect('rtt').rolling; // { count, sum, mean, min, max, rate }The factory receives W. It is called with W = windowSec * 1000 (record-time
ms), so the HUD and the peer can never disagree on the window.
What is fed. Every width-1 channel + paired SPAN when a factory is injected (CONT-wide channels are never fed). Feed rules:
| Kind | Fed (now, value) |
|------|--------------------|
| LEVEL / COUNTER | (t, a) |
| Complete SPAN | (t + a, a) -- the END time, so nested spans stay monotone |
| Paired SPAN | (t, t - tOpen) |
| INSTANT | (t, 1) -- so rate = count / windowSec |
A per-channel override hud.channel({ rolling }) takes a factory or false.
Zero-box hot path. The HUD writes the time + value into a per-channel
Float64Array in write()'s frame and feeds the peer via the zero-box
SlidingAggregate.addFrom(buf, i) (read UNBOXED inside the peer), so a fractional
time / value never boxes at the call boundary. advanceFrom / into read from the
same buffers on the cold render path.
Pre-check law (the peer never throws into write()). A value is kept iff
|v| <= 1e150; a time iff t >= lastNow and |t| <= (W / panes) * 2^42. A NaN
fails closed by construction. Every rejection increments stats().rollingDrops and
never lets addFrom throw into the caller's hot loop. There is no try / catch
on the hot path. nowMax and the dust tolerance are computed once at setup; the
last accepted time is cached in the HUD's own slot (no getter on the hot path). A
complete-SPAN end time at most (W / panes) / 1024 below the last accepted time is
dust-clamped up to it (so two spans ending at "the same" instant never drop on
FP rounding); anything further back drops.
Factory check (cold, fail-closed). The returned instance must duck-type
addFrom / advanceFrom / into / clear, have W === requested W, an integer
panes, mode === 'unset', a numeric bytes, and must not already be wired to
another channel -- a shared SlidingAggregate corrupts the per-channel window and
makes the peer throw into write(). An instance is claimed PROCESS-WIDE by the
first channel (of any HUD) it is wired to, for its lifetime; destroy() does NOT
release the claim, so the factory must return a FRESH instance per call. Any
violation throws @zakkster/lite-hud:.
One clock. Once per render (and before an inspect() read) the HUD advances
every rolling channel to the max record time it has seen (the idle slide), so an
idle channel's readout empties instead of freezing. A channel fed on a different
clock is advanced to the larger one and its own records then drop into
rollingDrops -- counted, never thrown.
EPOCH resets the analytics. On an OP_EPOCH meta record the HUD clears every
rolling instance (and its cached lastNow) and resets the M2 percentile window
(qA/qB.clear(), q = qA, qRotAt = 0) to a fresh state. Without this, a
backward record time after an epoch would stall both (the fix corrects a 2.2.0 bug
where such a stream kept stale mass -- a 60-record run a fresh HUD reads as 10).
Disclosed approximations. The covered span is [W, W + W/panes], always
containing the last W: autoscale over-covers the window by at most W / panes
(one pane width), and the rate over-counts by at most 1 / panes of a window.
count / min / max are bit-exact and |sum - exact| <= (4u) x sum|v|.
Memory cost: (panes + 1) x 48 B per rolling channel in the peer -- 1584 B at
panes = 32 -- plus a length-6 + length-10 Float64Array in the HUD. With no
factory injected, none of this exists and the full-frame render trace + stats()
(minus the always-present rollingDrops) + inspect() (no rolling key) are
byte-identical to 2.2.0.
Partial redraw (always on, no peer). render() repaints only the rows whose
ring count changed, whose last paint left content in or right of the plot (live
-- so a record stamped ahead of the render clock repaints when it scrolls in),
or that are paired channels with open spans. A full frame is forced on the first
frame, a resize / DPR change, row growth, show(), a new verdict or one in or
right of the plot last frame, and EPOCH. The full-frame output is byte-identical
to 2.2.0. The rolling idle slide
runs for every channel each frame, so an idle channel repaints once when its window
empties, then stops.
Exact range + p99 cross-check + windowed dedup (M5, optional peers)
Three additions. hud.range() needs no peer and always exists; the cross-check and
dedup are injected, never imported. With no M5 factory wired, every full-frame
render trace plus stats() and inspect() are byte-identical to 2.3.0 (gated by a
degrade golden captured from HEAD).
Exact range. hud.range(name, t0, t1) is a cold O(ring capacity) scan (see
the API reference above). It maps nothing to slots -- it just scans the live ring
in sequence order -- so out-of-order record times are handled exactly, with no
monotone flag and no null for that. lite-logn SegmentTree injection for an
O(log n) tile is deferred to v3.0 (pointer drag selection).
p99 cross-check (@zakkster/lite-logn >= 1.5.1 WaveletTree).
import { DDSketch } from '@zakkster/lite-sketch';
import { WaveletTree } from '@zakkster/lite-logn';
const hud = createHud(el, {
stats: {
quantiles: () => new DDSketch(0.01),
crossCheck: (n) => new WaveletTree(new Float64Array(n)), // n = ring capacity
},
});
// cold, inspect only:
hud.inspect('latency').crossCheck; // { exact, sketch, relErr, n } | nullThe population is identified by sequence, not time: qRotate records sequence
watermarks (qSeqOld / qSeqCur) with no M2 output change, so an out-of-order
record still belongs to the active sketch. While UNSEEDED (no rotation yet this
era) the population starts at the era start qSeqEpoch (0 at attach, count at
EPOCH); a rejected leading record never shifts it. The merged A+B window sketch
covers records [qSeqOld, count-1]; the HUD rebuilds the WaveletTree cold from exactly
those ring values that pass the M2 pre-check and reads the exact k-th value
(k = floor(0.99*(m-1)), the same 0-based rank DDSketch uses). It returns null
fail-closed when an accepted population record has left the ring, detected as the
gathered count (the gather is clamped to the oldest live ring slot) being below the
merged sketch count. No render tile (no
per-frame rebuild) in M5.
Windowed dedup (@zakkster/lite-filter >= 1.2.1 Bloom + @zakkster/lite-lru >=
1.19.0 LiteLru). INSTANT channels only, opt in with dedup: windowMs:
import { Bloom } from '@zakkster/lite-filter';
import { LiteLru } from '@zakkster/lite-lru';
const hud = createHud(el, {
stats: {
dedup: {
bloom: () => new Bloom(1 << 16, { keys: 'int' }),
lru: (clock) => new LiteLru(4096, { keys: 'int', ttl: 1, clock }),
},
},
});
const errs = hud.channel({ name: 'errors', kind: 1, dedup: 800 }); // 800 ms window
errs.push(code); // code: integer in [0, 2^20)One Bloom + one LiteLru per HUD. The signature is sig = (ci << 20) | code with the
channel index ci < 1024 -- lossless and in [0, 2^30), so Chrome's 31-bit
Smis never box it at the HUD's own Bloom/LRU call. A first-seen event is marked
NEW (stroked in a bright highlight); a repeat inside its window is suppressed
from drawing (still recorded, counted, and fed to rolling/quantiles). The window
is fixed from first sight -- a steady repeat is not suppressed forever. On the hot
path, a code that is not an integer in [0, 2^20) (a fractional code would truncate
in the fold -- fail closed), a non-finite first t (it never becomes the base),
a relative time floor(t - base) outside [0, 2^30), or a re-entered peer call is
a counted skip, drawn normally -- the dedup write path is 0 B/op. push(code) on
a dedup channel passes a non-number through as NaN (no || 0), so a bogus code is
skipped, never deduped as code 0. EPOCH clears the LRU (its time base changes) and
resets the base, but KEEPS the Bloom (first-seen means "ever").
Re-entrancy. The HUD wraps every Bloom/LRU call in a _dBusy guard: a dedup
write re-entered from inside the user's onEvict (where lite-lru is mid-callback)
is a skip, not a REENTRANT_MSG throw, and an EPOCH written from inside onEvict
defers its _lru.clear() to the next dedup record (once _dBusy is clear).
_dBusy is released in a
try/finally, so even a user onEvict that throws cannot wedge dedup. The one
caveat: a user onEvict's own throw still propagates out of write() -- it is
the user's callback -- but it leaves no stuck state.
Fail-closed wiring (cold). A WaveletTree without rebuildFrom/quantileInto, a
Bloom whose keysMode !== 'int', an LRU whose keysBacking !== 'int' or
ttlEnabled !== true, a ci >= 1024, a non-INSTANT or CONT-wide dedup channel, a
window outside [1, 2^30), a missing factory bag, or a peer instance shared across
channels/HUDs (claimed by a process-wide WeakSet) all throw at wiring with the
@zakkster/lite-hud: prefix -- so the HUD's OWN hot path never throws into
write() (only a user onEvict's own throw can).
Memory cost: cross-check adds a Float64Array(cap) value column + a length-1
output per sketched channel, plus the WaveletTree itself (~32 B per constructed
element, allocated once on the first rebuildFrom). Dedup adds a Uint8Array(cap)
draw-flag per channel, plus the one Bloom + one LiteLru for the whole HUD. range()
allocates nothing persistent.
Hot spots + cardinality (M3, optional peers)
Three injected sketches from @zakkster/lite-sketch
>= 1.2.0 answer "which few tags dominate" and "how many distinct", per channel.
All three are zero-arg factories -- no createHud closure ever reaches a peer:
import { SpaceSaving, HyperLogLog, CountMinSketch } from '@zakkster/lite-sketch';
const hud = createHud(el, {
stats: {
topk: () => new SpaceSaving(64),
distinct: () => new HyperLogLog(12),
freq: () => new CountMinSketch(4, 1024), // optional; requires topk
},
});
// cold, inspect only:
hud.inspect('render').topk; // { rows: [{ key, label, weight, count?, err }], n }
hud.inspect('render').distinct; // { n, se }What is fed. The record's interned tag id b is the high-cardinality
dimension; a is the stat.
| Kind | top-k over | weight | distinct over | freq |
|------|------------|--------|---------------|------|
| complete SPAN | tag b | integer us of a (SUM of durations) | tag b | count per tag |
| LEVEL / COUNTER | tag b | 1 (frequency) | tag b | count per tag |
| INSTANT | event code b | 1 | event code b | count per code |
| paired SPAN | -- (b is a correlId) | -- | correlId (distinct requests) | -- |
Eligibility. Top-k rides a tag channel only: a width-1, non-paired scope
channel, or a manual INSTANT (its push(code) writes the tag). A manual LEVEL /
COUNTER / SPAN writes b = 0, so it has no tag dimension -- the createHud default
never rides it, and an explicit topk / distinct / freq factory there
throws (@zakkster/lite-hud: prefix). Distinct also rides a paired SPAN (over the
safe-integer correlId). freq requires topk on the same channel; an explicit
freq without topk throws. A CONT-wide channel takes neither. A manual INSTANT
push(code) writes b = code on an M3-wired or dedup channel; a non-number passes
through as NaN and is a counted drop. With no M3 peer, push() is byte-identical
to 2.4.0.
Doors (the hot-path pre-check; peers never throw into write()). The key is a
u32 tag (b === (b >>> 0)); a paired correlId takes the wider safe-integer door
(key === floor(key) && |key| <= 2^53-1). The SPAN weight is
us = Math.floor(a * 1000 + 0.5) (round-half-up), clamped in float to
[1, 2^32-1] and written straight into the addFrom buffer -- never through | 0,
never an argument. So a span > 71.58 min ranks at the 71.58-min clamp, and a
span < 0.5 us is over-counted by at most 1 us (it still counts as an event). A
NaN / negative / +-Infinity duration is a counted drop; -0 folds to +0. A
strict 2^52 per-instance total door (the HUD keeps its own running total in a
Float64Array slot) drops any record whose add would reach 2^52; one slot covers
both SpaceSaving and CMS (CMS is fed only after SS accepts, adding 1 <= the SS
weight), so the merged A + B stays under 2^53-1 and merge cannot throw.
Window. Two instances per peer rotate every windowSec/2 by record time, each
channel on its own clock (a channel may have top-k without M2). At render / inspect
the HUD clear()s a scratch and merge()s A + B into it, then reads
topKInto (SS) / count() (HLL) / estimate(key) (CMS). EPOCH clears A + B of
every wired peer and forces a block repaint.
Readouts + render. inspect(name).topk -> { rows, n }, with each row
{ key, label, weight, count?, err } (weight / err in ms on a complete SPAN,
records otherwise; count only with freq; n = window total N in the row unit,
0 = empty). inspect(name).distinct -> { n, se }. On screen, each top-k channel
gets a fixed block band of kN*9 + 2 px under its row (kN = min(8, capacity),
sized at wiring -- no reflow): per line a label (<= 14 chars) + weight (<n>ms on
SPAN, x<count> otherwise), plus with freq the count n<count> and mean u<ms>
(SPAN only); the first line is -- only when there are 0 rows. The distinct count
sits in the label column (D<count>+-<se>%; D -- only when the window is empty).
The block repaints on rotation / EPOCH or a changed top-N and is skipped otherwise,
and it paints regardless of row visibility (disclosed). Labels come from
scope.label(key), cold; a label() that throws or returns a non-string shows the
numeric key (String(key)) and never wedges the readout. The catch also swallows a
throw from a HUD call made INSIDE label() (e.g. a write() contract throw), so keep
label() a pure lookup. A label() that re-enters render() / inspect() is safe: the
nested refresh is deferred to the next render, and a nested inspect() shows numeric
keys instead of calling label() again. If a label() fires an EPOCH, that one frame
still paints the pre-EPOCH rows; the next render clears them. An idle channel's window
freezes (it moves by record time), disclosed, as M2.
Drops. stats().topkDrops / distinctDrops / freqDrops count the rejected
records and appear ONLY once that peer is wired on a channel, so a no-factory
stats() stays byte-identical to 2.4.0.
Fail-closed wiring (cold). Each factory is called three times (A, B, cold merge
scratch) and duck-typed for addFrom / merge / clear plus topKInto (SS) /
count (HLL) / estimate (CMS); the sizing getters are read (SS integer
capacity >= 1 + numeric seed; HLL integer m >= 1 + numeric seed +
standardError in (0, 1); CMS integer d/w + seed + epsilon/delta in
(0, 1)), with freshness (total === 0, HLL count() === 0, CMS
saturated === false) and one shared prototype with equal sizing and seed (an
unequal HLL seed hashes keys differently, so merge fails closed -- proven by a dry
merge/clear at wiring, never deferred to render()). Each call must return a
distinct instance (A, B and the merge scratch cannot be one shared sketch), and
each is claimed in a process-wide WeakSet; a reused or already-wired instance,
a wrong shape, or a missing method throws with the @zakkster/lite-hud: prefix at
wiring.
Memory cost (defaults, per channel).
| Factory | Per instance | A/B/scratch trio | Error |
|---------|--------------|------------------|-------|
| SpaceSaving(64) | ~5,520 B | ~16,560 B (+ ~19 KB transient merge scratch per changed refresh) | count-err <= true <= count |
| HyperLogLog(12) | ~4,320 B | ~12,960 B | se 0.01625 |
| CountMinSketch(4, 1024) | 16,428 B | 49,284 B | eps 0.00265, delta 0.0183 |
Plus ~576 B/channel HUD-side (the Float64Array(8) staging buffer + the row
caches). All three wired is ~78.8 KB per channel. The ~19 KB SpaceSaving merge
scratch is the one disclosed cold allocation; it runs only on a refresh where the
record count advanced, never on the write path.
Ring layout
| Kind | Stride | Slots |
|------|--------|-------|
| LEVEL / INSTANT / COUNTER (width=1) | 3 | [t, a, b] |
| Complete SPAN (non-paired) | 3 | [t_start, duration_ms, 0] |
| Paired SPAN (closed) | 3 | [t_open, t_close, correlId] |
| width=N (CONT) | 3N | primary [t, a, b] + (N-1) CONT triplets |
Ring capacity = pow2(ceil(hz × windowSec) + 1) for LEVEL channels, 256 otherwise.
Zero-GC (witnessed)
The write path performs zero LIBRARY-OWNED allocation. Every record kind, the
paired open/close path, the pool eviction churn, and meta records are 0 B/op (for
SMI-domain inputs, inlined AND under --max-inlined-bytecode-size=0 -- the
paired-span hashKey returns a signed int32 so its result never boxes on return
from the non-inlined pool helpers) -- the rings and the paired-span open pool are
fixed typed arrays allocated once at attach and never reallocated or grown. The M5
dedup write path is 0 B/op too (DedupBox.test.mjs, ON == OFF both inlined and
no-inline, a one-box teeth control in its own process at delta >= 2*TOL): the record
stays in a Float64Array scratch and only ch crosses the dedupStep call; the
Smi signature + integer relative clock never box into the Bloom / LRU. Render
is a disclosed COLD path (caller-throttled to ~10-15 Hz) and may allocate to draw;
the WaveletTree cross-check is likewise COLD (inspect only).
The one caller-side caveat: a DISTINCT fractional double passed as an argument is boxed by V8 at the JS call boundary (a ~16 B transient nursery HeapNumber, never retained), exactly as for any JS function taking a double. Storing a fractional MAGNITUDE into the ring costs nothing (a reused constant is witnessed at 0 in the perf gate); only a varying distinct fractional value boxes, caller-side.
| Write-path op | Bytes/op | Witnessed by |
|---------------|----------|--------------|
| write() LEVEL / INSTANT / COUNTER | 0 | torture + perf gate |
| write() CONT-chained wide record | 0 | torture + perf gate |
| write() complete (D5) SPAN | 0 | torture + perf gate |
| paired SPAN open + close | 0 | torture + perf gate |
| paired pool eviction churn (backshift + FIFO) | 0 | torture + perf gate |
| meta EPOCH / VERDICT / BUDGET_SET (replace) | 0 | torture + perf gate |
| channel().push() (all kinds) | 0 retained | torture |
| analytics-ON write() LEVEL / complete SPAN / paired (addFrom) | 0 | torture + perf gate |
| rolling-ON write() every width-1 kind + paired (addFrom) | 0 | torture + perf gate |
| rolling headless render() (advanceFrom + into) | 0 | torture + RollingBox |
| M3 top-k / distinct / freq write() (addFrom, u32 tags near 2^32, fractional-ms weights, rotation-heavy) | 0 | torture + TopkBox |
| Retained growth over fill/clear cycles | 0 | torture (arrayBuffers flat) |
| GC major / minor collections over the window | 0 | torture |
Injecting the analytics (a () => DDSketch factory) keeps the write path at
0 B/op for all three sketched kinds -- LEVEL, complete SPAN, and paired SPAN
durations -- because the value is written into a per-channel Float64Array in
write()'s frame and fed to the peer via the zero-box addFrom(buf, i); the
pre-check + O(1) window rotate allocate nothing, the A/B window sketches are fixed
typed arrays allocated once at attach, and merge / clear (render/inspect only)
are 0-alloc. The yardstick is measured with FRACTIONAL inputs in
test/perf/AnalyticsBox.test.mjs: analytics-ON minor-GC scaling equals
analytics-OFF scaling (delta ~0 -- the only box is the caller passing fractional
t/a into write(), present with analytics OFF too). A control (an injected
sketch whose addFrom allocates per op) MUST scale above the baseline, and the
perf-gate mustFail catches an allocating injected sketch -- both prove the
instrument has teeth.
channel().push() reads wall-clock time (performance.now(), a fractional
double); V8 boxes a fractional double as a transient nursery value when passing
it to a large function, so it is gated on the RETAINED lane (torture, 0 B/op),
not the scavenge lane -- the box is orthogonal to lite-hud's own allocation.
Rolling analytics is held to the same bar: test/perf/RollingBox.test.mjs drives
FRACTIONAL times (at both scope-epoch ~1e5 and epoch-ms ~1.7e12 scale) and values
from Float64Array slots and asserts rolling-ON minor-GC scaling stays within
TOL = 5 scavenges of rolling-OFF (observed delta 0) for every kind, under BOTH
the default and the --max-inlined-bytecode-size=0 process
(npm run test:perf:noinline). Both --min-semi-space-size=4 and
--max-semi-space-size=4 are pinned (the test fails closed if they are absent, so
the one-box scale cannot drift). A ONE-BOX control
(test/perf/RollingTeeth.test.mjs, own process -- a rolling peer that boxes
exactly one HeapNumber per op into a PACKED_ELEMENTS slot) MUST scale above
TOL -- at the shared N/k (k = 32) it reads delta ~25 at 32N (>= 2 x TOL), a
non-thin proof -- and the perf-gate mustFail (RollingMustFail.test.mjs)
catches an allocating injected rolling peer. The headless render() lane (the
rolling step advanceFrom + into) is gated in its OWN process
(test/perf/RollingRender.test.mjs) with FRACTIONAL inputs and a write only every
64th op, so the rolling step -- not the write-arg box -- is the per-op signal:
baseline 0, real peer delta 0, and an into()-boxing detection control reads ~123,
proving the lane can see a box.
M3 hot spots + cardinality sits on the same yardstick. The key + weight ride a
per-channel Float64Array(8) in write()'s frame; only ch crosses the
topkStep call, so a u32 tag near 2^32-1 and a fractional-ms duration stay
unboxed as they feed all three peers via addFrom. The SpaceSaving running-total
compare, the [1, 2^32-1] weight clamp and the windowSec/2 A/B rotation
(clear() on SpaceSaving + HyperLogLog + CountMinSketch) all run in that frame and
allocate nothing. test/perf/TopkBox.test.mjs asserts all-three-ON minor-GC
scaling equals all-three-OFF with FRACTIONAL weights, u32 keys near 2^32-1 and a
rotation-heavy window, inline and under --max-inlined-bytecode-size=0, with the
non-vacuity that the lane actually evicts in SpaceSaving, rotates, and hits the
weight clamp (asserted counts). A ONE-BOX control (test/perf/TopkTeeth.test.mjs)
MUST scale above TOL. topKInto / count / estimate and the merge scratch
are COLD (render / inspect only).
node --expose-gc test/torture.mjs # lite-leak + lite-gc-profiler
npm run test:perf # lite-perf-gate (0 scavenges, must-fail control)
npm run test:perf:noinline # the whole perf dir with inlining disabled
npm run verify # test + torture + torture:controls + test:perf(+noinline)Test suite
npm test
# 241 tests, 0 failuresAll tests run headless (mountEl = null). The mock scope factory in the test file is a useful reference for testing your own probes against the HUD.
Migration from v1
Channel creation
// v1
const fps = hud.track('fps', { hi: 120, lo: 0, warnBelow: 30 });
fps.push(59.8);
// v2 -- manual channel
const fps = hud.channel({ name: 'fps', kind: 0 });
fps.push(59.8);
// Budget lines via attach option:
hud.attach(scope, { budgets: [{ channel: 'fps', threshold: 30, label: 'min fps' }] });Scope integration
// v1 -- HUD polled a memory sink separately
// v2 -- HUD is a live mux sink
hud.attach(scope); // that's itRendering
// v1 and v2 -- same
hud.render(); // call at ~10-15HzProtocol reference (SPP v1 constants, inlined)
Meta stream (sid 0): EPOCH 0x0F00 | CONT 0x0F01 | VERDICT 0x0F40 | BUDGET_SET 0x0F41
Kinds: LEVEL 0 | INSTANT 1 | SPAN 2 | COUNTER 3License
MIT -- Copyright (c) 2026 Zahary Shinikchiev
