diffct 2.3.0__py3-none-any.whl → 2.4.0__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 +5 -1
- diffct/_solver_state.py +42 -25
- diffct/backends.py +140 -50
- diffct/chunking.py +34 -0
- diffct/distribution.py +67 -7
- diffct/geometry.py +93 -17
- diffct/kernels/cone_beam.py +21 -8
- diffct/kernels/surface_geometry.py +130 -0
- diffct/operators.py +765 -343
- diffct/projectors.py +61 -30
- diffct/solvers.py +18 -11
- diffct/surface_gradients.py +117 -0
- diffct/surfaces.py +86 -43
- {diffct-2.3.0.dist-info → diffct-2.4.0.dist-info}/METADATA +24 -18
- diffct-2.4.0.dist-info/RECORD +33 -0
- diffct-2.3.0.dist-info/RECORD +0 -31
- {diffct-2.3.0.dist-info → diffct-2.4.0.dist-info}/WHEEL +0 -0
- {diffct-2.3.0.dist-info → diffct-2.4.0.dist-info}/licenses/LICENSE +0 -0
- {diffct-2.3.0.dist-info → diffct-2.4.0.dist-info}/licenses/NOTICE +0 -0
diffct/__init__.py
CHANGED
|
@@ -30,6 +30,8 @@ from .reconstruction import (
|
|
|
30
30
|
from .solvers import SolverResult, cgls, sirt, tv_reconstruction, cgls_into, total_variation
|
|
31
31
|
|
|
32
32
|
from .geometry import (
|
|
33
|
+
transform_trajectory,
|
|
34
|
+
circular_trajectory_3d_parallel,
|
|
33
35
|
circular_trajectory_3d,
|
|
34
36
|
random_trajectory_3d,
|
|
35
37
|
spiral_trajectory_3d,
|
|
@@ -62,7 +64,7 @@ try:
|
|
|
62
64
|
except ImportError:
|
|
63
65
|
pass
|
|
64
66
|
|
|
65
|
-
__version__ = '2.
|
|
67
|
+
__version__ = '2.4.0'
|
|
66
68
|
|
|
67
69
|
__all__ = [
|
|
68
70
|
'Projector',
|
|
@@ -88,6 +90,8 @@ __all__ = [
|
|
|
88
90
|
'FanBackprojectorFunction',
|
|
89
91
|
'ConeProjectorFunction',
|
|
90
92
|
'ConeBackprojectorFunction',
|
|
93
|
+
'transform_trajectory',
|
|
94
|
+
'circular_trajectory_3d_parallel',
|
|
91
95
|
'circular_trajectory_3d',
|
|
92
96
|
'random_trajectory_3d',
|
|
93
97
|
'spiral_trajectory_3d',
|
diffct/_solver_state.py
CHANGED
|
@@ -11,6 +11,7 @@ import torch.distributed as dist
|
|
|
11
11
|
|
|
12
12
|
from .pipeline import _fragments
|
|
13
13
|
from .storage import NpyStore, TensorStore
|
|
14
|
+
from .distribution import control_status, preflight
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def count(value, name, positive=False):
|
|
@@ -25,47 +26,56 @@ def finite(value, name, positive=False):
|
|
|
25
26
|
return float(value)
|
|
26
27
|
|
|
27
28
|
|
|
28
|
-
def agreement(operator, error=None, token=None):
|
|
29
|
+
def agreement(operator, error=None, token=None, *, cache_key=None):
|
|
29
30
|
"""Every rank rejects a bad request before numerical execution starts."""
|
|
30
31
|
world = getattr(operator, "world_size", 1)
|
|
31
32
|
if world == 1:
|
|
32
33
|
if error is not None:
|
|
33
34
|
raise error
|
|
34
35
|
return
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
error = None if error is None else ValueError("distributed solver agreement failed: " + str(error))
|
|
37
|
+
mismatch = "distributed solver agreement failed: inconsistent checkpoint iteration or controls"
|
|
38
|
+
partition = getattr(operator, "_partition", None)
|
|
39
|
+
if partition is not None:
|
|
40
|
+
partition.preflight(error, token, cache_key=cache_key, mismatch=mismatch)
|
|
41
|
+
else:
|
|
42
|
+
preflight(error, token, process_group=operator.process_group, world_size=world,
|
|
43
|
+
mismatch=mismatch)
|
|
41
44
|
|
|
42
45
|
|
|
43
|
-
def
|
|
44
|
-
"""
|
|
45
|
-
world = getattr(operator, "world_size", 1)
|
|
46
|
+
def owns_values(operator, kind):
|
|
47
|
+
"""Select one copy of each physical ray or voxel for scalar reductions."""
|
|
46
48
|
partition = getattr(operator, "partition", "views")
|
|
47
49
|
rank = getattr(operator, "rank", 0)
|
|
48
|
-
|
|
50
|
+
return (getattr(operator, "view_rank", 0) == 0 if kind == "volume"
|
|
49
51
|
else getattr(operator, "space_rank", 0) == 0) if partition == "hybrid" else (
|
|
50
52
|
rank == 0 if (kind == "volume") == (partition == "views") else True
|
|
51
53
|
)
|
|
52
|
-
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def scalar_sum(operator, value, kind):
|
|
57
|
+
"""Count physical rays/voxels once, including view/space replicas."""
|
|
58
|
+
world = getattr(operator, "world_size", 1)
|
|
59
|
+
value = value if owns_values(operator, kind) else 0.
|
|
53
60
|
if world > 1:
|
|
54
61
|
group = operator.process_group
|
|
55
62
|
device = torch.device("cuda", torch.cuda.current_device()) if dist.get_backend(group) == "nccl" else torch.device("cpu")
|
|
56
|
-
accumulator = torch.
|
|
63
|
+
accumulator = torch.as_tensor(value, dtype=torch.float64, device=device).clone()
|
|
57
64
|
dist.all_reduce(accumulator, group=group)
|
|
58
|
-
|
|
59
|
-
return value
|
|
65
|
+
return accumulator.item()
|
|
66
|
+
return float(value)
|
|
60
67
|
|
|
61
68
|
|
|
62
69
|
def squared_norm(operator, value, kind, elements=1048576):
|
|
63
70
|
store = TensorStore(value) if isinstance(value, torch.Tensor) else value
|
|
64
71
|
total = 0.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
72
|
+
if owns_values(operator, kind):
|
|
73
|
+
total = torch.zeros((), dtype=torch.float64, device=getattr(store, "device", "cpu"))
|
|
74
|
+
for selected in _fragments(tuple(store.shape), elements):
|
|
75
|
+
# Clone even an FP64 view: norms must never alter caller measurements.
|
|
76
|
+
block = store.read(selected).to(dtype=torch.float64).clone()
|
|
77
|
+
# Preserve the block reductions and their sequential FP64 order.
|
|
78
|
+
total.add_(block.square_().sum().to(device=total.device))
|
|
69
79
|
return scalar_sum(operator, total, kind)
|
|
70
80
|
|
|
71
81
|
|
|
@@ -270,15 +280,22 @@ def save_checkpoint(operator, directory, states, metadata, iteration, history, m
|
|
|
270
280
|
agreement(operator, error, iteration)
|
|
271
281
|
|
|
272
282
|
|
|
283
|
+
def callbacks_enabled(operator, callback):
|
|
284
|
+
"""Check callback presence once, including callbacks on only some ranks."""
|
|
285
|
+
return bool(control_status((int(callback is not None),),
|
|
286
|
+
process_group=getattr(operator, "process_group", None),
|
|
287
|
+
world_size=getattr(operator, "world_size", 1))[0])
|
|
288
|
+
|
|
289
|
+
|
|
273
290
|
def callback_stop(operator, callback, iteration, residual, volume):
|
|
274
291
|
error, stop = None, False
|
|
275
292
|
try:
|
|
276
293
|
stop = callback is not None and callback(iteration, residual, volume) is False
|
|
277
294
|
except Exception as caught:
|
|
278
295
|
error = caught
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
return stop
|
|
296
|
+
failed, stop = control_status((int(error is not None), int(stop)),
|
|
297
|
+
process_group=getattr(operator, "process_group", None),
|
|
298
|
+
world_size=getattr(operator, "world_size", 1))
|
|
299
|
+
if failed:
|
|
300
|
+
agreement(operator, error)
|
|
301
|
+
return bool(stop)
|
diffct/backends.py
CHANGED
|
@@ -61,6 +61,37 @@ 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):
|
|
65
|
+
"""Bound flat admission with corners and exact ambiguous-view fallback."""
|
|
66
|
+
views = owner.view_slice
|
|
67
|
+
if views.start == views.stop:
|
|
68
|
+
return
|
|
69
|
+
# Flat point component arithmetic is monotone in each detector
|
|
70
|
+
# coordinate; finite corner points and source differences bound the grid.
|
|
71
|
+
endpoints = tuple((slice(0, 1),) if size == 1 else (slice(0, 1), slice(size - 1, size))
|
|
72
|
+
for size in owner.detector_shape)
|
|
73
|
+
for pixels in itertools.product(*endpoints):
|
|
74
|
+
yield views, pixels
|
|
75
|
+
if owner.beam in ("parallel", "parallel3d"):
|
|
76
|
+
return
|
|
77
|
+
source, lower = (value[views].to(dtype=owner.dtype) for value in geometry[:2])
|
|
78
|
+
upper = lower
|
|
79
|
+
for size, pitch, axis in zip(owner.detector_shape, owner.detector_spacing, geometry[2:]):
|
|
80
|
+
coordinates = (torch.tensor([.5 - size / 2, size - .5 - size / 2], dtype=torch.float64)
|
|
81
|
+
* pitch).to(device=lower.device, dtype=owner.dtype)
|
|
82
|
+
products = coordinates[None, :, None] * axis[views, None, :].to(dtype=owner.dtype)
|
|
83
|
+
lower = lower + products.amin(dim=1)
|
|
84
|
+
upper = upper + products.amax(dim=1)
|
|
85
|
+
# Corners cannot exclude an interior pixel rounded onto the source.
|
|
86
|
+
# A staged source outside this AABB cannot coincide; inconclusive views
|
|
87
|
+
# retain the exact bounded pixel check rather than changing admission.
|
|
88
|
+
ambiguous = torch.all((source >= lower) & (source <= upper), dim=-1)
|
|
89
|
+
for index in torch.nonzero(ambiguous, as_tuple=False).flatten().tolist():
|
|
90
|
+
selected = slice(views.start + index, views.start + index + 1)
|
|
91
|
+
for pixels in _rectangles(owner.detector_shape, detector_chunk_shape):
|
|
92
|
+
yield selected, pixels
|
|
93
|
+
|
|
94
|
+
|
|
64
95
|
class _DistributedApply(torch.autograd.Function):
|
|
65
96
|
"""Distributed linear map whose adjoint SUMs replicated cotangents."""
|
|
66
97
|
|
|
@@ -132,7 +163,7 @@ class ReferenceProjector:
|
|
|
132
163
|
every voxel. All data operations remain ordinary differentiable PyTorch.
|
|
133
164
|
"""
|
|
134
165
|
|
|
135
|
-
def __init__(self, owner, *, validate=True):
|
|
166
|
+
def __init__(self, owner, *, validate=True, synchronize=True):
|
|
136
167
|
self.owner = owner
|
|
137
168
|
self.spacing = voxel_spacing_tuple(owner.voxel_spacing, len(owner.global_volume_shape))
|
|
138
169
|
self.chunk_shape, self.view_chunk_size, self.detector_chunk_shape = self._work_chunks()
|
|
@@ -140,25 +171,25 @@ class ReferenceProjector:
|
|
|
140
171
|
# Native owners already validated geometry before transform dispatch.
|
|
141
172
|
return
|
|
142
173
|
with torch.enable_grad():
|
|
143
|
-
geometry = self._snapshot_geometry()
|
|
174
|
+
geometry = self._snapshot_geometry(synchronize=synchronize)
|
|
144
175
|
if (owner.detector_surface is not None
|
|
145
176
|
and not isinstance(owner.detector_surface, ParameterizedSurface)):
|
|
146
177
|
trainable = any(value.requires_grad for value in geometry)
|
|
147
|
-
if owner.world_size > 1:
|
|
178
|
+
if synchronize and owner.world_size > 1:
|
|
148
179
|
device = (torch.device("cuda", torch.cuda.current_device())
|
|
149
180
|
if dist.get_backend(owner.process_group) == "nccl" else torch.device("cpu"))
|
|
150
181
|
flag = torch.tensor(int(trainable), device=device)
|
|
151
182
|
dist.all_reduce(flag, op=dist.ReduceOp.MAX, group=owner.process_group)
|
|
152
183
|
trainable = bool(flag.item())
|
|
153
184
|
owner._geometry_trainable = owner._geometry_trainable or trainable
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
185
|
+
self._validate_geometry(geometry, geometry[0].device, admission=True,
|
|
186
|
+
synchronize=synchronize)
|
|
187
|
+
|
|
188
|
+
def _admission_batches(self, geometry):
|
|
189
|
+
if self.owner.detector_surface is not None:
|
|
190
|
+
yield from self._batches()
|
|
191
|
+
else:
|
|
192
|
+
yield from _flat_admission_batches(self.owner, geometry, self.detector_chunk_shape)
|
|
162
193
|
|
|
163
194
|
def _work_chunks(self, batch_elements=1):
|
|
164
195
|
owner = self.owner
|
|
@@ -192,41 +223,96 @@ class ReferenceProjector:
|
|
|
192
223
|
for pixels in _rectangles(owner.detector_shape, detector_chunk_shape or self.detector_chunk_shape):
|
|
193
224
|
yield views, pixels
|
|
194
225
|
|
|
195
|
-
def _snapshot_geometry(self):
|
|
226
|
+
def _snapshot_geometry(self, *, synchronize=True):
|
|
196
227
|
owner = self.owner
|
|
197
228
|
frames = owner._learnable or owner._trajectory
|
|
198
229
|
surface = owner.detector_surface
|
|
199
|
-
if isinstance(surface, ParameterizedSurface):
|
|
200
|
-
return tuple(value.clone() for value in (*frames, *surface.parameters))
|
|
201
230
|
if surface is None:
|
|
202
231
|
return tuple(value.clone() for value in frames)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
232
|
+
error, metadata, snapshots = None, None, ()
|
|
233
|
+
try:
|
|
234
|
+
if isinstance(surface, ParameterizedSurface):
|
|
235
|
+
# Parameters are sampled on CPU. Preserve their autograd state
|
|
236
|
+
# when a sampler differentiates a potential during admission.
|
|
237
|
+
with torch.enable_grad():
|
|
238
|
+
parameters = tuple(value.to(device="cpu", copy=True) for value in surface.parameters)
|
|
239
|
+
snapshots = (*tuple(value.clone() for value in frames), *parameters)
|
|
240
|
+
else:
|
|
241
|
+
axes = [(torch.arange(size, device="cpu", dtype=torch.float64) + .5 - size / 2) * pitch
|
|
242
|
+
for size, pitch in zip(owner.detector_shape, owner.detector_spacing)]
|
|
243
|
+
u, v = (torch.meshgrid(*axes, indexing="ij") if len(axes) == 2
|
|
244
|
+
else (axes[0], torch.zeros_like(axes[0])))
|
|
245
|
+
|
|
246
|
+
def pack(value):
|
|
247
|
+
return value.detach().to(device="cpu").clone(), value.device
|
|
248
|
+
|
|
249
|
+
def unpack(saved):
|
|
250
|
+
value, device = saved
|
|
251
|
+
return value.to(device=device)
|
|
252
|
+
|
|
253
|
+
context = (nullcontext() if torch._C._are_functorch_transforms_active()
|
|
254
|
+
else torch.autograd.graph.saved_tensors_hooks(pack, unpack))
|
|
255
|
+
with context:
|
|
256
|
+
offsets = surface(u, v)
|
|
257
|
+
if not isinstance(offsets, torch.Tensor) or not torch.is_floating_point(offsets):
|
|
258
|
+
raise TypeError("detector_surface must return a floating-point tensor")
|
|
259
|
+
if tuple(offsets.shape) not in ((*owner.detector_shape, 3),
|
|
260
|
+
(owner._n_views, *owner.detector_shape, 3)):
|
|
261
|
+
raise ValueError("detector_surface output shape must match the complete detector grids")
|
|
262
|
+
metadata = (tuple(offsets.shape), str(offsets.dtype))
|
|
263
|
+
snapshots = (*tuple(value.clone() for value in frames), offsets.to(device="cpu", copy=True))
|
|
264
|
+
except (TypeError, ValueError, RuntimeError) as failure:
|
|
265
|
+
error = failure
|
|
266
|
+
if synchronize:
|
|
267
|
+
owner._partition.preflight(error, metadata, cache_key="portable surface snapshot",
|
|
268
|
+
revision=owner._agreement_revision())
|
|
269
|
+
elif error is not None:
|
|
270
|
+
raise error
|
|
271
|
+
return snapshots
|
|
272
|
+
|
|
273
|
+
def _validate_geometry(self, geometry, fallback, *, admission=False, synchronize=True):
|
|
274
|
+
"""Finish every local geometry check before any rank starts numerics."""
|
|
275
|
+
owner = self.owner
|
|
276
|
+
if not admission and (owner.detector_surface is None or
|
|
277
|
+
(owner.world_size == 1 and isinstance(owner.detector_surface, ParameterizedSurface))):
|
|
278
|
+
return
|
|
279
|
+
devices = tuple(torch.device(device) for device in (owner._devices or (fallback,)))
|
|
280
|
+
error = None
|
|
281
|
+
try:
|
|
282
|
+
frame_count = 4 if owner.beam in ("cone", "parallel3d") else 3
|
|
283
|
+
parameters = geometry[frame_count:]
|
|
284
|
+
if isinstance(owner.detector_surface, ParameterizedSurface):
|
|
285
|
+
with torch.enable_grad():
|
|
286
|
+
parameters = tuple(value.to(device="cpu") for value in parameters)
|
|
287
|
+
with torch.no_grad():
|
|
288
|
+
# Reuse frame transfers across detector chunks. Offset batches
|
|
289
|
+
# remain bounded and move only when a ray batch needs them.
|
|
290
|
+
staged = {device: (*tuple(value.to(device=device) for value in geometry[:frame_count]),
|
|
291
|
+
*parameters) for device in set(devices)}
|
|
292
|
+
batches = self._admission_batches(staged[devices[0]]) if admission else self._batches()
|
|
293
|
+
for batch, (views, pixels) in enumerate(batches):
|
|
294
|
+
device = devices[batch % len(devices)]
|
|
295
|
+
origins, directions, _ = self._rays(
|
|
296
|
+
views, pixels, device, validate=True, geometry=staged[device],
|
|
297
|
+
admission=admission,
|
|
298
|
+
)
|
|
299
|
+
if (not torch.isfinite(origins.to(dtype=owner.dtype)).all().item()
|
|
300
|
+
or not torch.isfinite(directions.to(dtype=owner.dtype)).all().item()):
|
|
301
|
+
raise ValueError("detector surface world coordinates must be finite")
|
|
302
|
+
if (owner.beam not in ("parallel", "parallel3d")
|
|
303
|
+
and torch.any(torch.all(directions == 0, dim=-1)).item()):
|
|
304
|
+
raise ValueError("source and detector surface pixels must differ")
|
|
305
|
+
except (TypeError, ValueError, RuntimeError) as failure:
|
|
306
|
+
error = failure
|
|
307
|
+
if synchronize:
|
|
308
|
+
owner._partition.preflight(error)
|
|
309
|
+
elif error is not None:
|
|
310
|
+
raise error
|
|
311
|
+
|
|
312
|
+
def _rays(self, views, pixels, device, validate=False, stats=None, geometry=None, *, admission=True):
|
|
227
313
|
owner = self.owner
|
|
228
314
|
# Admission compares original coordinates before FP32 rounding.
|
|
229
|
-
dtype = torch.float64 if validate else owner.dtype
|
|
315
|
+
dtype = torch.float64 if validate and admission else owner.dtype
|
|
230
316
|
axes = [(torch.arange(pixel.start, pixel.stop, dtype=torch.float64, device="cpu")
|
|
231
317
|
+ .5 - size / 2) * pitch
|
|
232
318
|
for pixel, size, pitch in zip(pixels, owner.detector_shape, owner.detector_spacing)]
|
|
@@ -234,7 +320,7 @@ class ReferenceProjector:
|
|
|
234
320
|
else (axes[0], torch.zeros_like(axes[0])))
|
|
235
321
|
ids = torch.arange(views.start, views.stop, device="cpu")
|
|
236
322
|
surface = owner.detector_surface
|
|
237
|
-
frame_count = 4 if owner.beam
|
|
323
|
+
frame_count = 4 if owner.beam in ("cone", "parallel3d") else 3
|
|
238
324
|
if isinstance(surface, ParameterizedSurface):
|
|
239
325
|
parameters = tuple(self._transfer(value, torch.device("cpu"), stats)
|
|
240
326
|
for value in (surface.parameters if geometry is None else geometry[frame_count:]))
|
|
@@ -247,16 +333,16 @@ class ReferenceProjector:
|
|
|
247
333
|
offsets = torch.stack((u, v, torch.zeros_like(u)), dim=-1)
|
|
248
334
|
if not isinstance(offsets, torch.Tensor) or not torch.is_floating_point(offsets):
|
|
249
335
|
raise TypeError("detector_surface must return a floating-point tensor")
|
|
250
|
-
if validate:
|
|
251
|
-
if not torch.isfinite(offsets).all().item():
|
|
252
|
-
raise ValueError("detector_surface offsets must be finite")
|
|
253
|
-
if owner.beam != "cone" and torch.any(offsets[..., 1] != 0).item():
|
|
254
|
-
raise ValueError("2D detector_surface middle offsets must be zero")
|
|
255
336
|
shared_shape = (*u.shape, 3)
|
|
256
337
|
if tuple(offsets.shape) == (owner._n_views, *shared_shape):
|
|
257
338
|
offsets = offsets[views]
|
|
258
339
|
elif tuple(offsets.shape) not in (shared_shape, (len(ids), *shared_shape)):
|
|
259
340
|
raise ValueError("detector_surface output shape must match the selected view/pixel batch")
|
|
341
|
+
if validate:
|
|
342
|
+
if not torch.isfinite(offsets).all().item():
|
|
343
|
+
raise ValueError("detector_surface offsets must be finite")
|
|
344
|
+
if owner.beam not in ("cone", "parallel3d") and torch.any(offsets[..., 1] != 0).item():
|
|
345
|
+
raise ValueError("2D detector_surface middle offsets must be zero")
|
|
260
346
|
frames = (owner._learnable or owner._trajectory) if geometry is None else geometry[:frame_count]
|
|
261
347
|
native_surface = owner.backend == "numba" and surface is not None
|
|
262
348
|
native_legacy = native_surface and not isinstance(surface, ParameterizedSurface)
|
|
@@ -279,7 +365,7 @@ class ReferenceProjector:
|
|
|
279
365
|
def world_points(offsets, components):
|
|
280
366
|
axis_u = frame(components[2])
|
|
281
367
|
points = frame(components[1]) + offsets[..., 0, None] * axis_u
|
|
282
|
-
if owner.beam
|
|
368
|
+
if owner.beam in ("cone", "parallel3d"):
|
|
283
369
|
axis_v = frame(components[3])
|
|
284
370
|
points = points + offsets[..., 1, None] * axis_v
|
|
285
371
|
normal_dtype = torch.promote_types(axis_u.dtype, axis_v.dtype)
|
|
@@ -290,7 +376,7 @@ class ReferenceProjector:
|
|
|
290
376
|
return points + offsets[..., 2, None] * normal
|
|
291
377
|
|
|
292
378
|
points = world_points(local, selected)
|
|
293
|
-
if (validate and owner.beam
|
|
379
|
+
if (validate and owner.beam not in ("parallel", "parallel3d") and owner.dtype == torch.float32
|
|
294
380
|
and owner.detector_surface is not None):
|
|
295
381
|
# Raw FP64 coordinates preserve the exact admission boundaries.
|
|
296
382
|
sources = frame(selected[0]).expand_as(points)
|
|
@@ -317,7 +403,7 @@ class ReferenceProjector:
|
|
|
317
403
|
# FP32 component arithmetic. Reuse the same sampled offsets.
|
|
318
404
|
selected = tuple(value.to(dtype=owner.dtype) for value in selected)
|
|
319
405
|
points = world_points(local.to(dtype=owner.dtype), selected)
|
|
320
|
-
if owner.beam
|
|
406
|
+
if owner.beam in ("parallel", "parallel3d"):
|
|
321
407
|
origins, directions = points, frame(selected[0]).expand_as(points)
|
|
322
408
|
else:
|
|
323
409
|
sources = frame(selected[0]).expand_as(points)
|
|
@@ -368,7 +454,7 @@ class ReferenceProjector:
|
|
|
368
454
|
first, last = (lower - origins) / safe_direction, (upper - origins) / safe_direction
|
|
369
455
|
enter = torch.where(constant, -torch.inf, torch.minimum(first, last)).amax(dim=-1)
|
|
370
456
|
leave = torch.where(constant, torch.inf, torch.maximum(first, last)).amin(dim=-1)
|
|
371
|
-
if owner.beam
|
|
457
|
+
if owner.beam not in ("parallel", "parallel3d"):
|
|
372
458
|
enter, leave = enter.clamp_min(0), torch.minimum(leave, ray_length)
|
|
373
459
|
inside = (~constant | ((origins >= lower) & (origins < upper))).all(dim=-1)
|
|
374
460
|
valid = inside & (leave > enter)
|
|
@@ -475,7 +561,9 @@ class ReferenceProjector:
|
|
|
475
561
|
error = ValueError(f"input trailing shape must be {shape}")
|
|
476
562
|
else:
|
|
477
563
|
leading = tuple(tensor.shape[:-len(shape)])
|
|
478
|
-
owner._partition.preflight(error, (is_project, reduce_cotangent, leading)
|
|
564
|
+
owner._partition.preflight(error, (is_project, reduce_cotangent, leading),
|
|
565
|
+
cache_key="portable input",
|
|
566
|
+
revision=owner._agreement_revision())
|
|
479
567
|
batch_elements = math.prod(leading)
|
|
480
568
|
plan = self._work_chunks(batch_elements) if _plan is None else _plan
|
|
481
569
|
stats = self._stats(is_project, plan)
|
|
@@ -569,6 +657,7 @@ class ReferenceProjector:
|
|
|
569
657
|
|
|
570
658
|
def _apply(self, tensor, is_project):
|
|
571
659
|
geometry = self._snapshot_geometry()
|
|
660
|
+
self._validate_geometry(geometry, tensor.device if isinstance(tensor, torch.Tensor) else torch.device("cpu"))
|
|
572
661
|
if self.owner.world_size > 1 and torch.is_grad_enabled():
|
|
573
662
|
return _DistributedApply.apply(tensor, self, is_project, False, self.owner._participation(), *geometry)
|
|
574
663
|
return self._tensor_apply(tensor, is_project, geometry=geometry)
|
|
@@ -622,6 +711,7 @@ class ReferenceProjector:
|
|
|
622
711
|
fallback = torch.device(getattr(source, "device", "cpu"))
|
|
623
712
|
output_device = torch.device(getattr(sink, "device", "cpu"))
|
|
624
713
|
geometry = self._snapshot_geometry()
|
|
714
|
+
self._validate_geometry(geometry, fallback)
|
|
625
715
|
|
|
626
716
|
def read(slices, device):
|
|
627
717
|
value = source.read(slices)
|
diffct/chunking.py
CHANGED
|
@@ -63,6 +63,40 @@ def _memory_budget(device):
|
|
|
63
63
|
return budget, allocation_limit
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
def _memory_lower_bounds(device):
|
|
67
|
+
"""Lower bounds of `_memory_budget` and `_reservation_budget` without an allocator snapshot.
|
|
68
|
+
|
|
69
|
+
Returns (budget, allocation_limit, reservation budget), each at most the value the two exact
|
|
70
|
+
functions return at the same allocator state. The snapshot is what makes those functions
|
|
71
|
+
slow; it only serves two corrections, and both are bounded by allocator counters: free splits
|
|
72
|
+
pinned by other streams are inactive reserved bytes, at most reserved - allocated, and pinned
|
|
73
|
+
segments hold at most every reserved byte.
|
|
74
|
+
"""
|
|
75
|
+
free, total = torch.cuda.mem_get_info(device)
|
|
76
|
+
# One nested statistics query; memory_allocated/memory_reserved each flatten every statistic.
|
|
77
|
+
stats = torch.cuda.memory_stats_as_nested_dict(device)
|
|
78
|
+
if stats:
|
|
79
|
+
allocated = stats["allocated_bytes"]["all"]["current"]
|
|
80
|
+
reserved = stats["reserved_bytes"]["all"]["current"]
|
|
81
|
+
else: # allocator not initialised: nothing allocated or reserved
|
|
82
|
+
allocated = reserved = 0
|
|
83
|
+
reusable = max(0, reserved - allocated)
|
|
84
|
+
getter = getattr(torch.cuda, "get_per_process_memory_fraction", None)
|
|
85
|
+
fraction = getter(device) if getter is not None else 1.0
|
|
86
|
+
allocator_room = max(0, int(total * fraction) - allocated)
|
|
87
|
+
backend = getattr(torch.cuda, "get_allocator_backend", lambda: "native")()
|
|
88
|
+
foreign_splits = reusable if backend == "native" else 0
|
|
89
|
+
available = max(0, min(free + reusable, allocator_room) - foreign_splits)
|
|
90
|
+
allocation_limit = (1 << 63) - 1
|
|
91
|
+
if backend == "native" and available < 20 * 1024 ** 2:
|
|
92
|
+
allocation_limit = 1024 ** 2
|
|
93
|
+
if backend != "native":
|
|
94
|
+
reservation = (1 << 63) - 1
|
|
95
|
+
else:
|
|
96
|
+
reservation = max(0, min(free, int(total * fraction) - reserved))
|
|
97
|
+
return available * 3 // 4, allocation_limit, reservation
|
|
98
|
+
|
|
99
|
+
|
|
66
100
|
def _native_segments(device):
|
|
67
101
|
index = torch.device(device).index if not isinstance(device, int) else device
|
|
68
102
|
index = torch.cuda.current_device() if index is None else index
|
diffct/distribution.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""Shared deterministic ownership for view and first-axis space partitions."""
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import math
|
|
4
5
|
import numbers
|
|
6
|
+
from copy import deepcopy
|
|
5
7
|
from weakref import WeakKeyDictionary
|
|
6
8
|
|
|
9
|
+
import torch
|
|
7
10
|
import torch.distributed as dist
|
|
8
11
|
|
|
9
12
|
|
|
@@ -52,20 +55,73 @@ def balanced_slice(size, rank, world_size):
|
|
|
52
55
|
return slice(start, start + base + int(rank < extra))
|
|
53
56
|
|
|
54
57
|
|
|
55
|
-
def
|
|
56
|
-
"""
|
|
58
|
+
def control_status(values, *, process_group=None, world_size=1):
|
|
59
|
+
"""Combine fixed-size control values without serializing Python objects."""
|
|
60
|
+
if world_size == 1:
|
|
61
|
+
return values
|
|
62
|
+
with torch._C._DisableFuncTorch():
|
|
63
|
+
device = (torch.device("cuda", torch.cuda.current_device())
|
|
64
|
+
if dist.get_backend(process_group) == "nccl" else torch.device("cpu"))
|
|
65
|
+
status = torch.tensor(values, dtype=torch.int64, device=device)
|
|
66
|
+
dist.all_reduce(status, op=dist.ReduceOp.MAX, group=process_group)
|
|
67
|
+
return status.tolist()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _canonical_metadata(value):
|
|
71
|
+
"""Keep container types while making dictionary order deterministic."""
|
|
72
|
+
if isinstance(value, dict):
|
|
73
|
+
return ("dict", tuple(sorted(
|
|
74
|
+
((_canonical_metadata(key), _canonical_metadata(item)) for key, item in value.items()),
|
|
75
|
+
key=repr,
|
|
76
|
+
)))
|
|
77
|
+
if isinstance(value, (tuple, list)):
|
|
78
|
+
return ("tuple" if isinstance(value, tuple) else "list",
|
|
79
|
+
tuple(_canonical_metadata(item) for item in value))
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def preflight(error=None, metadata=None, *, process_group=None, world_size=1,
|
|
84
|
+
cache=None, cache_key=None, revision=None,
|
|
85
|
+
mismatch="distributed ranks must agree on request metadata"):
|
|
86
|
+
"""Reject invalid/disagreeing requests collectively before numerical I/O.
|
|
87
|
+
|
|
88
|
+
Cache only local digest computation. Every call compares current metadata
|
|
89
|
+
across ranks, including calls through different warmed Partition objects.
|
|
90
|
+
"""
|
|
57
91
|
result = (None if error is None else (type(error).__name__, str(error)), metadata)
|
|
58
92
|
results = [result]
|
|
59
93
|
if world_size > 1:
|
|
60
|
-
|
|
61
|
-
|
|
94
|
+
# Control messages do not participate in data transforms or autograd.
|
|
95
|
+
with torch._C._DisableFuncTorch():
|
|
96
|
+
try:
|
|
97
|
+
changed = (cache is None or cache_key not in cache
|
|
98
|
+
or cache[cache_key][:2] != (revision, metadata))
|
|
99
|
+
digest = (int.from_bytes(hashlib.blake2b(
|
|
100
|
+
repr(_canonical_metadata(metadata)).encode("utf-8"), digest_size=8,
|
|
101
|
+
).digest(), "big", signed=True) if changed else cache[cache_key][2])
|
|
102
|
+
exchange = error is not None
|
|
103
|
+
except Exception:
|
|
104
|
+
# Metadata that cannot be digested here (e.g. self-referential) must not leave
|
|
105
|
+
# this rank outside the collective; the object exchange below reports it.
|
|
106
|
+
changed, digest, exchange = False, 0, True
|
|
107
|
+
# MAX(digest) and MAX(~digest) encode both signed int64 bounds.
|
|
108
|
+
# Complementing also handles -2**63 without negation overflow.
|
|
109
|
+
failed, upper, inverse_lower = control_status(
|
|
110
|
+
(int(exchange), digest, ~digest),
|
|
111
|
+
process_group=process_group, world_size=world_size,
|
|
112
|
+
)
|
|
113
|
+
if failed or upper != ~inverse_lower:
|
|
114
|
+
results = [None] * world_size
|
|
115
|
+
dist.all_gather_object(results, result, group=process_group)
|
|
62
116
|
failure = next((entry[0] for entry in results if entry[0] is not None), None)
|
|
63
117
|
if failure is not None:
|
|
64
118
|
classes = {kind.__name__: kind for kind in (TypeError, ValueError, RuntimeError,
|
|
65
119
|
PermissionError, FileNotFoundError)}
|
|
66
120
|
raise classes.get(failure[0], RuntimeError)(failure[1])
|
|
67
121
|
if any(entry[1] != metadata for entry in results):
|
|
68
|
-
raise ValueError(
|
|
122
|
+
raise ValueError(mismatch)
|
|
123
|
+
if cache is not None and world_size > 1 and changed:
|
|
124
|
+
cache[cache_key] = (revision, deepcopy(metadata), digest)
|
|
69
125
|
|
|
70
126
|
|
|
71
127
|
class Partition:
|
|
@@ -82,6 +138,7 @@ class Partition:
|
|
|
82
138
|
if process_group is not None and not distributed:
|
|
83
139
|
raise ValueError("process_group requires distributed=True")
|
|
84
140
|
self.process_group = process_group
|
|
141
|
+
self._preflight_cache = {}
|
|
85
142
|
self.rank, self.world_size = 0, 1
|
|
86
143
|
if distributed:
|
|
87
144
|
if not dist.is_available() or not dist.is_initialized():
|
|
@@ -132,6 +189,9 @@ class Partition:
|
|
|
132
189
|
def global_rank(self, rank):
|
|
133
190
|
return self.ranks[rank]
|
|
134
191
|
|
|
135
|
-
def preflight(self, error=None, metadata=None
|
|
192
|
+
def preflight(self, error=None, metadata=None, *, cache_key=None, revision=None,
|
|
193
|
+
mismatch="distributed ranks must agree on request metadata"):
|
|
136
194
|
"""Reject invalid/disagreeing requests on every parent rank before I/O."""
|
|
137
|
-
return preflight(error, metadata, process_group=self.process_group, world_size=self.world_size
|
|
195
|
+
return preflight(error, metadata, process_group=self.process_group, world_size=self.world_size,
|
|
196
|
+
cache=self._preflight_cache if cache_key is not None else None,
|
|
197
|
+
cache_key=cache_key, revision=revision, mismatch=mismatch)
|