Skip to content

Commit 729f6c1

Browse files
physicsrobclaude
andcommitted
fix(ops): floor_int cancellation-free, compiled-precise saturating staircase
floor_int built its staircase via piecewise_linear, which sums slope-change ReLUs Σ ±s·ReLU(x−b_i) in one linear projection. For a tall staircase at large x this is catastrophic: each term is ~s·x and the fp32 partial sums overflow 2^24 (16.7M) so the +/- terms cancel into garbage. floor_int(40000.4, [0,65535], s=1000) returned 0.0 (true 40000); even [0,255] staircases lost ~1-2 units near the top of the range, unstable to 1-ULP input changes. Rebuild as a sum of bounded saturating steps: floor(x) = min + Σ_k step_k, step_k = clamp(s·(x−k)+1, 0, W) = relu(t)−relu(t−W) floor = min + n − Σ_k relu(1 − step_k). Each step_k is a 2-term per-output difference (not a sum over all breakpoints), so the final Σ accumulates only bounded [0,1] terms — partial sums never exceed n. The step is clamped to [0, W] (not left unbounded), keeping the stage-1 intermediate small so it does not amplify upstream compiled noise (an unbounded relu(t) intermediate broke the linear_bin_index compile-fidelity probe). W is sized max(2, 8·ulp(s·n)) — 2 for the common small-range case, larger only as s·n approaches 2^24, where relu(t)−relu(t−W) would otherwise collapse to 0. Exact at any magnitude/sharpness AND compiled-precise. Cost: one extra MLP sublayer (two chained ReLUs). ceil_int = −floor_int(−x) inherits the fix. thermometer_floor_div shares the slope-change-sum structure but is correct for its documented integer-inputs-only contract (verified exact on large integers) — not reworked. Tests: test_floor_int_wide_range_large_magnitude (s=10/1000 over [0,65535]), test_floor_int_byte_range_exact_high_sharpness, test_ceil_int_wide_range; full arithmetic_ops + resampling (compiled probe) + value_type suites green. Noise re-measured (the [-5,10] distributions never probed the bug — see numerical_noise_findings.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2066416 commit 729f6c1

6 files changed

Lines changed: 217 additions & 55 deletions

File tree

docs/numerical_noise.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Numerical noise reference
22

33
*Generated by `scripts/measure_op_noise.py`; do not edit by hand.*
4-
*Measured at commit `fe133f9` on `2026-06-02`.*
4+
*Measured at commit `2e04d93` on `2026-06-02`.*
55

66
Every piecewise-linear op in `torchwright/ops/` is measured on one or more
77
named input distributions characterising its expected input ranges.
@@ -296,7 +296,7 @@ Uniform continuous samples on [-5, 10] — most land in flat zones; measures bas
296296
| --- | --- |
297297
| Samples | 4096 |
298298
| Max abs error | 0.9982 |
299-
| Mean abs error | 0.04655 |
299+
| Mean abs error | 0.04654 |
300300
| p99 abs error | 0.8959 |
301301
| Max rel error | 0.953 (over 3828 samples with &#124;ref&#124; ≥ 1e-6) |
302302
| Mean rel error | 0.01849 |

docs/numerical_noise_findings.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,71 @@ leakage of noise onto the validity bit gets multiplied by 1000 inside the
127127
attention logit. This op is not yet wired into the measurement pipeline
128128
and is a reasonable follow-up.
129129

130+
### `floor_int` staircase is cancellation-free (saturating steps), not a slope-change ReLU sum
131+
132+
`floor_int` (and `ceil_int = -floor_int(-x)`) previously realised its
133+
staircase through `piecewise_linear`, which sums slope-change ReLUs
134+
`Σ ±s·ReLU(x − b_i)` in **one** linear projection. For a tall staircase
135+
evaluated at large `x` this is catastrophic: each term has magnitude
136+
`~s·x`, and the partial sums during the fp32 dot-product accumulation
137+
overflow float32's `2^24 ≈ 16.7M` exact-integer limit, so the alternating
138+
`+`/`` terms cancel into garbage. `floor_int(40000.4, [0,65535], s=1000)`
139+
returned `0.0` (true `40000`); even `[0,255]` staircases lost ~1–2 units
140+
near the top of the range. **The measured noise distributions never caught
141+
this**`floor_uniform_neg5_10` / `floor_near_boundary_10` sample only
142+
`[-5,10]`, far inside the safe regime — which is the cautionary part: a
143+
clean per-op number does not certify an op outside its measured input
144+
range.
145+
146+
The op now sums **bounded saturating** steps, `floor(x) = min + Σ_k step_k`
147+
with `step_k = clamp(s(x−k)+1, 0, W) = relu(t) − relu(t−W)`, counted as
148+
`floor = min + n − Σ_k relu(1 − step_k)`. Each `step_k` is a 2-term
149+
per-output difference (not a sum over all breakpoints), so the final `Σ`
150+
accumulates only bounded `[0,1]` terms — partial sums never exceed `n`. Two
151+
subtleties matter:
152+
153+
- **Bound the step, don't leave it unbounded.** An earlier version output the
154+
raw `relu(t)` (∈ `[0, ~s·x]`) and relied on `1 − relu(1 − relu(t))` to
155+
saturate. That is reference-exact but the *intermediate* `relu(t)` carries
156+
upstream compiled noise amplified by `s` (≈×10), which broke the
157+
`linear_bin_index` compile-fidelity probe (`test_resampling_primitives`) on
158+
an internal node even though the output was correct. Clamping the step to
159+
`[0, W]` keeps the intermediate small (compiled-precise) with `≥ W−1` slack
160+
below the stage-2 threshold of 1.
161+
- **Size `W` to the magnitude.** `relu(t) − relu(t−W)` collapses to 0 once `t`
162+
and `t−W` round to the same float32 (`ulp(t) ≥ W`), so `W = max(2,
163+
8·ulp(s·n))` — 2 for the common small-range case, larger only as `s·n`
164+
approaches `2^24`.
165+
166+
Cost: one extra MLP sublayer (two chained ReLUs) and a width-`n` intermediate.
167+
Exact at any magnitude/sharpness AND compiled-precise; pinned by
168+
`test_floor_int_wide_range_large_magnitude`,
169+
`test_floor_int_byte_range_exact_high_sharpness`, and the existing
170+
`linear_bin_index` compile probe. Note `thermometer_floor_div` shares the
171+
slope-change-sum structure but is correct for its documented **integer-only**
172+
contract (integer inputs land in flat zones); it is not reworked.
173+
174+
### Known gap: the `map_select` table-lookup staircases have the same 2^24 cancellation
175+
176+
An audit for the `floor_int` cancellation class found one untriggered sibling.
177+
`_table_lookup_index_staircase` / `_table_lookup_row_vector` /
178+
`_table_lookup_column_mask` (`torchwright/ops/map_select.py`) build a
179+
`piecewise_linear` with `2·(n−1)+1` breakpoints and `input_scale=sharpness`
180+
(default 100), feeding `table_lookup_2d`/`table_lookup_3d`. Same single
181+
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`).
194+
130195
### Rel-error reporting: ramp-zone artefacts
131196

132197
For `compare`, `floor_int`, `linear_bin_index`, and the boolean ops

docs/op_noise_data.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schema_version": 1,
3-
"commit": "fe133f9",
3+
"commit": "2e04d93",
44
"measured_at": "2026-06-02",
55
"ops": [
66
{
@@ -254,12 +254,12 @@
254254
"name": "floor_near_boundary_10",
255255
"description": "Samples within ±1/step_sharpness of integer k=5 — stresses the ramp zone where `floor_int` interpolates.",
256256
"n_samples": 4096,
257-
"max_abs_error": 0.999268,
257+
"max_abs_error": 0.999279,
258258
"mean_abs_error": 0.128753,
259-
"p99_abs_error": 0.970386,
260-
"max_rel_error": 0.249817,
259+
"p99_abs_error": 0.970371,
260+
"max_rel_error": 0.24982,
261261
"mean_rel_error": 0.0321882,
262-
"p99_rel_error": 0.242596,
262+
"p99_rel_error": 0.242593,
263263
"rel_valid_samples": 4096,
264264
"worst_input": {
265265
"x": 4.99993
@@ -269,12 +269,12 @@
269269
"name": "floor_uniform_neg5_10",
270270
"description": "Uniform continuous samples on [-5, 10] — most land in flat zones; measures baseline `floor_int` accuracy.",
271271
"n_samples": 4096,
272-
"max_abs_error": 0.998169,
273-
"mean_abs_error": 0.0465452,
272+
"max_abs_error": 0.998161,
273+
"mean_abs_error": 0.0465444,
274274
"p99_abs_error": 0.895853,
275275
"max_rel_error": 0.953022,
276-
"mean_rel_error": 0.0184898,
277-
"p99_rel_error": 0.46109,
276+
"mean_rel_error": 0.0184896,
277+
"p99_rel_error": 0.461094,
278278
"rel_valid_samples": 3828,
279279
"worst_input": {
280280
"x": 5.99982

tests/ops/test_arithmetic_ops.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,52 @@ def test_ceil_int():
895895
), f"ceil({v}) = {expected}, got {result.item()}"
896896

897897

898+
def test_floor_int_wide_range_large_magnitude():
899+
"""Regression: the old slope-change ReLU staircase summed ~n terms of
900+
magnitude ~s*x in one projection, overflowing fp32's 2^24 exact-integer
901+
limit and collapsing to ~0 above x ~ 2^24/s (e.g. floor_int(40000.4,
902+
[0,65535], s=1000) returned 0.0). The saturating-step staircase keeps every
903+
partial sum in [0, n] so it stays exact at any magnitude/sharpness."""
904+
import math
905+
906+
# Mid-bin values (0.4 above an integer, clear of any ramp for s >= 10) so the
907+
# only thing under test is the staircase summation, not near-boundary ramps.
908+
x = create_input("x", 1)
909+
for s in (10.0, 1000.0):
910+
f = floor_int(x, min_value=0, max_value=65535, sharpness=s)
911+
for v in (10.4, 1000.4, 16800.4, 40000.4, 62195.4, 65534.4):
912+
r = f.compute(n_pos=1, input_values={"x": torch.tensor([[v]])}).item()
913+
assert abs(r - math.floor(v)) < 0.01, (
914+
f"floor_int({v}, [0,65535], s={s}) should be {math.floor(v)}, "
915+
f"got {r}"
916+
)
917+
918+
919+
def test_floor_int_byte_range_exact_high_sharpness():
920+
"""Exactness guard over the [0,255] byte range at high sharpness — the DOOM
921+
digit-quad high byte floor(q/256). The saturating-step form must stay exact
922+
across the whole range (the old form's partial sums grew with the step count
923+
and position and could lose ~1-2 units near the top)."""
924+
import math
925+
926+
x = create_input("x", 1)
927+
f = floor_int(x, min_value=0, max_value=255, sharpness=1000.0)
928+
for v in (0.4, 1.4, 100.6, 127.95, 200.3, 242.953, 254.6, 255.0):
929+
r = f.compute(n_pos=1, input_values={"x": torch.tensor([[v]])}).item()
930+
assert abs(r - math.floor(v)) < 0.01, f"floor_int({v}, [0,255]) got {r}"
931+
932+
933+
def test_ceil_int_wide_range_large_magnitude():
934+
"""ceil_int = -floor_int(-x); inherits the saturating-step fix."""
935+
import math
936+
937+
x = create_input("x", 1)
938+
c = ceil_int(x, min_value=0, max_value=65535)
939+
for v in (10.4, 1000.4, 40000.4, 62195.98):
940+
r = c.compute(n_pos=1, input_values={"x": torch.tensor([[v]])}).item()
941+
assert abs(r - math.ceil(v)) < 0.01, f"ceil_int({v}) should be {math.ceil(v)}, got {r}"
942+
943+
898944
def test_signed_multiply():
899945
"""Test all sign combinations."""
900946
a = create_input("a", 1)

0 commit comments

Comments
 (0)