Precision, training and reconstruction#
Projector, fbp and fdk expose backend and precision explicitly.
The native NVIDIA path remains the default when compatible. The PyTorch
backend uses the same cell-constant Siddon model and physical coordinates.
Setting |
Computation |
Devices |
|---|---|---|
|
Native FP32 for scalar or per-axis spacing and views/space partitions; otherwise PyTorch FP32 |
NVIDIA CUDA, or the input’s PyTorch device |
|
Native FP32 fast-math traversal, with FP64 ray clipping and entry setup |
NVIDIA CUDA; |
|
PyTorch FP32 data and local traversal, with FP64 ray clipping and entry setup (FP32 on Apple MPS) |
CPU, NVIDIA CUDA, PyTorch ROCm, Apple MPS |
|
PyTorch FP64 throughout the data operations, including analytical FFT/filter/gather |
CPU, NVIDIA CUDA, PyTorch ROCm |
backend="auto" selects PyTorch for strict precision modes, hybrid
ownership, or parallel3d beams. An incompatible explicit Numba
request raises an error. Projector.backend, .precision and .dtype describe the selected
execution. Supplying a float64 input to precision="fast" still yields FP32.
Create trajectories and data in float64 for a double-precision baseline;
casting an already rounded trajectory cannot recover its original precision.
Matched interpolation/traversal coefficients do not promise bitwise
determinism of floating-point scatter accumulation.
FP32 fan/cone modes retain the acquisition distance guards: each view (or
sampled surface ray) needs one endpoint within 1e6 voxels of the volume
center, and both endpoints within 1e15. With per-axis pitches, distance is
norm((x/dx, y/dy[, z/dz])). FP64 validates finite, distinct endpoints without
these FP32 cutoffs. Segment setup starts from the endpoint nearer the volume
center, then clips and locates each tile’s entry using bounded per-ray FP64
vectors. Traversal parameters are measured locally from that entry in the
selected precision, preserving voxel widths when both endpoints are distant.
Native surface preprocessing can preserve a higher-precision geometry dtype
before staging completed rays to FP32; its data transforms retain that behavior.
Explicit PyTorch FP32 validates component arithmetic in FP32. Use FP64 when
that arithmetic rounds distinct source/pixel coordinates together.
Install the appropriate PyTorch build first. pip install diffct suffices for
the PyTorch backend. Importing the library and using this backend do not load
Numba CUDA or require a CUDA runtime; native execution initializes them on
first use. The CUDA compiler extras are needed for native Numba execution.
On Apple Silicon (MPS) the working precision of ray clipping and entry setup
falls back to FP32, since MPS provides no FP64. The error of that FP32
clipping grows with the source distance: measured against CPU on a 64³ cone
scan with 16 views, the maximum relative sinogram error is ~3e-5 at
sid = 1e3 voxels, ~9e-3 at 1e5 and ~4e-2 at 1e6. Normal CT geometries sit
at the low end of that range; keep large-scale simulations on CUDA or CPU
when FP64-grade clipping accuracy matters.
ROCm uses PyTorch’s "cuda" device namespace, as described in the
PyTorch HIP notes.
Hardware support depends on the installed PyTorch build and process-group
backend. Existing published native performance numbers do not measure the
portable precision backend or hybrid execution.
Call-time trajectory validation#
An ordinary flat detector’s trajectory override is checked at its corners, with work proportional to the view count. If these bounds cannot exclude a source/pixel coincidence in the execution dtype, the affected views fall back to bounded pixel checks. Curved detectors still check sampled pixels. Native admission snapshots surface frames and parameters on CPU, then validates owned batches on compute devices. Validation batches shrink to fit memory and staging limits; they are separate from execution chunks. Portable admission stages frames once per validation device.
Retained constructor frames with gradients use the same admission on every
call, so optimizer updates cannot bypass geometry checks. Flat CUDA frames
stay on an accelerator for range and pixel checks; only scalar decisions
need host reads. Fixed constructor trajectories remain snapshots.
Updated parameterized surfaces are checked on portable calls and native
data transforms. Native tensor-returning calls check each batch before use;
*_into calls preflight all batches before changing caller output.
Native CUDA calls with either surface interface use full execution when the device memory, allocation and reservation limits admit the workload. Full geometry backward retains the surface composition graph on the compute device and pulls back all owned pixels together. This avoids per-row host autograd replay. Explicit chunk limits and workloads that cannot fit still use bounded streaming.
Run python examples/benchmark_surface_geometry.py for a synthetic helical
scan with 1338 views, a 736x64 curved detector and a 96x512x512 volume. It reports
separate synchronized forward, adjoint and geometry-gradient timings for both
surface interfaces. By default it reports and skips streamed plans; use
--allow-streamed to time that potentially slower fallback.
Run python examples/benchmark_trajectory_admission.py --warmup 1 --samples 3
to measure this validation with CPU and CUDA trajectories. The script bypasses
numerical projection and reports admission time, plus a separate untimed pass
counting validation rays and tensor transfers. These are not projection
throughput measurements. For CPU-only admission measurements, add
--backend torch --frame-device cpu.
Run python examples/benchmark_gpu_overheads.py --warmup 3 --samples 9 to
measure launch and transfer overheads on a small synthetic cone-beam case. It
reports synchronized wall times and incremental peak allocation for trainable
trajectories, legacy, per-view and sampled surfaces, CGLS, SIRT, TV and tiled
FDK. Run it once for each checkout with PYTHONPATH set to that checkout.
Small cases expose dispatch costs; do not extrapolate their ratios to large,
traversal-bound reconstructions.
Batch dimensions and axis spacing#
Any leading batch/channel dimensions precede the configured volume or ray
shape. Projection and adjoint preserve them, including empty dimensions.
voxel_spacing=(dy, dx) or (dz, dy, dx) follows tensor axis order; trajectory
coordinates remain (x, y[, z]). Spacings are fixed physical settings.
Both backends support per-axis spacing. Native traversal scales each world
coordinate by its axis pitch and integrates in physical length units.
Each native per-axis pitch must lie in the closed practical range [1e-6, 1e6].
The largest pitch divided by the smallest must not exceed 1e4.
These bounds are checked before FP32 conversion. FP32 representability alone
does not prevent extreme pitches from erasing ray segments or overflowing
geometry gradients. The conservative range limits these scaling effects;
existing FP32 geometry range checks still apply.
backend="numba" raises outside these bounds. backend="auto" selects PyTorch;
on NVIDIA CUDA, its fallback warning names the violated bound.
Scalar spacing retains its existing admission checks.
Per-axis finite rays are degenerate only when their endpoints coincide.
Detector offsets multiply their axis components before division by the pitch.
Endpoint metric derivatives use FP64 intermediates before the final FP32 cast.
Scalar spacing retains its existing arithmetic and degeneracy threshold.
Helical reconstruction accepts (dz, dy, dx) and uses dz for slice positions.
For 2D parallel rays, scalar spacing preserves the original direction-gradient convention. Per-axis spacing differentiates physical ray length. The gradients can differ in their radial component. Compare direction perturbations tangent to the required unit vector, or parameterize directions with angles.
import torch
from diffct import Projector, circular_trajectory_2d_parallel
trajectory = circular_trajectory_2d_parallel(12, device="cpu", dtype=torch.float64)
A = Projector(trajectory, (8, 10), 16, beam="parallel",
backend="torch", precision="float64", voxel_spacing=(0.7, 0.5),
volume_chunk_shape=(4, 5), view_chunk_size=3)
x = torch.ones(2, 3, 8, 10, dtype=torch.float64, requires_grad=True)
y = A.project(x) # (2, 3, 12, 16)
tangent = torch.ones_like(x)
value, jvp = torch.func.jvp(A.project, (x,), (tangent,))
batched = torch.vmap(A.project)(x) # same leading dimensions
Data gradients, JVP, vmap, jacrev, jacfwd and second data derivatives use
the portable PyTorch traversal when a function transform is active.
Ordinary native calls retain Numba execution. Native batches dispatch their
samples separately. Geometry and surface derivatives are first-order.
Forward-mode derivatives (torch.func.jvp, jacfwd) with respect to trajectory
tensors raise an error. Other function transforms of geometry and
torch.compile are not promised.
Distributed data grad, JVP, vmap and Hessian-vector products require every
rank to call the same transforms in the same order, with matching leading batch
sizes. Inputs and tangent slices describe one coherent global tensor; scalar
losses follow the replica divisors below. Dense per-rank Jacobians have no
global ownership convention in this API; use a single-process projector for
jacrev/jacfwd matrices.
Streaming analytical reconstruction#
fbp/fdk accept view_chunk_size, volume_chunk_shape, devices,
distributed, process_group, partition and partition_grid. Global angles,
quadrature and the isocenter are resolved once, before processing view batches.
Fan/cone normalization uses each view’s SID and SDD. Filtering needs complete
detector-U rows; the view limit bounds those resident FFT rows. Spatial gathers
use global voxel centers even for partial tiles and owned slabs.
fbp_into/fdk_into read and write TensorStore, NpyStore or compatible block
stores. They return the supplied output object. Shapes, writable state, aliases,
internal output overlap, dtype and geometry/options are checked before data I/O.
Output dtype must match the precision mode. These methods are numerical and
reject autograd inputs/outputs/weights. Tensor-returning APIs retain gradients
for data, explicit weights and filter responses. Analytical geometry remains
fixed and flat; FDK remains an approximate inverse.
Explicit chunks bound working tiles and view batches; autograd still needs its
complete returned arrays. Portable autograd does not record the ray traversal.
This also applies inside torch.vmap and torch.func.jvp when an input or
tangent requires gradients. It keeps the geometry snapshot, and the input only
when geometry gradients are needed. Backward applies the matched adjoint for
data gradients. For geometry gradients, it recomputes one ray batch and tile at
a time, so that memory does not grow with the number of batches. Each geometry
batch keeps a small autograd graph, so automatic geometry batches use a smaller
budget (see below) and large leading batches are split, as in the forward call.
Geometry gradients cost one extra traversal. Returned arrays and gradients
still require their own memory.
Projector’s portable path sizes its automatically chunked intermediate arrays from the compute device. Caller inputs, the returned array and allocations inside surface callbacks or samplers are excluded.
CUDA compute devices (NVIDIA CUDA or PyTorch ROCm, through
devices=[...]or a CUDA input): one call uses at most a quarter of the memory that the PyTorch allocator can still use when the call starts, for its work arrays and distributed reduction buffers. A per-ray and per-voxel byte model sizes the work arrays. It counts the crossing tables of the volume tiles that a ray batch visits, the previous tile’s tables, volume tile copies, ray setup and validation. Outputs accumulate in place, so the call makes no temporary copy of the full output. Withdevices=[...], the smallest device sets the size. Distributed ranks use the smallest value of all ranks, because their collectives follow the same tile and batch order. Distributed backward through a backprojection sums one cotangent tile at a time. There is no floor: with very little memory the plan shrinks to one voxel and one ray per step. One work array stays below 2^31 elements.CPU inputs, outputs or
*_intostores with CUDA compute devices add a host staging limit. It is the smaller of a quarter of the free host memory and the CUDA limit, but never less than 8 MiB, the fixed CPU ceiling. Where the platform does not report free host memory, the limit is 8 MiB.CPU and other devices keep a fixed 8 MiB ceiling and their previous plans.
Geometry backward recomputes each ray batch and tile with the CUDA limit
divided by two. One recomputed pair, including its autograd graph and
torch.autograd.grad, measured at most 1.3 times the work-array model of its
plan, so a pair stays within the same quarter. Geometry backward may keep one
extra buffer as large as the incoming cotangent, outside that quarter.
This buffer makes a strided cotangent contiguous or holds its distributed sum.
Known limit: distributed geometry backward with replicated outputs may hold up
to three cotangent-sized buffers during the cotangent reduction. These buffers
are outside the quarter-memory bound. The geometry gradients need their own
memory. Large leading batches are split into pieces that every rank chooses
alike, and each piece of a forward call is copied into the returned array, so
at most one piece is extra.
Explicit chunk hints take priority over these limits. For large mapped batches,
also use torch.vmap(..., chunk_size=...) to control the transform’s workspace.
With backend="auto", a Projector that falls back to the portable backend on
an NVIDIA CUDA machine emits one UserWarning at construction. The warning
names the reasons: per-axis voxel_spacing outside the native practical range, a precision other than
"fast", partition="hybrid" or beam="parallel3d". Pass backend="torch"
to select the portable backend explicitly and silence the warning.
Analytical tile order can reread and refilter the source for another volume
tile. The native path admits tiles against available CUDA memory. The separate
helical_fdk approximation and low-level CUDA helpers retain their original
execution contracts.
Hybrid ownership and losses#
partition="hybrid", partition_grid=(view_ranks, space_ranks) uses
rank = view_rank * space_ranks + space_rank; the grid product must equal the
process-group size. With partition_grid=None, the operator chooses a near-square
factorization, with at least as many view ranks as space ranks.
Each space rank owns a balanced first-axis slab; each view rank owns a balanced
view shard. Both retain global physical coordinates.
Forward ray contributions sum across space ranks; adjoint/analytical volume
tiles sum across view ranks. Empty and uneven shards participate normally.
Distributed PyTorch-backend projectors require the same volume_chunk_shape,
view_chunk_size and detector_chunk_shape arguments on every rank.
Neither reduction gathers a whole volume.
Output/loss |
views |
space |
hybrid |
|---|---|---|---|
Projection loss replica divisor |
1 |
|
|
Adjoint/analytical image loss replica divisor |
|
1 |
|
Use local sums divided by the global physical element count for a global
mean; local .mean() fails for uneven or empty ownership. All participating
ranks run backward. Geometry/surface gradients already sum globally; avoid a
second DDP reduction. total_variation returns a global scalar with the correct
derivative for its owned volume input. If that input is a replicated output of
another distributed operator, apply that operator’s loss replica divisor.
Numerical solver API#
cgls, sirt and tv_reconstruction return
SolverResult(volume, iterations, residual_history, converged, reason).
History contains the initial residual and one value per completed iteration.
Convergence means ||y - A x|| <= atol + rtol * ||y||, including the initial
iterate. callback(iteration, residual, volume) runs after each completed step;
return False to stop. Inputs and optional x0 are preserved. Solves detach
their numerical state from autograd.
Without a callback, SIRT and TV reuse the residual of the previous history
entry for the next update, which saves one projection per iteration. With a
callback, and after a resume, they recompute the residual because the callback
can change the volume or the operator. CGLS reads its two scalar norms of an
iteration together.
from diffct import cgls, sirt, tv_reconstruction
result = cgls(A, y[0, 0], iterations=30, rtol=1e-5,
checkpoint_directory="checkpoints")
print(result.iterations, result.residual_history[-1], result.reason)
# SIRT controls: relaxation=1.0, nonnegative=True.
# TV controls: weight=1.0, lr=0.1; Adam minimizes half the global mean squared error
# plus physical mean TV and clamps the volume after every step.
total_variation uses forward differences divided by each axis pitch, zero
differences at the global outer boundary, and sqrt(sum(diff**2) + eps) at
every voxel. reduction="mean" divides by the global voxel count; "sum"
retains the sum. Spatial/hybrid execution exchanges neighboring faces and
their backward contributions, including uneven and empty slabs.
cgls_into(A, measurements, output, iterations, workspace=..., block_elements=...)
keeps state in mapped files and returns SolverResult.volume is output.
The workspace must be new; initialization, vector operations, norms,
fingerprints and checkpoint copies operate on bounded blocks. Measurements
are checked for finite values in bounded blocks before creating the workspace
or writing the output; every rank agrees on admission before execution.
Checkpoint paths are step-000002/rank-00000/metadata.json, for example.
Saving never replaces a step: when the rank directory of a step already
exists, the solve raises a ValueError and leaves the saved checkpoint
unchanged. Use a new checkpoint_directory for a new solve.
Resume from the corresponding rank directory using resume=...; iterations
is the total target, including saved steps. Complete CGLS/SIRT/Adam state and
residual history are restored. Fingerprints bind the acquisition, measurements,
precision, ownership and algorithm settings. Ranks validate consistency before
numerical execution, flush state before publishing completed metadata, and
reject mismatched/incomplete snapshots together. Every rank must agree on
whether x0, resume and checkpoint_directory are supplied; checkpoint and
resume paths may be rank-local. A different rank layout needs
a new solve rather than an implicit redistribution of the saved state.
Checkpoints fingerprint the actual sampled detector rays. Legacy surface
callbacks need complete detector grids; their possible per-view offset output
must fit the fingerprint block budget or the solve raises before execution.
Use ParameterizedSurface to sample and fingerprint larger surfaces in blocks.