Skip to content

diag: the float32 FFT probe that found #31's root cause - #41

Open
tap wants to merge 6 commits into
mainfrom
diag/fft-determinism-phase1
Open

diag: the float32 FFT probe that found #31's root cause#41
tap wants to merge 6 commits into
mainfrom
diag/fft-determinism-phase1

Conversation

@tap

@tap tap commented Aug 10, 2026

Copy link
Copy Markdown
Owner

What this changes

Extends tests/fft_determinism_probe.cpp and adds a dispatchable macOS-only workflow that runs it. Diagnostic for #31; both files are marked for deletion when it closes.

This has now run and produced answers — they are below, and they located the root cause. The fix is in tap/DspTap#9.

Results, from run 31419956480

Apple M1 (Virtual) / VirtualMac2,1, macOS 26.5.2 (25F84), Apple clang 21.0.0. 200 processes per backend per size.

1a — which sizes bifurcate

size vDSP Ooura
64, 128, 256, 512, 1024, 8192 1 draw 1 draw
2048 2 draws, 140/60 1 draw
4096 2 draws, 110/90 1 draw

N=4096 was not previously known to be affected. The two N=2048 hashes match the ones in #31's original report.

1b — how far apart the draws are

At N=2048: 83.2% of bins differ; max absolute difference 1.31e-06, which is 2.16e-07 peak-normalized — inside the documented 4e-7 bound. So the chain has been bifurcating on a difference the FFT is explicitly permitted to have. That is the serious reading, not the reassuring one.

1c — what selects the draw

offset rp mod 64 hash
0, 64, 128, 256, 2048 0 591c8a27e6078cd7
4, 8, 16, 32, 4092 4, 8, 16, 32, 60 61cb8eac91ae7d41

vDSP dispatches on 64-byte alignment, and std::vector's 16-byte-aligned storage left the choice to the heap. Recreating the FFTSetup with buffers fixed changes nothing. Root cause; fixed in DspTap#9 by aligning the buffers.

Why the probe is shaped this way

The cross-process sweep alone cannot separate "the draw is fixed at process start" from "the draw follows the buffer address" — both are constant within a process. Only moving the buffers inside one process, with the setup held fixed, distinguishes them, which is why align exists and why it was the measurement that broke the case open.

The diff mode reports two metrics deliberately, because they disagree by ~1700× on this material and the whole argument in #31 turns on which is meant: peak-normalized absolute (what fft_backend_parity asserts and what the 4e-7 bound was measured as) and per-bin relative (what the chain is sensitive to, and what #31's perturbation study injected).

Verification

  • The workflow ran on the M1 runner and produced everything above.
  • Locally on macOS 15.7.7 / AppleClang 17 / Intel: vDSP reports deterministic, 200/200 identical at every size, with align showing no movement. A useful negative — the dispatch is specific to the newer stack, so a local Intel Mac cannot substitute for the runner.
  • diff has no natural case on a deterministic host, so it was exercised against synthesized draws: a 1-ULP perturbation classifies as epsilon (1.19e-07), a 1e-3 perturbation as past the breaking point.
  • align's replication of the wrapper is confirmed faithful by its offset-0 hash matching the wrapper's own output hash exactly.

Notes for the reviewer

🤖 Generated with Claude Code

https://claude.ai/code/session_01FVTJEw3TW1sMS1nq26rGmh

tap and others added 3 commits August 10, 2026 13:29
…nd why

#31 established the mechanism — vDSP_fft_zrip at N=2048 returns one of two
bit-exact outputs for identical input, drawn once per process — but left
three questions open that between them decide what to actually do about it.
This adds the measurements, and a dispatchable workflow to run them on the
runner where the split occurs.

1a. WHICH SIZES. Only N=512 and N=2048 have ever been probed. The sweep now
covers every power of two from 64 to 8192, so a consumer can tell whether
its geometry is exposed at all — the suppressor's N=2048 is not the only
geometry in the family.

1b. HOW FAR APART. The draws were hashed but never compared, and this is
the fork in the road. A gap >= 1e-4 relative means vDSP violates its own
documented 4e-7 contract and this is Apple's bug, fixed by turning the
backend off. A gap at ~1 ULP means the contract holds and OUR chain
bifurcates on a legitimate backend difference — which no threshold change
addresses and which would recur on any future toolchain. The diff mode
reports per-bin relative error and classifies the result against both
boundaries. Note this is in tension with the perturbation study earlier in
#31, which found the failing row survives 1e-5 injection and needs 1e-4 to
break; that tension is exactly what the measurement resolves.

1c. WHAT SELECTS THE DRAW. The cross-process sweep cannot separate "fixed
at process start" from "follows the heap address" — both are constant
within a process. The align mode moves the split-complex buffers and the
interleaved input to deliberate byte offsets within ONE process, holding
the FFTSetup fixed, and then holds the buffers fixed while recreating the
setup. If the hash tracks the offset, over-aligning m_rp/m_ip in DspTap's
wrapper fixes this while keeping vDSP and its ~3x speedup — the only
candidate outcome that costs nothing.

The workflow is macOS-only and deliberately separate from ci.yml: it is
temporary, it should be dispatchable on demand, and deleting it when #31
closes should be one file rather than a surgical edit.

Validated locally on macOS 15.7.7 / AppleClang 17 / Intel i9, where the
probe reports vDSP DETERMINISTIC — 200/200 identical at every size 64-8192,
including 2048, with the align probe showing no movement across ten buffer
offsets or eight fresh setups. That is a useful negative: the split is not
universal to vDSP, so it is specific to the newer stack (M1 and/or macOS 26
/ Xcode 26), and a local Intel Mac cannot substitute for the runner. The
align and diff paths were exercised against synthesized draws to confirm
they classify a 1-ULP gap and a 1e-3 gap correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVTJEw3TW1sMS1nq26rGmh
… size

Measured on this Intel Mac while validating the probe, and it changes what
the diagnostic should be asking.

The verdict line claimed a max per-bin relative gap of >=1e-4 meant "the
backend violates its contract outright". That was overstated, because the
contract and the chain are stated in DIFFERENT metrics, and on real data
they disagree by ~1700x:

  vDSP vs Ooura, N=2048, same host, same input
    peak-normalized absolute  1.57e-07   WITHIN the documented 4e-7
    per-bin relative          2.68e-04   PAST the 1e-4 at which #31's
                                         perturbation study broke the row

Both numbers describe the same difference. fft_backend_parity asserts the
first (5e-6 * peak) and "agrees to <4e-7 relative" was measured as the
first; the chain is sensitive to the second. So the honest reading is not
that Apple is broken, nor that our chain is spontaneously fragile — it is
that the contract is stated in a metric that does not bound what this
consumer needs. Report both, and say that plainly when they disagree.

This also resolves the tension flagged when the sweep was added: the
perturbation study found the row needs >=1e-4 per-bin relative to break,
and the real vDSP-vs-Ooura difference is 2.68e-04 per-bin relative. The
study's model predicted this correctly; it was being compared against the
wrong metric.

The new 1b' step makes the cross-backend diff a first-class measurement at
every size. Unlike the draw-vs-draw diff it exists on every host, not only
one that bifurcates — on Intel, where vDSP is fully deterministic across
200 processes, the cross-backend gap is still there and still past the
chain's breaking point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVTJEw3TW1sMS1nq26rGmh
Formatting only, no behaviour change. The file is compiled directly by the
diagnostic workflow rather than through CMake, so nothing but the style gate
reads it — and the style gate is what caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVTJEw3TW1sMS1nq26rGmh
@tap tap changed the title diag: which FFT sizes bifurcate, how far apart the draws are, and what selects them diag: the float32 FFT probe that found #31's root cause Aug 11, 2026
tap and others added 3 commits August 11, 2026 07:44
Pins the DspTap alignment fix here, and adds the round-2 experiment that
should have been run before the fix was called a fix.

The alignment change made vDSP deterministic by forcing every process onto
the 64-byte-aligned kernel. Nobody checked whether that is the kernel on
which the compliance rows PASS. The 140/60 split was measured on FFT output
hashes, not on test outcomes, so "the majority path" says nothing about
which path is good. If the tone rows pass on the other one, the fix
selected the losing kernel and the current account of #31 is wrong.

Three builds of one tree, same host, same run:

  aligned    as shipped
  unaligned  the fix deliberately defeated (+4 bytes past the boundary), so
             every process takes the non-64-aligned kernel
  ooura      backend control

Each runs the two failing tonal rows plus the broadband row that passes, 5
repetitions each. A row that fails 5/5 in one variant and passes 5/5 in
another is decisive. A row that wanders WITHIN a variant means a source of
variation survived the fix and the diagnosis is incomplete — which is the
outcome I most want to know about, because the claim that a ~1.7 ULP FFT
difference produces a 97 dB swing currently rests on two post-fix runs and
nothing else.

The patch that defeats the fix asserts its site appears exactly once.
Without that, a failed patch would silently build the same thing twice and
report it as a clean A/B — the most plausible way this experiment could lie.

Also re-runs the 200-process determinism sweep with the fix pinned. The
earlier sweep proved the bifurcation BEFORE the fix; this is the same
measurement after it, on the same host, and it is the in-situ check that
the wrapper is now invariant rather than merely passing DspTap's own tests.

Verified locally: the patch applies (assert passes, one site), the patched
tree compiles clean with -Wall -Wextra, and the submodule restores to a
clean checkout afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVTJEw3TW1sMS1nq26rGmh
Round 2 established that the compliance rows PASS on the non-64-aligned
kernel and FAIL on the aligned one — which means the alignment fix, by
forcing every process onto the aligned path, took the pass rate from ~30%
to 0%.

That result does not by itself justify flipping the constant. Two kernels
that differ by ~1.7 ULP can both be within contract, in which case picking
the one whose rounding the gates happen to like is choosing a lucky coin,
and the chain is still the thing that is broken. But if the aligned kernel
is materially LESS accurate — a real defect in Apple's aligned path — then
avoiding it is ordinary engineering.

The `truth` mode drives vDSP directly at both alignments within one process
and compares each against a double-precision transform of the same input
(error ~1e-15 at these sizes, so a legitimate stand-in for truth against
float32's ~1e-7). Two materials, since the failing rows are tonal and the
existing parity gate is broadband.

Locally on Intel both alignments report identical figures, which is the
expected null — Intel vDSP does not dispatch on alignment — so this only
proves the probe works. The M1 is the measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVTJEw3TW1sMS1nq26rGmh
…aterials

Two checks before acting on the round-2 result, because I have moved early
twice on this issue already.

1. THROUGH THE WRAPPER. The earlier accuracy numbers came from the probe
   driving vDSP directly with its own buffers, so "the aligned kernel is
   less accurate" could in principle have been an artefact of how the probe
   places them. The wrapper is now measured as compiled — built once stock
   (64-aligned) and once with the alignment defeated — which is the code
   path MuTap actually runs.

2. MORE MATERIALS. The finding rested on one on-bin tone at bin n/16. Now:
   broadband and an off-bin tone as controls (every bin carries energy, so
   both kernels should look identical), a second on-bin tone at n/8, and a
   sparse three-tone spectrum so a result cannot be pinned on the
   single-tone special case.

The prediction, if the round-2 reading is right: the two kernels agree on
broadband and off-bin material and diverge by orders of magnitude in the
p50 column on the sparse ones. If instead they diverge everywhere, or
nowhere, the reading is wrong and the remedy question reopens.

p50 is the column that matters and is worth saying why: on sparse material
max_rel saturates (a near-empty bin divided into anything is enormous, for
every backend) and abs/peak hides the damage (it normalizes by the loudest
bin, so an error confined to quiet bins vanishes). The median bin says
whether MOST of the spectrum is right, and it is the only one of the three
that separated the kernels.

Local Intel run: all three rows identical on every material, which is the
expected null — Intel vDSP does not dispatch on alignment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVTJEw3TW1sMS1nq26rGmh
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant