Skip to content

Commit c4ff125

Browse files
physicsrobclaude
andcommitted
attn: fix forward_cached mask for n_new>1 with non-empty past
Previously is_causal=(n_new == n_total) made every new row attend to every other new row when there was a past — silently wrong for speculative decoding's K+1 batched step. New regime: explicit (n_new, n_total) bool mask, full-visible past columns and lower-triangular new-row block. Pinned by a row-by-row bit-exact regression test against the sequential rollout. step() docstring documents the three regimes and the partial-commit slice pattern callers use to land K+1 spec-decode batches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9a6d2a7 commit c4ff125

3 files changed

Lines changed: 153 additions & 15 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Batched decode with non-empty past — the speculative-decoding shape.
2+
3+
When ``forward_cached`` is called with ``n_new > 1`` and a non-empty past
4+
(e.g. K+1 batched rows in a spec-decode step), each new row must:
5+
6+
1. Attend unconditionally to every past key — those are verified history.
7+
2. Attend causally among new rows — row ``i`` sees new rows ``0..i`` only.
8+
9+
A bug in either direction silently corrupts the rollout. This test pins
10+
both: drive a sequential per-row rollout to length L, then run one
11+
batched ``K+1``-row decode from past_len=L and check row-by-row that each
12+
batched-row output equals the corresponding sequential-step output.
13+
"""
14+
15+
import pytest
16+
import torch
17+
18+
from torchwright.compiler.forward.compile import forward_compile
19+
20+
D = 1024
21+
D_HEAD = 16
22+
23+
24+
@pytest.fixture(scope="module")
25+
def compiled_calc():
26+
from examples.calculator_v2 import create_network_parts
27+
28+
output_node, pos_encoding, embedding = create_network_parts(1)
29+
net = forward_compile(
30+
d=D,
31+
d_head=D_HEAD,
32+
output_node=output_node,
33+
pos_encoding=pos_encoding,
34+
verbose=False,
35+
)
36+
return net, output_node, embedding
37+
38+
39+
def _row_input(net, tokens, t):
40+
"""Build the t-th row of the input residual stream for ``tokens``."""
41+
full_res = net.get_input_res_stream(t + 1, {"embedding_input": tokens[: t + 1]})
42+
return full_res[t : t + 1].to(net.device)
43+
44+
45+
def test_batched_decode_with_past_matches_sequential(compiled_calc):
46+
"""K+1 batched rows from past_len=L equal L+1..L+K+1 sequential rows."""
47+
net, _, _ = compiled_calc
48+
tokens = ["<bos"] + list("3+5+9\n")
49+
seed_len = 3 # past_len at the start of the batched step
50+
batch_size = len(tokens) - seed_len # K+1 rows in the batched step
51+
assert batch_size >= 2, "test requires n_new >= 2 to exercise the new mask"
52+
53+
# Stage 1: sequential rollout up to seed_len, snapshot the past.
54+
kvs = None
55+
for t in range(seed_len):
56+
kvs, _ = _step_one(net, tokens, t, kvs)
57+
past_kvs_seed = [(K.clone(), V.clone()) for (K, V) in kvs]
58+
59+
# Stage 2a: continue sequentially to capture per-row reference outputs.
60+
seq_outputs = []
61+
kvs_seq = [(K.clone(), V.clone()) for (K, V) in past_kvs_seed]
62+
for t in range(seed_len, len(tokens)):
63+
kvs_seq, out = _step_one(net, tokens, t, kvs_seq)
64+
seq_outputs.append(out.detach().clone())
65+
66+
# Stage 2b: run the same suffix as one batched call from the snapshot.
67+
suffix_inp = torch.cat(
68+
[_row_input(net, tokens, t) for t in range(seed_len, len(tokens))],
69+
dim=0,
70+
)
71+
batched_out, _ = net.forward_cached(suffix_inp, past_kvs_seed)
72+
73+
# Bit-exact row-by-row equality. SDPA forces the MATH backend (see
74+
# _SDPA_BACKEND in components/attn.py), and the matmul shapes for a
75+
# given row are identical in the two paths once the mask is correct.
76+
for i, expected in enumerate(seq_outputs):
77+
actual = batched_out[i : i + 1]
78+
diff = (actual - expected).abs().max().item()
79+
assert diff == 0.0, (
80+
f"row {i}: sequential vs batched diverged by {diff}; "
81+
f"expected bit-exact equality"
82+
)
83+
84+
85+
def _step_one(net, tokens, t, kvs):
86+
"""Run one cached single-row forward and return (new_kvs, output)."""
87+
new_inp = _row_input(net, tokens, t)
88+
out, new_kvs = net.forward_cached(new_inp, kvs)
89+
return new_kvs, out

torchwright/compiler/components/attn.py

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -106,23 +106,44 @@ def forward_cached(
106106

107107
n_new = inp.shape[0]
108108
n_total = K.shape[1]
109-
110-
# is_causal=True → pure prefill (no past): local indices == absolute
111-
# positions; upper-triangular mask is correct.
112-
# is_causal=False → decode (has past): all K entries are strictly in
113-
# the past relative to Q; no future positions to mask.
109+
n_past = n_total - n_new
110+
111+
# Three regimes:
112+
# pure prefill (no past, n_new == n_total): is_causal=True gives
113+
# the standard upper-triangular mask.
114+
# single-step decode (n_new == 1, n_past > 0): every K entry is in
115+
# the past or is the lone new row, so no mask is needed.
116+
# batched decode with past (n_new > 1 and n_past > 0): every new
117+
# row sees the full past unconditionally, but among new rows the
118+
# mask is lower-triangular (row i sees new rows 0..i). This is
119+
# the speculative-decoding shape — is_causal=True would mask out
120+
# past columns for early rows; is_causal=False would let row 0
121+
# attend to future new rows.
114122
# scale=1.0 preserves the raw dot-product magnitudes that all attention
115123
# weights were compiled against (no 1/sqrt(d_head) rescaling).
116-
with sdpa_kernel(_SDPA_BACKEND):
117-
weighted = F.scaled_dot_product_attention(
118-
Q.unsqueeze(0),
119-
K.unsqueeze(0),
120-
V.unsqueeze(0),
121-
is_causal=(n_new == n_total),
122-
scale=1.0,
123-
).squeeze(
124-
0
125-
) # (n_heads, n_new, d_head)
124+
if n_new > 1 and n_past > 0:
125+
past_visible = torch.ones(n_new, n_past, dtype=torch.bool, device=Q.device)
126+
new_block = torch.ones(
127+
n_new, n_new, dtype=torch.bool, device=Q.device
128+
).tril()
129+
attn_mask = torch.cat([past_visible, new_block], dim=1)
130+
with sdpa_kernel(_SDPA_BACKEND):
131+
weighted = F.scaled_dot_product_attention(
132+
Q.unsqueeze(0),
133+
K.unsqueeze(0),
134+
V.unsqueeze(0),
135+
attn_mask=attn_mask,
136+
scale=1.0,
137+
).squeeze(0)
138+
else:
139+
with sdpa_kernel(_SDPA_BACKEND):
140+
weighted = F.scaled_dot_product_attention(
141+
Q.unsqueeze(0),
142+
K.unsqueeze(0),
143+
V.unsqueeze(0),
144+
is_causal=(n_new == n_total),
145+
scale=1.0,
146+
).squeeze(0) # (n_heads, n_new, d_head)
126147

127148
output = torch.einsum("hpk,hkd->pd", weighted, self.output_matrix)
128149

torchwright/compiler/export.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,34 @@ def step(
11761176
) -> tuple:
11771177
"""Cached forward step.
11781178
1179+
Supports three ``n_new`` regimes uniformly:
1180+
1181+
* Pure prefill (``past`` empty, ``n_new == seq_len``).
1182+
* Single-step decode (``n_new == 1`` with a non-empty ``past``).
1183+
* **Batched decode with past** (``n_new > 1`` with a non-empty
1184+
``past``) — the speculative-decoding shape. Each new row sees
1185+
the entire past unconditionally, plus a lower-triangular slice
1186+
of the new block (row ``i`` sees new rows ``0..i``). Row ``i``
1187+
of the returned outputs is bit-identical to the same row from
1188+
a sequential rollout starting from the same past.
1189+
1190+
Speculative-decoding partial-commit pattern. After running with
1191+
``n_new = K + 1`` (one current input + ``K`` drafts + bonus), a
1192+
caller may decide to commit only the first ``commit_count``
1193+
rows. Slice the returned ``new_past`` directly::
1194+
1195+
outputs, (new_K, new_V) = compiled.step(inputs_batch, past)
1196+
# ... compare outputs[:K] to the K drafts, decide commit_count ...
1197+
target_len = past[0][0].shape[1] + commit_count
1198+
committed = (
1199+
tuple(K[:, :target_len] for K in new_K),
1200+
tuple(V[:, :target_len] for V in new_V),
1201+
)
1202+
1203+
``committed`` is the past for the next step. The discarded rows
1204+
(``commit_count..n_new``) carry no state outside ``new_past``,
1205+
so the slice is the only commit primitive needed.
1206+
11791207
Args:
11801208
inputs: ``(n_new, d_input)`` float tensor for the new rows.
11811209
past: ``(past_K_tuple, past_V_tuple)`` from a prior step or

0 commit comments

Comments
 (0)