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 |
|
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-resultsandservestay for Live4DSTEM;compatibility shims, aliases, dead optics functions, environment switches,
SourceIntegrity.
Fixed#
What |
Before |
After |
|---|---|---|
|
wrong counts in non-final chunks: 26/40 test runs at 3a8abad6 |
0/40; 160 probe loads clean |
Browse |
HTTP 500 |
exact, equal to dense-then-bin |
|
refused |
starts |
|
NotImplementedError on every cache miss |
works; integer products equal CUDA |
|
NotImplementedError |
exact, byte-equal to CUDA |
|
ImportError cupy |
works |
|
released the caller’s acquisition |
refused at construction; borrowed data never released |
|
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-callersbranches; denoise left uncommitted for review).Reproducing the
retainrace 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/TorchBackendfor 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 |
|
|
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 |
|
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 |
|
float image |
exact counts, or refused for float sources |
Parallax upsampling ( |
summed spectrum tiled: every odd row and column 0 |
each image tiled and shifted on the finer grid, as QuantEM’s |
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 |
unaligned staging, 6 tests failed |
page-locked through CuPy; |
WebGPU contract tests |
errored (no esbuild) |
|
CUDA |
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; |
Five MPS tests failing since 3a8abad6 |
stale expectations, a missing |
pass |
Docs naming removed features, |
present |
removed or rewritten; the |
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.