# quantem.gpu restructure: what was removed, moved and fixed

Date: 2026-10-05. Branch `gpu-refactor`, from 3a8abad6.

## Question

Can quantem.gpu be organized into layered, plainly named modules, with no legacy code, while every
number that survives stays bit-identical and MAPED stays under 16 GB?

Owner's direction (2026-10-04): no `from __future__`, Python 3.11 to 3.14, no file names starting
with an underscore, no legacy ("this is a very new code so it's meant to change a lot"), a good level
of modularity; callers in quantem.widget and quantem.live are updated rather than kept compatible.

## Setup

- CUDA: RTX PRO 6000 Blackwell (Linux), torch 2.13 + CUDA 13.0, CuPy 14.2 (cuda12x),
  Python 3.14 and Python 3.11.
- MPS: Apple M5 Max, Python 3.14, full suite per wave.
- Every wave: full CUDA suite on both interpreters and the MPS suite compared per test (junit XML),
  MAPED's three test files with real seven-tilt data, MAPED peak memory, annotation checker for
  Python 3.11 evaluation, import-layer checker.

## Size

| | 3a8abad6 | after |
|---|---|---|
| Python lines in the package | 89,556 | 48,121 |
| files with an underscore name | 42 | 0 |
| `from __future__` | 68 | 0 |
| import cycles between modules | 6 (closed by imports inside functions) | 0 |
| wheel | 5.4 MB (swift, vulkan, libhdf5.a inside) | 1.45 MB |

## Layout

Imports go downward only: `device` < `formats` (GPU-free files) < `resident` (data held on the GPU
and its exact reductions) < `io` (load, save, discover, inspect, read) < `detector`, `geometry` <
`dpc`, `parallax` < `screening` < `ssb` < `remote` < `cli`; `optics`, `display`, `movie` need only
`device`. Backend code sits in `cuda/`, `mps/`, `webgpu/` under the package that owns the science;
kernel and shader files sit beside the Python that compiles them. `swift/` and `vulkan/` moved to
`native/` (out of the wheel); `android/` and the Direct3D projects were deleted.

## Removed (nothing reached it)

`io.load` on a GPU returns encoded residents only (`StreamedCounts`, `MPSStreamedCounts`,
`FloatANSResident`), so these were unreachable or used only by test fakes:

- the packed QGPUH5/QGIX/uint4 family and the PACKED representation;
- the test-only block-rANS format and the seven-tilt rANS artifacts;
- the 66-acquisition prepared series and the source112 archive (Python, CUDA, TypeScript);
- dense, cropped, sparse, random, sharded and u8/u4 GPU load routes and their options;
- the MPS dense loader, lazy multi-dataset series and `MultiChunkedFrames`;
- the remote SSB and MAPED services (no client called them; every compute route failed on real
  data); the browse service, `/api/ssb/saved-results` and `serve` stay for Live4DSTEM;
- compatibility shims, aliases, dead optics functions, environment switches, `SourceIntegrity`.

## Fixed

| What | Before | After |
|---|---|---|
| `io.load` on CUDA under GPU contention (`retain()` copied pooled arrays without waiting; added in 3a8abad6) | wrong counts in non-final chunks: 26/40 test runs at 3a8abad6 | 0/40; 160 probe loads clean |
| Browse `scan_bin`/`det_bin`/crop (Live4DSTEM) | HTTP 500 | exact, equal to dense-then-bin |
| `serve` without `--implementation-revision` (Live4DSTEM-linux) | refused | starts |
| `screening.prepare(backend="mps")` (quantem.live) | NotImplementedError on every cache miss | works; integer products equal CUDA |
| `dpc.center_of_mass` on MPS | NotImplementedError | exact, byte-equal to CUDA |
| `detector.prepare([...])` on MPS | ImportError cupy | works |
| `SSB(loaded.data)` then `close()` | released the caller's acquisition | refused at construction; borrowed data never released |
| `parallax.run` / `geometry.rotate_scan` on `io.load` output | TypeError | work, bit-identical to the dense path |
| MPS virtual-image results | aliased a reused buffer | owned by the caller |
| Metal buffers per query (100 rounds, 100 images) | +85 MB | flat |
| Screening (256² and 512² scans) | 0.97 s, 2.91 s | 0.63 s, 1.56 s |
| Show4DSTEM view construction (bounded mean) | 3.2 s CUDA, 6.4 s MPS | 0.13 s, 0.14 s |

## Numbers

Every surviving path was compared before and after: frozen tests, SSB fingerprints (72 CUDA, 57 MPS
products at 128-512 scans), detector products (400, CUDA, NumPy, Torch, MPS), screening and browse
products on real acquisitions (bytes), encoded load checksums on real data, MAPED frozen shifts and
merge sums (19 tests). MAPED seven-tilt `run()` peak with row release: 11.3-11.5 GiB (unchanged).

## Rejected ideas

- Compatibility shims for moved private modules: rejected; quantem.widget, quantem.live and denoise
  were updated instead (widget and live committed on `gpu-refactor-callers` branches; denoise left
  uncommitted for review).
- Reproducing the `retain` race in-process with a busy CUDA stream: never reproduced; it needs a
  second process loading the GPU (a second process running large matrix products while loading).
- One `ArrayBackend`/`TorchBackend` for NumPy and Torch: NumPy input lost float64 accumulation, so
  the NumPy path keeps its own float64 backend.

## Fixed after the restructure

The same day, three parallel fix branches (merged into `gpu-refactor`) fixed every pre-existing bug
the restructure had reported. Numbers of correct paths stayed bit-identical; each fix below changes
its numbers on purpose and has a test that failed before.

| What | Before | After |
|---|---|---|
| SSB automatic `det_sampling` (no `det_sampling` given) | `2 * semiangle / R` with three radius rules: 1.0909 (CUDA), 1.1287 (MPS array), 2.3077 (MPS dataset) mrad per pixel on an Arina acquisition calibrated at 0.554 | `semiangle / R` with one half-plateau disk radius on CUDA and MPS: 0.5524; abTEM known answer 0.546875, new 0.5486 (+0.32 %) |
| CUDA SSB result loss before a fit | batched estimate, 0.0566 against exact 0.1405 (256 scan) | always the exact objective |
| MPS SSB fit | re-prepared its own evidence from the source | uses the session's prepared state |
| MPS SSB export of a NumPy session | `AttributeError` | exports; read-back reconstructs within 6e-10 rad |
| CUDA float virtual images of saturated frames | uint32 accumulator wrapped (65534.0 for a sum of 4,295,032,830) | exact uint64 sum, then float32 (4.295033e9) |
| Metal detector sums | int32 atomics | uint64 accumulation |
| CUDA weighted sums and centre of mass | 32 lanes of weight times count in int32 (detectors wider than 1024 could overflow) | exact weight digits, as MPS |
| MPS selected-frame sums and maxima | ignored flagged detector pixels | zero them, as CUDA does |
| `masked_sum_exact(output="native")` on precision or float sources | float image | exact counts, or refused for float sources |
| Parallax upsampling (`upsampling_factor=2`) | summed spectrum tiled: every odd row and column 0 | each image tiled and shifted on the finer grid, as QuantEM's `DirectPtychography.reconstruct`; equals a float64 oracle to 2.4e-7 |
| Parallax shifts | applied twice (image matched "shifted twice" to 6e-3) | applied once (float64 single-shift oracle to 2.3e-7) |
| Parallax repeatability | float32 atomics: shifts differed between identical runs (up to 1.6e-4 px) | fixed-order sums: bit-identical |
| CUDA NVENC movie under GPU load | 5 of 40 runs encoded a frame four frames early | 0 of 40; encoder copies on the kernels' stream |
| Metal movie labels | 16 pt (CUDA and CPU 24 pt) | one size on every writer |
| Paired direct I/O without `cuda.bindings` | unaligned staging, 6 tests failed | page-locked through CuPy; `io.load` of a 256 x 256 Arina scan in that environment 3.0-3.9 s to 0.2-0.5 s |
| WebGPU contract tests | errored (no esbuild) | `npm ci` installs the pinned JS tools; tests run |
| CUDA `io.load` of a bitshuffle/LZ4 `uint8` master | wrong counts without an error (16,319 of 16,384 values differ); MPS refused the file | one-byte unshuffle on CUDA and MPS: equal to h5py for full, partial and multi-block frames; `uint16`/`uint32` decode byte-identical |
| Five MPS tests failing since 3a8abad6 | stale expectations, a missing `pandas` on the test Mac, a temp-path assumption | pass |
| Docs naming removed features, `.ans` containers, compatibility shims | present | removed or rewritten; the `.ans` container leaves no trace outside dated records |

**Correction.** Every SSB reconstruction made with automatic detector sampling before this fix
used about twice the physical sampling: on the Arina acquisition only 2,460 of the 8,878 selected
bright-field pixels kept a nonzero aperture weight, and the fitted defocus moved from 1.71 nm to
4.16 nm once corrected. Re-run such fits, or pass the calibrated `det_sampling` explicitly. Details:
`docs/maintainer/2026-10-05-ssb-detector-sampling.md`.

## Standards and anonymization pass

The restructure held `src/` to the standards; `tests/` and `scripts/` still carried 64
`from __future__ import annotations` lines and two underscore file names. The future imports are
gone (an annotation checker run on Python 3.11 finds 0 problems in 209 files), and
`scripts/_benchmark_support.py` and `scripts/_webgpu_cdp.py` are `benchmark_support.py` and
`webgpu_cdp.py`. Scripts inside the dated experiment records, now kept in the private evidence
archive, keep their imports as recorded.

Retained experiment records named computers by private host nicknames and one dataset by its
device description. They now say `maca`, `macb`, `macc` and `cudahost` (single words, so the registry's
public ids such as `macbook-pro-m5-max-128gb-webgpu-resident-dpc-row` come out unchanged) and
`arina-device-master`. Both registry validators pass. The four fingerprinted scripts changed only
by the import line and the rename (syntax trees otherwise identical), and
`docs/performance/evidence_manifest.json` records the new fingerprints. The docs guard against
host nicknames compares sha256 digests, so the test no longer spells out the names it refuses.
Every image in the tree (10 documentation figures, 8 notebook outputs) shows the public gold
dataset; the HDF5, EMD, NumPy and `.qem` fixtures are synthetic.
