diffct 2.4.1__py3-none-any.whl → 2.4.3__py3-none-any.whl
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.
- diffct/__init__.py +1 -1
- diffct/_solver_state.py +12 -9
- diffct/_solver_tv.py +3 -2
- diffct/analytical.py +3 -6
- diffct/analytical_execution.py +17 -0
- diffct/backends.py +8 -3
- diffct/chunking.py +1 -0
- diffct/kernels/surface_geometry.py +18 -10
- diffct/operators.py +312 -80
- diffct/solvers.py +22 -11
- diffct/surfaces.py +15 -12
- diffct/utils.py +10 -0
- {diffct-2.4.1.dist-info → diffct-2.4.3.dist-info}/METADATA +9 -9
- {diffct-2.4.1.dist-info → diffct-2.4.3.dist-info}/RECORD +17 -17
- {diffct-2.4.1.dist-info → diffct-2.4.3.dist-info}/WHEEL +0 -0
- {diffct-2.4.1.dist-info → diffct-2.4.3.dist-info}/licenses/LICENSE +0 -0
- {diffct-2.4.1.dist-info → diffct-2.4.3.dist-info}/licenses/NOTICE +0 -0
diffct/__init__.py
CHANGED
diffct/_solver_state.py
CHANGED
|
@@ -53,30 +53,32 @@ def owns_values(operator, kind):
|
|
|
53
53
|
)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def scalar_sum(operator, value, kind):
|
|
57
|
-
"""Count physical rays/voxels once,
|
|
56
|
+
def scalar_sum(operator, value, kind, *, return_tensor=False):
|
|
57
|
+
"""Count physical rays/voxels once, optionally retaining the reduction tensor."""
|
|
58
58
|
world = getattr(operator, "world_size", 1)
|
|
59
|
+
result_device = value.device if isinstance(value, torch.Tensor) else torch.device("cpu")
|
|
59
60
|
value = value if owns_values(operator, kind) else 0.
|
|
60
61
|
if world > 1:
|
|
61
62
|
group = operator.process_group
|
|
62
63
|
device = torch.device("cuda", torch.cuda.current_device()) if dist.get_backend(group) == "nccl" else torch.device("cpu")
|
|
63
64
|
accumulator = torch.as_tensor(value, dtype=torch.float64, device=device).clone()
|
|
64
65
|
dist.all_reduce(accumulator, group=group)
|
|
65
|
-
return accumulator.item()
|
|
66
|
+
return accumulator if return_tensor else accumulator.item()
|
|
67
|
+
if return_tensor:
|
|
68
|
+
return torch.as_tensor(value, dtype=torch.float64, device=result_device)
|
|
66
69
|
return float(value)
|
|
67
70
|
|
|
68
71
|
|
|
69
|
-
def squared_norm(operator, value, kind, elements=1048576):
|
|
72
|
+
def squared_norm(operator, value, kind, elements=1048576, *, return_tensor=False):
|
|
70
73
|
store = TensorStore(value) if isinstance(value, torch.Tensor) else value
|
|
71
|
-
total =
|
|
74
|
+
total = torch.zeros((), dtype=torch.float64, device=getattr(store, "device", "cpu"))
|
|
72
75
|
if owns_values(operator, kind):
|
|
73
|
-
total = torch.zeros((), dtype=torch.float64, device=getattr(store, "device", "cpu"))
|
|
74
76
|
for selected in _fragments(tuple(store.shape), elements):
|
|
75
77
|
# Clone even an FP64 view: norms must never alter caller measurements.
|
|
76
78
|
block = store.read(selected).to(dtype=torch.float64).clone()
|
|
77
79
|
# Preserve the block reductions and their sequential FP64 order.
|
|
78
80
|
total.add_(block.square_().sum().to(device=total.device))
|
|
79
|
-
return scalar_sum(operator, total, kind)
|
|
81
|
+
return scalar_sum(operator, total, kind, return_tensor=return_tensor)
|
|
80
82
|
|
|
81
83
|
|
|
82
84
|
def copy_blocks(source, target, elements):
|
|
@@ -91,9 +93,10 @@ def fill_blocks(target, elements):
|
|
|
91
93
|
|
|
92
94
|
|
|
93
95
|
def update_blocks(target, source, factor, elements, scale=1.):
|
|
96
|
+
device = getattr(target, "device", "cpu")
|
|
94
97
|
for selected in _fragments(tuple(target.shape), elements):
|
|
95
|
-
block = target.read(selected).to(device=
|
|
96
|
-
block.mul_(scale).add_(source.read(selected).to(device=
|
|
98
|
+
block = target.read(selected).to(device=device, dtype=target.dtype).clone()
|
|
99
|
+
block.mul_(scale).add_(source.read(selected).to(device=device, dtype=target.dtype), alpha=factor)
|
|
97
100
|
target.write(selected, block)
|
|
98
101
|
|
|
99
102
|
|
diffct/_solver_tv.py
CHANGED
|
@@ -104,7 +104,8 @@ def total_variation(volume, *, operator=None, voxel_spacing=1., eps=1e-6, reduct
|
|
|
104
104
|
total = local
|
|
105
105
|
elements = volume.numel()
|
|
106
106
|
if operator is not None:
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
if getattr(operator, "world_size", 1) > 1:
|
|
108
|
+
global_value = scalar_sum(operator, local.detach(), "volume", return_tensor=True)
|
|
109
|
+
total = local + (global_value.to(local) - local.detach())
|
|
109
110
|
elements = math.prod(operator.global_volume_shape)
|
|
110
111
|
return total if reduction == "sum" else total / max(elements, 1)
|
diffct/analytical.py
CHANGED
|
@@ -523,8 +523,7 @@ def _fan_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec,
|
|
|
523
523
|
d_sino, n_views, n_det, d_reco, Nx, Ny,
|
|
524
524
|
_DTYPE(detector_spacing), d_src_pos, d_det_center, d_det_u_vec,
|
|
525
525
|
cx, cy, _DTYPE(voxel_spacing),
|
|
526
|
-
_DTYPE(
|
|
527
|
-
_DTYPE(isocenter[1].item() / voxel_spacing),
|
|
526
|
+
*(_DTYPE(value / voxel_spacing) for value in isocenter.tolist()),
|
|
528
527
|
)
|
|
529
528
|
|
|
530
529
|
return reco
|
|
@@ -586,9 +585,7 @@ def _cone_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec, det
|
|
|
586
585
|
_DTYPE(du), _DTYPE(dv),
|
|
587
586
|
d_src_pos, d_det_center, d_det_u_vec, d_det_v_vec,
|
|
588
587
|
cx, cy, cz, _DTYPE(voxel_spacing),
|
|
589
|
-
_DTYPE(
|
|
590
|
-
_DTYPE(isocenter[1].item() / voxel_spacing),
|
|
591
|
-
_DTYPE(isocenter[2].item() / voxel_spacing),
|
|
588
|
+
*(_DTYPE(value / voxel_spacing) for value in isocenter.tolist()),
|
|
592
589
|
)
|
|
593
590
|
|
|
594
591
|
# Convert back to (D, H, W) layout for the user.
|
|
@@ -640,7 +637,7 @@ def _analytical_transpose_raw(image, beam, geometry, parameters, isocenter, sino
|
|
|
640
637
|
scale = (sdd_n / (2.0 * math.pi * sid_n)).view(
|
|
641
638
|
(-1,) + (1,) * (len(sinogram_shape) - 1)
|
|
642
639
|
)
|
|
643
|
-
iso = tuple(_DTYPE(value
|
|
640
|
+
iso = tuple(_DTYPE(value / parameters[-1]) for value in isocenter.tolist())
|
|
644
641
|
|
|
645
642
|
if beam == "cone":
|
|
646
643
|
D, H, W, du, dv, spacing = parameters
|
diffct/analytical_execution.py
CHANGED
|
@@ -53,6 +53,8 @@ def _sum(value, group, count):
|
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
def _sum_blocks(value, group, count, block_elements):
|
|
56
|
+
if count == 1:
|
|
57
|
+
return value
|
|
56
58
|
flat = value.reshape(-1)
|
|
57
59
|
blocks = [_sum(flat.narrow(0, start, min(block_elements, flat.numel() - start)), group, count)
|
|
58
60
|
for start in range(0, flat.numel(), block_elements)]
|
|
@@ -385,6 +387,21 @@ class AnalyticalExecution:
|
|
|
385
387
|
def tensor_result(self):
|
|
386
388
|
self._prepare_gradients()
|
|
387
389
|
result = torch.zeros(self.volume_shape, device=self.output_device, dtype=self.dtype)
|
|
390
|
+
inputs = (self.source, self.projection_weights, self.angular_weights, self.filter_response)
|
|
391
|
+
active_gradients = torch.is_grad_enabled() and any(value is not None and value.requires_grad
|
|
392
|
+
for value in inputs)
|
|
393
|
+
if self.partition.world_size == 1 and not active_gradients:
|
|
394
|
+
# Each tile still accumulates view batches in their original order.
|
|
395
|
+
# The numerical result itself holds the partial sums between batches.
|
|
396
|
+
for views, device in self._view_batches():
|
|
397
|
+
filtered, geometry = self._filtered(views, device)
|
|
398
|
+
for spatial, _ in _pixel_tiles(self.volume_shape, self.chunk_shape):
|
|
399
|
+
partial = (self._native_gather(filtered, geometry, spatial) if self.backend == "numba"
|
|
400
|
+
else self._portable_gather(filtered, geometry, views, spatial))
|
|
401
|
+
result[spatial].add_(partial.to(self.output_device))
|
|
402
|
+
del partial
|
|
403
|
+
del filtered, geometry
|
|
404
|
+
return result
|
|
388
405
|
# Every owned tile's backward collective precedes the bundled input
|
|
389
406
|
# transpose, even when this rank owns no data views. Empty space groups
|
|
390
407
|
# have no tile collectives but still contribute zero input cotangents.
|
diffct/backends.py
CHANGED
|
@@ -61,7 +61,7 @@ def _rectangles(shape, chunks):
|
|
|
61
61
|
for start, step, size in zip(starts, chunks, shape))
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
def _flat_admission_batches(owner, geometry, detector_chunk_shape):
|
|
64
|
+
def _flat_admission_batches(owner, geometry, detector_chunk_shape, *, batched_corners=False):
|
|
65
65
|
"""Bound flat admission with corners and exact ambiguous-view fallback."""
|
|
66
66
|
views = owner.view_slice
|
|
67
67
|
if views.start == views.stop:
|
|
@@ -70,8 +70,13 @@ def _flat_admission_batches(owner, geometry, detector_chunk_shape):
|
|
|
70
70
|
# coordinate; finite corner points and source differences bound the grid.
|
|
71
71
|
endpoints = tuple((slice(0, 1),) if size == 1 else (slice(0, 1), slice(size - 1, size))
|
|
72
72
|
for size in owner.detector_shape)
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
if batched_corners:
|
|
74
|
+
# Native validation can compose the Cartesian corner set in one batch.
|
|
75
|
+
# Stepped pixel slices retain the exact global endpoint coordinates.
|
|
76
|
+
yield views, tuple(slice(0, size, max(1, size - 1)) for size in owner.detector_shape)
|
|
77
|
+
else:
|
|
78
|
+
for pixels in itertools.product(*endpoints):
|
|
79
|
+
yield views, pixels
|
|
75
80
|
if owner.beam in ("parallel", "parallel3d"):
|
|
76
81
|
return
|
|
77
82
|
source, lower = (value[views].to(dtype=owner.dtype) for value in geometry[:2])
|
diffct/chunking.py
CHANGED
|
@@ -51,26 +51,34 @@ def compose_surface(frames, offsets, source, points, view_index, pixel_start,
|
|
|
51
51
|
``frame32`` gives the arithmetic dtype of each packed frame component.
|
|
52
52
|
``offset32`` gives the offset arithmetic dtype. Values are stored exactly
|
|
53
53
|
in float64 staging buffers. Results use caller-owned float32 buffers.
|
|
54
|
+
|
|
55
|
+
A launch may compose several views: grid row ``y``
|
|
56
|
+
writes view ``view_index + y`` from the ``y``-th of ``gridDim.y`` equal
|
|
57
|
+
frame packets in ``frames``. A shared offset buffer holds ``rank * capacity``
|
|
58
|
+
values plus status; a larger buffer holds one such packet per output view.
|
|
54
59
|
"""
|
|
55
60
|
pixel = cuda.grid(1)
|
|
61
|
+
view_index += cuda.blockIdx.y
|
|
62
|
+
base = cuda.blockIdx.y * (frames.size // cuda.gridDim.y)
|
|
56
63
|
if pixel == 0:
|
|
57
64
|
for axis in range(rank):
|
|
58
|
-
value = frames[axis]
|
|
65
|
+
value = frames[base + axis]
|
|
59
66
|
if not parallel:
|
|
60
67
|
value = _surface_add(value, -centre[axis], frame32[0])
|
|
61
68
|
source[view_index, axis] = np.float32(value)
|
|
62
69
|
if pixel >= count:
|
|
63
70
|
return
|
|
64
71
|
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
offset_base = view_index * rank * capacity if offsets.size > rank * capacity + 1 else 0
|
|
73
|
+
tangent = offsets[offset_base + pixel]
|
|
74
|
+
depth = offsets[offset_base + (rank - 1) * capacity + pixel]
|
|
67
75
|
tangent32 = offset32 and frame32[2]
|
|
68
76
|
point32 = frame32[1] and tangent32
|
|
69
77
|
normal32 = frame32[2]
|
|
70
78
|
vertical = np.float64(0.0)
|
|
71
79
|
vertical32 = False
|
|
72
80
|
if rank == 3:
|
|
73
|
-
vertical = offsets[capacity + pixel]
|
|
81
|
+
vertical = offsets[offset_base + capacity + pixel]
|
|
74
82
|
vertical32 = offset32 and frame32[3]
|
|
75
83
|
point32 = point32 and vertical32
|
|
76
84
|
normal32 = normal32 and frame32[3]
|
|
@@ -85,20 +93,20 @@ def compose_surface(frames, offsets, source, points, view_index, pixel_start,
|
|
|
85
93
|
point_squared = np.float64(0.0)
|
|
86
94
|
for axis in range(rank):
|
|
87
95
|
point = _surface_add(
|
|
88
|
-
frames[rank + axis],
|
|
89
|
-
_surface_multiply(tangent, frames[2 * rank + axis], tangent32),
|
|
96
|
+
frames[base + rank + axis],
|
|
97
|
+
_surface_multiply(tangent, frames[base + 2 * rank + axis], tangent32),
|
|
90
98
|
frame32[1] and tangent32,
|
|
91
99
|
)
|
|
92
100
|
if rank == 3:
|
|
93
101
|
point = _surface_add(
|
|
94
|
-
point, _surface_multiply(vertical, frames[3 * rank + axis], vertical32),
|
|
102
|
+
point, _surface_multiply(vertical, frames[base + 3 * rank + axis], vertical32),
|
|
95
103
|
point32,
|
|
96
104
|
)
|
|
97
|
-
normal = frames[4 * rank + axis]
|
|
105
|
+
normal = frames[base + 4 * rank + axis]
|
|
98
106
|
else:
|
|
99
|
-
normal = frames[2 * rank + 1] if axis == 0 else -frames[2 * rank]
|
|
107
|
+
normal = frames[base + 2 * rank + 1] if axis == 0 else -frames[base + 2 * rank]
|
|
100
108
|
point = _surface_add(point, _surface_multiply(depth, normal, depth32), final32)
|
|
101
|
-
source_value = frames[axis]
|
|
109
|
+
source_value = frames[base + axis]
|
|
102
110
|
source_native = np.float32(source_value)
|
|
103
111
|
point_native = np.float32(point)
|
|
104
112
|
finite = (finite and math.isfinite(source_value) and math.isfinite(point)
|
diffct/operators.py
CHANGED
|
@@ -16,7 +16,7 @@ from .storage import TensorStore
|
|
|
16
16
|
from .streaming import _validate_stores
|
|
17
17
|
from .surfaces import ParameterizedSurface, _GeometrySpec, _sample_geometry, _sample_offsets
|
|
18
18
|
from .surface_gradients import surface_batch_vjp
|
|
19
|
-
from .utils import TorchCUDABridge, _get_numba_external_stream_for
|
|
19
|
+
from .utils import TorchCUDABridge, _get_numba_external_stream_for, _raise_geometry_errors
|
|
20
20
|
from .backends import ReferenceProjector, _flat_admission_batches, select_backend, voxel_spacing_tuple
|
|
21
21
|
from .distribution import Partition
|
|
22
22
|
from .projectors import (
|
|
@@ -77,22 +77,27 @@ def _validate_trajectory(trajectory, beam, *, detector_surface=False, clone=True
|
|
|
77
77
|
if not isinstance(trajectory, (tuple, list)) or len(trajectory) != count:
|
|
78
78
|
raise TypeError(f"{beam} trajectory must contain {count} tensors")
|
|
79
79
|
|
|
80
|
-
geometry = []
|
|
80
|
+
geometry, checks = [], []
|
|
81
81
|
n_views = None
|
|
82
82
|
for component in trajectory:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
83
|
+
try:
|
|
84
|
+
if not isinstance(component, torch.Tensor):
|
|
85
|
+
raise TypeError("trajectory components must be tensors")
|
|
86
|
+
if component.ndim != 2 or component.shape[1] != dimensions:
|
|
87
|
+
raise ValueError(f"trajectory tensors must have shape (views, {dimensions})")
|
|
88
|
+
if not torch.is_floating_point(component):
|
|
89
|
+
raise TypeError("trajectory tensors must have floating-point dtype")
|
|
90
|
+
if component.shape[0] == 0:
|
|
91
|
+
raise ValueError("trajectory must contain at least one view")
|
|
92
|
+
if n_views is not None and component.shape[0] != n_views:
|
|
93
|
+
raise ValueError("trajectory tensors must have the same number of views")
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
# A deferred value error of an earlier component takes precedence.
|
|
96
|
+
if checks:
|
|
97
|
+
_raise_geometry_errors(checks)
|
|
98
|
+
raise
|
|
93
99
|
n_views = component.shape[0]
|
|
94
|
-
|
|
95
|
-
raise ValueError("trajectory tensors must contain only finite values")
|
|
100
|
+
checks.append((~torch.isfinite(component).all(), "trajectory tensors must contain only finite values"))
|
|
96
101
|
geometry.append(component.detach().clone() if clone else component.detach())
|
|
97
102
|
|
|
98
103
|
device = next((value.device for value in geometry if value.is_cuda), geometry[0].device)
|
|
@@ -103,28 +108,29 @@ def _validate_trajectory(trajectory, beam, *, detector_surface=False, clone=True
|
|
|
103
108
|
)
|
|
104
109
|
for axis in axes:
|
|
105
110
|
norms = torch.linalg.vector_norm(axis, dim=1)
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
checks.append((~torch.isclose(norms, torch.ones_like(norms), rtol=1e-4, atol=1e-4).all(),
|
|
112
|
+
"trajectory direction axes must be unit vectors"))
|
|
108
113
|
if beam in ("parallel", "cone", "parallel3d"):
|
|
109
114
|
first, second = (geometry[0], geometry[2]) if beam == "parallel" else (geometry[2], geometry[3])
|
|
110
115
|
dots = torch.sum(first.to(device) * second.to(device), dim=1)
|
|
111
|
-
|
|
112
|
-
raise ValueError("trajectory direction axes must be orthogonal")
|
|
116
|
+
checks.append((torch.any(torch.abs(dots) > 1e-4), "trajectory direction axes must be orthogonal"))
|
|
113
117
|
if beam == "parallel3d":
|
|
114
118
|
direction = geometry[0].to(device=device, dtype=torch.float64)
|
|
115
119
|
frame = torch.stack((direction, geometry[2].to(direction.device, torch.float64),
|
|
116
120
|
geometry[3].to(direction.device, torch.float64)), dim=-1)
|
|
117
121
|
# Numerical rank rejects a tangent frame at machine roundoff without
|
|
118
122
|
# imposing an application-level minimum angle of incidence.
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
# Bad entries already have a finite-value error above; keep the rank
|
|
124
|
+
# solver well-defined until the combined error flags reach the host.
|
|
125
|
+
frame = torch.where(torch.isfinite(frame), frame, 0.)
|
|
126
|
+
checks.append((torch.any(torch.linalg.matrix_rank(frame) < 3),
|
|
127
|
+
"parallel3d rays must not be tangent to the detector plane"))
|
|
121
128
|
if beam in ("fan", "cone") and not detector_surface:
|
|
122
129
|
# float64 avoids overflow of half-precision input and dtype mismatches in cross().
|
|
123
130
|
source = geometry[0].to(device=device, dtype=torch.float64)
|
|
124
131
|
principal = geometry[1].to(source.device, torch.float64) - source
|
|
125
132
|
lengths = torch.linalg.vector_norm(principal, dim=1)
|
|
126
|
-
|
|
127
|
-
raise ValueError("source and detector center must differ in every view")
|
|
133
|
+
checks.append((torch.any(lengths == 0), "source and detector center must differ in every view"))
|
|
128
134
|
principal = principal / lengths[:, None]
|
|
129
135
|
det_u = geometry[2].to(source.device, torch.float64)
|
|
130
136
|
if beam == "fan":
|
|
@@ -132,9 +138,10 @@ def _validate_trajectory(trajectory, beam, *, detector_surface=False, clone=True
|
|
|
132
138
|
else:
|
|
133
139
|
normal = torch.linalg.cross(det_u, geometry[3].to(source.device, torch.float64), dim=1)
|
|
134
140
|
facing = torch.sum(principal * normal, dim=1)
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
checks.append((torch.any(torch.abs(facing) < 1e-4),
|
|
142
|
+
"the detector must not be edge-on to the source in any view"))
|
|
137
143
|
|
|
144
|
+
_raise_geometry_errors(checks)
|
|
138
145
|
return tuple(geometry), n_views
|
|
139
146
|
|
|
140
147
|
|
|
@@ -489,6 +496,8 @@ class Projector:
|
|
|
489
496
|
if self.backend != "torch" and detector_surface is not None:
|
|
490
497
|
geometry, spec, surface_trainable, surface_metadata = self._surface_validation_inputs()
|
|
491
498
|
devices = self._devices or (torch.device("cuda", torch.cuda.current_device()),)
|
|
499
|
+
# Validation budgets depend on whether geometry gradients are needed.
|
|
500
|
+
self._geometry_trainable = bool(self._learnable) or surface_trainable
|
|
492
501
|
self._validate_surface_geometry(geometry, spec, devices)
|
|
493
502
|
except (TypeError, ValueError, RuntimeError) as error:
|
|
494
503
|
validation_error = error
|
|
@@ -542,16 +551,14 @@ class Projector:
|
|
|
542
551
|
)
|
|
543
552
|
for component in self._trajectory[:2]
|
|
544
553
|
])
|
|
545
|
-
|
|
546
|
-
|
|
554
|
+
_raise_geometry_errors([
|
|
555
|
+
(torch.any(distances.min(dim=0).values > 1e6),
|
|
547
556
|
"the source or the detector center must lie within 1e6 voxels "
|
|
548
|
-
"of the volume center in every view"
|
|
549
|
-
)
|
|
550
|
-
if torch.any(distances > 1e15).item():
|
|
551
|
-
raise ValueError(
|
|
557
|
+
"of the volume center in every view"),
|
|
558
|
+
(torch.any(distances > 1e15),
|
|
552
559
|
"the source and the detector center must lie within 1e15 voxels "
|
|
553
|
-
"of the volume center"
|
|
554
|
-
|
|
560
|
+
"of the volume center"),
|
|
561
|
+
])
|
|
555
562
|
|
|
556
563
|
def _agreement_revision(self):
|
|
557
564
|
surface = self.detector_surface
|
|
@@ -766,23 +773,30 @@ class Projector:
|
|
|
766
773
|
peers = len(active_devices) > 1 or (self._distributed and self.world_size > 1)
|
|
767
774
|
extra_rays = (2 * int(self.partition == "space") + int(is_project and foreign_output)
|
|
768
775
|
+ int(is_project and self.partition == "space" and peers))
|
|
776
|
+
surface_batch = True
|
|
769
777
|
|
|
770
778
|
def fits(shape):
|
|
771
779
|
points = surface or pixels != self.detector_shape
|
|
780
|
+
# Legacy offsets are one host snapshot; world points expand on the device.
|
|
781
|
+
host_points = points and self._host_world_points(geometry_spec)
|
|
772
782
|
buffers = _pipeline_buffers(self.beam, shape, batch, pixels, points,
|
|
773
783
|
geometry_grad=self._geometry_trainable,
|
|
774
784
|
ray_accumulators=extra_rays, peers=peers or foreign_output)
|
|
775
|
-
|
|
785
|
+
workspace = self._surface_work_buffers(batch, pixels, spec=geometry_spec,
|
|
786
|
+
streamed=True, batched=surface_batch)
|
|
776
787
|
return (
|
|
777
|
-
sum(buffers) <= budget
|
|
778
|
-
and _reservation_bytes(buffers
|
|
788
|
+
sum(buffers) + sum(workspace) <= budget
|
|
789
|
+
and _reservation_bytes(buffers + self._surface_reserved_buffers(
|
|
790
|
+
batch, pixels, spec=geometry_spec, streamed=True, batched=surface_batch)) <= reservation_budget
|
|
779
791
|
and _largest_buffer_bytes(self.beam, shape, batch, pixels, points) <= allocation_limit
|
|
780
|
-
and max(
|
|
781
|
-
and _host_geometry_bytes(self.beam, batch, pixels,
|
|
792
|
+
and max(workspace, default=0) <= allocation_limit
|
|
793
|
+
and _host_geometry_bytes(self.beam, batch, pixels, host_points) <= _pipeline._GEOMETRY_WORK_BYTES
|
|
782
794
|
)
|
|
783
795
|
|
|
784
796
|
while not fits(minimum_shape):
|
|
785
|
-
if self.
|
|
797
|
+
if self.detector_surface is not None and surface_batch:
|
|
798
|
+
surface_batch = False
|
|
799
|
+
elif self.view_chunk_size is None and batch > 1:
|
|
786
800
|
batch = max(1, batch // 2)
|
|
787
801
|
elif self.detector_chunk_shape is None and max(pixels) > 1:
|
|
788
802
|
axis = max(range(len(pixels)), key=pixels.__getitem__)
|
|
@@ -792,25 +806,36 @@ class Projector:
|
|
|
792
806
|
else:
|
|
793
807
|
raise RuntimeError("CUDA or host geometry memory budget cannot fit the minimum tile/view/pixel batch; reduce explicit limits")
|
|
794
808
|
while not fits(chunk):
|
|
809
|
+
if self.detector_surface is not None and surface_batch:
|
|
810
|
+
surface_batch = False
|
|
811
|
+
continue
|
|
795
812
|
if self.volume_chunk_shape is not None or max(chunk) == 1:
|
|
796
813
|
raise RuntimeError("CUDA memory budget cannot fit the requested tile/view batch")
|
|
797
814
|
axis = max(range(len(chunk)), key=chunk.__getitem__)
|
|
798
815
|
smaller = list(chunk)
|
|
799
816
|
smaller[axis] = max(1, smaller[axis] // 2)
|
|
800
817
|
chunk = tuple(smaller)
|
|
818
|
+
if not surface_batch:
|
|
819
|
+
# Smaller automatic tiles may now fit the complete workspace.
|
|
820
|
+
surface_batch = True
|
|
821
|
+
if not fits(chunk):
|
|
822
|
+
surface_batch = False
|
|
801
823
|
estimate = _pipeline_bytes(self.beam, chunk, batch, pixels,
|
|
802
824
|
surface or pixels != self.detector_shape,
|
|
803
825
|
geometry_grad=self._geometry_trainable,
|
|
804
826
|
ray_accumulators=extra_rays, peers=peers or foreign_output)
|
|
805
|
-
estimate += sum(self._surface_work_buffers(batch, pixels, spec=geometry_spec,
|
|
827
|
+
estimate += sum(self._surface_work_buffers(batch, pixels, spec=geometry_spec,
|
|
828
|
+
streamed=True, batched=surface_batch))
|
|
806
829
|
return _ChunkPlan(chunk, batch, devices, pixels, budget, allocation_limit,
|
|
807
830
|
estimated_gpu_bytes=estimate, foreign_output=foreign_output,
|
|
831
|
+
surface_batch=surface_batch,
|
|
808
832
|
reservation_budget_bytes=reservation_budget,
|
|
809
833
|
estimated_reserved_bytes=_reservation_bytes(_pipeline_buffers(
|
|
810
834
|
self.beam, chunk, batch, pixels, surface or pixels != self.detector_shape,
|
|
811
835
|
geometry_grad=self._geometry_trainable,
|
|
812
836
|
ray_accumulators=extra_rays, peers=peers or foreign_output)
|
|
813
|
-
+ self.
|
|
837
|
+
+ self._surface_reserved_buffers(batch, pixels, spec=geometry_spec,
|
|
838
|
+
streamed=True, batched=surface_batch)))
|
|
814
839
|
|
|
815
840
|
def _surface_capacity(self, views, pixels):
|
|
816
841
|
rank, columns = len(self.volume_shape), 3 if self.beam == "cone" else 2
|
|
@@ -818,46 +843,104 @@ class Projector:
|
|
|
818
843
|
maximum = max(views * math.prod(pixels) * rank, views * rank * frames)
|
|
819
844
|
return max(1, min(math.prod(pixels), 21, (maximum - 1) // columns))
|
|
820
845
|
|
|
821
|
-
def
|
|
822
|
-
"""
|
|
846
|
+
def _surface_snapshot_buffers(self):
|
|
847
|
+
"""Allocator-rounded FP64 bounds for independent geometry snapshots."""
|
|
848
|
+
return ((_allocation_bytes(2 * self._n_views * math.prod(self.detector_shape) * 3),)
|
|
849
|
+
+ (_allocation_bytes(2 * self._n_views * len(self.volume_shape)),) * len(self._trajectory))
|
|
850
|
+
|
|
851
|
+
def _surface_work_buffers(self, *args, **kwargs):
|
|
852
|
+
"""One inventory bounding every surface workspace phase.
|
|
853
|
+
|
|
854
|
+
Its total is the largest phase total and its largest entry is the
|
|
855
|
+
largest allocation of any phase. Phases do not overlap in time.
|
|
856
|
+
"""
|
|
857
|
+
phases = self._surface_work_phases(*args, **kwargs)
|
|
858
|
+
buffers = list(max(phases, key=sum))
|
|
859
|
+
peak = max(max(phase, default=0) for phase in phases)
|
|
860
|
+
if peak > max(buffers, default=0):
|
|
861
|
+
# The largest total covers the peak allocation: move the excess
|
|
862
|
+
# from the smallest entries into the largest one.
|
|
863
|
+
largest = buffers.index(max(buffers))
|
|
864
|
+
excess, buffers[largest] = peak - buffers[largest], peak
|
|
865
|
+
for index in sorted((i for i in range(len(buffers)) if i != largest), key=buffers.__getitem__):
|
|
866
|
+
taken = min(buffers[index], excess)
|
|
867
|
+
buffers[index] -= taken
|
|
868
|
+
excess -= taken
|
|
869
|
+
buffers = [size for size in buffers if size]
|
|
870
|
+
return tuple(buffers)
|
|
871
|
+
|
|
872
|
+
def _surface_reserved_buffers(self, *args, **kwargs):
|
|
873
|
+
"""Every surface workspace allocation of a call, for reservation bounds.
|
|
874
|
+
|
|
875
|
+
Phases never hold live tensors together, but blocks of one phase can
|
|
876
|
+
leave allocator segments pinned for the next, so all of them count.
|
|
877
|
+
"""
|
|
878
|
+
return tuple(size for phase in self._surface_work_phases(*args, **kwargs) for size in phase)
|
|
879
|
+
|
|
880
|
+
def _surface_work_phases(self, views, pixels, slots=1, spec=None, *, streamed=False, batched=True):
|
|
881
|
+
"""Bound device-local offset staging and world-point expression storage.
|
|
882
|
+
|
|
883
|
+
Returns the buffer inventories of phases that never coexist.
|
|
884
|
+
"""
|
|
823
885
|
if self.detector_surface is None:
|
|
824
|
-
return ()
|
|
886
|
+
return ((),)
|
|
825
887
|
if streamed:
|
|
826
888
|
rank, frames = len(self.volume_shape), 4 if self.beam == "cone" else 3
|
|
827
889
|
capacity = self._surface_capacity(views, pixels)
|
|
828
890
|
columns = 3 if self.beam == "cone" else 2
|
|
829
|
-
|
|
830
|
-
|
|
891
|
+
shared = spec is not None and spec.kind == "offsets" and spec.shared
|
|
892
|
+
if shared or batched:
|
|
893
|
+
capacity = math.prod(pixels)
|
|
894
|
+
# Batched jobs retain every frame and, unless shared, every offset.
|
|
895
|
+
buffers = (_allocation_bytes(2 * (views if shared or batched else 1) * (frames + (3 if rank == 3 else 0)) * rank),
|
|
896
|
+
_allocation_bytes(2 * (columns * capacity * (views if batched and not shared else 1) + 1)))
|
|
831
897
|
if self._geometry_trainable:
|
|
832
898
|
# Replay one view and a bounded detector row at a time.
|
|
833
|
-
replay = rank * min(
|
|
899
|
+
replay = rank * min(self._surface_capacity(views, pixels), pixels[-1])
|
|
834
900
|
replay_buffers = ((_allocation_bytes(2 * replay),) * 8
|
|
835
901
|
+ (_allocation_bytes(2 * rank),) * (frames + 3))
|
|
836
902
|
if isinstance(self.detector_surface, ParameterizedSurface):
|
|
837
903
|
# A CUDA sampler needs a device cotangent for its selected output.
|
|
838
904
|
replay_buffers += (_allocation_bytes(6 * views * math.prod(pixels)),)
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
return buffers
|
|
905
|
+
# Composition frees its workspace before replay starts.
|
|
906
|
+
return buffers, replay_buffers
|
|
907
|
+
return (buffers,)
|
|
842
908
|
elements = views * math.prod(pixels) * 3
|
|
843
909
|
dtype = torch.float64 if spec is None or spec.dtype is None else spec.dtype
|
|
844
910
|
buffers = (torch.empty((), dtype=dtype).element_size() * elements,) * (4 * slots)
|
|
845
911
|
if dtype != torch.float64:
|
|
846
912
|
buffers += (8 * elements,) * slots
|
|
847
|
-
|
|
913
|
+
buffers += (views * 3 * 8 * (4 if self.beam == "cone" else 3),) * slots
|
|
914
|
+
if spec is None and not isinstance(self.detector_surface, ParameterizedSurface):
|
|
915
|
+
# The preliminary full plan precedes the callback. Admit independent
|
|
916
|
+
# snapshots plus one bounded arena for the callback's saved tensors.
|
|
917
|
+
snapshots = self._surface_snapshot_buffers()
|
|
918
|
+
buffers += (*snapshots, sum(snapshots))
|
|
919
|
+
return (buffers,)
|
|
848
920
|
|
|
849
921
|
def _surface_spec(self, frames, offsets):
|
|
850
922
|
dtype = torch.float32
|
|
851
923
|
for component in (*frames[1:], offsets):
|
|
852
924
|
dtype = torch.promote_types(dtype, component.dtype)
|
|
853
|
-
return _GeometrySpec("offsets", dtype=dtype)
|
|
925
|
+
return _GeometrySpec("offsets", dtype=dtype, shared=offsets.ndim == len(self.detector_shape) + 1)
|
|
926
|
+
|
|
927
|
+
def _host_world_points(self, spec):
|
|
928
|
+
"""Whether the host geometry budget must count world points of each view.
|
|
929
|
+
|
|
930
|
+
Shared legacy offsets are one host snapshot whose world points are
|
|
931
|
+
composed on the compute device; per-view offsets and geometry
|
|
932
|
+
gradients keep host work proportional to views x pixels.
|
|
933
|
+
"""
|
|
934
|
+
return not (spec is not None and spec.kind == "offsets" and spec.shared
|
|
935
|
+
and not self._geometry_trainable)
|
|
854
936
|
|
|
855
937
|
def _surface_validation_inputs(self):
|
|
856
938
|
"""Stage frame metadata once without expanding surface world points."""
|
|
857
939
|
with torch.enable_grad():
|
|
858
|
-
frames =
|
|
940
|
+
frames = self._learnable or self._trajectory
|
|
859
941
|
surface = self.detector_surface
|
|
860
942
|
if isinstance(surface, ParameterizedSurface):
|
|
943
|
+
frames = tuple(value.to(device="cpu") for value in frames)
|
|
861
944
|
parameters = tuple(value.to(device="cpu") for value in surface.parameters)
|
|
862
945
|
geometry = (*frames, *parameters)
|
|
863
946
|
return (geometry, _GeometrySpec("sampled", surface.sampler, torch.float64),
|
|
@@ -884,7 +967,7 @@ class Projector:
|
|
|
884
967
|
buffers = self._surface_work_buffers(batch, pixels, spec=spec)
|
|
885
968
|
return (sum(buffers) <= budget and max(buffers, default=0) <= allocation
|
|
886
969
|
and _reservation_bytes(buffers) <= reservation
|
|
887
|
-
and _host_geometry_bytes(self.beam, batch, pixels,
|
|
970
|
+
and _host_geometry_bytes(self.beam, batch, pixels, self._host_world_points(spec))
|
|
888
971
|
<= _pipeline._GEOMETRY_WORK_BYTES)
|
|
889
972
|
|
|
890
973
|
if not fits():
|
|
@@ -1136,6 +1219,7 @@ class Projector:
|
|
|
1136
1219
|
return dict(operation="project" if is_project else "backproject", schedule=plan.schedule,
|
|
1137
1220
|
chunk_shape=tuple(plan.chunk_shape), view_chunk_size=plan.view_chunk_size,
|
|
1138
1221
|
detector_chunk_shape=tuple(plan.detector_chunk_shape or self.detector_shape),
|
|
1222
|
+
surface_batch=plan.surface_batch,
|
|
1139
1223
|
window_size=plan.window_size, gpu_budget_bytes=plan.gpu_budget_bytes,
|
|
1140
1224
|
estimated_gpu_bytes=plan.estimated_gpu_bytes,
|
|
1141
1225
|
reservation_budget_bytes=plan.reservation_budget_bytes,
|
|
@@ -1154,14 +1238,16 @@ class Projector:
|
|
|
1154
1238
|
return _pipeline_bytes(self.beam, shape, plan.view_chunk_size, pixels, points,
|
|
1155
1239
|
slots=slots, retained=retained, geometry_grad=self._geometry_trainable,
|
|
1156
1240
|
peers=peers, ray_accumulators=ray_accumulators) + sum(
|
|
1157
|
-
self._surface_work_buffers(plan.view_chunk_size, pixels, slots, spec,
|
|
1241
|
+
self._surface_work_buffers(plan.view_chunk_size, pixels, slots, spec,
|
|
1242
|
+
streamed=True, batched=plan.surface_batch))
|
|
1158
1243
|
|
|
1159
1244
|
def reserved(shape, slots=1, retained=1, ray_accumulators=0):
|
|
1160
1245
|
return _reservation_bytes(_pipeline_buffers(
|
|
1161
1246
|
self.beam, shape, plan.view_chunk_size, pixels, points,
|
|
1162
1247
|
slots=slots, retained=retained, geometry_grad=self._geometry_trainable,
|
|
1163
1248
|
peers=peers, ray_accumulators=ray_accumulators)
|
|
1164
|
-
+ self.
|
|
1249
|
+
+ self._surface_reserved_buffers(plan.view_chunk_size, pixels, slots, spec,
|
|
1250
|
+
streamed=True, batched=plan.surface_batch))
|
|
1165
1251
|
|
|
1166
1252
|
shapes = [plan.chunk_shape]
|
|
1167
1253
|
if self.volume_chunk_shape is None and len(volume_shape) == 3:
|
|
@@ -1197,7 +1283,8 @@ class Projector:
|
|
|
1197
1283
|
estimate = size(shape, slots, retained, extra_rays)
|
|
1198
1284
|
reservation = (reserved(shape, slots, retained, extra_rays)
|
|
1199
1285
|
if estimate <= plan.gpu_budget_bytes else (1 << 63) - 1)
|
|
1200
|
-
|
|
1286
|
+
host_points = points and self._host_world_points(spec)
|
|
1287
|
+
host_fits = 2 * _host_geometry_bytes(self.beam, plan.view_chunk_size, pixels, host_points) <= _pipeline._GEOMETRY_WORK_BYTES
|
|
1201
1288
|
if (estimate > plan.gpu_budget_bytes
|
|
1202
1289
|
or reservation > plan.reservation_budget_bytes or not host_fits):
|
|
1203
1290
|
if schedule == "window":
|
|
@@ -1236,8 +1323,8 @@ class Projector:
|
|
|
1236
1323
|
return _pipeline_buffers(
|
|
1237
1324
|
self.beam, plan.chunk_shape, plan.view_chunk_size, pixels, points,
|
|
1238
1325
|
slots=plan.slots, retained=plan.window_size, geometry_grad=self._geometry_trainable,
|
|
1239
|
-
peers=peers, ray_accumulators=extra_rays) + self.
|
|
1240
|
-
plan.view_chunk_size, pixels, plan.slots, spec, streamed=True)
|
|
1326
|
+
peers=peers, ray_accumulators=extra_rays) + self._surface_reserved_buffers(
|
|
1327
|
+
plan.view_chunk_size, pixels, plan.slots, spec, streamed=True, batched=plan.surface_batch)
|
|
1241
1328
|
|
|
1242
1329
|
def _choose_schedule(self, source, sink, is_project, geometry, plan, spec):
|
|
1243
1330
|
candidates = self._candidate_plans(plan, is_project, spec)
|
|
@@ -1247,7 +1334,8 @@ class Projector:
|
|
|
1247
1334
|
tuple(device.index for device in plan.devices), str(source.dtype), plan.gpu_budget_bytes,
|
|
1248
1335
|
plan.allocation_limit, plan.reservation_budget_bytes,
|
|
1249
1336
|
str(getattr(source, "device", "cpu")), str(getattr(sink, "device", "cpu")),
|
|
1250
|
-
spec.kind if spec is not None else "flat", self.schedule
|
|
1337
|
+
spec.kind if spec is not None else "flat", self.schedule, plan.surface_batch,
|
|
1338
|
+
spec.shared if spec is not None else False)
|
|
1251
1339
|
collective = self._distributed and self.world_size > 1
|
|
1252
1340
|
if not collective and key in self._schedule_cache:
|
|
1253
1341
|
return self._schedule_cache[key], [], True
|
|
@@ -1310,11 +1398,15 @@ class Projector:
|
|
|
1310
1398
|
rank, frame_count = len(self.volume_shape), 4 if self.beam == "cone" else 3
|
|
1311
1399
|
count = views.stop - views.start
|
|
1312
1400
|
pixel_shape = tuple(selected.stop - selected.start for selected in pixels)
|
|
1401
|
+
# Offsets sampled here derive from the snapshot and the pixel tile;
|
|
1402
|
+
# offsets supplied by the caller may differ per batch and are not cached.
|
|
1403
|
+
cacheable = offsets is None
|
|
1313
1404
|
if offsets is None:
|
|
1314
1405
|
offsets = _sample_offsets(self.beam, self.detector_shape, self.detector_spacing,
|
|
1315
1406
|
geometry, spec, views, pixels)
|
|
1316
1407
|
origin = torch.cuda.current_stream(offsets.device) if offsets.is_cuda else None
|
|
1317
1408
|
frames = geometry[:frame_count]
|
|
1409
|
+
frame_origins = tuple(torch.cuda.current_stream(value.device) for value in frames if value.is_cuda)
|
|
1318
1410
|
common = torch.float32
|
|
1319
1411
|
for value in (*frames, offsets):
|
|
1320
1412
|
common = torch.promote_types(common, value.dtype)
|
|
@@ -1324,23 +1416,118 @@ class Projector:
|
|
|
1324
1416
|
frame32 += (True,)
|
|
1325
1417
|
offset32 = ((torch.promote_types(torch.float32, offsets.dtype)
|
|
1326
1418
|
if spec.kind == "offsets" else common) == torch.float32)
|
|
1419
|
+
# Offsets shared by all views are staged once per call and composed
|
|
1420
|
+
# with one launch for all views instead of per view and row segment.
|
|
1421
|
+
shared = spec.kind == "offsets" and offsets.ndim == len(pixel_shape) + 1
|
|
1422
|
+
batched = shared or groups.get("_surface_batch", True)
|
|
1423
|
+
pixel_count = math.prod(pixel_shape)
|
|
1424
|
+
# One packed frame packet per view: source, center, axes, normal and
|
|
1425
|
+
# float32 cross-product scratch. Shared offsets pack every view of a job.
|
|
1426
|
+
stride = (frame_count + (3 if rank == 3 else 0)) * rank
|
|
1327
1427
|
if "_surface_workspace" not in groups:
|
|
1328
|
-
|
|
1428
|
+
maximum_views = groups.get("_max_geometry", (count * rank,))[0] // rank
|
|
1429
|
+
capacity = (groups.get("_max_rays", count * pixel_count) // maximum_views
|
|
1430
|
+
if batched else self._surface_capacity(count, pixel_shape))
|
|
1329
1431
|
groups["_surface_workspace"] = (
|
|
1330
|
-
pipe.empty((
|
|
1331
|
-
pipe.empty((
|
|
1432
|
+
pipe.empty((maximum_views if batched else 1) * stride, dtype=torch.float64),
|
|
1433
|
+
pipe.empty(rank * capacity * (maximum_views if batched and not shared else 1) + 1,
|
|
1434
|
+
dtype=torch.float64), capacity,
|
|
1332
1435
|
)
|
|
1333
1436
|
packed_frames, packed_offsets, capacity = groups["_surface_workspace"]
|
|
1334
1437
|
columns = (0, 1, 2) if rank == 3 else (0, 2)
|
|
1335
1438
|
source, points = targets[0].view(count, rank), targets[1].view(count, -1, rank)
|
|
1439
|
+
# A workspace sized for an earlier, smaller tile keeps the segment path.
|
|
1440
|
+
shared = shared and capacity >= pixel_count
|
|
1441
|
+
batched = batched and capacity >= pixel_count
|
|
1442
|
+
if batched and packed_frames.numel() < count * stride:
|
|
1443
|
+
# Earlier launches on the upload stream may still read the smaller buffer.
|
|
1444
|
+
pipe.retain(pipe.event(pipe.upload), packed_frames, None)
|
|
1445
|
+
packed_frames = pipe.empty(count * stride, dtype=torch.float64)
|
|
1446
|
+
if not shared:
|
|
1447
|
+
pipe.retain(pipe.event(pipe.upload), packed_offsets, None)
|
|
1448
|
+
packed_offsets = pipe.empty(rank * capacity * count + 1, dtype=torch.float64)
|
|
1449
|
+
groups["_surface_workspace"] = (packed_frames, packed_offsets, capacity)
|
|
1450
|
+
# Every job of a call slices the same offset snapshot: stage it once per
|
|
1451
|
+
# call, pixel tile and workspace. The key holds the snapshot and the
|
|
1452
|
+
# workspace, so neither a later snapshot reusing storage nor a recreated
|
|
1453
|
+
# workspace can match it.
|
|
1454
|
+
if shared and cacheable:
|
|
1455
|
+
staged_key = (geometry[frame_count], packed_offsets, tuple((part.start, part.stop) for part in pixels))
|
|
1456
|
+
restage = not ("_surface_shared_staged" in groups
|
|
1457
|
+
and groups["_surface_shared_staged"][0] is staged_key[0]
|
|
1458
|
+
and groups["_surface_shared_staged"][1] is staged_key[1]
|
|
1459
|
+
and groups["_surface_shared_staged"][2] == staged_key[2])
|
|
1460
|
+
else:
|
|
1461
|
+
restage = batched
|
|
1462
|
+
groups.pop("_surface_shared_staged", None)
|
|
1336
1463
|
with torch.cuda.device(pipe.device), torch.cuda.stream(pipe.upload), cuda.gpus[pipe.device.index]:
|
|
1337
1464
|
if origin is not None:
|
|
1338
1465
|
pipe.upload.wait_stream(origin)
|
|
1466
|
+
for frame_origin in frame_origins:
|
|
1467
|
+
pipe.upload.wait_stream(frame_origin)
|
|
1339
1468
|
packed_offsets[-1].zero_()
|
|
1340
1469
|
stream = _get_numba_external_stream_for(pipe.upload)
|
|
1341
1470
|
arrays = tuple(TorchCUDABridge.tensor_to_cuda_array(value)
|
|
1342
1471
|
for value in (packed_frames, packed_offsets, source, points))
|
|
1343
|
-
|
|
1472
|
+
if restage:
|
|
1473
|
+
# Preserve strided sampler outputs: copy directly into admitted
|
|
1474
|
+
# column storage without flattening the input into a temporary.
|
|
1475
|
+
offset_views = 1 if shared else count
|
|
1476
|
+
packed = packed_offsets[:offset_views * rank * capacity].view(offset_views, rank, capacity)
|
|
1477
|
+
selected_offsets = offsets.detach()
|
|
1478
|
+
if selected_offsets.ndim == len(pixel_shape) + 1:
|
|
1479
|
+
selected_offsets = selected_offsets.unsqueeze(0)
|
|
1480
|
+
if offsets.is_cuda:
|
|
1481
|
+
for column, original in enumerate(columns):
|
|
1482
|
+
target = packed[:, column, :pixel_count].view(offset_views, *pixel_shape)
|
|
1483
|
+
value = selected_offsets[..., original].expand(offset_views, *pixel_shape)
|
|
1484
|
+
_pipeline._copy(target, value, pipe.stats)
|
|
1485
|
+
pipe.retain(pipe.event(pipe.upload), value, packed_offsets)
|
|
1486
|
+
else:
|
|
1487
|
+
host = torch.empty((offset_views, rank, capacity), dtype=torch.float64)
|
|
1488
|
+
for column, original in enumerate(columns):
|
|
1489
|
+
host[:, column, :pixel_count].view(offset_views, *pixel_shape).copy_(
|
|
1490
|
+
selected_offsets[..., original])
|
|
1491
|
+
_pipeline._copy(packed, host, pipe.stats)
|
|
1492
|
+
pipe.retain(pipe.event(pipe.upload), host, packed_offsets)
|
|
1493
|
+
if shared and cacheable:
|
|
1494
|
+
groups["_surface_shared_staged"] = staged_key
|
|
1495
|
+
if batched:
|
|
1496
|
+
# Stage the frame packets of every view in one transfer and
|
|
1497
|
+
# compose all views with one launch; grid row y composes view y.
|
|
1498
|
+
block = packed_frames[:count * stride].view(count, stride)
|
|
1499
|
+
if frame_origins:
|
|
1500
|
+
for index, value in enumerate(frames):
|
|
1501
|
+
selected = value[views].detach()
|
|
1502
|
+
_pipeline._copy(block[:, index * rank:(index + 1) * rank], selected, pipe.stats)
|
|
1503
|
+
pipe.retain(pipe.event(pipe.upload), selected, packed_frames)
|
|
1504
|
+
else:
|
|
1505
|
+
packet = torch.zeros((count, stride), dtype=torch.float64)
|
|
1506
|
+
for index, value in enumerate(frames):
|
|
1507
|
+
packet[:, index * rank:(index + 1) * rank].copy_(value[views].detach())
|
|
1508
|
+
_pipeline._copy(block, packet, pipe.stats)
|
|
1509
|
+
pipe.retain(pipe.event(pipe.upload), packet, packed_frames)
|
|
1510
|
+
if rank == 3:
|
|
1511
|
+
normal = block[:, 4 * rank:5 * rank]
|
|
1512
|
+
axis_u, axis_v = block[:, 2 * rank:3 * rank], block[:, 3 * rank:4 * rank]
|
|
1513
|
+
if frame32[2] and frame32[3]:
|
|
1514
|
+
scratch = block.view(torch.float32)[:, 10 * rank:13 * rank]
|
|
1515
|
+
scratch[:, :rank].copy_(axis_u)
|
|
1516
|
+
scratch[:, rank:2 * rank].copy_(axis_v)
|
|
1517
|
+
torch.linalg.cross(scratch[:, :rank], scratch[:, rank:2 * rank], dim=1,
|
|
1518
|
+
out=scratch[:, 2 * rank:])
|
|
1519
|
+
normal.copy_(scratch[:, 2 * rank:])
|
|
1520
|
+
else:
|
|
1521
|
+
torch.linalg.cross(axis_u, axis_v, dim=1, out=normal)
|
|
1522
|
+
for first in range(0, count, 65535):
|
|
1523
|
+
rows = min(65535, count - first)
|
|
1524
|
+
compose_surface[((pixel_count + 127) // 128, rows), 128, stream](
|
|
1525
|
+
TorchCUDABridge.tensor_to_cuda_array(block[first:first + rows].reshape(-1)),
|
|
1526
|
+
*arrays[1:], first, 0, pixel_count, capacity, rank,
|
|
1527
|
+
self.beam == "parallel", frame32, offset32,
|
|
1528
|
+
tuple(centre) + (0.,) * (3 - rank), self.voxel_spacing,
|
|
1529
|
+
)
|
|
1530
|
+
for local in range(0 if batched else count):
|
|
1344
1531
|
packet = torch.cat([value[views.start + local].detach().to(device="cpu", dtype=torch.float64)
|
|
1345
1532
|
for value in frames])
|
|
1346
1533
|
_pipeline._copy(packed_frames[:frame_count * rank], packet, pipe.stats)
|
|
@@ -1463,7 +1650,7 @@ class Projector:
|
|
|
1463
1650
|
points = spec is not None or self.detector_chunk_shape is not None or pixels != self.detector_shape
|
|
1464
1651
|
sizes = ((plan.view_chunk_size * rank, rays * rank) if points
|
|
1465
1652
|
else (plan.view_chunk_size * rank,) * (4 if self.beam == "cone" else 3))
|
|
1466
|
-
return {"_max_rays": rays, "_max_geometry": sizes}
|
|
1653
|
+
return {"_max_rays": rays, "_max_geometry": sizes, "_surface_batch": plan.surface_batch}
|
|
1467
1654
|
|
|
1468
1655
|
def _prefetch_job(self, job, source, geometry, spec, transfer, groups, index, is_project,
|
|
1469
1656
|
native, reduced, active_rays, next_epoch=False):
|
|
@@ -2034,7 +2221,7 @@ class Projector:
|
|
|
2034
2221
|
spacing = self.detector_spacing if self.beam == "cone" else self.detector_spacing[0]
|
|
2035
2222
|
sampled = geometry_spec is not None and geometry_spec.kind in ("sampled", "offsets")
|
|
2036
2223
|
grads = [torch.zeros(component.shape, dtype=torch.float32,
|
|
2037
|
-
device=
|
|
2224
|
+
device=component.device if sampled else volume.device) for component in geometry]
|
|
2038
2225
|
for device_rank, device in enumerate(devices):
|
|
2039
2226
|
local_slice = _balanced_slice(local_views, device_rank, len(devices))
|
|
2040
2227
|
if local_slice.start == local_slice.stop:
|
|
@@ -2139,6 +2326,7 @@ class Projector:
|
|
|
2139
2326
|
slot.finish()
|
|
2140
2327
|
pipe.reclaim_output()
|
|
2141
2328
|
del local_groups["_surface_workspace"]
|
|
2329
|
+
local_groups.pop("_surface_shared_staged", None)
|
|
2142
2330
|
reduced = surface_batch_vjp(
|
|
2143
2331
|
self.beam, self.detector_shape, self.detector_spacing, inputs, offsets,
|
|
2144
2332
|
global_slice, pixels, self.voxel_spacing, pipe.device, parts,
|
|
@@ -2193,7 +2381,8 @@ class Projector:
|
|
|
2193
2381
|
self.last_execution_stats = stats
|
|
2194
2382
|
return tuple(grad.to(device=component.device, dtype=component.dtype)
|
|
2195
2383
|
for grad, component in zip(grads, geometry))
|
|
2196
|
-
|
|
2384
|
+
# Replay composition follows the admitted batched/fallback workspace.
|
|
2385
|
+
groups = {device: {"_surface_batch": plan.surface_batch} for device in plan.devices}
|
|
2197
2386
|
with _pipeline._TransferPipeline(plan.devices, self._pool_elements(plan, geometry_spec), stats,
|
|
2198
2387
|
slots=plan.slots) as transfer:
|
|
2199
2388
|
for spatial_slice, shape, centre in self._volume_tiles(plan.chunk_shape):
|
|
@@ -2420,22 +2609,28 @@ class Projector:
|
|
|
2420
2609
|
staged = tuple(value[owned].to(device=device, dtype=owner.dtype) for value in geometry)
|
|
2421
2610
|
admission_owner = copy(owner)
|
|
2422
2611
|
admission_owner.view_slice = slice(0, shard.stop - shard.start)
|
|
2423
|
-
|
|
2612
|
+
nonfinite = torch.zeros((), device=device, dtype=torch.bool)
|
|
2613
|
+
coincident = torch.zeros_like(nonfinite)
|
|
2614
|
+
for views, selected in _flat_admission_batches(
|
|
2615
|
+
admission_owner, staged, pixels, batched_corners=True,
|
|
2616
|
+
):
|
|
2424
2617
|
source, points = _sample_geometry(
|
|
2425
2618
|
owner.beam, owner.detector_shape, owner.detector_spacing, staged,
|
|
2426
2619
|
None, views, selected, owner.voxel_spacing, device=device,
|
|
2427
2620
|
dtype=owner.dtype, validate=False,
|
|
2428
2621
|
)
|
|
2429
|
-
|
|
2430
|
-
raise ValueError("detector surface world coordinates must be finite")
|
|
2622
|
+
nonfinite = nonfinite | ~torch.isfinite(source).all() | ~torch.isfinite(points).all()
|
|
2431
2623
|
sources = source.reshape(source.shape[0], *([1] * len(selected)), -1)
|
|
2432
|
-
if owner.beam != "parallel"
|
|
2433
|
-
|
|
2624
|
+
if owner.beam != "parallel":
|
|
2625
|
+
coincident = coincident | torch.any(torch.all(points == sources, dim=-1))
|
|
2434
2626
|
directions = (source if owner.beam == "parallel" else
|
|
2435
2627
|
points - sources)
|
|
2436
|
-
|
|
2437
|
-
raise ValueError("detector surface world coordinates must be finite")
|
|
2628
|
+
nonfinite = nonfinite | ~torch.isfinite(directions).all()
|
|
2438
2629
|
del source, points, sources, directions
|
|
2630
|
+
_raise_geometry_errors([
|
|
2631
|
+
(nonfinite, "detector surface world coordinates must be finite"),
|
|
2632
|
+
(coincident, "source and detector surface pixels must differ"),
|
|
2633
|
+
])
|
|
2439
2634
|
del staged
|
|
2440
2635
|
except (TypeError, ValueError, RuntimeError) as failure:
|
|
2441
2636
|
error = failure
|
|
@@ -2497,7 +2692,25 @@ class Projector:
|
|
|
2497
2692
|
def _prepare_execution(self, tensor, is_project, output_store=None):
|
|
2498
2693
|
device = torch.device(getattr(tensor, "device", "cpu"))
|
|
2499
2694
|
if self.detector_surface is not None:
|
|
2500
|
-
|
|
2695
|
+
# Only eligible full calls need a preliminary snapshot reservation.
|
|
2696
|
+
# Explicit streamed jobs first obtain exact shared-offset metadata.
|
|
2697
|
+
devices = self._devices or (device,)
|
|
2698
|
+
active_devices = devices[:min(len(devices), self.projection_shape[0])]
|
|
2699
|
+
full_candidate = (not isinstance(self.detector_surface, ParameterizedSurface)
|
|
2700
|
+
and isinstance(tensor, torch.Tensor) and device.type == "cuda"
|
|
2701
|
+
and self.world_size == 1 and device in active_devices
|
|
2702
|
+
and output_store is None and self.volume_chunk_shape is None
|
|
2703
|
+
and self.view_chunk_size is None and self.detector_chunk_shape is None
|
|
2704
|
+
and self.schedule == "auto" and self.partition == "views")
|
|
2705
|
+
if full_candidate:
|
|
2706
|
+
plan = self._execution_plan(tensor, is_project, output_store)
|
|
2707
|
+
spec, geometry = self._effective_geometry(plan, device)
|
|
2708
|
+
if plan is None:
|
|
2709
|
+
return plan, spec, geometry
|
|
2710
|
+
else:
|
|
2711
|
+
spec, geometry = self._effective_geometry(None, torch.device("cpu"))
|
|
2712
|
+
# Exact dtype/shared metadata can admit a borderline full call;
|
|
2713
|
+
# reuse the one host snapshot if the preliminary plan streamed.
|
|
2501
2714
|
plan = self._execution_plan(tensor, is_project, output_store, spec)
|
|
2502
2715
|
else:
|
|
2503
2716
|
plan = self._execution_plan(tensor, is_project, output_store)
|
|
@@ -2512,11 +2725,28 @@ class Projector:
|
|
|
2512
2725
|
return None, tuple(component.clone() if plan is None or component.device.type == "cpu"
|
|
2513
2726
|
else component.to(device="cpu", copy=True)
|
|
2514
2727
|
for component in self._learnable)
|
|
2515
|
-
#
|
|
2516
|
-
#
|
|
2517
|
-
|
|
2728
|
+
# Full CUDA execution has admitted its snapshots before the callback.
|
|
2729
|
+
# Streamed execution and explicit CPU samplers retain host snapshots.
|
|
2730
|
+
local = plan is None and input_device.type == "cuda"
|
|
2731
|
+
capacity = sum(self._surface_snapshot_buffers()) if local else 0
|
|
2732
|
+
arena, cursor = None, 0
|
|
2733
|
+
|
|
2518
2734
|
def pack(tensor):
|
|
2519
|
-
|
|
2735
|
+
nonlocal arena, cursor
|
|
2736
|
+
if (local and tensor.device == input_device and tensor.layout == torch.strided
|
|
2737
|
+
and not tensor.is_nested and not tensor.is_quantized):
|
|
2738
|
+
itemsize = tensor.element_size()
|
|
2739
|
+
start = (cursor + itemsize - 1) // itemsize * itemsize
|
|
2740
|
+
end = start + tensor.numel() * itemsize
|
|
2741
|
+
if end <= capacity:
|
|
2742
|
+
with torch.no_grad():
|
|
2743
|
+
if arena is None:
|
|
2744
|
+
arena = torch.empty(capacity, dtype=torch.uint8, device=input_device)
|
|
2745
|
+
saved = arena[start:end].view(tensor.dtype).reshape(tensor.shape)
|
|
2746
|
+
saved.copy_(tensor.detach())
|
|
2747
|
+
cursor = end
|
|
2748
|
+
return saved, tensor.device
|
|
2749
|
+
return tensor.detach().to(device="cpu", copy=True), tensor.device
|
|
2520
2750
|
|
|
2521
2751
|
def unpack(saved):
|
|
2522
2752
|
tensor, device = saved
|
|
@@ -2536,12 +2766,14 @@ class Projector:
|
|
|
2536
2766
|
with torch.autograd.graph.saved_tensors_hooks(pack, unpack):
|
|
2537
2767
|
offsets = self._surface_offsets()
|
|
2538
2768
|
values = (*frames, offsets)
|
|
2539
|
-
snapshots = tuple(value.clone() if value.device
|
|
2769
|
+
snapshots = tuple(value.clone() if (local and value.device == input_device)
|
|
2770
|
+
or value.device.type == "cpu" else value.to(device="cpu", copy=True)
|
|
2540
2771
|
for value in values)
|
|
2541
2772
|
spec = self._surface_spec(frames, offsets)
|
|
2542
2773
|
metadata = (tuple(offsets.shape), str(offsets.dtype))
|
|
2543
2774
|
self._snapshot_d2h_bytes += sum(value.numel() * value.element_size()
|
|
2544
|
-
for value in values
|
|
2775
|
+
for value, snapshot in zip(values, snapshots)
|
|
2776
|
+
if value.is_cuda and not snapshot.is_cuda)
|
|
2545
2777
|
except (TypeError, ValueError, RuntimeError) as failure:
|
|
2546
2778
|
error = failure
|
|
2547
2779
|
self._partition.preflight(error, metadata, cache_key="surface snapshot",
|
diffct/solvers.py
CHANGED
|
@@ -96,7 +96,7 @@ def cgls(operator, measurements, iterations=30, *, x0=None, rtol=1e-6, atol=0.,
|
|
|
96
96
|
x = _initial(operator, data, x0)
|
|
97
97
|
residual = data.clone() if x0 is None else data - operator.project(x)
|
|
98
98
|
norm = math.sqrt(squared_norm(operator, data, "rays"))
|
|
99
|
-
history = [math.sqrt(squared_norm(operator, residual, "rays"))]
|
|
99
|
+
history = [norm if x0 is None else math.sqrt(squared_norm(operator, residual, "rays"))]
|
|
100
100
|
completed = 0
|
|
101
101
|
adjoint = (operator.backproject(residual) if iterations and history[0] > atol + rtol * norm
|
|
102
102
|
else torch.zeros_like(x))
|
|
@@ -117,11 +117,15 @@ def cgls(operator, measurements, iterations=30, *, x0=None, rtol=1e-6, atol=0.,
|
|
|
117
117
|
states["x"].add_(states["p"], alpha=alpha)
|
|
118
118
|
states["r"].sub_(states["q"], alpha=alpha)
|
|
119
119
|
states["s"] = operator.backproject(states["r"])
|
|
120
|
-
next_gamma = squared_norm(operator, states["s"], "volume")
|
|
120
|
+
next_gamma = squared_norm(operator, states["s"], "volume", return_tensor=True)
|
|
121
|
+
residual_norm = squared_norm(operator, states["r"], "rays", return_tensor=True)
|
|
122
|
+
# Both reductions retain the same FP64 block/collective order. Read
|
|
123
|
+
# their results together; denominator breakdown is still checked first.
|
|
124
|
+
next_gamma, residual_norm = torch.stack((next_gamma, residual_norm.to(next_gamma.device))).cpu().tolist()
|
|
121
125
|
states["p"].mul_(next_gamma / gamma).add_(states["s"])
|
|
122
126
|
gamma = next_gamma
|
|
123
127
|
completed += 1
|
|
124
|
-
history.append(math.sqrt(
|
|
128
|
+
history.append(math.sqrt(residual_norm))
|
|
125
129
|
if checkpoint_directory is not None:
|
|
126
130
|
save_checkpoint(operator, checkpoint_directory, states, metadata, completed, history,
|
|
127
131
|
norm, 1048576, gamma=gamma)
|
|
@@ -148,11 +152,12 @@ def sirt(operator, measurements, iterations=100, *, x0=None, rtol=1e-6, atol=0.,
|
|
|
148
152
|
saved, stores = load_checkpoint(operator, resume, metadata, iterations, 1048576, ("x", "row", "column"))
|
|
149
153
|
states = {name: _read_tensor(store, data.device) for name, store in stores.items()}
|
|
150
154
|
completed, history, norm = saved["iteration"], list(saved["residual_history"]), saved["measurement_norm"]
|
|
155
|
+
residual = None
|
|
151
156
|
else:
|
|
152
157
|
states = dict(x=_initial(operator, data, x0))
|
|
153
158
|
completed, norm = 0, math.sqrt(squared_norm(operator, data, "rays"))
|
|
154
|
-
|
|
155
|
-
history = [math.sqrt(squared_norm(operator,
|
|
159
|
+
residual = data if x0 is None else data - operator.project(states["x"])
|
|
160
|
+
history = [norm if x0 is None else math.sqrt(squared_norm(operator, residual, "rays"))]
|
|
156
161
|
if iterations and history[0] > atol + rtol * norm:
|
|
157
162
|
row = operator.project(torch.ones_like(states["x"]))
|
|
158
163
|
column = operator.backproject(torch.ones_like(data))
|
|
@@ -160,12 +165,15 @@ def sirt(operator, measurements, iterations=100, *, x0=None, rtol=1e-6, atol=0.,
|
|
|
160
165
|
states["column"] = torch.where(column > 0, column.reciprocal(), 0.)
|
|
161
166
|
threshold, stopped = atol + rtol * norm, False
|
|
162
167
|
while completed < iterations and history[-1] > threshold:
|
|
163
|
-
|
|
168
|
+
# A callback may change either the iterate or the operator after history.
|
|
169
|
+
if callbacks or residual is None:
|
|
170
|
+
residual = data - operator.project(states["x"])
|
|
164
171
|
states["x"].add_(states["column"] * operator.backproject(states["row"] * residual), alpha=relaxation)
|
|
165
172
|
if nonnegative:
|
|
166
173
|
states["x"].clamp_(min=0)
|
|
167
174
|
completed += 1
|
|
168
|
-
|
|
175
|
+
residual = data - operator.project(states["x"])
|
|
176
|
+
history.append(math.sqrt(squared_norm(operator, residual, "rays")))
|
|
169
177
|
if checkpoint_directory is not None:
|
|
170
178
|
save_checkpoint(operator, checkpoint_directory, states, metadata, completed, history, norm, 1048576)
|
|
171
179
|
if callbacks:
|
|
@@ -195,14 +203,16 @@ def tv_reconstruction(operator, measurements, iterations=100, weight=1., lr=.1,
|
|
|
195
203
|
for name in ("exp_avg", "exp_avg_sq", "step")}
|
|
196
204
|
optimizer.state[x]["step"] = optimizer.state[x]["step"].reshape(())
|
|
197
205
|
completed, history, norm = saved["iteration"], list(saved["residual_history"]), saved["measurement_norm"]
|
|
206
|
+
residual = None
|
|
198
207
|
else:
|
|
199
208
|
completed, norm = 0, math.sqrt(squared_norm(operator, data, "rays"))
|
|
200
|
-
|
|
201
|
-
history = [math.sqrt(squared_norm(operator,
|
|
209
|
+
residual = -data if x0 is None else operator.project(x) - data
|
|
210
|
+
history = [norm if x0 is None else math.sqrt(squared_norm(operator, residual, "rays"))]
|
|
202
211
|
threshold, stopped = atol + rtol * norm, False
|
|
203
212
|
ray_count = scalar_sum(operator, data.numel(), "rays")
|
|
204
213
|
while completed < iterations and history[-1] > threshold:
|
|
205
|
-
|
|
214
|
+
if callbacks or residual is None:
|
|
215
|
+
residual = operator.project(x) - data
|
|
206
216
|
gradient = operator.backproject(residual) / max(ray_count, 1)
|
|
207
217
|
if weight:
|
|
208
218
|
with torch.enable_grad():
|
|
@@ -214,7 +224,8 @@ def tv_reconstruction(operator, measurements, iterations=100, weight=1., lr=.1,
|
|
|
214
224
|
optimizer.step()
|
|
215
225
|
x.clamp_(min=0)
|
|
216
226
|
completed += 1
|
|
217
|
-
|
|
227
|
+
residual = operator.project(x) - data
|
|
228
|
+
history.append(math.sqrt(squared_norm(operator, residual, "rays")))
|
|
218
229
|
if checkpoint_directory is not None:
|
|
219
230
|
states = dict(x=x, **optimizer.state[x])
|
|
220
231
|
states["step"] = states["step"].reshape(1)
|
diffct/surfaces.py
CHANGED
|
@@ -4,12 +4,15 @@ from dataclasses import dataclass
|
|
|
4
4
|
|
|
5
5
|
import torch
|
|
6
6
|
|
|
7
|
+
from .utils import _raise_geometry_errors
|
|
7
8
|
|
|
8
9
|
@dataclass(frozen=True)
|
|
9
10
|
class _GeometrySpec:
|
|
10
11
|
kind: str
|
|
11
12
|
sampler: object = None
|
|
12
13
|
dtype: object = None
|
|
14
|
+
# One (U, V, 3) host offset snapshot shared by all views.
|
|
15
|
+
shared: bool = False
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
class ParameterizedSurface:
|
|
@@ -39,7 +42,8 @@ class ParameterizedSurface:
|
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
def _detector_grids(detector_shape, spacing, pixels):
|
|
42
|
-
axes = [(torch.arange(selected.start, selected.stop,
|
|
45
|
+
axes = [(torch.arange(selected.start, selected.stop, selected.step or 1,
|
|
46
|
+
dtype=torch.float64, device="cpu") + .5 - size / 2) * pitch
|
|
43
47
|
for selected, size, pitch in zip(pixels, detector_shape, spacing)]
|
|
44
48
|
if len(axes) == 2:
|
|
45
49
|
return torch.meshgrid(*axes, indexing="ij")
|
|
@@ -104,9 +108,7 @@ def _sample_geometry(beam, detector_shape, spacing, geometry, spec, views, pixel
|
|
|
104
108
|
depth = offsets[..., 2].to(device=device, dtype=offset_dtype)
|
|
105
109
|
if offsets.ndim == dimensions + 1:
|
|
106
110
|
tangent, depth = tangent.unsqueeze(0), depth.unsqueeze(0)
|
|
107
|
-
if validate
|
|
108
|
-
if not torch.isfinite(offsets).all().item():
|
|
109
|
-
raise ValueError("detector surface coordinates must be finite")
|
|
111
|
+
checks = [(~torch.isfinite(offsets).all(), "detector surface coordinates must be finite")] if validate else []
|
|
110
112
|
|
|
111
113
|
def frame(component):
|
|
112
114
|
selected = component if local_geometry else component[views]
|
|
@@ -130,17 +132,18 @@ def _sample_geometry(beam, detector_shape, spacing, geometry, spec, views, pixel
|
|
|
130
132
|
source = (frames[0] if local_geometry else frames[0][views]).to(device=device, dtype=source_dtype)
|
|
131
133
|
if validate:
|
|
132
134
|
for component in (source, points):
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
checks.append((~torch.isfinite(component).all() | ~torch.isfinite(component.float()).all(),
|
|
136
|
+
"surface world coordinates must be finite in float32"))
|
|
135
137
|
if beam not in ("parallel", "parallel3d") and view_count:
|
|
136
138
|
sources = source.unsqueeze(1)
|
|
137
139
|
endpoints = points.reshape(view_count, -1, source.shape[-1])
|
|
138
|
-
|
|
139
|
-
|
|
140
|
+
checks.append((torch.any(torch.all(sources.float() == endpoints.float(), dim=-1)),
|
|
141
|
+
"source and detector surface pixels must differ"))
|
|
140
142
|
source_distance = torch.linalg.vector_norm(sources.double(), dim=-1) / voxel_spacing
|
|
141
143
|
pixel_distance = torch.linalg.vector_norm(endpoints.double(), dim=-1) / voxel_spacing
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
checks.append((torch.any(torch.minimum(source_distance, pixel_distance) > 1e6),
|
|
145
|
+
"surface source or pixel must lie within 1e6 voxels"))
|
|
146
|
+
checks.append((torch.any(source_distance > 1e15) | torch.any(pixel_distance > 1e15),
|
|
147
|
+
"surface coordinates must lie within 1e15 voxels"))
|
|
148
|
+
_raise_geometry_errors(checks)
|
|
146
149
|
return source, points
|
diffct/utils.py
CHANGED
|
@@ -18,6 +18,16 @@ import torch
|
|
|
18
18
|
from .constants import _DTYPE, _TPB_2D, _TPB_3D, _INF, _EPSILON
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def _raise_geometry_errors(checks):
|
|
22
|
+
"""Read one validity scalar; fetch individual error flags only on failure."""
|
|
23
|
+
device = next((flag.device for flag, _ in checks if flag.is_cuda), checks[0][0].device)
|
|
24
|
+
failed = torch.stack([flag.to(device=device) for flag, _ in checks])
|
|
25
|
+
if failed.any().item():
|
|
26
|
+
for invalid, (_, message) in zip(failed.tolist(), checks):
|
|
27
|
+
if invalid:
|
|
28
|
+
raise ValueError(message)
|
|
29
|
+
|
|
30
|
+
|
|
21
31
|
# ============================================================================
|
|
22
32
|
# Device Management Utilities
|
|
23
33
|
# ============================================================================
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: diffct
|
|
3
|
-
Version: 2.4.
|
|
3
|
+
Version: 2.4.3
|
|
4
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/
|
|
@@ -68,7 +68,7 @@ Description-Content-Type: text/markdown
|
|
|
68
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).
|
|
69
69
|
> For Apple Silicon, use the [MLX port by Linda-Sophie Schneider](https://github.com/Linda-SophieSchneider/DiffCT-MLX).
|
|
70
70
|
|
|
71
|
-
This README describes the source checkout
|
|
71
|
+
This README describes the source checkout. Features listed under [Unreleased](CHANGELOG.md#unreleased) require installation from source.
|
|
72
72
|
|
|
73
73
|
## Why diffct
|
|
74
74
|
|
|
@@ -177,18 +177,18 @@ volume slabs use `--partition space` under torchrun. Run
|
|
|
177
177
|
|
|
178
178
|
<p align="center">
|
|
179
179
|
<picture>
|
|
180
|
-
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png?v=2.4.
|
|
181
|
-
<img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.4.
|
|
180
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png?v=2.4.3">
|
|
181
|
+
<img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.4.3" width="100%" alt="Time per forward, adjoint and CGLS iteration on 1, 4 and 8 GPUs, 512^3 volume">
|
|
182
182
|
</picture>
|
|
183
183
|
</p>
|
|
184
184
|
|
|
185
185
|
| 512³ volume | 1 GPU | 4 GPUs, 1 node | 8 GPUs, 2 nodes |
|
|
186
186
|
|---|---:|---:|---:|
|
|
187
|
-
| Forward projection |
|
|
188
|
-
| Adjoint (backprojection) |
|
|
189
|
-
| CGLS iteration |
|
|
187
|
+
| Forward projection | 566.79 ms | 150.76 ms (3.8×) | 81.58 ms (6.9×) |
|
|
188
|
+
| Adjoint (backprojection) | 1060.77 ms | 279.37 ms (3.8×) | 224.04 ms (4.7×) |
|
|
189
|
+
| CGLS iteration | 1753.73 ms | 468.09 ms (3.7×) | 337.39 ms (5.2×) |
|
|
190
190
|
|
|
191
|
-
diffct 2.4.
|
|
191
|
+
diffct 2.4.3, FP32, A100 64 GB GPUs; circular cone beam, 360 views, 1024×768 detector, pitch 1.25. Default memory settings, without an imposed allocator cap.
|
|
192
192
|
|
|
193
193
|
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).
|
|
194
194
|
|
|
@@ -228,7 +228,7 @@ Walnut data: Meaney 2022, Zenodo 6986012, CC BY 4.0; see [examples/data/NOTICE](
|
|
|
228
228
|
|
|
229
229
|
## Examples
|
|
230
230
|
|
|
231
|
-
`quickstart.py`, `curved_detector.py`, `chunked_reconstruction.py`, `disk_reconstruction.py`, `benchmark_execution.py`, `benchmark_trajectory_admission.py`, `analytical_reconstruction.py`, `learnable_reconstruction.py`, `helical_fdk.py`, `iterative_reconstruction.py`, `walnut_reconstruction.py`, `geometry_calibration.py`, `benchmark_projector.py`, `benchmark_scaling.py`, `plot_trajectory.py`, and the Slurm template `slurm/multi_node.sbatch`. Launch modes and distributed-loss rules are in [examples/README.md](https://github.com/sypsyp97/diffct/blob/main/examples/README.md).
|
|
231
|
+
`quickstart.py`, `curved_detector.py`, `chunked_reconstruction.py`, `disk_reconstruction.py`, `benchmark_execution.py`, `benchmark_trajectory_admission.py`, `benchmark_surface_geometry.py`, `benchmark_gpu_overheads.py`, `analytical_reconstruction.py`, `learnable_reconstruction.py`, `helical_fdk.py`, `iterative_reconstruction.py`, `walnut_reconstruction.py`, `geometry_calibration.py`, `benchmark_projector.py`, `benchmark_scaling.py`, `plot_trajectory.py`, and the Slurm template `slurm/multi_node.sbatch`. Launch modes and distributed-loss rules are in [examples/README.md](https://github.com/sypsyp97/diffct/blob/main/examples/README.md).
|
|
232
232
|
|
|
233
233
|
## Documentation
|
|
234
234
|
|
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
diffct/__init__.py,sha256=
|
|
2
|
-
diffct/_solver_state.py,sha256=
|
|
3
|
-
diffct/_solver_tv.py,sha256=
|
|
4
|
-
diffct/analytical.py,sha256=
|
|
5
|
-
diffct/analytical_execution.py,sha256=
|
|
6
|
-
diffct/backends.py,sha256=
|
|
7
|
-
diffct/chunking.py,sha256=
|
|
1
|
+
diffct/__init__.py,sha256=m5Oy9KZOD9hqS_vMAk-FTd2BoMUqACyyitYrDAEVhFA,2965
|
|
2
|
+
diffct/_solver_state.py,sha256=ExWTTGftGOsPo9MQwxv-ylmYvhiPYXUo3Tmhi6VEvg0,16606
|
|
3
|
+
diffct/_solver_tv.py,sha256=2x16Bq5Bx9WCBszgPy3K-wicsH9AeuzzJS82uyiHBkE,5847
|
|
4
|
+
diffct/analytical.py,sha256=YTyI_lwgnhHdoglO9fboshfNQtrZsacK-gra63ja-xE,33130
|
|
5
|
+
diffct/analytical_execution.py,sha256=Al452qBRevGcEpwcdf7LpY8XbgZzgJ4YoupyRD_1LWA,26788
|
|
6
|
+
diffct/backends.py,sha256=zNNjO_sDJ8KzstTmP0WqPeUYIJDBeYg7m5KD5zQ8ZFw,48278
|
|
7
|
+
diffct/chunking.py,sha256=jJo7JlR8GrQz-_Rj44_HQLsNJeNgL1SpleJsL4QKJvE,11373
|
|
8
8
|
diffct/constants.py,sha256=oy3QNC6an43Ogdn1m81znz1-EMp9GeinVf2wkGYiXfE,3160
|
|
9
9
|
diffct/differentiable.py,sha256=cFirhqXRpUemL7y7Sl_0uqZKGJNj__ptQEcTwYNKzys,1879
|
|
10
10
|
diffct/distribution.py,sha256=iLC3BgAQ9lOkndbEOvNB_j_UaA2Ug9w1KiTUHkOzG8o,9982
|
|
11
11
|
diffct/geometry.py,sha256=iwWXQksIJGOPSuNWJ8tImWj6yPZCC71G3RBh2VDVylY,42581
|
|
12
|
-
diffct/operators.py,sha256=
|
|
12
|
+
diffct/operators.py,sha256=yGDie8Kvjq8wkBU3hnIKUuKWR9VxgkYFFMINE03a0LY,167009
|
|
13
13
|
diffct/pipeline.py,sha256=jp7AvDeQQOME3OPkO9XTcjWFLvOLrv8wczhAf1jcklM,10891
|
|
14
14
|
diffct/projectors.py,sha256=0oAFSA5mycN59dayTAWK2W7o96MP8oLuVp2V9X-1388,45334
|
|
15
15
|
diffct/reconstruction.py,sha256=KdDrpxQ31ufyUlL_bkzvDzaTcrK4BB97tnJ04ZfVfos,29368
|
|
16
|
-
diffct/solvers.py,sha256=
|
|
16
|
+
diffct/solvers.py,sha256=B7M4akFUTuwb_MBpQbpZLdlMv5Eyxy3WczExDAs613s,18000
|
|
17
17
|
diffct/storage.py,sha256=EdcFTzRtEAPRvAZhRPrSf8gRNF5o_JVbPLE8eKPhNdY,4153
|
|
18
18
|
diffct/streaming.py,sha256=2LB5xpl6vIJE1kXMBtQbG0OVxlutEEqXLBrcvlzBHl8,6086
|
|
19
19
|
diffct/surface_gradients.py,sha256=LeM_eDY1Alv604SdiPUx2CWFzr-J9NqCEcJ_ovVFGa8,6290
|
|
20
|
-
diffct/surfaces.py,sha256=
|
|
21
|
-
diffct/utils.py,sha256=
|
|
20
|
+
diffct/surfaces.py,sha256=JQ-PyMnKTTtK8VUykJ3kvIcquQHeDThVHQ18umlOYQg,8009
|
|
21
|
+
diffct/utils.py,sha256=ZOZpR_qpVSxv_uSQJ6--x-RXAqUrZJjWXYJoPKhu5nY,15240
|
|
22
22
|
diffct/kernels/__init__.py,sha256=YNdsqAkexN25pI7QaHy4phAnQNICQ7tO8K8S1zt7TnI,1899
|
|
23
23
|
diffct/kernels/analytical_transpose.py,sha256=D4vr_ZFPrjFeQyhoDqTyijbkTY-g-sHJet52ho-cbkQ,9187
|
|
24
24
|
diffct/kernels/cone_beam.py,sha256=jKlbqQPHhrng-pTbyMFx2b2l0pdQIMiaaB5hj6ayPQI,26753
|
|
25
25
|
diffct/kernels/fan_beam.py,sha256=NPha0Wl-_ayOdS6kAoXJngQffsUczp3rRnojXZ-F1y8,17556
|
|
26
26
|
diffct/kernels/geometry_vjp.py,sha256=YiPMdsuXINLu1oFhPcSbG1LW2P3WSfeJeEvHNLrmQzU,22008
|
|
27
27
|
diffct/kernels/parallel_beam.py,sha256=tCiSx9W8nO4mn8DVyRuK4E0Ry8q38g3pAUACab0UYVU,17143
|
|
28
|
-
diffct/kernels/surface_geometry.py,sha256=
|
|
29
|
-
diffct-2.4.
|
|
30
|
-
diffct-2.4.
|
|
31
|
-
diffct-2.4.
|
|
32
|
-
diffct-2.4.
|
|
33
|
-
diffct-2.4.
|
|
28
|
+
diffct/kernels/surface_geometry.py,sha256=W0G0ilIJJ_EotU4SELeppeWrzokzFWJqpm6qqKaWN7Y,5842
|
|
29
|
+
diffct-2.4.3.dist-info/METADATA,sha256=PeKQAUhUPwodcv-NYQahBD1mceTa5w7WPQDvBDXmolE,17612
|
|
30
|
+
diffct-2.4.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
31
|
+
diffct-2.4.3.dist-info/licenses/LICENSE,sha256=a1fjzGs44LpDnV_LmA2Tzp8z-eJKoNVVdLOnh-F6J6E,11344
|
|
32
|
+
diffct-2.4.3.dist-info/licenses/NOTICE,sha256=du9I2sDF5IDfiY9vF64WWzMJh-MarpVXyLfWQmhbvmI,714
|
|
33
|
+
diffct-2.4.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|