Making the readout fast¶
This is the inference-speed guide for typedecide.export. It is written for
someone who has to decide what to spend a week on, so every claim carries a label
saying how much it is worth.
| Label | Means |
|---|---|
| MEASURED | A number this project actually observed on hardware. The source file is named. |
| DERIVED | Exact arithmetic from a published model config. No timing claim — FLOPs and bytes, not milliseconds. |
| PROJECTED | An estimate. The assumptions are stated and the arithmetic is shown. It may be wrong. |
All MEASURED numbers come from bench/ and bench/results/: MacBook,
onnx-community/Qwen3-0.6B-ONNX, q4f16, unless the line says otherwise.
Nothing in this document is a measured wall-clock benchmark of pruning or of
trimming — those levers are derived and projected only. Say so if you quote
them.
The short version¶
| Lever | Effort | What it buys | Evidence |
|---|---|---|---|
| Trim logits to the last position | one graph edit | 45x less logit compute and readback on a 45-token suffix | DERIVED |
| Prune the LM head to the answer letters | this module | Kills the remaining readback; 0 download saving on a tied model, ~209 MiB on Qwen3-4B | DERIVED |
| Keep the KV cache on the GPU | one session option | 63.9 MB of round-tripping per criterion at a 256-token prefix | DERIVED |
Quantise to q4f16 |
one command | 4x smaller than fp16 | DERIVED size, unmeasured accuracy cost |
| Shorten the per-criterion suffix | prompt work | Linear in tokens; the whole per-criterion cost is the suffix | MEASURED shape, PROJECTED size |
| Batch the criteria | — | Impossible in the browser. Do not plan around it. | MEASURED |
Do them in that order. Trimming is the cheapest and largest single win; pruning finishes the job; everything else is smaller.
1. The export returns logits for every position¶
A stock Optimum export of a decoder returns logits shaped
[batch, sequence, vocab] — the whole sequence, not the position you asked
about. A typed readout reads exactly one position (web/engine.js reads
suffix.length - 1). Everything else is computed, serialised, copied out of the
accelerator, and discarded.
DERIVED. Qwen3-0.6B, vocab = 151936, a 45-token criterion suffix (the
figure bench/logit_cost.mjs uses as typical), fp32 logits:
untrimmed: 45 x 151,936 x 4 B = 27,348,480 B = 26.1 MiB per criterion
trimmed: 1 x 151,936 x 4 B = 607,744 B = 0.58 MiB per criterion
Over one pass of the 144-row authored144 fixture that is 3.94 GB of
GPU-to-CPU copy that nobody reads. The unembedding matmul scales the same way:
untrimmed head: 45 x 2 x 1024 x 151,936 = 14.0 GFLOP per criterion
rest of model: 45 x 2 x 440,467,456 = 39.6 GFLOP per criterion
-> the head is 26.1% of the per-criterion FLOPs
trimmed head: 1 x 2 x 1024 x 151,936 = 0.31 GFLOP (0.78% of the body)
So trimming alone removes about a quarter of the arithmetic and 98% of the readback. It is the single best thing you can do, and it is one node.
MEASURED. bench/logit_cost.mjs, Qwen3-0.6B q4f16 on onnxruntime-node (CPU),
prefix prefilled once and held fixed so only the suffix length varies. Result in
bench/results/logit-cost-Qwen3-0.6B-q4f16.json:
suffix tokens 1 8 16 32 64 96
median ms 32.1 54.0 80.0 132.4 233.6 339.6
fit: 28.4 ms fixed + 3.23 ms per suffix token
Cost is linear in suffix length, as the arithmetic above predicts, and a typical 45-token criterion lands at ~174 ms on this backend. (Not comparable to the browser's WebGPU path, which measures ~91 ms for the same shape — the point here is the slope, not the intercept.)
What that slope bounds:
- Lower bound, 1.27x. The unembedding is 26.1% of per-token FLOPs. Removing
it for 44 of 45 positions and nothing else:
174 -> 137 ms. - Upper bound, 5.5x. If the entire per-token term were logits-related:
174 -> 32 ms. It is not — attention and MLP are in there too.
The truth is between, and closer to the bottom than the top for FLOPs alone. What the arithmetic cannot bound is the 0.61 MB-per-token readback, whose cost depends entirely on the backend's transfer path; on WebGPU that copy crosses a device boundary and on CPU it does not. A trimmed export still has not been built or timed. Until it is, this is a well-founded reason to expect a win, not a win.
How to trim¶
typedecide.export.trim_logits_to_last_position does it by graph surgery: it
renames the logits output to logits_all_positions, appends a
Slice(starts=[-1], ends=[INT64_MAX], axes=[1]), and binds that node's output
back to logits, so the graph's interface is unchanged apart from the middle
dimension becoming 1.
from typedecide.export import export_onnx, ExportConfig, trim_logits_to_last_position
directory = export_onnx(ExportConfig("Qwen/Qwen3-0.6B", None, Path("out")))
trim_logits_to_last_position(directory)
Run it right after the export and before quantisation. It passes
onnx.checker or it writes nothing.
Two caveats, both real:
- Decode steps already have
sequence == 1, so this changes nothing there. It is a prefill/suffix fix. In this project the per-criterion suffix is the prefill, which is why it matters so much here and so little for a chatbot. - Any consumer must read the last position from the tensor's own shape.
Indexing a trimmed graph's output by
suffix.length - 1runs off the end of a one-position tensor and produces garbage, not an error.web/engine.jsalready does this correctly:readSlotscomputes the offset fromlogits.dims.at(1) - 1, which is right for both the full and the trimmed graph. If you write your own runtime, do the same.
2. Vocabulary pruning — slicing the LM head to the answer letters¶
This is the idea the module is built around. The readout compares at most
len(LETTERS) == 20 logits. Every other row of the unembedding exists to
support generation the model is never asked to do, so slice them off.
typedecide.export.prune_lm_head(model, keep) does it, and
slot_map.json records what each surviving row used to be.
The arithmetic, Qwen3-0.6B¶
vocab_size = 151936, hidden_size = 1024, 28 layers, head_dim = 128,
16 query heads, 8 KV heads, intermediate_size = 3072. DERIVED, and pinned
in tests/test_export_prune.py::test_qwen3_0_6b_lm_head_arithmetic so it cannot
drift:
embedding / unembedding table 151,936 x 1024 = 155,582,464 params
everything else = 440,467,456 params
total = 596,049,920 params
the table is 26.10% of the model
Pruning to K rows:
Per-position effects at K = 4:
unembedding matmul 2 x 1024 x 151,936 = 311,164,928 FLOP -> 8,192 FLOP
logits tensor, fp32 151,936 x 4 B = 607,744 B -> 16 B
logits tensor, fp16 151,936 x 2 B = 303,872 B -> 8 B
Combine with the trim from §1 and one criterion's logit traffic goes from
26.1 MiB to 12 bytes (K = 3, the authored144 option count) — a factor of
2.28 million. That is the number worth remembering, and it is arithmetic, not a
stopwatch.
The download: tied vs untied, and the honest answer¶
This is the part that is usually stated wrong, including in this project's own
CONTRACTS.md gloss. Whether pruning shrinks the download depends entirely on
config.tie_word_embeddings.
- Tied (Qwen3-0.6B and 1.7B): one table serves both the input embedding and
the unembedding. The input side still needs a row for every token id in the
prompt, so the table cannot leave the file. In the ONNX graph the same
initializer feeds the
Gatherand the finalMatMul, so it is stored once and pruning removes nothing from it. Pruning adds a newK x hiddenhead: 20 x 1024 x 4 B = 80 KiB at fp32. Net download change: approximately zero, very slightly up.lm_head_savings(..., tied=True)returnsweight_bytes_removed == 0.0for exactly this reason. What you get on a tied model is the matmul and the tensor copy, which is still most of the win. - Untied: the head is its own matrix and pruning deletes it outright.
MEASURED, from each checkpoint's published
config.json(2026-09-20): Qwen3-0.6B, 1.7B and 4B settie_word_embeddings: true; Qwen3-8B and 14B set it tofalse. So the download saving starts at 8B, not 4B. Only Qwen3-0.6B has actually been run in this repository. Readtie_word_embeddingsfor the exact checkpoint you ship, and pass--tiedtotypedecide savingsaccordingly.
DERIVED, at q4f16 (4 bits per weight plus one fp16 scale per block of 32 =
4.5 bits = 0.5625 bytes):
| Model | hidden | Tied | Head params | Head share of total | Download removed by pruning |
|---|---|---|---|---|---|
| Qwen3-0.6B | 1024 | yes | 155,582,464 | 26.10% of 596M | 0 (table still feeds the input embedding) |
| Qwen3-1.7B | 2048 | yes | 311,164,928 | 18.08% of 1.72B | 0 (same reason) |
| Qwen3-4B | 2560 | yes | 388,956,160 | ~9.7% of ~4.02B | 0 (same reason) |
| Qwen3-8B | 4096 | no | 622,329,856 | ~7.6% of ~8.19B | 622,313,472 x 0.5625 = 350,051,328 B (333.8 MiB) |
Head params and the removal bytes are exact. Total parameter counts for 4B and 8B are the published model-card figures, so the percentage column is approximate.
One loose end, PROJECTED. web/models.json annotates Qwen3-0.6B q4f16 as
~450 MB, but 596,049,920 x 0.5625 = 335.3 MB. The 115 MB gap is most likely
the embedding table not being 4-bit at all: Gather cannot index int4 data, so
quantisers commonly leave the table at int8 or fp16. If that is what is
happening, then on a tied model the shared table is sitting in the file at
int8 (155.6 MB) or fp16 (311 MB) purely because the input side needs it — and
after untying and pruning, the table is only an embedding table and can be
quantised on its own terms without any concern for what the readout matmul
needs. That is a real second-order download win, and it is unverified. Check it
before you promise it:
import onnx, collections
graph = onnx.load("out/onnx/model.onnx").graph
consumers = collections.defaultdict(list)
for node in graph.node:
for name in node.input:
consumers[name].append(node.op_type)
for init in graph.initializer:
if len(init.dims) == 2 and 150_000 < init.dims[0] < 160_000:
print(init.name, init.dims, onnx.TensorProto.DataType.Name(init.data_type),
consumers[init.name])
One entry consumed by both Gather and MatMul means the tied table is shared
and pruning will not shrink the file. Two entries means the exporter duplicated
it and pruning removes one whole copy.
The correctness trap: untie before you slice¶
Qwen3 ties at small sizes, which means lm_head.weight is
model.embed_tokens.weight — the same Parameter object, not a copy. Slicing
it in place leaves the input embedding with K rows, and every prompt token id
above K then indexes out of bounds or, worse, silently reads the wrong row. The
model still runs. The accuracy just quietly collapses.
prune_lm_head handles it in four steps, and refuses to return a model that
fails the last one:
- Build a fresh
K x hiddentensor from the kept rows and rebindlm_head.weightto it. Rebind, never mutate — mutation is what reaches through to the input side. - Set
config.tie_word_embeddings = False, or loading the checkpoint back re-ties the K-row head onto the input embedding and destroys it. - Clear
model._tied_weights_keys. Left set,save_pretrainedtreatslm_head.weightas a duplicate of the input embedding, omits it from the state dict, and the reloaded model has no head at all. This failure is invisible until inference. - Verify: the input embedding still has its original row count, its values at
the probed row are byte-identical, and the two tensors no longer share
storage. Any of those failing raises
ExportError.
config.vocab_size is deliberately not changed. The input side still needs a
row per token id, and lowering it makes Optimum generate out-of-range dummy
inputs during the trace.
tests/test_export_prune.py exercises all of this against a stub model whose
head and embedding are literally the same Python list, so the trap is tested in
CI with no torch installed.
What a pruned model can no longer do¶
Two things, both permanent, both worth deciding about before you ship:
- It cannot generate text. There is no row for any token that is not an answer letter. Sampling, beam search, perplexity, and any qualitative "what would it have said?" debugging are gone. Keep the unpruned checkpoint.
- It cannot report what share of total vocabulary mass landed on the answer
letters. The full-vocabulary softmax denominator does not exist any more.
web/engine.js:readSlotscurrently computes exactly this asallowedMass, and it is a genuinely useful diagnostic — it is the evidence that the model has learned the output format rather than being forced into it by the argmax-over-letters. A pruned model gives you the softmax over the K kept rows and nothing else. That restricted distribution is what the readout uses anyway, so accuracy, ECE and Brier over the answer set are unaffected; what you lose is the ability to notice that the model wanted to say something that was not on the list.
There is a consolation: computing allowedMass costs two full passes over
151,936 float16 values in JavaScript per criterion (303,872 element decodes,
151,936 Math.exp calls). PROJECTED: at an optimistic 50M of those
branchy half-decode operations per second that is order 5–10 ms per criterion
of pure JavaScript, on top of the model. Pruning removes both the metric and
its cost. Measure it with performance.now() around readSlots before you
decide which you would rather have.
slot_map.json¶
After pruning, the readout indexes rows 0..K-1, not vocabulary ids. Nothing in the weights records which row was which token, so the export writes it:
{
"version": 1,
"model_id": "Qwen/Qwen3-0.6B",
"separator": " ",
"letters": "ABCD",
"rows": 4,
"original_vocab_size": 151936,
"keep": [362, 425, 356, 422],
"token_to_row": {"362": 0, "425": 1, "356": 2, "422": 3},
"can_generate_text": false
}
keep[row] -> token id and token_to_row[token id] -> row are both written so
the consumer does not have to invert anything, and SlotMap.from_dict refuses a
file whose two directions disagree. separator records whether the letters were
probed as " A" or "A", because the browser has to reproduce the same prompt
boundary or it is scoring different tokens. It is null when the export could
not reproduce the supplied ids by probing — treat that as a warning, not a
detail.
The order in keep is the letter order and is never sorted. Sorting it would
permute the answer letters and produce a model that is confidently wrong rather
than one that crashes.
3. Quantisation tiers¶
typedecide.export.quantize(path, mode=...). Sizes are DERIVED from the
596,049,920-parameter total; the accuracy column is deliberately empty because
this project has not measured it.
| Mode | Bits/weight | Qwen3-0.6B weights | Compute | Accuracy cost |
|---|---|---|---|---|
fp32 |
32 | 2,384.2 MB | fp32 | none (it is the reference) |
fp16 |
16 | 1,192.1 MB | fp16 | unmeasured; expected negligible for an argmax over 20 logits |
q8 |
8 | 596.0 MB | fp32 | unmeasured |
q4 |
4 + 16/32 = 4.5 | 335.3 MB | fp32 | unmeasured |
q4f16 |
4.5 | 335.3 MB | fp16 | unmeasured; the tier most likely to cost something |
The 4.5 bits is 4 bits of weight plus one fp16 scale per block of 32. Change
block_size and the arithmetic moves: 4 + 16/block_size.
Do not measure this with perplexity. Perplexity is what quantisation
write-ups report and it is the wrong instrument here. A typed readout never
samples; it compares at most 20 logits and takes an argmax. What matters is
whether the margin between the top two answer letters survives the rounding.
Score the same eval set at each tier with typedecide.evaluation and compare:
balanced_accuracy— did the answers change?order_consistency— this is the one that catches the real failure. A tier that keeps accuracy but drops order consistency has not preserved the model; it has degraded it into a position-guesser that happens to score the same on this fixture.bench/rotation_probe.mjsexists for exactly this reason, and the README's model-comparison table is what it looks like when a model is answering by position.ece/brier— quantisation usually shifts calibration before it shifts argmaxes, so these move first and are the early warning.
Order matters: quantise after pruning. Quantising first spends bits encoding
151,932 rows that are about to be deleted, and on a tied model it forces the
quantiser to pick one representation for a table that the Gather and the
MatMul will no longer share.
4. Keep the KV cache on the GPU¶
DERIVED. Qwen3-0.6B has 28 layers, 8 KV heads, head_dim = 128. Per token,
per layer, K and V at fp16:
For a 256-token evidence prefix that is 29.4 MB of cache. If it lives in a
CPU-side tensor, every score() call uploads all of it and downloads the
present.* outputs, which are the prefix plus the 45-token suffix:
upload 256 x 114,688 = 29,360,128 B = 29.4 MB
download (256 + 45) x 114,688 = 34,521,088 B = 34.5 MB
total = 63.9 MB of round-trip per criterion
That is 9.2 GB over a 144-criterion run, before any arithmetic happens. At a 512-token prefix it is 122.6 MB per criterion.
web/engine.js already does the right thing structurally — it prefills once and
passes this.prefix.kv back in, never mutating or re-sending the text. What it
does not do is pin those tensors to the GPU. In transformers.js that is a
session option:
AutoModelForCausalLM.from_pretrained(repo, {
device: 'webgpu',
dtype: 'q4f16',
session_options: {
preferredOutputLocation: Object.fromEntries(
Array.from({ length: 28 }, (_, i) => [
[`present.${i}.key`, 'gpu-buffer'],
[`present.${i}.value`, 'gpu-buffer'],
]).flat(),
),
},
});
With present.* produced as GPU buffers, the prefill's cache never leaves the
device and is handed back in by handle. PROJECTED: the saving is the copy
time for ~57 MB per criterion, which on a laptop's shared memory bandwidth is
single-digit milliseconds, and considerably more on a discrete GPU over PCIe.
It is not a FLOPs win at all; if a profile shows the time is in the matmuls,
this lever will do nothing. Measure before you bother.
Two things to watch: a GPU-resident tensor must be freed explicitly when the prefix is replaced, or a long session leaks device memory; and the WASM backend ignores the option entirely, so the fallback path is unchanged.
5. Shorten the per-criterion suffix¶
MEASURED (shape). Shared-prefix reuse is exact: prefilling the state once
and reusing that KV for every criterion is bit-identical to scoring each full
prompt fresh, maximum probability drift 0.000e+0 in fp32
(bench/shared_check.mjs, reported in README.md and paper/paper.md §3.2).
This is stronger than the server-side references, which run BF16 and see drift
large enough to flip argmaxes on near-tied decisions.
The consequence is that the only per-criterion cost is the suffix:
"\nCriterion: ...\nOptions:\nA. ...\nAnswer:". Everything before it is paid
once per state. bench/logit_cost.mjs fits time = fixed + perToken x n by
holding the prefilled state constant and varying only the suffix length; it
takes ~45 tokens as a typical criterion. Its committed result
(bench/results/logit-cost-Qwen3-0.6B-q4f16.json, onnxruntime-node on CPU) is
the fit quoted in §1: 28.4 ms fixed + 3.23 ms per suffix token. That is a CPU
figure; run it yourself for your own backend.
PROJECTED. Suffix cost is linear in tokens, so the arithmetic is simple: trimming each option description from 12 tokens to 5 across 4 options saves 28 of ~45 tokens, i.e. ~62% of the per-criterion work. Things that actually pay:
- Option descriptions, not ids. The model reads the description; the id is
never tokenised into the prompt (
prompt.tail_textrenders"A. {description}"). - Dropping restated context from the question. It is already in the shared head.
- Keeping the number of options at what the decision genuinely needs. Each option is a whole line.
What does not pay: shortening INSTRUCTION or the evidence. Those are in the
shared head, prefilled once, and amortised across every criterion for that
state. Shortening them buys you one prefill, not N suffixes.
Do not shorten the suffix past the point where accuracy moves. The right way to
find that point is to measure both, which is what typedecide.evaluation's
per_criterion breakdown is for.
6. What does not work: batched branch evaluation¶
MEASURED, and it is a negative result worth knowing before you design around it. The server-side form of this technique replicates the prefix KV across a batch and evaluates every criterion in one padded forward pass, which makes total time roughly flat in the number of criteria. ONNX Runtime Web refuses:
The fused attention kernel permits a batch wider than one only when no past-key cache is supplied — which is the one case where batching would buy you nothing, because you would be re-prefilling the shared evidence for every criterion. wllama, the other common browser runtime, exposes no batched branch evaluation either, for unrelated reasons.
So criteria are scored serially in the browser, and total time is linear in the
criterion count. What the shared prefill buys is a shorter prompt per
decision, not a single forward pass. Any claim of "one batched pass, N
decisions" belongs to a server. This is recorded in README.md and
paper/paper.md §3.3.
The practical consequence for this module: the levers that matter are the ones that make a single forward pass cheaper — trimming, pruning, quantisation, cache residency, suffix length. There is no batching win to chase, so do not spend the week on one.
7. Measuring, honestly¶
A speed change that moves accuracy is not a speed change. Every lever here should be checked on both axes.
# Accuracy, before and after, on the same fixture and seed.
typedecide evaluate fixtures/authored144.jsonl --model Qwen/Qwen3-0.6B --adapter runs/lora --debias cyclic
MEASURED, for scale. From bench/results/, authored144 (144 rows,
Qwen3-0.6B q4f16), whole-run wall clock:
| Mode | Shots | Mean family balanced accuracy | Order-unstable rows | Seconds |
|---|---|---|---|---|
| readout | 0 | 0.385 | 0 (not checked) | 56.8 |
| readout | 3 | 0.419 | 0 (not checked) | 189.6 |
| generate | 3 | 0.419 | 0 (not checked) | 225.3 |
| readout, cyclic debias | 3 | 0.483 | 87 | 537.3 |
Read these carefully, because they are easy to over-claim:
- Readout and generation reach identical accuracy at 3 shots (0.41936868…, the same value to every digit), with readout 1.19x faster. The readout's case is that it cannot emit a malformed answer, not that it is dramatically quicker.
- Cyclic debiasing took about 2.8x the run time and bought +0.064 balanced accuracy —
and exposed 87 order-unstable rows out of 144 that the single-ordering runs
reported as 0 simply because they never checked. That
order_unstablecolumn is the most informative number in the table and it is invisible without the extra passes. - These runs are the unpruned, untrimmed stock ONNX model. They are the baseline the levers in this document would be measured against. No pruned or trimmed run has been committed, which is why §1 and §2 are labelled DERIVED throughout.
If you land a pruned export, commit its result file next to these and move the labels in this document from DERIVED to MEASURED. Until someone does that, the arithmetic is all this document is entitled to claim.