# Native Swift/Metal SSB redraw while dragging aberration controls, 2026-10-24

> **Historical:** describes the code as of 2026-10-24; the `complexWave` (`complex_wave`) estimator is retired. See the [current fit methods](../../../api/ssb.md).

## Question

Can the native `MetalSSBJointModel.reconstruct` re-render a fitted SSB phase at
30 frames per second (≤ 33 ms per frame, median and p90) while a user drags an
aberration control, after a fifth-order fit, at 3× output sampling, with every
BF pixel, all 14 aberration terms, sample tilt and depth spread, float32 and
complex64, and the default `phase_of_mean` estimator?

## Workload and boundary

- Apple M5 Max, 40-core GPU, 128 GB unified memory; the benchmark checks the
  Metal device name before timing. No thermal or performance warning was
  recorded. No other GPU job ran during the timings.
- Geometry of a 512 × 512 × 192 × 192 acquisition at 300 kV, 30 mrad and a
  0.373 Å scan step: 8,804 BF pixels (radius 52.95 px). The 256 × 256 rows use
  the same detector and step. Counts are deterministic synthetic values; the
  work depends on the geometry, grid and fitted terms, not on count values.
- Low order: C10, C12, φ12 with tilt (1.2, −0.8) mrad and 15 nm depth spread.
  Fifth order: the same plus all 12 higher-order terms nonzero (C21 … C56).
- One observation = one `reconstruct` call with a changed C10, synchronized:
  correction, mirror, origin, inverse FFT and phase into a host-visible buffer.
  Engine preparation and the first 3 calls are excluded; 20 warm observations.
  App display, colormap and FFT panel are excluded.
- Runner: `swift run -c release metal-ssb-redraw-benchmark --scans 256,512`
  (`native/swift/Benchmarks/MetalSSBRedrawBenchmark`). Before = the same runner
  built against `f5599f73`; after = `518c6761`. Both ran in one quiet session.

## Results

Per-frame time, median / p90, ms.

| Scan | Order | Output | Before | After | Speedup |
| --- | --- | --- | ---: | ---: | ---: |
| 256² | low | 1× | 13.5 / 14.0 | 5.8 / 5.9 | 2.3× |
| 256² | low | 2× | 25.3 / 25.7 | 8.7 / 8.8 | 2.9× |
| 256² | low | 3× | 24.3 / 24.8 | 8.9 / 9.1 | 2.7× |
| 256² | low | 4× | 24.3 / 24.9 | 9.4 / 9.5 | 2.6× |
| 256² | fifth | 1× | 29.1 / 29.3 | 6.4 / 7.1 | 4.6× |
| 256² | fifth | 2× | 62.8 / 63.7 | 9.9 / 10.1 | 6.4× |
| 256² | fifth | 3× | 67.9 / 69.3 | 9.9 / 10.0 | 6.9× |
| 256² | fifth | 4× | 68.1 / 68.9 | 10.3 / 10.4 | 6.6× |
| 512² | low | 1× | 29.9 / 30.6 | 20.5 / 21.2 | 1.5× |
| 512² | low | 2× | 65.3 / 67.0 | 30.5 / 31.6 | 2.1× |
| 512² | low | 3× | 60.4 / 64.3 | 31.2 / 31.5 | 1.9× |
| 512² | low | 4× | 60.6 / 61.6 | 32.2 / 33.0 | 1.9× |
| 512² | fifth | 1× | 101.5 / 102.9 | 21.8 / 22.7 | 4.7× |
| 512² | fifth | 2× | 203.0 / 212.3 | 34.6 / 35.1 | 5.9× |
| 512² | fifth | 3× | 214.9 / 223.8 | 33.7 / 33.9 | 6.4× |
| 512² | fifth | 4× | 224.6 / 227.2 | 35.7 / 36.3 | 6.3× |

The table's "after" column ran directly after the ~10-minute "before" sweep.
The target case run alone, twice: 28.8 ms median / 29.8–30.0 ms p90 (34.7 FPS).
300 consecutive frames: 29.2 ms median / 32.2 ms p90. The 30 FPS target at
512² fifth order 3× is therefore met for a drag, with about 1 ms p90 margin;
after many minutes of continuous load it sits at the 33 ms line. 2× and 4×
at 512² fifth order are 1–3 ms above it.

60 FPS at 512² 1× is not reachable on this machine with every BF pixel in
complex64: the 9.3 GB Fourier cache G(q, k) must be read each frame, and
measured read bandwidth is 450–510 GB/s (≥ 19 ms). Fifth-order 1× reaches
45.9 FPS. At 256², every case exceeds 97 FPS.

## What changed (`ssb_redraw.metal`)

1. Anti-Hermitian half: Γ(−q, k) = −conj Γ(q, k) also for odd orders, because
   the minus sideband uses χ(k − q); only source rows 0 … N/2 are evaluated and
   mirrored, with the output Nyquist column evaluated directly.
2. One thread per source frequency visits its output aliases inside each
   32-pixel batch, so aliases reread that batch of G from cache. Per-alias
   threads streamed G from DRAM once per alias (~17 GB per frame at 3×).
3. χ as a row-collapsed polynomial: per BF pixel and output row, χ is a degree
   ≤ 6 polynomial in the output column; the threadgroup computes the
   coefficients once, and each pair costs one Horner evaluation. Bivariate
   coefficients are built on the host in double precision from the packed
   float32 terms.
4. Conservative per-SIMD-group compaction: each group keeps only the BF pixels
   whose minus or plus sideband can open in its column interval (94% lane
   use), split into minus-only, plus-only and both. With one open sideband,
   conj Γ / |Γ| = ±exp(iΔχ): aperture and depth magnitudes cancel. Only the
   sinc sign is used, with a guarded fallback to the general formula near the
   1e-8 normalization floor.
5. `fast::sincos`, which is bit-identical to `precise::sincos` for |x| ≤ 200
   rad on this GPU, branch-free aperture weights, and padded float4 rows.

8× keeps the earlier per-term kernel.

## Parity

- Against the per-term kernel (`SSBRedrawReconstructionTests`): max wrapped
  phase error 5.2e-8 rad, RMS 1.5e-8 at 128²; 1.4e-7 / 5.6e-8 at 256². This
  covers 1–4×, cached and streamed spectra, low and fifth order, with and
  without depth. The stated bound is 1e-5 / 1e-6.
- Against an independent double-precision fifth-order definition: ≤ 1.1e-7
  rad, bound 2e-4.
- Full-size benchmark phases, after vs before: ≤ 3.9e-6 rad at 512², ≤ 8.5e-7
  at 256², correlation ≥ 0.99999972. These synthetic phases have small
  contrast (std 1e-3 rad).
- The new `complexWave` estimator agrees with Python MPS
  `phase_estimator="complex_wave"`: 2.5e-8 rad in phase, 3.1e-7 of the mean
  amplitude, on a committed synthetic fixture.

## Rejected candidates

These variants were measured and not kept:

- Accumulators for all F² aliases in registers: correct but slower (53–57 ms).
- G staged in threadgroup memory: 52–55 ms. Threadgroup memory shares the
  on-chip pool with registers and cache on this GPU.
- Several columns per thread, two pixels per iteration, software prefetch,
  rint-based sign: neutral or slower.

A bug found during development: a barrier inside non-uniform control flow
produced a nondeterministic race; the shipped kernel's barriers are uniform.

## Limits

- The app's on-screen render time and displayed FPS were not measured: control
  of the app was declined for the headed check, so the headed check remains
  open. Kernel time is not displayed frame rate.
- Synthetic counts at the real geometry; the real acquisition's BF count can
  differ slightly (8,807 vs 8,804).
- One Apple GPU; other Apple GPUs are not qualified.

Private evidence: experiment `20261024-metal-ssb-interactive` (raw JSON, logs,
phase dumps). Kernel SHA-256 (`ssb_redraw.metal`):
`b20d7914811d0fb2941d59907ba6e15607829f0988b08498f262d9942a3a5e34`.
