diffct 2.1.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {diffct-2.1.0 → diffct-2.2.0}/CHANGELOG.md +43 -0
  2. {diffct-2.1.0 → diffct-2.2.0}/CITATION.cff +2 -2
  3. {diffct-2.1.0 → diffct-2.2.0}/PKG-INFO +22 -17
  4. {diffct-2.1.0 → diffct-2.2.0}/README.md +21 -16
  5. {diffct-2.1.0 → diffct-2.2.0}/diffct/__init__.py +6 -1
  6. diffct-2.2.0/diffct/chunking.py +201 -0
  7. diffct-2.2.0/diffct/operators.py +2047 -0
  8. diffct-2.2.0/diffct/pipeline.py +258 -0
  9. {diffct-2.1.0 → diffct-2.2.0}/diffct/projectors.py +109 -204
  10. diffct-2.2.0/diffct/storage.py +125 -0
  11. diffct-2.2.0/diffct/streaming.py +48 -0
  12. diffct-2.2.0/diffct/surfaces.py +103 -0
  13. {diffct-2.1.0 → diffct-2.2.0}/examples/README.md +38 -0
  14. {diffct-2.1.0 → diffct-2.2.0}/formal/README.md +37 -0
  15. {diffct-2.1.0 → diffct-2.2.0}/pyproject.toml +1 -1
  16. diffct-2.2.0/tests/test_allocator_capacity.py +349 -0
  17. diffct-2.2.0/tests/test_axis_aligned_chords.py +118 -0
  18. diffct-2.2.0/tests/test_batched_surfaces.py +705 -0
  19. diffct-2.2.0/tests/test_block_cgls.py +996 -0
  20. diffct-2.2.0/tests/test_block_storage.py +539 -0
  21. diffct-2.2.0/tests/test_cgls_state_admission.py +173 -0
  22. {diffct-2.1.0 → diffct-2.2.0}/tests/test_chunked_projector.py +135 -39
  23. diffct-2.2.0/tests/test_completed_transfer_reuse.py +92 -0
  24. diffct-2.2.0/tests/test_cuda_sink_lifetimes.py +125 -0
  25. diffct-2.2.0/tests/test_example_psnr.py +75 -0
  26. diffct-2.2.0/tests/test_execution_buffers.py +613 -0
  27. diffct-2.2.0/tests/test_execution_schedules.py +582 -0
  28. diffct-2.2.0/tests/test_output_pipeline_overlap.py +130 -0
  29. diffct-2.2.0/tests/test_spatial_partition.py +433 -0
  30. diffct-2.2.0/tests/test_transfer_pipeline.py +646 -0
  31. diffct-2.2.0/tests/test_transfer_retirement_lifetimes.py +166 -0
  32. diffct-2.1.0/diffct/chunking.py +0 -73
  33. diffct-2.1.0/diffct/operators.py +0 -1033
  34. {diffct-2.1.0 → diffct-2.2.0}/.gitignore +0 -0
  35. {diffct-2.1.0 → diffct-2.2.0}/LICENSE +0 -0
  36. {diffct-2.1.0 → diffct-2.2.0}/NOTICE +0 -0
  37. {diffct-2.1.0 → diffct-2.2.0}/diffct/analytical.py +0 -0
  38. {diffct-2.1.0 → diffct-2.2.0}/diffct/constants.py +0 -0
  39. {diffct-2.1.0 → diffct-2.2.0}/diffct/differentiable.py +0 -0
  40. {diffct-2.1.0 → diffct-2.2.0}/diffct/geometry.py +0 -0
  41. {diffct-2.1.0 → diffct-2.2.0}/diffct/kernels/__init__.py +0 -0
  42. {diffct-2.1.0 → diffct-2.2.0}/diffct/kernels/cone_beam.py +0 -0
  43. {diffct-2.1.0 → diffct-2.2.0}/diffct/kernels/fan_beam.py +0 -0
  44. {diffct-2.1.0 → diffct-2.2.0}/diffct/kernels/geometry_vjp.py +0 -0
  45. {diffct-2.1.0 → diffct-2.2.0}/diffct/kernels/parallel_beam.py +0 -0
  46. {diffct-2.1.0 → diffct-2.2.0}/diffct/utils.py +0 -0
  47. {diffct-2.1.0 → diffct-2.2.0}/docs/video/README.md +0 -0
  48. {diffct-2.1.0 → diffct-2.2.0}/examples/data/NOTICE +0 -0
  49. {diffct-2.1.0 → diffct-2.2.0}/pytest.ini +0 -0
  50. {diffct-2.1.0 → diffct-2.2.0}/tests/__init__.py +0 -0
  51. {diffct-2.1.0 → diffct-2.2.0}/tests/_phantoms.py +0 -0
  52. {diffct-2.1.0 → diffct-2.2.0}/tests/benchmarks/README.md +0 -0
  53. {diffct-2.1.0 → diffct-2.2.0}/tests/benchmarks/__init__.py +0 -0
  54. {diffct-2.1.0 → diffct-2.2.0}/tests/benchmarks/_common.py +0 -0
  55. {diffct-2.1.0 → diffct-2.2.0}/tests/benchmarks/test_bench_cone.py +0 -0
  56. {diffct-2.1.0 → diffct-2.2.0}/tests/benchmarks/test_bench_fan.py +0 -0
  57. {diffct-2.1.0 → diffct-2.2.0}/tests/benchmarks/test_bench_parallel.py +0 -0
  58. {diffct-2.1.0 → diffct-2.2.0}/tests/distributed_projector_check.py +0 -0
  59. {diffct-2.1.0 → diffct-2.2.0}/tests/test_adjoint_inner_product.py +0 -0
  60. {diffct-2.1.0 → diffct-2.2.0}/tests/test_analytical_regressions.py +0 -0
  61. {diffct-2.1.0 → diffct-2.2.0}/tests/test_cone_projector_autograd.py +0 -0
  62. {diffct-2.1.0 → diffct-2.2.0}/tests/test_cuda_exit_deallocations.py +0 -0
  63. {diffct-2.1.0 → diffct-2.2.0}/tests/test_cuda_smoke.py +0 -0
  64. {diffct-2.1.0 → diffct-2.2.0}/tests/test_detector_surface_dtypes.py +0 -0
  65. {diffct-2.1.0 → diffct-2.2.0}/tests/test_detector_surface_mapping.py +0 -0
  66. {diffct-2.1.0 → diffct-2.2.0}/tests/test_detector_surface_units.py +0 -0
  67. {diffct-2.1.0 → diffct-2.2.0}/tests/test_detector_surfaces.py +0 -0
  68. {diffct-2.1.0 → diffct-2.2.0}/tests/test_fbp_fan_accuracy.py +0 -0
  69. {diffct-2.1.0 → diffct-2.2.0}/tests/test_fbp_fan_offsets.py +0 -0
  70. {diffct-2.1.0 → diffct-2.2.0}/tests/test_fbp_parallel_accuracy.py +0 -0
  71. {diffct-2.1.0 → diffct-2.2.0}/tests/test_fdk_cone_accuracy.py +0 -0
  72. {diffct-2.1.0 → diffct-2.2.0}/tests/test_fdk_cone_offsets.py +0 -0
  73. {diffct-2.1.0 → diffct-2.2.0}/tests/test_geometry_autograd.py +0 -0
  74. {diffct-2.1.0 → diffct-2.2.0}/tests/test_geometry_gradients.py +0 -0
  75. {diffct-2.1.0 → diffct-2.2.0}/tests/test_gradcheck.py +0 -0
  76. {diffct-2.1.0 → diffct-2.2.0}/tests/test_multi_gpu_exit.py +0 -0
  77. {diffct-2.1.0 → diffct-2.2.0}/tests/test_projector_api.py +0 -0
  78. {diffct-2.1.0 → diffct-2.2.0}/tests/test_projector_degenerate_geometry.py +0 -0
  79. {diffct-2.1.0 → diffct-2.2.0}/tests/test_projector_geometry_devices.py +0 -0
  80. {diffct-2.1.0 → diffct-2.2.0}/tests/test_ramp_filter_windows.py +0 -0
  81. {diffct-2.1.0 → diffct-2.2.0}/tests/test_release_2_0_1.py +0 -0
  82. {diffct-2.1.0 → diffct-2.2.0}/tests/test_weights.py +0 -0
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.2.0] - 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - Operation-specific automatic schedules and slab/block comparisons for
15
+ native projection and matched backprojection, with bounded CUDA pilots
16
+ and per-call execution/copy metadata.
17
+ - Detector-pixel tiling and explicit `ParameterizedSurface` batches using
18
+ global pixel/view coordinates, saved parameter/frame values and local VJP
19
+ reduction. Legacy two-argument surface callbacks remain compatible.
20
+ - Caller-owned numerical `project_into`/`backproject_into` outputs, tensor
21
+ block access and mapped `.npy` stores for data exceeding host RAM.
22
+ - Distributed `partition="space"` with fixed volume slabs, ownership-aware
23
+ autograd/norms and disk CGLS state/checkpoints without full-volume gathering.
24
+ - Independent CUDA tests and bounded formal protocol/ownership verification.
25
+
26
+ ### Changed
27
+
28
+ - GPU backprojection accumulators now span all contributing view batches,
29
+ with final transfer after tile completion. Contiguous native volume layouts
30
+ and ray buffers are reused across batches.
31
+ - Transfer execution uses bounded pinned slots, separate streams and events;
32
+ all extra staging/cached GPU buffers are included in capacity estimates.
33
+ Local GPU and NCCL reductions occur before completed-result downloads.
34
+ - Example CGLS norms accumulate FP64 blocks and vector updates reuse buffers.
35
+ The disk example keeps all three volume states plus residual/projection state
36
+ blockwise and retains `q` until the global `alpha` decision.
37
+
38
+ ### Fixed
39
+
40
+ - Tensor CGLS ranks agree on CPU fallback before jointly replanning unequal
41
+ spatial shards, preserving collective order.
42
+ - Parameterized detector surfaces use a common arithmetic dtype of at least
43
+ float32, supporting mixed frame dtypes and finite half-precision inputs.
44
+ - CUDA store uploads wait for newly generated blocks on their source stream,
45
+ including transfers within one GPU.
46
+ - Single-slot execution retires prior native computation before allocating
47
+ the next tile, keeping deferred CUDA allocations within its admission bound.
48
+ - Mapped input and output stores reject hard links to the same file before writes.
49
+ - Example PSNR supports CPU reconstruction results with CUDA reference volumes.
50
+ - Deferred CPU output writes retain their submission order across spatial tiles.
51
+ - Coronal slice displays preserve the measured detector row orientation.
52
+
10
53
  ## [2.1.0] - 2026-10-10
11
54
 
12
55
  ### Added
@@ -13,8 +13,8 @@ authors:
13
13
  given-names: Yipeng
14
14
  email: yipeng.sun@fau.de
15
15
  alias: sypsyp97
16
- version: 2.1.0
17
- date-released: "2026-10-10"
16
+ version: 2.2.0
17
+ date-released: "2026-10-11"
18
18
  license: Apache-2.0
19
19
  repository-code: "https://github.com/sypsyp97/diffct"
20
20
  url: "https://sypsyp97.github.io/diffct/"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diffct
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Differentiable CUDA projectors for CT: arbitrary trajectories, multi-GPU and multi-node execution, geometry gradients
5
5
  Project-URL: Homepage, https://github.com/sypsyp97/diffct
6
6
  Project-URL: Documentation, https://sypsyp97.github.io/diffct/
@@ -65,8 +65,6 @@ Description-Content-Type: text/markdown
65
65
  <a href="#citation">Citation</a>
66
66
  </p>
67
67
 
68
- > **Install** with the CUDA extra, `pip install "diffct[cu12]"` or `pip install "diffct[cu13]"`, after PyTorch. A plain `pip install diffct` needs a system CUDA Toolkit.
69
- >
70
68
  > **Version 2.0** replaces the circular-orbit API of 1.x with per-view trajectories. Code written for 1.x needs changes; see the [migration notes](https://github.com/sypsyp97/diffct/blob/main/docs/MIGRATION.md).
71
69
  > For Apple Silicon, use the [MLX port by Linda-Sophie Schneider](https://github.com/Linda-SophieSchneider/DiffCT-MLX).
72
70
 
@@ -75,10 +73,10 @@ Description-Content-Type: text/markdown
75
73
  To our knowledge, diffct is the only open-source GPU CT library that combines arbitrary per-view trajectories, autograd through the volume, first-order gradients through the acquisition geometry, and built-in multi-GPU and multi-node execution. We compared it with LEAP, TIGRE, ASTRA/tomosipo, DiffDRR and others in October 2026.
76
74
 
77
75
  - **Arbitrary trajectories.** Each view has its own source, detector centre and detector axes. Circular, helical, saddle, sinusoidal, random and calibrated scans use the same code. Trajectory tensors with `requires_grad=True` receive geometry gradients for calibration.
78
- - **Curved detectors.** A `detector_surface(u, v)` callback defines each pixel's position for arc, cylindrical or other parameterized surfaces. Projection and matched backprojection support first-order surface gradients. See [the surface API](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#parameterized-detector-surfaces).
76
+ - **Curved detectors.** `ParameterizedSurface` defines native arc, cylindrical or arbitrary pixels from global coordinates and explicit parameters, sampled in bounded view/pixel batches. Legacy `detector_surface(u, v)` callbacks remain available. Projection and matched backprojection support first-order surface gradients. See [the surface API](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#parameterized-detector-surfaces).
79
77
  - **Matched operators.** `project()` and `backproject()` form an exact adjoint pair for the cell-constant Siddon model. Both support PyTorch autograd, with volume/sinogram gradients and Hessian-vector products.
80
- - **Large volumes.** CPU volumes and sinograms automatically stream spatial tiles and view batches through CUDA. Full arrays and data gradients stay on CPU; tile sizes follow the available device memory and can be overridden. See [chunked execution](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#chunked-execution).
81
- - **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or one process per GPU with torchrun and NCCL. Views are partitioned. CPU-backed volumes stay in host RAM while GPUs compute tiles; distributed ranks retain replicated-volume semantics. Speedup depends on workload and communication costs.
78
+ - **Large volumes.** Automatic spatial, view and pixel blocks with bounded transfer buffers control working memory. `project_into`/`backproject_into` accept caller-owned tensor or disk stores; tensor autograd keeps complete data on its input device. See [chunked execution](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#chunked-execution).
79
+ - **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or torchrun and NCCL across ranks. Default `partition="views"` shards projections and replicates volumes. `partition="space"` gives each rank an owned volume slab and combines ray contributions. Speedup depends on workload and communication costs.
82
80
  - **Analytical helpers.** `diffct.analytical` provides ramp filters (ram-lak, shepp-logan, cosine, hamming, hann), fan, cone and Parker weights, and FBP and FDK backprojection.
83
81
 
84
82
  Capabilities, limits and isocenter rules: [docs/REFERENCE.md](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#capabilities-and-limits).
@@ -140,7 +138,7 @@ loss.backward() # x.grad = A^T (A x - y)
140
138
 
141
139
  **Geometry gradients.** Set `requires_grad=True` on trajectory tensors before you build `Projector`. See [REFERENCE](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#geometry-gradients).
142
140
 
143
- **Several GPUs and nodes.** Use `devices=[0, 1, 2, 3]` in one process, or one process per GPU with torchrun for one or more nodes. See [REFERENCE](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#several-gpus-and-nodes) and [DISTRIBUTED.md](https://github.com/sypsyp97/diffct/blob/main/docs/DISTRIBUTED.md).
141
+ **Several GPUs and nodes.** See [REFERENCE](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#several-gpus-and-nodes) and [DISTRIBUTED.md](https://github.com/sypsyp97/diffct/blob/main/docs/DISTRIBUTED.md).
144
142
 
145
143
  ```python
146
144
  A = Projector(trajectory, (128, 128, 128), (384, 256), detector_spacing=0.8, devices=[0, 1, 2, 3])
@@ -161,29 +159,36 @@ sinogram.square().mean().backward() # CPU volume.grad
161
159
  Run `python examples/chunked_reconstruction.py` for CPU-backed CGLS and measured
162
160
  CUDA allocation. Chunking preserves the Siddon model, with float32 summation
163
161
  differences; smaller blocks can increase transfer and launch overhead. Full
164
- arrays and iterative state still need enough host RAM.
162
+ tensor arrays and iterative state still need enough host RAM. For disk-backed
163
+ state and sharded checkpoints, run
164
+ `python examples/disk_reconstruction.py --output disk-run`; for fixed rank-local
165
+ volume slabs use `--partition space` under torchrun. Run
166
+ `python examples/benchmark_execution.py` to compare schedules and copy payloads.
165
167
 
166
168
  ## Performance
167
169
 
168
170
  <p align="center">
169
171
  <picture>
170
- <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png">
171
- <img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png" width="100%" alt="Time per forward, adjoint and CGLS iteration on 1, 4 and 8 GPUs, 64^3 and 128^3">
172
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png?v=2.2.0">
173
+ <img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.2.0" width="100%" alt="Time per forward, adjoint and CGLS iteration on 1, 4 and 8 GPUs, 512^3 volume">
172
174
  </picture>
173
175
  </p>
174
176
 
175
- | 128³ volume | 1 GPU | 4 GPUs, 1 node | 8 GPUs, 2 nodes |
177
+ | 512³ volume | 1 GPU | 4 GPUs, 1 node | 8 GPUs, 2 nodes |
176
178
  |---|---:|---:|---:|
177
- | Forward projection | 8.45 ms | 2.66 ms (3.2×) | 1.68 ms (5.0×) |
178
- | Adjoint (backprojection) | 20.66 ms | 5.89 ms (3.5×) | 4.62 ms (4.5×) |
179
- | CGLS iteration | 30.08 ms | 8.08 ms (3.7×) | 5.59 ms (5.4×) |
179
+ | Forward projection | 493.05 ms | 132.22 ms (3.7×) | 73.18 ms (6.7×) |
180
+ | Adjoint (backprojection) | 1400.80 ms | 369.29 ms (3.8×) | 265.87 ms (5.3×) |
181
+ | CGLS iteration | 2054.36 ms | 547.06 ms (3.8×) | 373.38 ms (5.5×) |
180
182
 
181
- Circular trajectory, 360 views, detector (2n, 1.5n) cells at pitch 1.25, A100 64 GB GPUs. Small volumes scale less: at 64³, one CGLS iteration takes 4.63 ms on 1 GPU, 1.57 ms on 4 and 1.56 ms on 8. Raw data: [docs/assets/scaling.json](https://github.com/sypsyp97/diffct/blob/main/docs/assets/scaling.json).
183
+ diffct 2.2.0, FP32, A100 64 GB GPUs; circular cone beam, 360 views, 1024×768 detector, pitch 1.25. Default memory settings, without an imposed allocator cap.
182
184
 
183
- To measure one against several GPUs on your machine (default scan of the script, not the table setup):
185
+ Projection times are medians of seven samples after three warmups. CGLS uses three ten-iteration solves after a two-iteration warmup; the median includes initialization and is divided by ten. Timings use synchronized wall time and the slowest rank. Raw samples and configuration: [scaling.json](https://github.com/sypsyp97/diffct/blob/main/docs/assets/scaling.json).
186
+
187
+ Run the same workload:
184
188
 
185
189
  ```bash
186
- python examples/benchmark_projector.py --devices 0 1
190
+ python examples/benchmark_scaling.py --output one.json
191
+ torchrun --standalone --nproc-per-node=4 examples/benchmark_scaling.py --expected-nodes 1 --output four.json
187
192
  ```
188
193
 
189
194
  ## Gallery
@@ -34,8 +34,6 @@
34
34
  <a href="#citation">Citation</a>
35
35
  </p>
36
36
 
37
- > **Install** with the CUDA extra, `pip install "diffct[cu12]"` or `pip install "diffct[cu13]"`, after PyTorch. A plain `pip install diffct` needs a system CUDA Toolkit.
38
- >
39
37
  > **Version 2.0** replaces the circular-orbit API of 1.x with per-view trajectories. Code written for 1.x needs changes; see the [migration notes](https://github.com/sypsyp97/diffct/blob/main/docs/MIGRATION.md).
40
38
  > For Apple Silicon, use the [MLX port by Linda-Sophie Schneider](https://github.com/Linda-SophieSchneider/DiffCT-MLX).
41
39
 
@@ -44,10 +42,10 @@
44
42
  To our knowledge, diffct is the only open-source GPU CT library that combines arbitrary per-view trajectories, autograd through the volume, first-order gradients through the acquisition geometry, and built-in multi-GPU and multi-node execution. We compared it with LEAP, TIGRE, ASTRA/tomosipo, DiffDRR and others in October 2026.
45
43
 
46
44
  - **Arbitrary trajectories.** Each view has its own source, detector centre and detector axes. Circular, helical, saddle, sinusoidal, random and calibrated scans use the same code. Trajectory tensors with `requires_grad=True` receive geometry gradients for calibration.
47
- - **Curved detectors.** A `detector_surface(u, v)` callback defines each pixel's position for arc, cylindrical or other parameterized surfaces. Projection and matched backprojection support first-order surface gradients. See [the surface API](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#parameterized-detector-surfaces).
45
+ - **Curved detectors.** `ParameterizedSurface` defines native arc, cylindrical or arbitrary pixels from global coordinates and explicit parameters, sampled in bounded view/pixel batches. Legacy `detector_surface(u, v)` callbacks remain available. Projection and matched backprojection support first-order surface gradients. See [the surface API](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#parameterized-detector-surfaces).
48
46
  - **Matched operators.** `project()` and `backproject()` form an exact adjoint pair for the cell-constant Siddon model. Both support PyTorch autograd, with volume/sinogram gradients and Hessian-vector products.
49
- - **Large volumes.** CPU volumes and sinograms automatically stream spatial tiles and view batches through CUDA. Full arrays and data gradients stay on CPU; tile sizes follow the available device memory and can be overridden. See [chunked execution](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#chunked-execution).
50
- - **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or one process per GPU with torchrun and NCCL. Views are partitioned. CPU-backed volumes stay in host RAM while GPUs compute tiles; distributed ranks retain replicated-volume semantics. Speedup depends on workload and communication costs.
47
+ - **Large volumes.** Automatic spatial, view and pixel blocks with bounded transfer buffers control working memory. `project_into`/`backproject_into` accept caller-owned tensor or disk stores; tensor autograd keeps complete data on its input device. See [chunked execution](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#chunked-execution).
48
+ - **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or torchrun and NCCL across ranks. Default `partition="views"` shards projections and replicates volumes. `partition="space"` gives each rank an owned volume slab and combines ray contributions. Speedup depends on workload and communication costs.
51
49
  - **Analytical helpers.** `diffct.analytical` provides ramp filters (ram-lak, shepp-logan, cosine, hamming, hann), fan, cone and Parker weights, and FBP and FDK backprojection.
52
50
 
53
51
  Capabilities, limits and isocenter rules: [docs/REFERENCE.md](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#capabilities-and-limits).
@@ -109,7 +107,7 @@ loss.backward() # x.grad = A^T (A x - y)
109
107
 
110
108
  **Geometry gradients.** Set `requires_grad=True` on trajectory tensors before you build `Projector`. See [REFERENCE](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#geometry-gradients).
111
109
 
112
- **Several GPUs and nodes.** Use `devices=[0, 1, 2, 3]` in one process, or one process per GPU with torchrun for one or more nodes. See [REFERENCE](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#several-gpus-and-nodes) and [DISTRIBUTED.md](https://github.com/sypsyp97/diffct/blob/main/docs/DISTRIBUTED.md).
110
+ **Several GPUs and nodes.** See [REFERENCE](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#several-gpus-and-nodes) and [DISTRIBUTED.md](https://github.com/sypsyp97/diffct/blob/main/docs/DISTRIBUTED.md).
113
111
 
114
112
  ```python
115
113
  A = Projector(trajectory, (128, 128, 128), (384, 256), detector_spacing=0.8, devices=[0, 1, 2, 3])
@@ -130,29 +128,36 @@ sinogram.square().mean().backward() # CPU volume.grad
130
128
  Run `python examples/chunked_reconstruction.py` for CPU-backed CGLS and measured
131
129
  CUDA allocation. Chunking preserves the Siddon model, with float32 summation
132
130
  differences; smaller blocks can increase transfer and launch overhead. Full
133
- arrays and iterative state still need enough host RAM.
131
+ tensor arrays and iterative state still need enough host RAM. For disk-backed
132
+ state and sharded checkpoints, run
133
+ `python examples/disk_reconstruction.py --output disk-run`; for fixed rank-local
134
+ volume slabs use `--partition space` under torchrun. Run
135
+ `python examples/benchmark_execution.py` to compare schedules and copy payloads.
134
136
 
135
137
  ## Performance
136
138
 
137
139
  <p align="center">
138
140
  <picture>
139
- <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png">
140
- <img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png" width="100%" alt="Time per forward, adjoint and CGLS iteration on 1, 4 and 8 GPUs, 64^3 and 128^3">
141
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png?v=2.2.0">
142
+ <img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.2.0" width="100%" alt="Time per forward, adjoint and CGLS iteration on 1, 4 and 8 GPUs, 512^3 volume">
141
143
  </picture>
142
144
  </p>
143
145
 
144
- | 128³ volume | 1 GPU | 4 GPUs, 1 node | 8 GPUs, 2 nodes |
146
+ | 512³ volume | 1 GPU | 4 GPUs, 1 node | 8 GPUs, 2 nodes |
145
147
  |---|---:|---:|---:|
146
- | Forward projection | 8.45 ms | 2.66 ms (3.2×) | 1.68 ms (5.0×) |
147
- | Adjoint (backprojection) | 20.66 ms | 5.89 ms (3.5×) | 4.62 ms (4.5×) |
148
- | CGLS iteration | 30.08 ms | 8.08 ms (3.7×) | 5.59 ms (5.4×) |
148
+ | Forward projection | 493.05 ms | 132.22 ms (3.7×) | 73.18 ms (6.7×) |
149
+ | Adjoint (backprojection) | 1400.80 ms | 369.29 ms (3.8×) | 265.87 ms (5.3×) |
150
+ | CGLS iteration | 2054.36 ms | 547.06 ms (3.8×) | 373.38 ms (5.5×) |
149
151
 
150
- Circular trajectory, 360 views, detector (2n, 1.5n) cells at pitch 1.25, A100 64 GB GPUs. Small volumes scale less: at 64³, one CGLS iteration takes 4.63 ms on 1 GPU, 1.57 ms on 4 and 1.56 ms on 8. Raw data: [docs/assets/scaling.json](https://github.com/sypsyp97/diffct/blob/main/docs/assets/scaling.json).
152
+ diffct 2.2.0, FP32, A100 64 GB GPUs; circular cone beam, 360 views, 1024×768 detector, pitch 1.25. Default memory settings, without an imposed allocator cap.
151
153
 
152
- To measure one against several GPUs on your machine (default scan of the script, not the table setup):
154
+ Projection times are medians of seven samples after three warmups. CGLS uses three ten-iteration solves after a two-iteration warmup; the median includes initialization and is divided by ten. Timings use synchronized wall time and the slowest rank. Raw samples and configuration: [scaling.json](https://github.com/sypsyp97/diffct/blob/main/docs/assets/scaling.json).
155
+
156
+ Run the same workload:
153
157
 
154
158
  ```bash
155
- python examples/benchmark_projector.py --devices 0 1
159
+ python examples/benchmark_scaling.py --output one.json
160
+ torchrun --standalone --nproc-per-node=4 examples/benchmark_scaling.py --expected-nodes 1 --output four.json
156
161
  ```
157
162
 
158
163
  ## Gallery
@@ -15,6 +15,8 @@ from .projectors import (
15
15
  )
16
16
 
17
17
  from .operators import Projector
18
+ from .storage import TensorStore, NpyStore
19
+ from .surfaces import ParameterizedSurface
18
20
 
19
21
  from .geometry import (
20
22
  circular_trajectory_3d,
@@ -49,10 +51,13 @@ try:
49
51
  except ImportError:
50
52
  pass
51
53
 
52
- __version__ = '2.1.0'
54
+ __version__ = '2.2.0'
53
55
 
54
56
  __all__ = [
55
57
  'Projector',
58
+ 'TensorStore',
59
+ 'NpyStore',
60
+ 'ParameterizedSurface',
56
61
  'ParallelProjectorFunction',
57
62
  'ParallelBackprojectorFunction',
58
63
  'FanProjectorFunction',
@@ -0,0 +1,201 @@
1
+ """Spatial slices and immutable execution limits for streamed projectors."""
2
+
3
+ from dataclasses import dataclass
4
+ from itertools import product
5
+ import math
6
+
7
+ import torch
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class _ChunkPlan:
12
+ chunk_shape: tuple
13
+ view_chunk_size: int
14
+ devices: tuple
15
+ detector_chunk_shape: tuple = None
16
+ gpu_budget_bytes: int = 0
17
+ allocation_limit: int = (1 << 63) - 1
18
+ schedule: str = "spatial"
19
+ window_size: int = 1
20
+ slots: int = 1
21
+ estimated_gpu_bytes: int = 0
22
+ fallback_reason: str = ""
23
+ foreign_output: bool = False
24
+ reservation_budget_bytes: int = (1 << 63) - 1
25
+ estimated_reserved_bytes: int = 0
26
+
27
+
28
+ def _pixel_tiles(shape, chunk_shape):
29
+ for starts in product(*(range(0, size, part) for size, part in zip(shape, chunk_shape))):
30
+ clipped = tuple(min(part, size - start) for size, start, part in zip(shape, starts, chunk_shape))
31
+ yield tuple(slice(start, start + size) for start, size in zip(starts, clipped)), clipped
32
+
33
+
34
+ def _memory_budget(device):
35
+ """Usable allocator bytes, retaining 25% headroom for allocation overhead.
36
+
37
+ Reusable reserved blocks count as available, but never bypass the process
38
+ fraction ceiling. Older PyTorch without its public fraction getter can
39
+ only observe driver/allocator memory; explicit limits remain available.
40
+ """
41
+ free, total = torch.cuda.mem_get_info(device)
42
+ allocated = torch.cuda.memory_allocated(device)
43
+ reusable = max(0, torch.cuda.memory_reserved(device) - allocated)
44
+ getter = getattr(torch.cuda, "get_per_process_memory_fraction", None)
45
+ fraction = getter(device) if getter is not None else 1.0
46
+ allocator_room = max(0, int(total * fraction) - allocated)
47
+ backend = getattr(torch.cuda, "get_allocator_backend", lambda: "native")()
48
+ foreign_splits = 0
49
+ if backend == "native":
50
+ caller = torch.cuda.current_stream(device).cuda_stream
51
+ for segment in _native_segments(device):
52
+ blocks = segment["blocks"]
53
+ if segment["stream"] != caller and any(block["state"] != "inactive" for block in blocks):
54
+ foreign_splits += sum(block["size"] for block in blocks if block["state"] == "inactive")
55
+ available = max(0, min(free + reusable, allocator_room) - foreign_splits)
56
+ budget = available * 3 // 4
57
+ allocation_limit = (1 << 63) - 1 # Unbounded limit in scalar int64 agreement.
58
+ # Native requests >1 MiB and <10 MiB reserve a 20 MiB segment. A smaller
59
+ # current available room must use the <=1 MiB small pool, whose segments are 2 MiB.
60
+ # Source: pytorch/c10/cuda/CUDACachingAllocator.cpp (Native allocator notes).
61
+ if backend == "native" and available < 20 * 1024 ** 2:
62
+ allocation_limit = 1024 ** 2
63
+ return budget, allocation_limit
64
+
65
+
66
+ def _native_segments(device):
67
+ index = torch.device(device).index if not isinstance(device, int) else device
68
+ index = torch.cuda.current_device() if index is None else index
69
+ return [segment for segment in torch.cuda.memory_snapshot() if segment["device"] == index]
70
+
71
+
72
+ def _reservation_budget(device):
73
+ """Room for fresh segments, conservatively excluding every pinned segment.
74
+
75
+ Free splits in a live segment may serve the caller, but cannot guarantee
76
+ placement of the complete new inventory. Wholly inactive segments can be
77
+ released by the native allocator, including those owned by other streams.
78
+ """
79
+ if torch.cuda.get_allocator_backend() != "native":
80
+ return (1 << 63) - 1
81
+ free, total = torch.cuda.mem_get_info(device)
82
+ segments = _native_segments(device)
83
+ pinned = sum(segment["total_size"] for segment in segments
84
+ if any(block["state"] != "inactive" for block in segment["blocks"]))
85
+ reclaimable = sum(segment["total_size"] for segment in segments
86
+ if all(block["state"] == "inactive" for block in segment["blocks"]))
87
+ getter = getattr(torch.cuda, "get_per_process_memory_fraction", None)
88
+ fraction = getter(device) if getter is not None else 1.0
89
+ return max(0, min(free + reclaimable, int(total * fraction) - pinned))
90
+
91
+
92
+ def _working_set_bytes(beam, shape, views, detector_shape, surface):
93
+ """Conservative native staging/layout, cotangent, geometry and VJP scratch."""
94
+ rank = len(shape)
95
+ voxels = math.prod(shape)
96
+ rays = views * math.prod(detector_shape)
97
+ geometry = (rank * (views + rays) if surface
98
+ else rank * (4 if beam == "cone" else 3) * views)
99
+ # A resident native tile, staging and completed cone layout can coexist.
100
+ # Two ray buffers cover reusable output/cotangent and transfer scratch; two
101
+ # geometry buffers cover staged values and their first-order VJP/reduction.
102
+ tile_buffers = 3 if beam == "cone" else 2
103
+ return 4 * (tile_buffers * voxels + 2 * rays + 2 * geometry)
104
+
105
+
106
+ def _largest_buffer_bytes(beam, shape, views, detector_shape, surface):
107
+ rank = len(shape)
108
+ rays = views * math.prod(detector_shape)
109
+ geometry = (rank * (views + rays) if surface
110
+ else rank * (4 if beam == "cone" else 3) * views)
111
+ return 4 * max(math.prod(shape), rays, geometry)
112
+
113
+
114
+ def _allocation_bytes(elements):
115
+ size = 4 * elements
116
+ if torch.cuda.get_allocator_backend() == "native":
117
+ return (size + 511) // 512 * 512
118
+ return size
119
+
120
+
121
+ def _reservation_bytes(buffer_sizes):
122
+ """Fresh, monotone fixed-buffer inventory bound for standard native pools.
123
+
124
+ Arbitrary free holes and allocator overrides are outside this model. Large
125
+ and middle requests share one pool, so mixed inventories use separate fresh
126
+ segments per request instead of assuming their placement is independent.
127
+ """
128
+ sizes = [size for size in buffer_sizes if size > 0]
129
+ if torch.cuda.get_allocator_backend() != "native":
130
+ return sum(sizes)
131
+ mib = 1024 ** 2
132
+ sizes = [(size + 511) // 512 * 512 for size in sizes]
133
+ large = [size for size in sizes if size >= 10 * mib]
134
+ reservation = sum((size + 2 * mib - 1) // (2 * mib) * (2 * mib) for size in large)
135
+ middle = [size for size in sizes if mib < size < 10 * mib]
136
+ if large:
137
+ reservation += len(middle) * 20 * mib
138
+ middle = []
139
+ for segment_size, requests in (
140
+ (2 * mib, [size for size in sizes if size <= mib]),
141
+ (20 * mib, middle),
142
+ ):
143
+ if not requests:
144
+ continue
145
+ total = sum(requests)
146
+ # At a new segment's birth every older segment holds more than
147
+ # segment_size - max(requests); the new one holds at least min(requests).
148
+ # This bounds online best-fit placement in any allocation order.
149
+ count = (1 if total <= segment_size else
150
+ (total - min(requests)) // (segment_size - max(requests)) + 1)
151
+ reservation += count * segment_size
152
+ return reservation
153
+
154
+
155
+ def _pipeline_buffers(beam, shape, views, pixels, points, *, slots=1, retained=1,
156
+ geometry_grad=False, peers=False, ray_accumulators=0):
157
+ """Live typed allocation inventory, with native rounding per buffer."""
158
+ rank, rays = len(shape), views * math.prod(pixels)
159
+ native = _allocation_bytes(math.prod(shape))
160
+ # Two producer epochs permit the preceding native/layout/output storage
161
+ # to stay in flight while the next retained tile window queues work.
162
+ tiles = slots * (retained + int(beam == "cone")) + int(peers)
163
+ sizes = ((views * rank, rays * rank) if points
164
+ else (views * rank,) * (4 if beam == "cone" else 3))
165
+ geometry = tuple(_allocation_bytes(size) for size in sizes)
166
+ return ((native,) * tiles + (_allocation_bytes(rays),) * (slots + ray_accumulators)
167
+ + geometry * (slots * (1 + int(geometry_grad))))
168
+
169
+
170
+ def _pipeline_bytes(beam, shape, views, pixels, points, *, slots=1, retained=1,
171
+ geometry_grad=False, peers=False, ray_accumulators=0):
172
+ """Live typed bytes; segment reservation is admitted separately."""
173
+ rank, rays = len(shape), views * math.prod(pixels)
174
+ native = _allocation_bytes(math.prod(shape))
175
+ tile = native * (slots * (retained + int(beam == "cone")) + int(peers))
176
+ sizes = ((views * rank, rays * rank) if points
177
+ else (views * rank,) * (4 if beam == "cone" else 3))
178
+ geometry = sum(_allocation_bytes(size) for size in sizes)
179
+ return (tile + slots * (_allocation_bytes(rays) + geometry)
180
+ + ray_accumulators * _allocation_bytes(rays) + (slots * geometry if geometry_grad else 0))
181
+
182
+
183
+ def _host_geometry_bytes(beam, views, pixels, points):
184
+ rank = 3 if beam == "cone" else 2
185
+ if not points:
186
+ return 8 * 2 * rank * views * (4 if beam == "cone" else 3)
187
+ # Grids/offsets, world expressions and first-order cotangent/graph work.
188
+ # Arbitrary sampler internals and explicit parameter snapshots are separate.
189
+ return 8 * rank * (math.prod(pixels) * (1 + 6 * views) + 4 * views)
190
+
191
+
192
+ def _tiles(shape, chunk_shape, spacing):
193
+ """Yield tensor slices, clipped shapes and physical centres in xyz order."""
194
+ starts = (range(0, size, chunk) for size, chunk in zip(shape, chunk_shape))
195
+ for start in product(*starts):
196
+ tile_shape = tuple(min(chunk, size - first)
197
+ for size, chunk, first in zip(shape, chunk_shape, start))
198
+ slices = tuple(slice(first, first + size) for first, size in zip(start, tile_shape))
199
+ centre = tuple((first + size / 2 - full / 2) * spacing
200
+ for first, size, full in zip(start, tile_shape, shape))
201
+ yield slices, tile_shape, centre[::-1]