generator: the async-ahead merge consumes a device buffer that outlives the batch that wrote it #45
Labels
No labels
human-approved
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
bitpartner/tt-stack#45
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The defect
generator.py:1915merges the device's async-ahead token into the host batch onthe strength of a bare position comparison:
The merge block runs only on
reset_batch/mode_switchedsteps, so the devicetoken/position buffer it reads can be many steps — and a whole request generation —
old. When it is, a stale
dev_poscan coincidentally satisfy that comparison andhand a slot a token belonging to a request that has already finished.
Confirmed live, not inferred
bench/runs/merge-stale-CONFIRMED-20260920T1000Z.jsonlSlot 7's stale
28equalshost_pos + 1 = 27 + 1, satisfies the comparison, andsubstitutes a different token.
_slots_prefilled_since_decodecannot guard this. It is cleared on every decodestep (
generator.py:1928) while the buffer outlives whole generations. That is whythe earlier slot-reuse fix (patch 0019) does not cover this case.
A predicted carrier — decode-bucket width changes — was refuted: 0
width_changelines in the audit. The real carrier is simply that the merge runs on a small subset
of steps.
Partial fix landed
patches/tt-metal/0022-async-merge-batch-composition-guard.patch(947872a),opt-in via
QWEN36_MERGE_BATCH_GUARD=1. The buffer must prove it belongs to thecurrent batch: its active-slot pattern (
dev_pos != -1) must match the batch's, orthe merge falls back to host tokens through the branch the shape-mismatch case
already takes and documents as safe.
The signal is chunk-wide by necessity — in the confirmed incident the guilty slot
looked valid on both sides; only the buffer's shape betrayed it. Per-slot filtering
is blind to it.
Measured, full 93,739-token suite, stage 3 PASS (0 failing of 11)
bench/runs/merge-batch-guard-verdict-20260920T0630Z.jsonlEvery delta is inside ±0.5% on single runs from separate boots — flat, not a win,
and must not be quoted as an improvement. The point is that the fix is free.
Guard fired 77×, preventing 15 real token substitutions. Most rejects are
harmless: they only forgo that step's async-ahead benefit.
What is still open
matches the batch passes straight through. Audit
step=202is exactly that:dev_pos=[128852,128852,1206993122,128842,-1,-1,-1,-1]vshost_pos=[128738,128738,128751,128855,-1,-1,-1,-1]— identical active pattern,not rejected. It did 0 harm only because the positions were far enough apart that
use_devwas False anyway; a coincidental match would still substitute.capture and check it at merge. That catches staleness whether or not composition
changed, and subsumes the garbage-value case (see linked issue).
QWEN36_MERGE_BATCH_GUARD=1durable inproduction? It is proven free and strictly better than the unguarded merge, but it
is a partial fix. Currently committed and not deployed — production runs without it.
Possibly related
The baseline 1-of-32 concurrency failure (
03d8884, ~0.48%/request, present in anunmodified control) is an unexplained wrong-token defect. The stale merge is a
plausible cause but this is not established — 0 of 192 in one characterisation
run, so confirming a rate change needs many more reps than have been spent.
Addressed in PR #44 (commit
369b487). Tagged @hermes for review.agy research (2026-09-24)
Findings & Root Causes
generator.pyis not mounted andQWEN36_MERGE_BATCH_GUARDis unset in the running service (PR #44 /369b487was merged to master but never deployed).89f75ceaccidentally deleted Patch 0019 (QWEN36_PREFILLED_SLOTS_FIX) inqwen36_vllm.py:prefill_forward, leaving_slots_prefilled_since_decodeunpopulated on the Qwen path.1206993122=0x47f140e2,1206993233=0x47f14151) are float32 CCL indices from_greedy_argmax_device(model.py:3719). Inactive slots intrace_in[1]overlap buffers marked corruptible (_tt_allow_decode_trace_buffer_reuse = True,qwen36_vllm.py:53) and read as int32.ttnn.plus_oneincrements them on device each resident step (1206993233 - 1206993122 = 111steps).QWEN36_MERGE_BATCH_GUARDonly compares active masks (dev_valid != host_valid). Audit step 202 had matching active masks, so stale buffers from previous batches pass through.Fix Implemented (
agy/issue-45, commit8d7aba6)_slots_prefilled_since_decodeinqwen36_vllm.py:prefill_forward.QWEN36_MERGE_LIFETIME_GUARD(default 0) ingenerator.py:1928-1948: requires device buffer to originate from immediately preceding decode step (last_step == curr_step - 1 and not mode_switched)._decode_forward_trace_text(generator.py:2306-2313), cleared onprefill_forward.mergeLifetimeGuardoption (default false) and Nix assertion inmodules/tenstorrent-serving.nix.tests/test_generator_async_merge.py, 6/6 PASS indspark-cpu): verifies stale substitution (#45), batch guard (#45), garbage bounding (#46), residual audit step 202 (#45), consecutive step acceptance, and prefill invalidation.Confirmation Device Test
bench/ab/sweep-32k.sh/ infbench at conc-16 against max_num_seqs 8 withQWEN36_DEVICE_ARGMAX=1,TT_CFG_SAMPLE_ON_DEVICE=decode_only,QWEN36_MERGE_LIFETIME_GUARD=1,QWEN36_PREFILLED_SLOTS_FIX=1.QWEN36_MERGE_LIFETIME_GUARD engagedbanner, 0/11 concurrency failures, reject counts logged without substitution harm, and makespan within ±0.5% noise margin.