diffct 2.2.1__tar.gz → 2.3.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 (121) hide show
  1. {diffct-2.2.1 → diffct-2.3.0}/CHANGELOG.md +39 -1
  2. {diffct-2.2.1 → diffct-2.3.0}/CITATION.cff +2 -2
  3. {diffct-2.2.1 → diffct-2.3.0}/PKG-INFO +11 -8
  4. {diffct-2.2.1 → diffct-2.3.0}/README.md +8 -5
  5. {diffct-2.2.1 → diffct-2.3.0}/diffct/__init__.py +13 -1
  6. diffct-2.3.0/diffct/_solver_state.py +284 -0
  7. diffct-2.3.0/diffct/_solver_tv.py +110 -0
  8. {diffct-2.2.1 → diffct-2.3.0}/diffct/analytical.py +55 -34
  9. diffct-2.3.0/diffct/analytical_execution.py +427 -0
  10. diffct-2.3.0/diffct/backends.py +675 -0
  11. {diffct-2.2.1 → diffct-2.3.0}/diffct/constants.py +10 -5
  12. diffct-2.3.0/diffct/distribution.py +137 -0
  13. diffct-2.3.0/diffct/kernels/__init__.py +52 -0
  14. {diffct-2.2.1 → diffct-2.3.0}/diffct/operators.py +138 -68
  15. {diffct-2.2.1 → diffct-2.3.0}/diffct/projectors.py +33 -16
  16. {diffct-2.2.1 → diffct-2.3.0}/diffct/reconstruction.py +108 -24
  17. diffct-2.3.0/diffct/solvers.py +318 -0
  18. diffct-2.3.0/diffct/streaming.py +140 -0
  19. {diffct-2.2.1 → diffct-2.3.0}/diffct/utils.py +9 -1
  20. {diffct-2.2.1 → diffct-2.3.0}/examples/README.md +18 -3
  21. diffct-2.3.0/examples/_block_cgls.py +265 -0
  22. diffct-2.3.0/examples/_common.py +296 -0
  23. diffct-2.3.0/examples/data/preprocess_walnut.py +329 -0
  24. diffct-2.3.0/examples/walnut_reconstruction.py +143 -0
  25. {diffct-2.2.1 → diffct-2.3.0}/pyproject.toml +4 -4
  26. {diffct-2.2.1 → diffct-2.3.0}/tests/test_analytical_autograd.py +2 -2
  27. diffct-2.3.0/tests/test_analytical_distribution.py +159 -0
  28. diffct-2.3.0/tests/test_analytical_weight_distribution.py +83 -0
  29. diffct-2.3.0/tests/test_angular_low_precision.py +70 -0
  30. diffct-2.3.0/tests/test_batch_execution_edges.py +205 -0
  31. {diffct-2.2.1 → diffct-2.3.0}/tests/test_block_storage.py +80 -20
  32. diffct-2.3.0/tests/test_cpu_install.py +100 -0
  33. diffct-2.3.0/tests/test_distributed_training_transforms.py +228 -0
  34. diffct-2.3.0/tests/test_execution_features.py +618 -0
  35. diffct-2.3.0/tests/test_lazy_native_observers.py +142 -0
  36. diffct-2.3.0/tests/test_native_surface_transforms.py +110 -0
  37. diffct-2.3.0/tests/test_partition_lifecycle.py +197 -0
  38. diffct-2.3.0/tests/test_portable_distributed_geometry.py +206 -0
  39. diffct-2.3.0/tests/test_portable_endpoint_precision.py +68 -0
  40. diffct-2.3.0/tests/test_portable_execution_bounds.py +203 -0
  41. diffct-2.3.0/tests/test_portable_numeric_regressions.py +185 -0
  42. diffct-2.3.0/tests/test_portable_surface_admission.py +85 -0
  43. {diffct-2.2.1 → diffct-2.3.0}/tests/test_projector_api.py +1 -1
  44. {diffct-2.2.1 → diffct-2.3.0}/tests/test_reconstruction_api.py +176 -0
  45. diffct-2.3.0/tests/test_reference_backend.py +473 -0
  46. diffct-2.3.0/tests/test_review_230.py +99 -0
  47. diffct-2.3.0/tests/test_review_regressions.py +345 -0
  48. diffct-2.3.0/tests/test_solver_admission_regressions.py +273 -0
  49. diffct-2.3.0/tests/test_solver_api.py +710 -0
  50. diffct-2.3.0/tests/test_solver_checkpoint_edges.py +287 -0
  51. diffct-2.3.0/tests/test_solver_collective_failures.py +225 -0
  52. diffct-2.3.0/tests/test_training_transforms.py +95 -0
  53. diffct-2.3.0/tests/test_tv_zero_dependency.py +75 -0
  54. diffct-2.3.0/tests/test_two_far_endpoints.py +84 -0
  55. diffct-2.2.1/diffct/kernels/__init__.py +0 -51
  56. diffct-2.2.1/diffct/streaming.py +0 -48
  57. {diffct-2.2.1 → diffct-2.3.0}/.gitignore +0 -0
  58. {diffct-2.2.1 → diffct-2.3.0}/LICENSE +0 -0
  59. {diffct-2.2.1 → diffct-2.3.0}/NOTICE +0 -0
  60. {diffct-2.2.1 → diffct-2.3.0}/diffct/chunking.py +0 -0
  61. {diffct-2.2.1 → diffct-2.3.0}/diffct/differentiable.py +0 -0
  62. {diffct-2.2.1 → diffct-2.3.0}/diffct/geometry.py +0 -0
  63. {diffct-2.2.1 → diffct-2.3.0}/diffct/kernels/analytical_transpose.py +0 -0
  64. {diffct-2.2.1 → diffct-2.3.0}/diffct/kernels/cone_beam.py +0 -0
  65. {diffct-2.2.1 → diffct-2.3.0}/diffct/kernels/fan_beam.py +0 -0
  66. {diffct-2.2.1 → diffct-2.3.0}/diffct/kernels/geometry_vjp.py +0 -0
  67. {diffct-2.2.1 → diffct-2.3.0}/diffct/kernels/parallel_beam.py +0 -0
  68. {diffct-2.2.1 → diffct-2.3.0}/diffct/pipeline.py +0 -0
  69. {diffct-2.2.1 → diffct-2.3.0}/diffct/storage.py +0 -0
  70. {diffct-2.2.1 → diffct-2.3.0}/diffct/surfaces.py +0 -0
  71. {diffct-2.2.1 → diffct-2.3.0}/docs/video/README.md +0 -0
  72. {diffct-2.2.1 → diffct-2.3.0}/examples/data/NOTICE +0 -0
  73. {diffct-2.2.1 → diffct-2.3.0}/formal/README.md +0 -0
  74. {diffct-2.2.1 → diffct-2.3.0}/pytest.ini +0 -0
  75. {diffct-2.2.1 → diffct-2.3.0}/tests/__init__.py +0 -0
  76. {diffct-2.2.1 → diffct-2.3.0}/tests/_phantoms.py +0 -0
  77. {diffct-2.2.1 → diffct-2.3.0}/tests/benchmarks/README.md +0 -0
  78. {diffct-2.2.1 → diffct-2.3.0}/tests/benchmarks/__init__.py +0 -0
  79. {diffct-2.2.1 → diffct-2.3.0}/tests/benchmarks/_common.py +0 -0
  80. {diffct-2.2.1 → diffct-2.3.0}/tests/benchmarks/test_bench_cone.py +0 -0
  81. {diffct-2.2.1 → diffct-2.3.0}/tests/benchmarks/test_bench_fan.py +0 -0
  82. {diffct-2.2.1 → diffct-2.3.0}/tests/benchmarks/test_bench_parallel.py +0 -0
  83. {diffct-2.2.1 → diffct-2.3.0}/tests/distributed_projector_check.py +0 -0
  84. {diffct-2.2.1 → diffct-2.3.0}/tests/test_adjoint_inner_product.py +0 -0
  85. {diffct-2.2.1 → diffct-2.3.0}/tests/test_allocator_capacity.py +0 -0
  86. {diffct-2.2.1 → diffct-2.3.0}/tests/test_analytical_regressions.py +0 -0
  87. {diffct-2.2.1 → diffct-2.3.0}/tests/test_axis_aligned_chords.py +0 -0
  88. {diffct-2.2.1 → diffct-2.3.0}/tests/test_batched_surfaces.py +0 -0
  89. {diffct-2.2.1 → diffct-2.3.0}/tests/test_block_cgls.py +0 -0
  90. {diffct-2.2.1 → diffct-2.3.0}/tests/test_cgls_state_admission.py +0 -0
  91. {diffct-2.2.1 → diffct-2.3.0}/tests/test_chunked_projector.py +0 -0
  92. {diffct-2.2.1 → diffct-2.3.0}/tests/test_completed_transfer_reuse.py +0 -0
  93. {diffct-2.2.1 → diffct-2.3.0}/tests/test_cone_projector_autograd.py +0 -0
  94. {diffct-2.2.1 → diffct-2.3.0}/tests/test_cuda_exit_deallocations.py +0 -0
  95. {diffct-2.2.1 → diffct-2.3.0}/tests/test_cuda_sink_lifetimes.py +0 -0
  96. {diffct-2.2.1 → diffct-2.3.0}/tests/test_cuda_smoke.py +0 -0
  97. {diffct-2.2.1 → diffct-2.3.0}/tests/test_detector_surface_dtypes.py +0 -0
  98. {diffct-2.2.1 → diffct-2.3.0}/tests/test_detector_surface_mapping.py +0 -0
  99. {diffct-2.2.1 → diffct-2.3.0}/tests/test_detector_surface_units.py +0 -0
  100. {diffct-2.2.1 → diffct-2.3.0}/tests/test_detector_surfaces.py +0 -0
  101. {diffct-2.2.1 → diffct-2.3.0}/tests/test_example_psnr.py +0 -0
  102. {diffct-2.2.1 → diffct-2.3.0}/tests/test_execution_buffers.py +0 -0
  103. {diffct-2.2.1 → diffct-2.3.0}/tests/test_execution_schedules.py +0 -0
  104. {diffct-2.2.1 → diffct-2.3.0}/tests/test_fbp_fan_accuracy.py +0 -0
  105. {diffct-2.2.1 → diffct-2.3.0}/tests/test_fbp_fan_offsets.py +0 -0
  106. {diffct-2.2.1 → diffct-2.3.0}/tests/test_fbp_parallel_accuracy.py +0 -0
  107. {diffct-2.2.1 → diffct-2.3.0}/tests/test_fdk_cone_accuracy.py +0 -0
  108. {diffct-2.2.1 → diffct-2.3.0}/tests/test_fdk_cone_offsets.py +0 -0
  109. {diffct-2.2.1 → diffct-2.3.0}/tests/test_geometry_autograd.py +0 -0
  110. {diffct-2.2.1 → diffct-2.3.0}/tests/test_geometry_gradients.py +0 -0
  111. {diffct-2.2.1 → diffct-2.3.0}/tests/test_gradcheck.py +0 -0
  112. {diffct-2.2.1 → diffct-2.3.0}/tests/test_multi_gpu_exit.py +0 -0
  113. {diffct-2.2.1 → diffct-2.3.0}/tests/test_output_pipeline_overlap.py +0 -0
  114. {diffct-2.2.1 → diffct-2.3.0}/tests/test_projector_degenerate_geometry.py +0 -0
  115. {diffct-2.2.1 → diffct-2.3.0}/tests/test_projector_geometry_devices.py +0 -0
  116. {diffct-2.2.1 → diffct-2.3.0}/tests/test_ramp_filter_windows.py +0 -0
  117. {diffct-2.2.1 → diffct-2.3.0}/tests/test_release_2_0_1.py +0 -0
  118. {diffct-2.2.1 → diffct-2.3.0}/tests/test_spatial_partition.py +0 -0
  119. {diffct-2.2.1 → diffct-2.3.0}/tests/test_transfer_pipeline.py +0 -0
  120. {diffct-2.2.1 → diffct-2.3.0}/tests/test_transfer_retirement_lifetimes.py +0 -0
  121. {diffct-2.2.1 → diffct-2.3.0}/tests/test_weights.py +0 -0
@@ -5,7 +5,45 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [2.3.0] - 2026-10-14
9
+
10
+ ### Added
11
+
12
+ - `backend="auto"`, `"numba"` or `"torch"` and `precision="fast"`, `"float32"` or `"float64"` on `Projector`, `fbp` and `fdk`. These settings are opt-in. Default calls keep native FP32 execution where compatible.
13
+ - `backend="auto"` selects the PyTorch backend for `precision="float32"`, `precision="float64"`, per-axis `voxel_spacing` and hybrid partitions. An incompatible explicit `backend="numba"` request raises an error.
14
+ - `backend="torch"` runs on CPU, NVIDIA CUDA and PyTorch ROCm. `precision="float64"` runs the data operations in FP64.
15
+ - Portable FP32 ray setup clips rays and locates tile entries in FP64. Traversal measures distances from each entry, so distant endpoints keep voxel widths.
16
+ - Per-axis `voxel_spacing`, such as `(dy, dx)` or `(dz, dy, dx)`, in tensor axis order.
17
+ - Leading batch and channel dimensions for projection and adjoint, including empty batches.
18
+ - Data gradients, JVP, `vmap`, `jacrev`, `jacfwd` and second data derivatives for the PyTorch projector. Dense Jacobians need a single-process projector.
19
+ - `view_chunk_size` and `volume_chunk_shape` on `fbp` and `fdk` stream view batches and spatial tiles. `devices` schedules tiles across local devices.
20
+ - `fbp_into` and `fdk_into` write into `TensorStore`, `NpyStore` or compatible block stores.
21
+ - `partition="hybrid"` with `partition_grid=(view_ranks, space_ranks)` for projectors and analytical reconstruction. Uneven and empty shards are supported.
22
+ - `cgls`, `sirt`, `tv_reconstruction` and `SolverResult`, with residual thresholds, residual history, callbacks and optional `x0`.
23
+ - `cgls_into` keeps solver state in mapped files. It rejects non-finite measurements in bounded blocks before any output write.
24
+ - `checkpoint_directory` and `resume` for `cgls`, `sirt` and `tv_reconstruction`. Fingerprints bind the acquisition, measurements, precision, ownership and settings.
25
+ - `total_variation` with `reduction="mean"` or `"sum"`. Spatial and hybrid runs exchange neighbouring faces.
26
+ - `docs/EXECUTION.md` documents precision, memory bounds, distributed loss replica divisors and checkpoint contracts.
27
+
28
+ ### Changed
29
+
30
+ - Importing `diffct` and using `backend="torch"` no longer loads Numba CUDA or requires a CUDA runtime. Native execution initializes them on first use.
31
+ - `pip install diffct` is enough for `backend="torch"`. Native Numba execution needs the CUDA extras `diffct[cu12]` or `diffct[cu13]`.
32
+ - Distributed PyTorch-backend projectors require the same `volume_chunk_shape`, `view_chunk_size` and `detector_chunk_shape` on every rank.
33
+ - The source distribution includes the four example helpers used by its shipped tests.
34
+
35
+ ### Fixed
36
+
37
+ - Parker short-scan distance and detector-u offset checks bound position and
38
+ detector-axis rounding per component along the checked direction, not by the
39
+ absolute coordinate size.
40
+ A bfloat16 or float16 detector-v axis made a 50-unit change in detector distance
41
+ pass, and the Parker weights then used the first view's distance.
42
+ - Fan and cone normalization in the analytical gather and its transpose uses each view's SID and SDD. Inactive views no longer rescale active views.
43
+ - `angular_integration_weights` and `parker_weights` keep FP32 and FP64 input precision. FP16 and BF16 angles are computed in FP32.
44
+ - Angular gaps come directly from sorted neighbouring angles. Float32 neighbours one ulp apart no longer get a zero weight, and Parker rows no longer depend on the input order.
45
+ - Valid low-precision Parker short scans keep finite weights and angle gradients.
46
+ - Output stores that overlap internally raise before any I/O. Disjoint interleaved `TensorStore` slices are accepted.
9
47
 
10
48
  ## [2.2.1] - 2026-10-13
11
49
 
@@ -13,8 +13,8 @@ authors:
13
13
  given-names: Yipeng
14
14
  email: yipeng.sun@fau.de
15
15
  alias: sypsyp97
16
- version: 2.2.1
17
- date-released: "2026-10-13"
16
+ version: 2.3.0
17
+ date-released: "2026-10-14"
18
18
  license: Apache-2.0
19
19
  repository-code: "https://github.com/sypsyp97/diffct"
20
20
  url: "https://sypsyp97.github.io/diffct/"
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diffct
3
- Version: 2.2.1
4
- Summary: Differentiable CUDA projectors for CT: arbitrary trajectories, multi-GPU and multi-node execution, geometry gradients
3
+ Version: 2.3.0
4
+ Summary: Differentiable CT projection and reconstruction: precision modes, streaming, distributed execution and geometry gradients
5
5
  Project-URL: Homepage, https://github.com/sypsyp97/diffct
6
6
  Project-URL: Documentation, https://sypsyp97.github.io/diffct/
7
7
  Project-URL: Changelog, https://github.com/sypsyp97/diffct/blob/main/CHANGELOG.md
@@ -10,7 +10,7 @@ Author-email: Yipeng Sun <yipeng.sun@fau.de>
10
10
  License-Expression: Apache-2.0
11
11
  License-File: LICENSE
12
12
  License-File: NOTICE
13
- Keywords: CT reconstruction,CUDA,PyTorch,computed tomography,differentiable programming
13
+ Keywords: CT reconstruction,CUDA,PyTorch,ROCm,computed tomography,differentiable programming
14
14
  Classifier: Development Status :: 5 - Production/Stable
15
15
  Classifier: Environment :: GPU :: NVIDIA CUDA
16
16
  Classifier: Intended Audience :: Science/Research
@@ -47,11 +47,11 @@ Description-Content-Type: text/markdown
47
47
  </p>
48
48
 
49
49
  <p align="center">
50
- <img src="https://raw.githubusercontent.com/sypsyp97/diffct/edf7c52227ec2cc14c988918faa7313f8ee0fe03/docs/assets/diffct_intro.gif" width="100%" alt="diffct intro: projection, sinogram, trajectories, multi-GPU split, reconstruction and geometry calibration">
50
+ <img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/diffct_intro.gif" width="100%" alt="diffct intro: projection, sinogram, trajectories, multi-GPU split, reconstruction and geometry calibration">
51
51
  </p>
52
52
 
53
53
  <p align="center">
54
- <a href="https://github.com/sypsyp97/diffct/blob/edf7c52227ec2cc14c988918faa7313f8ee0fe03/docs/assets/diffct_intro.mp4">Intro video (MP4)</a> ·
54
+ <a href="https://github.com/sypsyp97/diffct/blob/main/docs/assets/diffct_intro.mp4">Intro video (MP4)</a> ·
55
55
  <a href="https://www.preprints.org/manuscript/202605.1446/v1">Technical report</a> ·
56
56
  <a href="https://doi.org/10.20944/preprints202605.1446.v1">DOI</a>
57
57
  </p>
@@ -76,21 +76,23 @@ To our knowledge, diffct is the only open-source GPU CT library that combines ar
76
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).
77
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.
78
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.
79
+ - **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or torchrun and NCCL across ranks. Views, space and hybrid partitions control replication of volumes and projections. Speedup depends on workload and communication costs.
80
+ - **Precision and training.** Explicit native/PyTorch backends, FP32/FP64 modes, per-axis voxel spacing, leading batch/channel dimensions and data `torch.func` transforms. The portable path supports CPU and PyTorch CUDA/ROCm devices; see [execution contracts and validation limits](docs/EXECUTION.md).
81
+ - **Solver API.** `cgls`, `sirt` and `tv_reconstruction` report residual history, tolerances and callbacks, with scan/data fingerprints and complete checkpoint state. `cgls_into` keeps state in bounded mapped blocks.
80
82
  - **Differentiable FBP and FDK.** `fbp`, `fdk` and approximate `helical_fdk` compose the public filters, weights and analytical backprojectors. Their backward kernels transpose the actual interpolation stencil; custom filters and weights can be learned. See the [analytical guide](docs/source/analytical_learning.rst).
81
83
 
82
84
  Capabilities, limits and isocenter rules: [docs/REFERENCE.md](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#capabilities-and-limits).
83
85
 
84
86
  ## Install
85
87
 
86
- You need an NVIDIA GPU. Install PyTorch for your CUDA version first. Then install diffct with the extra for the same CUDA major version:
88
+ For the native path, install NVIDIA CUDA PyTorch first, then diffct with the matching CUDA major extra:
87
89
 
88
90
  ```bash
89
91
  pip install "diffct[cu12]" # PyTorch built for CUDA 12
90
92
  pip install "diffct[cu13]" # PyTorch built for CUDA 13
91
93
  ```
92
94
 
93
- The extra installs the CUDA compiler libraries that Numba CUDA needs to compile the kernels. A plain `pip install diffct` does not install them. Use it only if a CUDA Toolkit is already installed on the system.
95
+ The extra installs the CUDA compiler libraries Numba needs. For CPU or PyTorch ROCm execution, install the appropriate PyTorch build and plain `pip install diffct`, then select `backend="torch"`. Importing diffct and using this backend require no CUDA runtime; Numba CUDA initializes when native execution is used. Plain installation also works for native execution when a compatible CUDA Toolkit is already present.
94
96
 
95
97
  From source, with the examples:
96
98
 
@@ -226,6 +228,7 @@ Walnut data: Meaney 2022, Zenodo 6986012, CC BY 4.0; see [examples/data/NOTICE](
226
228
 
227
229
  | Document | Contents |
228
230
  |---|---|
231
+ | [Precision, training and reconstruction](docs/EXECUTION.md) | Backend modes, batch/spacing contracts, analytical storage, hybrid loss scaling and solver checkpoints |
229
232
  | [Trajectory guide](https://github.com/sypsyp97/diffct/blob/main/docs/source/trajectories.rst) | Trajectory tuples and `Projector` usage |
230
233
  | [docs/REFERENCE.md](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md) | Capabilities and limits, custom trajectories, geometry gradients, multi-GPU details, geometry and units |
231
234
  | [docs/DISTRIBUTED.md](https://github.com/sypsyp97/diffct/blob/main/docs/DISTRIBUTED.md) | Execution/memory choices, distributed loss rules, Slurm and cross-node checks |
@@ -16,11 +16,11 @@
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
- <img src="https://raw.githubusercontent.com/sypsyp97/diffct/edf7c52227ec2cc14c988918faa7313f8ee0fe03/docs/assets/diffct_intro.gif" width="100%" alt="diffct intro: projection, sinogram, trajectories, multi-GPU split, reconstruction and geometry calibration">
19
+ <img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/diffct_intro.gif" width="100%" alt="diffct intro: projection, sinogram, trajectories, multi-GPU split, reconstruction and geometry calibration">
20
20
  </p>
21
21
 
22
22
  <p align="center">
23
- <a href="https://github.com/sypsyp97/diffct/blob/edf7c52227ec2cc14c988918faa7313f8ee0fe03/docs/assets/diffct_intro.mp4">Intro video (MP4)</a> ·
23
+ <a href="https://github.com/sypsyp97/diffct/blob/main/docs/assets/diffct_intro.mp4">Intro video (MP4)</a> ·
24
24
  <a href="https://www.preprints.org/manuscript/202605.1446/v1">Technical report</a> ·
25
25
  <a href="https://doi.org/10.20944/preprints202605.1446.v1">DOI</a>
26
26
  </p>
@@ -45,21 +45,23 @@ To our knowledge, diffct is the only open-source GPU CT library that combines ar
45
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).
46
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.
47
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.
48
+ - **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or torchrun and NCCL across ranks. Views, space and hybrid partitions control replication of volumes and projections. Speedup depends on workload and communication costs.
49
+ - **Precision and training.** Explicit native/PyTorch backends, FP32/FP64 modes, per-axis voxel spacing, leading batch/channel dimensions and data `torch.func` transforms. The portable path supports CPU and PyTorch CUDA/ROCm devices; see [execution contracts and validation limits](docs/EXECUTION.md).
50
+ - **Solver API.** `cgls`, `sirt` and `tv_reconstruction` report residual history, tolerances and callbacks, with scan/data fingerprints and complete checkpoint state. `cgls_into` keeps state in bounded mapped blocks.
49
51
  - **Differentiable FBP and FDK.** `fbp`, `fdk` and approximate `helical_fdk` compose the public filters, weights and analytical backprojectors. Their backward kernels transpose the actual interpolation stencil; custom filters and weights can be learned. See the [analytical guide](docs/source/analytical_learning.rst).
50
52
 
51
53
  Capabilities, limits and isocenter rules: [docs/REFERENCE.md](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#capabilities-and-limits).
52
54
 
53
55
  ## Install
54
56
 
55
- You need an NVIDIA GPU. Install PyTorch for your CUDA version first. Then install diffct with the extra for the same CUDA major version:
57
+ For the native path, install NVIDIA CUDA PyTorch first, then diffct with the matching CUDA major extra:
56
58
 
57
59
  ```bash
58
60
  pip install "diffct[cu12]" # PyTorch built for CUDA 12
59
61
  pip install "diffct[cu13]" # PyTorch built for CUDA 13
60
62
  ```
61
63
 
62
- The extra installs the CUDA compiler libraries that Numba CUDA needs to compile the kernels. A plain `pip install diffct` does not install them. Use it only if a CUDA Toolkit is already installed on the system.
64
+ The extra installs the CUDA compiler libraries Numba needs. For CPU or PyTorch ROCm execution, install the appropriate PyTorch build and plain `pip install diffct`, then select `backend="torch"`. Importing diffct and using this backend require no CUDA runtime; Numba CUDA initializes when native execution is used. Plain installation also works for native execution when a compatible CUDA Toolkit is already present.
63
65
 
64
66
  From source, with the examples:
65
67
 
@@ -195,6 +197,7 @@ Walnut data: Meaney 2022, Zenodo 6986012, CC BY 4.0; see [examples/data/NOTICE](
195
197
 
196
198
  | Document | Contents |
197
199
  |---|---|
200
+ | [Precision, training and reconstruction](docs/EXECUTION.md) | Backend modes, batch/spacing contracts, analytical storage, hybrid loss scaling and solver checkpoints |
198
201
  | [Trajectory guide](https://github.com/sypsyp97/diffct/blob/main/docs/source/trajectories.rst) | Trajectory tuples and `Projector` usage |
199
202
  | [docs/REFERENCE.md](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md) | Capabilities and limits, custom trajectories, geometry gradients, multi-GPU details, geometry and units |
200
203
  | [docs/DISTRIBUTED.md](https://github.com/sypsyp97/diffct/blob/main/docs/DISTRIBUTED.md) | Execution/memory choices, distributed loss rules, Slurm and cross-node checks |
@@ -20,11 +20,15 @@ from .surfaces import ParameterizedSurface
20
20
  from .reconstruction import (
21
21
  fbp,
22
22
  fdk,
23
+ fbp_into,
24
+ fdk_into,
23
25
  helical_fdk,
24
26
  helical_view_weights,
25
27
  helical_weighted_backproject,
26
28
  )
27
29
 
30
+ from .solvers import SolverResult, cgls, sirt, tv_reconstruction, cgls_into, total_variation
31
+
28
32
  from .geometry import (
29
33
  circular_trajectory_3d,
30
34
  random_trajectory_3d,
@@ -58,7 +62,7 @@ try:
58
62
  except ImportError:
59
63
  pass
60
64
 
61
- __version__ = '2.2.1'
65
+ __version__ = '2.3.0'
62
66
 
63
67
  __all__ = [
64
68
  'Projector',
@@ -67,6 +71,14 @@ __all__ = [
67
71
  'ParameterizedSurface',
68
72
  'fbp',
69
73
  'fdk',
74
+ 'fbp_into',
75
+ 'fdk_into',
76
+ 'SolverResult',
77
+ 'cgls',
78
+ 'sirt',
79
+ 'tv_reconstruction',
80
+ 'cgls_into',
81
+ 'total_variation',
70
82
  'helical_fdk',
71
83
  'helical_view_weights',
72
84
  'helical_weighted_backproject',
@@ -0,0 +1,284 @@
1
+ """Bounded solver state, owned reductions and complete checkpoint publication."""
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import numbers
7
+ from pathlib import Path
8
+
9
+ import torch
10
+ import torch.distributed as dist
11
+
12
+ from .pipeline import _fragments
13
+ from .storage import NpyStore, TensorStore
14
+
15
+
16
+ def count(value, name, positive=False):
17
+ if isinstance(value, bool) or not isinstance(value, numbers.Integral) or value < int(positive):
18
+ raise ValueError(f"{name} must be a {'positive' if positive else 'nonnegative'} integer")
19
+ return int(value)
20
+
21
+
22
+ def finite(value, name, positive=False):
23
+ if isinstance(value, bool) or not isinstance(value, numbers.Real) or not math.isfinite(value) or value < 0 or (positive and value == 0):
24
+ raise ValueError(f"{name} must be finite and {'positive' if positive else 'nonnegative'}")
25
+ return float(value)
26
+
27
+
28
+ def agreement(operator, error=None, token=None):
29
+ """Every rank rejects a bad request before numerical execution starts."""
30
+ world = getattr(operator, "world_size", 1)
31
+ if world == 1:
32
+ if error is not None:
33
+ raise error
34
+ return
35
+ records = [None] * world
36
+ dist.all_gather_object(records, (None if error is None else str(error), token),
37
+ group=operator.process_group)
38
+ failures = [record[0] for record in records if record[0] is not None]
39
+ if failures or any(record[1] != records[0][1] for record in records):
40
+ raise ValueError("distributed solver agreement failed: " + (failures[0] if failures else "inconsistent checkpoint iteration or controls"))
41
+
42
+
43
+ def scalar_sum(operator, value, kind):
44
+ """Count physical rays/voxels once, including view/space replicas."""
45
+ world = getattr(operator, "world_size", 1)
46
+ partition = getattr(operator, "partition", "views")
47
+ rank = getattr(operator, "rank", 0)
48
+ owns = (getattr(operator, "view_rank", 0) == 0 if kind == "volume"
49
+ else getattr(operator, "space_rank", 0) == 0) if partition == "hybrid" else (
50
+ rank == 0 if (kind == "volume") == (partition == "views") else True
51
+ )
52
+ value = float(value) if owns else 0.
53
+ if world > 1:
54
+ group = operator.process_group
55
+ device = torch.device("cuda", torch.cuda.current_device()) if dist.get_backend(group) == "nccl" else torch.device("cpu")
56
+ accumulator = torch.tensor(value, dtype=torch.float64, device=device)
57
+ dist.all_reduce(accumulator, group=group)
58
+ value = accumulator.item()
59
+ return value
60
+
61
+
62
+ def squared_norm(operator, value, kind, elements=1048576):
63
+ store = TensorStore(value) if isinstance(value, torch.Tensor) else value
64
+ total = 0.
65
+ for selected in _fragments(tuple(store.shape), elements):
66
+ # Clone even an FP64 view: norms must never alter caller measurements.
67
+ block = store.read(selected).to(dtype=torch.float64).clone()
68
+ total += block.square_().sum().item()
69
+ return scalar_sum(operator, total, kind)
70
+
71
+
72
+ def copy_blocks(source, target, elements):
73
+ for selected in _fragments(tuple(target.shape), elements):
74
+ target.write(selected, source.read(selected).to(device=getattr(target, "device", "cpu"), dtype=target.dtype))
75
+
76
+
77
+ def fill_blocks(target, elements):
78
+ for selected in _fragments(tuple(target.shape), elements):
79
+ shape = tuple(part.stop - part.start for part in selected)
80
+ target.write(selected, torch.zeros(shape, dtype=target.dtype))
81
+
82
+
83
+ def update_blocks(target, source, factor, elements, scale=1.):
84
+ for selected in _fragments(tuple(target.shape), elements):
85
+ block = target.read(selected).to(device="cpu", dtype=target.dtype).clone()
86
+ block.mul_(scale).add_(source.read(selected).to(device="cpu", dtype=target.dtype), alpha=factor)
87
+ target.write(selected, block)
88
+
89
+
90
+ def fingerprint(value, elements):
91
+ store = TensorStore(value.detach()) if isinstance(value, torch.Tensor) else value
92
+ digest = hashlib.sha256(json.dumps((tuple(store.shape), str(store.dtype))).encode())
93
+ for selected in _fragments(tuple(store.shape), elements):
94
+ block = store.read(selected).detach().to(device="cpu").contiguous()
95
+ # Hash storage bytes without requiring NumPy support for the original
96
+ # floating dtype (for example bfloat16). This view allocates no payload.
97
+ digest.update(block.reshape(-1).view(torch.uint8).numpy().tobytes())
98
+ return digest.hexdigest()
99
+
100
+
101
+ def _effective_surface(operator, frames, elements):
102
+ """Hash the actual physical pixel rays, without materializing a volume."""
103
+ surface = getattr(operator, "detector_surface", None)
104
+ if surface is None:
105
+ return None
106
+ from .backends import ReferenceProjector
107
+ from .surfaces import ParameterizedSurface, _detector_grids
108
+
109
+ dimensions = len(operator.global_volume_shape)
110
+ if elements < max(3, dimensions):
111
+ raise ValueError("block_elements cannot hold one physical detector pixel")
112
+ if isinstance(surface, ParameterizedSurface):
113
+ if any(value.device.type != "cpu" and value.numel() > elements for value in surface.parameters):
114
+ raise ValueError("surface parameters cannot be staged within block_elements")
115
+ geometry = (*frames, *surface.parameters)
116
+ else:
117
+ # Legacy callbacks require complete grids. Their documented output can
118
+ # contain every view, so admit that snapshot only when it fits the
119
+ # bound; a smaller arbitrary callback invocation may change its meaning.
120
+ if operator._n_views * math.prod(operator.detector_shape) * 3 > elements:
121
+ raise ValueError("legacy surface fingerprint exceeds block_elements; use ParameterizedSurface")
122
+ pixels = tuple(slice(0, size) for size in operator.detector_shape)
123
+ u, v = _detector_grids(operator.detector_shape, operator.detector_spacing, pixels)
124
+ offsets = surface(u, v)
125
+ if not isinstance(offsets, torch.Tensor) or not torch.is_floating_point(offsets):
126
+ raise TypeError("detector_surface must return floating-point offsets")
127
+ if tuple(offsets.shape) not in ((*operator.detector_shape, 3),
128
+ (operator._n_views, *operator.detector_shape, 3)):
129
+ raise ValueError("legacy detector surface must match the complete pixel grids")
130
+ geometry = (*frames, offsets)
131
+ reference = getattr(operator, "_reference", None)
132
+ if reference is None:
133
+ reference = ReferenceProjector(operator, validate=False)
134
+ description = json.dumps((operator._n_views, operator.detector_shape, str(operator.dtype))).encode()
135
+ origins_digest, directions_digest = hashlib.sha256(description), hashlib.sha256(description)
136
+ for view in range(operator._n_views):
137
+ for pixels in _fragments(operator.detector_shape, elements // max(3, dimensions)):
138
+ origins, directions, _ = reference._rays(slice(view, view + 1), pixels,
139
+ torch.device("cpu"), validate=True, geometry=geometry)
140
+ # Separate streams preserve row-major ray ordering across different
141
+ # block sizes; changing a memory ceiling does not change the hash.
142
+ origins_digest.update(origins.detach().contiguous().numpy().tobytes())
143
+ directions_digest.update(directions.detach().contiguous().numpy().tobytes())
144
+ return [origins_digest.hexdigest(), directions_digest.hexdigest()]
145
+
146
+
147
+ def _signature_local(operator, source, algorithm, settings, elements, compute_dtype):
148
+ frames = getattr(operator, "_learnable", ()) or getattr(operator, "_trajectory", ())
149
+ geometry = tuple(fingerprint(value, elements) for value in frames)
150
+ surface = getattr(operator, "detector_surface", None)
151
+ surface_values = (() if surface is None else getattr(surface, "parameters", ()))
152
+ if isinstance(surface, torch.Tensor):
153
+ surface_values = (surface,)
154
+ return dict(
155
+ schema_version=1, algorithm=algorithm, settings=settings,
156
+ rank=getattr(operator, "rank", 0), world_size=getattr(operator, "world_size", 1),
157
+ partition=getattr(operator, "partition", "views"),
158
+ partition_grid=getattr(operator, "partition_grid", None),
159
+ global_volume_shape=list(operator.global_volume_shape), volume_shape=list(operator.volume_shape),
160
+ volume_slice=[[part.start, part.stop] for part in operator.volume_slice],
161
+ projection_shape=list(operator.projection_shape),
162
+ view_slice=[operator.view_slice.start, operator.view_slice.stop],
163
+ beam=operator.beam, voxel_spacing=operator.voxel_spacing,
164
+ detector_spacing=list(operator.detector_spacing), detector_shape=list(operator.detector_shape),
165
+ input_dtype=str(source.dtype), geometry=list(geometry),
166
+ surface=[fingerprint(value, elements) for value in surface_values],
167
+ effective_surface=_effective_surface(operator, frames, elements),
168
+ compute_dtype=str(compute_dtype), precision=getattr(operator, "precision", None),
169
+ backend=getattr(operator, "backend", None),
170
+ measurements=fingerprint(source, elements),
171
+ )
172
+
173
+
174
+ def signature(operator, source, algorithm, settings, elements, *, compute_dtype=None):
175
+ error, metadata = None, None
176
+ try:
177
+ dtype = getattr(operator, "dtype", source.dtype) if compute_dtype is None else compute_dtype
178
+ metadata = _signature_local(operator, source, algorithm, settings, elements, dtype)
179
+ except Exception as caught:
180
+ error = caught
181
+ agreement(operator, error)
182
+ if getattr(operator, "world_size", 1) > 1:
183
+ records = [None] * operator.world_size
184
+ dist.all_gather_object(records, metadata, group=operator.process_group)
185
+ local_fields = {"rank", "volume_shape", "volume_slice", "projection_shape", "view_slice", "measurements"}
186
+ scans = [{key: value for key, value in record.items() if key not in local_fields} for record in records]
187
+ if any(scan != scans[0] for scan in scans):
188
+ raise ValueError("distributed checkpoint scan and compute configuration must agree")
189
+ replicas = {}
190
+ for record in records:
191
+ views = tuple(record["view_slice"])
192
+ digest = record["measurements"]
193
+ if views in replicas and replicas[views] != digest:
194
+ raise ValueError("distributed checkpoint measurement replicas must agree")
195
+ replicas[views] = digest
196
+ return metadata
197
+
198
+
199
+ def load_checkpoint(operator, directory, expected, iterations, elements, names):
200
+ error, saved, states = None, None, None
201
+ try:
202
+ directory = Path(directory).resolve()
203
+ saved = json.loads((directory / "metadata.json").read_text(encoding="utf-8"))
204
+ # JSON canonicalization also normalizes tuple-valued physical spacing.
205
+ canonical = json.loads(json.dumps(expected))
206
+ if any(saved.get(key) != value for key, value in canonical.items()):
207
+ raise ValueError("checkpoint scan, data, ownership, dtype or algorithm settings do not match")
208
+ completed = count(saved.get("iteration"), "checkpoint iteration")
209
+ history = saved.get("residual_history")
210
+ if completed > iterations or not isinstance(history, list) or len(history) != completed + 1:
211
+ raise ValueError("checkpoint iteration or residual history is invalid")
212
+ for value in history:
213
+ finite(value, "checkpoint residual")
214
+ finite(saved.get("measurement_norm"), "checkpoint measurement norm")
215
+ if expected["algorithm"] in ("cgls", "cgls_into"):
216
+ finite(saved.get("gamma"), "checkpoint gamma")
217
+ manifest = saved.get("states", {})
218
+ if set(manifest) != set(names):
219
+ raise ValueError("checkpoint does not contain the complete solver state")
220
+ states = {}
221
+ for name in names:
222
+ store = NpyStore(directory / f"{name}.npy", mode="r")
223
+ specification = manifest[name]
224
+ shape = operator.projection_shape if name in ("r", "q", "row") else operator.volume_shape
225
+ if name == "step":
226
+ shape = (1,)
227
+ if tuple(store.shape) != tuple(shape) or list(store.shape) != specification.get("shape") or str(store.dtype) != specification.get("dtype"):
228
+ raise ValueError("checkpoint solver state shape or dtype does not match")
229
+ if fingerprint(store, elements) != specification.get("fingerprint"):
230
+ raise ValueError("checkpoint solver state fingerprint does not match")
231
+ states[name] = store
232
+ except (ValueError, TypeError, OSError, EOFError, KeyError) as caught:
233
+ error = caught
234
+ token = None if error is not None else (saved["iteration"], saved["residual_history"], saved.get("gamma"), saved["measurement_norm"])
235
+ agreement(operator, error, token)
236
+ return saved, states
237
+
238
+
239
+ def save_checkpoint(operator, directory, states, metadata, iteration, history, measurement_norm, elements, **scalars):
240
+ error, manifest, rank = None, {}, None
241
+ try:
242
+ step = Path(directory).resolve() / f"step-{iteration:06d}"
243
+ step.mkdir(parents=True, exist_ok=True)
244
+ rank = step / f"rank-{getattr(operator, 'rank', 0):05d}"
245
+ rank.mkdir()
246
+ for name, source in states.items():
247
+ source = TensorStore(source.detach()) if isinstance(source, torch.Tensor) else source
248
+ target = NpyStore.create(rank / f"{name}.npy", source.shape, dtype=source.dtype)
249
+ copy_blocks(source, target, elements)
250
+ target.flush()
251
+ manifest[name] = dict(shape=list(source.shape), dtype=str(source.dtype), fingerprint=fingerprint(target, elements))
252
+ except (OSError, ValueError, TypeError) as caught:
253
+ error = caught
254
+ # All owned states on every rank are written and flushed before any rank
255
+ # atomically publishes the metadata marking a completed iteration.
256
+ agreement(operator, error, (iteration, history))
257
+ error = None
258
+ try:
259
+ document = dict(metadata, iteration=iteration, residual_history=history,
260
+ measurement_norm=measurement_norm, states=manifest, **scalars)
261
+ temporary = rank / "metadata.tmp"
262
+ with temporary.open("x", encoding="utf-8") as file:
263
+ json.dump(document, file)
264
+ file.flush()
265
+ temporary.replace(rank / "metadata.json")
266
+ except Exception as caught:
267
+ error = caught
268
+ # A failed metadata write/rename must stop every rank before another
269
+ # projection or callback can enter a numerical collective.
270
+ agreement(operator, error, iteration)
271
+
272
+
273
+ def callback_stop(operator, callback, iteration, residual, volume):
274
+ error, stop = None, False
275
+ try:
276
+ stop = callback is not None and callback(iteration, residual, volume) is False
277
+ except Exception as caught:
278
+ error = caught
279
+ agreement(operator, error)
280
+ if getattr(operator, "world_size", 1) > 1:
281
+ records = [None] * operator.world_size
282
+ dist.all_gather_object(records, stop, group=operator.process_group)
283
+ stop = any(records)
284
+ return stop
@@ -0,0 +1,110 @@
1
+ """Physical forward-difference TV with differentiable slab boundary faces."""
2
+
3
+ import math
4
+ import numbers
5
+
6
+ import torch
7
+ import torch.distributed as dist
8
+
9
+ from ._solver_state import finite, scalar_sum
10
+ from .backends import _zero_dependency
11
+
12
+
13
+ def _peers(operator, nonempty):
14
+ spaces = operator.space_world_size
15
+ current = operator.space_rank
16
+ occupied = [rank for rank in range(spaces) if rank < min(spaces, operator.global_volume_shape[0])]
17
+ previous = max((rank for rank in occupied if rank < current), default=None) if nonempty else None
18
+ following = min((rank for rank in occupied if rank > current), default=None) if nonempty else None
19
+ def global_rank(rank):
20
+ if rank is None:
21
+ return None
22
+ parent = operator.view_rank * spaces + rank
23
+ return operator._partition.global_rank(parent)
24
+ return global_rank(previous), global_rank(following)
25
+
26
+
27
+ class _FollowingFace(torch.autograd.Function):
28
+ @staticmethod
29
+ def forward(ctx, volume, operator):
30
+ previous, following = _peers(operator, volume.shape[0] > 0)
31
+ ctx.operator, ctx.previous, ctx.following = operator, previous, following
32
+ ctx.shape, ctx.dtype, ctx.device = volume.shape, volume.dtype, volume.device
33
+ nccl = dist.get_backend(operator.space_group) == "nccl"
34
+ ctx.communication_device = (torch.device("cuda", torch.cuda.current_device())
35
+ if nccl else torch.device("cpu"))
36
+ if nccl:
37
+ # Initialize the subgroup communicator collectively, including
38
+ # empty slabs, before the first subset-only face exchange.
39
+ dist.barrier(group=operator.space_group)
40
+ face = torch.zeros(volume.shape[1:], dtype=volume.dtype, device=ctx.communication_device)
41
+ operations = []
42
+ if previous is not None:
43
+ first = volume[0].detach().to(device=ctx.communication_device).contiguous()
44
+ operations.append(dist.P2POp(dist.isend, first, previous, operator.space_group))
45
+ if following is not None:
46
+ operations.append(dist.P2POp(dist.irecv, face, following, operator.space_group))
47
+ if operations:
48
+ for work in dist.batch_isend_irecv(operations):
49
+ work.wait()
50
+ return face.to(device=volume.device)
51
+
52
+ @staticmethod
53
+ def backward(ctx, grad_face):
54
+ received = torch.zeros(ctx.shape[1:], dtype=ctx.dtype, device=ctx.communication_device)
55
+ operations = []
56
+ if ctx.following is not None:
57
+ outgoing = grad_face.to(device=ctx.communication_device).contiguous()
58
+ operations.append(dist.P2POp(dist.isend, outgoing, ctx.following, ctx.operator.space_group))
59
+ if ctx.previous is not None:
60
+ operations.append(dist.P2POp(dist.irecv, received, ctx.previous, ctx.operator.space_group))
61
+ if operations:
62
+ for work in dist.batch_isend_irecv(operations):
63
+ work.wait()
64
+ gradient = torch.zeros(ctx.shape, dtype=ctx.dtype, device=ctx.device)
65
+ if ctx.shape[0] > 0:
66
+ gradient[0] = received.to(device=ctx.device)
67
+ return gradient, None
68
+
69
+
70
+ def total_variation(volume, *, operator=None, voxel_spacing=1., eps=1e-6, reduction="mean"):
71
+ """Isotropic TV over every voxel, with physical forward differences.
72
+
73
+ Differences vanish at the global outer boundary. Spatially partitioned
74
+ volumes exchange a single neighboring face in forward and backward; the
75
+ reported scalar counts replicated ownership once and gradients stay local.
76
+ """
77
+ if not isinstance(volume, torch.Tensor) or not torch.is_floating_point(volume) or volume.ndim < 1:
78
+ raise TypeError("volume must be a real floating-point tensor")
79
+ eps = finite(eps, "eps", positive=True)
80
+ if reduction not in ("mean", "sum"):
81
+ raise ValueError("reduction must be mean or sum")
82
+ spacing = (voxel_spacing,) * volume.ndim if isinstance(voxel_spacing, numbers.Real) else tuple(voxel_spacing)
83
+ if len(spacing) != volume.ndim:
84
+ raise ValueError("voxel_spacing must match the volume axes")
85
+ spacing = tuple(finite(pitch, "voxel_spacing", positive=True) for pitch in spacing)
86
+ if operator is not None and tuple(volume.shape) != tuple(operator.volume_shape):
87
+ raise ValueError("volume shape must match its operator ownership")
88
+ distributed_space = operator is not None and getattr(operator, "world_size", 1) > 1 and operator.partition in ("space", "hybrid")
89
+ halo = _FollowingFace.apply(volume, operator) if distributed_space else None
90
+ squared = torch.zeros_like(volume)
91
+ for axis, pitch in enumerate(spacing):
92
+ current, following = [slice(None)] * volume.ndim, [slice(None)] * volume.ndim
93
+ current[axis], following[axis] = slice(0, -1), slice(1, None)
94
+ difference = torch.zeros_like(volume)
95
+ difference[tuple(current)] = (volume[tuple(following)] - volume[tuple(current)]) / pitch
96
+ if axis == 0 and halo is not None and volume.shape[0] and operator.volume_slice[0].stop < operator.global_volume_shape[0]:
97
+ difference[-1] = (halo - volume[-1]) / pitch
98
+ squared = squared + difference.square()
99
+ local = torch.sqrt(squared + eps).sum()
100
+ if halo is not None:
101
+ # The final slab also needs backward to receive the previous slab's
102
+ # contribution, even though its own global outer difference is zero.
103
+ local = local + _zero_dependency(halo)
104
+ total = local
105
+ elements = volume.numel()
106
+ if operator is not None:
107
+ global_value = scalar_sum(operator, local.detach().item(), "volume")
108
+ total = local + (local.new_tensor(global_value) - local.detach())
109
+ elements = math.prod(operator.global_volume_shape)
110
+ return total if reduction == "sum" else total / max(elements, 1)