Skip to content

Commit 71a94c8

Browse files
physicsrobclaude
andcommitted
fix(ops): cancellation-free table-lookup builders (min-clamp + saturating steps)
The map_select table-lookup builders _table_lookup_row_vector / _table_lookup_column_mask / _table_lookup_index_staircase built a single piecewise_linear over 2*(n-1)+1 breakpoints with input_scale=sharpness -- the same single-projection sum delta*relu(s*x - b) floor_int had. The partial sums grow to ~s*x*n and overflow fp32's 2^24 exact-integer limit, so the alternating-sign terms cancel into garbage. Two triggers: (a) a scaled index far outside [0,n-1] pushes sharpness*index past 2^24; (b) a tall table's top breakpoint position passes 2^24. _table_lookup_index_staircase(n=256, s=100) at x=100000 returned 0.0 (should clamp to 255 -- the index was never clamped before the staircase); n=20000, s=1000 collapsed an in-range x=19999 to 0.0. Dormant at current call sites (small tables, in-range pre-rounded indices) but a landmine for the DOOM texture/palette lookup track's larger tables. Rebuild all three on a shared _saturating_step_select helper, the table analogue of floor_int's saturating-step staircase: out = top - sum_k relu(1 - step_k) * deltas[k-1], step_k = relu(t_k) - relu(t_k - W), t_k = s*(x - (k-0.5)) + 0.5, rows reconstructed from consecutive differences (deltas[k-1] = value[k] - value[k-1], value[n-1] = top). Each step is a 2-term per-output difference, so the accumulation's partial sums are bounded by the table's total variation, not by s*x -- removing the cancellation regardless of n (trigger b). A leading min(index, n-1) clamp (one sublayer, (n-1) - relu((n-1) - x), a single ReLU of a magnitude, no cancellation) bounds the out-of-range case so W -- sized for the in-range span -- always resolves the saturating difference (trigger a); the lower edge needs no clamp. The +0.5 centers the ramp on the boundary, reproducing the old two-row linear blend in the transition band, so off-grid values are unchanged. Cost: one min sublayer + the two chained ReLU sublayers of the staircase, vs the old single sublayer. Reproducer tests at the smallest layer (test_table_lookup_2d_out_of_range_index_clamps_to_edge, _tall_table_stays_exact, _index_staircase_clamps_and_rounds_at_any_magnitude, _3d_large_flattened_rows_exact); existing table_lookup tests (incl transition band + compile-fidelity) stay green. table_lookup_2d/_3d are not measured ops so op_noise_data.json is unchanged; docs/numerical_noise_findings.md "Known gap" -> "Resolved". Drops now-dead _lookup_breakpoints / _lookup_integer_index_at and the unused piecewise_linear / Assert module imports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d5da1db commit 71a94c8

3 files changed

Lines changed: 305 additions & 67 deletions

File tree

docs/numerical_noise_findings.md

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -171,26 +171,56 @@ Exact at any magnitude/sharpness AND compiled-precise; pinned by
171171
slope-change-sum structure but is correct for its documented **integer-only**
172172
contract (integer inputs land in flat zones); it is not reworked.
173173

174-
### Known gap: the `map_select` table-lookup staircases have the same 2^24 cancellation
174+
### Resolved: the `map_select` table-lookup staircases had the same 2^24 cancellation
175175

176176
An audit for the `floor_int` cancellation class found one untriggered sibling.
177177
`_table_lookup_index_staircase` / `_table_lookup_row_vector` /
178-
`_table_lookup_column_mask` (`torchwright/ops/map_select.py`) build a
178+
`_table_lookup_column_mask` (`torchwright/ops/map_select.py`) built a
179179
`piecewise_linear` with `2·(n−1)+1` breakpoints and `input_scale=sharpness`
180180
(default 100), feeding `table_lookup_2d`/`table_lookup_3d`. Same single
181181
alternating-sign ReLU sum, so the same overflow:
182-
`_table_lookup_index_staircase(n=256, sharpness=100)` at `x=100000` returns
183-
`0.0` (should clamp to 255 — the index is never clamped before the staircase),
184-
and `n=20000, sharpness=1000` collapses an in-range `x=19999` to `0.0`
185-
(`s·max_breakpoint ≈ 2e7 > 2^24`). **Untriggered today**: current table
186-
consumers use small tables with in-range, pre-rounded integer indices, and the
187-
DOOM texture/palette lookup track is still a Phase-F stub. Fix before that
188-
track lands large tables — clamp the index into `[0, n−1]` *before* the
189-
staircase, and/or apply the saturating-step reformulation. The audit also
190-
flagged `compare` as a milder 2-term variant that fails silently for `|x| ≳
191-
2^24/sharpness` (default-sharpness boundary ~1.68e6; live callers stay far
192-
under it), and confirmed `square`/`exp`/`multiply_2d`/`low_rank_2d` safe
193-
(monotone slopes or `input_scale=1`).
182+
`_table_lookup_index_staircase(n=256, sharpness=100)` at `x=100000` returned
183+
`0.0` (should clamp to 255 — the index was never clamped before the staircase),
184+
and `n=20000, sharpness=1000` collapsed an in-range `x=19999` to `0.0`
185+
(`s·max_breakpoint ≈ 2e7 > 2^24`). Two triggers: **(a)** a scaled index far
186+
outside `[0, n−1]` pushes `sharpness·x` past `2^24`; **(b)** a tall table's top
187+
breakpoint position passes `2^24`.
188+
189+
**Fixed** by rebuilding all three on the shared `_saturating_step_select`
190+
helper, the table analogue of `floor_int`'s saturating-step staircase. Two
191+
parts, one per trigger:
192+
193+
- **(b) The staircase.** Each half-integer boundary `k − 0.5` contributes a
194+
*bounded* step `step_k = relu(t_k) − relu(t_k − W)`
195+
(`t_k = s·(x − (k−0.5)) + 0.5`, `W = max(2, 8·ulp(s·(n−1)+1))`), and the
196+
output accumulates `out = top − Σ_k relu(1 − step_k)·deltas[k−1]` (rows
197+
reconstructed from consecutive differences: `deltas[k−1] = value[k]
198+
value[k−1]`, `value[n−1] = top`). Every partial sum is bounded by the table's
199+
total variation `Σ_k |deltas[k−1]|`, not by `s·x`, so it never overflows
200+
regardless of `n`. The `+ 0.5` centers the ramp on the boundary
201+
(indicator `= 0.5` there), reproducing the old two-row linear blend in the
202+
transition band — off-grid values are unchanged.
203+
- **(a) The clamp.** A leading `min(index, n−1)` (one sublayer,
204+
`(n−1) − relu((n−1) − x)` — a single ReLU of a magnitude, no cancellation)
205+
bounds the out-of-range case so `W`, sized for the in-range span, always
206+
resolves the saturating difference. The lower edge needs no clamp: a hugely
207+
negative index leaves every step off (ReLU of a negative is exactly 0) and
208+
selects row 0.
209+
210+
Cost: one `min` sublayer plus the two chained ReLU sublayers of the staircase,
211+
versus the old single sublayer. Exact at any magnitude/sharpness and
212+
compiled-precise; pinned by `test_table_lookup_2d_out_of_range_index_clamps_to_edge`,
213+
`test_table_lookup_2d_tall_table_stays_exact`,
214+
`test_table_lookup_index_staircase_clamps_and_rounds_at_any_magnitude`, and
215+
`test_table_lookup_3d_large_flattened_rows_exact` in
216+
`tests/ops/test_resampling_primitives.py`. (`table_lookup_2d`/`_3d` are not in
217+
the measured-op set in `scripts/measure_op_noise.py`; adding them there for a
218+
tracked noise number is a reasonable follow-on.)
219+
220+
The same audit flagged `compare` as a milder 2-term variant that fails silently
221+
for `|x| ≳ 2^24/sharpness` (default-sharpness boundary ~1.68e6; live callers
222+
stay far under it) and confirmed `square`/`exp`/`multiply_2d`/`low_rank_2d` safe
223+
(monotone slopes or `input_scale=1`); those are unchanged.
194224

195225
### Rel-error reporting: ramp-zone artefacts
196226

tests/ops/test_resampling_primitives.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,3 +1130,120 @@ def test_linear_bin_index_inv_range_probe():
11301130
assert (
11311131
report.first_divergent is None
11321132
), f"probe divergence on hoisted inv_range:\n{report.format_short()}"
1133+
1134+
1135+
# ---------------------------------------------------------------------------
1136+
# Cancellation-free index staircase (regression)
1137+
#
1138+
# The old single-projection ``piecewise_linear`` builders summed ~n ReLU terms
1139+
# of magnitude ~sharpness*index into one accumulator, overflowing fp32's 2^24
1140+
# exact-integer limit so the alternating-sign terms cancelled into garbage.
1141+
# Two triggers: (a) a scaled index far outside [0, n-1] (sharpness*index past
1142+
# 2^24), and (b) a tall table whose top breakpoint position passes 2^24. See
1143+
# docs/numerical_noise_findings.md, "the map_select table-lookup staircases".
1144+
# Mirrors test_floor_int_wide_range_large_magnitude; the cancellation lives in
1145+
# the exact-math fp32 sum that node.compute / reference_eval reproduces.
1146+
# ---------------------------------------------------------------------------
1147+
1148+
1149+
def test_table_lookup_2d_out_of_range_index_clamps_to_edge():
1150+
"""Trigger (a): a row index far outside [0, rows-1] clamps to the table
1151+
edge instead of collapsing (index 1e5 at sharpness 100 -> sharpness*index
1152+
1e7 > 2^24 used to return ~0)."""
1153+
rows = 256
1154+
table = torch.arange(rows * 2, dtype=torch.float32).reshape(rows, 2)
1155+
out_node = _build_table_lookup_2d_graph(table) # default sharpness=100
1156+
1157+
i_val = torch.tensor([[1.0e5], [-50.0], [0.0], [128.0]], dtype=torch.float32)
1158+
j_val = torch.zeros_like(i_val)
1159+
inputs = {"i": i_val, "j": j_val}
1160+
expected = torch.tensor(
1161+
[
1162+
[float(table[255, 0])],
1163+
[float(table[0, 0])],
1164+
[float(table[0, 0])],
1165+
[float(table[128, 0])],
1166+
]
1167+
)
1168+
cache = reference_eval(out_node, inputs, i_val.shape[0])
1169+
assert torch.allclose(cache[out_node], expected, atol=5e-3), cache[out_node]
1170+
1171+
# ...and the min-clamp + saturating-step chain compiles precisely.
1172+
report = probe_graph(
1173+
out_node,
1174+
pos_encoding=None,
1175+
input_values=inputs,
1176+
n_pos=i_val.shape[0],
1177+
d=1024,
1178+
d_head=16,
1179+
verbose=False,
1180+
atol=5e-3,
1181+
)
1182+
assert report.first_divergent is None, report.format_short()
1183+
1184+
1185+
def test_table_lookup_2d_tall_table_stays_exact():
1186+
"""Trigger (b): a 20000-row table at sharpness 100 and 1000 keeps in-range
1187+
indices exact (an in-range index used to collapse to 0). Oracle-only:
1188+
compiling a 20000-row table is needlessly expensive and the cancellation is
1189+
in the exact-math fp32 sum, like the floor_int wide-range regression."""
1190+
rows = 20000
1191+
table = torch.arange(rows, dtype=torch.float32).reshape(rows, 1)
1192+
idxs = [0.0, 1.0, 9999.0, 12345.0, 19998.0, 19999.0]
1193+
for s in (100.0, 1000.0):
1194+
i = create_input("i", 1)
1195+
j = create_input("j", 1)
1196+
out_node = table_lookup_2d(i, j, table, sharpness=s)
1197+
inputs = {
1198+
"i": torch.tensor([[v] for v in idxs]),
1199+
"j": torch.zeros(len(idxs), 1),
1200+
}
1201+
got = reference_eval(out_node, inputs, len(idxs))[out_node].squeeze(-1)
1202+
expected = torch.tensor(idxs)
1203+
assert torch.allclose(got, expected, atol=5e-3), (s, got, expected)
1204+
1205+
1206+
def test_table_lookup_index_staircase_clamps_and_rounds_at_any_magnitude():
1207+
"""The scalar staircase computes clamp(round(x), 0, n-1) exactly at any
1208+
magnitude — the smallest layer that reproduces both triggers (out-of-range
1209+
x and large n)."""
1210+
from torchwright.ops.map_select import _table_lookup_index_staircase
1211+
1212+
for n, s in [(256, 100.0), (256, 1000.0), (20000, 1000.0)]:
1213+
x = create_input("x", 1)
1214+
node = _table_lookup_index_staircase(
1215+
x, n, sharpness=s, d_max=1024, name="st"
1216+
)
1217+
cases = [
1218+
(-1.0e6, 0.0),
1219+
(-0.4, 0.0),
1220+
(0.0, 0.0),
1221+
(0.6, 1.0),
1222+
(float(n // 3), float(n // 3)),
1223+
(float(n - 1), float(n - 1)),
1224+
(1.0e6, float(n - 1)),
1225+
]
1226+
xs = torch.tensor([[c[0]] for c in cases])
1227+
out = reference_eval(node, {"x": xs}, len(cases))[node].squeeze(-1)
1228+
exp = torch.tensor([c[1] for c in cases])
1229+
assert torch.allclose(out, exp, atol=5e-3), (n, s, out, exp)
1230+
1231+
1232+
def test_table_lookup_3d_large_flattened_rows_exact():
1233+
"""The 3D lookup flattens (A, B) into A*B rows; the motivating 16x128x128
1234+
shape reshapes to 2048 rows — well into the tall-table regime — and stays
1235+
exact on the integer grid through the cancellation-free row builder."""
1236+
t3 = torch.arange(16 * 128 * 128, dtype=torch.float32).reshape(16, 128, 128)
1237+
i = create_input("i", 1)
1238+
j = create_input("j", 1)
1239+
k = create_input("k", 1)
1240+
node = table_lookup_3d(i, j, k, t3)
1241+
cells = [(0, 0, 0), (15, 127, 127), (7, 64, 33), (3, 5, 120)]
1242+
inputs = {
1243+
"i": torch.tensor([[float(a)] for a, _, _ in cells]),
1244+
"j": torch.tensor([[float(b)] for _, b, _ in cells]),
1245+
"k": torch.tensor([[float(c)] for _, _, c in cells]),
1246+
}
1247+
got = reference_eval(node, inputs, len(cells))[node].squeeze(-1)
1248+
expected = torch.tensor([float(t3[a, b, c]) for a, b, c in cells])
1249+
assert torch.allclose(got, expected, atol=5e-3), (got, expected)

0 commit comments

Comments
 (0)