Skip to content

Commit b192865

Browse files
physicsrobclaude
andcommitted
Redesign PosEncoding: counter in its own column, exact 1.0 query constant
Move the raw position counter out of the slowest-sin slot into its own last column (counter_col = d_pos-1), leaving a complete sin/cos block (trig_width = d_pos-1) and requiring odd d_pos. This decouples the encoding width from d_head and removes the slow-cosine "almost one" that several attention primitives leaned on. - pos_encoding: add trig_width/trig_slice/counter_col; reject even d_pos; rename get_pos_delta_matrix -> trig_shift_matrix (row convention, key matrix numerically identical to before); drop slow_sin_freq and the dead get_position_scalar max_pos arg. - get_prev_value: rebuild on an exact LiteralValue([1.0]) query plus raw-counter recency, documenting the TF32 max_pos=256 budget. The old query "constant" was the sum of every sinusoid at the query position, which is negative at ~19% of the first 2048 positions and inverted the recency ordering there. - attention_ops: switch all query-side constants from the slow cosine (now overwritten by the counter) to LiteralValue([1.0]); shrink the selection heads' d_qk to 1-2 so it no longer scales with d_pos, and auto-split value pass-through (drop the len(value) <= d_pos cap). - compiler: auto-create PosEncoding(17); validate d_head >= trig_width early with an actionable error; copy heads zero the counter column explicitly (trig-only self-match); affine rule reads counter_col. create_pos_encoding now returns PosEncoding(17) (16 trig columns + 1 counter), whose trig_width matches the default d_head=16. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 101c3eb commit b192865

17 files changed

Lines changed: 403 additions & 306 deletions

tests/compile/forward/test_compiler_assertions.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def test_C_literal_write_rejects_extra_target_cols():
125125
def test_B_attn_rejects_v_source_cols_wrong_length():
126126
"""_write_compute_attn raises when V source_cols length doesn't match
127127
value_in width."""
128-
pos = PosEncoding(d_pos=D_HEAD)
128+
pos = PosEncoding(17)
129129
v_in = InputNode("v", 4, value_range=(-100.0, 100.0))
130130
node = Attn(
131131
query_in=pos,
@@ -155,7 +155,7 @@ def test_B_attn_rejects_v_source_cols_wrong_length():
155155

156156

157157
def test_B_attn_rejects_q_source_cols_wrong_length():
158-
pos = PosEncoding(d_pos=D_HEAD)
158+
pos = PosEncoding(17)
159159
v_in = InputNode("v", 4, value_range=(-100.0, 100.0))
160160
node = Attn(
161161
query_in=pos,
@@ -198,7 +198,7 @@ def test_A_end_of_layer_catches_freed_too_early(monkeypatch):
198198
weight = torch.eye(4, 4)
199199
bias = torch.zeros(4)
200200
l = Linear(x, weight, bias)
201-
pos = PosEncoding(d_pos=D_HEAD)
201+
pos = PosEncoding(17)
202202

203203
graph = GraphAnalyzer(l)
204204
rmap = ResidualStreamMap(D)
@@ -225,7 +225,7 @@ def test_A_require_live_raises_for_unallocated_input():
225225
op context before the downstream KeyError."""
226226
from torchwright.compiler.forward.scheduler import LayerScheduler
227227

228-
pos = PosEncoding(d_pos=D_HEAD)
228+
pos = PosEncoding(17)
229229
x = InputNode("x", 4, value_range=(-100.0, 100.0))
230230
weight = torch.eye(4, 4)
231231
bias = torch.zeros(4)
@@ -275,7 +275,7 @@ def test_scheduler_pins_never_marks_pinned_dead():
275275
even when its effective consumers have all been computed."""
276276
from torchwright.compiler.forward.scheduler import LayerScheduler
277277

278-
pos = PosEncoding(d_pos=D_HEAD)
278+
pos = PosEncoding(17)
279279
x = InputNode("x", 4, value_range=(-100.0, 100.0))
280280
weight = torch.eye(4, 4)
281281
bias = torch.zeros(4)
@@ -306,22 +306,25 @@ def test_delta_transfer_guard_catches_reallocation():
306306
_verify_overlay_target_protection,
307307
)
308308

309-
pos = PosEncoding(d_pos=D_HEAD)
309+
pos = PosEncoding(17)
310310
x = InputNode("x", 4, value_range=(-1.0, 1.0))
311311
bait = InputNode("bait", 1, value_range=(-1.0, 1.0))
312312
weight = torch.eye(4, 4)
313313
bias = torch.zeros(4)
314314
y = Linear(x, weight, bias)
315315

316316
rmap = ResidualStreamMap(D)
317-
rmap.allocate(pos) # [0..15]
318-
rmap.allocate(x) # [16..19]
319-
rmap.allocate(bait) # [20]
317+
rmap.allocate(pos)
318+
rmap.allocate(x)
319+
rmap.allocate(bait)
320+
bait_col = rmap.get_indices(bait)[0]
320321

321-
# Poison: overlay's target col 20 is owned by `bait`, which is neither
322+
# Poison: overlay's target col is owned by `bait`, which is neither
322323
# pos_encoding nor a pinned input. The guard must fire.
323-
overlays = {y: (x, [20])}
324-
with pytest.raises(AssertionError, match=r"Overlay target column 20 is owned by"):
324+
overlays = {y: (x, [bait_col])}
325+
with pytest.raises(
326+
AssertionError, match=rf"Overlay target column {bait_col} is owned by"
327+
):
325328
_verify_overlay_target_protection(
326329
overlays, rmap, pos_encoding=pos, overlay_pinned_inputs=set()
327330
)

tests/compile/forward/test_cpsat_chain_overlap.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def test_chain_detector_no_overlap_after_fusion():
6969
f"{L_b.inputs[0]!r}"
7070
)
7171

72-
pos = PosEncoding(8)
72+
pos = PosEncoding(9)
7373
gm = build_graph_model(L_c, pos)
7474

7575
# The structural assertion in build_graph_model would have raised
@@ -98,7 +98,7 @@ def test_chain_detector_picks_upstream_chain():
9898
x, L_pre, R, L_a, L_b, R_prime, L_c = _build_post_fusion_repro()
9999
fuse_consecutive_linears({L_c})
100100

101-
pos = PosEncoding(8)
101+
pos = PosEncoding(9)
102102
gm = build_graph_model(L_c, pos)
103103

104104
# Exactly one chain — the upstream one. The downstream chain
@@ -119,7 +119,7 @@ def test_cpsat_solves_post_fusion_repro():
119119
x, L_pre, R, L_a, L_b, R_prime, L_c = _build_post_fusion_repro()
120120
fuse_consecutive_linears({L_c})
121121

122-
pos = PosEncoding(8)
122+
pos = PosEncoding(9)
123123
assignment, stats = solve_schedule(
124124
L_c, pos,
125125
d=64, d_head=8, d_hidden=128,
@@ -148,7 +148,7 @@ def test_heuristic_chain_detector_no_overlap_after_fusion():
148148
x, L_pre, R, L_a, L_b, R_prime, L_c = _build_post_fusion_repro()
149149
fuse_consecutive_linears({L_c})
150150

151-
pos = PosEncoding(8)
151+
pos = PosEncoding(9)
152152
graph = GraphAnalyzer(L_c)
153153
scheduler = LayerScheduler(graph, d=64, d_head=8, pos_encoding=pos)
154154
# Build a ready set that includes both L_pre and L_b — the case

tests/compile/forward/test_d_hidden_decoupling.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@
1111
from torchwright.compiler.export import compile_headless
1212
from torchwright.compiler.forward.compile import forward_compile
1313
from torchwright.compiler.groups.mlp_sublayer import MLPSubLayer
14-
from torchwright.ops.inout_nodes import create_input, create_pos_encoding
14+
from torchwright.graph import PosEncoding
15+
from torchwright.ops.inout_nodes import create_input
1516
from torchwright.ops.linear_relu_linear import linear_relu_linear
1617

1718

1819
def _build_relu_chain_graph(d_input: int, d_hidden_chain: int, d_output: int):
1920
"""Build a tiny graph: input -> Linear -> ReLU -> Linear with random weights."""
2021
inp = create_input("x", d_input)
21-
pos = create_pos_encoding()
22+
# These tests compile at d_head=8, so the encoding's trig_width must be 8.
23+
pos = PosEncoding(9)
2224
torch.manual_seed(0)
2325
input_proj = torch.randn(d_hidden_chain, d_input)
2426
input_bias = torch.randn(d_hidden_chain)

tests/compile/forward/test_forward_compile.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,8 @@ def test_compile_split_vo(d_qk, d_v):
602602

603603
def test_compile_split_vo_large_ratio():
604604
"""d_v=64, d_head=8 — 8 V/O heads with tiny d_qk=2."""
605-
pos = create_pos_encoding()
605+
# d_head=8 requires trig_width <= 8, i.e. PosEncoding(9).
606+
pos = PosEncoding(9)
606607
x = create_input("x", 8)
607608
out = _build_attn(x, x, x, d_qk=2, d_v=64, d_out=8)
608609

@@ -622,6 +623,23 @@ def test_compile_split_vo_large_ratio():
622623
), f"Max diff: {(actual.cpu() - expected).abs().max().item():.6f}"
623624

624625

626+
def test_compile_rejects_d_head_below_trig_width():
627+
"""forward_compile rejects d_head < pos_encoding.trig_width early, before
628+
the copy heads would silently match on only part of the trig grid."""
629+
pos = PosEncoding(17) # trig_width = 16
630+
x = create_input("x", 4)
631+
out = pos.attend_to_offset(x, delta_pos=-1)
632+
with pytest.raises(ValueError, match="trig_width"):
633+
forward_compile(
634+
d=256,
635+
d_head=8,
636+
output_node=out,
637+
pos_encoding=pos,
638+
verbose=False,
639+
max_layers=10,
640+
)
641+
642+
625643
def test_compile_dqk_equals_dv_unchanged():
626644
"""d_qk == d_v == 8 — backward compat, single head, padded to d_head=16."""
627645
x = create_input("x", 8)

tests/compile/forward/test_scheduler.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232

3333
def _make_pos_encoding():
34-
return PosEncoding(d_pos=D_HEAD)
34+
return PosEncoding(17) # trig_width 16 == D_HEAD
3535

3636

3737
def _make_linear(inp, d_out, name=""):
@@ -476,14 +476,14 @@ def test_mlp_slot_exhaustion():
476476
def test_schedule_under_column_pressure():
477477
"""Stream full with dead nodes: scheduler cancels to make room for new computes.
478478
479-
Setup: D=64, pos=16, filler=40, x=4, a=4 → 0 free.
479+
Setup: D=64, pos=17, filler=39, x=4, a=4 → 0 free.
480480
x is dead (consumer a is computed). Relu chain needs 3 output cols.
481481
Scheduler must cancel x to free space, then schedule the chain.
482482
"""
483483
pos = _make_pos_encoding()
484484
filler = InputNode(
485-
"filler", D - D_HEAD - 8, value_range=(-100.0, 100.0)
486-
) # 40 cols, not in graph
485+
"filler", D - len(pos) - 8, value_range=(-100.0, 100.0)
486+
) # fills the stream to 0 free alongside pos + x + a
487487
x = InputNode("x", 4, value_range=(-100.0, 100.0))
488488
a = _make_linear(x, 4, "a")
489489
l2, r, l1 = _make_relu_chain(a, 8, 3, "out")
@@ -882,9 +882,9 @@ def test_no_progress_raises_error():
882882

883883
graph = GraphAnalyzer(out)
884884
# Tiny d: just enough for pos + a + b, nothing spare
885-
small_d = D_HEAD + 4 + 4 # 24
885+
small_d = len(pos) + 4 + 4 # 17 + 4 + 4
886886
rmap = ResidualStreamMap(small_d)
887-
rmap.allocate(pos) # 16 cols
887+
rmap.allocate(pos) # len(pos) cols
888888
rmap.allocate(a) # 4 cols
889889
rmap.allocate(b) # 4 cols
890890
assert rmap.get_free_count() == 0

tests/compile/forward/test_weight_writer.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737

3838
def _make_pos_encoding():
39-
return PosEncoding(d_pos=D_HEAD)
39+
return PosEncoding(17) # trig_width 16 == D_HEAD
4040

4141

4242
def _build_residual_stream(
@@ -264,11 +264,13 @@ def test_attn_compute_multiposition():
264264
cond_in = InputNode("c", 1, value_range=(-100.0, 100.0))
265265

266266
attn_node = pos.get_prev_value(value_in, cond_in)
267+
query_one = attn_node.inputs[0] # LiteralValue([1.0]) query constant
267268

268269
rmap = ResidualStreamMap(D)
269270
rmap.allocate(pos)
270271
rmap.allocate(value_in)
271272
rmap.allocate(cond_in)
273+
rmap.allocate(query_one)
272274
out_cols = rmap.allocate(attn_node)
273275

274276
layer = TransformerLayer(D, D_HEAD, pos)
@@ -287,15 +289,15 @@ def test_attn_compute_multiposition():
287289
c_values = torch.tensor([[1.0], [0.0], [0.0], [1.0]])
288290
pe_values = pos.compute(N_POS, {})
289291

290-
# get_prev_value needs Concatenate([pos, cond]) as key_in
291-
# We need to also place the concatenated node's children
292-
concat_node = attn_node.inputs[1] # key_in is Concatenate([pos, cond])
292+
# get_prev_value's key_in is Concatenate([cond, pos]); the query is an
293+
# exact 1.0 literal. Place every leaf/constant the head reads.
293294
res = _build_residual_stream(
294295
rmap,
295296
{
296297
pos: pe_values,
297298
value_in: v_values,
298299
cond_in: c_values,
300+
query_one: query_one.compute(N_POS, {}),
299301
},
300302
)
301303

tests/graph/test_affine_bounds.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -724,16 +724,16 @@ def test_pos_encoding_identity_bound(self):
724724
"""PosEncoding produces an identity A-matrix with [-1, 1] per column."""
725725
from torchwright.graph import PosEncoding
726726

727-
pe = PosEncoding(d_pos=8)
727+
pe = PosEncoding(d_pos=9)
728728
ab = pe.affine_bound
729729
assert ab.n_cols > 0, "PosEncoding must have non-degenerate bound"
730-
assert ab.d_output == 8
730+
assert ab.d_output == 9
731731
assert pe.node_id in ab.columns
732732
assert pe.node_id in ab.input_ranges
733-
assert torch.equal(ab.A_lo, torch.eye(8, dtype=torch.float64))
733+
assert torch.equal(ab.A_lo, torch.eye(9, dtype=torch.float64))
734734
intervals = ab.to_interval()
735735
for i, iv in enumerate(intervals):
736-
if i == 6: # d_pos - 2: raw position counter
736+
if i == pe.counter_col: # last column: raw position counter
737737
assert iv.lo == pytest.approx(0.0)
738738
assert iv.hi == pytest.approx(100000.0)
739739
else:
@@ -746,14 +746,14 @@ def test_attn_propagates_value_range(self):
746746
with fresh_graph_session():
747747
from torchwright.graph import PosEncoding, Attn
748748

749-
pe = PosEncoding(d_pos=8)
749+
pe = PosEncoding(d_pos=9)
750750
value = LiteralValue(torch.tensor([2.0, 3.0]))
751751
attn = Attn(
752752
query_in=pe,
753753
key_in=pe,
754754
value_in=value,
755-
query_matrix=torch.eye(8, 2),
756-
key_matrix=torch.eye(8, 2),
755+
query_matrix=torch.eye(9, 2),
756+
key_matrix=torch.eye(9, 2),
757757
value_matrix=torch.eye(2),
758758
output_matrix=torch.eye(2),
759759
)

tests/graph/test_pos_encoding.py

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,42 @@
11
from torchwright.graph import PosEncoding, InputNode
2-
from torchwright.graph.pos_encoding import get_pos_delta_matrix
2+
from torchwright.graph.pos_encoding import trig_shift_matrix
3+
import pytest
34
import torch
45

56
atol = 1.0e-4 # Absolute tolerance for comparing tensors
67

7-
d_pos = 32
8+
d_pos = 33
89
N_pos = 16
910

1011
pos_encoding_node = PosEncoding(d_pos)
1112
pos_encoding = pos_encoding_node.compute(n_pos=N_pos, input_values={})
1213

1314

15+
def test_layout_properties():
16+
pe = PosEncoding(17)
17+
assert pe.trig_width == 16
18+
assert pe.counter_col == 16
19+
assert pe.trig_slice == slice(0, 16)
20+
21+
22+
def test_even_d_pos_rejected():
23+
with pytest.raises(ValueError):
24+
PosEncoding(16)
25+
26+
27+
def test_counter_column_is_exact_position():
28+
counter = pos_encoding[:, pos_encoding_node.counter_col]
29+
assert torch.allclose(counter, torch.arange(N_pos, dtype=counter.dtype))
30+
31+
1432
def test_get_prev_value():
1533
input_values = torch.tensor([[1.0], [2.0], [3.0], [4.0], [5.0]])
1634
cond_values = torch.tensor([[1.0], [0.0], [0.0], [1.0], [0.0]])
1735
expected_prev_values = torch.tensor([[1.0], [1.0], [1.0], [4.0], [4.0]])
1836

1937
value_input = InputNode("value", 1, value_range=(-100.0, 100.0))
2038
cond_input = InputNode("cond", 1, value_range=(-100.0, 100.0))
21-
pos_encoding = PosEncoding(16)
39+
pos_encoding = PosEncoding(17)
2240
last_input = pos_encoding.get_prev_value(value_input, cond_input)
2341
output = last_input.compute(
2442
n_pos=5, input_values={"value": input_values, "cond": cond_values}
@@ -29,7 +47,7 @@ def test_get_prev_value():
2947
def test_attend_to_offset():
3048
input_values = torch.tensor([[1.0], [2.0], [3.0], [4.0], [5.0]])
3149
value_input = InputNode("value", 1, value_range=(-100.0, 100.0))
32-
pos_encoding = PosEncoding(16)
50+
pos_encoding = PosEncoding(17)
3351
last_input = pos_encoding.attend_to_offset(value_input, delta_pos=-1)
3452
output = last_input.compute(n_pos=5, input_values={"value": input_values})
3553
assert torch.allclose(output[0], input_values[0])
@@ -39,35 +57,31 @@ def test_attend_to_offset():
3957
def test_attend_to_offset_value_can_be_wider_than_position_encoding():
4058
input_values = torch.arange(5 * 20, dtype=torch.float32).reshape(5, 20)
4159
value_input = InputNode("value", 20, value_range=(-100.0, 100.0))
42-
pos_encoding = PosEncoding(16)
60+
pos_encoding = PosEncoding(17)
4361
last_input = pos_encoding.attend_to_offset(value_input, delta_pos=-1)
4462
output = last_input.compute(n_pos=5, input_values={"value": input_values})
4563
assert torch.allclose(output[0], input_values[0])
4664
assert torch.allclose(output[1:], input_values[:-1])
4765

4866

49-
def test_delta_matrix():
50-
# The last pair (d_pos-2, d_pos-1) contains the counter — exclude it.
51-
s = d_pos - 2
52-
delta_matrix = get_pos_delta_matrix(delta_pos=1, d=d_pos)[:s, :s]
53-
sin_part = pos_encoding[:, :s]
54-
delta_pos_encoding = sin_part @ delta_matrix
55-
assert torch.allclose(delta_pos_encoding[1:, :], sin_part[:-1, :], atol=atol)
67+
def _check_shift(k: int):
68+
# Row-convention shift over the trig block: trig(pos) @ S == trig(pos+k).
69+
tw = pos_encoding_node.trig_width
70+
trig = pos_encoding[:, :tw]
71+
shifted = trig @ trig_shift_matrix(k, tw)
72+
if k >= 0:
73+
assert torch.allclose(shifted[: N_pos - k], trig[k:], atol=atol)
74+
else:
75+
assert torch.allclose(shifted[-k:], trig[: N_pos + k], atol=atol)
76+
5677

78+
def test_trig_shift_matrix_pos1():
79+
_check_shift(1)
5780

58-
def test_delta_matrix2():
59-
s = d_pos - 2
60-
delta_matrix = get_pos_delta_matrix(delta_pos=2, d=d_pos)[:s, :s]
61-
sin_part = pos_encoding[:, :s]
62-
delta_pos_encoding = sin_part @ delta_matrix
63-
assert torch.allclose(delta_pos_encoding[2:, :], sin_part[:-2, :], atol=atol)
6481

82+
def test_trig_shift_matrix_pos2():
83+
_check_shift(2)
6584

66-
def test_delta_matrix_neg():
67-
s = d_pos - 2
68-
delta_matrix = get_pos_delta_matrix(delta_pos=-1, d=d_pos)[:s, :s]
69-
sin_part = pos_encoding[:, :s]
70-
delta_pos_encoding = sin_part @ delta_matrix
71-
assert torch.allclose(delta_pos_encoding[:-1, :], sin_part[1:, :], atol=atol)
7285

73-
assert torch.allclose(delta_pos_encoding[1], sin_part[2], atol=atol)
86+
def test_trig_shift_matrix_neg1():
87+
_check_shift(-1)

0 commit comments

Comments
 (0)