Serve qwen3_xml, not hermes: tool calling is broken in production #34

Merged
Grok merged 60 commits from serving-throughput into master 2026-09-19 11:16:33 +02:00
Owner

The defect

Production serves Qwen3.8-27B with --tool-call-parser hermes. The model emits
Qwen XML; hermes expects JSON inside <tool_call>. The result is not a
degradation, it is a silent total failure:

  • non-streaming: raw XML leaks into content, tool_calls is null
  • streaming: 2 chunks, 0 content, 0 tool-call deltas — an agent client
    sees an empty response

Verified fixed with qwen3_xml: 8 tool-call deltas with name and arguments
parsed. The chat template did not need changing.

Evidence: bench/runs/toolcall-parser-mismatch-20260918T1100Z.jsonl.

Why it went unnoticed

Nothing in our benchmark suite exercised tool calling. It surfaced only when
this stack moved to infbench, whose stage 3 gates perf behind correctness —
the gate went FAIL → PASS on this one change. Every performance number this
repo recorded before 779ccba was measured against a server whose tool calling
returned nothing.

What changed

  • scripts/vllm-tt-serve.sh — --tool-call-parser "${TOOL_CALL_PARSER:-qwen3_xml}"
  • nix/production-serving-recipe.nix — both sites (serving arm, prefix-caching arm)

This is the half that matters for deployment: the running
tenstorrent-serving unit still bakes hermes into its /nix/store ExecStart,
so nixos-llm must rebuild from this input before the fix reaches production.

Also in this branch

Measured on two cabled p150a cards, every result gated:

  • Prefix caching, gated both ways (dca377d): makespan 303.63 → 80.58 s
    (−73.5%) and last-agent TTFT 277.93 → 14.02 s (−95.0%) at B=8 on
    93,739-token prompts. The crossover is the finding — it loses at B=1
    (+35.0%) and the win grows with batch, because the saving is prefill
    (serialised across the batch) and the cost is decode (paid per stream).
    Ranked on decode ms/token it is a 61.8% regression.
  • A boot trap fixed (b4c10af): the prod-prefix profile could not boot on
    its own defaults — GDN block 16384 against attention block 2048, which vLLM's
    hybrid coordinator always rejects. Every recorded prefix run had passed both
    values by hand, so the defaults were never exercised.
  • A negative result (db6ce1a): decode trace replay and a full-shape KV pool
    do not fit on the card together (card DRAM 99.66% allocated). The +4.5% cost
    of tracing measured at CTX=18432 does not transfer to the target shape.
  • Tracy prefill profiling closed (f967859) on a hardware limit — marker
    volume, not ring size.

What is NOT claimed

Prefix caching is not deployed here. The decode regression remains unattributed
between trace_mode=none and the cache itself, and an unaligned-resume
equivalence test does not yet exist.

🤖 Generated with Claude Code

## The defect Production serves Qwen3.8-27B with `--tool-call-parser hermes`. The model emits Qwen XML; hermes expects JSON inside `<tool_call>`. The result is not a degradation, it is a silent total failure: - **non-streaming**: raw XML leaks into `content`, `tool_calls` is `null` - **streaming**: 2 chunks, 0 content, **0 tool-call deltas** — an agent client sees an empty response Verified fixed with `qwen3_xml`: 8 tool-call deltas with name and arguments parsed. The chat template did **not** need changing. Evidence: `bench/runs/toolcall-parser-mismatch-20260918T1100Z.jsonl`. ## Why it went unnoticed Nothing in our benchmark suite exercised tool calling. It surfaced only when this stack moved to `infbench`, whose stage 3 gates perf behind correctness — the gate went FAIL → PASS on this one change. Every performance number this repo recorded before `779ccba` was measured against a server whose tool calling returned nothing. ## What changed - `scripts/vllm-tt-serve.sh` — `--tool-call-parser "${TOOL_CALL_PARSER:-qwen3_xml}"` - `nix/production-serving-recipe.nix` — both sites (serving arm, prefix-caching arm) **This is the half that matters for deployment**: the running `tenstorrent-serving` unit still bakes `hermes` into its `/nix/store` ExecStart, so nixos-llm must rebuild from this input before the fix reaches production. ## Also in this branch Measured on two cabled p150a cards, every result gated: - **Prefix caching, gated both ways** (`dca377d`): makespan **303.63 → 80.58 s** (−73.5%) and last-agent TTFT **277.93 → 14.02 s** (−95.0%) at B=8 on 93,739-token prompts. The crossover is the finding — it *loses* at B=1 (+35.0%) and the win grows with batch, because the saving is prefill (serialised across the batch) and the cost is decode (paid per stream). Ranked on decode ms/token it is a 61.8% regression. - **A boot trap fixed** (`b4c10af`): the `prod-prefix` profile could not boot on its own defaults — GDN block 16384 against attention block 2048, which vLLM's hybrid coordinator always rejects. Every recorded prefix run had passed both values by hand, so the defaults were never exercised. - **A negative result** (`db6ce1a`): decode trace replay and a full-shape KV pool do not fit on the card together (card DRAM 99.66% allocated). The +4.5% cost of tracing measured at `CTX=18432` does **not** transfer to the target shape. - **Tracy prefill profiling closed** (`f967859`) on a hardware limit — marker volume, not ring size. ## What is NOT claimed Prefix caching is not deployed here. The decode regression remains unattributed between `trace_mode=none` and the cache itself, and an unaligned-resume equivalence test does not yet exist. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Grok added 28 commits 2026-09-18 21:34:14 +02:00
Written for a new session taking this over from a fresh master. It carries
the ranked work list, the two P0 blockers that stop the measured prefix
win from shipping, the safety rules that have each already cost a host or
a card, and the two instrument defects that were paid for here.

It also records two corrections to things this session said out loud: that
chunked prefill is the largest structural lever (it is not a throughput
lever at all for a closed batch), and that the TTFT staircase is a
scheduler property (it is arrival-dependent; the same server returned all
eight first tokens within 7 ms of each other under batched admission).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prefix arm's +112.7% warm-prefill win was a throughput result with two
things unknown: whether a cache-served request still produces the right tokens,
and what the fail-closed host snapshot budget does in production. Both are now
measured on hardware, and one of them is worse than we thought.

Resume equivalence PASSES. 4/4 prompts token-identical cold vs warm, control
discriminating, arrival order pinned. It is not vacuous: every equiv_warm phase
added exactly 6,144 prefix-cache hits where every equiv_cold added 0, so all
four comparisons were genuinely served from the cache. What this does NOT settle
is concurrency -- EQUIV_SEQS=1 deliberately removes the arrival-order confound
that made three of four digests differ on 2026-09-15 -- and with n=4 it bounds
the mismatch rate only under ~53%. It catches a gross regression, not a rare one.

The gate had to be taught to test the shipping configuration. prod-prefix
defaults TT_CFG_TRACE_MODE=none, but both the 49.14 ms decode and the warm
prefill win were measured at decode_only, so the harness as it stood would have
certified a server nobody runs. It now forwards the variable and records the
trace-replay banner as arm evidence.

The snapshot budget reproduces at request 17 of 17 -- the predicted count,
the wrong consequence. The previous note said the 17th request fails mid-run.
It does not fail the request: _budget() raises inside the forward path
(model_runner.py:2359 -> complete() -> _budget()), so the EngineCore dies and
takes every concurrent agent with it, leaving the shared endpoint down until the
service restarts. Sixteen ordinary sequential requests reach that in under a
minute of serving.

A prediction written into the new bench before running it -- that patch 0004's
`end % block_size == 0` filter makes the cadence one checkpoint per 2048-token
boundary, so exhaustion should land at the 8th-9th request -- is falsified and
recorded as such. Prompts ran 3,470-4,924 tokens and the 16 survivors crossed
~26 block boundaries, yet the budget held to the 17th. One snapshot per request
is the model that fits; the boundary filter only selects which step within a
request writes the checkpoint.

Eviction tied to vLLM's LRU remains the owner-sanctioned fix, and raising
QWEN36_PREFIX_MAX_SNAPSHOTS only moves the cliff from 17 to ~56 within the
2-4 GiB the 2026-09-15 ruling allows. Separately worth costing: making
exhaustion reject the request rather than kill the engine, which turns an
endpoint outage into one degraded request and is the right backstop even after
eviction lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I predicted that concurrent B=8 load would reach the fail-closed budget sooner
than the sequential baseline, because in-flight request states are charged
against the same budget as cached ones. Measured: it does not. 16 requests
succeed and the 17th raises, identically to the sequential run. A wave's live
entries retire when its requests finish, so live and cached never accumulate
together; the budget is consumed by CACHED entries at one per distinct request,
and that is concurrency-independent. The prediction is recorded as falsified
rather than dropped -- it is the second one this defect has falsified, after the
block-boundary cadence model.

What concurrency does change is how much a crash costs. Wave 3 was eight
concurrent requests and all eight returned HTTP 500 together, because the
EngineCore died under the first of them. Sequentially one request is lost per
crash; concurrently the whole in-flight batch is.

What this still does not cover is sustained overlapping arrivals, where new
requests begin before earlier ones retire so up to eight live entries coexist
with a nearly-full cache. That is the realistic agent shape and it could still
raise before 17. This bench issues discrete waves that drain before the next
starts, and the record says so rather than implying coverage.

The run also corroborates the cold-prefill regression at batch. Prompt
throughput was ~1,570-1,600 tok/s cold at B=8 on the prefix arm against 3,128
on production, which extends a finding previously measured only at B=1
(1,706-2,517 tok/s) and rules out its being a B=1 artefact. Quoted in the record
as a cross-check, not a benchmark: the bench has no TTFT separation and includes
decode and HTTP.

CONCURRENCY is plumbed through the driver; at its default of 1 the loop reduces
exactly to the sequential baseline this compares against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The budget defect is a bounds mismatch, not a missing mechanism, and writing that
down changes how big the work looks. vLLM's block pool already keeps free blocks
in eviction order and already has _maybe_evict_cached_block; our arm already
patches block_pool.py, scheduler.py and output.py to emit invalidated_block_ids;
and the sidecar already releases snapshots through that hook on every step. What
is missing is pressure: at POOL=262144 the pool holds ~514 cached blocks while
the snapshot budget allows 48, so vLLM never evicts until long after the sidecar
is full.

So the proposal is to bound snapshot-bearing cached blocks in the block pool and
let the existing path do the rest, keeping _budget unchanged as a fail-closed
assertion that should become unreachable.

Two findings that shaped it. Scheduler and worker share one process -- the crash
traceback carries worker.py and model_runner.py frames under EngineCore pid=24 --
so no cross-process protocol is needed. And one request costs exactly one
snapshot because its three GDN group entries slice the same 48 tensors and
_budget counts tensor identities, which is also why the block-boundary cadence
prediction was falsified.

The real design work left is that a snapshot is freed only when all three of its
group blocks go, so strict LRU can evict from three snapshots and free none. That
is called out as the thing to settle before code, not glossed.

Also records why the cheap mitigation was abandoned: prepare() hard-raises on a
missing snapshot, so dropping one behind vLLM relocates the crash instead of
removing it, exactly as the "never silently evict" comment warns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fail-closed snapshot budget killed the EngineCore on the 17th distinct
request. It now evicts instead, and the same bench that reproduced the crash runs
32 requests with the engine healthy throughout and per-request timings
indistinguishable from the baseline (2.77-4.78 s against 2.78-4.74 s), so
eviction costs nothing on the serving path.

Three rules make the eviction correct, and each was a way to get it wrong.
Eviction happens in SNAPSHOT UNITS: one checkpointing step writes one cached
entry per GDN group, all three slicing the same 48 tensors, so evicting by block
takes one entry from each of three units and frees nothing. A unit whose creating
request is still live is never a candidate, because `live` references those same
tensors and dropping the cached entry would cost a future hit while freeing no
memory. And LRU is by last USE, restamped on every resume hit -- creation order
would evict the shared system preamble the cache exists to keep.

The sidecar cannot evict on its own: prepare() hard-raises on a missing snapshot,
so dropping one while vLLM still advertises its block relocates the crash rather
than removing it. The scheduler retires the nominated blocks in update_from_output
-- after the step, before the next scheduling decision, so the next schedule
cannot plan a hit on a block just evicted -- through the kv_cache_manager
evict_blocks() that already existed, which emits the invalidated_block_ids the
sidecar already honours. _budget is unchanged and is now a backstop that should
be unreachable; it still raises when every snapshot is genuinely owned by a live
request, which the offline test pins.

The offline test earns its keep by discriminating: against the unpatched sidecar
it fails at request 5 with max_snapshots=4, the same budget+1 pattern as 17 at
16, so it reproduces the production defect in milliseconds rather than a 4.5
minute boot and the shared endpoint.

WHAT IS NOT YET SHOWN: that the cache still HITS. Every prompt in this bench is
deliberately disjoint, so hits stayed at 0.0 by design and a degenerate fix that
simply stopped caching would pass this run identically. Resume equivalence and an
agents-bifrost warm arm on this tree are required before any of it is believed,
and the eviction banner added here is what will let those runs prove eviction
actually fired rather than inferring it from the absence of a crash.

The worker->scheduler transport is provisional and labelled as such in both
files: the correct home is a ModelRunnerOutput field, but vllm/v1/outputs.py is
not in the prod-prefix mount set and adding it would grow the pinned source
manifest, which is not a change to make unilaterally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resume equivalence passes 4/4 on the eviction tree with the cache demonstrably
working: every warm phase added exactly 6,144 hits and every cold phase and the
control added none, identical to the pre-eviction run, with cold-vs-warm wall
time 7.84 -> 4.71 s. That matters because it rules out the one outcome the
32-request budget run could not distinguish -- a "fix" that simply stopped
caching would have passed that run identically, since every prompt there is
disjoint and hits stayed at zero by design.

But this run was set up to force eviction with max_snapshots=4 and it did not
fire once: the banner appears zero times. The cause is the shape, not the code.
POOL=40960 is 20 blocks and each 8,192-token prompt takes four, so vLLM recycles
blocks between phases, recycled blocks arrive as invalidated_block_ids, and
consume() releases their snapshots naturally -- the sidecar never approached its
budget. The original crash needed POOL=262144 for exactly the complementary
reason: at 514 blocks there is no recycling pressure and nothing is ever
released.

So there is still no direct evidence that eviction has fired on hardware. The
32-request pass is strong circumstantial evidence -- same bench, same pool, same
prompts, 17 before and 32 after, eviction the only change -- but the banner
landed after that run, which makes it an inference rather than a measurement.
Recorded as unproven rather than folded into the pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
We believed prefill throughput fell with context (3,534 tok/s at 16k, 2,366 at
128k, 1,707 at 256k) but had no evidence about WHERE the cost went, because every
number was a whole-prefill average across three separate runs at three shapes.
Three points at n=1 each cannot distinguish "attention over accumulated KV grows"
from "something about long requests is slower" -- host pipelining stalls at
_SYNC_EVERY=8, page-table DMA, KV write bandwidth all predict the same averages.

A two-point fit T(n) = a*n + c*n^2 to our own 128k and 256k records gave a ~= 0.53
s/chunk and c ~= 5.3 ms/chunk^2, and therefore the sharp prediction that the four
quarters INSIDE one 256k prefill take ~22/33/44/53 s. Flat quarters would have
falsified it outright.

Measured, one 254,546-token request on the live production server:
15.89 / 29.65 / 40.50 / 51.19 s over 31 chunks each -- 0.513, 0.956, 1.306, 1.651
s per chunk. Q4/Q1 = 3.22 against a predicted 2.4. Per-chunk cost rises linearly
with chunk position, which is what a quadratic total requires, so the growth is
inside the prefill and scales with the KV already written. That is prefill
attention, and it supports rather than falsifies the structural prior that the 48
GDN layers are context-flat while the 16 attention layers are not.

Refitting within this single prefill moves both coefficients: a ~= 0.32 s/chunk
(not 0.53) and c ~= 6.1 ms/chunk^2 (not 5.3), making the context-growing term ~70%
of a 256k prefill rather than 56%. So the attention lever is LARGER than the
two-point fit claimed and the flat per-chunk lever (matmul program configs, MMRS
grid) correspondingly smaller -- which re-ranks them against each other.

NOT SETTLED: the refitted coefficients predict 43.5 s for a 128k prefill against
55.4 s measured. The two runs differ in batch width and max_model_len, so the
SHAPE of the curve is established and the coefficients are not. n=1, one shape,
one prompt; this says the growth is inside the prefill, and attributes it to no
op. Tracy per-op on chunk 0 vs chunk 120 is the next instrument.

Cost one request against the already-running server: no boot, no config change,
no endpoint outage. The instrument was already in the shipping image
(model.py:2640 logs every max(1, num_full//4) chunks); this only read it.

Two dead ends recorded in the driver so the next agent does not repay them: the
prompt must be max_model_len minus one 2048 block (a full-size prompt is refused),
and it must be sized against the server's own /tokenize -- a 4.2 chars/token guess
against a corpus that measures 3.262 produced a 336k-token request and a bare 400
with no token count anywhere in the log.

Also adds bench/ab/prefill-cold-pyspy.sh, the matched-pair driver for the
cold-prefill regression question. Unrun here; it fails closed unless the server's
own banners agree with the arm label it was given.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Half of the matched pair for the unexplained ~32% cold-prefill regression on the
prefix arm (1,706-2,517 tok/s vs 3,052-3,128 production). The proposed cause is
that the prefix arm never uses the traced chunk path -- patches/tt-metal/0017's
prefill_prefix_slots loops EAGER prefill_masked_bucket per 2048 tokens, where
production runs one execute_trace per chunk -- and the audit measured an eager
64-layer forward at ~437 ms of host dispatch for ~40 ms of device work. If that is
the cause, the prefix arm's samples sit in ttnn dispatch; if the eager path is
already overlapping its dispatch, they sit in a blocking call and the lever dies.

This records the CONTROL only: production, one 9,120-token cold prefill at 3,014
tok/s, 556 samples. 73.9% blocked in a native call, 18.9% ttnn dispatch, 6.5% host
tensor marshalling. The traced path is device-bound, which is what it should be and
what makes it a usable baseline for the other arm.

A RECORD WAS WRITTEN WITH THE WRONG VERDICT FIRST, and this commit carries the
correction rather than hiding it. The original classifier searched the whole stack
string for 'synchronize'/'to_torch'/'.cpu', put 403 of 556 samples in "other", and
concluded "host-dispatch-dominated" -- the exact opposite. Those 403 samples have
leaf frame _prefill_traced_chunked_tp (model.py:2647), which IS
ttnn.synchronize_device(self.device): py-spy attributes time inside a native callee
to the CALLING Python frame, so the name of the blocking function never appears in
the stack at all. Had this gone unnoticed it would have "confirmed" the eager-path
hypothesis on the arm that disproves it.

bench/ab/pyspy-classify.py now buckets on whether the LEAF frame is in ttnn (the
interpreter is constructing and launching ops) or in our own code (the interpreter
is blocked in a native callee), which needs no table of blocking call sites and
cannot be defeated the same way. It refuses a verdict when more than 20% of samples
are unclassified rather than guessing. The raw profile is committed next to the
record so the reclassification is checkable without re-running anything; no new
measurement was taken for the correction.

This is the third instrument in this project unable to see the effect it was built
to measure (text_sha256 identical across every config; decode.stalls structurally
blind to a prefill stall). The rule is now written down in the classifier's header.

The prefix half needs a boot and an endpoint outage, so it is a separate driver
(bench/ab/prefill-cold-pyspy-prefix.sh) that fails closed unless the server's own
banner shows QWEN36_PREFIX_CACHING engaged. Neither record means anything alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ~32% cold-prefill regression on the prefix arm has been unexplained since it was
measured, and it is paid on exactly the metric the owner ranks by. This closes it.

Matched pair, same prompt shape (~8-9k tokens, one cold prefill, max_tokens=1),
classified by bench/ab/pyspy-classify.py:

                          production      prefix arm
  ttnn dispatch                18.9%           61.8%
  host tensor marshalling       6.5%           18.0%
  blocked in native call       73.9%           18.1%
  cold prefill             3,014 tok/s     1,493 tok/s

The profiles are inverted. Production's traced chunk path waits on the device; the
prefix arm spends four fifths of its time in the Python interpreter. The top leaf
flips from _prefill_traced_chunked_tp (model.py:2647, i.e. ttnn.synchronize_device,
403 samples) to ttnn/decorators.py:728 op dispatch (628 samples), with
prefill_masked_bucket (model.py:2334) third at 111 -- the eager bucket loop named
directly. That is patches/tt-metal/0017's prefill_prefix_slots looping EAGER
prefill_masked_bucket(bucket=2048) per 2048 tokens instead of one execute_trace per
chunk, and the audit's ~437 ms of host dispatch per eager 64-layer forward for ~40 ms
of device work is the right order for what is measured here.

TWO THINGS BEYOND WHAT WAS PREDICTED. The gap on this pair is -50.5%, not the ~32%
the agent-shaped runs showed, so the regression is larger at this shape than the
number we have been quoting. And 18.0% of the arm's samples are host tensor
marshalling (to_torch 95, from_torch 92) -- the GDN snapshot/restore round trip that
was flagged as an unmeasured secondary contributor. It is now measured, it is a fifth
of the arm's time, and it is SEPARATE from the dispatch cost, so putting the arm on
the traced chunk path does not by itself recover it.

WHAT THIS LICENSES, AND WHAT IT DOES NOT. It identifies where the time goes; it does
not prove the traced path can be used for a resumed prefill. That still needs the
sequence-start reset skipped, the runner's trace_mode=all guard moved, and the
flex-SDPA q/k-chunk rule asserted never to select 32 at a resume offset (32 silently
corrupts masked SDPA -- CLAUDE.md trap). It also re-opens resume equivalence, which
is 4/4 at B=1 only.

n=1 per arm, two boots 30+ minutes apart rather than interleaved, and different
corpus slices, so the two tok/s figures are same-shape but not same-prompt. The
direction and the size of the split are far outside that noise; the exact percentages
are not. Raw profiles committed next to both records.

Endpoint taken down for the prefix boot under standing owner authorisation and
restored afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both are FAILURES, committed because the guards firing is the evidence that they
work. 182316Z: the prompt was max_model_len exactly, and the driver's own check
refused it before spending a request -- a full-size prompt leaves no room for the
output token. 182347Z: sized at an assumed 4.2 chars/token against a corpus that
measures 3.262, so a 260,096-token request became ~336k and the server answered a
bare 400 with no token count anywhere in its log. The fix in both cases is in the
shipping driver; these files are why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 256k quarter gradient established that prefill cost grows INSIDE a prefill.
It could not distinguish "cost scales with the KV already written" (attention)
from "long requests are slower for some other reason" -- host pipelining stalls at
_SYNC_EVERY=8, page-table DMA, KV write bandwidth. Those predict different things
about a SHORTER prompt: under the attention explanation, chunk 40 costs the same
whether it sits in a 128k prompt or a 256k one; under the others it need not.

Measured, a second request on the same live server, 126,278 tokens:
quarters 4.81 / 10.79 / 13.49 / 16.20 s over 15 chunks each, Q4/Q1 = 3.37 (the
256k run gave 3.22). At matched absolute chunk positions the two runs agree to
~5% -- 0.533 vs 0.513 s/chunk near chunk 15, 1.008 vs 0.956 near chunk 46 -- across
prompts differing twofold in length. Cost is a function of position, not of length.

A joint least-squares fit over all eight quarter-points gives a = 0.358 s/chunk and
c = 6.23 ms/chunk^2, and reproduces BOTH runs' quarter sums within 2% (140.1 vs
137.2 at 256k; 43.9 vs 45.3 at 128k). The context-growing term is 68% of a 256k
prefill and 52% of a 128k one.

THIS RESOLVES THE DISCREPANCY THE LAST COMMIT FLAGGED. The single-run refit
predicted 43.5 s for 128k against a 55.4 s measurement and was recorded as
unsettled. The 43.9 s prediction is right; the 55.4 s figure is a B=8 per-prompt
number from a different boot at a different max_model_len and carries costs this
B=1 chunk-replay window does not. Do not mix the two.

Sizing: halving the growing term takes a 256k prefill 140 -> 92 s and 4x256k
last-agent TTFT 609 -> ~369 s; a 128k prefill 46 -> 34 s.

WHAT IS STILL NOT SHOWN. This attributes the growth to no OP. It is consistent
with prefill attention over the paged bf8 KV -- the chunked_scaled_dot_product_
attention call at attention/tp.py:776 -- and inconsistent with the length-driven
explanations, but naming the op needs Tracy per-op on chunk 0 vs chunk ~120, and
the existing Tracy harness profiles DECODE (it drains inside process_output_decode)
while the traced prefill path reports ops with empty names. That is new instrument
work, not a run.

The first quarter of both runs sits below the fitted line -- the largest residuals
are there -- so the curve is not a clean quadratic at small n and the fit should
not be trusted below about chunk 15. n=1 per length.

Cost: one request, no boot, no outage, on the shipping image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The quarter gradients established that prefill cost grows with a chunk's absolute
position and not with the prompt's length, which is what attention over accumulated
KV does. It named no op. This isolates the prime suspect --
ttnn.transformer.chunked_scaled_dot_product_attention at attention/tp.py:776 -- and
times it directly across chunk position on an idle mesh, at the served
configuration read out of the model rather than retyped: paged bf8 K/V, 12 local
heads / 2 local KV heads / head_dim 256 at TP=2, q/k_chunk 128, exp_approx_mode
False, tp_common.COMPUTE_HIFI2 imported, full BH grid, page table padded to a
multiple of 32 blocks by tp.py's own logic.

Measured, 9 positions from chunk 0 to chunk 120 (245,760 tokens), 7 reps each,
spread under 0.25% everywhere:

  0.575 + 0.5807*n ms per call, max residual 0.047 ms over the whole range.

It is linear to the resolution of the instrument; a free quadratic refit puts the
n^2 term at -1.1e-5, i.e. zero.

THE ARITHMETIC THAT MATTERS. 16 of the 64 layers are attention, so this op
contributes 16 x 0.5807 = 9.29 ms of whole-model growth per chunk index, against
the cost model's 2c = 12.45. Chunked SDPA is therefore 74.6% of prefill's growing
term, and 3.16 ms per chunk index -- a quarter of the growth -- IS SOMETHING ELSE
AND IS NOT YET IDENTIFIED. Reporting that rather than rounding it to "attention is
the growth", because the missing quarter is a quarter of the largest TTFT lever we
have.

Second finding: SDPA is almost purely a GROWING cost, not a flat one. At n=0 it is
0.53 ms, so across 16 layers and 128 chunks it is ~1.1 s against the model's 45.8 s
flat term over a 256k prefill. Whatever the 0.358 s/chunk flat cost is, it is not
attention -- which matters because the flat term is what the matmul/grid work
(patch 0001, still not in the served image) would attack.

WHAT THIS DOES NOT LICENSE. It is a standalone op probe on an idle mesh, the same
class of evidence that predicted -9% and delivered 0 to +2% SLOWER in
docs/PREFILL-AUDIT-2026-09-12.md, because a standalone probe cannot see L1/DRAM
contention with the fused CCL ops. Testing an attribution is safer than proposing
an optimisation, but not as much safer as it is tempting to claim: contention can
only make the in-model op slower, so 74.6% is a lower bound ONLY IF contention is
position-independent. If contention itself grows with position -- plausible, since
more KV traffic competes for the same DRAM -- part of the missing quarter is
attention's too. The defensible claim is "at least about three quarters, plausibly
more", and it cannot be pushed the other way.

The denominator c = 6.23 ms/chunk^2 is a FIT over 8 points with residuals up to
0.13 s/chunk, not a measurement, and a 10% error in c moves the share by 10 points.
Read 74.6% as "about three quarters".

Next: the same ladder on ttnn.experimental.paged_fill_cache, called twice per
attention layer with a page table that grows with n -- the cheapest candidate for
the missing 3.16 ms/chunk-index.

Endpoint taken down under standing owner authorisation and restored; lock released;
no container was force-killed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The quarter gradients showed prefill cost grows with a chunk's absolute position,
and the standalone SDPA ladder attributed about three quarters of that growth to
chunked_scaled_dot_product_attention. The remaining ~3.16 ms per chunk index also
grows with position and is in no named op. This builds the instrument that can name
it: a per-op device-time table for prefill at two chunk positions.

WHAT IT CORRECTS. TRACY-PRODUCTION-PROFILE.md and prod_itemise.py record that a
TRACED arm yields ops with empty names, which is why a prefill per-op table looked
to need the untraced path (a different code path from what production runs). Reading
the image's own sources says otherwise: runtime_id_to_opname_ is a process-global
inline that is never cleared and is populated at op launch including during trace
CAPTURE, a replayed marker carries the same runHostCounter, and profiler.cpp
resolves the op name on the common run-start path with no trace branch. The old
belief most likely came from the RAW profile_log_device.csv, which genuinely has no
op-name column. That is source reading, not a measurement, so the reducer refuses
any table more than 10% unnamed and an untraced arm stands by as the fallback --
and that fallback is now a good control rather than a different path, because trace
capture itself calls _forward_prefill_chunk_tp, so the untraced arm runs the same
function with the same persistent buffers and only host dispatch differs.

A SECOND BLOCKER, NOT PREVIOUSLY KNOWN. The DRAM marker ring is sized in PROGRAMS
(DEFAULT_PROFILER_PROGRAM_SUPPORT_COUNT = 1000) and STALLS when full rather than
wrapping. One 64-layer chunk exceeds it, so chunk 60's markers would never be
written if the preceding chunks went undrained. Draining is therefore separated
from selection: DRAIN_EVERY=1 purges, CHUNKS=0,60 selects. Also
METAL TRACE REPLAY SESSION ID counts replays since process start across all
requests, not the chunk index, so the hook logs chunkidx/chunk_start/replay_total at
every drain and the reducer builds the map from those lines rather than guessing.

*** AND THE FINDING THAT MATTERS MOST, FOUND BY THE DERIVER'S OWN GUARD REFUSING ***
bench/profile/prod-sources.sha256 pins model.py at 266e94d2fd52d02c. The in-tree
patches/prod-local-metal/.../model.py is dcca7cf1f0229b5cc, and the LIVE production
container serves dcca7cf1f0229b5cc -- read back from
/opt/tt-metal-src/... inside vllmtt-prod. In-tree and served agree; THE MANIFEST
PINS A FILE NOTHING RUNS. So a tool that validates against this manifest validates
against a tree we do not serve, which is the exact mistake TRACY-PRODUCTION-PROFILE
exists to prevent and which already invalidated the P0 profile once. This is the
second stale manifest found today; prod-prefix-sources.sha256 is stale too, by three
files, consistent with patch 0004 landing after it was pinned.
NEITHER IS RE-PINNED HERE. The hard rule forbids re-pinning to a derived or
experimental file, and deciding which direction to correct these is an owner call:
re-pin to what is served, or restore the served tree to what is pinned. Until then
the derivation's hash guard must be pointed at the SERVED hash deliberately, and
the hardware run below must not proceed on the assumption that the pinned file is
production.

Offline tests: 24 of 24 pass (14 derivation, 10 reducer), and inertness with the env
vars unset is EXECUTED against a stub ttnn rather than argued -- the derived loop
body makes exactly the production call counts and logs nothing. The 15th case,
applying the derivation to the pinned source, is the one that surfaced the manifest
problem.

Nothing has run on hardware. The estimated first run is 60-100 minutes dominated by
a full JIT recompile with -DPROFILE_KERNEL=1, needs the endpoint down for both the
card lock and the 4 GB memory guard, and defaults to a 128k prompt rather than 256k
because the cost model says chunk 60 costs the same in either. The reducer refuses
to name an op unless the summed device delta accounts for a real share of the
6.23 ms x 60 = 374 ms the cost model predicts; if it does not, the growth is not in
device kernel time at all and the answer is host-side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured today: the prefix arm's cold prefill is 79.8% host-bound (61.8% ttnn
dispatch + 18.0% marshalling) where production is 73.9% device-bound, at 1,493 vs
3,014 tok/s on a matched pair (bench/runs/prefill-cold-pyspy-*). The cause is that
prefill_prefix_slots loops EAGER prefill_masked_bucket per 2048 tokens instead of
replaying the captured chunk trace.

Patch 0017's stated reason for going eager -- "offset-dependent compilation is not
qualified alongside parked decode traces" -- IS FALSE, and this was verified rather
than assumed: the traced chunk compiles nothing per offset, because position is the
runtime device tensor _chunk_start_idx_tensor and the page-table, token and cos/sin
buffers are fixed-width and DMA'd.

0018 adds a module-level, ttnn-free prefill_chunk_plan(start, end, ...) returning
{reset, lead, replays, tail}, threads an absolute-offset `start` through
prefill_traced_chunked and both TP loops, and points prefill_prefix_slots at it. The
prefix arm and production then share one prefill entry point differing only by
`start`. 0006 adds QWEN36_PREFIX_PREFILL_TRACE=1 (default off) so the prefix arm
warms and captures the chunk trace; trace_mode="all" stays REFUSED deliberately,
because `all` also flips the generic enable_trace kwarg on prefill_dispatch, which
is a larger change than the evidence supports.

TWO THINGS THE ORIGINAL SCOPE MISSED, both found by reading rather than assuming.
(1) Resume starts are only ATTENTION-PAGE aligned (64/128), not 2048-aligned:
qwen36_prefix_cache.prepare accepts any resume where cp.end == start, and cp.end is
an earlier request's prompt length. Without an eager "lead" segment up to the next
chunk boundary, a resume at 2112 would replay a chunk at the wrong position. The
plan emits that lead and the test covers it.
(2) warmup_model_prefill returns immediately when enable_trace=False, so THE
DEPLOYED PREFIX ARM WARMS NO PREFILL PROGRAMS AT ALL -- its first prefill compiles
them at request time with the decode trace already parked. That is the
compile-clobbers-trace hazard, live in the arm today and not introduced by this
patch.

k_chunk=32, which silently corrupts masked SDPA, is structurally unreachable rather
than merely unlikely: the flexible branch hardcodes qk_chunk=128 and the smallest
bucket is 128. The test pins both. Fidelity untouched.

Offline test: 12 cases plus a working negative control that is asserted to fail on
all 8 resume cases against the unpatched logic. It extracts the real functions from
the deployed model.py by AST rather than testing a copy, and sets
sys.dont_write_bytecode (a __pycache__ in the source tree breaks the deployment
scp). Verified independently of the author: applies at -p6 to the materialized
prefix source set, passes 12+control patched, exits 1 unpatched.

NOTHING HERE HAS RUN ON HARDWARE. Expected 1,493 -> roughly 2,300-2,700 tok/s cold,
explicitly NOT parity with production's 3,014, because the 18.0% GDN snapshot/restore
marshalling is untouched by this patch and becomes the next bottleneck. Failure
signature is named in advance: under ~1,800 tok/s, or a py-spy re-profile whose top
leaf is still ttnn/decorators.py:728 -- which would mean the trace was not captured
or not replayed, not that the mechanism is wrong.

THIS RE-OPENS RESUME EQUIVALENCE. The standing 4/4 is B=1 on the eager loop and does
not transfer. It gates everything downstream: a speed win here is worthless without
it, and the unaligned-lead path has no equivalence coverage at all.

Underrated risks, recorded so they are not rediscovered: the conv-carry restore
allocates a device tensor at request time and now does so with a PREFILL trace also
parked (most likely hang cause); the lead/tail now use production's _mask_bucket_for
instead of the arm's hardcoded 2048, which is a second changed variable in the same
A/B; and how often a real scheduler produces an unaligned resume has never been
measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
profile flips

Validation of patches tt-metal 0018 + plugin 0006 on hardware. TWO runs are recorded
because the first was a real failure and its cause is worth more than its number.

RUN 1 (192959Z) FAILED, and exactly in the shape the patch author predicted:
prefill 781 tok/s -- WORSE than the 1,493 baseline -- no QWEN36_PREFIX_PREFILL_TRACE
banner, ZERO chunk-replay lines, and the top py-spy leaf was 0018's own eager
fallback _prefill_chunked_eager_tp. Cause: scripts/vllm-tt-serve.sh builds its docker
-e list explicitly and never forwarded QWEN36_PREFIX_PREFILL_TRACE, so the flag never
reached the container and prefill_traced_chunked fell through to its untraced path.
The patch was never switched on. The serve script now forwards it, with the incident
in a comment so the next person does not read an unswitched patch as an inert one.

That failure leaves a REAL DEFECT ON RECORD: 0018's untraced fallback measured 781
tok/s against the 1,493 of the eager loop it replaces. If trace capture ever fails in
production the arm is twice as slow as before the patch. That is not addressed here
and must be before this ships.

RUN 2 (193539Z), flag wired, 8,364-token cold prefill:

                          production   prefix before   prefix traced
  ttnn dispatch                18.9%          61.8%           21.0%
  host tensor marshalling       6.5%          18.0%           16.2%
  blocked in native call       73.9%          18.1%           61.9%
  cold prefill             3,014 tok/s    1,493 tok/s     1,972 tok/s

The banner engaged, FOUR chunk-replay lines appeared for a four-full-chunk prompt --
exactly what prefill_chunk_plan predicts -- and the top leaf flipped from
ttnn/decorators.py:728 to _prefill_traced_chunked_tp, i.e. ttnn.synchronize_device.
The mechanism is confirmed: dispatch fell to production's level and the arm became
device-bound.

+32.1%, which is BELOW the predicted 2,300-2,700 band and above the 1,800 failure
threshold. Do not round this up. The prediction assumed replacing dispatch with
device wait would convert at roughly its share of wall; it converted less.

WHAT THE NUMBER NOW SAYS THAT IT DID NOT BEFORE. Dispatch is no longer the
difference: 21.0% against production's 18.9%. Yet the arm is 1,972 against 3,014.
So the remaining 35% gap is DEVICE time plus the untouched 16.2% marshalling, and
whatever makes the prefix arm's device work slower than production's at the same
shape is a NEW question this patch does not answer. The GDN snapshot/restore round
trip was predicted to survive and did.

n=1 per arm; separate boots; different corpus slices, so same-shape not same-prompt.
The direction and the bucket flip are far outside that noise; the 32.1% is not.

RESUME EQUIVALENCE IS NOT YET RUN and gates everything here. A 32% cold-prefill win
on a resume path that returns different tokens is worth nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate named in 7720ff3 is now met: with QWEN36_PREFIX_PREFILL_TRACE actually
engaged, a prefix resume returns tokens identical to a cold prefill, 4/4, against
a control this harness is able to distinguish
(bench/runs/prefix-resume-equiv-traced-20260917T204500Z.jsonl). Cold-phase
prefill in that run reads 2,364 tok/s apparent against 1,804 on the unpatched
attempt, consistent with the 1,972 vs 1,493 measured separately.

THE FIRST ATTEMPT MEASURED THE UNPATCHED ARM AND WOULD HAVE BEEN QUOTED AS THE
GATE. bench/runs/prefix-resume-equiv-traced-20260917T194141Z.jsonl is kept for
that reason. Its engaged_banners carries only DECODE_LOGITS_RM and
PREFIX_CACHING; the trace banner is absent, so patch 0018 was inert and its
"4/4 token-identical" licenses nothing about the traced path.

The cause is the same defect fixed one layer down in 7720ff3, repeated here:
cache-speed-16k-32k.sh ships its remote work through an EXPLICIT ssh env list
(:126) and an explicit boot env list (:202), neither of which named the flag, so
exporting it in the launcher could not reach the container. Both now forward it,
defaulting to 0. Two explicit env lists in series is two chances to silently run
the control, and the only sound discriminator is the banner -- a chunk-replay
line count read from this harness's log is NOT one, because that log carries the
bench's stdout and not docker logs.

Still open before 0018 ships: its untraced fallback measures 781 tok/s against
the 1,493 of the eager loop it replaces, and an unaligned (page- but not
2048-aligned) resume has no equivalence coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bench/profile/prod-sources.sha256 pins model.py at 266e94d2. The live vllmtt-prod
container mounts /nix/store/rbgsjkbjclc3plazc5l61b569i8mcxnp-model.py, which is
dcca7cf1, and the in-tree copy agrees with the container. The earlier note that
these had diverged did not say WHICH WAY; diffing them settles it. The pin is
stale BACKWARDS: the served file is 266e94d2 plus 63 changed lines, all of them
the cleared device-argmax sampler (__init__ gate, the _forward_decode branch and
its engaged banner) and the QWEN_SDPA_BF8 paged-KV dtype banner. Both are
decode-path, so a prefill per-op table derived from either would be the same
table -- but a profiler that verifies against a tree nothing runs is the P0
error rebuilt, and being right by luck is not a method.

prefill-chunk-profile.sh now takes MANIFEST and EXPECT_SHA overrides, defaulting
to the historical pin so no existing invocation changes. served-sources.sha256 is
a SEPARATE pin carrying the three served hashes. prod-sources.sha256 is untouched:
the hard rule forbids re-pinning it to a derived or experimental file, and while
dcca7cf1 is neither, which of the two trees is the error -- the pin or the deploy
-- is an owner call and not a profiler's to make by editing a manifest.

The derivation reproduces against the served source (derived 89b677cb, 65 lines
added, one env-gated block in the TP chunk-replay loop) and the 24 offline tests
pass, 1 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three hardware attempts, three overflows of the per-RISC DRAM marker ring, and
the axis I chose was wrong. Recorded so nobody pays for it again.

  PROGRAM_SUPPORT  markers/RISC  outcome
  6000              72,000       overflow 0.8 s into the prefill, EngineCore dies
  36000            431,912       overflow, sooner in wall terms
  36000 + SUM      576,000       overflow

One 64-layer 2048-token chunk emits more than 576,000 markers per RISC across the
full Blackhole grid, so there is no plausible ring size for per-chunk TIMELINE
capture, and TT_METAL_PROFILER_SUM did not collapse the volume as hoped. The
per-chunk host drain is already the finest cadence available -- the ring fills
INSIDE one chunk -- and TT_METAL_PROFILER_MID_RUN_DUMP was on for all three.

TWO THINGS I HAD WRONG. An undersized ring was documented here as something that
"STALLS rather than wrapping, so an undersized ring silently truncates". It does
neither: the EngineCore exits with no Python traceback and the request takes an
HTTP 500, i.e. it kills the arm. And PROGRAM_SUPPORT=6000 was not conservative --
at ~12 markers per program it is roughly ONE chunk, so there was never headroom.

The arm's own failure text is misleading in the direction of blaming the code under
test ("the prefill hook never fired ... QWEN36_PROFILER_PREFILL_CHUNKS was unset,
the derived model.py was not mounted"). The hook was live and emitting the markers
that overflowed; that message keys off a drain count a crash makes unreachable.

Also fixed here, from attempt one: prefill-one-shot.sh hardcoded the model id
"qwen3.8-27b-tt" and the path /tokenize. The profile server advertises itself as
Qwen/Qwen3.8-27B-FP8, so sizing 404'd; under `set -uo pipefail` with no -e that
wrote an EMPTY prompt, sent an empty completion, took a 400, and let both arms
finish reporting only "the prefill hook never fired". It now discovers the id from
/v1/models, probes /tokenize and /v1/tokenize, FAILS CLOSED rather than guessing a
chars/token ratio, and uses the discovered id for the completion too. awk replaces
bc, which is not installed on cfx-llm2. With that fixed the gate_sha control arm
passes: 126,227 tokens in 52.1 s = 2,423 tok/s, against 2,366 held at 128k.

NOT a reason to reopen op-count work, and not evidence about any op. The open
question -- which op owns the 3.16 ms/chunk-index (25.4%) of the growing term that
chunked SDPA does not accountation for -- is untouched. The approach that has
actually produced an attribution is the standalone-op ladder
(bench/probes/chunked_sdpa_position_scaling.py, 74.6% to chunked SDPA, linear fit,
max residual 0.047 ms), which needs no profiler and no vLLM boot. Note for whoever
takes it: paged_fill_cache is the WEAKER next candidate than it looks, because
tp.py:722 fills from chunk_page_table, a per-chunk table of 32 blocks that does not
grow with position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The falsifier the investigator asked for, run at the target server shape
(max_model_len=131072, max_num_seqs=8, pool 1,048,576) with BOTH arms booted by
us at the same max_model_len -- which also closes the axis ec61375 flagged
against itself, where prefix ran CTX=18432 against production's 262144 and
prefill had never been swept against that parameter.

B=8, 89,159-token prompts sharing an 86,857-token head, production -> prefix:

  warm-head  makespan        280.40 -> 60.37 s   -78.5%
  warm-head  last-agent TTFT 262.46 -> 13.30 s   -94.9%
  warm-turn2 makespan        280.74 -> 61.84 s   -78.0%

THE WIN GROWS WITH PREFIX LENGTH, which is the mechanism behaving correctly:
-32.2% makespan at an 8k head (ec61375), -78.5% at 87k. Each avoided prefill is
bigger. Warm PP is 6,702 tok/s here against 6,651 at 8k, so the cache rate itself
is length-INDEPENDENT; what grows is how much work it skips.

THREE THINGS MUST BE QUOTED WITH THIS.

(1) The B=8 tg_ms_median is NOT clean decode on either arm. It is
(wall-TTFT)/(tokens-1), and production's batch is frozen during each of eight
serialised prefills, so its 694 ms is mostly interference. The clean comparison
is cold-head at B=1: 56.23 ms production vs 238.41 ms prefix. THE PREFIX ARM
DECODES 4.24x SLOWER with trace_mode=none. That penalty is real and is swamped
at this shape, not absent -- at long output it would come back.

(2) The prompt is ~87k, not 128k. I sized the head from a remembered 3.26
chars/token (the prose corpus's ratio); the harness's own text measures 4.55
against the server's /tokenize. The server is at CTX=131072; the prompt is not.

(3) n=1 per cell, and the prefix arm runs WITHOUT patch 0018/0006.

Also measured: cold prefill on the prefix arm is -9.8% here (2,416 vs 2,679
tok/s), against the -32% recorded at small shapes. The cold regression SHRINKS as
the prompt grows, which makes it less of a shipping obstacle than it looked.

Host RAM high-water mark at 32 snapshots: MemAvailable fell to 527 MB during the
prefix run and returned to 4.93 GB on container stop. No budget exhaustion. That
is a real ceiling, close to the ~4 GB line below which a boot can freeze the host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First run of the standardized harness against our own endpoint, and it failed a
correctness gate our bespoke benches never had. infbench stage 3 aborts at turn
1/20 of the long-horizon agentic check: "empty across every output channel".

Reproduced by hand, directly against vLLM on cfx-llm2:8000, not through Bifrost.

  STREAMING, tools passed, tool_choice=auto:
    2 chunks, 0 content, 0 tool-call deltas. Nothing.
  NON-STREAMING, identical request:
    finish_reason=stop, tool_calls=null, 32 completion tokens, and content =
      <tool_call>
      <function=lookup_record>
      <parameter=record_id>
      R0000
      </parameter>
      </function>
      </tool_call>

The model emits QWEN XML tool-call syntax. Production serves
`--tool-call-parser hermes`, which expects JSON inside <tool_call>. The parser
never matches, so `tool_calls` is never populated: non-streaming leaks the raw
XML into `content`, and streaming returns an empty response.

EVERY AGENT CLIENT STREAMS AND PASSES TOOLS. On this endpoint that returns
nothing. For a deployment whose stated goal is serving concurrent long-context
AGENTS, this is more important than any throughput number measured this week,
and every throughput number we hold was taken without tools in the request, so
none of them exercised it.

NOT established: the correct replacement. The image ships
vllm/tool_parsers/qwen3_engine_tool_parser.py (Qwen3EngineToolParser, from
Qwen3ParserToolAdapter), but its registered CLI name was not read off the
registry -- importing vllm loads the TT platform plugin, which is expensive and
noisy -- so confirm against `vllm serve --help` at deploy time, and check
whether the chat template needs to match as well. Do not assume a flag swap is
sufficient until a streamed tool call comes back parsed.

Unrelated to the older gateway-side stage-3 failure (repeated_unit '!!' 96x at
turn 6/8, engine=bifrost) in the harness's own bench/runs; that one is still
unexplained and went through a path this one did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fix for 81e355e, validated on a live boot at production's exact shape
(prod profile, CTX=262144, SEQS=8, pool 1,048,576) before being written here.

  same request, --tool-call-parser hermes -> qwen3_xml

  non-streaming   tool_calls=null, raw XML in content
               -> tool_calls=[lookup_record {"record_id": "R0000"}], content=null
  streaming       2 chunks, 0 content, 0 tool-call deltas
               -> 8 tool-call deltas, name and arguments parsed
  infbench stage3 FAIL at turn 1/20 "empty across every output channel"
               -> PASS, 11 checks, 212.5 s

vllm/tool_parsers/__init__.py registers qwen3_xml, qwen3_coder and mimo onto the
same Qwen3EngineToolParser; qwen3_xml is the one that names this wire format.
The chat template did NOT need to change -- 81e355e flagged that as an open
question and it is now answered, not assumed.

Changed in BOTH places that serve production: the serving arm and the
prefix-caching arm of nix/production-serving-recipe.nix, which is what nixos-llm
consumes through nixosModules.tenstorrent-host, and scripts/vllm-tt-serve.sh,
where it is now the TOOL_CALL_PARSER default so a bench arm cannot silently
differ from production on this axis.

THE RUNNING SERVICE IS STILL ON hermes until nixos-llm rebuilds from this input;
the unit's ExecStart is a /nix/store path baked at deploy time. Tool calling is
broken on the shared endpoint until that happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every win since 2026-09-12 came from pick-one-lever / predict / measure against
a self-identifying control / record either way, and every wasted day came from
skipping a step. This makes that cycle a prompt: the backlog carries predicted
makespan deltas so a lever without a number is visibly not ready, the closed
lines are listed so a fresh context cannot spend a day re-deriving that grid
tuning is worth 0.48%, and the stop conditions hand back to the owner rather
than letting the loop grind on a lever worth under 3%.

Measurement goes through infbench, not a new bench/ab/ script: a perf number
without a stage-3 verdict is what this project produced for six days before the
tool-call parser defect turned up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MAMBA_BLOCK_SIZE defaulted to 16384 and PREFIX_ATTN_BLOCK to 2048, and vLLM
sets hash_block_size to the lcm across KV groups and then asserts every group
divides it -- so the smaller attention block always failed, in
HybridKVCacheCoordinator, five minutes in and AFTER the weights were loaded and
the model warmed. Nobody noticed for four days because every recorded prefix
run passed both values explicitly; the defaults were dead code that looked
alive. The infbench A/B called the script the way the comments say to call it
and paid for it.

Default the GDN block to the attention block, and refuse unequal values up
front where the cause is legible.

This also retires a standing suspicion rather than just adding a guard: the
2048 block was recorded in prefix-trace-decode-only-20260916T2030Z.jsonl as a
possible reason the hit counter stayed at zero, i.e. as a workaround with an
unknown cost. The 87k A/B measured a 69.6% hit rate at 2048/2048, so 2048 is
simply a working block size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first prefix-vs-production pair in this repo where both arms carry a
passing correctness gate. Every earlier one was a speed number with no verdict
attached -- and stage 3 was in fact FAILING until the qwen3_xml parser fix, so
those numbers were taken on a server whose tool calling returned nothing.

At the target shape the case is no longer arguable: makespan 303.63 -> 80.58 s
and last-agent TTFT 277.93 -> 14.02 s at B=8, 93,739-token prompts, three
repeats, both arms gated.

The crossover is the real finding. Prefix caching LOSES at B=1 (+35.0%
makespan) and the win grows with batch (-51.4% at B=4, -73.5% at B=8), because
the saving is prefill -- serialised across the batch, so it scales with B --
while the cost is decode, paid per stream at every B. Ranked on decode ms/token
this arm is a 61.8% regression and would be rejected; that is exactly the
ranking the goal document forbids.

What this does not settle: the arms are not one tree differing only by
environment. The prefix arm runs trace_mode=none, and decode trace replay alone
is worth 3.74x at B=8, so an unknown share of the decode regression is the
missing trace rather than the cache. Controlling that is the next measurement,
not an argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lever was to split the prefix arm's -61.8% decode delta between
trace_mode=none and the cache itself, predicting TG 243.4 -> ~97 ms/token from
the +4.5% measured at CTX=18432. The hypothesis is untested: with the trace
captured and replay active, the first served request died on
TT_FATAL: Out of Memory, 24 MiB wanted against 13.5 MiB free, card DRAM
99.66% allocated.

So the +4.5% figure does not transfer. Both earlier traced-prefix runs used a
KV pool about 4x smaller; the trace region is 512 MiB the untraced arm never
pays, and at the target shape there is no room for it. That is a constraint on
any shipping configuration, not just on this measurement.

Worth recording separately: infbench caught this at stage 1 in 1.16 s as
"every output channel was empty" and refused to run the perf stages. The same
arm under our own bench/ab/ drivers would have produced a complete sweep from a
server that was dying on every request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pass 5 aborted by its own preflight: the host is out of pinned pages
Some checks failed
tt-stack-ci / Build simulators and check the host module (pull_request) Failing after 11s
tt-stack-ci / Report upstream drift (pull_request) Successful in 10s
ccccaf2e92
MemAvailable was 3.79 GB with zero containers alive and 238 MB of total process
RSS, so roughly 11 GB is pinned and unaccounted for after six vLLM boots today.
The driver refused to boot rather than spend device time on a run the host
cannot hold, which is the guard working.

The remedy is measured and is a reboot: the 2026-09-14 record has rmmod+modprobe
reclaiming 0 MB at module refcount 0 and a reboot reclaiming 4.2 GiB. That is an
owner decision, not a benchmark's, so the loop hands back here rather than
taking the box out on its own judgment.

Recording this as a blocked pass rather than silently retrying: each boot leaks
further, so the window for a two-arm comparison is closing, and that is
information the owner needs before the next pass is scheduled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tool calling works on the production endpoint again
Some checks failed
tt-stack-ci / Build simulators and check the host module (pull_request) Failing after 6s
tt-stack-ci / Report upstream drift (pull_request) Successful in 8s
cfa4b6d97a
Verified at all three layers rather than the convenient one: systemd ExecStart,
the container's actual argv, and a live request that comes back with
get_weather({"city": "Prague"}) parsed and nothing leaking into content. A
systemd-level check alone proves the override was read, not that the server
behaves.

The override is deliberately volatile. The generated ExecStart hardcodes the
parser in /nix/store with no env indirection and /etc/systemd/system is
read-only here, so it lives in /run and dies on reboot. A copy under /etc would
outlive the nixos-llm rebuild and pin a stale container spec, which fails worse
and later. PR #34 is the durable fix; until it deploys, a reboot of cfx-llm2
puts production back to returning empty tool calls.

Also records the reboot that cleared the pinned-page leak: MemAvailable
3.90 -> 6.73 GB, which unblocks the pass-5 preflight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Grok added 28 commits 2026-09-19 09:21:26 +02:00
Predicted before measuring: TG 243.4 -> ~97 ms/token and makespan 80.58 ->
~40.5 s at B=8. Measured 96.6 and 40.49. With both arms at POOL=786432 and both
gated, makespan is 302.62 -> 40.49 s (-86.6%) and last-agent TTFT 276.90 ->
13.86 s (-95.0%), for +3.8% on decode.

This retracts the headline of dca377d. That record framed the crossover -- prefix
caching losing 35% at B=1 and winning only with batch -- as the finding, and
explained it with a mechanism that sounded right: the saving is prefill and
scales with B, the cost is decode and does not. The mechanism was real but the
cost was not the cache. It was trace_mode=none paying 240 ms/token, which at
B=1 has no batch of skipped prefills to absorb it. Traced, the arm wins at every
concurrency, -63.5% at B=1.

It also resolves the caution in db6ce1a: the +4.5% tracing cost measured at
CTX=18432 does transfer (+3.8% here). What did not transfer was the memory
budget.

The second boot paid for itself: production re-measured at the new pool
reproduces the old pool to within 0.3% on every metric, so the pool is not a
confound rather than merely assumed not to be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pass-6 preflight stopped tenstorrent-serving, read MemAvailable at 3.40 GB
and refused to boot. The host was fine. systemctl stop leaves the CONTAINER
running -- the unit goes to "failed" and docker keeps the process -- so the
reading was of a host with a 27B model still resident. Stopping the container
took it to 6.65 GB.

My first diagnosis of this was also wrong: I called it a reclamation-timing
artefact and proposed polling until the value stabilised. It is not timing. The
value is flat across 96 s of polling because nothing is being reclaimed; the
container is alive. The fix is a teardown assertion, not a longer sleep.

This does not invalidate the earlier pinned-page finding, which was taken with
zero containers and 238 MB of total RSS and was confirmed by a reboot
reclaiming 2.8 GB. The two look identical in a log and have different causes,
which is exactly why the guard now records the container count next to every
memory number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One of 32 concurrent requests returned 33 where 391 was required, at
concurrency 16, with all three banners confirming the arm. The same suite
passes 11/11 on the identical arm with argmax off, so the variable is argmax.

The existing argmax clearance does not cover this. It was measured on the
production arm, at fixed width, with prefix caching off, and using an
equivalence hash rather than a concurrent-load probe -- so it says nothing
about argmax running alongside a prefix cache under load.

What passed is as informative as what failed: single-stream determinism, batch
invariance at [1,8,32], and cache equality are all clean. Whatever this is, it
needs concurrency to show, which is why no previous harness in this repo could
have caught it.

n=1, so reproducibility is untested and this is not yet called a defect in the
feature. The next pass runs stage 3 alone, repeated, with argmax on and off.
No perf number exists for this arm; the pipeline aborted before stage 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3 of 3 clean with argmax on, 3 of 3 clean with it off. Tally is now 1 failure in
4 runs with argmax, 0 in 3 without -- about 1 wrong answer in 128 concurrent
requests versus 0 in 96. At those sample sizes that is not a difference, so
argmax is neither cleared nor convicted.

Worth stating because the tempting read is "it passed, move on": three clean
runs cannot show the absence of a ~1-in-128 event. What we have is a rare
concurrency correctness failure somewhere in the prefix-caching configuration
with the argmax variable unresolved.

The consequential possibility is the one nothing has excluded. If this belongs
to prefix caching under concurrency rather than to argmax, it is carried by the
traced no-argmax arm that 23b500a recommends deploying. That is the arm worth
spending repetitions on, sized to detect the rate rather than to re-observe a
pass.

Both arms proved their own identity from banners, failing closed in both
directions -- an argmax-off control that silently had argmax on would have made
the whole comparison meaningless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records where 50 ms actually sits: decode is 56.10 ms at 4k, 71.86 at 16k and
93.1 at 93,739 tokens, so about 37 ms of the current figure is context scaling
and 56 ms is flat. The two remaining sized levers total ~9 ms of the ~47 ms
needed at long context, which makes this a program rather than a pass -- worth
writing down before a pass claims otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Owner set 30 TG tok/s at B=1 on short context with minimal context degradation,
which is docs/GOAL-30TPS.md returning with a degradation clause.

Writing down the budget before any pass claims progress against it: B=1 is
~50.47 ms at 4k, the weight read is 23.34 ms and already near bandwidth, so
33.3 ms leaves ~10 ms for everything else against ~27 ms today. That is a 63%
cut of all non-weight-read cost. It is not excluded -- the weight-read ceiling
is 42.8 tok/s -- but nothing in the current backlog is sized for it.

Also recording a distinction the two bars can blur: B=1 loses only ~6 ms from
4k to 93,739 while B=8 loses ~37 ms. Context degradation is mostly a batch
problem, so a B=1 short-context win may do nothing for 8x128k.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The subtraction I committed in 9403cdc was too generous. Of the 27.6 ms of
non-weight-read cost, ~9.9 ms is matmul arithmetic intercept at M=32, which
does not shrink with fewer bytes. Standalone matmul measures 33.27 ms, which
IS the 33.3 ms budget: the target would need zero attention, zero GDN, zero
CCL and zero host. Honest achievable on two cards is ~44-45 ms, 22-23 tok/s.
30 tok/s needs TP=4 or a quantisation reversal the owner has retired.

Three of my own claims are corrected here rather than quietly dropped.

The "+6 ms from 4k to 93k at B=1" I reported as a slope is config drift: three
boots, two KV dtypes, and an implied 1.2 ms from 16k to 93k against 4.4 ms from
4k to 16k, which is not physical. No clean B=1 context ladder exists. Weight
read is 46% of the B=1 step, not the 37% I quoted -- that was the B=8 step. And
the decode-path GDN snapshot I kept listing as a suspect was fixed by patch
0004 three days ago.

One thing reopens: Tracy is closed for PREFILL only. A decode step has ~1/60 of
a prefill chunk's program count, the dump-trigger fix exists, and a decode
per-op table was never attempted -- so the ~12 ms non-matmul device block at
B=1 is unmeasured, and it is the only place a lever above 2 ms can still hide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read the branch rather than the claim. Thatch's 118.22 tok/s at 4k is committed
throughput "including drafting, verification and commit" -- 12.1 tokens per
97.55 ms block. Their non-speculative baseline is 18.01 tok/s at 4k against our
19.6, on the same quantisation, so there is no base-runtime speedup to take.
The entire 6.5x is the speculative loop.

Which makes this the moment to reopen a line we closed. CLAUDE.md dropped MTP
because "speculation is B=1 by construction" and is "the wrong lever once
aggregate is the goal". That was correct for that objective. The objective
changed yesterday to 30 tok/s at B=1, where B=1 by construction is the point.
And we established hours ago that step-shaving floors at 22-23 tok/s, because
standalone matmul alone is 33.27 ms against a 33.3 ms budget -- so committing
more than one token per step is the only path left that does not need TP=4.

The most valuable thing on the branch is not the headline. We have been calling
GDN state rollback an unsolved research-grade blocker; their commit-only GDN
verification passes an ABBA on hardware with 22 exact native-GDN hash
comparisons around verification, by never publishing a speculative state before
the accept decision. That turns our blocker into an engineering task with a
worked design.

What does not travel: offline tests rather than serving, B=1 only, a pinned
synthetic coding prompt that flatters a lookup drafter, their own T32 committing
fewer tokens per block than T16, and the speculative win falling from 118 tok/s
at 4k to 50 at 64k. Acceptance on our workload is the whole result and is
unmeasured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stop rule ranked everything by makespan at 8x128k and would have halted the
loop on its first pass under a B=1 decode target, since nothing remaining is
worth 3% of makespan. Decode levers now have their own bar set at the fast
bench's actual resolution, 0.5 ms/token, so a lever below it is called
unresolvable rather than ranked.

And a fast tier, because booting at 93k/B=8 for every pass costs an hour per arm
while the levers left are 1-2 ms. Size cheap, promote expensive. The full shape
keeps sole right to quote a makespan or TTFT number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured before building anything, because acceptance is the whole speculation
result and it is workload-bound. A suffix-matching drafter over our Python,
shell, markdown and prose commits 1.16-1.41 tokens per block: nothing to copy
when the continuation is novel, so speculation would buy ~1.2-1.4x and the
verifier would erase it.

Then the same probe on the actual agentic pattern -- a file shown, then
re-emitted with 5% of lines changed -- commits 12.21 tokens per block at 78.6%
acceptance. That reproduces Thatch's 12.1 with no MTP drafter at all, which
suggests their headline is carried by the cheap lookup half on traffic that
repeats its context, not by the model-based half they also ship.

So the 6.5x is a property of the traffic, not of their runtime, and the number
that decides whether to build this is not on their branch or ours: it is what
fraction of our real agent sessions re-emit their context. A single average
over mixed traffic would hide both regimes.

No hardware was used. Verifier cost is not modelled: committing 12 tokens per
block only pays if multi-row verify beats 12 single-token steps, and their
verifier is 66.63 ms of a 97.55 ms cycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Owner directive. Research parallelises well and testing does not -- cfx-llm2
runs exactly one tt-metal process and a subagent cannot see the lock, so every
research prompt says so explicitly. A subagent finding stays a hypothesis until
measured by the orchestrator, and proposals arrive with a predicted number and
a refutation condition or they are sent back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Our own JSONL and probe docstring attributed their 12.1 committed tokens/block
to a lookup+MTP hybrid. Reading their CI, lookup is explicitly disabled whenever
a neural drafter is selected, and their lookup-only runs measure 1.3-1.4
tokens/block -- the same number we measured. Their 24.67 ms drafting cost says
the same thing, since a suffix match is free on host.

So our 12.21 on synthetic edits and their 12.1 are not the same mechanism
arriving at the same place; it is a coincidence of workload. Recording that
before it becomes a load-bearing assumption.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I wrote that committing 12 tokens per block only pays if a multi-row verify
beats 12 single-token steps, 611 ms. That framing was wrong and it set the bar
about ten times too high. The weight read is flat in K: at B=1 the decode
matmul already pads M to a full 32-row tile, so a T-row verify reads identical
bytes for T <= 32. Verify is 56 ms at K=3 and 60 ms at K=7 against a 50.9 ms
step. Break-even is 1.18 committed tokens per block, and 30 tok/s needs 1.81 --
not 12. Thatch's own profile agrees from the other side: all-matmul 32.050 ms
at T8 and 32.060 ms at T16.

That changes what the measured acceptance means. Free-running code at 1.41 per
block reaches 25 tok/s at K=3, which is +28% rather than hopeless; prose at
1.16 with K=15 is a loss. Block size has to be adaptive, which is also why
Thatch's T32 committed fewer tokens than their T16.

And the blocker CLAUDE.md calls research-grade is solved in the tree our own
flake already pins. PR #55548 returns per-token states and commits a chosen
slot in place, so the live state is never written during verify and there is
nothing to roll back; fused in-place decode is untouched. spec_decode.py is 831
lines of traced verify and commit sitting unmeasured at ttm-build/src.

What actually blocks us is mundane: production has no spec code, the spec tree
has no production perf flags, and the one A/B died on a wheel/kernel header
mismatch inside the image -- pins that did not move as a set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coordinated the fan-out. Putting the verifier cost model against the acceptance
numbers gives the bar: 1.18 committed tokens per block to break even at K=7,
1.81 to hit 30 tok/s. Tuned lookup tops out near 1.64 on free-running code, so
it is a genuine +28% and still short; on prose at K=15 it is a loss. MTP
projects 3.7-5.8 and clears it.

So they are complementary rather than competing -- adaptive-width lookup for
edit traffic in front of MTP for free-running text, which is the gate Thatch
already ship and disable only because their trained drafter is strong enough
alone.

I re-ran the one claim cheap enough to check directly: MIN_NGRAM 2 to 1 gives
python 1.407 to 1.637 and shell 1.409 to 1.563, matching the subagent's
prediction exactly on a different implementation and a different tokeniser
path. Their grid earns more trust for that.

The corollary is that my probe's K=15 was Thatch's T16 copied without thought
and wastes about 55% of its verifier rows on our free text. No fixed K is
right: the match-length gate that protects free text throws away a third of the
edit win, so width has to key on match length.

Carrying four risks into the hardware test, the sharpest being that a T-row
logits tensor is the same tile-padded 15.9 MB buffer that used to cost 10.4 ms
per token -- device argmax has to be on the verify path or the speculative win
is spent on a readback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tested researcher 2's proposal myself: draft width keyed on suffix-match length,
eleven tier sets against a flat control.

The proposal is confirmed, but not for the reason it was offered. Adaptivity
buys almost nothing on free-running text -- the flat K=4 control is already at
1.575 committed per block on python and the best tier set reaches 1.612. That
free-text win was the MIN_N 2-to-1 gate, which we had measured before.

What adaptivity actually does is capture the edit-traffic win without paying for
it elsewhere: the same flat control gets 4.73 committed per block on the 5%-edit
fixture and the tiered drafter gets 28.77, six times more, at no free-text cost.
That is the result and it is a strong one.

My pre-registered win condition was a bad proxy and I am not hiding behind it.
It capped rows per block at 3.0, and the best configuration by modelled speed
sits at 4.2-4.9 rows and fails that clause. Rows cost 0.68 ms against a 50.9 ms
step, so the cap was pricing something the cost model prices directly; obeying
it would have shipped a configuration 14% slower on free text.

None of this rescues lookup from the earlier finding. Free text tops out at a
modelled 28.4 tok/s against the 30 target, and prose at 21.6. MTP is still the
arm that has to clear the bar on free-running generation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recording a result that was measured and never written down: eight clean stage-3
reps at full concurrency on the traced prefix arm with argmax off, 256 requests,
zero failures. The rule of three puts the per-request failure rate under 1.17%.

That is the configuration pass 5 recommended for deployment, so a bound on its
failure rate is exactly what a deploy decision needs.

It does not retract the pass-6 event and it does not settle the argmax question.
A clean 256 on the argmax-off arm is equally consistent with the intermittent
being argmax-specific and with it being too rare for 256 requests to see. The
tally is 1 in ~128 with argmax against 0 in ~352 without, which at these sample
sizes is not a difference.

The run stopped at rep 8 of 12 because I killed the runner with pkill and left
its container up, so the managed unit started alongside it. Caught in seconds
and the second container never opened the devices, but the order is container
first, then script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ran lever 1 as a proper two-arm pass, both arms from one tree in one session,
differing only by the argmax flag. I predicted makespan 40.49 to 38.99 s and
decode 96.6 to 91.0 ms/token before measuring.

The prediction was never reached. The argmax arm failed stage 3 and the pipeline
aborted, so no performance number exists for it and none may be quoted.

What makes this pass worth more than the last one is that the failure reproduced
byte for byte: same check, same concurrency, same 1 of 32, and the same wrong
answer '33' where '391' was expected. Random corruption does not land on the
same token twice. The tally is 2 failures in 5 argmax runs against 0 in 13
without, Fisher p about 0.07 -- not conventional significance, and I am not
going to dress it up as one, but the p-value treats the two failures as
exchangeable and they are identical.

The control is clean and reproduces pass 5 within 0.2% on makespan, TTFT and
ITL, so the argmax arm's failure is not drift between boots.

Device argmax was cleared standalone at B=4 and B=8 with prefix caching off, and
has only ever failed in combination with prefix caching. The suspect is the
interaction. One mechanism worth looking for: with argmax the decode step never
returns logits to host, so a snapshot taken around such a step sees different
host-visible state. That is a hypothesis, not a finding.

Lever 1 stops being a performance item and becomes a correctness investigation.
The shipping candidate is untouched -- it is the argmax-off arm measured here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The device-argmax lever cannot be ranked by predicted makespan any more, because
its arm never produces a makespan. Replacing it with the investigation that
would unblock it, and recording the pass-9 control numbers, which reproduce pass
5 within 0.2% and so establish that the two boots did not drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I filled the missing cell of the 2x2: device argmax with prefix caching off. It
failed 6 of 6, and not as the rare 1-in-32 intermittent -- 27 to 32 of 32
requests wrong per run. That looked like a spectacular conviction of device
argmax and I nearly reported it as one.

It is not, because pass 10 moved two variables against the passing pass-5
production arm: argmax, and an explicit TT_CFG_TRACE_MODE=decode_only. I
pre-registered the refutation before running the control: if the control also
fails, the defect belongs to the profile or the trace mode and pass 10 says
nothing about argmax. The control failed 3 of 3 at the same magnitude. So that
is what I am recording -- void, not a finding.

What the pair does establish is separate and worth having. Against pass 5, which
used the same tree, profile and pool and passed, the only difference is the trace
mode: unset, which the serve script documents as the plugin default 'all', versus
forced decode_only, which runs prefill eager. That points at the eager prefill
path -- the same path as backlog lever 2, which was ranked as a throughput item
and would outrank that position if this is correctness. The complication I have
to honour is that the prefix arm also runs decode_only and passes 13 of 13, so
decode_only alone is not sufficient.

Separately, the research subagent found a mechanism and I verified its
load-bearing claim in code myself: _slots_prefilled_since_decode has exactly one
writer, in the shared generator's prefill, and the Qwen tree never calls it --
zero occurrences across the whole qwen36 directory. The device-sampling merge
depends on that set to exclude freshly prefilled slots, so a slot just handed to
a new request can be fed the previous occupant's token. It is gated on device
sampling, which is why argmax-off arms cannot take it. That is a verified code
path, not a measured cause, and testing it is the next pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 2x2 lever is gone -- its control failed, and the snapshot hypothesis it was
built on is dead for an unrelated reason: these prompts are too short for a
checkpoint to fire during decode at all.

What replaces it is one verified code defect worth a one-line fix-as-test, and
one new trap about the trace mode that may reclassify lever 2 from throughput to
correctness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The failing ingredient was not device argmax and not prefix caching. It was that
_slots_prefilled_since_decode has exactly one writer, in the shared generator's
prefill, and no Qwen prefill branch calls it -- zero occurrences across the whole
qwen36 tree. The device-sampling merge relies on that set to exclude freshly
prefilled slots, so on a reset step a slot just handed to a new request could
match against the stale trace input buffer and be fed the previous occupant's
token at its first decode step. The block is gated on device sampling, which is
why every argmax-off arm was clean and why this only ever appeared once argmax
was on.

Mirroring the shared writer at the top of Qwen's prefill_forward gives 11 clean
full-pipeline runs across two boots on the exact configuration that failed
byte-identically twice. At the prior base rate of 2 in 5, that is p about 0.004.

I stopped at three reps first and said plainly that three clean runs happen 21.6%
of the time by luck, then ran eight more rather than bank it. Eleven still does
not show zero: it bounds the per-request rate under 0.85%, and the fix and its
evidence share one tree edit, which a second no-op control would separate.

The first attempt aborted on my own banner check, because the three existing
banners are emitted at boot and mine fires from prefill_forward -- I was looking
for it before any request had been served. The fail-closed rule caught it and
refused to measure an arm it could not prove was live, which is what it is for.

A correctness fix is not a win here until it becomes an end-to-end number, so
the makespan run follows with the -3.7% prediction already on record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the slot-tracking fix in place the gate finally clears, so lever 1 has the
end-to-end number it never produced. Stage 3 passes and conc-8 makespan goes
40.56 to 38.45 s, decode 96.3 to 88.9 ms/token, e2e 53.8 to 56.8 tok/s, all
against a same-session control whose repeats spread 0.5%.

I predicted -3.7% and got -5.2%; I predicted -5.8% on decode and got -7.7%. Both
gaps are well outside the repeat spread, so this is not noise. It is also the
second time an argmax prediction has come in too conservative in the same
direction -- the 2026-09-13 landing did the same and its cause was never
measured either. Two under-predictions on one feature is a pattern, and the
honest reading is that our cost model for the logits readback is incomplete
rather than that we got lucky twice.

TTFT is unmoved at -0.4%, inside the control spread, exactly as predicted. That
is the important negative: argmax is decode-side, so the last agent's wait at
8x128k and 4x256k is still owned entirely by prefill and this lever does nothing
for it.

Two caveats travel with the number. The arm carries both the argmax flag and the
slot fix and this run cannot separate them; the fix should be performance-neutral
but that is an argument, not a measurement. And the win is measured at 93k with
98.3% of the prompt cached, not at the target shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both halves are done -- the slot-tracking defect is confirmed and device argmax
landed at -5.2% makespan. What replaces them is the unglamorous part: the fix
exists only as an edit to a file under /tmp on the host and will vanish with the
box, so it has to become a real patch, and whether argmax plus prefix caching
ships is the owner's call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The -5.2% makespan result rested on an edit to a file under /tmp on the host,
and this session reboots the box freely. That made the measurement one reboot
away from being unreproducible, so durability outranked another performance
lever this pass.

The patch goes on the device-argmax mount set rather than the prefix one. The
bypass lives in Generator.decode_forward's merge and is gated on device
sampling, so it belongs wherever 0014 goes; the prefix mounts map over that list
and inherit it, and prod-argmax needs it too since the defect was never
prefix-specific.

Two things went wrong that I only found by checking. The patch did not apply --
my hand-edits to the generated diff added a line to the logger call without
updating the hunk count, so git apply called it corrupt. And the committed patch
differed from the tree that produced the results: I had rewritten the comments
and converted the banner from %s-style loguru args, which loguru never
interpolated, to an f-string. The logic diffed identical, but the banner is
load-bearing -- every argmax arm fails closed without it -- so an untested
banner form would abort the next pass on a cosmetic change.

One boot settled it: the banner now prints its slot list, stage 3 passes, and
the host tree byte-matches the committed patch. That is 12 of 12 clean across
three boots.

Nothing is rebuilt from the recipe yet and nothing is re-pinned; production still
serves the prod profile, which does not mount this file at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pass 11 failed 3 of 3 with TT_CFG_TRACE_MODE=decode_only forced on the prod
profile. The same boot procedure with that one variable unset passes 3 of 3, in
the same session, same tree, same pool. That closes the n=1 hole I left: the
comparison no longer rests on a cross-session run from pass 5.

Fisher on 3-versus-3 gives p=0.1, which is the best that sample can produce and
understates what happened. The effect is zero failures against total failure --
more failing assertions than there were requests -- in the predicted direction on
a single variable. The p-value treats "some failures" and "everything failed" as
the same event.

Production is not at risk. The serve script documents the default as the
plugin's "all" and its own comment says production is "all" and nothing changes
that, so the managed unit runs the passing configuration. This is a trap for
anyone who forces decode_only on prod, which is what I did in passes 10 and 11.

The arm was defined by an env var being absent, which is exactly the kind of
condition that silently fails to take effect, so the runner reads trace_mode back
from the container log and aborts if it disagrees rather than trusting that I
omitted the line correctly.

What still needs explaining is that the prefix arm runs decode_only and is clean
12 of 12. Decode_only leaves prefill eager, and the prefix arm has its own traced
prefill from 0018, so it keeps a traced prefill where prod falls back to the
eager one. That would explain both arms with a single mechanism and would make
lever 2 a correctness item rather than a throughput one -- but I have not
measured it, and the test is to disable 0018 on the prefix arm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The table had drifted into three rows numbered 1 and a duplicate 1b, because
each pass inserted its successor in place without reconciling the ids -- and my
last edit silently consumed the 1c row it was supposed to update, which is how I
noticed. Closed items now carry letters and only open work is numbered, so the
ranking reads as a ranking.

Also promoted the argmax result into the numbers-to-quote block. The old table
was still the headline even though a later pass moved every cell in it, and the
one line worth reading there is that TTFT did not move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Port Thatch's speculative coordinator and give it the width policy we measured
Some checks failed
tt-stack-ci / Build simulators and check the host module (pull_request) Failing after 9s
tt-stack-ci / Report upstream drift (pull_request) Successful in 10s
fc45704fda
Their harness is worth reusing rather than rewriting. Every rule in its state
machine corresponds to a way speculative decoding goes wrong on this hardware:
one live ticket per request checked by identity, an epoch on every block, a
publish callback that must synchronize before returning, and failures that
poison the request instead of retrying. With 48 GDN layers holding an in-place
recurrence, a retryable commit is a correctness bug waiting to happen.

What we add is the width. Their propose takes a fixed bucket from the caller and
carries match_length on the ticket without ever using it; we key the bucket on
that match length, which is the one thing our own measurements said matters.

Respecting the verifier's power-of-two buckets is new here and it cost almost
nothing: the unconstrained optimum modelled 28.42 tok/s on python against 28.26
for the best bucket-legal policy.

The tests exist to assert one property -- that speculation is output-identical to
plain greedy decoding. Each drives the session with an oracle replaying a fixed
tape and checks the emitted tokens are exactly that tape, at every bucket from 2
to 32 rows. A drafter may change how many verifier rows are spent, never what
comes out.

They caught two bugs I would not have found by reading. I had the budget bound
off by one, so a 4-row block could emit four tokens with three left in the
budget; Thatch had that right and I broke it in the port. And the cost model
charged speculation's drafting overhead to plain decode steps too, which made the
no-speculation denominator read 18.32 tok/s instead of 19.65 and flattered every
speculative arm by about 7%.

Driving the real code over the corpora reproduces the simulator to three decimal
places and repeats the finding that matters: adaptivity is worth nothing on free
text, 28.30 flat against 28.28 tiered, and everything on edit traffic, 123.60 to
281.03. None of this reaches the owner's 30 tok/s bar on free-running text, and
none of it is a throughput result -- there is still no device executor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I re-ranked backlog lever 2 from throughput to correctness on the theory that
decode_only leaves prefill eager while the prefix arm keeps a traced prefill from
0018. The served prefix tree does not have 0018. It is not in the tree, not in
the recipe, and the env flag that would gate it defaults to 0 with nothing
reading it.

So both arms run eager prefill, which makes eager prefill clean in one arm and
catastrophic in the other. The hypothesis cannot survive that, and the re-rank
goes with it. For the 30-token prompts the failing check actually uses, both
trace modes run the same masked-bucket forward anyway.

What decode_only really changes is narrower and better: warmup_model_prefill is
skipped entirely. Phase 1 passes enable_trace=False, which Qwen's override
discards on the first line, and phase 2 is gated on a flag decode_only makes
false. Decode-side warmup is identical between the modes. So prefill programs and
state are first allocated during the first real serving prefill, behind
already-parked decode traces -- which this tree's own docstrings call unsafe, and
which upstream describes as a known hazard.

One detail worth keeping: the failure count exceeds the request count because the
evaluator emits up to three checks per completion. Those requests returned text
that was wrong. Nothing hung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Believed: decode_only corrupts concurrent output because Qwen's
warmup_model_prefill is skipped on BOTH warmup phases, so prefill programs
are first allocated during real serving behind parked decode traces.
Predicted, before measuring: passes 3 of 3.

Measured: 1 of 32 failures per rep, against a control (pass 10, same
profile, pool, trace mode and suite) of 27-43 of 32. The prediction is
refuted -- forcing the warmup does not make decode_only safe -- but the
mechanism is confirmed as the dominant contributor, which a clean pass or a
clean failure would both have hidden.

decode_only stays unshippable; production is unaffected (it runs the
default trace_mode=all, which passes 3 of 3).

Next suspect is the unpartitioned per-bucket decode trace store, testable
on its own with TT_DECODE_BUCKETING=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Our prefill SDPA has been vetoing the streaming kernel all along
Some checks failed
tt-stack-ci / Build simulators and check the host module (pull_request) Failing after 8s
tt-stack-ci / Report upstream drift (pull_request) Successful in 9s
ac5b691ede
Believed: closing the gap to upstream's Blackhole SDPA work needed a pin bump
and a backport of unmerged PRs.

Evidence: sdpa_program_factory.cpp gates the streaming (v2) compute kernel on
`return !fp32_dest_acc_en`, and our COMPUTE_HIFI2 (tp_common.py:22) sets
fp32_dest_acc_en=True. That config reaches both chunked_scaled_dot_product_
attention call sites, so every production prefill chunk has taken the legacy
compute kernel -- the one upstream measures ~24% slower on Blackhole. The
streaming kernel was already in our tree; we were vetoing it by configuration.

Three of the four upstream SDPA PRs turn out not to apply: the full-grid change
is already banked at our call site, the qwen36 GQA multicast is inert on a
causal+chunked path, and the writer-RISC forwarding PR is blocked on an unmerged
base plus a `!is_causal && !is_chunked` gate.

Adds an opt-in SDPA-prefill-only compute config (QWEN36_SDPA_STREAMING=1) with
fp32_dest_acc_en=False, leaving self.compute_cfg untouched for the QKV/wo
matmuls, with a self-identifying banner so the arm cannot run silently.

Side effect worth recording: our LoFi+fp32_dest_acc_en silent-corruption trap
requires that flag, so it lives only on the legacy kernel and this makes it
unreachable. That is not a claim upstream fixed it -- nothing upstream
acknowledges the bug.

Unmeasured. The gate is accuracy, not bit-identity: this changes prefill
numerics by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

Session update 2026-09-19 — what this branch now carries, and the numbers with it

This PR started as the qwen3_xml parser fix and has accumulated the serving-throughput work since. Recording the current measured state so the merge carries its own evidence.

Numbers as merged

  • PP 328.5 tok/s computed (98.3% cached)
  • TG 88.9 ms/token @conc-8 · 71.6 @conc-4 · 53.9 @conc-1
  • conc-8 makespan 38.45 s (device argmax arm), stage 3 PASS

Landed this session

  • Device argmax + prefix arm: conc-8 makespan 40.56 → 38.45 s (−5.2%, predicted −3.7%), TG 96.3 → 88.9 ms, PP 311.4 → 328.5, TTFT unmoved (−0.4%), stage 3 PASS. Second conservative argmax prediction in a row — the logits-readback cost model is incomplete.
  • Patch 0019 (_slots_prefilled_since_decode): root-caused a rare-then-catastrophic bypass where a slot handed to a new request could be fed the previous occupant's token. 12/12 clean across three boots (p≈0.004). Bound, not zero (≤0.85%/request).
  • Patch 0020 (QWEN36_SDPA_STREAMING, this commit): our chunked prefill SDPA has always taken the legacy compute kernel, because sdpa_program_factory.cpp gates streaming on return !fp32_dest_acc_en and COMPUTE_HIFI2 sets it True. Upstream measures that kernel ~24% slower on Blackhole. Opt-in, self-identifying, unmeasured.
  • decode_only refuted twice: forcing the skipped prefill warmup cuts corruption 27–43/32 → 1/32 but does not fix it. Production runs trace_mode=all and is unaffected.

Host RAM (measured, not yet permanent)

hugepagesPerCard 4 → 1 reclaims +5.9 GB of 16 (MemAvailable ~3.1 → 9.0 GB serving). UMD maps exactly 1 page/card (tt_cluster.cpp:353-355); the other 6 GiB were reserved and never touched — the direct cause of the boot OOMs that hard-froze this host twice. Applied at runtime only; a reboot reverts it until the nixos-llm change lands.

Known-not-done

  • Prefix caching remains blocked on the vLLM block-pool spec, and nothing in upstream's last 290 commits moves it.
  • Patch 0020 and the RAM bundle are unmeasured as of this comment.
## Session update 2026-09-19 — what this branch now carries, and the numbers with it This PR started as the `qwen3_xml` parser fix and has accumulated the serving-throughput work since. Recording the current measured state so the merge carries its own evidence. ### Numbers as merged - **PP 328.5 tok/s** computed (98.3% cached) - **TG 88.9 ms/token** @conc-8 · 71.6 @conc-4 · **53.9** @conc-1 - conc-8 makespan **38.45 s** (device argmax arm), stage 3 PASS ### Landed this session - **Device argmax + prefix arm**: conc-8 makespan 40.56 → 38.45 s (−5.2%, predicted −3.7%), TG 96.3 → 88.9 ms, PP 311.4 → 328.5, **TTFT unmoved (−0.4%)**, stage 3 PASS. Second conservative argmax prediction in a row — the logits-readback cost model is incomplete. - **Patch 0019** (`_slots_prefilled_since_decode`): root-caused a rare-then-catastrophic bypass where a slot handed to a new request could be fed the previous occupant's token. 12/12 clean across three boots (p≈0.004). Bound, not zero (≤0.85%/request). - **Patch 0020** (`QWEN36_SDPA_STREAMING`, this commit): our chunked prefill SDPA has always taken the **legacy** compute kernel, because `sdpa_program_factory.cpp` gates streaming on `return !fp32_dest_acc_en` and `COMPUTE_HIFI2` sets it True. Upstream measures that kernel ~24% slower on Blackhole. Opt-in, self-identifying, **unmeasured**. - **`decode_only` refuted twice**: forcing the skipped prefill warmup cuts corruption 27–43/32 → 1/32 but does not fix it. Production runs `trace_mode=all` and is unaffected. ### Host RAM (measured, not yet permanent) `hugepagesPerCard` 4 → 1 reclaims **+5.9 GB of 16** (MemAvailable ~3.1 → 9.0 GB serving). UMD maps exactly 1 page/card (`tt_cluster.cpp:353-355`); the other 6 GiB were reserved and never touched — the direct cause of the boot OOMs that hard-froze this host twice. Applied at runtime only; **a reboot reverts it** until the `nixos-llm` change lands. ### Known-not-done - Prefix caching remains blocked on the vLLM block-pool spec, and nothing in upstream's last 290 commits moves it. - Patch 0020 and the RAM bundle are unmeasured as of this comment.
Grok merged commit abc2e6971b into master 2026-09-19 11:16:33 +02:00
Sign in to join this conversation.
No reviewers
No labels
human-approved
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
bitpartner/tt-stack!34
No description provided.