model-graph

Making ONNX observable: residual streams for in-browser interpretability

2026-07-20 · Quirin Schlegel · 6 min

Running an LLM in the browser via WebGPU (transformers.js + ONNX Runtime) is delightful — and completely opaque. A compiled ONNX graph exposes only its declared outputs: logits and KV-cache. There is nowhere to hang a forward hook. The hidden states exist for microseconds inside fused kernels, then vanish.

The trick: they're already computed

Optimized exports fuse each residual add into a SkipSimplifiedLayerNormalization node — which has an optional fourth output, input_skip_bias_sum: the residual stream itself. Layer i's residual is exactly that output on layer i+1's input-layernorm. The final layer's sum is one undeclared optional output away on the final norm node.

So the "surgery" is almost embarrassingly small: map the 30 tensor names, bridge each through a Cast to fp32 with a stable name (hidden.0…hidden.29), and append them to graph.output. Zero extra compute — the tensors were always flowing; they just weren't visible.

hidden.i = layers[i+1].input_layernorm.input_skip_bias_sum
validation vs PyTorch hooks:
  ✓ next-token argmax parity
  ✓ residual norms track reference (worst drift 12.1% — q4f16)
  ✓ 30 outputs, [batch, seq, 576]

What it unlocks

In model-graph, a patched model.forward reads hidden.* each step and computes residual norms, a seeded 3D projection (the trajectory view), and top-k — all client-side, from real tensors, on WebGPU. Serverless interpretability: the browser fetches weights from Hugging Face and inspects itself.

The honest boundary: per-block writes (attention/MLP separately) and per-head signals need more declared outputs — same technique, more tensor-name mapping. The export script and validation live in the repo (export_observable.py); try the UI at model-graph.com.