diffct 2.4.2__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 CHANGED
@@ -64,7 +64,7 @@ try:
64
64
  except ImportError:
65
65
  pass
66
66
 
67
- __version__ = '2.4.2'
67
+ __version__ = '2.4.3'
68
68
 
69
69
  __all__ = [
70
70
  'Projector',
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, including view/space replicas."""
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 = 0.
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="cpu", dtype=target.dtype).clone()
96
- block.mul_(scale).add_(source.read(selected).to(device="cpu", dtype=target.dtype), alpha=factor)
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
- global_value = scalar_sum(operator, local.detach().item(), "volume")
108
- total = local + (local.new_tensor(global_value) - local.detach())
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(isocenter[0].item() / voxel_spacing),
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(isocenter[0].item() / voxel_spacing),
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.item() / parameters[-1]) for value in isocenter)
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
@@ -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
- for pixels in itertools.product(*endpoints):
74
- yield views, pixels
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
@@ -23,6 +23,7 @@ class _ChunkPlan:
23
23
  foreign_output: bool = False
24
24
  reservation_budget_bytes: int = (1 << 63) - 1
25
25
  estimated_reserved_bytes: int = 0
26
+ surface_batch: bool = True
26
27
 
27
28
 
28
29
  def _pixel_tiles(shape, chunk_shape):
@@ -52,9 +52,10 @@ def compose_surface(frames, offsets, source, points, view_index, pixel_start,
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
54
 
55
- A launch may compose several views with the same offsets: grid row ``y``
55
+ A launch may compose several views: grid row ``y``
56
56
  writes view ``view_index + y`` from the ``y``-th of ``gridDim.y`` equal
57
- frame packets in ``frames``.
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.
58
59
  """
59
60
  pixel = cuda.grid(1)
60
61
  view_index += cuda.blockIdx.y
@@ -68,15 +69,16 @@ def compose_surface(frames, offsets, source, points, view_index, pixel_start,
68
69
  if pixel >= count:
69
70
  return
70
71
 
71
- tangent = offsets[pixel]
72
- depth = offsets[(rank - 1) * capacity + pixel]
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]
73
75
  tangent32 = offset32 and frame32[2]
74
76
  point32 = frame32[1] and tangent32
75
77
  normal32 = frame32[2]
76
78
  vertical = np.float64(0.0)
77
79
  vertical32 = False
78
80
  if rank == 3:
79
- vertical = offsets[capacity + pixel]
81
+ vertical = offsets[offset_base + capacity + pixel]
80
82
  vertical32 = offset32 and frame32[3]
81
83
  point32 = point32 and vertical32
82
84
  normal32 = normal32 and frame32[3]
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
- if not isinstance(component, torch.Tensor):
84
- raise TypeError("trajectory components must be tensors")
85
- if component.ndim != 2 or component.shape[1] != dimensions:
86
- raise ValueError(f"trajectory tensors must have shape (views, {dimensions})")
87
- if not torch.is_floating_point(component):
88
- raise TypeError("trajectory tensors must have floating-point dtype")
89
- if component.shape[0] == 0:
90
- raise ValueError("trajectory must contain at least one view")
91
- if n_views is not None and component.shape[0] != n_views:
92
- raise ValueError("trajectory tensors must have the same number of views")
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
- if not torch.isfinite(component).all().item():
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
- if not torch.allclose(norms, torch.ones_like(norms), rtol=1e-4, atol=1e-4):
107
- raise ValueError("trajectory direction axes must be unit vectors")
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
- if torch.any(torch.abs(dots) > 1e-4).item():
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
- if torch.any(torch.linalg.matrix_rank(frame) < 3).item():
120
- raise ValueError("parallel3d rays must not be tangent to the detector plane")
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
- if torch.any(lengths == 0).item():
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
- if torch.any(torch.abs(facing) < 1e-4).item():
136
- raise ValueError("the detector must not be edge-on to the source in any view")
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
 
@@ -544,16 +551,14 @@ class Projector:
544
551
  )
545
552
  for component in self._trajectory[:2]
546
553
  ])
547
- if torch.any(distances.min(dim=0).values > 1e6).item():
548
- raise ValueError(
554
+ _raise_geometry_errors([
555
+ (torch.any(distances.min(dim=0).values > 1e6),
549
556
  "the source or the detector center must lie within 1e6 voxels "
550
- "of the volume center in every view"
551
- )
552
- if torch.any(distances > 1e15).item():
553
- raise ValueError(
557
+ "of the volume center in every view"),
558
+ (torch.any(distances > 1e15),
554
559
  "the source and the detector center must lie within 1e15 voxels "
555
- "of the volume center"
556
- )
560
+ "of the volume center"),
561
+ ])
557
562
 
558
563
  def _agreement_revision(self):
559
564
  surface = self.detector_surface
@@ -768,6 +773,7 @@ class Projector:
768
773
  peers = len(active_devices) > 1 or (self._distributed and self.world_size > 1)
769
774
  extra_rays = (2 * int(self.partition == "space") + int(is_project and foreign_output)
770
775
  + int(is_project and self.partition == "space" and peers))
776
+ surface_batch = True
771
777
 
772
778
  def fits(shape):
773
779
  points = surface or pixels != self.detector_shape
@@ -776,17 +782,21 @@ class Projector:
776
782
  buffers = _pipeline_buffers(self.beam, shape, batch, pixels, points,
777
783
  geometry_grad=self._geometry_trainable,
778
784
  ray_accumulators=extra_rays, peers=peers or foreign_output)
779
- buffers += self._surface_work_buffers(batch, pixels, spec=geometry_spec, streamed=True)
785
+ workspace = self._surface_work_buffers(batch, pixels, spec=geometry_spec,
786
+ streamed=True, batched=surface_batch)
780
787
  return (
781
- sum(buffers) <= budget
782
- and _reservation_bytes(buffers) <= reservation_budget
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
783
791
  and _largest_buffer_bytes(self.beam, shape, batch, pixels, points) <= allocation_limit
784
- and max(self._surface_work_buffers(batch, pixels, spec=geometry_spec, streamed=True), default=0) <= allocation_limit
792
+ and max(workspace, default=0) <= allocation_limit
785
793
  and _host_geometry_bytes(self.beam, batch, pixels, host_points) <= _pipeline._GEOMETRY_WORK_BYTES
786
794
  )
787
795
 
788
796
  while not fits(minimum_shape):
789
- if self.view_chunk_size is None and batch > 1:
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:
790
800
  batch = max(1, batch // 2)
791
801
  elif self.detector_chunk_shape is None and max(pixels) > 1:
792
802
  axis = max(range(len(pixels)), key=pixels.__getitem__)
@@ -796,25 +806,36 @@ class Projector:
796
806
  else:
797
807
  raise RuntimeError("CUDA or host geometry memory budget cannot fit the minimum tile/view/pixel batch; reduce explicit limits")
798
808
  while not fits(chunk):
809
+ if self.detector_surface is not None and surface_batch:
810
+ surface_batch = False
811
+ continue
799
812
  if self.volume_chunk_shape is not None or max(chunk) == 1:
800
813
  raise RuntimeError("CUDA memory budget cannot fit the requested tile/view batch")
801
814
  axis = max(range(len(chunk)), key=chunk.__getitem__)
802
815
  smaller = list(chunk)
803
816
  smaller[axis] = max(1, smaller[axis] // 2)
804
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
805
823
  estimate = _pipeline_bytes(self.beam, chunk, batch, pixels,
806
824
  surface or pixels != self.detector_shape,
807
825
  geometry_grad=self._geometry_trainable,
808
826
  ray_accumulators=extra_rays, peers=peers or foreign_output)
809
- estimate += sum(self._surface_work_buffers(batch, pixels, spec=geometry_spec, streamed=True))
827
+ estimate += sum(self._surface_work_buffers(batch, pixels, spec=geometry_spec,
828
+ streamed=True, batched=surface_batch))
810
829
  return _ChunkPlan(chunk, batch, devices, pixels, budget, allocation_limit,
811
830
  estimated_gpu_bytes=estimate, foreign_output=foreign_output,
831
+ surface_batch=surface_batch,
812
832
  reservation_budget_bytes=reservation_budget,
813
833
  estimated_reserved_bytes=_reservation_bytes(_pipeline_buffers(
814
834
  self.beam, chunk, batch, pixels, surface or pixels != self.detector_shape,
815
835
  geometry_grad=self._geometry_trainable,
816
836
  ray_accumulators=extra_rays, peers=peers or foreign_output)
817
- + self._surface_work_buffers(batch, pixels, spec=geometry_spec, streamed=True)))
837
+ + self._surface_reserved_buffers(batch, pixels, spec=geometry_spec,
838
+ streamed=True, batched=surface_batch)))
818
839
 
819
840
  def _surface_capacity(self, views, pixels):
820
841
  rank, columns = len(self.volume_shape), 3 if self.beam == "cone" else 2
@@ -822,37 +843,80 @@ class Projector:
822
843
  maximum = max(views * math.prod(pixels) * rank, views * rank * frames)
823
844
  return max(1, min(math.prod(pixels), 21, (maximum - 1) // columns))
824
845
 
825
- def _surface_work_buffers(self, views, pixels, slots=1, spec=None, *, streamed=False):
826
- """Bound device-local offset staging and world-point expression storage."""
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
+ """
827
885
  if self.detector_surface is None:
828
- return ()
886
+ return ((),)
829
887
  if streamed:
830
888
  rank, frames = len(self.volume_shape), 4 if self.beam == "cone" else 3
831
889
  capacity = self._surface_capacity(views, pixels)
832
890
  columns = 3 if self.beam == "cone" else 2
833
891
  shared = spec is not None and spec.kind == "offsets" and spec.shared
834
- if shared:
892
+ if shared or batched:
835
893
  capacity = math.prod(pixels)
836
- # Shared offsets pack the frame packets of every view of a job.
837
- buffers = (_allocation_bytes(2 * (views if shared else 1) * (frames + (3 if rank == 3 else 0)) * rank),
838
- _allocation_bytes(2 * (columns * capacity + 1)))
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)))
839
897
  if self._geometry_trainable:
840
898
  # Replay one view and a bounded detector row at a time.
841
- replay = rank * min(capacity, pixels[-1])
899
+ replay = rank * min(self._surface_capacity(views, pixels), pixels[-1])
842
900
  replay_buffers = ((_allocation_bytes(2 * replay),) * 8
843
901
  + (_allocation_bytes(2 * rank),) * (frames + 3))
844
902
  if isinstance(self.detector_surface, ParameterizedSurface):
845
903
  # A CUDA sampler needs a device cotangent for its selected output.
846
904
  replay_buffers += (_allocation_bytes(6 * views * math.prod(pixels)),)
847
- if sum(replay_buffers) > sum(buffers):
848
- return replay_buffers
849
- return buffers
905
+ # Composition frees its workspace before replay starts.
906
+ return buffers, replay_buffers
907
+ return (buffers,)
850
908
  elements = views * math.prod(pixels) * 3
851
909
  dtype = torch.float64 if spec is None or spec.dtype is None else spec.dtype
852
910
  buffers = (torch.empty((), dtype=dtype).element_size() * elements,) * (4 * slots)
853
911
  if dtype != torch.float64:
854
912
  buffers += (8 * elements,) * slots
855
- return buffers + (views * 3 * 8 * (4 if self.beam == "cone" else 3),) * slots
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,)
856
920
 
857
921
  def _surface_spec(self, frames, offsets):
858
922
  dtype = torch.float32
@@ -873,9 +937,10 @@ class Projector:
873
937
  def _surface_validation_inputs(self):
874
938
  """Stage frame metadata once without expanding surface world points."""
875
939
  with torch.enable_grad():
876
- frames = tuple(value.to(device="cpu") for value in (self._learnable or self._trajectory))
940
+ frames = self._learnable or self._trajectory
877
941
  surface = self.detector_surface
878
942
  if isinstance(surface, ParameterizedSurface):
943
+ frames = tuple(value.to(device="cpu") for value in frames)
879
944
  parameters = tuple(value.to(device="cpu") for value in surface.parameters)
880
945
  geometry = (*frames, *parameters)
881
946
  return (geometry, _GeometrySpec("sampled", surface.sampler, torch.float64),
@@ -1154,6 +1219,7 @@ class Projector:
1154
1219
  return dict(operation="project" if is_project else "backproject", schedule=plan.schedule,
1155
1220
  chunk_shape=tuple(plan.chunk_shape), view_chunk_size=plan.view_chunk_size,
1156
1221
  detector_chunk_shape=tuple(plan.detector_chunk_shape or self.detector_shape),
1222
+ surface_batch=plan.surface_batch,
1157
1223
  window_size=plan.window_size, gpu_budget_bytes=plan.gpu_budget_bytes,
1158
1224
  estimated_gpu_bytes=plan.estimated_gpu_bytes,
1159
1225
  reservation_budget_bytes=plan.reservation_budget_bytes,
@@ -1172,14 +1238,16 @@ class Projector:
1172
1238
  return _pipeline_bytes(self.beam, shape, plan.view_chunk_size, pixels, points,
1173
1239
  slots=slots, retained=retained, geometry_grad=self._geometry_trainable,
1174
1240
  peers=peers, ray_accumulators=ray_accumulators) + sum(
1175
- self._surface_work_buffers(plan.view_chunk_size, pixels, slots, spec, streamed=True))
1241
+ self._surface_work_buffers(plan.view_chunk_size, pixels, slots, spec,
1242
+ streamed=True, batched=plan.surface_batch))
1176
1243
 
1177
1244
  def reserved(shape, slots=1, retained=1, ray_accumulators=0):
1178
1245
  return _reservation_bytes(_pipeline_buffers(
1179
1246
  self.beam, shape, plan.view_chunk_size, pixels, points,
1180
1247
  slots=slots, retained=retained, geometry_grad=self._geometry_trainable,
1181
1248
  peers=peers, ray_accumulators=ray_accumulators)
1182
- + self._surface_work_buffers(plan.view_chunk_size, pixels, slots, spec, streamed=True))
1249
+ + self._surface_reserved_buffers(plan.view_chunk_size, pixels, slots, spec,
1250
+ streamed=True, batched=plan.surface_batch))
1183
1251
 
1184
1252
  shapes = [plan.chunk_shape]
1185
1253
  if self.volume_chunk_shape is None and len(volume_shape) == 3:
@@ -1255,8 +1323,8 @@ class Projector:
1255
1323
  return _pipeline_buffers(
1256
1324
  self.beam, plan.chunk_shape, plan.view_chunk_size, pixels, points,
1257
1325
  slots=plan.slots, retained=plan.window_size, geometry_grad=self._geometry_trainable,
1258
- peers=peers, ray_accumulators=extra_rays) + self._surface_work_buffers(
1259
- 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)
1260
1328
 
1261
1329
  def _choose_schedule(self, source, sink, is_project, geometry, plan, spec):
1262
1330
  candidates = self._candidate_plans(plan, is_project, spec)
@@ -1266,7 +1334,8 @@ class Projector:
1266
1334
  tuple(device.index for device in plan.devices), str(source.dtype), plan.gpu_budget_bytes,
1267
1335
  plan.allocation_limit, plan.reservation_budget_bytes,
1268
1336
  str(getattr(source, "device", "cpu")), str(getattr(sink, "device", "cpu")),
1269
- 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)
1270
1339
  collective = self._distributed and self.world_size > 1
1271
1340
  if not collective and key in self._schedule_cache:
1272
1341
  return self._schedule_cache[key], [], True
@@ -1337,6 +1406,7 @@ class Projector:
1337
1406
  geometry, spec, views, pixels)
1338
1407
  origin = torch.cuda.current_stream(offsets.device) if offsets.is_cuda else None
1339
1408
  frames = geometry[:frame_count]
1409
+ frame_origins = tuple(torch.cuda.current_stream(value.device) for value in frames if value.is_cuda)
1340
1410
  common = torch.float32
1341
1411
  for value in (*frames, offsets):
1342
1412
  common = torch.promote_types(common, value.dtype)
@@ -1349,25 +1419,33 @@ class Projector:
1349
1419
  # Offsets shared by all views are staged once per call and composed
1350
1420
  # with one launch for all views instead of per view and row segment.
1351
1421
  shared = spec.kind == "offsets" and offsets.ndim == len(pixel_shape) + 1
1422
+ batched = shared or groups.get("_surface_batch", True)
1352
1423
  pixel_count = math.prod(pixel_shape)
1353
1424
  # One packed frame packet per view: source, center, axes, normal and
1354
1425
  # float32 cross-product scratch. Shared offsets pack every view of a job.
1355
1426
  stride = (frame_count + (3 if rank == 3 else 0)) * rank
1356
1427
  if "_surface_workspace" not in groups:
1357
- capacity = pixel_count if shared else self._surface_capacity(count, pixel_shape)
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))
1358
1431
  groups["_surface_workspace"] = (
1359
- pipe.empty((count if shared else 1) * stride, dtype=torch.float64),
1360
- pipe.empty((3 if rank == 3 else 2) * capacity + 1, dtype=torch.float64), capacity,
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,
1361
1435
  )
1362
1436
  packed_frames, packed_offsets, capacity = groups["_surface_workspace"]
1363
1437
  columns = (0, 1, 2) if rank == 3 else (0, 2)
1364
1438
  source, points = targets[0].view(count, rank), targets[1].view(count, -1, rank)
1365
1439
  # A workspace sized for an earlier, smaller tile keeps the segment path.
1366
1440
  shared = shared and capacity >= pixel_count
1367
- if shared and packed_frames.numel() < count * stride:
1441
+ batched = batched and capacity >= pixel_count
1442
+ if batched and packed_frames.numel() < count * stride:
1368
1443
  # Earlier launches on the upload stream may still read the smaller buffer.
1369
1444
  pipe.retain(pipe.event(pipe.upload), packed_frames, None)
1370
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)
1371
1449
  groups["_surface_workspace"] = (packed_frames, packed_offsets, capacity)
1372
1450
  # Every job of a call slices the same offset snapshot: stage it once per
1373
1451
  # call, pixel tile and workspace. The key holds the snapshot and the
@@ -1380,39 +1458,55 @@ class Projector:
1380
1458
  and groups["_surface_shared_staged"][1] is staged_key[1]
1381
1459
  and groups["_surface_shared_staged"][2] == staged_key[2])
1382
1460
  else:
1383
- restage = shared
1461
+ restage = batched
1384
1462
  groups.pop("_surface_shared_staged", None)
1385
1463
  with torch.cuda.device(pipe.device), torch.cuda.stream(pipe.upload), cuda.gpus[pipe.device.index]:
1386
1464
  if origin is not None:
1387
1465
  pipe.upload.wait_stream(origin)
1466
+ for frame_origin in frame_origins:
1467
+ pipe.upload.wait_stream(frame_origin)
1388
1468
  packed_offsets[-1].zero_()
1389
1469
  stream = _get_numba_external_stream_for(pipe.upload)
1390
1470
  arrays = tuple(TorchCUDABridge.tensor_to_cuda_array(value)
1391
1471
  for value in (packed_frames, packed_offsets, source, points))
1392
1472
  if restage:
1393
- # Pack the float64 columns where the offsets live, then copy each
1394
- # column into the workspace without a device staging buffer.
1395
- flat = offsets.detach().reshape(pixel_count, 3)
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)
1396
1480
  if offsets.is_cuda:
1397
1481
  for column, original in enumerate(columns):
1398
- packed_offsets[column * capacity:column * capacity + pixel_count].copy_(flat[:, original])
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)
1399
1486
  else:
1400
- host = flat[:, list(columns)].T.to(dtype=torch.float64).contiguous()
1401
- for column in range(len(columns)):
1402
- _pipeline._copy(packed_offsets[column * capacity:column * capacity + pixel_count],
1403
- host[column], pipe.stats)
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)
1404
1492
  pipe.retain(pipe.event(pipe.upload), host, packed_offsets)
1405
- if cacheable:
1493
+ if shared and cacheable:
1406
1494
  groups["_surface_shared_staged"] = staged_key
1407
- if shared:
1495
+ if batched:
1408
1496
  # Stage the frame packets of every view in one transfer and
1409
1497
  # compose all views with one launch; grid row y composes view y.
1410
1498
  block = packed_frames[:count * stride].view(count, stride)
1411
- packet = torch.zeros((count, stride), dtype=torch.float64)
1412
- for index, value in enumerate(frames):
1413
- packet[:, index * rank:(index + 1) * rank].copy_(value[views].detach())
1414
- _pipeline._copy(block, packet, pipe.stats)
1415
- pipe.retain(pipe.event(pipe.upload), packet, packed_frames)
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)
1416
1510
  if rank == 3:
1417
1511
  normal = block[:, 4 * rank:5 * rank]
1418
1512
  axis_u, axis_v = block[:, 2 * rank:3 * rank], block[:, 3 * rank:4 * rank]
@@ -1433,7 +1527,7 @@ class Projector:
1433
1527
  self.beam == "parallel", frame32, offset32,
1434
1528
  tuple(centre) + (0.,) * (3 - rank), self.voxel_spacing,
1435
1529
  )
1436
- for local in range(0 if shared else count):
1530
+ for local in range(0 if batched else count):
1437
1531
  packet = torch.cat([value[views.start + local].detach().to(device="cpu", dtype=torch.float64)
1438
1532
  for value in frames])
1439
1533
  _pipeline._copy(packed_frames[:frame_count * rank], packet, pipe.stats)
@@ -1556,7 +1650,7 @@ class Projector:
1556
1650
  points = spec is not None or self.detector_chunk_shape is not None or pixels != self.detector_shape
1557
1651
  sizes = ((plan.view_chunk_size * rank, rays * rank) if points
1558
1652
  else (plan.view_chunk_size * rank,) * (4 if self.beam == "cone" else 3))
1559
- return {"_max_rays": rays, "_max_geometry": sizes}
1653
+ return {"_max_rays": rays, "_max_geometry": sizes, "_surface_batch": plan.surface_batch}
1560
1654
 
1561
1655
  def _prefetch_job(self, job, source, geometry, spec, transfer, groups, index, is_project,
1562
1656
  native, reduced, active_rays, next_epoch=False):
@@ -2127,7 +2221,7 @@ class Projector:
2127
2221
  spacing = self.detector_spacing if self.beam == "cone" else self.detector_spacing[0]
2128
2222
  sampled = geometry_spec is not None and geometry_spec.kind in ("sampled", "offsets")
2129
2223
  grads = [torch.zeros(component.shape, dtype=torch.float32,
2130
- device="cpu" if sampled else volume.device) for component in geometry]
2224
+ device=component.device if sampled else volume.device) for component in geometry]
2131
2225
  for device_rank, device in enumerate(devices):
2132
2226
  local_slice = _balanced_slice(local_views, device_rank, len(devices))
2133
2227
  if local_slice.start == local_slice.stop:
@@ -2287,7 +2381,8 @@ class Projector:
2287
2381
  self.last_execution_stats = stats
2288
2382
  return tuple(grad.to(device=component.device, dtype=component.dtype)
2289
2383
  for grad, component in zip(grads, geometry))
2290
- groups = {device: {} for device in plan.devices}
2384
+ # Replay composition follows the admitted batched/fallback workspace.
2385
+ groups = {device: {"_surface_batch": plan.surface_batch} for device in plan.devices}
2291
2386
  with _pipeline._TransferPipeline(plan.devices, self._pool_elements(plan, geometry_spec), stats,
2292
2387
  slots=plan.slots) as transfer:
2293
2388
  for spatial_slice, shape, centre in self._volume_tiles(plan.chunk_shape):
@@ -2514,22 +2609,28 @@ class Projector:
2514
2609
  staged = tuple(value[owned].to(device=device, dtype=owner.dtype) for value in geometry)
2515
2610
  admission_owner = copy(owner)
2516
2611
  admission_owner.view_slice = slice(0, shard.stop - shard.start)
2517
- for views, selected in _flat_admission_batches(admission_owner, staged, pixels):
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
+ ):
2518
2617
  source, points = _sample_geometry(
2519
2618
  owner.beam, owner.detector_shape, owner.detector_spacing, staged,
2520
2619
  None, views, selected, owner.voxel_spacing, device=device,
2521
2620
  dtype=owner.dtype, validate=False,
2522
2621
  )
2523
- if not torch.isfinite(source).all().item() or not torch.isfinite(points).all().item():
2524
- raise ValueError("detector surface world coordinates must be finite")
2622
+ nonfinite = nonfinite | ~torch.isfinite(source).all() | ~torch.isfinite(points).all()
2525
2623
  sources = source.reshape(source.shape[0], *([1] * len(selected)), -1)
2526
- if owner.beam != "parallel" and torch.any(torch.all(points == sources, dim=-1)).item():
2527
- raise ValueError("source and detector surface pixels must differ")
2624
+ if owner.beam != "parallel":
2625
+ coincident = coincident | torch.any(torch.all(points == sources, dim=-1))
2528
2626
  directions = (source if owner.beam == "parallel" else
2529
2627
  points - sources)
2530
- if not torch.isfinite(directions).all().item():
2531
- raise ValueError("detector surface world coordinates must be finite")
2628
+ nonfinite = nonfinite | ~torch.isfinite(directions).all()
2532
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
+ ])
2533
2634
  del staged
2534
2635
  except (TypeError, ValueError, RuntimeError) as failure:
2535
2636
  error = failure
@@ -2591,7 +2692,25 @@ class Projector:
2591
2692
  def _prepare_execution(self, tensor, is_project, output_store=None):
2592
2693
  device = torch.device(getattr(tensor, "device", "cpu"))
2593
2694
  if self.detector_surface is not None:
2594
- spec, geometry = self._effective_geometry(None, device)
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.
2595
2714
  plan = self._execution_plan(tensor, is_project, output_store, spec)
2596
2715
  else:
2597
2716
  plan = self._execution_plan(tensor, is_project, output_store)
@@ -2606,11 +2725,28 @@ class Projector:
2606
2725
  return None, tuple(component.clone() if plan is None or component.device.type == "cpu"
2607
2726
  else component.to(device="cpu", copy=True)
2608
2727
  for component in self._learnable)
2609
- # Callback graphs can save mutable captured parameters. Snapshot their
2610
- # values on the host, restoring their original device only when the
2611
- # callback's own backward needs them. Native tile graphs are not saved.
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
+
2612
2734
  def pack(tensor):
2613
- return tensor.detach().to(device="cpu").clone(), tensor.device
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
2614
2750
 
2615
2751
  def unpack(saved):
2616
2752
  tensor, device = saved
@@ -2630,12 +2766,14 @@ class Projector:
2630
2766
  with torch.autograd.graph.saved_tensors_hooks(pack, unpack):
2631
2767
  offsets = self._surface_offsets()
2632
2768
  values = (*frames, offsets)
2633
- snapshots = tuple(value.clone() if value.device.type == "cpu" else value.to(device="cpu", copy=True)
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)
2634
2771
  for value in values)
2635
2772
  spec = self._surface_spec(frames, offsets)
2636
2773
  metadata = (tuple(offsets.shape), str(offsets.dtype))
2637
2774
  self._snapshot_d2h_bytes += sum(value.numel() * value.element_size()
2638
- for value in values if value.is_cuda)
2775
+ for value, snapshot in zip(values, snapshots)
2776
+ if value.is_cuda and not snapshot.is_cuda)
2639
2777
  except (TypeError, ValueError, RuntimeError) as failure:
2640
2778
  error = failure
2641
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(squared_norm(operator, states["r"], "rays")))
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
- initial = data if x0 is None else data - operator.project(states["x"])
155
- history = [math.sqrt(squared_norm(operator, initial, "rays"))]
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
- residual = data - operator.project(states["x"])
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
- history.append(math.sqrt(squared_norm(operator, data - operator.project(states["x"]), "rays")))
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
- initial = data if x0 is None else data - operator.project(x)
201
- history = [math.sqrt(squared_norm(operator, initial, "rays"))]
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
- residual = operator.project(x) - data
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
- history.append(math.sqrt(squared_norm(operator, data - operator.project(x), "rays")))
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,6 +4,7 @@ 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:
@@ -41,7 +42,8 @@ class ParameterizedSurface:
41
42
 
42
43
 
43
44
  def _detector_grids(detector_shape, spacing, pixels):
44
- axes = [(torch.arange(selected.start, selected.stop, dtype=torch.float64, device="cpu") + .5 - size / 2) * pitch
45
+ axes = [(torch.arange(selected.start, selected.stop, selected.step or 1,
46
+ dtype=torch.float64, device="cpu") + .5 - size / 2) * pitch
45
47
  for selected, size, pitch in zip(pixels, detector_shape, spacing)]
46
48
  if len(axes) == 2:
47
49
  return torch.meshgrid(*axes, indexing="ij")
@@ -106,9 +108,7 @@ def _sample_geometry(beam, detector_shape, spacing, geometry, spec, views, pixel
106
108
  depth = offsets[..., 2].to(device=device, dtype=offset_dtype)
107
109
  if offsets.ndim == dimensions + 1:
108
110
  tangent, depth = tangent.unsqueeze(0), depth.unsqueeze(0)
109
- if validate:
110
- if not torch.isfinite(offsets).all().item():
111
- raise ValueError("detector surface coordinates must be finite")
111
+ checks = [(~torch.isfinite(offsets).all(), "detector surface coordinates must be finite")] if validate else []
112
112
 
113
113
  def frame(component):
114
114
  selected = component if local_geometry else component[views]
@@ -132,17 +132,18 @@ def _sample_geometry(beam, detector_shape, spacing, geometry, spec, views, pixel
132
132
  source = (frames[0] if local_geometry else frames[0][views]).to(device=device, dtype=source_dtype)
133
133
  if validate:
134
134
  for component in (source, points):
135
- if not torch.isfinite(component).all().item() or not torch.isfinite(component.float()).all().item():
136
- raise ValueError("surface world coordinates must be finite in float32")
135
+ checks.append((~torch.isfinite(component).all() | ~torch.isfinite(component.float()).all(),
136
+ "surface world coordinates must be finite in float32"))
137
137
  if beam not in ("parallel", "parallel3d") and view_count:
138
138
  sources = source.unsqueeze(1)
139
139
  endpoints = points.reshape(view_count, -1, source.shape[-1])
140
- if torch.any(torch.all(sources.float() == endpoints.float(), dim=-1)).item():
141
- raise ValueError("source and detector surface pixels must differ")
140
+ checks.append((torch.any(torch.all(sources.float() == endpoints.float(), dim=-1)),
141
+ "source and detector surface pixels must differ"))
142
142
  source_distance = torch.linalg.vector_norm(sources.double(), dim=-1) / voxel_spacing
143
143
  pixel_distance = torch.linalg.vector_norm(endpoints.double(), dim=-1) / voxel_spacing
144
- if torch.any(torch.minimum(source_distance, pixel_distance) > 1e6).item():
145
- raise ValueError("surface source or pixel must lie within 1e6 voxels")
146
- if torch.any(source_distance > 1e15).item() or torch.any(pixel_distance > 1e15).item():
147
- raise ValueError("surface coordinates must lie within 1e15 voxels")
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)
148
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.2
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, including [unreleased APIs](CHANGELOG.md#unreleased). Per-call trajectories, angle-tensor circular helpers, `transform_trajectory`, `parallel3d` and `native_volume_layout` require installation from source.
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.1">
181
- <img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.4.1" width="100%" alt="Time per forward, adjoint and CGLS iteration on 1, 4 and 8 GPUs, 512^3 volume">
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 | 569.99 ms | 151.02 ms (3.8×) | 81.50 ms (7.0×) |
188
- | Adjoint (backprojection) | 1061.36 ms | 278.75 ms (3.8×) | 220.34 ms (4.8×) |
189
- | CGLS iteration | 1756.03 ms | 468.05 ms (3.8×) | 333.08 ms (5.3×) |
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.1, FP32, A100 64 GB GPUs; circular cone beam, 360 views, 1024×768 detector, pitch 1.25. Default memory settings, without an imposed allocator cap.
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=saZamKDQ9CLB1HoVwIeZG6HMWNnbZbdNXtaT_MIpDLw,2965
2
- diffct/_solver_state.py,sha256=DKfrgrKcSpx3YetbF8hjA2yzMzJag-iHVpMZGup_dLw,16258
3
- diffct/_solver_tv.py,sha256=9nE1DVRzdUlcfd-OQQ86GpntX6CcaVvsSVWK-L4uR_I,5783
4
- diffct/analytical.py,sha256=u2PUp8GTNHoqwP2S4s0y9xNjiVBLfnYMULuuThcWmdU,33245
5
- diffct/analytical_execution.py,sha256=ZuOmt45-TQg3wb2J0erJe2fXIq0ZQr-uXrrbYiAWg7E,25660
6
- diffct/backends.py,sha256=VaALT_iE-aL4jAbzJ5xGvINfGLrHqqvuPozz0R8h048,47960
7
- diffct/chunking.py,sha256=EzozqIuG8NH_vuBRPqaN5zL0rvRFp5ftwReAhZXBP44,11342
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=QH7TedxIc35616bn0PwgaLDllH3Xt3aadFzIpbe8y0M,158446
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=ZroVUlhHOE2IAXB0clw9hUCvomrFSgYdGRQWwnSFZxs,17297
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=z_0O98H7eMdSd9qrgUtcg5hBaE643s6sG_AjnH0Dyhw,7935
21
- diffct/utils.py,sha256=cih3GyZ33gnOrTW7O8r2_aXIP2a-rFjOMuAPuvHeQ4I,14794
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=eXD_aqW0e6vBMsS0R5oZ-cyedK_wrX7RDjFfiM0HIg4,5602
29
- diffct-2.4.2.dist-info/METADATA,sha256=0NsA9YkbCMTwARCVFc1RCqY53W8-zwGjK6ZNOlbcTgU,17661
30
- diffct-2.4.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
31
- diffct-2.4.2.dist-info/licenses/LICENSE,sha256=a1fjzGs44LpDnV_LmA2Tzp8z-eJKoNVVdLOnh-F6J6E,11344
32
- diffct-2.4.2.dist-info/licenses/NOTICE,sha256=du9I2sDF5IDfiY9vF64WWzMJh-MarpVXyLfWQmhbvmI,714
33
- diffct-2.4.2.dist-info/RECORD,,
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