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 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.3.0'
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
- records = [None] * world
36
- dist.all_gather_object(records, (None if error is None else str(error), token),
37
- group=operator.process_group)
38
- failures = [record[0] for record in records if record[0] is not None]
39
- if failures or any(record[1] != records[0][1] for record in records):
40
- raise ValueError("distributed solver agreement failed: " + (failures[0] if failures else "inconsistent checkpoint iteration or controls"))
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 scalar_sum(operator, value, kind):
44
- """Count physical rays/voxels once, including view/space replicas."""
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
- owns = (getattr(operator, "view_rank", 0) == 0 if kind == "volume"
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
- value = float(value) if owns else 0.
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.tensor(value, dtype=torch.float64, device=device)
63
+ accumulator = torch.as_tensor(value, dtype=torch.float64, device=device).clone()
57
64
  dist.all_reduce(accumulator, group=group)
58
- value = accumulator.item()
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
- for selected in _fragments(tuple(store.shape), elements):
66
- # Clone even an FP64 view: norms must never alter caller measurements.
67
- block = store.read(selected).to(dtype=torch.float64).clone()
68
- total += block.square_().sum().item()
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
- agreement(operator, error)
280
- if getattr(operator, "world_size", 1) > 1:
281
- records = [None] * operator.world_size
282
- dist.all_gather_object(records, stop, group=operator.process_group)
283
- stop = any(records)
284
- return stop
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
- for views, pixels in self._batches():
155
- origins, directions, _ = self._rays(views, pixels, torch.device("cpu"), validate=True,
156
- geometry=geometry)
157
- if (not torch.isfinite(origins.to(dtype=owner.dtype)).all().item()
158
- or not torch.isfinite(directions.to(dtype=owner.dtype)).all().item()):
159
- raise ValueError("detector surface world coordinates must be finite")
160
- if owner.beam != "parallel" and torch.any(torch.all(directions == 0, dim=-1)).item():
161
- raise ValueError("source and detector surface pixels must differ")
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
- axes = [(torch.arange(size, device="cpu", dtype=torch.float64) + .5 - size / 2) * pitch
204
- for size, pitch in zip(owner.detector_shape, owner.detector_spacing)]
205
- u, v = (torch.meshgrid(*axes, indexing="ij") if len(axes) == 2
206
- else (axes[0], torch.zeros_like(axes[0])))
207
-
208
- def pack(value):
209
- return value.detach().to(device="cpu").clone(), value.device
210
-
211
- def unpack(saved):
212
- value, device = saved
213
- return value.to(device=device)
214
-
215
- context = (nullcontext() if torch._C._are_functorch_transforms_active()
216
- else torch.autograd.graph.saved_tensors_hooks(pack, unpack))
217
- with context:
218
- offsets = surface(u, v)
219
- if not isinstance(offsets, torch.Tensor) or not torch.is_floating_point(offsets):
220
- raise TypeError("detector_surface must return a floating-point tensor")
221
- if tuple(offsets.shape) not in ((*owner.detector_shape, 3),
222
- (owner._n_views, *owner.detector_shape, 3)):
223
- raise ValueError("detector_surface output shape must match the complete detector grids")
224
- return (*tuple(value.clone() for value in frames), offsets.clone())
225
-
226
- def _rays(self, views, pixels, device, validate=False, stats=None, geometry=None):
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 == "cone" else 3
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 == "cone":
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 != "parallel" and owner.dtype == torch.float32
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 == "parallel":
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 != "parallel":
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 preflight(error=None, metadata=None, *, process_group=None, world_size=1):
56
- """Reject invalid/disagreeing requests collectively before numerical I/O."""
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
- results = [None] * world_size
61
- dist.all_gather_object(results, result, group=process_group)
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("distributed ranks must agree on request metadata")
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)