MTP on ThinkingCap-Qwen3.8-27B-FP8 (TP=2): inventory, per-row B=4/8 design, go/no-go, phased gates #63

Open
opened 2026-09-24 11:41:26 +02:00 by Grok · 19 comments
Owner

Tracking issue for ledger entry 0aaaaaaaaaaaaaaaaaa (owner, 2026-09-24): serve bottlecapai/ThinkingCap-Qwen3.8-27B-FP8 (rev e55ba7f6) and get MTP self-speculative decoding working on it, on 2x P150a (TP=2). This reverses 0aaaaa ("MTP IS DROPPED"). Branch: mtp-thinkingcap. Related: #61, #62 (see the end).

Labels used below: MEASURED (cites bench/runs/*), THIRD-PARTY (someone else's number, not reproduced here), ESTIMATE (arithmetic done for this issue; not a result). House rule: extrapolations here have been wrong by +49% and +105%. Nothing labelled ESTIMATE may be used for ranking until the P1 probes land.

1. Inventory: what exists, where it lives, what was measured

The code exists, but only upstream, and only at B=1. tt-metal PR #55548 (atupe-tt) is in our flake pin (flake.nix -> /home/orchestra/ttm-build/src). It is also baked into the vllm-tt:mtp image on cfx-llm2 (ttnn 0.75.0rc10 at 96f3f041, nix/mtp-pr55548/).

Piece Where State
MTP head (mtp.*: fc + 1 full-attention layer + 3 norms; shares embed + lm_head; own paged KV) qwen36/tt/mtp.py (248 lines) Upstream only. TP-sharded.
Draft step (K chained draft steps, EAGLE-style hidden feedback; batched reseed of the MTP KV) tt/spec_decode.py (831 lines) Upstream only.
verify_K (TP=2 multi-position verify forward; traced; T=K+1 rows of ONE sequence) model.verify_traced, attention/tp.py _SPEC_SDPA_L1_FIT (T in {4,8,12}) Upstream only. bench/runs/dspark-verify-hw-20260911T131537Z.jsonl: the head-packed K+1 SDPA reproduces sequential decode on our cards (PCC 0.99999, K in {1,3,7}). MEASURED for correctness only; no timing.
GDN commit across 48 layers gdn/tp.py _verify_fullbatch + commit_verify_slot(idx) Upstream only. Verify calls fused_recurrent_gated_delta_rule(..., output_per_token_state=True) -> [B,T,Nv,Dk,Dv]. The live state is never written speculatively. The commit then copies slot idx into rec_state in place, and is traced once per idx. There is no rollback. The old "research-grade blocker" in DECODE-OPTIMIZATION.md is superseded.
KV handling for the 16 attention layers implicit Rejected positions sit past the frontier. They are never attended, and they get overwritten on the next iteration.
Scalar-commit constraint spec_decode._commit(mi) One scalar mi for all 48 GDN layers. generate() takes one prompt, and p is a scalar. The batch axis is spent on the K+1 candidates. This is B=1 by construction (bench/runs/mtp-batch-structural-20260912T200000Z.jsonl).
Production tree patches/prod-local-metal/, vllm-tt:k2 Zero spec code. patches/tt-metal/0006 drops mtp.* at load. The spec tree has zero production perf flags (CONV_GATES / PACKED_QKV / FUSED_INPLACE / LOGITS_RM / device argmax). The two trees are disjoint (50c6918).
Plugin vllm-tt-plugin platform.py:1473 asserts not speculative_config. model_runner.py assumes a fixed num_out_tokens. There is no proposer and no rejection sampler. lane_scheduler.py already forwards update_draft_token_ids / num_invalid_spec_tokens.
Pinned vLLM 0.26.0 (568afb3a, in vllm-tt:k2) It ships qwen3_5_mtp (model_executor/models/qwen3_5_mtp.py) and method="mtp" in config/speculative.py. Per-row hybrid spec is already modelled: v1/attention/backends/gdn_attn.py carries spec_state_indices_tensor [batch, num_spec] and num_accepted_tokens [batch]. That is exactly the per-row design needed below. The rejection sampler is Triton, which is unusable here (VLLM_TARGET_DEVICE=empty), but greedy acceptance is a host prefix match.

What was measured (all B=1, none on ThinkingCap):

  • MTP acceptance was NEVER measured on any checkpoint. The one attempt (bench/runs/mtp-ab-failure-20260912T193000Z.jsonl) loaded the head (mtp=True, model load 1215 s). It then died in a JIT kernel build (writer_unary_stick_layout_wh_multicore static assert) on a PLAIN arm. The cause, broken image vs a stale shared TT_METAL_CACHE entry, was never settled.
  • State reached on cfx-llm2: /var/lib/models/tt-cache/P300/tensor_cache_bfp8_mesh1x2/mtp/ (2026-09-11) holds the Qwen 27B MTP head converted for TP=2. metal-mtp/ is that image's kernel cache. tt-cache/mtp/ (2026-09-05) is a different, MoE-shaped head (it has se_router), not ours.
  • Draft-token fidelity on one card: TT argmax agrees with an f32 oracle 82.8% top-1 over Gaussian-proxy hidden states, and 127/128 are in the top-5 (bench/runs/mtp-draft-token-20260911T144311Z.jsonl). This is a pessimistic bound, since proxy logits are flat.
  • Verifier cost model (bench/runs/verifier-cost-model-20260919T0500Z.jsonl): V(T) ~= step + 0.68*(T-1) + attn(T) + 3 ms commit. Its inputs are THIRD-PARTY or standalone, not in-model on our tree.
  • DSpark drafter acceptance 0.10/block, NOT_JUSTIFIED (dspark-acceptance-rate-20260911T153249Z.jsonl). That was a different drafter, not MTP.
  • Lookup drafting commits 1.16-1.41 tokens/block on free text and 12.21 on agentic re-emission (lookup-draft-acceptance-20260919T0400Z.jsonl). Workload regime is the dominant term.

New today (MEASURED, no device):

  • bench/runs/thinkingcap-ckpt-contract-index-20260924T0935Z.jsonl: ThinkingCap vs our Qwen snapshot. Base key set is identical (1584 keys). The arch config is identical and the FP8 scheme is identical. The 15 mtp.* weights carry no weight_scale_inv. Header-level dtype/shape checking runs once the download completes.
  • The PR #55548 lazy loader (LazyFP8StateDict) dequantises only float8_e4m3fn tensors and passes bf16 through, so the bf16 head needs no loader change in the spec tree. Verified on the Qwen snapshot by bench/mtp/check_mtp_load.py (host only). The ThinkingCap run is queued behind the download.

2. Design for B=4/8: per-row accepted lengths

The goal is to replace the scalar mi with a per-row m_b. Everything is keyed to one constraint: B·(K+1) <= 32 verify rows. Decode matmuls already pad M to one 32-row tile, so up to 32 rows the weight read and the matmul intercept are flat. Beyond 32, a second tile costs ~8-10 ms (n1-matmul-read-compute-split-20260912T174500Z.jsonl). B=8 => K<=3; B=4 => K<=7.

  1. Verify as B·T virtual rows.
    • Attention (16 layers + the MTP layer): paged SDPA with per-row positions p_b+1..p_b+T. It must be the packed spec SDPA extended with a batch axis, so each sequence's KV is read once, not T times. KV writes go row by row (alias_kv_write exists). Rejected slots need no rollback, because the per-row frontier advances only by m_b+1.
    • GDN (48 layers): the fused recurrent kernel already has a batch axis and emits per-token state [B,T,Nv,Dk,Dv]. The conv window becomes [B, K-1+T, C] instead of [1, ...].
  2. Commit per row, trace-safe. GDN state is ~1.5 MiB/layer/chip/sequence fp32 (72 MiB/chip across 48 layers), and it is context-independent. Three options, in order of preference:
    • (a) Index-gather commit. A persistent device index tensor [B] is refreshed from host outside the trace. One gather per layer selects states[b, m_b] and the conv window rows [m_b, m_b+K). ESTIMATE: ~1-2 ms over 48 layers at B=8, versus 3.2 ms scalar traced commit at B=1 (THIRD-PARTY, upstream note).
    • (b) Pointer commit, zero-copy, as vLLM does. Keep T state slots per row and have the next recurrence read its initial state from slot m_b. This needs a per-row initial-state index in the kernel. It is the best long-term option and upstreamable.
    • (c) Masked recompute. Keep no per-token history. Re-run the recurrence over the T tokens with beta=0, g=0 beyond m_b: S*exp(0)+0 leaves the state bit-unchanged. It costs a second recurrence pass (~0.85+0.68·T ms at B=1, THIRD-PARTY Thatch). It saves the history memory, which is ~2.3 GiB/chip fp32 at B=8,T=4.
  3. Draft: B rows x K sequential MTP steps, one MTP KV per row. A ragged reseed pads to K rows per sequence into the existing scratch block.
  4. Accept: device argmax over all B·T rows (patch 0014 path; a T-row logits readback is the 15.9 MB / 10.4 ms trap). The host computes m_b as the longest matching prefix. The gate is batch-wide greedy-only: any sampled or penalised row sends the whole step to plain decode, the same rule as device argmax.
  5. Plugin/vLLM: use speculative_config={"method":"mtp","num_speculative_tokens":K}. Drop the platform assert. The runner returns per-row variable token lists plus draft_token_ids in ModelRunnerOutput, with drafting done in-model on TT. Rejection is a host greedy prefix match.
  6. Prefix caching: blocks hash committed tokens only. The MTP layer's KV must be a cached layer too (a 17th attention layer in the hybrid cache spec). Otherwise a prefix hit has neither MTP KV nor the base hidden states needed to rebuild it, and acceptance collapses right after every hit. GDN block-boundary snapshots (plugin 0004) must be taken from committed state only.
  7. Memory: at <=32k everything fits. At 8x128k / 4x256k it does not: MTP KV (+1/16 of KV, ~2 GiB) plus fp32 history (~2.3 GiB) exceeds the ~3.6 GiB margin. Spec at the long shapes needs (c) or a bf16 history, and a smaller K.

3. Go / no-go per batch size: ESTIMATE

bench/mtp/go_nogo_estimate.py -> bench/runs/mtp-thinkingcap-go-nogo-estimate-20260924.jsonl. Model: cycle = step(B) + c_row·B·K + tile_penalty + commit(B) + K·d_draft + reseed, tau = (1-a^(K+1))/(1-a), S = tau·step/cycle.

Inputs:

  • step(B): MEASURED on the cleared arm. B=1 48.51 ms. B=4 52.84 ms at 4k and 57.67 ms at 32k. B=8 56.10 ms at 4k and 66.21 ms at 32k.
  • c_row = 0.8 ms (range 0.68-1.2). This is measured B4->B8 slope minus the shared KV read: ESTIMATE.
  • d_draft = 3.0 ms (range 2.5-4.5). This is ~2 ms MTP layer + lm_head weight read plus fixed cost: ESTIMATE.
  • Per-position acceptance a = 0.705. That is THIRD-PARTY accept_len 2.55/4 at k=3 on MMLU-Pro. Range 0.50-0.80.
B ctx best K (rows) verify_cost_ratio decode speedup makespan (<=32k sweep cell)
1 4k 5 (6) 1.08 x2.01 (x1.32-2.69) n/a
4 4k 4 (20) 1.24 x1.79 (x1.21-2.34) -39% (-15 to -51%)
4 32k 4 (20) 1.22 x1.84 (x1.24-2.42) -26% (-11 to -34%)
8 4k 3 (32) 1.34 x1.58 (x1.10-1.92) -29% (-7 to -38%)
8 32k 3 (32) 1.29 x1.68 (x1.15-2.03) -17% (-5 to -22%)

Ranges run from a=0.50 with pessimistic costs to a=0.80 with optimistic costs. The makespan column assumes decode after the last TTFT is the whole decode cost, which puts decode at 46-89% of these cells. That disagrees with the ledger's "prefill 74-79% of wall" and needs reconciling. Either way, prefix caching raises the decode share for agentic traffic.

Verdict (ESTIMATE):

  • B=1: GO.
  • B=4: GO.
  • B=8: CONDITIONAL GO. K is capped at 3 by the tile. It is NO-GO if measured a < 0.5 AND c_row >= 1.2 ms (x1.10, about -5% makespan, not worth the tree work).
  • The dominant unknown is acceptance on ThinkingCap with TT numerics on our agentic prompts. The second is c_row at batch.

Cheapest verify_cost_ratio probes:

  • P1b, zero code, production tree. Run a decode step at B=16 and B=32 at 1k context against B=8 at the same max_model_len: c_row ~= (step(32)-step(8))/24. This is an upper bound: B=32 reads 32 sequences' KV and holds 32 GDN states, where a verify has 8. It is a probe only; B>8 is not a serving target.
  • P1a. Read [SPEC_TIMING] verify= at T=4 and T=8 in the vllm-tt:mtp tree at B=1. This gives the in-model ratio, not the absolute ms.

4. Phases, fail-closed probes and gates

  • P0: inventory, design, estimate (this issue). Checkpoint contract at index level (MEASURED). Harness made checkpoint-agnostic, with guards that refuse a non-Qwen checkpoint on the shared un-keyed tt-cache, a missing shard, or MemAvailable < 4 GB. A lazy-loader overlay avoids the image's ~54 GB eager FP8 load.
  • P0b: header-level contract + host loader check on ThinkingCap (after download). This also measures whether ThinkingCap retrained its MTP head: relative L2 of each mtp.* tensor vs the Qwen head. If it shipped the base head in bf16, acceptance against the fine-tuned base is at risk.
  • P1a: B=1 hardware probe, vllm-tt:mtp tree (bench/mtp/run-thinkingcap-b1.sh). Distinct TT_CACHE_PATH, fresh kernel cache, lock protocol, detached.
    • Arms: plain-fused / spec K=3 / spec K=7 on agentic_edit, code, prose_free, qa_reason. count_control is reported separately.
    • Gates: engagement asserted both ways from the demo's own log lines; greedy spec output token-identical to plain fused-GDN greedy on every prompt (fixture-tested reducer bench/mtp/reduce_accept.py); a smoke on the fresh kernel cache before any arm.
    • Kill criteria:
      • committed/iter at K=3 < 1.5 on BOTH free text and agentic_edit: B=8 NO-GO, B=4 re-costed;
      • verify_cost_ratio(T=4) > 1.35: the cost model is wrong, profile before anything else;
      • JIT failure again on a fresh cache: the image is broken, rebuild with pins moved as a set.
  • P1b: c_row at batch, production tree, zero code. See above. Gate: c_row <= 1.2 ms keeps B=8 K=3; > 1.6 ms means B=8 NO-GO.
  • P2: tree merge. Port mtp/spec_decode/GDN verify+commit into the production tree behind QWEN36_SPEC=0 (default off). The production loader keeps mtp.* only under QWEN36_MTP=1, because 0006 drops it today. Gates: SPEC=0 is output-identical to production on batch-equiv-ref-* at B=4/8 and within 0.5 ms; B=1 spec equivalence re-passes in-tree; a QWEN36_SPEC engaged K=.. B=.. banner is present (arms must self-identify).
  • P3: batched verify + per-row commit (design 2(a)). Gates:
    • fixed-width equivalence: spec greedy == non-spec greedy at B=4 and at B=8. Cross-width identity is not claimed; width alone changes output.
    • per-row acceptance counters;
    • verify ms at (B=8,T=4) and (B=4,T=5/8) measured, and this table recomputed from MEASURED inputs.
  • P4: plugin + vLLM method=mtp, with the MTP layer in the hybrid cache spec for prefix caching. Gates:
    • server-level greedy equivalence at B=4/8;
    • infbench agentic canary + tool-call stage;
    • acceptance on OUR agentic workload: replay captured agent sessions (Bifrost). Report the re-emission fraction separately from free-text acceptance; one average hides a ~10x regime difference.
  • P5: E2E. bench/ab/sweep-32k.sh with a spec arm vs the cleared arm (and vs the prefix-caching arm), one source tree, env-only difference. Rank by makespan/TTLT, never decode ms.

Risks carried

  • ThinkingCap licence (PolyForm Small Business 1.0.0): commercial coverage is an OPEN owner question.
  • ThinkingCap is a reasoning model. Thinking tokens are part of the output stream, and their acceptance may differ from the answer's. It is measured, not assumed.
  • Prefix-cache interaction (MTP KV caching) is unbuilt; see design 6.
  • Long-context memory (design 7) excludes spec at 8x128k / 4x256k as designed.
  • #62 (bfp-quant + weight accuracy gate): its P1 teacher-forced KL / top-1 harness is the right instrument for the TT MTP head's draft fidelity at bf8 (the head is bf16 in ThinkingCap and is converted to bf8 on device). Any MLP/attention bf4 move changes the base argmax that acceptance is defined against, so spec equivalence must be re-run after it.
  • #61: shows byte-level decode savings are worth 4-6 ms/step at most. MTP is the larger decode-side lever, and the two compose: MTP amortises whatever step cost remains.
  • Prior history: #22 (MTP scout), #23 (DSpark acceptance), #18.
Tracking issue for ledger entry **0aaaaaaaaaaaaaaaaaa** (owner, 2026-09-24): serve **bottlecapai/ThinkingCap-Qwen3.8-27B-FP8** (rev `e55ba7f6`) and get **MTP self-speculative decoding** working on it, on 2x P150a (TP=2). This reverses 0aaaaa ("MTP IS DROPPED"). Branch: `mtp-thinkingcap`. Related: #61, #62 (see the end). Labels used below: **MEASURED** (cites `bench/runs/*`), **THIRD-PARTY** (someone else's number, not reproduced here), **ESTIMATE** (arithmetic done for this issue; not a result). House rule: extrapolations here have been wrong by +49% and +105%. Nothing labelled ESTIMATE may be used for ranking until the P1 probes land. ## 1. Inventory: what exists, where it lives, what was measured **The code exists, but only upstream, and only at B=1.** tt-metal PR #55548 (atupe-tt) is in our flake pin (`flake.nix` -> `/home/orchestra/ttm-build/src`). It is also baked into the `vllm-tt:mtp` image on cfx-llm2 (ttnn 0.75.0rc10 at `96f3f041`, `nix/mtp-pr55548/`). | Piece | Where | State | | --- | --- | --- | | MTP head (`mtp.*`: fc + 1 full-attention layer + 3 norms; shares embed + lm_head; own paged KV) | `qwen36/tt/mtp.py` (248 lines) | Upstream only. TP-sharded. | | Draft step (K chained draft steps, EAGLE-style hidden feedback; batched reseed of the MTP KV) | `tt/spec_decode.py` (831 lines) | Upstream only. | | **verify_K** (TP=2 multi-position verify forward; traced; T=K+1 rows of ONE sequence) | `model.verify_traced`, `attention/tp.py` `_SPEC_SDPA_L1_FIT` (T in {4,8,12}) | Upstream only. `bench/runs/dspark-verify-hw-20260911T131537Z.jsonl`: the head-packed K+1 SDPA reproduces sequential decode on our cards (PCC 0.99999, K in {1,3,7}). **MEASURED** for correctness only; no timing. | | **GDN commit** across 48 layers | `gdn/tp.py` `_verify_fullbatch` + `commit_verify_slot(idx)` | Upstream only. Verify calls `fused_recurrent_gated_delta_rule(..., output_per_token_state=True)` -> `[B,T,Nv,Dk,Dv]`. The live state is never written speculatively. The commit then copies slot `idx` into `rec_state` in place, and is traced once per idx. There is no rollback. The old "research-grade blocker" in `DECODE-OPTIMIZATION.md` is superseded. | | KV handling for the 16 attention layers | implicit | Rejected positions sit past the frontier. They are never attended, and they get overwritten on the next iteration. | | Scalar-commit constraint | `spec_decode._commit(mi)` | **One scalar `mi` for all 48 GDN layers.** `generate()` takes one prompt, and `p` is a scalar. The batch axis is spent on the K+1 candidates. This is B=1 by construction (`bench/runs/mtp-batch-structural-20260912T200000Z.jsonl`). | | Production tree | `patches/prod-local-metal/`, `vllm-tt:k2` | **Zero spec code.** `patches/tt-metal/0006` drops `mtp.*` at load. The spec tree has zero production perf flags (CONV_GATES / PACKED_QKV / FUSED_INPLACE / LOGITS_RM / device argmax). The two trees are disjoint (`50c6918`). | | Plugin | vllm-tt-plugin | `platform.py:1473` asserts `not speculative_config`. `model_runner.py` assumes a fixed `num_out_tokens`. There is no proposer and no rejection sampler. `lane_scheduler.py` already forwards `update_draft_token_ids` / `num_invalid_spec_tokens`. | | Pinned vLLM | **0.26.0** (`568afb3a`, in `vllm-tt:k2`) | It ships `qwen3_5_mtp` (`model_executor/models/qwen3_5_mtp.py`) and `method="mtp"` in `config/speculative.py`. **Per-row hybrid spec is already modelled**: `v1/attention/backends/gdn_attn.py` carries `spec_state_indices_tensor [batch, num_spec]` and `num_accepted_tokens [batch]`. That is exactly the per-row design needed below. The rejection sampler is Triton, which is unusable here (`VLLM_TARGET_DEVICE=empty`), but greedy acceptance is a host prefix match. | **What was measured (all B=1, none on ThinkingCap):** - **MTP acceptance was NEVER measured on any checkpoint.** The one attempt (`bench/runs/mtp-ab-failure-20260912T193000Z.jsonl`) loaded the head (`mtp=True`, model load 1215 s). It then died in a JIT kernel build (`writer_unary_stick_layout_wh_multicore` static assert) on a PLAIN arm. The cause, broken image vs a stale shared `TT_METAL_CACHE` entry, was never settled. - **State reached on cfx-llm2:** `/var/lib/models/tt-cache/P300/tensor_cache_bfp8_mesh1x2/mtp/` (2026-09-11) holds the Qwen 27B MTP head converted for TP=2. `metal-mtp/` is that image's kernel cache. `tt-cache/mtp/` (2026-09-05) is a different, MoE-shaped head (it has `se_router`), not ours. - **Draft-token fidelity on one card**: TT argmax agrees with an f32 oracle 82.8% top-1 over Gaussian-proxy hidden states, and 127/128 are in the top-5 (`bench/runs/mtp-draft-token-20260911T144311Z.jsonl`). This is a pessimistic bound, since proxy logits are flat. - **Verifier cost model** (`bench/runs/verifier-cost-model-20260919T0500Z.jsonl`): V(T) ~= step + 0.68*(T-1) + attn(T) + 3 ms commit. Its inputs are **THIRD-PARTY** or standalone, not in-model on our tree. - DSpark drafter acceptance 0.10/block, NOT_JUSTIFIED (`dspark-acceptance-rate-20260911T153249Z.jsonl`). That was a different drafter, not MTP. - Lookup drafting commits 1.16-1.41 tokens/block on free text and 12.21 on agentic re-emission (`lookup-draft-acceptance-20260919T0400Z.jsonl`). Workload regime is the dominant term. **New today (MEASURED, no device):** - `bench/runs/thinkingcap-ckpt-contract-index-20260924T0935Z.jsonl`: ThinkingCap vs our Qwen snapshot. Base key set is identical (1584 keys). The arch config is identical and the FP8 scheme is identical. The 15 `mtp.*` weights carry no `weight_scale_inv`. Header-level dtype/shape checking runs once the download completes. - The PR #55548 lazy loader (`LazyFP8StateDict`) dequantises only `float8_e4m3fn` tensors and passes bf16 through, so the **bf16 head needs no loader change in the spec tree**. Verified on the Qwen snapshot by `bench/mtp/check_mtp_load.py` (host only). The ThinkingCap run is queued behind the download. ## 2. Design for B=4/8: per-row accepted lengths The goal is to replace the scalar `mi` with a per-row `m_b`. Everything is keyed to one constraint: **B·(K+1) <= 32 verify rows**. Decode matmuls already pad M to one 32-row tile, so up to 32 rows the weight read and the matmul intercept are flat. Beyond 32, a second tile costs ~8-10 ms (`n1-matmul-read-compute-split-20260912T174500Z.jsonl`). **B=8 => K<=3; B=4 => K<=7.** 1. **Verify as B·T virtual rows.** - Attention (16 layers + the MTP layer): paged SDPA with per-row positions `p_b+1..p_b+T`. It must be the **packed** spec SDPA extended with a batch axis, so each sequence's KV is read once, not T times. KV writes go row by row (`alias_kv_write` exists). Rejected slots need no rollback, because the per-row frontier advances only by `m_b+1`. - GDN (48 layers): the fused recurrent kernel already has a batch axis and emits per-token state `[B,T,Nv,Dk,Dv]`. The conv window becomes `[B, K-1+T, C]` instead of `[1, ...]`. 2. **Commit per row, trace-safe.** GDN state is ~1.5 MiB/layer/chip/sequence fp32 (72 MiB/chip across 48 layers), and it is context-independent. Three options, in order of preference: - **(a) Index-gather commit.** A persistent device index tensor `[B]` is refreshed from host outside the trace. One gather per layer selects `states[b, m_b]` and the conv window rows `[m_b, m_b+K)`. **ESTIMATE**: ~1-2 ms over 48 layers at B=8, versus 3.2 ms scalar traced commit at B=1 (**THIRD-PARTY**, upstream note). - **(b) Pointer commit, zero-copy, as vLLM does.** Keep T state slots per row and have the next recurrence read its initial state from slot `m_b`. This needs a per-row initial-state index in the kernel. It is the best long-term option and upstreamable. - **(c) Masked recompute.** Keep no per-token history. Re-run the recurrence over the T tokens with `beta=0, g=0` beyond `m_b`: `S*exp(0)+0` leaves the state bit-unchanged. It costs a second recurrence pass (~0.85+0.68·T ms at B=1, **THIRD-PARTY** Thatch). It saves the history memory, which is ~2.3 GiB/chip fp32 at B=8,T=4. 3. **Draft**: B rows x K sequential MTP steps, one MTP KV per row. A ragged reseed pads to K rows per sequence into the existing scratch block. 4. **Accept**: device argmax over all B·T rows (patch 0014 path; a T-row logits readback is the 15.9 MB / 10.4 ms trap). The host computes `m_b` as the longest matching prefix. The gate is **batch-wide greedy-only**: any sampled or penalised row sends the whole step to plain decode, the same rule as device argmax. 5. **Plugin/vLLM**: use `speculative_config={"method":"mtp","num_speculative_tokens":K}`. Drop the platform assert. The runner returns per-row variable token lists plus `draft_token_ids` in `ModelRunnerOutput`, with drafting done in-model on TT. Rejection is a host greedy prefix match. 6. **Prefix caching**: blocks hash committed tokens only. **The MTP layer's KV must be a cached layer too** (a 17th attention layer in the hybrid cache spec). Otherwise a prefix hit has neither MTP KV nor the base hidden states needed to rebuild it, and acceptance collapses right after every hit. GDN block-boundary snapshots (plugin 0004) must be taken from committed state only. 7. **Memory**: at <=32k everything fits. **At 8x128k / 4x256k it does not**: MTP KV (+1/16 of KV, ~2 GiB) plus fp32 history (~2.3 GiB) exceeds the ~3.6 GiB margin. Spec at the long shapes needs (c) or a bf16 history, and a smaller K. ## 3. Go / no-go per batch size: ESTIMATE `bench/mtp/go_nogo_estimate.py` -> `bench/runs/mtp-thinkingcap-go-nogo-estimate-20260924.jsonl`. Model: `cycle = step(B) + c_row·B·K + tile_penalty + commit(B) + K·d_draft + reseed`, `tau = (1-a^(K+1))/(1-a)`, `S = tau·step/cycle`. Inputs: - step(B): **MEASURED** on the cleared arm. B=1 48.51 ms. B=4 52.84 ms at 4k and 57.67 ms at 32k. B=8 56.10 ms at 4k and 66.21 ms at 32k. - c_row = 0.8 ms (range 0.68-1.2). This is measured B4->B8 slope minus the shared KV read: ESTIMATE. - d_draft = 3.0 ms (range 2.5-4.5). This is ~2 ms MTP layer + lm_head weight read plus fixed cost: ESTIMATE. - Per-position acceptance a = 0.705. That is THIRD-PARTY accept_len 2.55/4 at k=3 on MMLU-Pro. Range 0.50-0.80. | B | ctx | best K (rows) | verify_cost_ratio | decode speedup | makespan (<=32k sweep cell) | | --- | --- | --- | --- | --- | --- | | 1 | 4k | 5 (6) | 1.08 | **x2.01** (x1.32-2.69) | n/a | | 4 | 4k | 4 (20) | 1.24 | **x1.79** (x1.21-2.34) | **-39%** (-15 to -51%) | | 4 | 32k | 4 (20) | 1.22 | **x1.84** (x1.24-2.42) | **-26%** (-11 to -34%) | | 8 | 4k | 3 (32) | 1.34 | **x1.58** (x1.10-1.92) | **-29%** (-7 to -38%) | | 8 | 32k | 3 (32) | 1.29 | **x1.68** (x1.15-2.03) | **-17%** (-5 to -22%) | Ranges run from a=0.50 with pessimistic costs to a=0.80 with optimistic costs. The makespan column assumes decode after the last TTFT is the whole decode cost, which puts decode at 46-89% of these cells. That disagrees with the ledger's "prefill 74-79% of wall" and needs reconciling. Either way, prefix caching raises the decode share for agentic traffic. **Verdict (ESTIMATE):** - **B=1: GO.** - **B=4: GO.** - **B=8: CONDITIONAL GO.** K is capped at 3 by the tile. It is NO-GO if measured a < 0.5 AND c_row >= 1.2 ms (x1.10, about -5% makespan, not worth the tree work). - The dominant unknown is acceptance **on ThinkingCap with TT numerics on our agentic prompts**. The second is c_row at batch. **Cheapest verify_cost_ratio probes:** - **P1b, zero code, production tree.** Run a decode step at B=16 and B=32 at 1k context against B=8 at the same `max_model_len`: `c_row ~= (step(32)-step(8))/24`. This is an **upper bound**: B=32 reads 32 sequences' KV and holds 32 GDN states, where a verify has 8. It is a probe only; B>8 is not a serving target. - **P1a.** Read `[SPEC_TIMING] verify=` at T=4 and T=8 in the vllm-tt:mtp tree at B=1. This gives the in-model ratio, not the absolute ms. ## 4. Phases, fail-closed probes and gates - [x] **P0: inventory, design, estimate** (this issue). Checkpoint contract at index level (MEASURED). Harness made checkpoint-agnostic, with guards that refuse a non-Qwen checkpoint on the shared un-keyed `tt-cache`, a missing shard, or MemAvailable < 4 GB. A lazy-loader overlay avoids the image's ~54 GB eager FP8 load. - [ ] **P0b: header-level contract + host loader check on ThinkingCap** (after download). This also measures whether ThinkingCap **retrained** its MTP head: relative L2 of each `mtp.*` tensor vs the Qwen head. If it shipped the base head in bf16, acceptance against the fine-tuned base is at risk. - [ ] **P1a: B=1 hardware probe, vllm-tt:mtp tree** (`bench/mtp/run-thinkingcap-b1.sh`). Distinct `TT_CACHE_PATH`, fresh kernel cache, lock protocol, detached. - Arms: plain-fused / spec K=3 / spec K=7 on agentic_edit, code, prose_free, qa_reason. count_control is reported separately. - **Gates:** engagement asserted both ways from the demo's own log lines; **greedy spec output token-identical to plain fused-GDN greedy on every prompt** (fixture-tested reducer `bench/mtp/reduce_accept.py`); a smoke on the fresh kernel cache before any arm. - **Kill criteria:** - committed/iter at K=3 < 1.5 on BOTH free text and agentic_edit: B=8 NO-GO, B=4 re-costed; - verify_cost_ratio(T=4) > 1.35: the cost model is wrong, profile before anything else; - JIT failure again on a fresh cache: the image is broken, rebuild with pins moved as a set. - [ ] **P1b: c_row at batch, production tree, zero code.** See above. **Gate:** c_row <= 1.2 ms keeps B=8 K=3; > 1.6 ms means B=8 NO-GO. - [ ] **P2: tree merge.** Port mtp/spec_decode/GDN verify+commit into the production tree behind `QWEN36_SPEC=0` (default off). The production loader keeps `mtp.*` only under `QWEN36_MTP=1`, because 0006 drops it today. **Gates:** SPEC=0 is output-identical to production on `batch-equiv-ref-*` at B=4/8 and within 0.5 ms; B=1 spec equivalence re-passes in-tree; a `QWEN36_SPEC engaged K=.. B=..` banner is present (arms must self-identify). - [ ] **P3: batched verify + per-row commit** (design 2(a)). **Gates:** - **fixed-width equivalence: spec greedy == non-spec greedy at B=4 and at B=8**. Cross-width identity is not claimed; width alone changes output. - per-row acceptance counters; - verify ms at (B=8,T=4) and (B=4,T=5/8) measured, and this table recomputed from MEASURED inputs. - [ ] **P4: plugin + vLLM `method=mtp`**, with the MTP layer in the hybrid cache spec for prefix caching. **Gates:** - server-level greedy equivalence at B=4/8; - infbench agentic canary + tool-call stage; - **acceptance on OUR agentic workload**: replay captured agent sessions (Bifrost). Report the re-emission fraction separately from free-text acceptance; one average hides a ~10x regime difference. - [ ] **P5: E2E.** `bench/ab/sweep-32k.sh` with a spec arm vs the cleared arm (and vs the prefix-caching arm), one source tree, env-only difference. Rank by makespan/TTLT, never decode ms. ## Risks carried - ThinkingCap licence (PolyForm Small Business 1.0.0): commercial coverage is an OPEN owner question. - ThinkingCap is a reasoning model. Thinking tokens are part of the output stream, and their acceptance may differ from the answer's. It is measured, not assumed. - Prefix-cache interaction (MTP KV caching) is unbuilt; see design 6. - Long-context memory (design 7) excludes spec at 8x128k / 4x256k as designed. ## Related - **#62** (bfp-quant + weight accuracy gate): its P1 teacher-forced KL / top-1 harness is the right instrument for the TT MTP head's draft fidelity at bf8 (the head is bf16 in ThinkingCap and is converted to bf8 on device). Any MLP/attention bf4 move changes the base argmax that acceptance is defined against, so spec equivalence must be re-run after it. - **#61**: shows byte-level decode savings are worth 4-6 ms/step at most. MTP is the larger decode-side lever, and the two compose: MTP amortises whatever step cost remains. - Prior history: #22 (MTP scout), #23 (DSpark acceptance), #18.
Author
Owner

Progress 1 (2026-09-24): P0b done. ThinkingCap ships the BASE Qwen MTP head, and P1a is on the cards

Checkpoint (MEASURED, no device):

  • bench/runs/thinkingcap-ckpt-contract-header-20260924T1015Z.jsonl: PASS at header level.
    • All 1584 base tensors match our Qwen snapshot in dtype and shape.
    • The 15 mtp.* tensors are all BF16, match Qwen's shapes, and carry no scale. The head has 424.7M params.
  • bench/runs/mtp-thinkingcap-host-load-20260924T1012Z.jsonl: PASS. The spec tree's lazy loader hands every bf16 mtp.* tensor to Qwen36MTP with the right keys, dtypes and shapes. The FP8 base path still dequantises. Wall time was 4 s.

Finding: the MTP head was NOT retrained with the fine-tune. I compared each mtp.* tensor against the Qwen head:

  • Tensors that are bf16 in Qwen (fc, all norms) are bit-identical (rel-L2 0.0).
  • Tensors that are FP8 in Qwen (q/k/v/o, gate/up/down) differ by 0.0265 rel-L2, uniformly. That is FP8-e4m3 rounding, not training.

So ThinkingCap ships the base model's head in its pre-quantisation bf16 form. The head predicts Qwen's next-next token.

Finding: the base fine-tune is light. bench/runs/thinkingcap-base-diff-20260924T1020Z.jsonl byte-compares a sample of base tensors:

  • embed_tokens, lm_head, the final norm and the FP8 block scales are identical.
  • Linear weights differ in every 4 MB chunk, but only in 1-4% of FP8 bytes.

A light fine-tune plus a base head is consistent with the THIRD-PARTY accept_len 2.55/4, which was measured with this same head. Acceptance is still the P1a measurement, not an assumption.

P1a is running (bench/mtp/run-thinkingcap-b1.sh, detached on cfx-llm2, host lock held as mtp-smoke:*):

  • MemAvailable was 9.1 GB at boot.
  • The distinct tensor cache is tt-cache/thinkingcap-e55ba7f6 (stamped with the checkpoint). The fresh kernel cache is metal-mtp-thinkingcap-20260924T094156Z.
  • The cold tensor-cache build is proceeding. With the lazy-loader overlay, layers convert in seconds each. The eager path spent 1215 s last time.
  • Next are the stock-demo smoke (it gates the matrix), then the 20-arm B=1 matrix, then reduce_accept.py.

No speed numbers yet (no PP tok/s or TG ms/token from this work).

## Progress 1 (2026-09-24): P0b done. ThinkingCap ships the BASE Qwen MTP head, and P1a is on the cards **Checkpoint (MEASURED, no device):** - `bench/runs/thinkingcap-ckpt-contract-header-20260924T1015Z.jsonl`: **PASS at header level.** - All 1584 base tensors match our Qwen snapshot in dtype and shape. - The 15 `mtp.*` tensors are all BF16, match Qwen's shapes, and carry no scale. The head has 424.7M params. - `bench/runs/mtp-thinkingcap-host-load-20260924T1012Z.jsonl`: **PASS.** The spec tree's lazy loader hands every bf16 `mtp.*` tensor to `Qwen36MTP` with the right keys, dtypes and shapes. The FP8 base path still dequantises. Wall time was 4 s. **Finding: the MTP head was NOT retrained with the fine-tune.** I compared each `mtp.*` tensor against the Qwen head: - Tensors that are bf16 in Qwen (fc, all norms) are **bit-identical** (rel-L2 0.0). - Tensors that are FP8 in Qwen (q/k/v/o, gate/up/down) differ by **0.0265 rel-L2, uniformly**. That is FP8-e4m3 rounding, not training. So ThinkingCap ships the base model's head in its pre-quantisation bf16 form. The head predicts *Qwen's* next-next token. **Finding: the base fine-tune is light.** `bench/runs/thinkingcap-base-diff-20260924T1020Z.jsonl` byte-compares a sample of base tensors: - `embed_tokens`, `lm_head`, the final norm and the FP8 block scales are identical. - Linear weights differ in every 4 MB chunk, but only in 1-4% of FP8 bytes. A light fine-tune plus a base head is consistent with the THIRD-PARTY accept_len 2.55/4, which was measured with this same head. Acceptance is still the P1a measurement, not an assumption. **P1a is running** (`bench/mtp/run-thinkingcap-b1.sh`, detached on cfx-llm2, host lock held as `mtp-smoke:*`): - MemAvailable was 9.1 GB at boot. - The distinct tensor cache is `tt-cache/thinkingcap-e55ba7f6` (stamped with the checkpoint). The fresh kernel cache is `metal-mtp-thinkingcap-20260924T094156Z`. - The cold tensor-cache build is proceeding. With the lazy-loader overlay, layers convert in seconds each. The eager path spent 1215 s last time. - Next are the stock-demo smoke (it gates the matrix), then the 20-arm B=1 matrix, then `reduce_accept.py`. No speed numbers yet (no PP tok/s or TG ms/token from this work).
Author
Owner

Progress 2 (2026-09-24 12:25 CEST): P1a attempt 1 was stopped externally, so there is no measurement yet

bench/runs/mtp-thinkingcap-b1-p1a-attempt1-20260924T1023Z.jsonl

What worked:

  • Every guard passed.
  • The cold model load with the MTP head on TP=2 completed in 550.6 s. The eager loader took 1215 s last time. The ThinkingCap tensor cache, including mtp/, is fully built at tt-cache/thinkingcap-e55ba7f6.
  • The run reached prefill compile.

What stopped it:

  • At 12:19 a deploy-rs activation started tenstorrent-serving.service. The service's card probe failed because our container held the cards; that unit does not honour /tmp/ttlock.
  • At 12:22 another session ran systemctl stop tenstorrent-serving.service, and our container got SIGTERM. dockerd force-killed it 60 s later (exit 137).

This is not an image verdict. run-chain.sh had mislabelled it as one, and that is fixed in 8b8c9cc. The 2026-09-12 JIT question is still open, because the kernel involved is built at first decode, which this run never reached.

Blocker: card contention with the production serving deploy. I did not retry, to avoid fighting it. Two consequences:

  • A live tt-metal container was force-killed, so the next open may need sudo tt-smi -r.
  • Rerun command, when the cards are allocated to this work:
    cd /var/lib/models/mtp-thinkingcap && setsid nohup bash run-thinkingcap-b1.sh > driver-$(date -u +%Y%m%dT%H%M%SZ).log 2>&1 < /dev/null &
    The tensor cache is warm now. Expect a few minutes of load, then roughly 30-40 min for the smoke plus the 20 arms.

A suggestion for the serving unit: have tt-probe-cards / ExecStartPre wait on /tmp/ttlock rather than failing into start-limit.

Still no speed numbers from this work (no PP tok/s or TG ms/token).

## Progress 2 (2026-09-24 12:25 CEST): P1a attempt 1 was stopped externally, so there is no measurement yet `bench/runs/mtp-thinkingcap-b1-p1a-attempt1-20260924T1023Z.jsonl` What worked: - Every guard passed. - **The cold model load with the MTP head on TP=2 completed in 550.6 s.** The eager loader took 1215 s last time. The ThinkingCap tensor cache, including `mtp/`, is fully built at `tt-cache/thinkingcap-e55ba7f6`. - The run reached prefill compile. What stopped it: - At 12:19 a deploy-rs activation started `tenstorrent-serving.service`. The service's card probe failed because our container held the cards; that unit does not honour `/tmp/ttlock`. - At 12:22 another session ran `systemctl stop tenstorrent-serving.service`, and our container got SIGTERM. dockerd force-killed it 60 s later (exit 137). This is **not** an image verdict. `run-chain.sh` had mislabelled it as one, and that is fixed in `8b8c9cc`. The 2026-09-12 JIT question is still open, because the kernel involved is built at first decode, which this run never reached. **Blocker: card contention with the production serving deploy.** I did not retry, to avoid fighting it. Two consequences: - A live tt-metal container was force-killed, so the next open may need `sudo tt-smi -r`. - Rerun command, when the cards are allocated to this work: `cd /var/lib/models/mtp-thinkingcap && setsid nohup bash run-thinkingcap-b1.sh > driver-$(date -u +%Y%m%dT%H%M%SZ).log 2>&1 < /dev/null &` The tensor cache is warm now. Expect a few minutes of load, then roughly 30-40 min for the smoke plus the 20 arms. A suggestion for the serving unit: have `tt-probe-cards` / `ExecStartPre` wait on `/tmp/ttlock` rather than failing into start-limit. Still no speed numbers from this work (no PP tok/s or TG ms/token).
Author
Owner

P1a attempt 2 (2026-09-24 10:26-10:34Z): result is FAIL_IMAGE_BROKEN, with no numbers

Settled: the vllm-tt:mtp image is broken; the shared kernel cache is not. I ran the stock demo smoke (traced_128, SPEC=0, no custom code) on a fresh kernel cache. It failed in the kernel JIT with the same error as 2026-09-12:
compile_time_args.h:27 static assertion failed: Index out of range while building writer_unary_stick_layout_wh_multicore. The ttnn 0.75 wheel's device headers don't match the kernel sources baked into /opt/tt-metal-src, so the pins did not move as a set.

  • Host state:
    • Device open was fine: no FW-init timeout, no reset needed.
    • MemAvailable was 9012 MB at boot.
    • Warm model load took 232.3 s. The ThinkingCap tensor cache at /var/lib/models/tt-cache/thinkingcap-e55ba7f6 is built.
    • The host-only MTP loader check passed.
  • tenstorrent-serving.service is enabled but failed. A deploy-rs activation could restart it. I noted this and did not touch it.
  • Nothing was measured for either arm: no acceptance, verify_cost_ratio, greedy token-identity, prefill tok/s or decode ms/token.

Incident, now fixed in the harness:

  • What happened:
    • The build-error spam filled the head -c log cap.
    • The resulting SIGPIPE killed the docker client (rc 141) but not the container.
    • The harness then released /tmp/ttlock while our container still held the cards.
    • p150-autoresearch took the lock.
    • I stopped our container gracefully (docker stop -t 150, 10:31:53-10:34:24Z). p150-autoresearch may have started before our container was gone.
  • Fixed in 6e9731a:
    • release() now stops the container before it frees the lock.
    • The log cap is now an awk filter that never closes the pipe.
    • run-chain.sh now takes its image verdict from the log, not the exit code.

Record: bench/runs/mtp-thinkingcap-b1-p1a-attempt2-20260924T1031Z.jsonl

Next: rebuild the MTP image with the ttnn wheel and kernel sources from one tt-metal commit, built locally and shipped. The candidate is the ttm-build flake pin, which already has #55548 merged with main. The alternative is to port spec_decode/mtp onto the ttnn 0.79 production tree. After that, rerun P1a.

## P1a attempt 2 (2026-09-24 10:26-10:34Z): result is FAIL_IMAGE_BROKEN, with no numbers **Settled:** the `vllm-tt:mtp` image is broken; the shared kernel cache is not. I ran the stock demo smoke (traced_128, SPEC=0, no custom code) on a **fresh** kernel cache. It failed in the kernel JIT with the same error as 2026-09-12: `compile_time_args.h:27 static assertion failed: Index out of range` while building `writer_unary_stick_layout_wh_multicore`. The ttnn 0.75 wheel's device headers don't match the kernel sources baked into `/opt/tt-metal-src`, so the pins did not move as a set. - Host state: - Device open was fine: no FW-init timeout, no reset needed. - MemAvailable was 9012 MB at boot. - Warm model load took 232.3 s. The ThinkingCap tensor cache at `/var/lib/models/tt-cache/thinkingcap-e55ba7f6` is built. - The host-only MTP loader check passed. - `tenstorrent-serving.service` is **enabled** but **failed**. A deploy-rs activation could restart it. I noted this and did not touch it. - **Nothing was measured for either arm:** no acceptance, verify_cost_ratio, greedy token-identity, prefill tok/s or decode ms/token. **Incident, now fixed in the harness:** - What happened: - The build-error spam filled the `head -c` log cap. - The resulting SIGPIPE killed the docker client (rc 141) but not the container. - The harness then released `/tmp/ttlock` while our container still held the cards. - `p150-autoresearch` took the lock. - I stopped our container gracefully (`docker stop -t 150`, 10:31:53-10:34:24Z). p150-autoresearch may have started before our container was gone. - Fixed in 6e9731a: - `release()` now stops the container before it frees the lock. - The log cap is now an awk filter that never closes the pipe. - `run-chain.sh` now takes its image verdict from the log, not the exit code. Record: `bench/runs/mtp-thinkingcap-b1-p1a-attempt2-20260924T1031Z.jsonl` **Next:** rebuild the MTP image with the ttnn wheel and kernel sources from one tt-metal commit, built locally and shipped. The candidate is the ttm-build flake pin, which already has #55548 merged with main. The alternative is to port `spec_decode`/`mtp` onto the ttnn 0.79 production tree. After that, rerun P1a.
Author
Owner

Root cause of the broken MTP image found at file level; vllm-tt:mtp2 fix queued

I ran nix/mtp-pr55548/verify_onecommit.py inside the existing vllm-tt:mtp as a negative control (MEASURED, bench/runs/mtp-image-onecommit-negctl-20260924T1044Z.jsonl):

  • The PR #55548 CI wheel is exactly 96f3f041. All 4634 files it shares with the git archive of that commit are byte-identical to the archive.
  • The JIT source tree /opt/tt-metal-src is not 96f3f041. 754 files differ from the wheel and 1256 are missing. writer_unary_stick_layout_wh_multicore.cpp, the kernel that hit the static assert, is among the differing files. The old Dockerfile swapped only the wheel and the qwen36 directory.

Fix: nix/mtp-pr55548/Dockerfile.onecommit builds vllm-tt:mtp2 on top of vllm-tt:mtp.

  • The tree becomes the 96f3f041 archive, overlaid with the wheel's own tt_metal/ and ttnn/cpp/.
  • The build fails closed unless the archive, the wheel and the JIT tree agree.
  • Nothing compiles: it is a copy-only layer, assembled on the box the same way as k2-tracy. The build host has no docker daemon and only 19 GB free, so shipping a 17 GB docker save was not an option.

Status: the driver is running detached on cfx-llm2 and is waiting for /tmp/ttlock, currently held by p150-autoresearch. It will not preempt. Once it has the lock it will, in order:

  1. build vllm-tt:mtp2 and run the host load check, both inside the lock;
  2. release the lock, then run the smoke on a fresh kernel cache;
  3. run the 20-arm B=1 matrix (acceptance, token identity, verify_cost_ratio, prefill and decode speed).

Commit 9acc5c6 pushed to mtp-thinkingcap.

## Root cause of the broken MTP image found at file level; vllm-tt:mtp2 fix queued I ran `nix/mtp-pr55548/verify_onecommit.py` inside the existing `vllm-tt:mtp` as a negative control (MEASURED, `bench/runs/mtp-image-onecommit-negctl-20260924T1044Z.jsonl`): - **The PR #55548 CI wheel is exactly 96f3f041.** All 4634 files it shares with the git archive of that commit are byte-identical to the archive. - **The JIT source tree `/opt/tt-metal-src` is not 96f3f041.** 754 files differ from the wheel and 1256 are missing. `writer_unary_stick_layout_wh_multicore.cpp`, the kernel that hit the static assert, is among the differing files. The old Dockerfile swapped only the wheel and the qwen36 directory. **Fix:** `nix/mtp-pr55548/Dockerfile.onecommit` builds `vllm-tt:mtp2` on top of `vllm-tt:mtp`. - The tree becomes the 96f3f041 archive, overlaid with the wheel's own `tt_metal/` and `ttnn/cpp/`. - The build **fails closed** unless the archive, the wheel and the JIT tree agree. - Nothing compiles: it is a copy-only layer, assembled on the box the same way as k2-tracy. The build host has no docker daemon and only 19 GB free, so shipping a 17 GB `docker save` was not an option. **Status:** the driver is running detached on cfx-llm2 and is waiting for `/tmp/ttlock`, currently held by p150-autoresearch. It will not preempt. Once it has the lock it will, in order: 1. build `vllm-tt:mtp2` and run the host load check, both inside the lock; 2. release the lock, then run the smoke on a fresh kernel cache; 3. run the 20-arm B=1 matrix (acceptance, token identity, verify_cost_ratio, prefill and decode speed). Commit 9acc5c6 pushed to `mtp-thinkingcap`.
Author
Owner

Smoke result on vllm-tt:mtp2: the JIT problem is fixed, but a TP=2 prefill L1 clash is next

  • Image: vllm-tt:mtp2 built in 19 s. It is a copy-only layer, built inside the lock. The one-commit check PASSED: the archive, the wheel and the JIT tree all match 96f3f041 with 0 mismatches. The host load check also passed.
  • Attempt 3: device 0 failed firmware init ("Timeout waiting for physical cores", left behind by the previous lock holder). I ran one tt-smi -r under our own lock, as the protocol says.
  • Attempt 4 (smoke, stock demo, SPEC=0, fresh kernel cache):
    • The static assert is gone. Kernels compile, and all 64 layers load from the ThinkingCap tensor cache.
    • It then fails while capturing the prefill trace: Statically allocated circular buffers in program 100 clash with L1 buffers in the GDN ttnn.conv1d on a [1,2051,1,5120] input. The PR hardcodes CHUNK=2048, which was validated on QB2 (TP=4). At TP=2 each device holds twice the conv channels, so the L1-resident conv overflows.
    • The ttm-build tree already carries the fix, a QWEN36_PREFILL_CHUNK override. I adopted it as an overlay on demo/text_demo.py with default 512.
    • Caveat: prefill tok/s from the next run will understate prefill because of the smaller chunk. Decode and acceptance are unaffected.
  • Still no measurements: no acceptance, verify_cost_ratio, token identity, prefill tok/s or decode ms/token for either arm.
  • Record: bench/runs/mtp-thinkingcap-b1-p1a-attempt3-4-20260924T1055Z.jsonl. Commit 1d42736.

Card slot: the owner gave the next slot to the ThinkingCap baseline agent, which holds the lock now (tc-baseline:…). Our driver is queued behind after-owner.sh. It starts only after tc-baseline: has taken the lock and released it, and it will not preempt.

## Smoke result on vllm-tt:mtp2: the JIT problem is fixed, but a TP=2 prefill L1 clash is next - **Image:** `vllm-tt:mtp2` built in 19 s. It is a copy-only layer, built inside the lock. The one-commit check **PASSED**: the archive, the wheel and the JIT tree all match 96f3f041 with 0 mismatches. The host load check also passed. - **Attempt 3:** device 0 failed firmware init ("Timeout waiting for physical cores", left behind by the previous lock holder). I ran one `tt-smi -r` under our own lock, as the protocol says. - **Attempt 4 (smoke, stock demo, SPEC=0, fresh kernel cache):** - **The static assert is gone.** Kernels compile, and all 64 layers load from the ThinkingCap tensor cache. - It then fails while capturing the prefill trace: `Statically allocated circular buffers in program 100 clash with L1 buffers` in the GDN `ttnn.conv1d` on a `[1,2051,1,5120]` input. The PR hardcodes `CHUNK=2048`, which was validated on QB2 (TP=4). At TP=2 each device holds twice the conv channels, so the L1-resident conv overflows. - The ttm-build tree already carries the fix, a `QWEN36_PREFILL_CHUNK` override. I adopted it as an overlay on `demo/text_demo.py` with default 512. - **Caveat:** prefill tok/s from the next run will understate prefill because of the smaller chunk. Decode and acceptance are unaffected. - **Still no measurements:** no acceptance, verify_cost_ratio, token identity, prefill tok/s or decode ms/token for either arm. - Record: `bench/runs/mtp-thinkingcap-b1-p1a-attempt3-4-20260924T1055Z.jsonl`. Commit 1d42736. **Card slot:** the owner gave the next slot to the ThinkingCap baseline agent, which holds the lock now (`tc-baseline:…`). Our driver is queued behind `after-owner.sh`. It starts only after `tc-baseline:` has taken the lock and released it, and it will not preempt.
Author
Owner

No-MTP baseline for ThinkingCap and base Qwen on the production arm is in #65 (branch thinkingcap-baseline). Serving speed is the same for both checkpoints: TG 48-62 ms/token, prefill 2.25-3.25k tok/s. With thinking on, ThinkingCap's GSM8K completions are 35% shorter at the median and 52% shorter on the mean. One finding affects this issue: the deployed argmax tree needs the 0019 slot fix, or it fails infbench stage-3 concurrency.

No-MTP baseline for ThinkingCap and base Qwen on the production arm is in #65 (branch `thinkingcap-baseline`). Serving speed is the same for both checkpoints: TG 48-62 ms/token, prefill 2.25-3.25k tok/s. With thinking on, ThinkingCap's GSM8K completions are 35% shorter at the median and 52% shorter on the mean. One finding affects this issue: the deployed argmax tree needs the 0019 slot fix, or it fails infbench stage-3 concurrency.
Author
Owner

P1a blocked: PR #55548's GDN prefill does not run at TP=2 on the P300 pair

I ran after the tc-baseline agent had taken and released its slot. Five attempts, each a stock text_demo traced_128 run with SPEC=0 in vllm-tt:mtp2, record bench/runs/mtp-thinkingcap-b1-p1a-attempt5-9-20260924T1410Z.jsonl:

# change result
5 prefill chunk 512 same conv1d L1 clash. The CB region still ends at 1094720, while the lowest L1 buffer moved 560128 -> 779264. The clash does not depend on the chunk size.
6 + q/k/v/z/a/b in DRAM same clash (lowest L1 buffer now at 833280)
7 + MAC FIR instead of ttnn.conv1d device hang. The host blocked for more than 15 min in the fused chunk_gated_delta_rule
8 + seq adapter instead of the fused op device hang. The host blocked in a plain ttnn.copy. Dispatch is async, so the host blocks at the next op that waits on the queue, not at the op that hung
9 qkvzab back in L1 + op timeout 90 s TIMEOUT: device timeout in fetch queue wait. The timeout names no op. Triage needs ttexalens, which is not in the image

After each hang the container was stopped with docker stop -t 150 (each ran to SIGKILL), then tt-smi -r under our lock. The cards are reset and the lock is free. No host OOM or pinned-page leak.

Conclusion:

  • The PR was validated only on QB2 at TP=4. At TP=2 each device carries twice the GDN heads and channels, and its L1-resident prefill layout breaks.
  • Nothing after the GDN conv had ever executed at TP=2 before today.
  • Still no numbers for either arm: no acceptance, verify_cost_ratio, token identity, prefill tok/s or decode ms/token.

Options:

  • (a) Port spec_decode, mtp and verify onto the 0.79 production tree, whose TP=2 prefill is proven. This was planned as the follow-up and is now the critical path.
  • (b) Name the hung op: add ttexalens to mtp2 (built off-box) and run dump_running_operations.py against the live hung process.
  • (c) Bisect with a device sync after each op, at about one card slot per try.

Commit d37e202. The overlay keeps all workarounds as env switches, and EXTRA_ENV is available for diagnostics. Skills mtp-tt and tt-card-slot are in the repo (4e1b1e9, trap 8 added in d37e202).

## P1a blocked: PR #55548's GDN prefill does not run at TP=2 on the P300 pair I ran after the tc-baseline agent had taken and released its slot. Five attempts, each a stock `text_demo traced_128` run with SPEC=0 in `vllm-tt:mtp2`, record `bench/runs/mtp-thinkingcap-b1-p1a-attempt5-9-20260924T1410Z.jsonl`: | # | change | result | |---|---|---| | 5 | prefill chunk 512 | same conv1d L1 clash. The CB region still ends at 1094720, while the lowest L1 buffer moved 560128 -> 779264. **The clash does not depend on the chunk size.** | | 6 | + q/k/v/z/a/b in DRAM | same clash (lowest L1 buffer now at 833280) | | 7 | + MAC FIR instead of `ttnn.conv1d` | **device hang.** The host blocked for more than 15 min in the fused `chunk_gated_delta_rule` | | 8 | + seq adapter instead of the fused op | **device hang.** The host blocked in a plain `ttnn.copy`. Dispatch is async, so the host blocks at the next op that waits on the queue, not at the op that hung | | 9 | qkvzab back in L1 + op timeout 90 s | `TIMEOUT: device timeout in fetch queue wait`. The timeout names no op. Triage needs `ttexalens`, which is not in the image | After each hang the container was stopped with `docker stop -t 150` (each ran to SIGKILL), then `tt-smi -r` under our lock. The cards are reset and the lock is free. No host OOM or pinned-page leak. **Conclusion:** - The PR was validated only on QB2 at TP=4. At TP=2 each device carries twice the GDN heads and channels, and its L1-resident prefill layout breaks. - Nothing after the GDN conv had ever executed at TP=2 before today. - **Still no numbers for either arm:** no acceptance, verify_cost_ratio, token identity, prefill tok/s or decode ms/token. **Options:** - **(a)** Port spec_decode, mtp and verify onto the 0.79 production tree, whose TP=2 prefill is proven. This was planned as the follow-up and is now the critical path. - **(b)** Name the hung op: add ttexalens to mtp2 (built off-box) and run `dump_running_operations.py` against the live hung process. - **(c)** Bisect with a device sync after each op, at about one card slot per try. Commit d37e202. The overlay keeps all workarounds as env switches, and `EXTRA_ENV` is available for diagnostics. Skills `mtp-tt` and `tt-card-slot` are in the repo (4e1b1e9, trap 8 added in d37e202).
Author
Owner

Status summary 2026-09-24 (before the triage and bisect runs)

1. The old MTP image was broken; fixed.

  • Root cause (MEASURED): vllm-tt:mtp shipped the PR #55548 CI wheel (96f3f041) but compiled its kernels from the 0.79 production source tree. 754 files differ from the wheel and 1256 are missing, which gave the compile_time_args.h:27 static assert.
  • Fix: vllm-tt:mtp2 (nix/mtp-pr55548/Dockerfile.onecommit) takes the compile tree from the git archive of 96f3f041, overlaid with the wheel's own sources.
  • Consistency check: verify_onecommit.py fails the build on any mismatch. The wheel matches the archive on all 4634 shared files, and the tree matches the wheel with 0 mismatches. The same check fails on the old image, as it should (negative control).

2. Prefill conv hits an on-chip memory (L1) clash at TP=2; worked around.

  • The GDN ttnn.conv1d reports "static circular buffers clash with L1 buffers".
  • It does not depend on the prefill chunk size: the circular-buffer region ends at 1094720 at both 2048 and 512 tokens. Moving five intermediate buffers to DRAM doesn't clear it either.
  • Workaround: QWEN36_GDN_CONV1D=0 switches to the simpler MAC filter the code already uses elsewhere.

3. The TP=2 prefill then hangs the device; open.

  • It hangs with both the fused and the fallback kernel for the next step. TT_METAL_OPERATION_TIMEOUT_SECONDS turns it into a TIMEOUT but names no operation.
  • The PR was validated only on QB2 at TP=4. At TP=2 each device holds twice the GDN heads and channels. Before today, nothing after the GDN conv step had ever run at TP=2.

4. Still no numbers. No acceptance, verify_cost_ratio, token identity, prefill tok/s or decode ms/token for either arm.

5. Go/no-go per batch size is still an ESTIMATE (bench/runs/mtp-thinkingcap-go-nogo-estimate-20260924.jsonl):

  • B=1: GO, ×2.01 (range ×1.32–2.69), K=5.
  • B=4: GO, ×1.79–1.84. Makespan −39% at 4k, −26% at 32k.
  • B=8: conditional GO, ×1.58–1.68 at K=3. Makespan −29% / −17%. NO-GO if acceptance < 0.5 and the per-row verify cost is ≥ 1.2 ms.

6. Next, per the owner:

  • (a) Add ttexalens to the image, keeping the one-commit check passing, and run the upstream triage tool against a live hang to name the operation.
  • (b) Bisect with a device sync after each GDN sub-op or layer.
  • The goal is an env-switched overlay workaround, or a precise upstream bug report.
  • After that: porting spec_decode, mtp and verify onto the production tree (whose TP=2 prefill is proven) remains the planned follow-up.

Skills: .claude/skills/mtp-tt, .claude/skills/tt-card-slot. Branch mtp-thinkingcap @ d37e202.

## Status summary 2026-09-24 (before the triage and bisect runs) **1. The old MTP image was broken; fixed.** - **Root cause (MEASURED):** `vllm-tt:mtp` shipped the PR #55548 CI wheel (96f3f041) but compiled its kernels from the 0.79 production source tree. 754 files differ from the wheel and 1256 are missing, which gave the `compile_time_args.h:27` static assert. - **Fix:** `vllm-tt:mtp2` (`nix/mtp-pr55548/Dockerfile.onecommit`) takes the compile tree from the git archive of 96f3f041, overlaid with the wheel's own sources. - **Consistency check:** `verify_onecommit.py` fails the build on any mismatch. The wheel matches the archive on all 4634 shared files, and the tree matches the wheel with 0 mismatches. The same check fails on the old image, as it should (negative control). **2. Prefill conv hits an on-chip memory (L1) clash at TP=2; worked around.** - The GDN `ttnn.conv1d` reports "static circular buffers clash with L1 buffers". - It does not depend on the prefill chunk size: the circular-buffer region ends at 1094720 at both 2048 and 512 tokens. Moving five intermediate buffers to DRAM doesn't clear it either. - **Workaround:** `QWEN36_GDN_CONV1D=0` switches to the simpler MAC filter the code already uses elsewhere. **3. The TP=2 prefill then hangs the device; open.** - It hangs with both the fused and the fallback kernel for the next step. `TT_METAL_OPERATION_TIMEOUT_SECONDS` turns it into a TIMEOUT but names no operation. - **The PR was validated only on QB2 at TP=4.** At TP=2 each device holds twice the GDN heads and channels. Before today, nothing after the GDN conv step had ever run at TP=2. **4. Still no numbers.** No acceptance, verify_cost_ratio, token identity, prefill tok/s or decode ms/token for either arm. **5. Go/no-go per batch size is still an ESTIMATE** (`bench/runs/mtp-thinkingcap-go-nogo-estimate-20260924.jsonl`): - B=1: GO, ×2.01 (range ×1.32–2.69), K=5. - B=4: GO, ×1.79–1.84. Makespan −39% at 4k, −26% at 32k. - B=8: conditional GO, ×1.58–1.68 at K=3. Makespan −29% / −17%. NO-GO if acceptance < 0.5 and the per-row verify cost is ≥ 1.2 ms. **6. Next, per the owner:** - **(a)** Add `ttexalens` to the image, keeping the one-commit check passing, and run the upstream triage tool against a live hang to name the operation. - **(b)** Bisect with a device sync after each GDN sub-op or layer. - The goal is an env-switched overlay workaround, or a precise upstream bug report. - **After that:** porting spec_decode, mtp and verify onto the production tree (whose TP=2 prefill is proven) remains the planned follow-up. Skills: `.claude/skills/mtp-tt`, `.claude/skills/tt-card-slot`. Branch `mtp-thinkingcap` @ d37e202.
Author
Owner

Hang diagnosis: the fused GDN out-proj matmul_reduce_scatter_async deadlocks on the 1x2 mesh

One card slot, two methods, and they agree (MEASURED, bench/runs/mtp-thinkingcap-hang-triage-20260924T1942Z.jsonl, raw outputs in the directory of the same name):

  1. tt-metal triage on the live hang. I ran it inside the hung container, using vllm-tt:mtp2-triage (mtp2 plus the tree's own triage requirements, binary wheels; the one-commit check still PASSES).
    • dump_running_operations reports op 1560, MatmulReduceScatterAsyncDeviceOperation, on both devices, 36 cores: [1,1,512,3072] fp32 x [3072,5120] bf8, producing [1,1,512,5120] and RS [1,1,512,2560].
    • dump_callstacks shows every worker parked in reduce_scatter_minimal_async: the reader waits on out_ready_sem (reader.cpp:416), the ring_reduction compute waits on CB tiles, and no matmul kernel is still running.
  2. Op-level sync (QWEN36_OP_SYNC=1): the last op started without completing is [OPSYNC] B 2836 ttnn.experimental.matmul_reduce_scatter_async tt/tp_common.py:553, the GDN out-proj of layer 0. Serialising the ops does not make the hang go away, so this is a deterministic deadlock, not a race.
  • The earlier suspects were innocent. The fused chunk op and ttnn.copy were only where the async host happened to block next.
  • Prior art: production vllm-tt:k2 gdn/tp.py:197-205 already turns this fusion off for 2-device meshes with a unit dimension ("deadlocks there in every offset/links/topology config probed"). It uses _row_proj + tt_all_reduce instead. PR #55548 was branched without that gate.
  • Workaround (overlay, env-switched): QWEN36_GDN_OUT_MMRS=auto (default) applies the production gate; 1 reproduces the hang, 0 forces the fusion off. The fused chunk op is back to the default.
  • Upstream bug draft (in the record): matmul_reduce_scatter_async deadlocks on a 1x2 Blackhole mesh (Linear topology, num_links=2, grid (8,8), rs_offset (0,8), M=512, K_local=3072, N=5120, fp32 out). The same inputs pass when the matmul and reduce_scatter_minimal_async run unfused. The production comment adds a likely cause: on the p150a 1x2 worker grid, that placement puts 14 of the 36 reduce-scatter cores off-grid, and the op accepts it without validating.

Next: the full B=1 P1a chain (smoke, then the 20-arm matrix) is queued on vllm-tt:mtp2-triage with the gate on. It is waiting for the lock, which the DSpark agent currently holds. Commit 3de2491.

## Hang diagnosis: the fused GDN out-proj `matmul_reduce_scatter_async` deadlocks on the 1x2 mesh One card slot, two methods, and they agree (MEASURED, `bench/runs/mtp-thinkingcap-hang-triage-20260924T1942Z.jsonl`, raw outputs in the directory of the same name): 1. **tt-metal triage on the live hang.** I ran it inside the hung container, using `vllm-tt:mtp2-triage` (mtp2 plus the tree's own triage requirements, binary wheels; the one-commit check still PASSES). - `dump_running_operations` reports op 1560, **`MatmulReduceScatterAsyncDeviceOperation`**, on both devices, 36 cores: `[1,1,512,3072]` fp32 x `[3072,5120]` bf8, producing `[1,1,512,5120]` and RS `[1,1,512,2560]`. - `dump_callstacks` shows every worker parked in `reduce_scatter_minimal_async`: the reader waits on `out_ready_sem` (reader.cpp:416), the ring_reduction compute waits on CB tiles, and no matmul kernel is still running. 2. **Op-level sync** (`QWEN36_OP_SYNC=1`): the last op started without completing is **`[OPSYNC] B 2836 ttnn.experimental.matmul_reduce_scatter_async tt/tp_common.py:553`**, the GDN out-proj of **layer 0**. Serialising the ops does not make the hang go away, so this is a deterministic deadlock, not a race. - **The earlier suspects were innocent.** The fused chunk op and `ttnn.copy` were only where the async host happened to block next. - **Prior art:** production `vllm-tt:k2 gdn/tp.py:197-205` already turns this fusion off for 2-device meshes with a unit dimension ("deadlocks there in every offset/links/topology config probed"). It uses `_row_proj` + `tt_all_reduce` instead. PR #55548 was branched without that gate. - **Workaround (overlay, env-switched):** `QWEN36_GDN_OUT_MMRS=auto` (default) applies the production gate; `1` reproduces the hang, `0` forces the fusion off. The fused chunk op is back to the default. - **Upstream bug draft** (in the record): `matmul_reduce_scatter_async` deadlocks on a 1x2 Blackhole mesh (Linear topology, num_links=2, grid (8,8), rs_offset (0,8), M=512, K_local=3072, N=5120, fp32 out). The same inputs pass when the matmul and `reduce_scatter_minimal_async` run unfused. The production comment adds a likely cause: on the p150a 1x2 worker grid, that placement puts 14 of the 36 reduce-scatter cores off-grid, and the op accepts it without validating. **Next:** the full B=1 P1a chain (smoke, then the 20-arm matrix) is queued on `vllm-tt:mtp2-triage` with the gate on. It is waiting for the lock, which the DSpark agent currently holds. Commit 3de2491.
Author
Owner

First PR #55548 run completes on the P150 pair: plain B=1 smoke PASS

  • What made it pass: with the MMRS gate, the MAC FIR conv and the prefill chunk back at 2048, the stock text_demo traced_128 (SPEC=0) PASSES at TP=2. This is the first time the PR stack has run to completion on our pair.
  • Plain decode (MEASURED): 18.72 tok/s, i.e. 53.4 ms/token. That is the PR tree, not production (production B=1 is 48.5 ms); quote ratios, not absolutes.
  • TTFT: 0.40 s on a 128-token prompt. It includes the first decode step, so this is not a prefill-throughput number. The matrix arms at 4k will give one.
  • Attempt 10's failure: the vision-merge buffers are sized to the prefill chunk, so the 512 override broke the masked-bucket warmup. The 512 was only ever for the conv clash, which the FIR avoids at any chunk size.
  • Records: bench/runs/mtp-thinkingcap-b1-p1a-smoke-pass-20260924T2049Z.jsonl, …-attempt10-…jsonl.

Now running: the 20-arm B=1 MTP matrix (thinkingcap-b1-20260924T204859Z): plain vs spec at K=3 and K=7, over agentic_edit / code / prose / QA. It will give acceptance per regime, verify_cost_ratio, token identity, and prefill/decode speed per arm.

## First PR #55548 run completes on the P150 pair: plain B=1 smoke PASS - **What made it pass:** with the MMRS gate, the MAC FIR conv and the prefill chunk back at 2048, the stock `text_demo traced_128` (SPEC=0) **PASSES** at TP=2. This is the first time the PR stack has run to completion on our pair. - **Plain decode (MEASURED):** 18.72 tok/s, i.e. **53.4 ms/token**. That is the PR tree, not production (production B=1 is 48.5 ms); quote ratios, not absolutes. - **TTFT:** 0.40 s on a 128-token prompt. It includes the first decode step, so this is **not** a prefill-throughput number. The matrix arms at 4k will give one. - **Attempt 10's failure:** the vision-merge buffers are sized to the prefill chunk, so the 512 override broke the masked-bucket warmup. The 512 was only ever for the conv clash, which the FIR avoids at any chunk size. - **Records:** `bench/runs/mtp-thinkingcap-b1-p1a-smoke-pass-20260924T2049Z.jsonl`, `…-attempt10-…jsonl`. **Now running:** the 20-arm B=1 MTP matrix (`thinkingcap-b1-20260924T204859Z`): plain vs spec at K=3 and K=7, over agentic_edit / code / prose / QA. It will give acceptance per regime, verify_cost_ratio, token identity, and prefill/decode speed per arm.
Author
Owner

P1a (B=1): the full matrix completes at TP=2, but token identity fails

Run thinkingcap-b1-20260924T210159Z, image vllm-tt:mtp2-triage (PR #55548 @96f3f041), 20/20 arms ok. All numbers are MEASURED. Record: bench/runs/mtp-thinkingcap-b1-p1a-matrix-20260924T2135Z.jsonl (plus .reduced.json / .arms.jsonl), commit fbc7900.

One new TP=2 fix was needed first. The spec verify SDPA (spec_multi_pos_tiles) requires 1 KV head per device. That holds at TP=4, but TP=2 has 2. The overlay now falls back to the legacy B=T verify call.

Acceptance (committed tokens/iteration, count_control excluded)

regime K=3 K=7
agentic_edit 3.27 4.02
free text (prose + qa) 2.82 3.22
code 3.98 7.62

Speed (B=1; the PR tree, not production, so compare ratios only)

  • Prefill: 3,183–3,198 tok/s at 4k (plain arms); 1,791–1,838 tok/s at 2,095 tokens (agentic).
  • Spec-arm TTFT penalty: TTFT is 3.3–4.2 s in the spec arms vs 1.1–1.3 s plain, about 2.3 s extra. This is probably the MTP-head prefill/reseed; not yet investigated.
  • Plain decode: 53.8–54.5 ms/token.
prompt spec K=3 decode spec K=7 decode
agentic 33.0 ms (×1.64) 38.1 ms (×1.42)
code 29.0 ms (×1.88) 20.4 ms (×2.67)
qa 33.2 ms (×1.63) 40.4 ms (×1.34)
prose 43.6 ms (×1.25) 54.4 ms (×1.00)
  • verify_cost_ratio: 1.25–1.28 at K=3, 1.40–1.43 at K=7. This is an upper bound, because the fallback reads KV T times.
  • Draft cost: about 9 ms per draft token (27 ms at K=3, 59 ms at K=7). Together with the verify cost, it is why K=7 loses on free text.
  • Against the estimate (B=1 GO ×2.01 at K=5, band ×1.32–2.69): the measured result is inside the band. For agentic work, K=3 beats K=7.

Gate: greedy token identity FAILS on 7/8 arms (code passes at both K)

  • First divergence positions: agentic at token 20, qa at 57, prose at 17 (K=3) and 7 (K=7).
  • Both paths are deterministic: plain C == C2 and spec T == T2 over all 256 tokens. The divergence position does not depend on K, so it is a fixed numeric difference between the verify path and plain decode, not a flaky acceptance bug.
  • For scale, plain fused vs plain unfused also diverges, at token 53.
  • Not yet localised. Suspects: the legacy B=T SDPA fallback, the FIR conv in verify, and the multi-position GDN path.
  • Next measurement: the top-2 logit gap at the first divergence. A near-tie means numerics; a large gap means a bug.
  • Until then, the speedups above are not licensed. Whether a near-tie tolerance is acceptable is an owner decision.

Hang diagnosis: unchanged from the earlier comment (fused GDN out-proj MMRS deadlock at 1x2; production gate applied in the overlay).

## P1a (B=1): the full matrix completes at TP=2, but token identity fails Run `thinkingcap-b1-20260924T210159Z`, image `vllm-tt:mtp2-triage` (PR #55548 @96f3f041), 20/20 arms ok. All numbers are **MEASURED**. Record: `bench/runs/mtp-thinkingcap-b1-p1a-matrix-20260924T2135Z.jsonl` (plus `.reduced.json` / `.arms.jsonl`), commit fbc7900. **One new TP=2 fix was needed first.** The spec verify SDPA (`spec_multi_pos_tiles`) requires 1 KV head per device. That holds at TP=4, but TP=2 has 2. The overlay now falls back to the legacy B=T verify call. ### Acceptance (committed tokens/iteration, count_control excluded) | regime | K=3 | K=7 | |---|---|---| | agentic_edit | 3.27 | 4.02 | | free text (prose + qa) | 2.82 | 3.22 | | code | 3.98 | 7.62 | ### Speed (B=1; the PR tree, not production, so compare ratios only) - **Prefill:** 3,183–3,198 tok/s at 4k (plain arms); 1,791–1,838 tok/s at 2,095 tokens (agentic). - **Spec-arm TTFT penalty:** TTFT is 3.3–4.2 s in the spec arms vs 1.1–1.3 s plain, about 2.3 s extra. This is probably the MTP-head prefill/reseed; not yet investigated. - **Plain decode:** 53.8–54.5 ms/token. | prompt | spec K=3 decode | spec K=7 decode | |---|---|---| | agentic | 33.0 ms (×1.64) | 38.1 ms (×1.42) | | code | 29.0 ms (×1.88) | 20.4 ms (×2.67) | | qa | 33.2 ms (×1.63) | 40.4 ms (×1.34) | | prose | 43.6 ms (×1.25) | 54.4 ms (×1.00) | - **verify_cost_ratio:** 1.25–1.28 at K=3, 1.40–1.43 at K=7. This is an **upper bound**, because the fallback reads KV T times. - **Draft cost:** about 9 ms per draft token (27 ms at K=3, 59 ms at K=7). Together with the verify cost, it is why K=7 loses on free text. - **Against the estimate** (B=1 GO ×2.01 at K=5, band ×1.32–2.69): the measured result is inside the band. For agentic work, K=3 beats K=7. ### Gate: greedy token identity **FAILS on 7/8 arms** (code passes at both K) - **First divergence positions:** agentic at token 20, qa at 57, prose at 17 (K=3) and 7 (K=7). - **Both paths are deterministic:** plain C == C2 and spec T == T2 over all 256 tokens. The divergence position does not depend on K, so it is a **fixed numeric difference between the verify path and plain decode**, not a flaky acceptance bug. - For scale, plain fused vs plain unfused also diverges, at token 53. - **Not yet localised.** Suspects: the legacy B=T SDPA fallback, the FIR conv in verify, and the multi-position GDN path. - **Next measurement:** the top-2 logit gap at the first divergence. A near-tie means numerics; a large gap means a bug. - **Until then, the speedups above are not licensed.** Whether a near-tie tolerance is acceptable is an owner decision. **Hang diagnosis:** unchanged from the earlier comment (fused GDN out-proj MMRS deadlock at 1x2; production gate applied in the overlay).
Author
Owner

Token-identity gap study: queued

We need to know whether the 7/8 identity failures are bf16 near-ties or a real bug. That answer feeds the owner's tolerance decision. Harness commit 7b80e4a.

  • Probe: with QWEN36_LOGIT_PROBE=1, both paths record the top-16 logits of every row they emit a token from. Plain decode reads its trace logits; spec reads the verify [T, vocab] rows.
    • At each first divergence, reduce_gap.py reports, for both paths, the margin of its own token over the other path's token.
    • It classifies each divergence against two scales: the bf16 ulp at that logit, and the path-to-path margin noise measured on the agreed prefix.
  • Prompts: the 4 originals plus variants: agentic ×5, code ×5, prose ×3, qa ×3. That gives n = 6 agentic, 6 code and 8 free-text prompts. Each prompt runs plain fused vs spec K3 at 4k context, generating 128 tokens.
  • Reference pair: plain fused vs plain unfused on the originals (agentic diverged at token 53 last time).
  • Localisation swaps, run last:
    • the per-token verify loop instead of the fully-batched one;
    • native conv1d in verify instead of the FIR.
    • The legacy B=T verify SDPA cannot be swapped out at TP=2, because the fused kernel requires 1 KV head per device.
  • Status: 51 arms, about 75 min of card time. Queued behind the current lock holder (bfpq-R2), with no preemption.
## Token-identity gap study: queued We need to know whether the 7/8 identity failures are bf16 near-ties or a real bug. That answer feeds the owner's tolerance decision. Harness commit 7b80e4a. - **Probe:** with `QWEN36_LOGIT_PROBE=1`, both paths record the top-16 logits of every row they emit a token from. Plain decode reads its trace logits; spec reads the verify `[T, vocab]` rows. - At each first divergence, `reduce_gap.py` reports, for both paths, the margin of its own token over the other path's token. - It classifies each divergence against two scales: the bf16 ulp at that logit, and the path-to-path margin noise measured on the agreed prefix. - **Prompts:** the 4 originals plus variants: agentic ×5, code ×5, prose ×3, qa ×3. That gives n = 6 agentic, 6 code and 8 free-text prompts. Each prompt runs plain fused vs spec K3 at 4k context, generating 128 tokens. - **Reference pair:** plain fused vs plain unfused on the originals (agentic diverged at token 53 last time). - **Localisation swaps, run last:** - the per-token verify loop instead of the fully-batched one; - native conv1d in verify instead of the FIR. - The legacy B=T verify SDPA cannot be swapped out at TP=2, because the fused kernel requires 1 KV head per device. - **Status:** 51 arms, about 75 min of card time. Queued behind the current lock holder (bfpq-R2), with no preemption.
Author
Owner

Token-identity gap study: the divergences are bf16 near-ties, not a bug (MEASURED)

  • Run: thinkingcap-gap-b1-20260924T214536Z, finished 22:23Z, before the API outage.
  • Coverage: 20 prompts (agentic 6, code 6, free text 8), each plain fused vs spec K=3, plus 4 plain fused-vs-unfused reference pairs. B=1, 4k context, 128 generated tokens, top-16 logits recorded on both paths.
  • Records: bench/runs/mtp-thinkingcap-b1-identity-gap-20260924T2223Z.jsonl (plus .reduced.json and .arms.jsonl.gz), commit 9bbd44d.

Result

  • Divergences: 18 of 20 spec prompts diverge from plain; 2 are identical over 128 tokens.
  • Candidates: every divergence is between the top 3 candidates of both paths. No path ever picks a token the other ranks low.
  • Plain's own margin (plain's logit for its token minus its logit for spec's token) is at most 0.5 logit, i.e. at most 4 bf16 ulps at logit magnitudes of 16–32.
    • 11 of the 18 are within 1 ulp.
    • 4 are exact bf16 ties.
  • Swing (how far the two paths move the gap between the two tokens) is at most 0.875 logit.
  • Reference scale, plain fused vs plain unfused: this compares the same model with a different GDN decode op.
    • It also diverges on 3 of 4 prompts (swings 0.125–0.5).
    • Its margin noise on agreed positions is median 0.19, p99 1.25, max 1.875.
    • All 18 spec swings are inside that p99.
    • On qa_reason, spec and plain-unfused diverge at the same token (57) with identical margins, so there the plain-fused reference is the odd path out.
  • Spec vs plain margin noise: median 0.19, p99 1.625, max 4.5. The verify path perturbs margins slightly more than the GDN-op swap does, but on positions where both paths still agree.
  • Device tie-breaks: 63 of 6,580 emitted tokens are not the host top-1 of their own row. All 63 are exact bf16 ties broken by the device argmax.

Classification

rule near-tie real
plain's margin ≤ 2 ulps and swing ≤ pooled noise p99 (strict) 13 5
swing ≤ the fused/unfused control's p99 18 0

The 5 flagged by the strict rule have plain margins of 0.375–0.5 (3–4 ulps). The tolerance is the owner's decision; this data sets its scale.

Localisation

The classification did not require it. One swap failed, one never ran:

  • The per-token verify swap is stale in the PR: commit_verify_slot copies into _conv_win_buf, which only the full-batch path allocates. It fails as a host TypeError; the device was not affected.
  • The harness exits after a failed arm, so the native-conv-in-verify swap never ran.

The main arms were not affected by the outage.

## Token-identity gap study: the divergences are bf16 near-ties, not a bug (MEASURED) - **Run:** `thinkingcap-gap-b1-20260924T214536Z`, finished 22:23Z, before the API outage. - **Coverage:** 20 prompts (agentic 6, code 6, free text 8), each plain fused vs spec K=3, plus 4 plain fused-vs-unfused reference pairs. B=1, 4k context, 128 generated tokens, top-16 logits recorded on both paths. - **Records:** `bench/runs/mtp-thinkingcap-b1-identity-gap-20260924T2223Z.jsonl` (plus `.reduced.json` and `.arms.jsonl.gz`), commit 9bbd44d. ### Result - **Divergences:** 18 of 20 spec prompts diverge from plain; 2 are identical over 128 tokens. - **Candidates:** every divergence is between the **top 3 candidates of both paths**. No path ever picks a token the other ranks low. - **Plain's own margin** (plain's logit for its token minus its logit for spec's token) is **at most 0.5 logit, i.e. at most 4 bf16 ulps** at logit magnitudes of 16–32. - 11 of the 18 are within 1 ulp. - 4 are exact bf16 ties. - **Swing** (how far the two paths move the gap between the two tokens) is at most 0.875 logit. - **Reference scale, plain fused vs plain unfused:** this compares the same model with a different GDN decode op. - It **also diverges on 3 of 4 prompts** (swings 0.125–0.5). - Its margin noise on agreed positions is median 0.19, p99 1.25, max 1.875. - **All 18 spec swings are inside that p99.** - On qa_reason, spec and plain-unfused diverge at the same token (57) with identical margins, so there the plain-fused reference is the odd path out. - **Spec vs plain margin noise:** median 0.19, p99 1.625, max 4.5. The verify path perturbs margins slightly more than the GDN-op swap does, but on positions where both paths still agree. - **Device tie-breaks:** 63 of 6,580 emitted tokens are not the host top-1 of their own row. All 63 are exact bf16 ties broken by the device argmax. ### Classification | rule | near-tie | real | |---|---|---| | plain's margin ≤ 2 ulps and swing ≤ pooled noise p99 (strict) | 13 | 5 | | swing ≤ the fused/unfused control's p99 | 18 | 0 | The 5 flagged by the strict rule have plain margins of 0.375–0.5 (3–4 ulps). **The tolerance is the owner's decision; this data sets its scale.** ### Localisation The classification did not require it. One swap failed, one never ran: - The per-token verify swap is **stale in the PR**: `commit_verify_slot` copies into `_conv_win_buf`, which only the full-batch path allocates. It fails as a host TypeError; the device was not affected. - The harness exits after a failed arm, so the native-conv-in-verify swap never ran. The main arms were not affected by the outage.
Author
Owner

MTP on bfp4 (G2): feasibility done; the G2 vs P A/B is running

(a) Two single-card instances: PASS (MEASURED)

Record: bench/runs/two-card-coexistence-20260925T0625Z.jsonl.

  • What was tested: one container per card with exactly one --device /dev/tenstorrent/N (the owner's recipe), and separately both nodes mapped with TT_VISIBLE_DEVICES=0/1. Both variants were run under our lock.
  • Result: in both variants, both containers opened a device, computed a matmul and an add correctly while overlapping in time, and closed. Each card then opened cleanly alone, with no reset needed.
  • The one-process-per-host rule applies to mesh (two-card) processes, not to one card per process.
  • Host memory:
    • HugePages_Free stayed at 4 of 4 throughout; these containers do not use the host hugepage pool.
    • There was no pinned-page leak per probe.
    • A full model per card is not shown safe on host RAM: about 5-7 GB is available.

(b) TP=1 full-model MTP: NOT VIABLE without new code

Record: bench/runs/mtp-tp1-feasibility-20260925T0640Z.jsonl.

  • Blocker: in PR #55548, prefill_for_spec, verify_forward and verify_traced all assert num_devices > 1.
  • Memory is not the blocker (ESTIMATE): 27B at bfp4 would be about 18-20 GB of weights on a 31 GB card.
  • Fallback: serialized TP=2 on both cards for every MTP end-to-end A/B, per the directive. Single-card containers remain available for parallel op/kernel probes.

G2 on the PR tree

  • Loader gate: ported the QWEN36_ATTN_GDN_BF4 gate into the overlay's PR-tree loaders (5 attention and 4 GDN weight sites, the same as the bfpq copies).
  • MTP head exemption:
    • Found: the MTP head builds its attention through the same loader, so it would have silently gone to bf4, as RTN of its bf16 weights.
    • Now: it is exempted by default (QWEN36_ATTN_GDN_BF4_MTP=1 opts in), so the drafter is identical across arms.
  • Arm self-identification: G2 must print 64 engaged plus 1 exempt banners; P must print none.
  • Session setup: g2-ab.sh holds one lock across G2 then P, with a separate TT_CACHE_PATH for each: mtp-G2-e55ba7f6 (a cold build) and thinkingcap-e55ba7f6.
  • Arms per checkpoint:
    • Timed speed arms (no probe) on the 4 original prompts: plain, K=3 and K=7.
    • Identity and acceptance arms (probe on) on 20 prompts: plain, K=3 and K=7, gen 256.
    • Fused/unfused reference for the identity rule.
  • Identity rule: unchanged. A divergence whose swing is within the fused/unfused drift p99 counts as a near-tie.
  • B=4/8: speculation in this tree is B=1 by construction. B=4/8 makespan needs the per-row commit, which does not exist yet.

Commit 389b170.

## MTP on bfp4 (G2): feasibility done; the G2 vs P A/B is running ### (a) Two single-card instances: PASS (MEASURED) Record: `bench/runs/two-card-coexistence-20260925T0625Z.jsonl`. - **What was tested:** one container per card with exactly one `--device /dev/tenstorrent/N` (the owner's recipe), and separately both nodes mapped with `TT_VISIBLE_DEVICES=0/1`. Both variants were run under our lock. - **Result:** in both variants, both containers opened a device, computed a matmul and an add correctly while overlapping in time, and closed. Each card then opened cleanly alone, with no reset needed. - **The one-process-per-host rule** applies to mesh (two-card) processes, not to one card per process. - **Host memory:** - `HugePages_Free` stayed at 4 of 4 throughout; these containers do not use the host hugepage pool. - There was no pinned-page leak per probe. - A full model per card is **not** shown safe on host RAM: about 5-7 GB is available. ### (b) TP=1 full-model MTP: NOT VIABLE without new code Record: `bench/runs/mtp-tp1-feasibility-20260925T0640Z.jsonl`. - **Blocker:** in PR #55548, `prefill_for_spec`, `verify_forward` and `verify_traced` all `assert num_devices > 1`. - **Memory is not the blocker (ESTIMATE):** 27B at bfp4 would be about 18-20 GB of weights on a 31 GB card. - **Fallback:** serialized TP=2 on both cards for every MTP end-to-end A/B, per the directive. Single-card containers remain available for parallel op/kernel probes. ### G2 on the PR tree - **Loader gate:** ported the `QWEN36_ATTN_GDN_BF4` gate into the overlay's PR-tree loaders (5 attention and 4 GDN weight sites, the same as the bfpq copies). - **MTP head exemption:** - **Found:** the MTP head builds its attention through the same loader, so it would have silently gone to bf4, as RTN of its bf16 weights. - **Now:** it is exempted by default (`QWEN36_ATTN_GDN_BF4_MTP=1` opts in), so the drafter is identical across arms. - **Arm self-identification:** G2 must print 64 `engaged` plus 1 `exempt` banners; P must print none. - **Session setup:** `g2-ab.sh` holds one lock across G2 then P, with a separate `TT_CACHE_PATH` for each: `mtp-G2-e55ba7f6` (a cold build) and `thinkingcap-e55ba7f6`. - **Arms per checkpoint:** - **Timed speed arms** (no probe) on the 4 original prompts: plain, K=3 and K=7. - **Identity and acceptance arms** (probe on) on 20 prompts: plain, K=3 and K=7, gen 256. - **Fused/unfused reference** for the identity rule. - **Identity rule:** unchanged. A divergence whose swing is within the fused/unfused drift p99 counts as a near-tie. - **B=4/8:** speculation in this tree is B=1 by construction. B=4/8 makespan needs the per-row commit, which does not exist yet. Commit 389b170.
Author
Owner

MTP on the bfp4 quant: G2 vs P, B=1, TP=2 (MEASURED 2026-09-25)

Setup

  • Session g2ab-20260925T064042Z. Both runs held one lock and each used its own tensor cache.
  • G2 printed the bf4 banner 64 times, plus 1 exemption for the MTP head, which stays bf8. P printed neither.
  • 80 arms per checkpoint, 0 failed.
  • Generation length 256 at a 4k prompt. The demo harness uses bf16 KV.

Acceptance (committed tokens per iteration, 20 prompts)

G2 P G2−P (paired)
K3 all 3.01 2.96
K7 all 4.33 3.99
agentic K3 / K7 2.86 / 4.20 2.76 / 3.78 +0.10 / +0.42
code K3 / K7 3.36 / 5.01 3.41 / 5.19 −0.05 / −0.19
free text K3 / K7 2.86 / 3.91 2.77 / 3.25 +0.09 / +0.66

Speed at B=1 (timed arms, one run per prompt)

PP is prefill tok/s. TG is ms/token. Makespan is derived as TTFT + 256 × ms/token; it is not an end-to-end wall.

prompt PP G2/P TG plain G2/P TG K3 G2/P TG K7 G2/P makespan plain/K3/K7, G2 same, P
agentic (2.1k) 1889/1857 50.6/53.9 35.9/37.6 33.9/34.2 14.1/12.6/12.5 s 14.9/13.1/12.9 s
code (4k) 3198/3198 51.0/54.6 27.1/27.5 20.2/19.8 14.3/10.6/9.0 s 15.3/10.5/9.2 s
prose_free (4k) 3203/3193 50.9/54.4 42.0/43.4 53.3/54.2 14.3/14.6/17.6 s 15.2/15.1/17.8 s
qa_reason (4k) 3200/3205 51.0/54.0 37.5/33.2 40.0/40.3 14.3/13.2/14.2 s 15.1/12.1/14.2 s
  • Spec TTFT is 3.5–4.1 s, against 1.1–1.3 s plain. The difference is the MTP KV warm pass over the prompt.
  • G2 plain decode is 6–7% faster than P.
  • K7 loses to plain on prose_free on both checkpoints.

Identity (owner B=1 rule, unchanged: near-tie iff swing ≤ the fused/unfused control p99)

  • P, control p99 1.31: K3 has 18 near-tie and 2 identical; K7 is the same.
  • G2, control p99 1.94: K3 has 17 near-tie, 2 identical and 1 over the line; K7 has 17 near-tie, 2 identical and 1 REAL.
    • The K3 case is prose_free.v1 at position 58: swing 2.125, a swap between ranks 2 and 3.
    • The K7 case is prose_free.v1 at position 126. The plain row is flat (14.69/14.56/14.13). Verify row j=4 of the T=8 verify puts token 17169 at 21.9 and pushes plain's token to rank 5. At position 125, row j=3 agrees to within 0.25. A 7-logit shift is beyond numerics, so this is one real disagreement in 20 prompts. It has not been localised.
  • Device argmax: 232 of 23,616 rows differ from host top-1, and all 232 are exact bf16 ties.

B=4/8: not measurable, because there is no per-row commit.

Records: bench/runs/mtp-g2-vs-p-b1-20260925T0640Z.{jsonl,compare.json,gap-G2.json,gap-P.json,arms-*.jsonl.gz}.

TP=1 (single card): the code is implemented, committed and pushed (QWEN36_TP1=1, the TP stack at divisor 1). The hardware session tp1-session.sh is queued behind the current lock holder (tt-quant:gptq), and there is no TP=1 number yet.

## MTP on the bfp4 quant: G2 vs P, B=1, TP=2 (MEASURED 2026-09-25) **Setup** - Session `g2ab-20260925T064042Z`. Both runs held one lock and each used its own tensor cache. - G2 printed the bf4 banner 64 times, plus 1 exemption for the MTP head, which stays bf8. P printed neither. - 80 arms per checkpoint, 0 failed. - Generation length 256 at a 4k prompt. The demo harness uses bf16 KV. **Acceptance (committed tokens per iteration, 20 prompts)** | | G2 | P | G2−P (paired) | |---|---|---|---| | K3 all | **3.01** | 2.96 | | | K7 all | **4.33** | 3.99 | | | agentic K3 / K7 | 2.86 / 4.20 | 2.76 / 3.78 | +0.10 / +0.42 | | code K3 / K7 | 3.36 / 5.01 | 3.41 / 5.19 | −0.05 / −0.19 | | free text K3 / K7 | 2.86 / 3.91 | 2.77 / 3.25 | +0.09 / +0.66 | **Speed at B=1 (timed arms, one run per prompt)** PP is prefill tok/s. TG is ms/token. Makespan is derived as TTFT + 256 × ms/token; it is not an end-to-end wall. | prompt | PP G2/P | TG plain G2/P | TG K3 G2/P | TG K7 G2/P | makespan plain/K3/K7, G2 | same, P | |---|---|---|---|---|---|---| | agentic (2.1k) | 1889/1857 | 50.6/53.9 | 35.9/37.6 | 33.9/34.2 | 14.1/12.6/12.5 s | 14.9/13.1/12.9 s | | code (4k) | 3198/3198 | 51.0/54.6 | 27.1/27.5 | 20.2/19.8 | 14.3/10.6/9.0 s | 15.3/10.5/9.2 s | | prose_free (4k) | 3203/3193 | 50.9/54.4 | 42.0/43.4 | 53.3/54.2 | 14.3/14.6/17.6 s | 15.2/15.1/17.8 s | | qa_reason (4k) | 3200/3205 | 51.0/54.0 | 37.5/33.2 | 40.0/40.3 | 14.3/13.2/14.2 s | 15.1/12.1/14.2 s | - Spec TTFT is 3.5–4.1 s, against 1.1–1.3 s plain. The difference is the MTP KV warm pass over the prompt. - G2 plain decode is 6–7% faster than P. - K7 loses to plain on prose_free on both checkpoints. **Identity (owner B=1 rule, unchanged: near-tie iff swing ≤ the fused/unfused control p99)** - P, control p99 1.31: K3 has 18 near-tie and 2 identical; K7 is the same. - G2, control p99 1.94: K3 has 17 near-tie, 2 identical and 1 over the line; K7 has 17 near-tie, 2 identical and 1 REAL. - The K3 case is prose_free.v1 at position 58: swing 2.125, a swap between ranks 2 and 3. - **The K7 case is prose_free.v1 at position 126.** The plain row is flat (14.69/14.56/14.13). Verify row j=4 of the T=8 verify puts token 17169 at 21.9 and pushes plain's token to rank 5. At position 125, row j=3 agrees to within 0.25. A 7-logit shift is beyond numerics, so this is one real disagreement in 20 prompts. It has not been localised. - Device argmax: 232 of 23,616 rows differ from host top-1, and all 232 are exact bf16 ties. **B=4/8:** not measurable, because there is no per-row commit. Records: `bench/runs/mtp-g2-vs-p-b1-20260925T0640Z.{jsonl,compare.json,gap-G2.json,gap-P.json,arms-*.jsonl.gz}`. **TP=1 (single card):** the code is implemented, committed and pushed (`QWEN36_TP1=1`, the TP stack at divisor 1). The hardware session `tp1-session.sh` is queued behind the current lock holder (tt-quant:gptq), and there is no TP=1 number yet.
Author
Owner

TP=1 MTP (63b) is reopened as engineering work, not a limit. mtp-tp1-feasibility-20260925T0640Z is CODE-FACT + ESTIMATE: the blockers are our own num_devices > 1 asserts. It is also tracked under the 1-card target, #69.

Reopened under the new rule (owner, 2026-09-25): a closure needs an on-hardware record at the stated scope; estimates, code-reads and third-party numbers close nothing. See docs/LEDGER.md "Reopened" (PR #68).

**TP=1 MTP (63b)** is reopened as engineering work, not a limit. `mtp-tp1-feasibility-20260925T0640Z` is CODE-FACT + ESTIMATE: the blockers are our own `num_devices > 1` asserts. It is also tracked under the 1-card target, #69. Reopened under the new rule (owner, 2026-09-25): **a closure needs an on-hardware record at the stated scope**; estimates, code-reads and third-party numbers close nothing. See docs/LEDGER.md "Reopened" (PR #68).
Author
Owner

TP=1 MTP: interim status (2026-09-25 10:10Z). No new speed number in this update.

"Decode on one card is broken": there is no decode failure on record yet. No TP=1 run has reached decode.

  • The ttx g2-tp1-probe runs (cfx-llm2:/var/lib/models/ttx-runs/ttx-g2-tp1-probe-smoke-20260925T083748Z and the ttx-ab-...-20260925T090800Z-r{0,1}-{A,B} runs) are load-only. Their phases are imports, open, ttnn_matmul, model_load, hold and done, and every one ends ok: true. They show that G2 loads on one card: 20.96 GB of 33.1 GB DRAM, VmHWM 5.6–5.8 GB, and 32–34 MB RSS after load. They never prefill or decode.
  • My own TP=1 attempts died in the first prefill chunk, before decode:
    • attempt 1: a GDN qkvzab 2D matmul needed 3.32 MB of static CBs;
    • attempt 2: a GDN nlp_concat_heads over 48 heads needed 1.68 MB.
    • Both are fixed and committed (TP1_L1_FIT: blocked prefill outputs and grouped concat-heads), and neither affects TP=2. See bench/runs/mtp-tp1-attempts-20260925.jsonl.
  • The next smoke (warmups W0–W3 plus one K3 spec arm) is the first run that can reach decode. It is queued on card 1 under a ttx per-card slot (bench/mtp/tp1-card.sh). It is waiting for MemAvailable to reach 4 GB: tt-quant's GPTQ on card 0 holds 2 GB RSS, and a TP=1 load peaks at ~5.8 GB. If decode fails there, I will report the op, file and line.

TP=2 byte-identity with the TP=1 code: whole-chunk prompts are unchanged; the tail-chunk difference is pre-existing (bench/runs/mtp-tp1-tp2identity-20260925.jsonl).

  • Every 4096-token prompt is token-identical in all six runs, and code logits are bit-identical.
  • agentic_edit (2048 + a 47-token tail chunk) repeats exactly when code and harness are identical, but changes with any change to the process:
Overlay Harness Output
old old 8dac
new new or old cb11
old new a61a
  • The harness change adds no device compute op. So the TP=1 code does not cause the difference: the eager partial-chunk prefill is sensitive to perturbation. The suspect is that the last-tile pad rows 47–63 reach the GDN state unmasked. It is not localised yet.
  • Consequence: the TP=1 vs TP=2 identity gate uses the near-tie rule on every prompt, and bit-exactness is expected only on whole-chunk prompts.

Docs moved to the new layout: LEDGER R-2026-09-25-mtp-g2 and D-2026-09-25-mtp-tp1, MTP.md and STATUS.md.

**TP=1 MTP: interim status (2026-09-25 10:10Z).** No new speed number in this update. **"Decode on one card is broken": there is no decode failure on record yet.** No TP=1 run has reached decode. - The ttx `g2-tp1-probe` runs (cfx-llm2:/var/lib/models/ttx-runs/ttx-g2-tp1-probe-smoke-20260925T083748Z and the `ttx-ab-...-20260925T090800Z-r{0,1}-{A,B}` runs) are **load-only**. Their phases are imports, open, ttnn_matmul, model_load, hold and done, and every one ends `ok: true`. They show that G2 loads on one card: 20.96 GB of 33.1 GB DRAM, VmHWM 5.6–5.8 GB, and 32–34 MB RSS after load. They never prefill or decode. - My own TP=1 attempts died in the **first prefill chunk**, before decode: - attempt 1: a GDN qkvzab 2D matmul needed 3.32 MB of static CBs; - attempt 2: a GDN `nlp_concat_heads` over 48 heads needed 1.68 MB. - Both are fixed and committed (`TP1_L1_FIT`: blocked prefill outputs and grouped concat-heads), and neither affects TP=2. See `bench/runs/mtp-tp1-attempts-20260925.jsonl`. - The next smoke (warmups W0–W3 plus one K3 spec arm) is the first run that can reach decode. It is queued on **card 1 under a ttx per-card slot** (`bench/mtp/tp1-card.sh`). It is waiting for MemAvailable to reach 4 GB: tt-quant's GPTQ on card 0 holds 2 GB RSS, and a TP=1 load peaks at ~5.8 GB. If decode fails there, I will report the op, file and line. **TP=2 byte-identity with the TP=1 code: whole-chunk prompts are unchanged; the tail-chunk difference is pre-existing** (`bench/runs/mtp-tp1-tp2identity-20260925.jsonl`). - Every 4096-token prompt is token-identical in all six runs, and code logits are bit-identical. - agentic_edit (2048 + a 47-token tail chunk) repeats exactly when code and harness are identical, but changes with any change to the process: | Overlay | Harness | Output | |---|---|---| | old | old | 8dac | | new | new or old | cb11 | | old | new | a61a | - The harness change adds no device compute op. So the TP=1 code does not cause the difference: the eager partial-chunk prefill is sensitive to perturbation. The suspect is that the last-tile pad rows 47–63 reach the GDN state unmasked. It is not localised yet. - Consequence: the TP=1 vs TP=2 identity gate uses the near-tie rule on every prompt, and bit-exactness is expected only on whole-chunk prompts. Docs moved to the new layout: LEDGER `R-2026-09-25-mtp-g2` and `D-2026-09-25-mtp-tp1`, MTP.md and STATUS.md.
Author
Owner

TP=1 decode on one card now runs: G2 plain and MTP K3/K7 on a single P150a (card 1, ttx per-card slot). Label: TP=1 iteration, not production. Record: bench/runs/mtp-tp1-smoke-20260925T1310Z.jsonl.

Why decode looked broken. No TP=1 run had ever reached decode:

  • The ttx g2-tp1-probe runs are load-only.
  • Every earlier attempt died in the first prefill chunk, before decode.

The root cause of the last failure (attempt 3, 13:08Z):

  • Op: ttnn.experimental.nlp_concat_heads.
  • Call path: tp_common.py:74 concat_heads_fit, called from gdn/tp.py:761 forward_prefill (fused out: rms_norm, then concat heads), called from model.py:1798 _forward_prefill_chunk_tp, during the warmup prefill trace capture.
  • Error: TT_THROW dataflow_buffer.cpp:2617 "static dataflow buffers ... clash with L1 buffers ... L1 buffer at 860160, CB region ends at 898048".
  • Why it happens at TP=1: the head-major GDN norm output (48 heads at TP=1, 24 at TP=2) was held in L1. Every prefill activation the TP code keeps in L1 was tuned at half the TP=1 width.

Fix (commit on mtp-thinkingcap). tp_common.pf_l1() returns DRAM under TP1_L1_FIT (set only at one device) and L1 otherwise. It is applied at every short-lived prefill L1 site:

  • attention: qkv3, the q/kv slices, create-heads, the q/k norm, the gate sigmoid, the wo prefill output;
  • GDN: the FIR conv output, the fused-out norm and concat.

At TP=2 every call returns what it did before. With prefill fixed, decode ran first time, with no decode-side change.

Smoke numbers (B=1, 4096-token prompts, G2, 64 engaged / 1 exempt banners):

Arm ms/token TTFT s Committed per iteration
plain (warmup, 20 tokens) ~80.9 2.9 –
K3 spec, code (timed, 256 tokens) 38.4 4.8 3.92
TP=2 G2, same K3 code arm 27.1 3.7 3.98
  • The K3 iteration breaks down as draft 34.6 + verify 102.7 + reseed 13.1 = 151 ms.
  • DRAM peaks at 24.4 of 33.1 GB.
  • Warm load takes 165 s. Host VmHWM reaches 9.45 GB during load and falls to 48 MB after, so two TP=1 loads have to be staggered.
  • No timed TP=1 plain TG or PP number yet. The 80.9 ms is a 20-token warmup.

Next: gate (c), two TP=1 containers at once (card 0 on the s2a arms, card 1 on the s2b arms, staggered, with watchdog and mem.tsv). That run also supplies the 20-prompt data for gate (a), identity plus logit PCC against TP=2, and gate (b), K3/K7 acceptance. After that comes the solo timed s1 run for TP=1 PP and TG. The pair is queued behind tt-quant's mesh run.

**TP=1 decode on one card now runs: G2 plain and MTP K3/K7 on a single P150a** (card 1, ttx per-card slot). Label: **TP=1 iteration, not production.** Record: `bench/runs/mtp-tp1-smoke-20260925T1310Z.jsonl`. **Why decode looked broken.** No TP=1 run had ever reached decode: - The ttx `g2-tp1-probe` runs are load-only. - Every earlier attempt died in the **first prefill chunk**, before decode. The root cause of the last failure (attempt 3, 13:08Z): - **Op:** `ttnn.experimental.nlp_concat_heads`. - **Call path:** `tp_common.py:74 concat_heads_fit`, called from `gdn/tp.py:761 forward_prefill` (fused out: rms_norm, then concat heads), called from `model.py:1798 _forward_prefill_chunk_tp`, during the warmup prefill trace capture. - **Error:** `TT_THROW dataflow_buffer.cpp:2617 "static dataflow buffers ... clash with L1 buffers ... L1 buffer at 860160, CB region ends at 898048"`. - **Why it happens at TP=1:** the head-major GDN norm output (48 heads at TP=1, 24 at TP=2) was held in L1. Every prefill activation the TP code keeps in L1 was tuned at half the TP=1 width. **Fix** (commit on `mtp-thinkingcap`). `tp_common.pf_l1()` returns DRAM under `TP1_L1_FIT` (set only at one device) and L1 otherwise. It is applied at every short-lived prefill L1 site: - attention: qkv3, the q/kv slices, create-heads, the q/k norm, the gate sigmoid, the wo prefill output; - GDN: the FIR conv output, the fused-out norm and concat. At TP=2 every call returns what it did before. With prefill fixed, **decode ran first time, with no decode-side change.** **Smoke numbers** (B=1, 4096-token prompts, G2, 64 engaged / 1 exempt banners): | Arm | ms/token | TTFT s | Committed per iteration | |---|---:|---:|---:| | plain (warmup, 20 tokens) | ~80.9 | 2.9 | – | | K3 spec, code (timed, 256 tokens) | **38.4** | 4.8 | 3.92 | | TP=2 G2, same K3 code arm | 27.1 | 3.7 | 3.98 | - The K3 iteration breaks down as draft 34.6 + verify 102.7 + reseed 13.1 = 151 ms. - DRAM peaks at 24.4 of 33.1 GB. - Warm load takes 165 s. Host VmHWM reaches 9.45 GB during load and falls to 48 MB after, so two TP=1 loads have to be staggered. - **No timed TP=1 plain TG or PP number yet.** The 80.9 ms is a 20-token warmup. **Next:** gate (c), two TP=1 containers at once (card 0 on the s2a arms, card 1 on the s2b arms, staggered, with watchdog and mem.tsv). That run also supplies the 20-prompt data for gate (a), identity plus logit PCC against TP=2, and gate (b), K3/K7 acceptance. After that comes the solo timed s1 run for TP=1 PP and TG. The pair is queued behind tt-quant's mesh run.
Author
Owner

TP=1 MTP on one P150a: gates measured. G2, B=1, greedy, 256 tokens, ttx per-card slots. Label: TP=1 iteration, not production; rankings stay on TP=2 E2E. Records: bench/runs/mtp-tp1-gates-20260925T1410Z.jsonl (plus .compare.json) on mtp-thinkingcap (d2e9fea). LEDGER R-2026-09-25-mtp-tp1.

Speed (card 1 solo, timed arms with the probe off), ms/token:

Prompt plain TG K3 K7 PP tok/s (plain)
agentic_edit (2,095) 80.4 51.1 40.0 1,144
code (4,096) 80.9 38.1 25.9 1,410
prose_free (4,096) 80.8 58.8 72.8 1,411
qa_reason (4,096) 80.8 49.4 54.4 1,411
  • TP=2 G2 on the same arms: plain 50.6–51.0 ms/token, PP ~3,200 tok/s, K3 code 27.1, K7 code 20.2.
  • Spec-path TTFT is 4.7–5.1 s. A TP=1 verify iteration costs ~103 ms, because the fused verify SDPA falls back at NKV=4.

Gate (b), acceptance: PASSED. Committed tokens per iteration, paired over 20 prompts:

  • K3: TP=1 3.05 vs TP=2 3.01 (Δ +0.04 ± 0.18);
  • K7: 4.54 vs 4.33 (Δ +0.22 ± 0.60).

Gate (c), two TP=1 containers at once: PASSED.

  • Card 0 ran the s2a arms (38) and card 1 the s2b arms (26), all OK, computing together from 13:37 to 13:44Z.
  • MemAvailable stayed ≥ 6,584 MB, and the watchdog (1,536 MB) never fired.
  • VmRSS ≤ 1.35 GB per process after load. VmHWM is ~9.1–9.5 GB during load, so the second card starts only after the first has loaded (bench/mtp/tp1-pair.sh).
  • Caveat: the host had just been rebooted (MemAvailable 10.6 GB at start).

Gate (a), identity vs TP=2: NOT PASSED under the owner rule (control p99: TP=2 1.94, TP=1 1.81).

  • 20 prompts: 1 identical (code, all 256 tokens), 16 near-tie, 3 REAL:
Prompt Position What happens Swing
code.v2 20 TP=2 near-tie 271/198; TP=1 takes 318, TP=2's #3 11.3
code.v5 1 – 3.2
prose_free.v1 58 a top-2 swap 2.6
  • Every divergent token is in the other path's top 3.
  • Logit PCC over 105 dumped rows (4 prompts): median 0.9990, mean 0.9929.
    • Four rows are low: code i=18/22/30 at 0.96/0.85/0.82, agentic i=28 at 0.81.
    • On those rows top-1 agrees (the code tokens are identical throughout), but 25–37k vocab entries scatter by more than 2 logits, up to 9.9.
    • The rows are sporadic in position, with no chunk or bucket boundary.
  • TP=1 vs TP=2 top-2-margin noise: p99 2.44, above TP=2's own fused/unfused control at 1.94.
  • Not localised. One hypothesis is that TP=2 rounds partials to bf16 at each of ~192 row-parallel all-reduces per token while TP=1 accumulates the full K. That is unproven, and it does not explain the sporadic tail rows. Deciding which path is closer to the truth needs a reference forward or a layer-by-layer hidden-state diff at code i=22.

TP=2 byte-identity with this code: whole-chunk prompts are bit-identical. The 2048+47 tail-chunk prompt varies with any process change, including the old overlay with a new harness. That is pre-existing (bench/runs/mtp-tp1-tp2identity-20260925.jsonl).

Code changes this round (all gated to one device):

  • tp_common.pf_l1(): DRAM for short-lived prefill activations that TP=2 keeps in L1. This is the fix that got prefill through and let decode run.
  • run-mtp-ab.sh: SLOT_OWNER mode.
  • tp1-card.sh and tp1-pair.sh: the ttx-slot drivers.

Docs updated: LEDGER, MTP.md, STATUS.md, and the mtp-tt and tt-card-slot skills.

**TP=1 MTP on one P150a: gates measured.** G2, B=1, greedy, 256 tokens, ttx per-card slots. Label: **TP=1 iteration, not production**; rankings stay on TP=2 E2E. Records: `bench/runs/mtp-tp1-gates-20260925T1410Z.jsonl` (plus `.compare.json`) on `mtp-thinkingcap` (d2e9fea). LEDGER `R-2026-09-25-mtp-tp1`. **Speed** (card 1 solo, timed arms with the probe off), ms/token: | Prompt | plain TG | K3 | K7 | PP tok/s (plain) | |---|---:|---:|---:|---:| | agentic_edit (2,095) | 80.4 | 51.1 | 40.0 | 1,144 | | code (4,096) | 80.9 | 38.1 | 25.9 | 1,410 | | prose_free (4,096) | 80.8 | 58.8 | 72.8 | 1,411 | | qa_reason (4,096) | 80.8 | 49.4 | 54.4 | 1,411 | - TP=2 G2 on the same arms: plain 50.6–51.0 ms/token, PP ~3,200 tok/s, K3 code 27.1, K7 code 20.2. - Spec-path TTFT is 4.7–5.1 s. A TP=1 verify iteration costs ~103 ms, because the fused verify SDPA falls back at NKV=4. **Gate (b), acceptance: PASSED.** Committed tokens per iteration, paired over 20 prompts: - K3: TP=1 3.05 vs TP=2 3.01 (Δ +0.04 ± 0.18); - K7: 4.54 vs 4.33 (Δ +0.22 ± 0.60). **Gate (c), two TP=1 containers at once: PASSED.** - Card 0 ran the s2a arms (38) and card 1 the s2b arms (26), all OK, computing together from 13:37 to 13:44Z. - MemAvailable stayed ≥ 6,584 MB, and the watchdog (1,536 MB) never fired. - VmRSS ≤ 1.35 GB per process after load. VmHWM is ~9.1–9.5 GB during load, so the second card starts only after the first has loaded (`bench/mtp/tp1-pair.sh`). - Caveat: the host had just been rebooted (MemAvailable 10.6 GB at start). **Gate (a), identity vs TP=2: NOT PASSED** under the owner rule (control p99: TP=2 1.94, TP=1 1.81). - 20 prompts: 1 identical (code, all 256 tokens), 16 near-tie, 3 REAL: | Prompt | Position | What happens | Swing | |---|---|---|---| | code.v2 | 20 | TP=2 near-tie 271/198; TP=1 takes 318, TP=2's #3 | 11.3 | | code.v5 | 1 | – | 3.2 | | prose_free.v1 | 58 | a top-2 swap | 2.6 | - Every divergent token is in the other path's top 3. - Logit PCC over 105 dumped rows (4 prompts): median 0.9990, mean 0.9929. - Four rows are low: code i=18/22/30 at 0.96/0.85/0.82, agentic i=28 at 0.81. - On those rows top-1 agrees (the code tokens are identical throughout), but 25–37k vocab entries scatter by more than 2 logits, up to 9.9. - The rows are sporadic in position, with no chunk or bucket boundary. - TP=1 vs TP=2 top-2-margin noise: p99 2.44, above TP=2's own fused/unfused control at 1.94. - Not localised. One hypothesis is that TP=2 rounds partials to bf16 at each of ~192 row-parallel all-reduces per token while TP=1 accumulates the full K. That is unproven, and it does not explain the sporadic tail rows. Deciding which path is closer to the truth needs a reference forward or a layer-by-layer hidden-state diff at code i=22. **TP=2 byte-identity with this code:** whole-chunk prompts are bit-identical. The 2048+47 tail-chunk prompt varies with any process change, including the old overlay with a new harness. That is pre-existing (`bench/runs/mtp-tp1-tp2identity-20260925.jsonl`). **Code changes this round** (all gated to one device): - `tp_common.pf_l1()`: DRAM for short-lived prefill activations that TP=2 keeps in L1. This is the fix that got prefill through and let decode run. - `run-mtp-ab.sh`: `SLOT_OWNER` mode. - `tp1-card.sh` and `tp1-pair.sh`: the ttx-slot drivers. Docs updated: LEDGER, MTP.md, STATUS.md, and the mtp-tt and tt-card-slot skills.
Sign in to join this conversation.
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#63
No description provided.