diffct 2.4.4__py3-none-any.whl → 2.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
diffct/__init__.py CHANGED
@@ -64,7 +64,7 @@ try:
64
64
  except ImportError:
65
65
  pass
66
66
 
67
- __version__ = '2.4.4'
67
+ __version__ = '2.5.0'
68
68
 
69
69
  __all__ = [
70
70
  'Projector',
diffct/_solver_state.py CHANGED
@@ -12,6 +12,7 @@ import torch.distributed as dist
12
12
  from .pipeline import _fragments
13
13
  from .storage import NpyStore, TensorStore
14
14
  from .distribution import control_status, preflight
15
+ from .utils import _work_dtype
15
16
 
16
17
 
17
18
  def count(value, name, positive=False):
@@ -65,20 +66,46 @@ def scalar_sum(operator, value, kind, *, return_tensor=False):
65
66
  dist.all_reduce(accumulator, group=group)
66
67
  return accumulator if return_tensor else accumulator.item()
67
68
  if return_tensor:
68
- return torch.as_tensor(value, dtype=torch.float64, device=result_device)
69
+ return torch.as_tensor(value, dtype=_work_dtype(result_device), device=result_device)
69
70
  return float(value)
70
71
 
71
72
 
72
73
  def squared_norm(operator, value, kind, elements=1048576, *, return_tensor=False):
73
74
  store = TensorStore(value) if isinstance(value, torch.Tensor) else value
74
- total = torch.zeros((), dtype=torch.float64, device=getattr(store, "device", "cpu"))
75
+ # Scale every block by its own peak before squaring and track the
76
+ # running maximum on the device: unscaled FP32 squares overflow for
77
+ # large measurements and underflow for tiny geometries.
78
+ device = getattr(store, "device", torch.device("cpu"))
79
+ if _work_dtype(device) == torch.float64:
80
+ total = torch.zeros((), dtype=torch.float64, device=device)
81
+ if owns_values(operator, kind):
82
+ for selected in _fragments(tuple(store.shape), elements):
83
+ # Clone even an FP64 view: norms must never alter caller measurements.
84
+ block = store.read(selected).to(dtype=torch.float64).clone()
85
+ # Preserve the block reductions and their sequential FP64 order.
86
+ total.add_(block.square_().sum().to(device=total.device))
87
+ return scalar_sum(operator, total, kind, return_tensor=return_tensor)
88
+ # FP32 devices (MPS): squares of large measurements overflow and tiny
89
+ # geometries underflow, so every block is rescaled by its peak and the
90
+ # running sum follows the largest peak seen. All block math stays on the
91
+ # device; the two closing scalar reads fit the per-iteration reduction
92
+ # budget, and their product leaves FP32 before it can overflow.
93
+ peak = torch.zeros((), device=device, dtype=torch.float32)
94
+ total = torch.zeros((), device=device, dtype=torch.float32)
75
95
  if owns_values(operator, kind):
76
96
  for selected in _fragments(tuple(store.shape), elements):
77
- # Clone even an FP64 view: norms must never alter caller measurements.
78
- block = store.read(selected).to(dtype=torch.float64).clone()
79
- # Preserve the block reductions and their sequential FP64 order.
80
- total.add_(block.square_().sum().to(device=total.device))
81
- return scalar_sum(operator, total, kind, return_tensor=return_tensor)
97
+ block = store.read(selected)
98
+ if not block.numel():
99
+ continue
100
+ block_peak = block.detach().abs().amax()
101
+ safe = torch.where(block_peak > 0, block_peak, torch.ones_like(block_peak))
102
+ combined = torch.maximum(peak, block_peak)
103
+ guard = torch.where(combined > 0, combined, torch.ones_like(combined))
104
+ total = (total * (peak / guard).square()
105
+ + (block.detach() / safe).square().sum() * (block_peak / guard).square())
106
+ peak = combined
107
+ return scalar_sum(operator, peak.item() ** 2 * total.item(), kind,
108
+ return_tensor=return_tensor)
82
109
 
83
110
 
84
111
  def copy_blocks(source, target, elements):
@@ -160,6 +160,8 @@ class AnalyticalExecution:
160
160
  raise TypeError("sinogram store must have dtype float32 or float64")
161
161
  self.source = source
162
162
  self.output_device = torch.device(getattr(source, "device", "cpu"))
163
+ if self.dtype == torch.float64 and self.output_device.type == "mps":
164
+ raise ValueError("precision must be 'fast' or 'float32' on MPS; MPS provides no float64")
163
165
  self.detector_shape = tuple(source.shape[1:])
164
166
  for component in trajectory:
165
167
  _fixed(component, "trajectory")
diffct/backends.py CHANGED
@@ -12,6 +12,7 @@ import torch.distributed as dist
12
12
  from torch.autograd import forward_ad
13
13
 
14
14
  from .chunking import _memory_lower_bounds
15
+ from .utils import _work_dtype
15
16
  from .distribution import control_status
16
17
  from .surfaces import ParameterizedSurface
17
18
  from .constants import _native_spacing
@@ -76,6 +77,16 @@ def _zero_dependency(value):
76
77
  return value.unsqueeze(0)[:0].sum()
77
78
 
78
79
 
80
+ def _constant_like(reference, values):
81
+ """Build a constant tensor on ``reference``'s device and dtype.
82
+
83
+ ``new_tensor`` has no kernel behind a functorch dual on MPS and raises
84
+ "DispatchKey Undefined doesn't correspond to a device"; an explicit
85
+ constructor keeps the same constant on every device and transform.
86
+ """
87
+ return torch.tensor(values, dtype=reference.dtype, device=reference.device)
88
+
89
+
79
90
  def voxel_spacing_tuple(spacing, rank):
80
91
  """Return positive physical pitches in tensor axis order."""
81
92
  values = spacing if isinstance(spacing, (tuple, list)) else (spacing,) * rank
@@ -563,7 +574,14 @@ class ReferenceProjector:
563
574
  with torch.no_grad():
564
575
  # Reuse frame transfers across detector chunks. Offset batches
565
576
  # remain bounded and move only when a ray batch needs them.
566
- staged = {device: (*tuple(value.to(device=device) for value in geometry[:frame_count]),
577
+ def _stage(value, target_device):
578
+ # FP64 frames must not follow FP64-less compute devices.
579
+ target_dtype = _work_dtype(target_device)
580
+ if value.dtype.itemsize > target_dtype.itemsize:
581
+ return value.to(device=target_device, dtype=target_dtype)
582
+ return value.to(device=target_device)
583
+
584
+ staged = {device: (*tuple(_stage(value, device) for value in geometry[:frame_count]),
567
585
  *parameters) for device in set(devices)}
568
586
  batches = (self._admission_batches(staged[devices[0]], plan) if admission
569
587
  else self._batches(*plan[1:]))
@@ -630,7 +648,7 @@ class ReferenceProjector:
630
648
  def compose(validate, stats):
631
649
  check_range = (validate and owner.beam not in ("parallel", "parallel3d")
632
650
  and owner.dtype == torch.float32 and surface is not None)
633
- dtype = torch.float64 if check_range else owner.dtype
651
+ dtype = _work_dtype(device) if check_range else owner.dtype
634
652
  frames = (owner._learnable or owner._trajectory) if geometry is None else geometry[:frame_count]
635
653
  native_surface = owner.backend == "numba" and surface is not None
636
654
  native_legacy = native_surface and not isinstance(surface, ParameterizedSurface)
@@ -666,7 +684,7 @@ class ReferenceProjector:
666
684
  if check_range and not native_surface:
667
685
  # Certify ordinary batches without expanding raw FP64 world points.
668
686
  # L1 norms bound Euclidean distances without squaring tiny bases.
669
- pitches = local.new_tensor(self.spacing[::-1])
687
+ pitches = _constant_like(local, self.spacing[::-1])
670
688
  maximum = local.abs().amax(dim=tuple(range(1, local.ndim - 1)))
671
689
  axis_u = selected[2]
672
690
  if owner.beam == "cone":
@@ -698,7 +716,7 @@ class ReferenceProjector:
698
716
  if check_range:
699
717
  # Raw FP64 coordinates preserve the exact admission boundaries.
700
718
  sources = frame(selected[0]).expand_as(points)
701
- pitches = points.new_tensor(self.spacing[::-1])
719
+ pitches = _constant_like(points, self.spacing[::-1])
702
720
  source_distance = torch.linalg.vector_norm(sources / pitches, dim=-1)
703
721
  pixel_distance = torch.linalg.vector_norm(points / pitches, dim=-1)
704
722
  if torch.any(torch.minimum(source_distance, pixel_distance) > 1e6).item():
@@ -725,7 +743,7 @@ class ReferenceProjector:
725
743
  origins, directions = points, frame(selected[0]).expand_as(points)
726
744
  else:
727
745
  sources = frame(selected[0]).expand_as(points)
728
- pitches = points.new_tensor(self.spacing[::-1])
746
+ pitches = _constant_like(points, self.spacing[::-1])
729
747
  source_distance = torch.linalg.vector_norm(sources / pitches, dim=-1)
730
748
  pixel_distance = torch.linalg.vector_norm(points / pitches, dim=-1)
731
749
  if validate:
@@ -764,14 +782,14 @@ class ReferenceProjector:
764
782
  # Global FP32 segment parameters lose voxel widths when both endpoints
765
783
  # are distant. Plane tables below use local physical distances and the
766
784
  # chosen precision, including for native data-transform dispatch.
767
- origins = origins.to(dtype=torch.float64)
768
- directions = directions.to(dtype=torch.float64)
785
+ origins = origins.to(dtype=_work_dtype(origins.device))
786
+ directions = directions.to(dtype=_work_dtype(directions.device))
769
787
  ray_length = torch.linalg.vector_norm(directions, dim=-1)
770
788
  directions = directions / ray_length[:, None]
771
- lower = origins.new_tensor([(part.start - size / 2) * pitch
772
- for part, size, pitch in zip(world_tile, shape, spacing)])
773
- upper = origins.new_tensor([(part.stop - size / 2) * pitch
774
- for part, size, pitch in zip(world_tile, shape, spacing)])
789
+ lower = _constant_like(origins, [(part.start - size / 2) * pitch
790
+ for part, size, pitch in zip(world_tile, shape, spacing)])
791
+ upper = _constant_like(origins, [(part.stop - size / 2) * pitch
792
+ for part, size, pitch in zip(world_tile, shape, spacing)])
775
793
  constant = directions == 0
776
794
  safe_direction = torch.where(constant, torch.ones_like(directions), directions)
777
795
  first, last = (lower - origins) / safe_direction, (upper - origins) / safe_direction
@@ -1093,7 +1111,17 @@ class ReferenceProjector:
1093
1111
  # The zero dependency keeps unused inputs connected. With several
1094
1112
  # devices, a pair's loss and the geometry leaves can be on different
1095
1113
  # CUDA devices, so each zero moves to the loss's device.
1096
- loss = loss + sum(_zero_dependency(value).to(loss.device) for value in inputs)
1114
+ def _zero_to(value, device):
1115
+ zero = _zero_dependency(value)
1116
+ target_dtype = _work_dtype(device)
1117
+ if zero.dtype.itemsize > target_dtype.itemsize:
1118
+ # Cast on the source device first: a fused device and dtype
1119
+ # copy routes the float64 gradient back through the
1120
+ # FP64-less device on the way out.
1121
+ return zero.to(dtype=target_dtype).to(device=device)
1122
+ return zero.to(device=device)
1123
+
1124
+ loss = loss + sum(_zero_to(value, loss.device) for value in inputs)
1097
1125
  for total, part in zip(totals, torch.autograd.grad(loss, inputs)):
1098
1126
  total.add_(part)
1099
1127
 
diffct/geometry.py CHANGED
@@ -5,6 +5,7 @@ and custom trajectories for parallel, fan and cone beams, plus rigid transforms.
5
5
  """
6
6
 
7
7
  import math
8
+ import operator
8
9
  import torch
9
10
  from typing import Callable, Tuple
10
11
 
@@ -40,10 +41,20 @@ def transform_trajectory(trajectory, rotation=None, translation=None, *, beam='c
40
41
  if rotation is not None:
41
42
  if tuple(rotation.shape) not in ((dimensions, dimensions), (views, dimensions, dimensions)):
42
43
  raise ValueError("rotation must have shape (d, d) or (views, d, d)")
43
- matrix = rotation.detach().double()
44
+ # MPS provides no float64; the formal harness also loads this module
45
+ # standalone, so the cast stays inline instead of importing helpers.
46
+ matrix = rotation.detach().to(dtype=torch.float32 if rotation.device.type == "mps"
47
+ else torch.float64)
44
48
  identity = torch.eye(dimensions, device=matrix.device, dtype=matrix.dtype).expand_as(matrix)
49
+ # Small explicit determinants: linalg.det routes through LU kernels
50
+ # that MPS does not provide.
51
+ if dimensions == 2:
52
+ determinant = (matrix[..., 0, 0] * matrix[..., 1, 1]
53
+ - matrix[..., 0, 1] * matrix[..., 1, 0])
54
+ else:
55
+ determinant = (matrix[..., 0] * torch.linalg.cross(matrix[..., 1], matrix[..., 2], dim=-1)).sum(-1)
45
56
  if (not torch.allclose(matrix.transpose(-1, -2) @ matrix, identity, rtol=1e-4, atol=1e-4)
46
- or not torch.allclose(torch.linalg.det(matrix), matrix.new_ones(matrix.shape[:-2]),
57
+ or not torch.allclose(determinant, matrix.new_ones(matrix.shape[:-2]),
47
58
  rtol=1e-4, atol=1e-4)):
48
59
  raise ValueError("rotation must be orthogonal and proper (determinant +1)")
49
60
  if translation is not None and tuple(translation.shape) not in ((dimensions,), (views, dimensions)):
@@ -84,22 +95,50 @@ def _circular_angles(n_views, start_angle, end_angle, device, dtype):
84
95
  if not torch.isfinite(n_views).all().item():
85
96
  raise ValueError("angles must be finite")
86
97
  return n_views, n_views.numel(), n_views.device, n_views.dtype
98
+ _check_n_views(n_views)
87
99
  step = (end_angle - start_angle) / n_views
88
100
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
89
101
  return angles, n_views, device, dtype
90
102
 
91
103
 
92
- def _check_source_positions(src_pos):
104
+ def _check_source_positions(src_pos, what="source_path_fn"):
93
105
  """Reject non-finite sources and sources at the isocenter, which have no viewing direction."""
94
106
  if not torch.isfinite(src_pos).all():
95
- raise ValueError("source_path_fn returned non-finite source positions")
107
+ raise ValueError(f"{what} returned non-finite source positions")
96
108
  if (torch.linalg.vector_norm(src_pos.detach(), dim=1) == 0).any():
97
109
  raise ValueError(
98
- "source_path_fn returned a source at the isocenter; every source needs a "
110
+ f"{what} returned a source at the isocenter; every source needs a "
99
111
  "nonzero distance from the origin"
100
112
  )
101
113
 
102
114
 
115
+ def _check_n_views(n_views):
116
+ """Reject view counts that are not positive integers.
117
+
118
+ A float count would space ``arange(ceil(n))`` views by ``span / n``, and a
119
+ nonpositive count builds an empty trajectory or divides by zero.
120
+ """
121
+ try:
122
+ count = operator.index(n_views)
123
+ except TypeError:
124
+ count = None
125
+ if count is None or isinstance(n_views, bool):
126
+ raise TypeError(f"n_views must be a positive integer, got {n_views!r}")
127
+ if count < 1:
128
+ raise ValueError(f"n_views must be a positive integer, got {n_views!r}")
129
+
130
+
131
+ def _check_ray_directions(ray_dir):
132
+ """Reject non-finite and zero ray directions, which normalize to NaN."""
133
+ if not torch.isfinite(ray_dir).all():
134
+ raise ValueError("ray_dir_fn returned non-finite ray directions")
135
+ if (torch.linalg.vector_norm(ray_dir.detach(), dim=1) == 0).any():
136
+ raise ValueError(
137
+ "ray_dir_fn returned a zero ray direction; every view needs a "
138
+ "nonzero direction vector"
139
+ )
140
+
141
+
103
142
  def circular_trajectory_3d(n_views, sid, sdd, start_angle=0.0, end_angle=None, device='cuda', dtype=torch.float32):
104
143
  """Generate circular trajectory geometry for cone-beam CT.
105
144
 
@@ -286,6 +325,8 @@ def random_trajectory_3d(n_views, sid_mean, sdd_mean, sid_std=0.0, pos_std=0.0,
286
325
  det_u_vec = det_u_vec / torch.norm(det_u_vec, dim=1, keepdim=True)
287
326
  det_v_vec = det_v_vec / torch.norm(det_v_vec, dim=1, keepdim=True)
288
327
 
328
+ _check_source_positions(src_pos, "random_trajectory_3d")
329
+
289
330
  return src_pos, det_center, det_u_vec, det_v_vec
290
331
 
291
332
 
@@ -334,6 +375,7 @@ def spiral_trajectory_3d(n_views, sid, sdd, z_range=100.0, n_turns=2.0,
334
375
  """
335
376
  # Generate angles for spiral motion
336
377
  end_angle = start_angle + 2 * math.pi * n_turns
378
+ _check_n_views(n_views)
337
379
  step = (end_angle - start_angle) / n_views
338
380
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
339
381
 
@@ -419,6 +461,7 @@ def sinusoidal_trajectory_3d(n_views, sid, sdd, amplitude=50.0, frequency=2.0,
419
461
  """
420
462
  # Generate angles
421
463
  end_angle = start_angle + 2 * math.pi
464
+ _check_n_views(n_views)
422
465
  step = (end_angle - start_angle) / n_views
423
466
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
424
467
 
@@ -505,6 +548,7 @@ def saddle_trajectory_3d(n_views, sid, sdd, z_amplitude=50.0, radial_amplitude=3
505
548
  """
506
549
  # Generate angles
507
550
  end_angle = start_angle + 2 * math.pi
551
+ _check_n_views(n_views)
508
552
  step = (end_angle - start_angle) / n_views
509
553
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
510
554
 
@@ -601,6 +645,7 @@ def custom_trajectory_3d(n_views, sid, sdd,
601
645
  """
602
646
  # Generate angles
603
647
  end_angle = start_angle + 2 * math.pi
648
+ _check_n_views(n_views)
604
649
  step = (end_angle - start_angle) / n_views
605
650
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
606
651
 
@@ -746,6 +791,7 @@ def sinusoidal_trajectory_2d_fan(n_views, sid, sdd, amplitude=50.0, frequency=2.
746
791
  """
747
792
  # Generate angles
748
793
  end_angle = start_angle + 2 * math.pi
794
+ _check_n_views(n_views)
749
795
  step = (end_angle - start_angle) / n_views
750
796
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
751
797
 
@@ -829,6 +875,7 @@ def custom_trajectory_2d_fan(n_views, sid, sdd,
829
875
  """
830
876
  # Generate angles
831
877
  end_angle = start_angle + 2 * math.pi
878
+ _check_n_views(n_views)
832
879
  step = (end_angle - start_angle) / n_views
833
880
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
834
881
 
@@ -973,6 +1020,7 @@ def sinusoidal_trajectory_2d_parallel(n_views, amplitude=50.0, frequency=2.0,
973
1020
  """
974
1021
  # Generate angles
975
1022
  end_angle = start_angle + math.pi
1023
+ _check_n_views(n_views)
976
1024
  step = (end_angle - start_angle) / n_views
977
1025
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
978
1026
 
@@ -1055,6 +1103,7 @@ def custom_trajectory_2d_parallel(n_views,
1055
1103
  """
1056
1104
  # Generate angles
1057
1105
  end_angle = start_angle + math.pi
1106
+ _check_n_views(n_views)
1058
1107
  step = (end_angle - start_angle) / n_views
1059
1108
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
1060
1109
 
@@ -1069,6 +1118,9 @@ def custom_trajectory_2d_parallel(n_views,
1069
1118
  raise ValueError(f"det_origin_fn must return tensor of shape ({n_views}, 2), "
1070
1119
  f"got {det_origin.shape}")
1071
1120
 
1121
+ # Reject degenerate directions before normalization turns them into NaN
1122
+ _check_ray_directions(ray_dir)
1123
+
1072
1124
  # Normalize ray directions
1073
1125
  ray_dir = ray_dir / torch.norm(ray_dir, dim=1, keepdim=True)
1074
1126
 
diffct/operators.py CHANGED
@@ -9,6 +9,7 @@ import warnings
9
9
 
10
10
  import torch
11
11
 
12
+ from .constants import _native_spacing
12
13
  from .chunking import (_ChunkPlan, _allocation_bytes, _host_geometry_bytes, _largest_buffer_bytes, _memory_budget,
13
14
  _memory_lower_bounds, _pipeline_buffers, _pipeline_bytes, _pixel_tiles,
14
15
  _reservation_budget, _reservation_bytes, _tiles, _working_set_bytes)
@@ -17,11 +18,10 @@ from .storage import TensorStore
17
18
  from .streaming import _validate_stores
18
19
  from .surfaces import ParameterizedSurface, _GeometrySpec, _sample_geometry, _sample_offsets
19
20
  from .surface_gradients import surface_batch_vjp
20
- from .utils import TorchCUDABridge, _get_numba_external_stream_for, _raise_geometry_errors
21
+ from .utils import TorchCUDABridge, _cast_work, _get_numba_external_stream_for, _raise_geometry_errors, _work_dtype
21
22
  from .backends import (_GEOMETRY_FORWARD_DERIVATIVE, ReferenceProjector, _flat_admission_batches, _forward_tangent,
22
23
  select_backend, voxel_spacing_tuple)
23
24
  from .distribution import Partition
24
- from .constants import _native_spacing
25
25
  from .projectors import (
26
26
  _backproject_into,
27
27
  _geometry_vjp,
@@ -126,28 +126,36 @@ def _validate_trajectory(trajectory, beam, *, detector_surface=False, clone=True
126
126
  dots = torch.sum(first.to(device) * second.to(device), dim=1)
127
127
  checks.append((torch.any(torch.abs(dots) > 1e-4), "trajectory direction axes must be orthogonal"))
128
128
  if beam == "parallel3d":
129
- direction = geometry[0].to(device=device, dtype=torch.float64)
130
- frame = torch.stack((direction, geometry[2].to(direction.device, torch.float64),
131
- geometry[3].to(direction.device, torch.float64)), dim=-1)
129
+ direction = _cast_work(geometry[0], device)
130
+ frame = torch.stack((direction, _cast_work(geometry[2], direction.device),
131
+ _cast_work(geometry[3], direction.device)), dim=-1)
132
132
  # Numerical rank rejects a tangent frame at machine roundoff without
133
133
  # imposing an application-level minimum angle of incidence.
134
134
  # Bad entries already have a finite-value error above; keep the rank
135
135
  # solver well-defined until the combined error flags reach the host.
136
136
  frame = torch.where(torch.isfinite(frame), frame, 0.)
137
- checks.append((torch.any(torch.linalg.matrix_rank(frame) < 3),
138
- "parallel3d rays must not be tangent to the detector plane"))
137
+ if frame.dtype == torch.float64:
138
+ checks.append((torch.any(torch.linalg.matrix_rank(frame) < 3),
139
+ "parallel3d rays must not be tangent to the detector plane"))
140
+ else:
141
+ # |det| via the triple product: matrix_rank has no MPS kernel. Its
142
+ # float32 threshold sits far above the FP64 rank tolerance, so it
143
+ # rejects a wider band of near-tangent frames than the FP64 check.
144
+ determinant = torch.sum(frame[..., 0] * torch.linalg.cross(frame[..., 1], frame[..., 2], dim=-1), dim=-1)
145
+ scale = torch.linalg.vector_norm(frame, dim=-2).prod(dim=-1)
146
+ checks.append((torch.any(determinant.abs() <= torch.finfo(determinant.dtype).eps * scale),
147
+ "parallel3d rays must not be tangent to the detector plane"))
139
148
  if beam in ("fan", "cone") and not detector_surface:
140
- # float64 avoids overflow of half-precision input and dtype mismatches in cross().
141
- source = geometry[0].to(device=device, dtype=torch.float64)
142
- principal = geometry[1].to(source.device, torch.float64) - source
149
+ source = _cast_work(geometry[0], device)
150
+ principal = _cast_work(geometry[1], source.device) - source
143
151
  lengths = torch.linalg.vector_norm(principal, dim=1)
144
152
  checks.append((torch.any(lengths == 0), "source and detector center must differ in every view"))
145
153
  principal = principal / lengths[:, None]
146
- det_u = geometry[2].to(source.device, torch.float64)
154
+ det_u = _cast_work(geometry[2], source.device)
147
155
  if beam == "fan":
148
156
  facing = principal[:, 0] * det_u[:, 1] - principal[:, 1] * det_u[:, 0]
149
157
  else:
150
- normal = torch.linalg.cross(det_u, geometry[3].to(source.device, torch.float64), dim=1)
158
+ normal = torch.linalg.cross(det_u, _cast_work(geometry[3], source.device), dim=1)
151
159
  facing = torch.sum(principal * normal, dim=1)
152
160
  checks.append((torch.any(torch.abs(facing) < 1e-4),
153
161
  "the detector must not be edge-on to the source in any view"))
@@ -442,6 +450,7 @@ class Projector:
442
450
  self.native_volume_layout = bool(native_volume_layout)
443
451
  self.precision = precision
444
452
  self.dtype = torch.float64 if precision == "float64" else torch.float32
453
+ self._validate_precision(trajectory)
445
454
  self._reference = None
446
455
  self.partition = partition
447
456
  if not isinstance(partition, str) or partition not in ("views", "space", "hybrid"):
@@ -571,6 +580,12 @@ class Projector:
571
580
  UserWarning, stacklevel=2,
572
581
  )
573
582
 
583
+ def _validate_precision(self, values):
584
+ if self.dtype == torch.float64 and any(
585
+ torch.device(getattr(value, "device", "cpu")).type == "mps"
586
+ for value in values):
587
+ raise ValueError("precision must be 'fast' or 'float32' on MPS; MPS provides no float64")
588
+
574
589
  def _validate_geometry_range(self):
575
590
  if self.dtype == torch.float32 and self.beam in ("fan", "cone") and self.detector_surface is None:
576
591
  # Both FP32 backends set up each segment from the endpoint nearer
@@ -578,10 +593,11 @@ class Projector:
578
593
  spacing = voxel_spacing_tuple(self.voxel_spacing, len(self.global_volume_shape))
579
594
  device = next((value.device for value in self._trajectory[:2] if value.is_cuda),
580
595
  self._trajectory[0].device)
581
- pitches = torch.tensor(spacing[::-1], device=device, dtype=torch.float64)
596
+ work = _work_dtype(device)
597
+ pitches = torch.tensor(spacing[::-1], device=device, dtype=work)
582
598
  distances = torch.stack([
583
599
  torch.linalg.vector_norm(
584
- component.to(device=device, dtype=torch.float64) / pitches,
600
+ component.to(device=device, dtype=work) / pitches,
585
601
  dim=1,
586
602
  )
587
603
  for component in self._trajectory[:2]
@@ -2602,8 +2618,10 @@ class Projector:
2602
2618
  override = trajectory is not None
2603
2619
  if trajectory is None and self._learnable:
2604
2620
  trajectory = self._learnable
2605
- if trajectory is not None:
2606
- try:
2621
+ try:
2622
+ self._validate_precision(args)
2623
+ if trajectory is not None:
2624
+ self._validate_precision(trajectory)
2607
2625
  _reject_trajectory_tangents(trajectory)
2608
2626
  frames, views = _validate_trajectory(
2609
2627
  trajectory, self.beam, detector_surface=self.detector_surface is not None,
@@ -2686,8 +2704,8 @@ class Projector:
2686
2704
  (coincident, "source and detector surface pixels must differ"),
2687
2705
  ])
2688
2706
  del staged
2689
- except (TypeError, ValueError, RuntimeError) as failure:
2690
- error = failure
2707
+ except (TypeError, ValueError, RuntimeError) as failure:
2708
+ error = failure
2691
2709
  # None must participate too: mixed override/default calls or gradient
2692
2710
  # layouts cannot skip a collective on only one rank.
2693
2711
  # Metadata transport needs ordinary storage-backed tensors, even when
diffct/reconstruction.py CHANGED
@@ -350,8 +350,20 @@ def helical_view_weights(angles, source_z, slice_z):
350
350
  if not torch.is_floating_point(angles) or angles.ndim != 1 or len(angles) < 2:
351
351
  raise ValueError("angles must be a real vector with at least two views")
352
352
  output_dtype = angles.dtype
353
- source_z = torch.as_tensor(source_z, device=angles.device, dtype=torch.float64)
354
- slice_z = torch.as_tensor(slice_z, device=angles.device, dtype=torch.float64)
353
+ output_device = angles.device
354
+ if output_device.type == "mps":
355
+ # MPS has no float64: the scheduling math runs on the host. List
356
+ # inputs are created directly as float64 — converting through the
357
+ # default dtype loses precision for large coordinates, and a fused
358
+ # MPS->CPU float64 cast returns zeros on torch 2.14.
359
+ angles = angles.to(device="cpu").to(dtype=torch.float64)
360
+ source_z = (source_z.to(device="cpu") if isinstance(source_z, torch.Tensor)
361
+ else torch.as_tensor(source_z, dtype=torch.float64, device="cpu")).to(dtype=torch.float64)
362
+ slice_z = (slice_z.to(device="cpu") if isinstance(slice_z, torch.Tensor)
363
+ else torch.as_tensor(slice_z, dtype=torch.float64, device="cpu")).to(dtype=torch.float64)
364
+ else:
365
+ source_z = torch.as_tensor(source_z, device=output_device, dtype=torch.float64)
366
+ slice_z = torch.as_tensor(slice_z, device=output_device, dtype=torch.float64)
355
367
  if (source_z.shape != angles.shape or slice_z.ndim != 1 or len(slice_z) == 0
356
368
  or not all(torch.isfinite(t).all() for t in (angles, source_z, slice_z))):
357
369
  raise ValueError("angles/source_z must match and slice_z must be a finite vector")
@@ -385,7 +397,7 @@ def helical_view_weights(angles, source_z, slice_z):
385
397
  - torch.maximum(edges[:-1][None, :], lower[:, None])).clamp_min(0) * 0.5
386
398
  weights = torch.empty_like(overlap)
387
399
  weights[:, order] = overlap
388
- return weights.to(output_dtype)
400
+ return weights.to(device=output_device, dtype=output_dtype)
389
401
 
390
402
 
391
403
  def _axial_helix(geometry, isocenter):
diffct/streaming.py CHANGED
@@ -96,6 +96,13 @@ def _tensor_internal_overlap(tensor):
96
96
  def _tensor_overlap(first, second):
97
97
  if first.device != second.device:
98
98
  return False
99
+ if first.device.type == "mps":
100
+ # MPS tensors are allocator-backed and cannot wrap external memory,
101
+ # so distinct storages are distinct allocations. Cross-allocation
102
+ # data_ptr differences are not meaningful on MPS, so only views of
103
+ # one storage reach the stride solver, where offsets stay valid.
104
+ if first.untyped_storage() is not second.untyped_storage():
105
+ return False
99
106
  a, b = _tensor_span(first), _tensor_span(second)
100
107
  if a is None or b is None or max(a[0], b[0]) >= min(a[1], b[1]):
101
108
  return False
diffct/surfaces.py CHANGED
@@ -4,7 +4,7 @@ from dataclasses import dataclass
4
4
 
5
5
  import torch
6
6
 
7
- from .utils import _raise_geometry_errors
7
+ from .utils import _raise_geometry_errors, _work_dtype
8
8
 
9
9
  @dataclass(frozen=True)
10
10
  class _GeometrySpec:
@@ -96,8 +96,11 @@ def _sample_geometry(beam, detector_shape, spacing, geometry, spec, views, pixel
96
96
  dtype = torch.float32
97
97
  for component in (*frames, offsets):
98
98
  dtype = torch.promote_types(dtype, component.dtype)
99
- elif dtype not in (torch.float32, torch.float64):
99
+ if dtype not in (torch.float32, torch.float64):
100
100
  raise ValueError("surface geometry dtype must be float32 or float64")
101
+ if dtype.itemsize > _work_dtype(device).itemsize:
102
+ # CPU-side FP64 geometry must not follow FP64-less compute devices.
103
+ dtype = _work_dtype(device)
101
104
  offset_dtype = offsets.dtype if native_legacy else dtype
102
105
  if beam in ("cone", "parallel3d"):
103
106
  offsets = offsets.to(device=device, dtype=offset_dtype)
@@ -139,13 +142,14 @@ def _sample_geometry(beam, detector_shape, spacing, geometry, spec, views, pixel
139
142
  endpoints = points.reshape(view_count, -1, source.shape[-1])
140
143
  checks.append((torch.any(torch.all(sources.float() == endpoints.float(), dim=-1)),
141
144
  "source and detector surface pixels must differ"))
145
+ work = _work_dtype(sources.device)
142
146
  if isinstance(voxel_spacing, (tuple, list)):
143
- pitches = sources.new_tensor(voxel_spacing[::-1], dtype=torch.float64)
144
- source_distance = torch.linalg.vector_norm(sources.double() / pitches, dim=-1)
145
- pixel_distance = torch.linalg.vector_norm(endpoints.double() / pitches, dim=-1)
147
+ pitches = sources.new_tensor(voxel_spacing[::-1], dtype=work)
148
+ source_distance = torch.linalg.vector_norm(sources.to(dtype=work) / pitches, dim=-1)
149
+ pixel_distance = torch.linalg.vector_norm(endpoints.to(dtype=work) / pitches, dim=-1)
146
150
  else:
147
- source_distance = torch.linalg.vector_norm(sources.double(), dim=-1) / voxel_spacing
148
- pixel_distance = torch.linalg.vector_norm(endpoints.double(), dim=-1) / voxel_spacing
151
+ source_distance = torch.linalg.vector_norm(sources.to(dtype=work), dim=-1) / voxel_spacing
152
+ pixel_distance = torch.linalg.vector_norm(endpoints.to(dtype=work), dim=-1) / voxel_spacing
149
153
  checks.append((torch.any(torch.minimum(source_distance, pixel_distance) > 1e6),
150
154
  "surface source or pixel must lie within 1e6 voxels"))
151
155
  checks.append((torch.any(source_distance > 1e15) | torch.any(pixel_distance > 1e15),
diffct/utils.py CHANGED
@@ -18,6 +18,26 @@ import torch
18
18
  from .constants import _DTYPE, _TPB_2D, _TPB_3D, _INF, _EPSILON
19
19
 
20
20
 
21
+ def _work_dtype(device):
22
+ """Interval, validation and composition math runs in float64 where the device supports it.
23
+
24
+ The MPS backend has no float64, so those paths fall back to float32 there.
25
+ """
26
+ return torch.float32 if torch.device(device).type == "mps" else torch.float64
27
+
28
+
29
+ def _cast_work(value, device):
30
+ """Cast value to the working dtype on device.
31
+
32
+ torch 2.14 MPS returns zeros for a fused MPS->CPU float64 cast, so those
33
+ conversions go through the source device first. Autograd stays connected.
34
+ """
35
+ dtype = _work_dtype(device)
36
+ if value.device.type == "mps" and dtype == torch.float64 and value.device != torch.device(device):
37
+ return value.cpu().to(dtype=dtype).to(device=device)
38
+ return value.to(device=device, dtype=dtype)
39
+
40
+
21
41
  def _raise_geometry_errors(checks):
22
42
  """Read one validity scalar; fetch individual error flags only on failure."""
23
43
  device = next((flag.device for flag, _ in checks if flag.is_cuda), checks[0][0].device)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diffct
3
- Version: 2.4.4
3
+ Version: 2.5.0
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/
@@ -10,7 +10,7 @@ Author-email: Yipeng Sun <yipeng.sun@fau.de>
10
10
  License-Expression: Apache-2.0
11
11
  License-File: LICENSE
12
12
  License-File: NOTICE
13
- Keywords: CT reconstruction,CUDA,PyTorch,ROCm,computed tomography,differentiable programming
13
+ Keywords: Apple Silicon,CT reconstruction,CUDA,MPS,PyTorch,ROCm,computed tomography,differentiable programming
14
14
  Classifier: Development Status :: 5 - Production/Stable
15
15
  Classifier: Environment :: GPU :: NVIDIA CUDA
16
16
  Classifier: Intended Audience :: Science/Research
@@ -34,7 +34,8 @@ Description-Content-Type: text/markdown
34
34
  <p align="center">English · <a href="https://github.com/sypsyp97/diffct/blob/main/README.zh.md">简体中文</a></p>
35
35
 
36
36
  <p align="center">
37
- Differentiable CUDA projectors for CT: arbitrary trajectories, multi-GPU, multi-node, geometry gradients.
37
+ Differentiable CT projectors: arbitrary trajectories, multi-GPU, multi-node, geometry gradients.<br>
38
+ Native NVIDIA CUDA kernels, plus a portable PyTorch backend for CPU, ROCm and Apple Silicon (MPS).
38
39
  </p>
39
40
 
40
41
  <p align="center">
@@ -66,7 +67,7 @@ Description-Content-Type: text/markdown
66
67
  </p>
67
68
 
68
69
  > **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
- > For Apple Silicon, use the [MLX port by Linda-Sophie Schneider](https://github.com/Linda-SophieSchneider/DiffCT-MLX).
70
+ > Apple Silicon runs through the built-in MPS support (`backend="torch"`); a community [MLX port by Linda-Sophie Schneider](https://github.com/Linda-SophieSchneider/DiffCT-MLX) also exists.
70
71
 
71
72
  This README describes the source checkout. Features listed under [Unreleased](CHANGELOG.md#unreleased) require installation from source.
72
73
 
@@ -79,7 +80,7 @@ To our knowledge, diffct is the only open-source GPU CT library that combines ar
79
80
  - **Matched operators.** `project()` and `backproject()` form an exact adjoint pair for the cell-constant Siddon model. Both support PyTorch autograd, with volume/sinogram gradients and Hessian-vector products.
80
81
  - **Large volumes.** Automatic spatial, view and pixel blocks with bounded transfer buffers control working memory. `project_into`/`backproject_into` accept caller-owned tensor or disk stores; tensor autograd keeps complete data on its input device. See [chunked execution](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#chunked-execution).
81
82
  - **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or torchrun and NCCL across ranks. Views, space and hybrid partitions control replication of volumes and projections. Speedup depends on workload and communication costs.
82
- - **Precision and training.** Explicit native/PyTorch backends, FP32/FP64 modes, per-axis voxel spacing, leading batch/channel dimensions and data `torch.func` transforms. The portable path supports CPU and PyTorch CUDA/ROCm devices; see [execution contracts](docs/EXECUTION.md).
83
+ - **Precision and training.** Explicit native/PyTorch backends, FP32/FP64 modes, per-axis voxel spacing, leading batch/channel dimensions and data `torch.func` transforms. The portable path supports CPU, PyTorch CUDA/ROCm devices and Apple Silicon GPUs (MPS, `backend="torch"`); see [execution contracts](docs/EXECUTION.md).
83
84
  - **Solver API.** `cgls`, `sirt` and `tv_reconstruction` report residual history, tolerances and callbacks, with scan/data fingerprints and complete checkpoint state. `cgls_into` keeps state in bounded mapped blocks.
84
85
  - **Differentiable FBP and FDK.** `fbp`, `fdk` and approximate `helical_fdk` compose the public filters, weights and analytical backprojectors. Their backward kernels transpose the actual interpolation stencil; custom filters and weights can be learned. See the [analytical guide](docs/source/analytical_learning.rst).
85
86
 
@@ -95,7 +96,7 @@ pip install "diffct[cu12]" # PyTorch built for CUDA 12
95
96
  pip install "diffct[cu13]" # PyTorch built for CUDA 13
96
97
  ```
97
98
 
98
- The extra installs the CUDA compiler libraries Numba needs. For CPU or PyTorch ROCm execution, install the appropriate PyTorch build and plain `pip install diffct`, then select `backend="torch"`. Importing diffct and using this backend require no CUDA runtime; Numba CUDA initializes when native execution is used. Plain installation also works for native execution when a compatible CUDA Toolkit is already present.
99
+ The extra installs the CUDA compiler libraries Numba needs. For CPU, PyTorch ROCm or Apple Silicon (MPS) execution, install the appropriate PyTorch build and plain `pip install diffct`, then select `backend="torch"`. Importing diffct and using this backend require no CUDA runtime; Numba CUDA initializes when native execution is used. Plain installation also works for native execution when a compatible CUDA Toolkit is already present.
99
100
 
100
101
  From source, with the examples:
101
102
 
@@ -1,24 +1,24 @@
1
- diffct/__init__.py,sha256=Euic5adzAfIdFslOxF_81MyphkyZK2sPT0ETeYofrRY,2965
2
- diffct/_solver_state.py,sha256=ExWTTGftGOsPo9MQwxv-ylmYvhiPYXUo3Tmhi6VEvg0,16606
1
+ diffct/__init__.py,sha256=jZeEhRW9GpQpRVrxV2LbuyBs33b1RzoIT7cqJE2pQVA,2965
2
+ diffct/_solver_state.py,sha256=qzyXgJCx48gWXPcJDeANXuAhAZopDzvK7ByckABVN9I,18238
3
3
  diffct/_solver_tv.py,sha256=2x16Bq5Bx9WCBszgPy3K-wicsH9AeuzzJS82uyiHBkE,5847
4
4
  diffct/analytical.py,sha256=LGYA5NEXVJ0qKwOWPXIZMXY2EOKvLto_w7bBcep5N2I,33398
5
- diffct/analytical_execution.py,sha256=sS3WFUyaaBJnr8d3XiUJoWTxQujoiB3-oPbXPvZcL9c,27069
6
- diffct/backends.py,sha256=pV2a3ODyhZWEV0nPAI82yPqNDlT85btKnTbCag4pCtQ,70473
5
+ diffct/analytical_execution.py,sha256=Tc4x0IglgUdQ7IGoZu2Jt_5onCGibX8XwWx7lzhXSLc,27248
6
+ diffct/backends.py,sha256=oQbNPJY-zyUBzG9uw384duF66Oo6UuiVfi3wBqFISd0,71906
7
7
  diffct/chunking.py,sha256=sdZHajLpHEh1mq4OHCAQZzU72QGQrMRN5Ft0bxE3nTM,11480
8
8
  diffct/constants.py,sha256=wgOfwUZW8CZsj0cj0f6gBLCxbq2qa4xY47QiPEUvWrc,3747
9
9
  diffct/differentiable.py,sha256=cFirhqXRpUemL7y7Sl_0uqZKGJNj__ptQEcTwYNKzys,1879
10
10
  diffct/distribution.py,sha256=iLC3BgAQ9lOkndbEOvNB_j_UaA2Ug9w1KiTUHkOzG8o,9982
11
- diffct/geometry.py,sha256=iwWXQksIJGOPSuNWJ8tImWj6yPZCC71G3RBh2VDVylY,42581
12
- diffct/operators.py,sha256=aSHd1sIJ1_fc4epMFS8jW183r9dI206cvbB8YsAtNjs,170540
11
+ diffct/geometry.py,sha256=1oaXkwm3MoIgCcBP1W38MhVikhz-bH9IeWRyIitdI0s,44699
12
+ diffct/operators.py,sha256=yWvITQcAKtGaUlY94xSA65ItGdynmv-jj2PmgdQExBg,171550
13
13
  diffct/pipeline.py,sha256=jp7AvDeQQOME3OPkO9XTcjWFLvOLrv8wczhAf1jcklM,10891
14
14
  diffct/projectors.py,sha256=fYfjw-BU1odP0htmlgVWdJk5pL0zP6MfgArOzdGCyq0,45900
15
- diffct/reconstruction.py,sha256=z_5dPffzs3PEaLl8rdV_l2FpilSNkUqheWgEEfywJM8,29564
15
+ diffct/reconstruction.py,sha256=8RTbtouesjFW4CkcuzZ-wDinfeupQZnEuiqveMR0yF8,30415
16
16
  diffct/solvers.py,sha256=B7M4akFUTuwb_MBpQbpZLdlMv5Eyxy3WczExDAs613s,18000
17
17
  diffct/storage.py,sha256=EdcFTzRtEAPRvAZhRPrSf8gRNF5o_JVbPLE8eKPhNdY,4153
18
- diffct/streaming.py,sha256=2LB5xpl6vIJE1kXMBtQbG0OVxlutEEqXLBrcvlzBHl8,6086
18
+ diffct/streaming.py,sha256=5Iu4zY9bhP5ALw3mqvhHe-VbM1eesyAGzYRnZWXRp8w,6512
19
19
  diffct/surface_gradients.py,sha256=LeM_eDY1Alv604SdiPUx2CWFzr-J9NqCEcJ_ovVFGa8,6290
20
- diffct/surfaces.py,sha256=-tp7tJi2GVy1lE4-Yi46hVQ6BMMF08Pfck3rkibgF-Y,8370
21
- diffct/utils.py,sha256=ZOZpR_qpVSxv_uSQJ6--x-RXAqUrZJjWXYJoPKhu5nY,15240
20
+ diffct/surfaces.py,sha256=p7vk-W0KR7pbJcb_zJqQxNG8-iocmBkCe9WAq6H9ac8,8609
21
+ diffct/utils.py,sha256=1Bn37Ay9RsqgofIYNYZilpjT-jkJWv_GBF2Q-vIXqz8,16023
22
22
  diffct/kernels/__init__.py,sha256=YNdsqAkexN25pI7QaHy4phAnQNICQ7tO8K8S1zt7TnI,1899
23
23
  diffct/kernels/analytical_transpose.py,sha256=3aiYjMm4IVJUZ9DJaHZLgA_cPgoTc8pvoZUJ6KZCYtI,10006
24
24
  diffct/kernels/cone_beam.py,sha256=EQRmd5Q5MGFrtk2iddIgxV4xlyxhjQh-_3u3ZTa1Z0Q,30670
@@ -27,8 +27,8 @@ diffct/kernels/geometry_vjp.py,sha256=JZ5At_0EtsCBacxt38hrxGG43USEA_4eiqz0PHF1zM
27
27
  diffct/kernels/parallel_beam.py,sha256=M6Hjf8B66MNpb6ze5ScYEZNQ151tPd0TODrMFs8YMV8,18764
28
28
  diffct/kernels/spacing.py,sha256=iRVlHZpLi15dS1sjzNJMohfxooKjschF6Y1wTDe7SZA,2031
29
29
  diffct/kernels/surface_geometry.py,sha256=tTJXykpK7lOfdPr3Oz1bCCFm7pRnbttGJHTkHuAxRIA,6322
30
- diffct-2.4.4.dist-info/METADATA,sha256=xmnyKp7ix75ZC1SZkP0xC8g6_0Ztf_wab50ifFQOgHU,17865
31
- diffct-2.4.4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
32
- diffct-2.4.4.dist-info/licenses/LICENSE,sha256=a1fjzGs44LpDnV_LmA2Tzp8z-eJKoNVVdLOnh-F6J6E,11344
33
- diffct-2.4.4.dist-info/licenses/NOTICE,sha256=Qb3qWsJ3TMqipmMm2_jS8Ryrq9XyHtdjc3qH1q8ELi4,798
34
- diffct-2.4.4.dist-info/RECORD,,
30
+ diffct-2.5.0.dist-info/METADATA,sha256=1a01Ijl6XVM-8m0uUwySgpJKLNqIZGdj-BBhXnvvrK4,18115
31
+ diffct-2.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
32
+ diffct-2.5.0.dist-info/licenses/LICENSE,sha256=a1fjzGs44LpDnV_LmA2Tzp8z-eJKoNVVdLOnh-F6J6E,11344
33
+ diffct-2.5.0.dist-info/licenses/NOTICE,sha256=Qb3qWsJ3TMqipmMm2_jS8Ryrq9XyHtdjc3qH1q8ELi4,798
34
+ diffct-2.5.0.dist-info/RECORD,,
File without changes