diffct 2.4.1__py3-none-any.whl → 2.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
diffct/__init__.py CHANGED
@@ -64,7 +64,7 @@ try:
64
64
  except ImportError:
65
65
  pass
66
66
 
67
- __version__ = '2.4.1'
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):
@@ -51,26 +51,34 @@ def compose_surface(frames, offsets, source, points, view_index, pixel_start,
51
51
  ``frame32`` gives the arithmetic dtype of each packed frame component.
52
52
  ``offset32`` gives the offset arithmetic dtype. Values are stored exactly
53
53
  in float64 staging buffers. Results use caller-owned float32 buffers.
54
+
55
+ A launch may compose several views: grid row ``y``
56
+ writes view ``view_index + y`` from the ``y``-th of ``gridDim.y`` equal
57
+ frame packets in ``frames``. A shared offset buffer holds ``rank * capacity``
58
+ values plus status; a larger buffer holds one such packet per output view.
54
59
  """
55
60
  pixel = cuda.grid(1)
61
+ view_index += cuda.blockIdx.y
62
+ base = cuda.blockIdx.y * (frames.size // cuda.gridDim.y)
56
63
  if pixel == 0:
57
64
  for axis in range(rank):
58
- value = frames[axis]
65
+ value = frames[base + axis]
59
66
  if not parallel:
60
67
  value = _surface_add(value, -centre[axis], frame32[0])
61
68
  source[view_index, axis] = np.float32(value)
62
69
  if pixel >= count:
63
70
  return
64
71
 
65
- tangent = offsets[pixel]
66
- 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]
67
75
  tangent32 = offset32 and frame32[2]
68
76
  point32 = frame32[1] and tangent32
69
77
  normal32 = frame32[2]
70
78
  vertical = np.float64(0.0)
71
79
  vertical32 = False
72
80
  if rank == 3:
73
- vertical = offsets[capacity + pixel]
81
+ vertical = offsets[offset_base + capacity + pixel]
74
82
  vertical32 = offset32 and frame32[3]
75
83
  point32 = point32 and vertical32
76
84
  normal32 = normal32 and frame32[3]
@@ -85,20 +93,20 @@ def compose_surface(frames, offsets, source, points, view_index, pixel_start,
85
93
  point_squared = np.float64(0.0)
86
94
  for axis in range(rank):
87
95
  point = _surface_add(
88
- frames[rank + axis],
89
- _surface_multiply(tangent, frames[2 * rank + axis], tangent32),
96
+ frames[base + rank + axis],
97
+ _surface_multiply(tangent, frames[base + 2 * rank + axis], tangent32),
90
98
  frame32[1] and tangent32,
91
99
  )
92
100
  if rank == 3:
93
101
  point = _surface_add(
94
- point, _surface_multiply(vertical, frames[3 * rank + axis], vertical32),
102
+ point, _surface_multiply(vertical, frames[base + 3 * rank + axis], vertical32),
95
103
  point32,
96
104
  )
97
- normal = frames[4 * rank + axis]
105
+ normal = frames[base + 4 * rank + axis]
98
106
  else:
99
- normal = frames[2 * rank + 1] if axis == 0 else -frames[2 * rank]
107
+ normal = frames[base + 2 * rank + 1] if axis == 0 else -frames[base + 2 * rank]
100
108
  point = _surface_add(point, _surface_multiply(depth, normal, depth32), final32)
101
- source_value = frames[axis]
109
+ source_value = frames[base + axis]
102
110
  source_native = np.float32(source_value)
103
111
  point_native = np.float32(point)
104
112
  finite = (finite and math.isfinite(source_value) and math.isfinite(point)
diffct/operators.py CHANGED
@@ -16,7 +16,7 @@ from .storage import TensorStore
16
16
  from .streaming import _validate_stores
17
17
  from .surfaces import ParameterizedSurface, _GeometrySpec, _sample_geometry, _sample_offsets
18
18
  from .surface_gradients import surface_batch_vjp
19
- from .utils import TorchCUDABridge, _get_numba_external_stream_for
19
+ from .utils import TorchCUDABridge, _get_numba_external_stream_for, _raise_geometry_errors
20
20
  from .backends import ReferenceProjector, _flat_admission_batches, select_backend, voxel_spacing_tuple
21
21
  from .distribution import Partition
22
22
  from .projectors import (
@@ -77,22 +77,27 @@ def _validate_trajectory(trajectory, beam, *, detector_surface=False, clone=True
77
77
  if not isinstance(trajectory, (tuple, list)) or len(trajectory) != count:
78
78
  raise TypeError(f"{beam} trajectory must contain {count} tensors")
79
79
 
80
- geometry = []
80
+ geometry, checks = [], []
81
81
  n_views = None
82
82
  for component in trajectory:
83
- 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
 
@@ -489,6 +496,8 @@ class Projector:
489
496
  if self.backend != "torch" and detector_surface is not None:
490
497
  geometry, spec, surface_trainable, surface_metadata = self._surface_validation_inputs()
491
498
  devices = self._devices or (torch.device("cuda", torch.cuda.current_device()),)
499
+ # Validation budgets depend on whether geometry gradients are needed.
500
+ self._geometry_trainable = bool(self._learnable) or surface_trainable
492
501
  self._validate_surface_geometry(geometry, spec, devices)
493
502
  except (TypeError, ValueError, RuntimeError) as error:
494
503
  validation_error = error
@@ -542,16 +551,14 @@ class Projector:
542
551
  )
543
552
  for component in self._trajectory[:2]
544
553
  ])
545
- if torch.any(distances.min(dim=0).values > 1e6).item():
546
- raise ValueError(
554
+ _raise_geometry_errors([
555
+ (torch.any(distances.min(dim=0).values > 1e6),
547
556
  "the source or the detector center must lie within 1e6 voxels "
548
- "of the volume center in every view"
549
- )
550
- if torch.any(distances > 1e15).item():
551
- raise ValueError(
557
+ "of the volume center in every view"),
558
+ (torch.any(distances > 1e15),
552
559
  "the source and the detector center must lie within 1e15 voxels "
553
- "of the volume center"
554
- )
560
+ "of the volume center"),
561
+ ])
555
562
 
556
563
  def _agreement_revision(self):
557
564
  surface = self.detector_surface
@@ -766,23 +773,30 @@ class Projector:
766
773
  peers = len(active_devices) > 1 or (self._distributed and self.world_size > 1)
767
774
  extra_rays = (2 * int(self.partition == "space") + int(is_project and foreign_output)
768
775
  + int(is_project and self.partition == "space" and peers))
776
+ surface_batch = True
769
777
 
770
778
  def fits(shape):
771
779
  points = surface or pixels != self.detector_shape
780
+ # Legacy offsets are one host snapshot; world points expand on the device.
781
+ host_points = points and self._host_world_points(geometry_spec)
772
782
  buffers = _pipeline_buffers(self.beam, shape, batch, pixels, points,
773
783
  geometry_grad=self._geometry_trainable,
774
784
  ray_accumulators=extra_rays, peers=peers or foreign_output)
775
- 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)
776
787
  return (
777
- sum(buffers) <= budget
778
- 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
779
791
  and _largest_buffer_bytes(self.beam, shape, batch, pixels, points) <= allocation_limit
780
- and max(self._surface_work_buffers(batch, pixels, spec=geometry_spec, streamed=True), default=0) <= allocation_limit
781
- and _host_geometry_bytes(self.beam, batch, pixels, points) <= _pipeline._GEOMETRY_WORK_BYTES
792
+ and max(workspace, default=0) <= allocation_limit
793
+ and _host_geometry_bytes(self.beam, batch, pixels, host_points) <= _pipeline._GEOMETRY_WORK_BYTES
782
794
  )
783
795
 
784
796
  while not fits(minimum_shape):
785
- if self.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:
786
800
  batch = max(1, batch // 2)
787
801
  elif self.detector_chunk_shape is None and max(pixels) > 1:
788
802
  axis = max(range(len(pixels)), key=pixels.__getitem__)
@@ -792,25 +806,36 @@ class Projector:
792
806
  else:
793
807
  raise RuntimeError("CUDA or host geometry memory budget cannot fit the minimum tile/view/pixel batch; reduce explicit limits")
794
808
  while not fits(chunk):
809
+ if self.detector_surface is not None and surface_batch:
810
+ surface_batch = False
811
+ continue
795
812
  if self.volume_chunk_shape is not None or max(chunk) == 1:
796
813
  raise RuntimeError("CUDA memory budget cannot fit the requested tile/view batch")
797
814
  axis = max(range(len(chunk)), key=chunk.__getitem__)
798
815
  smaller = list(chunk)
799
816
  smaller[axis] = max(1, smaller[axis] // 2)
800
817
  chunk = tuple(smaller)
818
+ if not surface_batch:
819
+ # Smaller automatic tiles may now fit the complete workspace.
820
+ surface_batch = True
821
+ if not fits(chunk):
822
+ surface_batch = False
801
823
  estimate = _pipeline_bytes(self.beam, chunk, batch, pixels,
802
824
  surface or pixels != self.detector_shape,
803
825
  geometry_grad=self._geometry_trainable,
804
826
  ray_accumulators=extra_rays, peers=peers or foreign_output)
805
- estimate += sum(self._surface_work_buffers(batch, pixels, spec=geometry_spec, streamed=True))
827
+ estimate += sum(self._surface_work_buffers(batch, pixels, spec=geometry_spec,
828
+ streamed=True, batched=surface_batch))
806
829
  return _ChunkPlan(chunk, batch, devices, pixels, budget, allocation_limit,
807
830
  estimated_gpu_bytes=estimate, foreign_output=foreign_output,
831
+ surface_batch=surface_batch,
808
832
  reservation_budget_bytes=reservation_budget,
809
833
  estimated_reserved_bytes=_reservation_bytes(_pipeline_buffers(
810
834
  self.beam, chunk, batch, pixels, surface or pixels != self.detector_shape,
811
835
  geometry_grad=self._geometry_trainable,
812
836
  ray_accumulators=extra_rays, peers=peers or foreign_output)
813
- + self._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)))
814
839
 
815
840
  def _surface_capacity(self, views, pixels):
816
841
  rank, columns = len(self.volume_shape), 3 if self.beam == "cone" else 2
@@ -818,46 +843,104 @@ class Projector:
818
843
  maximum = max(views * math.prod(pixels) * rank, views * rank * frames)
819
844
  return max(1, min(math.prod(pixels), 21, (maximum - 1) // columns))
820
845
 
821
- def _surface_work_buffers(self, views, pixels, slots=1, spec=None, *, streamed=False):
822
- """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
+ """
823
885
  if self.detector_surface is None:
824
- return ()
886
+ return ((),)
825
887
  if streamed:
826
888
  rank, frames = len(self.volume_shape), 4 if self.beam == "cone" else 3
827
889
  capacity = self._surface_capacity(views, pixels)
828
890
  columns = 3 if self.beam == "cone" else 2
829
- buffers = (_allocation_bytes(2 * (frames + (3 if rank == 3 else 0)) * rank),
830
- _allocation_bytes(2 * (columns * capacity + 1)))
891
+ shared = spec is not None and spec.kind == "offsets" and spec.shared
892
+ if shared or batched:
893
+ capacity = math.prod(pixels)
894
+ # Batched jobs retain every frame and, unless shared, every offset.
895
+ buffers = (_allocation_bytes(2 * (views if shared or batched else 1) * (frames + (3 if rank == 3 else 0)) * rank),
896
+ _allocation_bytes(2 * (columns * capacity * (views if batched and not shared else 1) + 1)))
831
897
  if self._geometry_trainable:
832
898
  # Replay one view and a bounded detector row at a time.
833
- replay = rank * min(capacity, pixels[-1])
899
+ replay = rank * min(self._surface_capacity(views, pixels), pixels[-1])
834
900
  replay_buffers = ((_allocation_bytes(2 * replay),) * 8
835
901
  + (_allocation_bytes(2 * rank),) * (frames + 3))
836
902
  if isinstance(self.detector_surface, ParameterizedSurface):
837
903
  # A CUDA sampler needs a device cotangent for its selected output.
838
904
  replay_buffers += (_allocation_bytes(6 * views * math.prod(pixels)),)
839
- if sum(replay_buffers) > sum(buffers):
840
- return replay_buffers
841
- return buffers
905
+ # Composition frees its workspace before replay starts.
906
+ return buffers, replay_buffers
907
+ return (buffers,)
842
908
  elements = views * math.prod(pixels) * 3
843
909
  dtype = torch.float64 if spec is None or spec.dtype is None else spec.dtype
844
910
  buffers = (torch.empty((), dtype=dtype).element_size() * elements,) * (4 * slots)
845
911
  if dtype != torch.float64:
846
912
  buffers += (8 * elements,) * slots
847
- 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,)
848
920
 
849
921
  def _surface_spec(self, frames, offsets):
850
922
  dtype = torch.float32
851
923
  for component in (*frames[1:], offsets):
852
924
  dtype = torch.promote_types(dtype, component.dtype)
853
- return _GeometrySpec("offsets", dtype=dtype)
925
+ return _GeometrySpec("offsets", dtype=dtype, shared=offsets.ndim == len(self.detector_shape) + 1)
926
+
927
+ def _host_world_points(self, spec):
928
+ """Whether the host geometry budget must count world points of each view.
929
+
930
+ Shared legacy offsets are one host snapshot whose world points are
931
+ composed on the compute device; per-view offsets and geometry
932
+ gradients keep host work proportional to views x pixels.
933
+ """
934
+ return not (spec is not None and spec.kind == "offsets" and spec.shared
935
+ and not self._geometry_trainable)
854
936
 
855
937
  def _surface_validation_inputs(self):
856
938
  """Stage frame metadata once without expanding surface world points."""
857
939
  with torch.enable_grad():
858
- frames = tuple(value.to(device="cpu") for value in (self._learnable or self._trajectory))
940
+ frames = self._learnable or self._trajectory
859
941
  surface = self.detector_surface
860
942
  if isinstance(surface, ParameterizedSurface):
943
+ frames = tuple(value.to(device="cpu") for value in frames)
861
944
  parameters = tuple(value.to(device="cpu") for value in surface.parameters)
862
945
  geometry = (*frames, *parameters)
863
946
  return (geometry, _GeometrySpec("sampled", surface.sampler, torch.float64),
@@ -884,7 +967,7 @@ class Projector:
884
967
  buffers = self._surface_work_buffers(batch, pixels, spec=spec)
885
968
  return (sum(buffers) <= budget and max(buffers, default=0) <= allocation
886
969
  and _reservation_bytes(buffers) <= reservation
887
- and _host_geometry_bytes(self.beam, batch, pixels, True)
970
+ and _host_geometry_bytes(self.beam, batch, pixels, self._host_world_points(spec))
888
971
  <= _pipeline._GEOMETRY_WORK_BYTES)
889
972
 
890
973
  if not fits():
@@ -1136,6 +1219,7 @@ class Projector:
1136
1219
  return dict(operation="project" if is_project else "backproject", schedule=plan.schedule,
1137
1220
  chunk_shape=tuple(plan.chunk_shape), view_chunk_size=plan.view_chunk_size,
1138
1221
  detector_chunk_shape=tuple(plan.detector_chunk_shape or self.detector_shape),
1222
+ surface_batch=plan.surface_batch,
1139
1223
  window_size=plan.window_size, gpu_budget_bytes=plan.gpu_budget_bytes,
1140
1224
  estimated_gpu_bytes=plan.estimated_gpu_bytes,
1141
1225
  reservation_budget_bytes=plan.reservation_budget_bytes,
@@ -1154,14 +1238,16 @@ class Projector:
1154
1238
  return _pipeline_bytes(self.beam, shape, plan.view_chunk_size, pixels, points,
1155
1239
  slots=slots, retained=retained, geometry_grad=self._geometry_trainable,
1156
1240
  peers=peers, ray_accumulators=ray_accumulators) + sum(
1157
- self._surface_work_buffers(plan.view_chunk_size, pixels, slots, spec, streamed=True))
1241
+ self._surface_work_buffers(plan.view_chunk_size, pixels, slots, spec,
1242
+ streamed=True, batched=plan.surface_batch))
1158
1243
 
1159
1244
  def reserved(shape, slots=1, retained=1, ray_accumulators=0):
1160
1245
  return _reservation_bytes(_pipeline_buffers(
1161
1246
  self.beam, shape, plan.view_chunk_size, pixels, points,
1162
1247
  slots=slots, retained=retained, geometry_grad=self._geometry_trainable,
1163
1248
  peers=peers, ray_accumulators=ray_accumulators)
1164
- + self._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))
1165
1251
 
1166
1252
  shapes = [plan.chunk_shape]
1167
1253
  if self.volume_chunk_shape is None and len(volume_shape) == 3:
@@ -1197,7 +1283,8 @@ class Projector:
1197
1283
  estimate = size(shape, slots, retained, extra_rays)
1198
1284
  reservation = (reserved(shape, slots, retained, extra_rays)
1199
1285
  if estimate <= plan.gpu_budget_bytes else (1 << 63) - 1)
1200
- host_fits = 2 * _host_geometry_bytes(self.beam, plan.view_chunk_size, pixels, points) <= _pipeline._GEOMETRY_WORK_BYTES
1286
+ host_points = points and self._host_world_points(spec)
1287
+ host_fits = 2 * _host_geometry_bytes(self.beam, plan.view_chunk_size, pixels, host_points) <= _pipeline._GEOMETRY_WORK_BYTES
1201
1288
  if (estimate > plan.gpu_budget_bytes
1202
1289
  or reservation > plan.reservation_budget_bytes or not host_fits):
1203
1290
  if schedule == "window":
@@ -1236,8 +1323,8 @@ class Projector:
1236
1323
  return _pipeline_buffers(
1237
1324
  self.beam, plan.chunk_shape, plan.view_chunk_size, pixels, points,
1238
1325
  slots=plan.slots, retained=plan.window_size, geometry_grad=self._geometry_trainable,
1239
- peers=peers, ray_accumulators=extra_rays) + self._surface_work_buffers(
1240
- plan.view_chunk_size, pixels, plan.slots, spec, streamed=True)
1326
+ peers=peers, ray_accumulators=extra_rays) + self._surface_reserved_buffers(
1327
+ plan.view_chunk_size, pixels, plan.slots, spec, streamed=True, batched=plan.surface_batch)
1241
1328
 
1242
1329
  def _choose_schedule(self, source, sink, is_project, geometry, plan, spec):
1243
1330
  candidates = self._candidate_plans(plan, is_project, spec)
@@ -1247,7 +1334,8 @@ class Projector:
1247
1334
  tuple(device.index for device in plan.devices), str(source.dtype), plan.gpu_budget_bytes,
1248
1335
  plan.allocation_limit, plan.reservation_budget_bytes,
1249
1336
  str(getattr(source, "device", "cpu")), str(getattr(sink, "device", "cpu")),
1250
- spec.kind if spec is not None else "flat", self.schedule)
1337
+ spec.kind if spec is not None else "flat", self.schedule, plan.surface_batch,
1338
+ spec.shared if spec is not None else False)
1251
1339
  collective = self._distributed and self.world_size > 1
1252
1340
  if not collective and key in self._schedule_cache:
1253
1341
  return self._schedule_cache[key], [], True
@@ -1310,11 +1398,15 @@ class Projector:
1310
1398
  rank, frame_count = len(self.volume_shape), 4 if self.beam == "cone" else 3
1311
1399
  count = views.stop - views.start
1312
1400
  pixel_shape = tuple(selected.stop - selected.start for selected in pixels)
1401
+ # Offsets sampled here derive from the snapshot and the pixel tile;
1402
+ # offsets supplied by the caller may differ per batch and are not cached.
1403
+ cacheable = offsets is None
1313
1404
  if offsets is None:
1314
1405
  offsets = _sample_offsets(self.beam, self.detector_shape, self.detector_spacing,
1315
1406
  geometry, spec, views, pixels)
1316
1407
  origin = torch.cuda.current_stream(offsets.device) if offsets.is_cuda else None
1317
1408
  frames = geometry[:frame_count]
1409
+ frame_origins = tuple(torch.cuda.current_stream(value.device) for value in frames if value.is_cuda)
1318
1410
  common = torch.float32
1319
1411
  for value in (*frames, offsets):
1320
1412
  common = torch.promote_types(common, value.dtype)
@@ -1324,23 +1416,118 @@ class Projector:
1324
1416
  frame32 += (True,)
1325
1417
  offset32 = ((torch.promote_types(torch.float32, offsets.dtype)
1326
1418
  if spec.kind == "offsets" else common) == torch.float32)
1419
+ # Offsets shared by all views are staged once per call and composed
1420
+ # with one launch for all views instead of per view and row segment.
1421
+ shared = spec.kind == "offsets" and offsets.ndim == len(pixel_shape) + 1
1422
+ batched = shared or groups.get("_surface_batch", True)
1423
+ pixel_count = math.prod(pixel_shape)
1424
+ # One packed frame packet per view: source, center, axes, normal and
1425
+ # float32 cross-product scratch. Shared offsets pack every view of a job.
1426
+ stride = (frame_count + (3 if rank == 3 else 0)) * rank
1327
1427
  if "_surface_workspace" not in groups:
1328
- capacity = 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))
1329
1431
  groups["_surface_workspace"] = (
1330
- pipe.empty((frame_count + (3 if rank == 3 else 0)) * rank, dtype=torch.float64),
1331
- 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,
1332
1435
  )
1333
1436
  packed_frames, packed_offsets, capacity = groups["_surface_workspace"]
1334
1437
  columns = (0, 1, 2) if rank == 3 else (0, 2)
1335
1438
  source, points = targets[0].view(count, rank), targets[1].view(count, -1, rank)
1439
+ # A workspace sized for an earlier, smaller tile keeps the segment path.
1440
+ shared = shared and capacity >= pixel_count
1441
+ batched = batched and capacity >= pixel_count
1442
+ if batched and packed_frames.numel() < count * stride:
1443
+ # Earlier launches on the upload stream may still read the smaller buffer.
1444
+ pipe.retain(pipe.event(pipe.upload), packed_frames, None)
1445
+ packed_frames = pipe.empty(count * stride, dtype=torch.float64)
1446
+ if not shared:
1447
+ pipe.retain(pipe.event(pipe.upload), packed_offsets, None)
1448
+ packed_offsets = pipe.empty(rank * capacity * count + 1, dtype=torch.float64)
1449
+ groups["_surface_workspace"] = (packed_frames, packed_offsets, capacity)
1450
+ # Every job of a call slices the same offset snapshot: stage it once per
1451
+ # call, pixel tile and workspace. The key holds the snapshot and the
1452
+ # workspace, so neither a later snapshot reusing storage nor a recreated
1453
+ # workspace can match it.
1454
+ if shared and cacheable:
1455
+ staged_key = (geometry[frame_count], packed_offsets, tuple((part.start, part.stop) for part in pixels))
1456
+ restage = not ("_surface_shared_staged" in groups
1457
+ and groups["_surface_shared_staged"][0] is staged_key[0]
1458
+ and groups["_surface_shared_staged"][1] is staged_key[1]
1459
+ and groups["_surface_shared_staged"][2] == staged_key[2])
1460
+ else:
1461
+ restage = batched
1462
+ groups.pop("_surface_shared_staged", None)
1336
1463
  with torch.cuda.device(pipe.device), torch.cuda.stream(pipe.upload), cuda.gpus[pipe.device.index]:
1337
1464
  if origin is not None:
1338
1465
  pipe.upload.wait_stream(origin)
1466
+ for frame_origin in frame_origins:
1467
+ pipe.upload.wait_stream(frame_origin)
1339
1468
  packed_offsets[-1].zero_()
1340
1469
  stream = _get_numba_external_stream_for(pipe.upload)
1341
1470
  arrays = tuple(TorchCUDABridge.tensor_to_cuda_array(value)
1342
1471
  for value in (packed_frames, packed_offsets, source, points))
1343
- for local in range(count):
1472
+ if restage:
1473
+ # Preserve strided sampler outputs: copy directly into admitted
1474
+ # column storage without flattening the input into a temporary.
1475
+ offset_views = 1 if shared else count
1476
+ packed = packed_offsets[:offset_views * rank * capacity].view(offset_views, rank, capacity)
1477
+ selected_offsets = offsets.detach()
1478
+ if selected_offsets.ndim == len(pixel_shape) + 1:
1479
+ selected_offsets = selected_offsets.unsqueeze(0)
1480
+ if offsets.is_cuda:
1481
+ for column, original in enumerate(columns):
1482
+ target = packed[:, column, :pixel_count].view(offset_views, *pixel_shape)
1483
+ value = selected_offsets[..., original].expand(offset_views, *pixel_shape)
1484
+ _pipeline._copy(target, value, pipe.stats)
1485
+ pipe.retain(pipe.event(pipe.upload), value, packed_offsets)
1486
+ else:
1487
+ host = torch.empty((offset_views, rank, capacity), dtype=torch.float64)
1488
+ for column, original in enumerate(columns):
1489
+ host[:, column, :pixel_count].view(offset_views, *pixel_shape).copy_(
1490
+ selected_offsets[..., original])
1491
+ _pipeline._copy(packed, host, pipe.stats)
1492
+ pipe.retain(pipe.event(pipe.upload), host, packed_offsets)
1493
+ if shared and cacheable:
1494
+ groups["_surface_shared_staged"] = staged_key
1495
+ if batched:
1496
+ # Stage the frame packets of every view in one transfer and
1497
+ # compose all views with one launch; grid row y composes view y.
1498
+ block = packed_frames[:count * stride].view(count, stride)
1499
+ if frame_origins:
1500
+ for index, value in enumerate(frames):
1501
+ selected = value[views].detach()
1502
+ _pipeline._copy(block[:, index * rank:(index + 1) * rank], selected, pipe.stats)
1503
+ pipe.retain(pipe.event(pipe.upload), selected, packed_frames)
1504
+ else:
1505
+ packet = torch.zeros((count, stride), dtype=torch.float64)
1506
+ for index, value in enumerate(frames):
1507
+ packet[:, index * rank:(index + 1) * rank].copy_(value[views].detach())
1508
+ _pipeline._copy(block, packet, pipe.stats)
1509
+ pipe.retain(pipe.event(pipe.upload), packet, packed_frames)
1510
+ if rank == 3:
1511
+ normal = block[:, 4 * rank:5 * rank]
1512
+ axis_u, axis_v = block[:, 2 * rank:3 * rank], block[:, 3 * rank:4 * rank]
1513
+ if frame32[2] and frame32[3]:
1514
+ scratch = block.view(torch.float32)[:, 10 * rank:13 * rank]
1515
+ scratch[:, :rank].copy_(axis_u)
1516
+ scratch[:, rank:2 * rank].copy_(axis_v)
1517
+ torch.linalg.cross(scratch[:, :rank], scratch[:, rank:2 * rank], dim=1,
1518
+ out=scratch[:, 2 * rank:])
1519
+ normal.copy_(scratch[:, 2 * rank:])
1520
+ else:
1521
+ torch.linalg.cross(axis_u, axis_v, dim=1, out=normal)
1522
+ for first in range(0, count, 65535):
1523
+ rows = min(65535, count - first)
1524
+ compose_surface[((pixel_count + 127) // 128, rows), 128, stream](
1525
+ TorchCUDABridge.tensor_to_cuda_array(block[first:first + rows].reshape(-1)),
1526
+ *arrays[1:], first, 0, pixel_count, capacity, rank,
1527
+ self.beam == "parallel", frame32, offset32,
1528
+ tuple(centre) + (0.,) * (3 - rank), self.voxel_spacing,
1529
+ )
1530
+ for local in range(0 if batched else count):
1344
1531
  packet = torch.cat([value[views.start + local].detach().to(device="cpu", dtype=torch.float64)
1345
1532
  for value in frames])
1346
1533
  _pipeline._copy(packed_frames[:frame_count * rank], packet, pipe.stats)
@@ -1463,7 +1650,7 @@ class Projector:
1463
1650
  points = spec is not None or self.detector_chunk_shape is not None or pixels != self.detector_shape
1464
1651
  sizes = ((plan.view_chunk_size * rank, rays * rank) if points
1465
1652
  else (plan.view_chunk_size * rank,) * (4 if self.beam == "cone" else 3))
1466
- return {"_max_rays": rays, "_max_geometry": sizes}
1653
+ return {"_max_rays": rays, "_max_geometry": sizes, "_surface_batch": plan.surface_batch}
1467
1654
 
1468
1655
  def _prefetch_job(self, job, source, geometry, spec, transfer, groups, index, is_project,
1469
1656
  native, reduced, active_rays, next_epoch=False):
@@ -2034,7 +2221,7 @@ class Projector:
2034
2221
  spacing = self.detector_spacing if self.beam == "cone" else self.detector_spacing[0]
2035
2222
  sampled = geometry_spec is not None and geometry_spec.kind in ("sampled", "offsets")
2036
2223
  grads = [torch.zeros(component.shape, dtype=torch.float32,
2037
- device="cpu" if sampled else volume.device) for component in geometry]
2224
+ device=component.device if sampled else volume.device) for component in geometry]
2038
2225
  for device_rank, device in enumerate(devices):
2039
2226
  local_slice = _balanced_slice(local_views, device_rank, len(devices))
2040
2227
  if local_slice.start == local_slice.stop:
@@ -2139,6 +2326,7 @@ class Projector:
2139
2326
  slot.finish()
2140
2327
  pipe.reclaim_output()
2141
2328
  del local_groups["_surface_workspace"]
2329
+ local_groups.pop("_surface_shared_staged", None)
2142
2330
  reduced = surface_batch_vjp(
2143
2331
  self.beam, self.detector_shape, self.detector_spacing, inputs, offsets,
2144
2332
  global_slice, pixels, self.voxel_spacing, pipe.device, parts,
@@ -2193,7 +2381,8 @@ class Projector:
2193
2381
  self.last_execution_stats = stats
2194
2382
  return tuple(grad.to(device=component.device, dtype=component.dtype)
2195
2383
  for grad, component in zip(grads, geometry))
2196
- 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}
2197
2386
  with _pipeline._TransferPipeline(plan.devices, self._pool_elements(plan, geometry_spec), stats,
2198
2387
  slots=plan.slots) as transfer:
2199
2388
  for spatial_slice, shape, centre in self._volume_tiles(plan.chunk_shape):
@@ -2420,22 +2609,28 @@ class Projector:
2420
2609
  staged = tuple(value[owned].to(device=device, dtype=owner.dtype) for value in geometry)
2421
2610
  admission_owner = copy(owner)
2422
2611
  admission_owner.view_slice = slice(0, shard.stop - shard.start)
2423
- 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
+ ):
2424
2617
  source, points = _sample_geometry(
2425
2618
  owner.beam, owner.detector_shape, owner.detector_spacing, staged,
2426
2619
  None, views, selected, owner.voxel_spacing, device=device,
2427
2620
  dtype=owner.dtype, validate=False,
2428
2621
  )
2429
- if not torch.isfinite(source).all().item() or not torch.isfinite(points).all().item():
2430
- raise ValueError("detector surface world coordinates must be finite")
2622
+ nonfinite = nonfinite | ~torch.isfinite(source).all() | ~torch.isfinite(points).all()
2431
2623
  sources = source.reshape(source.shape[0], *([1] * len(selected)), -1)
2432
- if owner.beam != "parallel" and torch.any(torch.all(points == sources, dim=-1)).item():
2433
- 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))
2434
2626
  directions = (source if owner.beam == "parallel" else
2435
2627
  points - sources)
2436
- if not torch.isfinite(directions).all().item():
2437
- raise ValueError("detector surface world coordinates must be finite")
2628
+ nonfinite = nonfinite | ~torch.isfinite(directions).all()
2438
2629
  del source, points, sources, directions
2630
+ _raise_geometry_errors([
2631
+ (nonfinite, "detector surface world coordinates must be finite"),
2632
+ (coincident, "source and detector surface pixels must differ"),
2633
+ ])
2439
2634
  del staged
2440
2635
  except (TypeError, ValueError, RuntimeError) as failure:
2441
2636
  error = failure
@@ -2497,7 +2692,25 @@ class Projector:
2497
2692
  def _prepare_execution(self, tensor, is_project, output_store=None):
2498
2693
  device = torch.device(getattr(tensor, "device", "cpu"))
2499
2694
  if self.detector_surface is not None:
2500
- 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.
2501
2714
  plan = self._execution_plan(tensor, is_project, output_store, spec)
2502
2715
  else:
2503
2716
  plan = self._execution_plan(tensor, is_project, output_store)
@@ -2512,11 +2725,28 @@ class Projector:
2512
2725
  return None, tuple(component.clone() if plan is None or component.device.type == "cpu"
2513
2726
  else component.to(device="cpu", copy=True)
2514
2727
  for component in self._learnable)
2515
- # Callback graphs can save mutable captured parameters. Snapshot their
2516
- # values on the host, restoring their original device only when the
2517
- # 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
+
2518
2734
  def pack(tensor):
2519
- 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
2520
2750
 
2521
2751
  def unpack(saved):
2522
2752
  tensor, device = saved
@@ -2536,12 +2766,14 @@ class Projector:
2536
2766
  with torch.autograd.graph.saved_tensors_hooks(pack, unpack):
2537
2767
  offsets = self._surface_offsets()
2538
2768
  values = (*frames, offsets)
2539
- snapshots = tuple(value.clone() if value.device.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)
2540
2771
  for value in values)
2541
2772
  spec = self._surface_spec(frames, offsets)
2542
2773
  metadata = (tuple(offsets.shape), str(offsets.dtype))
2543
2774
  self._snapshot_d2h_bytes += sum(value.numel() * value.element_size()
2544
- for value in values if value.is_cuda)
2775
+ for value, snapshot in zip(values, snapshots)
2776
+ if value.is_cuda and not snapshot.is_cuda)
2545
2777
  except (TypeError, ValueError, RuntimeError) as failure:
2546
2778
  error = failure
2547
2779
  self._partition.preflight(error, metadata, cache_key="surface snapshot",
diffct/solvers.py CHANGED
@@ -96,7 +96,7 @@ def cgls(operator, measurements, iterations=30, *, x0=None, rtol=1e-6, atol=0.,
96
96
  x = _initial(operator, data, x0)
97
97
  residual = data.clone() if x0 is None else data - operator.project(x)
98
98
  norm = math.sqrt(squared_norm(operator, data, "rays"))
99
- history = [math.sqrt(squared_norm(operator, residual, "rays"))]
99
+ history = [norm if x0 is None else math.sqrt(squared_norm(operator, residual, "rays"))]
100
100
  completed = 0
101
101
  adjoint = (operator.backproject(residual) if iterations and history[0] > atol + rtol * norm
102
102
  else torch.zeros_like(x))
@@ -117,11 +117,15 @@ def cgls(operator, measurements, iterations=30, *, x0=None, rtol=1e-6, atol=0.,
117
117
  states["x"].add_(states["p"], alpha=alpha)
118
118
  states["r"].sub_(states["q"], alpha=alpha)
119
119
  states["s"] = operator.backproject(states["r"])
120
- next_gamma = squared_norm(operator, states["s"], "volume")
120
+ next_gamma = squared_norm(operator, states["s"], "volume", return_tensor=True)
121
+ residual_norm = squared_norm(operator, states["r"], "rays", return_tensor=True)
122
+ # Both reductions retain the same FP64 block/collective order. Read
123
+ # their results together; denominator breakdown is still checked first.
124
+ next_gamma, residual_norm = torch.stack((next_gamma, residual_norm.to(next_gamma.device))).cpu().tolist()
121
125
  states["p"].mul_(next_gamma / gamma).add_(states["s"])
122
126
  gamma = next_gamma
123
127
  completed += 1
124
- history.append(math.sqrt(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,12 +4,15 @@ from dataclasses import dataclass
4
4
 
5
5
  import torch
6
6
 
7
+ from .utils import _raise_geometry_errors
7
8
 
8
9
  @dataclass(frozen=True)
9
10
  class _GeometrySpec:
10
11
  kind: str
11
12
  sampler: object = None
12
13
  dtype: object = None
14
+ # One (U, V, 3) host offset snapshot shared by all views.
15
+ shared: bool = False
13
16
 
14
17
 
15
18
  class ParameterizedSurface:
@@ -39,7 +42,8 @@ class ParameterizedSurface:
39
42
 
40
43
 
41
44
  def _detector_grids(detector_shape, spacing, pixels):
42
- axes = [(torch.arange(selected.start, selected.stop, 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
43
47
  for selected, size, pitch in zip(pixels, detector_shape, spacing)]
44
48
  if len(axes) == 2:
45
49
  return torch.meshgrid(*axes, indexing="ij")
@@ -104,9 +108,7 @@ def _sample_geometry(beam, detector_shape, spacing, geometry, spec, views, pixel
104
108
  depth = offsets[..., 2].to(device=device, dtype=offset_dtype)
105
109
  if offsets.ndim == dimensions + 1:
106
110
  tangent, depth = tangent.unsqueeze(0), depth.unsqueeze(0)
107
- if validate:
108
- if not torch.isfinite(offsets).all().item():
109
- raise ValueError("detector surface coordinates must be finite")
111
+ checks = [(~torch.isfinite(offsets).all(), "detector surface coordinates must be finite")] if validate else []
110
112
 
111
113
  def frame(component):
112
114
  selected = component if local_geometry else component[views]
@@ -130,17 +132,18 @@ def _sample_geometry(beam, detector_shape, spacing, geometry, spec, views, pixel
130
132
  source = (frames[0] if local_geometry else frames[0][views]).to(device=device, dtype=source_dtype)
131
133
  if validate:
132
134
  for component in (source, points):
133
- if not torch.isfinite(component).all().item() or not torch.isfinite(component.float()).all().item():
134
- 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"))
135
137
  if beam not in ("parallel", "parallel3d") and view_count:
136
138
  sources = source.unsqueeze(1)
137
139
  endpoints = points.reshape(view_count, -1, source.shape[-1])
138
- if torch.any(torch.all(sources.float() == endpoints.float(), dim=-1)).item():
139
- 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"))
140
142
  source_distance = torch.linalg.vector_norm(sources.double(), dim=-1) / voxel_spacing
141
143
  pixel_distance = torch.linalg.vector_norm(endpoints.double(), dim=-1) / voxel_spacing
142
- if torch.any(torch.minimum(source_distance, pixel_distance) > 1e6).item():
143
- raise ValueError("surface source or pixel must lie within 1e6 voxels")
144
- if torch.any(source_distance > 1e15).item() or torch.any(pixel_distance > 1e15).item():
145
- 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)
146
149
  return source, points
diffct/utils.py CHANGED
@@ -18,6 +18,16 @@ import torch
18
18
  from .constants import _DTYPE, _TPB_2D, _TPB_3D, _INF, _EPSILON
19
19
 
20
20
 
21
+ def _raise_geometry_errors(checks):
22
+ """Read one validity scalar; fetch individual error flags only on failure."""
23
+ device = next((flag.device for flag, _ in checks if flag.is_cuda), checks[0][0].device)
24
+ failed = torch.stack([flag.to(device=device) for flag, _ in checks])
25
+ if failed.any().item():
26
+ for invalid, (_, message) in zip(failed.tolist(), checks):
27
+ if invalid:
28
+ raise ValueError(message)
29
+
30
+
21
31
  # ============================================================================
22
32
  # Device Management Utilities
23
33
  # ============================================================================
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diffct
3
- Version: 2.4.1
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=yYLUQ2lVM28RH2J4YlSafnN7-QCgrf9CgtKrzdu4E5Y,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=krbRZZ9vb6nuOkQHHfxEimdKlWPvv3XOyP4FnfiQxac,152188
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=gFk-cRCec8PtWYoWS3iNlf5yBxsaR_0pWgZV9-rUSFs,7848
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=vPNzHtiuz-tvyK_lYH0JlXG8btRjEJlmTDUdB0WM550,5264
29
- diffct-2.4.1.dist-info/METADATA,sha256=6JD-c14bkSl9hu8pekWYMKsziuAP3uSP-TXx3v03SMI,17661
30
- diffct-2.4.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
31
- diffct-2.4.1.dist-info/licenses/LICENSE,sha256=a1fjzGs44LpDnV_LmA2Tzp8z-eJKoNVVdLOnh-F6J6E,11344
32
- diffct-2.4.1.dist-info/licenses/NOTICE,sha256=du9I2sDF5IDfiY9vF64WWzMJh-MarpVXyLfWQmhbvmI,714
33
- diffct-2.4.1.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