diffct 2.4.1__py3-none-any.whl → 2.4.2__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.2'
68
68
 
69
69
  __all__ = [
70
70
  'Projector',
@@ -51,11 +51,17 @@ 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 with the same offsets: grid row ``y``
56
+ writes view ``view_index + y`` from the ``y``-th of ``gridDim.y`` equal
57
+ frame packets in ``frames``.
54
58
  """
55
59
  pixel = cuda.grid(1)
60
+ view_index += cuda.blockIdx.y
61
+ base = cuda.blockIdx.y * (frames.size // cuda.gridDim.y)
56
62
  if pixel == 0:
57
63
  for axis in range(rank):
58
- value = frames[axis]
64
+ value = frames[base + axis]
59
65
  if not parallel:
60
66
  value = _surface_add(value, -centre[axis], frame32[0])
61
67
  source[view_index, axis] = np.float32(value)
@@ -85,20 +91,20 @@ def compose_surface(frames, offsets, source, points, view_index, pixel_start,
85
91
  point_squared = np.float64(0.0)
86
92
  for axis in range(rank):
87
93
  point = _surface_add(
88
- frames[rank + axis],
89
- _surface_multiply(tangent, frames[2 * rank + axis], tangent32),
94
+ frames[base + rank + axis],
95
+ _surface_multiply(tangent, frames[base + 2 * rank + axis], tangent32),
90
96
  frame32[1] and tangent32,
91
97
  )
92
98
  if rank == 3:
93
99
  point = _surface_add(
94
- point, _surface_multiply(vertical, frames[3 * rank + axis], vertical32),
100
+ point, _surface_multiply(vertical, frames[base + 3 * rank + axis], vertical32),
95
101
  point32,
96
102
  )
97
- normal = frames[4 * rank + axis]
103
+ normal = frames[base + 4 * rank + axis]
98
104
  else:
99
- normal = frames[2 * rank + 1] if axis == 0 else -frames[2 * rank]
105
+ normal = frames[base + 2 * rank + 1] if axis == 0 else -frames[base + 2 * rank]
100
106
  point = _surface_add(point, _surface_multiply(depth, normal, depth32), final32)
101
- source_value = frames[axis]
107
+ source_value = frames[base + axis]
102
108
  source_native = np.float32(source_value)
103
109
  point_native = np.float32(point)
104
110
  finite = (finite and math.isfinite(source_value) and math.isfinite(point)
diffct/operators.py CHANGED
@@ -489,6 +489,8 @@ class Projector:
489
489
  if self.backend != "torch" and detector_surface is not None:
490
490
  geometry, spec, surface_trainable, surface_metadata = self._surface_validation_inputs()
491
491
  devices = self._devices or (torch.device("cuda", torch.cuda.current_device()),)
492
+ # Validation budgets depend on whether geometry gradients are needed.
493
+ self._geometry_trainable = bool(self._learnable) or surface_trainable
492
494
  self._validate_surface_geometry(geometry, spec, devices)
493
495
  except (TypeError, ValueError, RuntimeError) as error:
494
496
  validation_error = error
@@ -769,6 +771,8 @@ class Projector:
769
771
 
770
772
  def fits(shape):
771
773
  points = surface or pixels != self.detector_shape
774
+ # Legacy offsets are one host snapshot; world points expand on the device.
775
+ host_points = points and self._host_world_points(geometry_spec)
772
776
  buffers = _pipeline_buffers(self.beam, shape, batch, pixels, points,
773
777
  geometry_grad=self._geometry_trainable,
774
778
  ray_accumulators=extra_rays, peers=peers or foreign_output)
@@ -778,7 +782,7 @@ class Projector:
778
782
  and _reservation_bytes(buffers) <= reservation_budget
779
783
  and _largest_buffer_bytes(self.beam, shape, batch, pixels, points) <= allocation_limit
780
784
  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
785
+ and _host_geometry_bytes(self.beam, batch, pixels, host_points) <= _pipeline._GEOMETRY_WORK_BYTES
782
786
  )
783
787
 
784
788
  while not fits(minimum_shape):
@@ -826,7 +830,11 @@ class Projector:
826
830
  rank, frames = len(self.volume_shape), 4 if self.beam == "cone" else 3
827
831
  capacity = self._surface_capacity(views, pixels)
828
832
  columns = 3 if self.beam == "cone" else 2
829
- buffers = (_allocation_bytes(2 * (frames + (3 if rank == 3 else 0)) * rank),
833
+ shared = spec is not None and spec.kind == "offsets" and spec.shared
834
+ if shared:
835
+ capacity = math.prod(pixels)
836
+ # Shared offsets pack the frame packets of every view of a job.
837
+ buffers = (_allocation_bytes(2 * (views if shared else 1) * (frames + (3 if rank == 3 else 0)) * rank),
830
838
  _allocation_bytes(2 * (columns * capacity + 1)))
831
839
  if self._geometry_trainable:
832
840
  # Replay one view and a bounded detector row at a time.
@@ -850,7 +858,17 @@ class Projector:
850
858
  dtype = torch.float32
851
859
  for component in (*frames[1:], offsets):
852
860
  dtype = torch.promote_types(dtype, component.dtype)
853
- return _GeometrySpec("offsets", dtype=dtype)
861
+ return _GeometrySpec("offsets", dtype=dtype, shared=offsets.ndim == len(self.detector_shape) + 1)
862
+
863
+ def _host_world_points(self, spec):
864
+ """Whether the host geometry budget must count world points of each view.
865
+
866
+ Shared legacy offsets are one host snapshot whose world points are
867
+ composed on the compute device; per-view offsets and geometry
868
+ gradients keep host work proportional to views x pixels.
869
+ """
870
+ return not (spec is not None and spec.kind == "offsets" and spec.shared
871
+ and not self._geometry_trainable)
854
872
 
855
873
  def _surface_validation_inputs(self):
856
874
  """Stage frame metadata once without expanding surface world points."""
@@ -884,7 +902,7 @@ class Projector:
884
902
  buffers = self._surface_work_buffers(batch, pixels, spec=spec)
885
903
  return (sum(buffers) <= budget and max(buffers, default=0) <= allocation
886
904
  and _reservation_bytes(buffers) <= reservation
887
- and _host_geometry_bytes(self.beam, batch, pixels, True)
905
+ and _host_geometry_bytes(self.beam, batch, pixels, self._host_world_points(spec))
888
906
  <= _pipeline._GEOMETRY_WORK_BYTES)
889
907
 
890
908
  if not fits():
@@ -1197,7 +1215,8 @@ class Projector:
1197
1215
  estimate = size(shape, slots, retained, extra_rays)
1198
1216
  reservation = (reserved(shape, slots, retained, extra_rays)
1199
1217
  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
1218
+ host_points = points and self._host_world_points(spec)
1219
+ host_fits = 2 * _host_geometry_bytes(self.beam, plan.view_chunk_size, pixels, host_points) <= _pipeline._GEOMETRY_WORK_BYTES
1201
1220
  if (estimate > plan.gpu_budget_bytes
1202
1221
  or reservation > plan.reservation_budget_bytes or not host_fits):
1203
1222
  if schedule == "window":
@@ -1310,6 +1329,9 @@ class Projector:
1310
1329
  rank, frame_count = len(self.volume_shape), 4 if self.beam == "cone" else 3
1311
1330
  count = views.stop - views.start
1312
1331
  pixel_shape = tuple(selected.stop - selected.start for selected in pixels)
1332
+ # Offsets sampled here derive from the snapshot and the pixel tile;
1333
+ # offsets supplied by the caller may differ per batch and are not cached.
1334
+ cacheable = offsets is None
1313
1335
  if offsets is None:
1314
1336
  offsets = _sample_offsets(self.beam, self.detector_shape, self.detector_spacing,
1315
1337
  geometry, spec, views, pixels)
@@ -1324,15 +1346,42 @@ class Projector:
1324
1346
  frame32 += (True,)
1325
1347
  offset32 = ((torch.promote_types(torch.float32, offsets.dtype)
1326
1348
  if spec.kind == "offsets" else common) == torch.float32)
1349
+ # Offsets shared by all views are staged once per call and composed
1350
+ # with one launch for all views instead of per view and row segment.
1351
+ shared = spec.kind == "offsets" and offsets.ndim == len(pixel_shape) + 1
1352
+ pixel_count = math.prod(pixel_shape)
1353
+ # One packed frame packet per view: source, center, axes, normal and
1354
+ # float32 cross-product scratch. Shared offsets pack every view of a job.
1355
+ stride = (frame_count + (3 if rank == 3 else 0)) * rank
1327
1356
  if "_surface_workspace" not in groups:
1328
- capacity = self._surface_capacity(count, pixel_shape)
1357
+ capacity = pixel_count if shared else self._surface_capacity(count, pixel_shape)
1329
1358
  groups["_surface_workspace"] = (
1330
- pipe.empty((frame_count + (3 if rank == 3 else 0)) * rank, dtype=torch.float64),
1359
+ pipe.empty((count if shared else 1) * stride, dtype=torch.float64),
1331
1360
  pipe.empty((3 if rank == 3 else 2) * capacity + 1, dtype=torch.float64), capacity,
1332
1361
  )
1333
1362
  packed_frames, packed_offsets, capacity = groups["_surface_workspace"]
1334
1363
  columns = (0, 1, 2) if rank == 3 else (0, 2)
1335
1364
  source, points = targets[0].view(count, rank), targets[1].view(count, -1, rank)
1365
+ # A workspace sized for an earlier, smaller tile keeps the segment path.
1366
+ shared = shared and capacity >= pixel_count
1367
+ if shared and packed_frames.numel() < count * stride:
1368
+ # Earlier launches on the upload stream may still read the smaller buffer.
1369
+ pipe.retain(pipe.event(pipe.upload), packed_frames, None)
1370
+ packed_frames = pipe.empty(count * stride, dtype=torch.float64)
1371
+ groups["_surface_workspace"] = (packed_frames, packed_offsets, capacity)
1372
+ # Every job of a call slices the same offset snapshot: stage it once per
1373
+ # call, pixel tile and workspace. The key holds the snapshot and the
1374
+ # workspace, so neither a later snapshot reusing storage nor a recreated
1375
+ # workspace can match it.
1376
+ if shared and cacheable:
1377
+ staged_key = (geometry[frame_count], packed_offsets, tuple((part.start, part.stop) for part in pixels))
1378
+ restage = not ("_surface_shared_staged" in groups
1379
+ and groups["_surface_shared_staged"][0] is staged_key[0]
1380
+ and groups["_surface_shared_staged"][1] is staged_key[1]
1381
+ and groups["_surface_shared_staged"][2] == staged_key[2])
1382
+ else:
1383
+ restage = shared
1384
+ groups.pop("_surface_shared_staged", None)
1336
1385
  with torch.cuda.device(pipe.device), torch.cuda.stream(pipe.upload), cuda.gpus[pipe.device.index]:
1337
1386
  if origin is not None:
1338
1387
  pipe.upload.wait_stream(origin)
@@ -1340,7 +1389,51 @@ class Projector:
1340
1389
  stream = _get_numba_external_stream_for(pipe.upload)
1341
1390
  arrays = tuple(TorchCUDABridge.tensor_to_cuda_array(value)
1342
1391
  for value in (packed_frames, packed_offsets, source, points))
1343
- for local in range(count):
1392
+ if restage:
1393
+ # Pack the float64 columns where the offsets live, then copy each
1394
+ # column into the workspace without a device staging buffer.
1395
+ flat = offsets.detach().reshape(pixel_count, 3)
1396
+ if offsets.is_cuda:
1397
+ for column, original in enumerate(columns):
1398
+ packed_offsets[column * capacity:column * capacity + pixel_count].copy_(flat[:, original])
1399
+ else:
1400
+ host = flat[:, list(columns)].T.to(dtype=torch.float64).contiguous()
1401
+ for column in range(len(columns)):
1402
+ _pipeline._copy(packed_offsets[column * capacity:column * capacity + pixel_count],
1403
+ host[column], pipe.stats)
1404
+ pipe.retain(pipe.event(pipe.upload), host, packed_offsets)
1405
+ if cacheable:
1406
+ groups["_surface_shared_staged"] = staged_key
1407
+ if shared:
1408
+ # Stage the frame packets of every view in one transfer and
1409
+ # compose all views with one launch; grid row y composes view y.
1410
+ block = packed_frames[:count * stride].view(count, stride)
1411
+ packet = torch.zeros((count, stride), dtype=torch.float64)
1412
+ for index, value in enumerate(frames):
1413
+ packet[:, index * rank:(index + 1) * rank].copy_(value[views].detach())
1414
+ _pipeline._copy(block, packet, pipe.stats)
1415
+ pipe.retain(pipe.event(pipe.upload), packet, packed_frames)
1416
+ if rank == 3:
1417
+ normal = block[:, 4 * rank:5 * rank]
1418
+ axis_u, axis_v = block[:, 2 * rank:3 * rank], block[:, 3 * rank:4 * rank]
1419
+ if frame32[2] and frame32[3]:
1420
+ scratch = block.view(torch.float32)[:, 10 * rank:13 * rank]
1421
+ scratch[:, :rank].copy_(axis_u)
1422
+ scratch[:, rank:2 * rank].copy_(axis_v)
1423
+ torch.linalg.cross(scratch[:, :rank], scratch[:, rank:2 * rank], dim=1,
1424
+ out=scratch[:, 2 * rank:])
1425
+ normal.copy_(scratch[:, 2 * rank:])
1426
+ else:
1427
+ torch.linalg.cross(axis_u, axis_v, dim=1, out=normal)
1428
+ for first in range(0, count, 65535):
1429
+ rows = min(65535, count - first)
1430
+ compose_surface[((pixel_count + 127) // 128, rows), 128, stream](
1431
+ TorchCUDABridge.tensor_to_cuda_array(block[first:first + rows].reshape(-1)),
1432
+ *arrays[1:], first, 0, pixel_count, capacity, rank,
1433
+ self.beam == "parallel", frame32, offset32,
1434
+ tuple(centre) + (0.,) * (3 - rank), self.voxel_spacing,
1435
+ )
1436
+ for local in range(0 if shared else count):
1344
1437
  packet = torch.cat([value[views.start + local].detach().to(device="cpu", dtype=torch.float64)
1345
1438
  for value in frames])
1346
1439
  _pipeline._copy(packed_frames[:frame_count * rank], packet, pipe.stats)
@@ -2139,6 +2232,7 @@ class Projector:
2139
2232
  slot.finish()
2140
2233
  pipe.reclaim_output()
2141
2234
  del local_groups["_surface_workspace"]
2235
+ local_groups.pop("_surface_shared_staged", None)
2142
2236
  reduced = surface_batch_vjp(
2143
2237
  self.beam, self.detector_shape, self.detector_spacing, inputs, offsets,
2144
2238
  global_slice, pixels, self.voxel_spacing, pipe.device, parts,
diffct/surfaces.py CHANGED
@@ -10,6 +10,8 @@ class _GeometrySpec:
10
10
  kind: str
11
11
  sampler: object = None
12
12
  dtype: object = None
13
+ # One (U, V, 3) host offset snapshot shared by all views.
14
+ shared: bool = False
13
15
 
14
16
 
15
17
  class ParameterizedSurface:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diffct
3
- Version: 2.4.1
3
+ Version: 2.4.2
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/
@@ -1,4 +1,4 @@
1
- diffct/__init__.py,sha256=yYLUQ2lVM28RH2J4YlSafnN7-QCgrf9CgtKrzdu4E5Y,2965
1
+ diffct/__init__.py,sha256=saZamKDQ9CLB1HoVwIeZG6HMWNnbZbdNXtaT_MIpDLw,2965
2
2
  diffct/_solver_state.py,sha256=DKfrgrKcSpx3YetbF8hjA2yzMzJag-iHVpMZGup_dLw,16258
3
3
  diffct/_solver_tv.py,sha256=9nE1DVRzdUlcfd-OQQ86GpntX6CcaVvsSVWK-L4uR_I,5783
4
4
  diffct/analytical.py,sha256=u2PUp8GTNHoqwP2S4s0y9xNjiVBLfnYMULuuThcWmdU,33245
@@ -9,7 +9,7 @@ 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=QH7TedxIc35616bn0PwgaLDllH3Xt3aadFzIpbe8y0M,158446
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
@@ -17,7 +17,7 @@ diffct/solvers.py,sha256=ZroVUlhHOE2IAXB0clw9hUCvomrFSgYdGRQWwnSFZxs,17297
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
20
+ diffct/surfaces.py,sha256=z_0O98H7eMdSd9qrgUtcg5hBaE643s6sG_AjnH0Dyhw,7935
21
21
  diffct/utils.py,sha256=cih3GyZ33gnOrTW7O8r2_aXIP2a-rFjOMuAPuvHeQ4I,14794
22
22
  diffct/kernels/__init__.py,sha256=YNdsqAkexN25pI7QaHy4phAnQNICQ7tO8K8S1zt7TnI,1899
23
23
  diffct/kernels/analytical_transpose.py,sha256=D4vr_ZFPrjFeQyhoDqTyijbkTY-g-sHJet52ho-cbkQ,9187
@@ -25,9 +25,9 @@ diffct/kernels/cone_beam.py,sha256=jKlbqQPHhrng-pTbyMFx2b2l0pdQIMiaaB5hj6ayPQI,2
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=eXD_aqW0e6vBMsS0R5oZ-cyedK_wrX7RDjFfiM0HIg4,5602
29
+ diffct-2.4.2.dist-info/METADATA,sha256=0NsA9YkbCMTwARCVFc1RCqY53W8-zwGjK6ZNOlbcTgU,17661
30
+ diffct-2.4.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
31
+ diffct-2.4.2.dist-info/licenses/LICENSE,sha256=a1fjzGs44LpDnV_LmA2Tzp8z-eJKoNVVdLOnh-F6J6E,11344
32
+ diffct-2.4.2.dist-info/licenses/NOTICE,sha256=du9I2sDF5IDfiY9vF64WWzMJh-MarpVXyLfWQmhbvmI,714
33
+ diffct-2.4.2.dist-info/RECORD,,
File without changes