Skip to content

Commit 2066416

Browse files
physicsrobclaude
andcommitted
fix(ops): build multiply_2d in O(n) via analytic quarter-square; guard SVD
multiply_2d was infeasible to BUILD at high breakpoint counts. It lowered to piecewise_linear_2d's least-squares solve, whose `numpy.linalg.svd(..., full_matrices=True)` materializes a discarded (n1*n2, n1*n2) left-singular matrix U. At 257 breakpoints/axis that U is ~35 GB of float64 — a single graph build OOM'd a 30 GB machine. Two layered fixes: 1. Analytic product fast path (the named-op fix). A product x*y is exactly bilinear, so multiply_2d no longer routes through the generic solve. On the common unit grid it builds the piecewise-linear interpolant in closed form via the quarter-square identity x*y = ((x+y)^2 - (x-y)^2)/4. The interpolant of t^2 on a uniform grid of spacing h has a constant slope change of 2h at every interior breakpoint, so each square is an equal-weight ReLU staircase: one MLP sublayer, ~2*(2n-2) neurons, no solve. The generic piecewise_linear_2d path is untouched for true 2D functions. Build at n=257 over [-1500,1500]: OOM -> 230 ms / 0.44 MB. Noise is unchanged (multiply_uniform_pm10 0.06250 -> 0.06249, within GPU FP jitter); the interpolant is the same diagonal-aligned product. 2. Conditional full_matrices guard in piecewise_linear_2d. The fit reads the nullspace basis Vh[rank:].T, which needs every right-singular vector up to the column dimension. When A_v is TALL (uniform/collapsed grids) the reduced SVD already returns all of them, so full_matrices=False is bit-identical and skips the giant U. When A_v is WIDE (non-uniform grids, where the sum/diff hyperplane families don't collapse) the reduced SVD drops the nullspace rows, so full_matrices=True is required there — an earlier unconditional flip was the source of the historical 19.4 vs 7.78 diff_trig regression. The guard keeps the non-uniform path at 7.78 while making the tall path buildable. Tests: add a build-cost regression and a multi-chunk regression to test_multiply_2d.py; add scripts/validate_multiply_2d_build_cost.py. D7: re-measured noise, refreshed footers/JSON/markdown, and updated numerical_noise_findings.md (the stale 19.4 piecewise_linear_2d finding is resolved; added a quarter-square note). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fe133f9 commit 2066416

8 files changed

Lines changed: 487 additions & 99 deletions

File tree

docs/numerical_noise.md

Lines changed: 19 additions & 19 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 `c234ba4` on `2026-04-29`.*
4+
*Measured at commit `fe133f9` 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.
@@ -26,13 +26,13 @@ For commentary on the numbers — which are expected, which warrant investigatio
2626
| `exp` | `torchwright.ops.arithmetic_ops` | 0.02797 | 0.0001924 | `exp_pm5` |
2727
| `floor_int` | `torchwright.ops.arithmetic_ops` | 0.9993 | 0.953 | `floor_near_boundary_10` |
2828
| `linear_bin_index` | `torchwright.ops.arithmetic_ops` | 1 | 0.9854 | `linear_bin_index_tex_col_16` |
29-
| `log` | `torchwright.ops.arithmetic_ops` | 0.003026 | 0.003721 | `log_6decades_wide` |
29+
| `log` | `torchwright.ops.arithmetic_ops` | 0.003023 | 0.003705 | `log_6decades_wide` |
3030
| `log_abs` | `torchwright.ops.arithmetic_ops` | 0.000699 | 0.1428 | `log_abs_3decades_pm100` |
31-
| `low_rank_2d` | `torchwright.ops.arithmetic_ops` | 0.0651 | 0.3314 | `atan_cross_dot_nonuniform` |
31+
| `low_rank_2d` | `torchwright.ops.arithmetic_ops` | 0.0651 | 0.3315 | `atan_cross_dot_nonuniform` |
3232
| `max` | `torchwright.ops.arithmetic_ops` | 3.815e-06 | 1.953e-06 | `minmax_uniform_pm50` |
3333
| `min` | `torchwright.ops.arithmetic_ops` | 3.815e-06 | 1.953e-06 | `minmax_uniform_pm50` |
3434
| `mod_const` | `torchwright.ops.arithmetic_ops` | 0 | 0 | `mod_integers_0_100_by7` |
35-
| `multiply_2d` | `torchwright.ops.arithmetic_ops` | 0.0625 | 2.15 | `multiply_uniform_pm10` |
35+
| `multiply_2d` | `torchwright.ops.arithmetic_ops` | 0.06249 | 2.15 | `multiply_uniform_pm10` |
3636
| `multiply_integers` | `torchwright.ops.arithmetic_ops` | 0 | 0 | `multiply_integers_0_10` |
3737
| `piecewise_linear` | `torchwright.ops.arithmetic_ops` | 0.25 | 231.1 | `parabola_0_10_step1` |
3838
| `piecewise_linear_2d` | `torchwright.ops.arithmetic_ops` | 7.778 | 0.6024 | `diff_trig_nonuniform` |
@@ -333,12 +333,12 @@ log(x) over [0.01, 100] — 4 decades. With per-decade sectioning (default), eac
333333
| Metric | Value |
334334
| --- | --- |
335335
| Samples | 4096 |
336-
| Max abs error | 0.000247 |
337-
| Mean abs error | 0.0001126 |
338-
| p99 abs error | 0.0001694 |
339-
| Max rel error | 0.003721 (over 4096 samples with &#124;ref&#124; ≥ 1e-6) |
340-
| Mean rel error | 4.257e-05 |
341-
| p99 rel error | 0.0002646 |
336+
| Max abs error | 0.0002475 |
337+
| Mean abs error | 0.0001119 |
338+
| p99 abs error | 0.0001681 |
339+
| Max rel error | 0.003705 (over 4096 samples with &#124;ref&#124; ≥ 1e-6) |
340+
| Mean rel error | 4.237e-05 |
341+
| p99 rel error | 0.0002638 |
342342
| Worst input | `x=0.01797` |
343343

344344
### `log_6decades_wide`
@@ -348,12 +348,12 @@ log(x) over [0.01, 30000] — 6 decades. Stresses the sectioning path: a single-
348348
| Metric | Value |
349349
| --- | --- |
350350
| Samples | 4096 |
351-
| Max abs error | 0.003026 |
351+
| Max abs error | 0.003023 |
352352
| Mean abs error | 0.001523 |
353-
| p99 abs error | 0.002798 |
354-
| Max rel error | 0.001149 (over 4096 samples with &#124;ref&#124; ≥ 1e-6) |
355-
| Mean rel error | 0.0001697 |
356-
| p99 rel error | 0.0003993 |
353+
| p99 abs error | 0.002797 |
354+
| Max rel error | 0.001148 (over 4096 samples with &#124;ref&#124; ≥ 1e-6) |
355+
| Mean rel error | 0.0001696 |
356+
| p99 rel error | 0.0003989 |
357357
| Worst input | `x=2.707e+04` |
358358

359359
## `log_abs`
@@ -389,10 +389,10 @@ atan2(cross/dot) over [-2, 2] × [0.5, 10] on a non-uniform grid — rank-3 SVD
389389
| Max abs error | 0.0651 |
390390
| Mean abs error | 0.01586 |
391391
| p99 abs error | 0.05744 |
392-
| Max rel error | 0.3314 (over 4096 samples with &#124;ref&#124; ≥ 1e-6) |
392+
| Max rel error | 0.3315 (over 4096 samples with &#124;ref&#124; ≥ 1e-6) |
393393
| Mean rel error | 0.08032 |
394394
| p99 rel error | 0.2172 |
395-
| Worst input | `a=2`, `b=3.365` |
395+
| Worst input | `a=-2`, `b=3.365` |
396396

397397
## `max`
398398

@@ -453,7 +453,7 @@ Integer inputs on [0, 100] with divisor=7. `mod_const` is x − divisor × `ther
453453

454454
## `multiply_2d`
455455

456-
Single-sublayer product via 2D piecewise-linear lookup. Analytical worst-cell absolute bound is `step1*step2/4 = 0.25` for this configuration.
456+
Single-sublayer product built analytically via the quarter-square identity `a*b = ((a+b)^2 - (a-b)^2)/4` (O(n) neurons, no least-squares solve). Worst-cell absolute bound is `step1*step2/4 = 0.25` for this configuration.
457457

458458
### `multiply_uniform_pm10`
459459

@@ -462,7 +462,7 @@ a·b over [-10, 10]² with step=1 — generic uniform product grid; analytical b
462462
| Metric | Value |
463463
| --- | --- |
464464
| Samples | 4096 |
465-
| Max abs error | 0.0625 |
465+
| Max abs error | 0.06249 |
466466
| Mean abs error | 0.0202 |
467467
| p99 abs error | 0.05847 |
468468
| Max rel error | 2.15 (over 4096 samples with &#124;ref&#124; ≥ 1e-6) |

docs/numerical_noise_findings.md

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,60 @@ observations and removing findings a fix has invalidated. See the
1515

1616
### `piecewise_linear_2d` oscillates on non-uniform grids
1717

18-
`diff_trig_nonuniform` hits **19.4 absolute error** on a product whose
19-
reference value is `-24.3` — a ~80% relative deviation.
20-
`diff_vel_nonuniform` hits **13.6 absolute error**. The op's own docstring
18+
`diff_trig_nonuniform` hits **7.78 absolute error** on a product whose
19+
reference value is `-24.3` — a ~32% relative deviation.
20+
`diff_vel_nonuniform` hits **5.44 absolute error**. The op's own docstring
2121
already flags this: the constrained least-squares fit used on non-uniform
2222
breakpoint grids can oscillate inside cell interiors. **Preferred
2323
replacement:** `low_rank_2d`, which measures 0.065 absolute / 33% relative
2424
(rank-3 SVD, with a compile-time `σ_{K+1}` bound).
2525

26-
**Noise ~doubled after commit `3895411`** ("perf(ops): drop full_matrices in
27-
piecewise_linear_2d SVD"). That change moved `torch.linalg.svd` from
28-
`full_matrices=True` to `False` to save ~15 GB of unused U. For `m > n` the
29-
two SVD modes compute the same `Vh` in theory, but torch's reduced-SVD
30-
algorithm produces a slightly different orthonormal basis in the nullspace
31-
span (which is what the piecewise_linear_2d fit uses via `Vh[rank:]`).
32-
Different nullspace basis → different constrained least-squares solution →
33-
different oscillation pattern, same shape but higher amplitude at DIFF_BP's
34-
sparsely-sampled outer cells. The commit did not re-measure noise (D7
35-
violation caught by `test_numerical_noise_drift` and resolved on this
36-
branch). The underlying issue — piecewise_linear_2d is brittle on highly
26+
**The SVD's `full_matrices` flag is load-bearing here, and only for *tall*
27+
design matrices.** `piecewise_linear_2d` reads the nullspace basis
28+
`N_basis = Vh[rank:].T`, which needs every right-singular vector up to the
29+
column dimension `3+K`. The shape of `A_v` decides whether the two SVD
30+
modes agree:
31+
32+
* **Tall** (`vertex rows n1·n2 >= 3+K`): this happens on uniform / collapsed
33+
grids — e.g. the unit grid `multiply_2d` used to feed here, where the
34+
sum/diff hyperplane families collapse to O(n) distinct lines. The reduced
35+
SVD (`full_matrices=False`) already returns all `3+K` right-singular
36+
vectors, so `Vh` — and therefore the solution — is identical to the full
37+
SVD, while skipping the discarded `(n1·n2, n1·n2)` left-singular matrix U
38+
(~35 GB of float64 at n1·n2 ≈ 66049 — a real build-time OOM).
39+
* **Wide** (`rows < 3+K`): this is the *non-uniform* case — DIFF_BP × TRIG_BP
40+
has 117 vertex rows but 219 hyperplane columns. The reduced SVD returns
41+
only the first 117 right-singular vectors, so the nullspace rows
42+
`rank..(3+K)` are *missing*. `N_basis` then can't span the nullspace the
43+
interior-sample regularization needs, the oscillation is no longer pinned,
44+
and `diff_trig_nonuniform`'s max abs error jumps from 7.78 to **19.4**.
45+
46+
So the op now guards the flag: `full_matrices=False` only when `A_v` is
47+
tall, `full_matrices=True` (the U it builds is only `(rows, rows)`, and the
48+
non-uniform grids that hit this path are small) otherwise. An earlier commit
49+
that flipped the flag unconditionally was the source of the historical 19.4
50+
regression; the conditional guard keeps the non-uniform path at its
51+
original 7.78 while making the tall path buildable at high breakpoint
52+
counts. The underlying issue — `piecewise_linear_2d` is brittle on highly
3753
non-uniform grids — is unchanged; callers should prefer `low_rank_2d` or a
3854
uniform grid.
3955

56+
### `multiply_2d` builds its product grid analytically (quarter-square)
57+
58+
`multiply_2d` no longer routes the product through `piecewise_linear_2d`'s
59+
generic least-squares solve. A product is exactly bilinear, so on the
60+
common unit grid the op now builds the piecewise-linear interpolant in
61+
closed form via the quarter-square identity
62+
`u·v = ((u+v)² − (u−v)²)/4`: the interpolant of `` on a uniform grid of
63+
spacing `h` has a *constant* slope change of `2h` at every interior
64+
breakpoint, so each square is an equal-weight ReLU staircase and the whole
65+
product is one ReLU bank of `~2·(2n−2)` neurons — O(n) build, no SVD, no
66+
O(n²) design matrix (the old path OOM'd at ~257 breakpoints/axis). This is
67+
the same single MLP sublayer and the same diagonal-aligned interpolant, so
68+
`multiply_uniform_pm10` is unchanged at ~0.0625 absolute (it moved by ~1e-5,
69+
within GPU FP run-to-run jitter). The near-zero high-relative-error
70+
pathology below is structural to the product and unaffected.
71+
4072
### `reciprocal` relative error is load-bearing at the high-x tail
4173

4274
`reciprocal_03_200` reports 0.007 absolute error, which sounds small. But

docs/op_noise_data.json

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"schema_version": 1,
3-
"commit": "c234ba4",
4-
"measured_at": "2026-04-29",
3+
"commit": "fe133f9",
4+
"measured_at": "2026-06-02",
55
"ops": [
66
{
77
"name": "abs",
@@ -315,12 +315,12 @@
315315
"name": "log_4decades_001_100",
316316
"description": "log(x) over [0.01, 100] — 4 decades. With per-decade sectioning (default), each section's pre-cancellation magnitude is bounded by `section_factor=10`, so float32 ULP noise is ~1.2e-6 per section. The dominant residual error comes from the multiply_2d blending grid in routing.",
317317
"n_samples": 4096,
318-
"max_abs_error": 0.000247002,
319-
"mean_abs_error": 0.000112631,
320-
"p99_abs_error": 0.000169402,
321-
"max_rel_error": 0.00372105,
322-
"mean_rel_error": 4.25736e-05,
323-
"p99_rel_error": 0.000264624,
318+
"max_abs_error": 0.000247478,
319+
"mean_abs_error": 0.000111867,
320+
"p99_abs_error": 0.000168088,
321+
"max_rel_error": 0.00370548,
322+
"mean_rel_error": 4.2368e-05,
323+
"p99_rel_error": 0.000263829,
324324
"rel_valid_samples": 4096,
325325
"worst_input": {
326326
"x": 0.0179683
@@ -330,12 +330,12 @@
330330
"name": "log_6decades_wide",
331331
"description": "log(x) over [0.01, 30000] — 6 decades. Stresses the sectioning path: a single-section piecewise log over this range fails outright (cancellation floor `(x_max/x_min) · 2⁻²³ ≈ 0.36 absolute, observed worst ~1.0 abs). Sectioning drops the floor to ~5e-3 absolute, dominated by compare-cancellation noise at large `x` propagating through multiply_2d blending.",
332332
"n_samples": 4096,
333-
"max_abs_error": 0.00302601,
334-
"mean_abs_error": 0.00152333,
335-
"p99_abs_error": 0.00279808,
336-
"max_rel_error": 0.00114918,
337-
"mean_rel_error": 0.000169692,
338-
"p99_rel_error": 0.000399339,
333+
"max_abs_error": 0.00302315,
334+
"mean_abs_error": 0.00152277,
335+
"p99_abs_error": 0.00279686,
336+
"max_rel_error": 0.00114823,
337+
"mean_rel_error": 0.000169627,
338+
"p99_rel_error": 0.000398892,
339339
"rel_valid_samples": 4096,
340340
"worst_input": {
341341
"x": 27070.5
@@ -374,15 +374,15 @@
374374
"name": "atan_cross_dot_nonuniform",
375375
"description": "atan2(cross/dot) over [-2, 2] × [0.5, 10] on a non-uniform grid — rank-3 SVD via `low_rank_2d`.",
376376
"n_samples": 4096,
377-
"max_abs_error": 0.0651017,
377+
"max_abs_error": 0.0651015,
378378
"mean_abs_error": 0.0158553,
379379
"p99_abs_error": 0.0574396,
380-
"max_rel_error": 0.33142,
381-
"mean_rel_error": 0.0803217,
382-
"p99_rel_error": 0.217151,
380+
"max_rel_error": 0.331451,
381+
"mean_rel_error": 0.0803216,
382+
"p99_rel_error": 0.21716,
383383
"rel_valid_samples": 4096,
384384
"worst_input": {
385-
"a": 2.0,
385+
"a": -2.0,
386386
"b": 3.36508
387387
}
388388
}
@@ -459,17 +459,17 @@
459459
{
460460
"name": "multiply_2d",
461461
"module": "torchwright.ops.arithmetic_ops",
462-
"notes": "Single-sublayer product via 2D piecewise-linear lookup. Analytical worst-cell absolute bound is `step1*step2/4 = 0.25` for this configuration.",
462+
"notes": "Single-sublayer product built analytically via the quarter-square identity `a*b = ((a+b)^2 - (a-b)^2)/4` (O(n) neurons, no least-squares solve). Worst-cell absolute bound is `step1*step2/4 = 0.25` for this configuration.",
463463
"distributions": [
464464
{
465465
"name": "multiply_uniform_pm10",
466466
"description": "a·b over [-10, 10]² with step=1 — generic uniform product grid; analytical bound `step1*step2/4 = 0.25`.",
467467
"n_samples": 4096,
468-
"max_abs_error": 0.0624998,
469-
"mean_abs_error": 0.0201988,
470-
"p99_abs_error": 0.0584654,
471-
"max_rel_error": 2.15046,
472-
"mean_rel_error": 0.00729335,
468+
"max_abs_error": 0.0624883,
469+
"mean_abs_error": 0.0201983,
470+
"p99_abs_error": 0.0584726,
471+
"max_rel_error": 2.14985,
472+
"mean_rel_error": 0.00729313,
473473
"p99_rel_error": 0.0661755,
474474
"rel_valid_samples": 4096,
475475
"worst_input": {

scripts/measure_op_noise.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -812,9 +812,10 @@ def _target_ops() -> List[TargetOp]:
812812
reference_fn=lambda inputs: inputs["a"] * inputs["b"],
813813
distribution_names=("multiply_uniform_pm10",),
814814
notes=(
815-
"Single-sublayer product via 2D piecewise-linear lookup. "
816-
"Analytical worst-cell absolute bound is `step1*step2/4 = 0.25` "
817-
"for this configuration."
815+
"Single-sublayer product built analytically via the "
816+
"quarter-square identity `a*b = ((a+b)^2 - (a-b)^2)/4` "
817+
"(O(n) neurons, no least-squares solve). Worst-cell absolute "
818+
"bound is `step1*step2/4 = 0.25` for this configuration."
818819
),
819820
),
820821
TargetOp(
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Validate multiply_2d's build cost and accuracy at high breakpoint counts.
2+
3+
Background. ``multiply_2d`` used to lower to ``piecewise_linear_2d``'s
4+
generic least-squares solve, which built an O(n²)-row vertex design matrix
5+
and ran ``numpy.linalg.svd(..., full_matrices=True)`` whose left-singular
6+
matrix is ``(n², n²)``. At ~257 breakpoints per axis that discarded U
7+
matrix is ~35 GB of float64 — a single graph build OOM'd a 30 GB machine.
8+
9+
A product ``x·y`` is exactly bilinear (rank-1), so ``multiply_2d`` now
10+
builds its piecewise-linear interpolant analytically via the quarter-square
11+
identity ``x·y = ((x+y)² − (x−y)²)/4`` — O(n) neurons, no global solve.
12+
13+
This script:
14+
15+
* (i) builds a 257×257 grid over a realistic operand range and confirms it
16+
constructs in well under a second using a trivial amount of memory;
17+
* (ii) checks the max abs error of the product against exact ``x·y`` over a
18+
dense sample, at magnitudes up to ~1500·1500, stays inside the
19+
bilinear ``step²/4`` interpolation bound.
20+
21+
Run with the workspace venv::
22+
23+
/data/torchdoom/.venv/bin/python -m scripts.validate_multiply_2d_build_cost
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import time
29+
import tracemalloc
30+
31+
import torch
32+
33+
from torchwright.ops.arithmetic_ops import multiply_2d
34+
from torchwright.ops.inout_nodes import create_input
35+
36+
37+
def main() -> None:
38+
max_abs = 1500.0
39+
n_bp = 257
40+
step = (2 * max_abs) / (n_bp - 1) # 257 breakpoints per axis over [-1500, 1500]
41+
42+
# (i) Construction cost.
43+
a = create_input("a", 1)
44+
b = create_input("b", 1)
45+
tracemalloc.start()
46+
t0 = time.perf_counter()
47+
node = multiply_2d(
48+
a, b, max_abs1=max_abs, max_abs2=max_abs, step1=step, step2=step
49+
)
50+
elapsed = time.perf_counter() - t0
51+
_cur, peak = tracemalloc.get_traced_memory()
52+
tracemalloc.stop()
53+
54+
print(f"breakpoints per axis : {n_bp}")
55+
print(f"operand range : [-{max_abs}, {max_abs}], step {step:.4f}")
56+
print(f"build time : {elapsed * 1000:.1f} ms")
57+
print(f"build alloc (traced) : {peak / 1e6:.2f} MB")
58+
print()
59+
print(
60+
"old generic path at this n built a (n², n²) = "
61+
f"({n_bp ** 2}, {n_bp ** 2}) float64 SVD U matrix "
62+
f"≈ {n_bp ** 4 * 8 / 1e9:.1f} GB → OOM"
63+
)
64+
print()
65+
66+
assert elapsed < 1.0, f"build took {elapsed:.2f}s — expected well under a second"
67+
assert peak < 200e6, f"build allocated {peak / 1e6:.1f} MB — expected a few MB"
68+
69+
# (ii) Accuracy vs exact product over a dense sample.
70+
torch.manual_seed(0)
71+
n_samples = 200_000
72+
xs = (torch.rand(n_samples) * 2 - 1) * max_abs
73+
ys = (torch.rand(n_samples) * 2 - 1) * max_abs
74+
worst = 0.0
75+
worst_at = None
76+
# compute() takes one position at a time here; batch through n_pos.
77+
inputs = {"a": xs.unsqueeze(1), "b": ys.unsqueeze(1)}
78+
out = node.compute(n_pos=n_samples, input_values=inputs).flatten()
79+
err = (out - xs * ys).abs()
80+
worst = err.max().item()
81+
worst_at = (xs[err.argmax()].item(), ys[err.argmax()].item())
82+
83+
bound = step * step / 4
84+
print(f"max abs error : {worst:.4f} at {worst_at}")
85+
print(f"bilinear step²/4 bnd : {bound:.4f}")
86+
assert worst < bound + 1.0, f"error {worst} exceeds bilinear bound {bound}"
87+
88+
print()
89+
print("PASS: builds in O(n) time/memory and stays within the interpolation bound.")
90+
91+
92+
if __name__ == "__main__":
93+
main()

0 commit comments

Comments
 (0)