diffct 2.2.1__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
diffct/__init__.py CHANGED
@@ -20,12 +20,18 @@ from .surfaces import ParameterizedSurface
20
20
  from .reconstruction import (
21
21
  fbp,
22
22
  fdk,
23
+ fbp_into,
24
+ fdk_into,
23
25
  helical_fdk,
24
26
  helical_view_weights,
25
27
  helical_weighted_backproject,
26
28
  )
27
29
 
30
+ from .solvers import SolverResult, cgls, sirt, tv_reconstruction, cgls_into, total_variation
31
+
28
32
  from .geometry import (
33
+ transform_trajectory,
34
+ circular_trajectory_3d_parallel,
29
35
  circular_trajectory_3d,
30
36
  random_trajectory_3d,
31
37
  spiral_trajectory_3d,
@@ -58,7 +64,7 @@ try:
58
64
  except ImportError:
59
65
  pass
60
66
 
61
- __version__ = '2.2.1'
67
+ __version__ = '2.4.0'
62
68
 
63
69
  __all__ = [
64
70
  'Projector',
@@ -67,6 +73,14 @@ __all__ = [
67
73
  'ParameterizedSurface',
68
74
  'fbp',
69
75
  'fdk',
76
+ 'fbp_into',
77
+ 'fdk_into',
78
+ 'SolverResult',
79
+ 'cgls',
80
+ 'sirt',
81
+ 'tv_reconstruction',
82
+ 'cgls_into',
83
+ 'total_variation',
70
84
  'helical_fdk',
71
85
  'helical_view_weights',
72
86
  'helical_weighted_backproject',
@@ -76,6 +90,8 @@ __all__ = [
76
90
  'FanBackprojectorFunction',
77
91
  'ConeProjectorFunction',
78
92
  'ConeBackprojectorFunction',
93
+ 'transform_trajectory',
94
+ 'circular_trajectory_3d_parallel',
79
95
  'circular_trajectory_3d',
80
96
  'random_trajectory_3d',
81
97
  'spiral_trajectory_3d',
@@ -0,0 +1,301 @@
1
+ """Bounded solver state, owned reductions and complete checkpoint publication."""
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import numbers
7
+ from pathlib import Path
8
+
9
+ import torch
10
+ import torch.distributed as dist
11
+
12
+ from .pipeline import _fragments
13
+ from .storage import NpyStore, TensorStore
14
+ from .distribution import control_status, preflight
15
+
16
+
17
+ def count(value, name, positive=False):
18
+ if isinstance(value, bool) or not isinstance(value, numbers.Integral) or value < int(positive):
19
+ raise ValueError(f"{name} must be a {'positive' if positive else 'nonnegative'} integer")
20
+ return int(value)
21
+
22
+
23
+ def finite(value, name, positive=False):
24
+ if isinstance(value, bool) or not isinstance(value, numbers.Real) or not math.isfinite(value) or value < 0 or (positive and value == 0):
25
+ raise ValueError(f"{name} must be finite and {'positive' if positive else 'nonnegative'}")
26
+ return float(value)
27
+
28
+
29
+ def agreement(operator, error=None, token=None, *, cache_key=None):
30
+ """Every rank rejects a bad request before numerical execution starts."""
31
+ world = getattr(operator, "world_size", 1)
32
+ if world == 1:
33
+ if error is not None:
34
+ raise error
35
+ return
36
+ error = None if error is None else ValueError("distributed solver agreement failed: " + str(error))
37
+ mismatch = "distributed solver agreement failed: inconsistent checkpoint iteration or controls"
38
+ partition = getattr(operator, "_partition", None)
39
+ if partition is not None:
40
+ partition.preflight(error, token, cache_key=cache_key, mismatch=mismatch)
41
+ else:
42
+ preflight(error, token, process_group=operator.process_group, world_size=world,
43
+ mismatch=mismatch)
44
+
45
+
46
+ def owns_values(operator, kind):
47
+ """Select one copy of each physical ray or voxel for scalar reductions."""
48
+ partition = getattr(operator, "partition", "views")
49
+ rank = getattr(operator, "rank", 0)
50
+ return (getattr(operator, "view_rank", 0) == 0 if kind == "volume"
51
+ else getattr(operator, "space_rank", 0) == 0) if partition == "hybrid" else (
52
+ rank == 0 if (kind == "volume") == (partition == "views") else True
53
+ )
54
+
55
+
56
+ def scalar_sum(operator, value, kind):
57
+ """Count physical rays/voxels once, including view/space replicas."""
58
+ world = getattr(operator, "world_size", 1)
59
+ value = value if owns_values(operator, kind) else 0.
60
+ if world > 1:
61
+ group = operator.process_group
62
+ device = torch.device("cuda", torch.cuda.current_device()) if dist.get_backend(group) == "nccl" else torch.device("cpu")
63
+ accumulator = torch.as_tensor(value, dtype=torch.float64, device=device).clone()
64
+ dist.all_reduce(accumulator, group=group)
65
+ return accumulator.item()
66
+ return float(value)
67
+
68
+
69
+ def squared_norm(operator, value, kind, elements=1048576):
70
+ store = TensorStore(value) if isinstance(value, torch.Tensor) else value
71
+ total = 0.
72
+ if owns_values(operator, kind):
73
+ total = torch.zeros((), dtype=torch.float64, device=getattr(store, "device", "cpu"))
74
+ for selected in _fragments(tuple(store.shape), elements):
75
+ # Clone even an FP64 view: norms must never alter caller measurements.
76
+ block = store.read(selected).to(dtype=torch.float64).clone()
77
+ # Preserve the block reductions and their sequential FP64 order.
78
+ total.add_(block.square_().sum().to(device=total.device))
79
+ return scalar_sum(operator, total, kind)
80
+
81
+
82
+ def copy_blocks(source, target, elements):
83
+ for selected in _fragments(tuple(target.shape), elements):
84
+ target.write(selected, source.read(selected).to(device=getattr(target, "device", "cpu"), dtype=target.dtype))
85
+
86
+
87
+ def fill_blocks(target, elements):
88
+ for selected in _fragments(tuple(target.shape), elements):
89
+ shape = tuple(part.stop - part.start for part in selected)
90
+ target.write(selected, torch.zeros(shape, dtype=target.dtype))
91
+
92
+
93
+ def update_blocks(target, source, factor, elements, scale=1.):
94
+ 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)
97
+ target.write(selected, block)
98
+
99
+
100
+ def fingerprint(value, elements):
101
+ store = TensorStore(value.detach()) if isinstance(value, torch.Tensor) else value
102
+ digest = hashlib.sha256(json.dumps((tuple(store.shape), str(store.dtype))).encode())
103
+ for selected in _fragments(tuple(store.shape), elements):
104
+ block = store.read(selected).detach().to(device="cpu").contiguous()
105
+ # Hash storage bytes without requiring NumPy support for the original
106
+ # floating dtype (for example bfloat16). This view allocates no payload.
107
+ digest.update(block.reshape(-1).view(torch.uint8).numpy().tobytes())
108
+ return digest.hexdigest()
109
+
110
+
111
+ def _effective_surface(operator, frames, elements):
112
+ """Hash the actual physical pixel rays, without materializing a volume."""
113
+ surface = getattr(operator, "detector_surface", None)
114
+ if surface is None:
115
+ return None
116
+ from .backends import ReferenceProjector
117
+ from .surfaces import ParameterizedSurface, _detector_grids
118
+
119
+ dimensions = len(operator.global_volume_shape)
120
+ if elements < max(3, dimensions):
121
+ raise ValueError("block_elements cannot hold one physical detector pixel")
122
+ if isinstance(surface, ParameterizedSurface):
123
+ if any(value.device.type != "cpu" and value.numel() > elements for value in surface.parameters):
124
+ raise ValueError("surface parameters cannot be staged within block_elements")
125
+ geometry = (*frames, *surface.parameters)
126
+ else:
127
+ # Legacy callbacks require complete grids. Their documented output can
128
+ # contain every view, so admit that snapshot only when it fits the
129
+ # bound; a smaller arbitrary callback invocation may change its meaning.
130
+ if operator._n_views * math.prod(operator.detector_shape) * 3 > elements:
131
+ raise ValueError("legacy surface fingerprint exceeds block_elements; use ParameterizedSurface")
132
+ pixels = tuple(slice(0, size) for size in operator.detector_shape)
133
+ u, v = _detector_grids(operator.detector_shape, operator.detector_spacing, pixels)
134
+ offsets = surface(u, v)
135
+ if not isinstance(offsets, torch.Tensor) or not torch.is_floating_point(offsets):
136
+ raise TypeError("detector_surface must return floating-point offsets")
137
+ if tuple(offsets.shape) not in ((*operator.detector_shape, 3),
138
+ (operator._n_views, *operator.detector_shape, 3)):
139
+ raise ValueError("legacy detector surface must match the complete pixel grids")
140
+ geometry = (*frames, offsets)
141
+ reference = getattr(operator, "_reference", None)
142
+ if reference is None:
143
+ reference = ReferenceProjector(operator, validate=False)
144
+ description = json.dumps((operator._n_views, operator.detector_shape, str(operator.dtype))).encode()
145
+ origins_digest, directions_digest = hashlib.sha256(description), hashlib.sha256(description)
146
+ for view in range(operator._n_views):
147
+ for pixels in _fragments(operator.detector_shape, elements // max(3, dimensions)):
148
+ origins, directions, _ = reference._rays(slice(view, view + 1), pixels,
149
+ torch.device("cpu"), validate=True, geometry=geometry)
150
+ # Separate streams preserve row-major ray ordering across different
151
+ # block sizes; changing a memory ceiling does not change the hash.
152
+ origins_digest.update(origins.detach().contiguous().numpy().tobytes())
153
+ directions_digest.update(directions.detach().contiguous().numpy().tobytes())
154
+ return [origins_digest.hexdigest(), directions_digest.hexdigest()]
155
+
156
+
157
+ def _signature_local(operator, source, algorithm, settings, elements, compute_dtype):
158
+ frames = getattr(operator, "_learnable", ()) or getattr(operator, "_trajectory", ())
159
+ geometry = tuple(fingerprint(value, elements) for value in frames)
160
+ surface = getattr(operator, "detector_surface", None)
161
+ surface_values = (() if surface is None else getattr(surface, "parameters", ()))
162
+ if isinstance(surface, torch.Tensor):
163
+ surface_values = (surface,)
164
+ return dict(
165
+ schema_version=1, algorithm=algorithm, settings=settings,
166
+ rank=getattr(operator, "rank", 0), world_size=getattr(operator, "world_size", 1),
167
+ partition=getattr(operator, "partition", "views"),
168
+ partition_grid=getattr(operator, "partition_grid", None),
169
+ global_volume_shape=list(operator.global_volume_shape), volume_shape=list(operator.volume_shape),
170
+ volume_slice=[[part.start, part.stop] for part in operator.volume_slice],
171
+ projection_shape=list(operator.projection_shape),
172
+ view_slice=[operator.view_slice.start, operator.view_slice.stop],
173
+ beam=operator.beam, voxel_spacing=operator.voxel_spacing,
174
+ detector_spacing=list(operator.detector_spacing), detector_shape=list(operator.detector_shape),
175
+ input_dtype=str(source.dtype), geometry=list(geometry),
176
+ surface=[fingerprint(value, elements) for value in surface_values],
177
+ effective_surface=_effective_surface(operator, frames, elements),
178
+ compute_dtype=str(compute_dtype), precision=getattr(operator, "precision", None),
179
+ backend=getattr(operator, "backend", None),
180
+ measurements=fingerprint(source, elements),
181
+ )
182
+
183
+
184
+ def signature(operator, source, algorithm, settings, elements, *, compute_dtype=None):
185
+ error, metadata = None, None
186
+ try:
187
+ dtype = getattr(operator, "dtype", source.dtype) if compute_dtype is None else compute_dtype
188
+ metadata = _signature_local(operator, source, algorithm, settings, elements, dtype)
189
+ except Exception as caught:
190
+ error = caught
191
+ agreement(operator, error)
192
+ if getattr(operator, "world_size", 1) > 1:
193
+ records = [None] * operator.world_size
194
+ dist.all_gather_object(records, metadata, group=operator.process_group)
195
+ local_fields = {"rank", "volume_shape", "volume_slice", "projection_shape", "view_slice", "measurements"}
196
+ scans = [{key: value for key, value in record.items() if key not in local_fields} for record in records]
197
+ if any(scan != scans[0] for scan in scans):
198
+ raise ValueError("distributed checkpoint scan and compute configuration must agree")
199
+ replicas = {}
200
+ for record in records:
201
+ views = tuple(record["view_slice"])
202
+ digest = record["measurements"]
203
+ if views in replicas and replicas[views] != digest:
204
+ raise ValueError("distributed checkpoint measurement replicas must agree")
205
+ replicas[views] = digest
206
+ return metadata
207
+
208
+
209
+ def load_checkpoint(operator, directory, expected, iterations, elements, names):
210
+ error, saved, states = None, None, None
211
+ try:
212
+ directory = Path(directory).resolve()
213
+ saved = json.loads((directory / "metadata.json").read_text(encoding="utf-8"))
214
+ # JSON canonicalization also normalizes tuple-valued physical spacing.
215
+ canonical = json.loads(json.dumps(expected))
216
+ if any(saved.get(key) != value for key, value in canonical.items()):
217
+ raise ValueError("checkpoint scan, data, ownership, dtype or algorithm settings do not match")
218
+ completed = count(saved.get("iteration"), "checkpoint iteration")
219
+ history = saved.get("residual_history")
220
+ if completed > iterations or not isinstance(history, list) or len(history) != completed + 1:
221
+ raise ValueError("checkpoint iteration or residual history is invalid")
222
+ for value in history:
223
+ finite(value, "checkpoint residual")
224
+ finite(saved.get("measurement_norm"), "checkpoint measurement norm")
225
+ if expected["algorithm"] in ("cgls", "cgls_into"):
226
+ finite(saved.get("gamma"), "checkpoint gamma")
227
+ manifest = saved.get("states", {})
228
+ if set(manifest) != set(names):
229
+ raise ValueError("checkpoint does not contain the complete solver state")
230
+ states = {}
231
+ for name in names:
232
+ store = NpyStore(directory / f"{name}.npy", mode="r")
233
+ specification = manifest[name]
234
+ shape = operator.projection_shape if name in ("r", "q", "row") else operator.volume_shape
235
+ if name == "step":
236
+ shape = (1,)
237
+ if tuple(store.shape) != tuple(shape) or list(store.shape) != specification.get("shape") or str(store.dtype) != specification.get("dtype"):
238
+ raise ValueError("checkpoint solver state shape or dtype does not match")
239
+ if fingerprint(store, elements) != specification.get("fingerprint"):
240
+ raise ValueError("checkpoint solver state fingerprint does not match")
241
+ states[name] = store
242
+ except (ValueError, TypeError, OSError, EOFError, KeyError) as caught:
243
+ error = caught
244
+ token = None if error is not None else (saved["iteration"], saved["residual_history"], saved.get("gamma"), saved["measurement_norm"])
245
+ agreement(operator, error, token)
246
+ return saved, states
247
+
248
+
249
+ def save_checkpoint(operator, directory, states, metadata, iteration, history, measurement_norm, elements, **scalars):
250
+ error, manifest, rank = None, {}, None
251
+ try:
252
+ step = Path(directory).resolve() / f"step-{iteration:06d}"
253
+ step.mkdir(parents=True, exist_ok=True)
254
+ rank = step / f"rank-{getattr(operator, 'rank', 0):05d}"
255
+ rank.mkdir()
256
+ for name, source in states.items():
257
+ source = TensorStore(source.detach()) if isinstance(source, torch.Tensor) else source
258
+ target = NpyStore.create(rank / f"{name}.npy", source.shape, dtype=source.dtype)
259
+ copy_blocks(source, target, elements)
260
+ target.flush()
261
+ manifest[name] = dict(shape=list(source.shape), dtype=str(source.dtype), fingerprint=fingerprint(target, elements))
262
+ except (OSError, ValueError, TypeError) as caught:
263
+ error = caught
264
+ # All owned states on every rank are written and flushed before any rank
265
+ # atomically publishes the metadata marking a completed iteration.
266
+ agreement(operator, error, (iteration, history))
267
+ error = None
268
+ try:
269
+ document = dict(metadata, iteration=iteration, residual_history=history,
270
+ measurement_norm=measurement_norm, states=manifest, **scalars)
271
+ temporary = rank / "metadata.tmp"
272
+ with temporary.open("x", encoding="utf-8") as file:
273
+ json.dump(document, file)
274
+ file.flush()
275
+ temporary.replace(rank / "metadata.json")
276
+ except Exception as caught:
277
+ error = caught
278
+ # A failed metadata write/rename must stop every rank before another
279
+ # projection or callback can enter a numerical collective.
280
+ agreement(operator, error, iteration)
281
+
282
+
283
+ def callbacks_enabled(operator, callback):
284
+ """Check callback presence once, including callbacks on only some ranks."""
285
+ return bool(control_status((int(callback is not None),),
286
+ process_group=getattr(operator, "process_group", None),
287
+ world_size=getattr(operator, "world_size", 1))[0])
288
+
289
+
290
+ def callback_stop(operator, callback, iteration, residual, volume):
291
+ error, stop = None, False
292
+ try:
293
+ stop = callback is not None and callback(iteration, residual, volume) is False
294
+ except Exception as caught:
295
+ error = caught
296
+ failed, stop = control_status((int(error is not None), int(stop)),
297
+ process_group=getattr(operator, "process_group", None),
298
+ world_size=getattr(operator, "world_size", 1))
299
+ if failed:
300
+ agreement(operator, error)
301
+ return bool(stop)
diffct/_solver_tv.py ADDED
@@ -0,0 +1,110 @@
1
+ """Physical forward-difference TV with differentiable slab boundary faces."""
2
+
3
+ import math
4
+ import numbers
5
+
6
+ import torch
7
+ import torch.distributed as dist
8
+
9
+ from ._solver_state import finite, scalar_sum
10
+ from .backends import _zero_dependency
11
+
12
+
13
+ def _peers(operator, nonempty):
14
+ spaces = operator.space_world_size
15
+ current = operator.space_rank
16
+ occupied = [rank for rank in range(spaces) if rank < min(spaces, operator.global_volume_shape[0])]
17
+ previous = max((rank for rank in occupied if rank < current), default=None) if nonempty else None
18
+ following = min((rank for rank in occupied if rank > current), default=None) if nonempty else None
19
+ def global_rank(rank):
20
+ if rank is None:
21
+ return None
22
+ parent = operator.view_rank * spaces + rank
23
+ return operator._partition.global_rank(parent)
24
+ return global_rank(previous), global_rank(following)
25
+
26
+
27
+ class _FollowingFace(torch.autograd.Function):
28
+ @staticmethod
29
+ def forward(ctx, volume, operator):
30
+ previous, following = _peers(operator, volume.shape[0] > 0)
31
+ ctx.operator, ctx.previous, ctx.following = operator, previous, following
32
+ ctx.shape, ctx.dtype, ctx.device = volume.shape, volume.dtype, volume.device
33
+ nccl = dist.get_backend(operator.space_group) == "nccl"
34
+ ctx.communication_device = (torch.device("cuda", torch.cuda.current_device())
35
+ if nccl else torch.device("cpu"))
36
+ if nccl:
37
+ # Initialize the subgroup communicator collectively, including
38
+ # empty slabs, before the first subset-only face exchange.
39
+ dist.barrier(group=operator.space_group)
40
+ face = torch.zeros(volume.shape[1:], dtype=volume.dtype, device=ctx.communication_device)
41
+ operations = []
42
+ if previous is not None:
43
+ first = volume[0].detach().to(device=ctx.communication_device).contiguous()
44
+ operations.append(dist.P2POp(dist.isend, first, previous, operator.space_group))
45
+ if following is not None:
46
+ operations.append(dist.P2POp(dist.irecv, face, following, operator.space_group))
47
+ if operations:
48
+ for work in dist.batch_isend_irecv(operations):
49
+ work.wait()
50
+ return face.to(device=volume.device)
51
+
52
+ @staticmethod
53
+ def backward(ctx, grad_face):
54
+ received = torch.zeros(ctx.shape[1:], dtype=ctx.dtype, device=ctx.communication_device)
55
+ operations = []
56
+ if ctx.following is not None:
57
+ outgoing = grad_face.to(device=ctx.communication_device).contiguous()
58
+ operations.append(dist.P2POp(dist.isend, outgoing, ctx.following, ctx.operator.space_group))
59
+ if ctx.previous is not None:
60
+ operations.append(dist.P2POp(dist.irecv, received, ctx.previous, ctx.operator.space_group))
61
+ if operations:
62
+ for work in dist.batch_isend_irecv(operations):
63
+ work.wait()
64
+ gradient = torch.zeros(ctx.shape, dtype=ctx.dtype, device=ctx.device)
65
+ if ctx.shape[0] > 0:
66
+ gradient[0] = received.to(device=ctx.device)
67
+ return gradient, None
68
+
69
+
70
+ def total_variation(volume, *, operator=None, voxel_spacing=1., eps=1e-6, reduction="mean"):
71
+ """Isotropic TV over every voxel, with physical forward differences.
72
+
73
+ Differences vanish at the global outer boundary. Spatially partitioned
74
+ volumes exchange a single neighboring face in forward and backward; the
75
+ reported scalar counts replicated ownership once and gradients stay local.
76
+ """
77
+ if not isinstance(volume, torch.Tensor) or not torch.is_floating_point(volume) or volume.ndim < 1:
78
+ raise TypeError("volume must be a real floating-point tensor")
79
+ eps = finite(eps, "eps", positive=True)
80
+ if reduction not in ("mean", "sum"):
81
+ raise ValueError("reduction must be mean or sum")
82
+ spacing = (voxel_spacing,) * volume.ndim if isinstance(voxel_spacing, numbers.Real) else tuple(voxel_spacing)
83
+ if len(spacing) != volume.ndim:
84
+ raise ValueError("voxel_spacing must match the volume axes")
85
+ spacing = tuple(finite(pitch, "voxel_spacing", positive=True) for pitch in spacing)
86
+ if operator is not None and tuple(volume.shape) != tuple(operator.volume_shape):
87
+ raise ValueError("volume shape must match its operator ownership")
88
+ distributed_space = operator is not None and getattr(operator, "world_size", 1) > 1 and operator.partition in ("space", "hybrid")
89
+ halo = _FollowingFace.apply(volume, operator) if distributed_space else None
90
+ squared = torch.zeros_like(volume)
91
+ for axis, pitch in enumerate(spacing):
92
+ current, following = [slice(None)] * volume.ndim, [slice(None)] * volume.ndim
93
+ current[axis], following[axis] = slice(0, -1), slice(1, None)
94
+ difference = torch.zeros_like(volume)
95
+ difference[tuple(current)] = (volume[tuple(following)] - volume[tuple(current)]) / pitch
96
+ if axis == 0 and halo is not None and volume.shape[0] and operator.volume_slice[0].stop < operator.global_volume_shape[0]:
97
+ difference[-1] = (halo - volume[-1]) / pitch
98
+ squared = squared + difference.square()
99
+ local = torch.sqrt(squared + eps).sum()
100
+ if halo is not None:
101
+ # The final slab also needs backward to receive the previous slab's
102
+ # contribution, even though its own global outer difference is zero.
103
+ local = local + _zero_dependency(halo)
104
+ total = local
105
+ elements = volume.numel()
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())
109
+ elements = math.prod(operator.global_volume_shape)
110
+ return total if reduction == "sum" else total / max(elements, 1)