GPU detector execution¶
Install the optional CUDA backend and select it when constructing a persistent camera:
python -m pip install 'getframes[gpu]'
import cupy as cp
import getframes as gf
camera = gf.Camera.from_preset(
"andor_ocam2k",
device="gpu",
precision="single",
)
photon_rate = cp.full(camera.resolution, 2.0e6, dtype=cp.float32)
frame = camera.expose(photon_rate, exposure=1.0e-3, seed=0)
assert frame.device == "gpu"
adu_gpu = frame.data
truth_gpu = frame.truth.mean_electrons
adu_cpu = gf.to_numpy(adu_gpu)
The complete detector chain stays on the selected device: fixed PRNU/DSNU and
defect maps, photo and dark expectations, Poisson/read/reset noise, stochastic
EM/eAPD gain, cosmic rays, blooming, CTI, IPC, nonlinearity, amplifier maps,
binning, truth, and ADU digitisation. Wavelength-resolved
Camera.expose_spectral likewise preserves its incident cube and integrated
truth on device. Static fixed-pattern maps are constructed in the camera's working
precision and cached once, so reuse the same Camera in a frame loop.
Choosing a device¶
device takes the words shared across the AO stack:
device |
Runs on |
|---|---|
"cpu" (default) |
NumPy, the reference implementation |
"gpu" |
CuPy on the CUDA device current when the camera is built |
"gpu:N" |
CuPy on CUDA device N |
"auto" |
the current CUDA device when CuPy is installed and sees one, else the CPU |
Matching is case-insensitive, and the older spellings still work: "numpy" for
"cpu", "cuda"/"cupy" for "gpu" (also "cuda:N"). An unknown word, or a
"gpu:N" beyond the devices CuPy sees, raises ValueError naming the device
count; "gpu" without CuPy raises ImportError. "auto" never raises for a
missing GPU, so one script runs on a laptop and a CUDA workstation alike:
camera = gf.Camera.from_preset("andor_ocam2k", device="auto", precision="single")
camera.device # "gpu" or "cpu"
camera.device_id # CUDA device number, or None on the CPU
A GPU camera is pinned to one card. Its fixed-pattern maps and its cuRAND
streams are created on that device, and every camera method runs with it
current, so device="gpu:1" works whichever device is current when you call
it; Camera.with_config keeps the same card. The low-level
getframes.noise functions run on the current device; to use
them on another card, enter the backend's device context:
backend = gf.get_backend("gpu:1")
with backend.activate():
... # noise.simulate_frame(..., backend=backend)
precision is "single" (float32) or "double" (float64, the default), with
"float32"/"float64" accepted as aliases; see
Scale & datasets.
Host transfers¶
frame.data is the zero-copy device interface. np.asarray(frame),
Frame.stats(), and Frame.to_fits() are explicit host-facing operations and
copy GPU data. Use getframes.to_numpy() when a named host boundary is clearer.
Reproducibility and parity¶
A per-call seed repeats exactly on the same backend and dependency version. NumPy and CuPy use independent generators, as do most scientific CPU/GPU packages; the same integer seed is not expected to produce identical pixels across devices. Tests instead compare mean and variance against the same detector physics and verify deterministic repetition separately on each backend.
Reference throughput¶
The July 2026 development reference used an AMD Ryzen 9 9950X3D and NVIDIA RTX 5090 with Python 3.12, NumPy 2.2.6, SciPy 1.16.3, and CuPy 13.6.0. Each cell reused one persistent float32 camera, performed ten untimed warm-up frames, and then ran for at least two seconds. Photon-rate maps, detector truth, and ADU stayed on the selected device; camera construction and host transfers were not timed. The camera RNG was seeded once and advanced for every frame. CUDA was synchronized immediately before and after each timed region.
| Workflow | Detector | Native shape | CPU (frames/s) | GPU (frames/s) | Speedup |
|---|---|---|---|---|---|
| Pyramid WFS CMOS | CMOS | 80x80 | 5,264.9 | 10,565.4 | 2.01x |
| Shack-Hartmann WFS CMOS | CMOS | 160x160 | 1,372.0 | 10,538.1 | 7.68x |
| OCAM2K EMCCD | EMCCD | 240x240 | 347.7 | 8,057.1 | 23.17x |
| SAPHIRA eAPD | eAPD | 256x320 | 283.2 | 7,481.8 | 26.42x |
| Large science CMOS | CMOS | 1024x1024 | 31.5 | 1,431.9 | 45.39x |
Higher frames/s is better. Relative to the original GPU implementation on the same machine, the optimized path is 1.36x–1.42x faster for CMOS and about 2.35x–2.41x faster for EMCCD/eAPD. The hot path now reuses a reseedable GPU RNG, samples directly in float32, applies Gamma gain without mask/gather/scatter or a host synchronization, and reuses the realized-electron buffer during in-place digitization. CPU float32 Gaussian sampling and buffer reuse improve the large CMOS case by about 11%.
The benchmark artifact records the exact command, revision, dirty-checkout flag, environment, methodology, elapsed time, frame count, frames/s, and megapixels/s for every cell. The checked-in snapshot was intentionally recorded from the GPU development checkout, so it is evidence rather than a release guarantee. See the rendered snapshot and raw JSON. An Arm data point (Ampere Neoverse-N1, 16 pinned cores, RTX 4060 and RTX A400) is in device-results-neoverse-n1.md.
Small frames: fused kernels¶
A wavefront-sensor frame (80x80 to 240x240 pixels) is limited by the host issuing kernels, not by the GPU running them: on a slow host core each CuPy call costs 10–20 µs, and the 2.4.0 chain issued about twenty per frame, three of them uploads of a scalar input. Since 2.5.0 the GPU path:
- computes the photo and total expectations in one kernel, and the whole
readout (full-well clip, defects, reset/avalanche/read noise, gain, bias
pedestal and structure, common mode, rounding, ADC saturation and the
uint32conversion) in another; - keeps scalar inputs (
background,extra_electrons, the EM-gain scale, the CIC rate) on the host instead of uploading them every frame; - samples Poisson counts directly in the working precision, and reuses the
chain's
photo + dark + extrasum as the truth instead of adding it again.
The noise is still drawn by the same CuPy calls in the same order, and the fused
kernels perform the same floating-point operations in the same order and
precision with FMA contraction disabled, so seeded frames are bit-identical to
2.4.0 (tests/test_gpu.py checks this against the separate operations).
Measured on an Ampere Neoverse-N1 host (12 pinned cores), median of three
interleaved bench_devices.py runs per cell, frames/s:
| Workflow | Native shape | RTX 4060 2.4.0 | RTX 4060 2.5.0 | RTX A400 2.4.0 | RTX A400 2.5.0 |
|---|---|---|---|---|---|
| Pyramid WFS CMOS | 80x80 | 2,422 | 6,418 (2.65x) | 2,403 | 6,315 (2.63x) |
| Shack-Hartmann WFS CMOS | 160x160 | 2,424 | 6,340 (2.62x) | 1,698 | 1,794 (1.06x) |
| OCAM2K EMCCD | 240x240 | 1,650 | 2,733 (1.66x) | 517 | 531 (1.03x) |
| SAPHIRA eAPD | 256x320 | 1,809 | 2,072 (1.15x) | 384 | 393 (1.02x) |
| Large science CMOS | 1024x1024 | 240 | 247 (1.03x) | 42 | 45 (1.06x) |
Once the host overhead is gone a frame is bound by CuPy's Poisson and Gamma samplers, which work in double precision: consumer GPUs run that at a small fraction of their single-precision rate (1/64 on the RTX 4060), so larger frames and the A400 gain little. The NumPy path is bound the same way by NumPy's Poisson sampler (about 80% of a 1024x1024 frame); its draws come from one sequential stream, so they cannot be split across threads without changing seeded output.
An additional owner-isolation benchmark covers structured-detector digitization.
On this repository's Quadro P620, native float32 amplifier and bias maps reduced
the 2048x2048 digitization median from 9.196 ms to 6.654 ms (1.382x); the local
CPU median fell from 29.712 ms to 22.373 ms (1.328x). Persistent coefficient
storage fell from 96 MiB to 48 MiB. These numbers exclude stochastic detector
stages and are not full-exposure speedup claims; the alternating raw record is
fixed-map-dtype-results.json.
Reproduce and render it from the repository root:
python benchmarks/bench_devices.py --seconds 2 --warmup 10 --device both \
--output benchmarks/device-results.json
python benchmarks/render_device_table.py benchmarks/device-results.json \
--output benchmarks/device-results.md
Do not compare unsynchronized CUDA submission time with completed CPU work. Also do not construct a camera per frame: that includes fixed-pattern generation and defeats the persistent state used by a real frame loop.