segresnet-mlx 0.2.1__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.

Potentially problematic release.


This version of segresnet-mlx might be problematic. Click here for more details.

@@ -0,0 +1,195 @@
1
+ """MONAI 1.6 SegResNet inference graph in MLX, using NCDHW tensors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import TYPE_CHECKING, cast
7
+
8
+ if TYPE_CHECKING:
9
+ from medmlx_core.compensated import CompensatedFloat32Operators, CompensatedFloat48Operators
10
+
11
+ from segresnet_mlx._types import MLX, Array
12
+
13
+ from medmlx_core.layout import require_ncdhw
14
+ from medmlx_core.ops import conv_transpose3d_ncdhw, upsample_trilinear_ncdhw
15
+ from medmlx_core.precision import Float32Operators
16
+
17
+ from segresnet_mlx.topology import ArithmeticProfile, SegResNetSpec
18
+
19
+
20
+ def group_mean(values: Array, mx: MLX) -> Array:
21
+ """Mean over the last axis of ``(N, G, S)`` values, summed in 256-element tiles.
22
+
23
+ Tiling bounds the float32 accumulation depth of a group that spans a whole
24
+ volume. Returns ``(N, G, 1)``. ``SegResNet`` does not call this (its GroupNorm
25
+ comes from the ``medmlx_core`` operators); it is exercised by
26
+ ``tests/test_equivalence.py``.
27
+ """
28
+ size = int(values.shape[-1])
29
+ padded = mx.pad(values, [(0, 0), (0, 0), (0, (-size) % 256)])
30
+ tiles = mx.reshape(padded, (*values.shape[:2], -1, 256))
31
+ return mx.sum(mx.sum(tiles, axis=-1), axis=-1, keepdims=True) / size
32
+
33
+
34
+ class SegResNet:
35
+ """Raw-logit forward pass for MONAI's 3-D, group-normalized SegResNet.
36
+
37
+ Tensors are channels-first ``(N, C, D, H, W)`` float32 at every layer boundary.
38
+ ``weights`` uses MONAI's state-dict keys and Torch parameter layouts
39
+ (convolution weights are ``(out, in, kD, kH, kW)``); ``required_shapes`` in
40
+ ``segresnet_mlx.weights`` lists them. Inference only: dropout is omitted and
41
+ no activation is applied to the output.
42
+
43
+ ``arithmetic_profile`` selects the accumulation scheme (see the README):
44
+ ``"torch-order-fp32"`` (default), ``"compensated-fp32"``, or
45
+ ``"compensated-fp48"``, which needs ``upsample_mode="deconv"`` and raises
46
+ ``ValueError`` otherwise.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ spec: SegResNetSpec,
52
+ weights: Mapping[str, Array],
53
+ *,
54
+ mx: MLX,
55
+ arithmetic_profile: ArithmeticProfile = "torch-order-fp32",
56
+ ) -> None:
57
+ self.spec = spec
58
+ self._weights = weights
59
+ self._mx = mx
60
+ self.arithmetic_profile = arithmetic_profile
61
+ # Both convolution and normalization accumulation order affect sparse
62
+ # released-checkpoint logits; correcting only one leaves amplified drift.
63
+ if arithmetic_profile == "torch-order-fp32":
64
+ self._operators = Float32Operators(mx, spec.num_groups, spec.norm_eps)
65
+ elif arithmetic_profile == "compensated-fp32":
66
+ from medmlx_core.compensated import CompensatedFloat32Operators as AccuracyOperators
67
+
68
+ self._operators = AccuracyOperators(mx, spec.num_groups, spec.norm_eps)
69
+ elif arithmetic_profile == "compensated-fp48":
70
+ if spec.upsample_mode != "deconv":
71
+ raise ValueError("Paired precision requires transpose-convolution upsampling")
72
+ from medmlx_core.compensated import CompensatedFloat48Operators as PairedOperators
73
+
74
+ self._operators = PairedOperators(mx, spec.num_groups, spec.norm_eps)
75
+ else:
76
+ raise ValueError(f"Unknown SegResNet arithmetic profile {arithmetic_profile!r}")
77
+
78
+ def __call__(self, values: Array) -> Array:
79
+ """Return float32 logits ``(N, out_channels, D, H, W)`` for an ``(N, C, D, H, W)`` input.
80
+
81
+ Raises:
82
+ ValueError: If the input is not rank 5, has the wrong channel count, or a
83
+ spatial size is not divisible by ``2 ** len(spec.blocks_up)``.
84
+ """
85
+ if self.arithmetic_profile == "compensated-fp48":
86
+ operators = cast("CompensatedFloat48Operators", self._operators)
87
+ return operators.round(self.paired_forward(values))
88
+ return self._forward(values)
89
+
90
+ def paired_forward(self, values: Array) -> Array:
91
+ """Run the paired-precision graph without the final rounding.
92
+
93
+ Returns ``(2N, out_channels, D, H, W)``: the high words of the ``N`` logit
94
+ volumes followed by their low words. Their float64 sum is the logit; the
95
+ caller (``predict_wide_logits``) does that sum on the host.
96
+ """
97
+ if self.arithmetic_profile != "compensated-fp48":
98
+ raise ValueError("Paired logits require the compensated-fp48 profile")
99
+ operators = cast("CompensatedFloat48Operators", self._operators)
100
+ return self._forward(operators.pack(values))
101
+
102
+ def _forward(self, values: Array) -> Array:
103
+ require_ncdhw(values, name="SegResNet input")
104
+ if int(values.shape[1]) != self.spec.in_channels:
105
+ raise ValueError(
106
+ f"SegResNet input has {values.shape[1]} channels; expected {self.spec.in_channels}"
107
+ )
108
+
109
+ divisor = 2 ** len(self.spec.blocks_up)
110
+ if any(int(size) % divisor for size in values.shape[2:]):
111
+ raise ValueError(f"SegResNet spatial sizes must be divisible by {divisor}")
112
+ hidden = self.conv(values, "convInit.conv", padding=1)
113
+ skips: list[Array] = []
114
+ for stage, block_count in enumerate(self.spec.blocks_down):
115
+ if stage > 0:
116
+ hidden = self.conv(hidden, f"down_layers.{stage}.0.conv", padding=1, stride=2)
117
+ block_offset = 1
118
+ for block in range(block_count):
119
+ hidden = self.residual(hidden, f"down_layers.{stage}.{block + block_offset}")
120
+ skips.append(hidden)
121
+
122
+ for stage, block_count in enumerate(self.spec.blocks_up):
123
+ hidden = self.conv(hidden, f"up_samples.{stage}.0.conv", padding=0)
124
+ if self.spec.upsample_mode == "deconv":
125
+ prefix = f"up_samples.{stage}.1.deconv"
126
+ if self.arithmetic_profile != "torch-order-fp32":
127
+ hidden = cast("CompensatedFloat32Operators", self._operators).conv_transpose3d(
128
+ hidden, self._weights[f"{prefix}.weight"], self._weights[f"{prefix}.bias"]
129
+ )
130
+ else:
131
+ hidden = conv_transpose3d_ncdhw(
132
+ hidden,
133
+ self._weights[f"{prefix}.weight"],
134
+ self._weights[f"{prefix}.bias"],
135
+ padding=0,
136
+ output_padding=0,
137
+ stride=2,
138
+ mx=self._mx,
139
+ )
140
+ hidden = self._add(hidden, skips[-(stage + 2)])
141
+ else:
142
+ hidden = upsample_trilinear_ncdhw(hidden, mx=self._mx) + skips[-(stage + 2)]
143
+ for block in range(block_count):
144
+ hidden = self.residual(hidden, f"up_layers.{stage}.{block}")
145
+
146
+ hidden = self._norm(hidden, "conv_final.0")
147
+ hidden = self._relu(hidden)
148
+ return self.conv(hidden, "conv_final.2.conv", padding=0, bias=True)
149
+
150
+ def residual(self, values: Array, prefix: str) -> Array:
151
+ """Pre-activation residual block: (norm, ReLU, 3x3x3 conv) twice, plus the input."""
152
+ hidden = self._norm(values, f"{prefix}.norm1")
153
+ hidden = self._relu(hidden)
154
+ hidden = self.conv(hidden, f"{prefix}.conv1.conv", padding=1)
155
+ hidden = self._norm(hidden, f"{prefix}.norm2")
156
+ hidden = self._relu(hidden)
157
+ hidden = self.conv(hidden, f"{prefix}.conv2.conv", padding=1)
158
+ return self._add(hidden, values)
159
+
160
+ def _relu(self, values: Array) -> Array:
161
+ return (
162
+ cast("CompensatedFloat48Operators", self._operators).relu(values)
163
+ if self.arithmetic_profile == "compensated-fp48"
164
+ else self._mx.maximum(values, 0.0)
165
+ )
166
+
167
+ def _add(self, values: Array, other: Array) -> Array:
168
+ return (
169
+ cast("CompensatedFloat48Operators", self._operators).add(values, other)
170
+ if self.arithmetic_profile == "compensated-fp48"
171
+ else values + other
172
+ )
173
+
174
+ def _norm(self, values: Array, prefix: str) -> Array:
175
+ return self._operators.norm(
176
+ values, self._weights[f"{prefix}.weight"], self._weights[f"{prefix}.bias"]
177
+ )
178
+
179
+ def conv(
180
+ self,
181
+ values: Array,
182
+ prefix: str,
183
+ *,
184
+ padding: int,
185
+ stride: int = 1,
186
+ bias: bool = False,
187
+ ) -> Array:
188
+ """Convolve with ``{prefix}.weight`` (plus ``{prefix}.bias`` if ``bias``)."""
189
+ return self._operators.conv(
190
+ values,
191
+ self._weights[f"{prefix}.weight"],
192
+ self._weights.get(f"{prefix}.bias") if bias else None,
193
+ padding=padding,
194
+ stride=stride,
195
+ )
@@ -0,0 +1,303 @@
1
+ """Measured MLX window execution and NumPy full-volume raw-logit inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from contextlib import nullcontext
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING, Literal, cast
10
+
11
+ from segresnet_mlx._records import BundleConfig, InfererConfig
12
+ from segresnet_mlx._runtime import import_mlx
13
+
14
+ if TYPE_CHECKING:
15
+ from ignite.engine import Engine, Events
16
+
17
+ from segresnet_mlx._types import MLX, Array, CoreSlidingWindow
18
+ from segresnet_mlx.network import SegResNet
19
+
20
+ import numpy as np
21
+ from medmlx_core.errors import InvalidInputError
22
+ from medmlx_core.layout import require_ncdhw
23
+ from medmlx_core.typing import ArrayFactory
24
+
25
+ from segresnet_mlx._arrays import Float32Array, HostArray
26
+ from segresnet_mlx.medmlx import BUNDLES
27
+ from segresnet_mlx.telemetry import SCHEMA_VERSION, WindowTelemetry
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class SlidingWindowConfig:
32
+ """The bundle's ``SlidingWindowInferer`` settings; other options use MONAI defaults.
33
+
34
+ Attributes:
35
+ roi_size: Window size in voxels, ``(D, H, W)`` of the preprocessed volume.
36
+ sw_batch_size: Windows sent to the network per call.
37
+ overlap: Fraction of each window shared with its neighbour, in ``[0, 1)``.
38
+ mode: Window blending, ``"constant"`` or ``"gaussian"`` (sigma scale 0.125).
39
+ padding_mode: How a volume smaller than ``roi_size`` is padded: ``"constant"``
40
+ (zeros), ``"reflect"``, ``"replicate"`` or ``"circular"``.
41
+ """
42
+
43
+ roi_size: tuple[int, int, int]
44
+ sw_batch_size: int = 1
45
+ overlap: float = 0.25
46
+ mode: str = "constant"
47
+ padding_mode: str = "constant"
48
+
49
+ @classmethod
50
+ def from_bundle_config(cls, config: InfererConfig | BundleConfig) -> SlidingWindowConfig:
51
+ """Read the staged JSON's inferer section without evaluating bundle expressions.
52
+
53
+ Missing keys fall back to MONAI's defaults. Values are taken literally, so a
54
+ reference such as ``@displayable_configs#sw_overlap`` is not resolved.
55
+ """
56
+ inferer = cast(InfererConfig, config.get("inferer", config))
57
+ return cls(
58
+ cast(tuple[int, int, int], tuple(inferer["roi_size"])),
59
+ inferer.get("sw_batch_size", 1),
60
+ inferer.get("overlap", 0.25),
61
+ inferer.get("mode", "constant"),
62
+ inferer.get("padding_mode", "constant"),
63
+ )
64
+
65
+
66
+ # Window settings of each bundle in ``segresnet_mlx.medmlx.BUNDLES``.
67
+ BUNDLE_CONFIGS = {
68
+ name: SlidingWindowConfig(
69
+ bundle.roi_size, bundle.sw_batch_size, bundle.overlap, bundle.mode, bundle.padding_mode
70
+ )
71
+ for name, bundle in BUNDLES.items()
72
+ }
73
+
74
+ _PAD_MODES: dict[str, Literal["constant", "reflect", "edge", "wrap"]] = {
75
+ "constant": "constant",
76
+ "reflect": "reflect",
77
+ "replicate": "edge",
78
+ "circular": "wrap",
79
+ }
80
+
81
+
82
+ class BundlePredictor:
83
+ """Synchronized MLX window callback with an injected network.
84
+
85
+ ``forward`` retains the MLX-array callback API; ``predict_logits`` passes
86
+ synchronized NumPy logits directly to the host stitcher. Telemetry
87
+ lifecycle remains caller-owned here, allowing an engine to finish only after
88
+ postprocessing. The module-level ``predict_logits`` owns a direct-call session.
89
+ """
90
+
91
+ schema_version = SCHEMA_VERSION
92
+ network_label = "SegResNet"
93
+
94
+ def __init__(
95
+ self,
96
+ network: Callable[[Array], Array],
97
+ *,
98
+ telemetry: WindowTelemetry | None = None,
99
+ mx: MLX | None = None,
100
+ ) -> None:
101
+ self.network = network
102
+ self.telemetry = telemetry
103
+ try:
104
+ self.mx = import_mlx() if mx is None else mx
105
+ self.mx.reset_peak_memory()
106
+ if telemetry is not None:
107
+ telemetry.set_metadata(
108
+ {
109
+ "schema_version": self.schema_version,
110
+ "dtype": "float32",
111
+ "network_device": str(self.mx.default_device()),
112
+ "transforms_and_stitching_device": "cpu",
113
+ "compile": False,
114
+ "synchronization": "mx.eval and mx.synchronize before host export",
115
+ "window_timing_scope": (
116
+ "synchronized network, MLX input creation and host export; "
117
+ "excludes validation and telemetry"
118
+ ),
119
+ "peak_memory_scope": "predictor lifetime, including warmup",
120
+ }
121
+ )
122
+ except BaseException as error:
123
+ if telemetry is not None:
124
+ telemetry.fail(error)
125
+ raise
126
+
127
+ def attach(self, engine: Engine, *, events: type[Events]) -> None:
128
+ """Attach the telemetry (if any) to an Ignite ``engine`` using the caller's ``events``."""
129
+ if self.telemetry is not None:
130
+ self.telemetry.attach(engine, events=events)
131
+
132
+ def __call__(self, values: HostArray | Array) -> Array:
133
+ return self.forward(values)
134
+
135
+ def forward(self, values: HostArray | Array) -> Array:
136
+ """Run one finite float32 window and return its logits as an MLX array.
137
+
138
+ The logits are exported to host NumPy for the finite check and timing, then
139
+ wrapped again as an MLX array for callers that expect one.
140
+ """
141
+ try:
142
+ return cast(ArrayFactory, self.mx.array)(self._forward_window(values))
143
+ except BaseException as error:
144
+ if self.telemetry is not None:
145
+ self.telemetry.fail(error)
146
+ raise
147
+
148
+ def _forward_window(self, values: HostArray | Array) -> Float32Array:
149
+ host = self._host_input(values)
150
+ self.mx.synchronize()
151
+ started = time.perf_counter()
152
+ result = self.network(cast(ArrayFactory, self.mx.array)(host))
153
+ self.mx.eval(result)
154
+ self.mx.synchronize()
155
+ output = cast(Float32Array, np.array(result))
156
+ elapsed = time.perf_counter() - started
157
+ if not np.isfinite(output).all():
158
+ raise RuntimeError(f"{self.network_label} produced non-finite logits")
159
+ if self.telemetry is not None:
160
+ self.telemetry.observe(elapsed, self.mx.get_peak_memory())
161
+ # The allocator cache is kept across windows: every window has the same
162
+ # shapes, so later windows reuse the buffers instead of reallocating
163
+ # them. ``predict_logits`` releases the cache once the volume is done.
164
+ del result
165
+ return output
166
+
167
+ def _host_input(self, values: HostArray | Array) -> Float32Array:
168
+ host = np.asarray(values)
169
+ if host.dtype != np.float32 or not np.isfinite(host).all():
170
+ raise InvalidInputError(f"{self.network_label} input must be finite float32")
171
+ return cast(Float32Array, host)
172
+
173
+ def predict_logits(
174
+ self, values: HostArray | Array, configuration: SlidingWindowConfig
175
+ ) -> Float32Array:
176
+ """Stitch preprocessed ``(N, C, D, H, W)`` float32 data into full-volume logits.
177
+
178
+ Returns float32 NumPy ``(N, out_channels, D, H, W)`` at the input spatial
179
+ size. Volumes smaller than ``roi_size`` are padded and cropped back. Window
180
+ placement and blending follow MONAI's ``SlidingWindowInferer`` via the
181
+ shared core. Callers own telemetry completion.
182
+
183
+ Raises:
184
+ InvalidInputError: If ``values`` is not finite float32.
185
+ ValueError: If ``values`` is not rank 5.
186
+ """
187
+ import medmlx_core.sliding_window as core_sliding_window
188
+
189
+ sliding_window_inference = cast(
190
+ "CoreSlidingWindow", core_sliding_window
191
+ ).sliding_window_inference
192
+
193
+ try:
194
+ host = self._host_input(values)
195
+ require_ncdhw(host, name="SegResNet input")
196
+ if self.telemetry is not None:
197
+ self.telemetry.set_metadata({"sw_batch_size": configuration.sw_batch_size})
198
+ callback: (
199
+ Callable[[HostArray | Array], Float32Array]
200
+ | Callable[[HostArray | Array, list[list[slice]]], Float32Array]
201
+ ) = self._forward_window
202
+ use_coordinates = configuration.sw_batch_size == 1
203
+ if use_coordinates:
204
+ # MONAI preserves the sliced input's strides for batch size 1;
205
+ # the shared core concatenates even a single patch. MLX selects
206
+ # different reduction kernels for strided/contiguous inputs.
207
+ # Open gap: a core change that skips the single-patch
208
+ # concatenation would make this workaround unnecessary.
209
+ padding = [(0, 0), (0, 0)]
210
+ for size, roi in zip(host.shape[2:], configuration.roi_size, strict=True):
211
+ extra = max(roi - size, 0)
212
+ padding.append((extra // 2, extra - extra // 2))
213
+ # Torch padding materializes a contiguous array; unpadded data
214
+ # retains its original strides. Match the bundle's padding_mode
215
+ # so this fast path agrees with the core's own padding.
216
+ pad_kind = _PAD_MODES[configuration.padding_mode]
217
+ padded = (
218
+ np.ascontiguousarray(np.pad(host, padding, mode=pad_kind))
219
+ if any(before or after for before, after in padding)
220
+ else host
221
+ )
222
+
223
+ def coordinate_callback(
224
+ values: HostArray | Array, coordinates: list[list[slice]]
225
+ ) -> Float32Array:
226
+ return self._forward_window(padded[tuple(coordinates[0])])
227
+
228
+ callback = coordinate_callback
229
+
230
+ return sliding_window_inference(
231
+ host,
232
+ configuration.roi_size,
233
+ configuration.sw_batch_size,
234
+ callback,
235
+ overlap=configuration.overlap,
236
+ mode=configuration.mode,
237
+ padding_mode=configuration.padding_mode,
238
+ with_coord=use_coordinates,
239
+ predictor_input="numpy",
240
+ )
241
+ except BaseException as error:
242
+ if self.telemetry is not None:
243
+ self.telemetry.fail(error)
244
+ raise
245
+ finally:
246
+ self.mx.clear_cache()
247
+
248
+
249
+ def predict_logits(
250
+ values: HostArray | Array,
251
+ configuration: SlidingWindowConfig,
252
+ *,
253
+ network: Callable[[Array], Array],
254
+ telemetry: WindowTelemetry | None = None,
255
+ ) -> Float32Array:
256
+ """Return float32 NumPy NCDHW logits, completing optional window telemetry.
257
+
258
+ ``values`` is preprocessed, finite float32 ``(N, C, D, H, W)``; the result has the
259
+ same spatial size. Input preparation, activation, label mapping, image I/O and QC
260
+ are external.
261
+ """
262
+ with telemetry.session() if telemetry is not None else nullcontext():
263
+ predictor = BundlePredictor(network, telemetry=telemetry)
264
+ return predictor.predict_logits(values, configuration)
265
+
266
+
267
+ def predict_wide_logits(
268
+ network: SegResNet, values: Float32Array, configuration: SlidingWindowConfig, *, mx: MLX
269
+ ) -> Float32Array:
270
+ """Carry paired Metal logits into FP64 host blending, rounding only at export.
271
+
272
+ Preprocessed values and checkpoint parameters remain the same FP32 inputs.
273
+ The original MONAI float64 network/inferer is the independent oracle for this
274
+ explicit profile. The learned graph runs on Metal; host work is stitching.
275
+ """
276
+ from medmlx_core.sliding_window import sliding_window_inference
277
+
278
+ def window(
279
+ patch: np.ndarray[tuple[int, ...], np.dtype[np.float64]],
280
+ ) -> np.ndarray[tuple[int, ...], np.dtype[np.float64]]:
281
+ result = network.paired_forward(cast(ArrayFactory, mx.array)(patch.astype(np.float32)))
282
+ mx.eval(result)
283
+ mx.synchronize()
284
+ packed = np.array(result, dtype=np.float32)
285
+ batch = packed.shape[0] // 2
286
+ output = packed[:batch].astype(np.float64) + packed[batch:].astype(np.float64)
287
+ if not np.isfinite(output).all():
288
+ raise RuntimeError("SegResNet produced non-finite paired logits")
289
+ del result
290
+ mx.clear_cache()
291
+ return output
292
+
293
+ logits = sliding_window_inference(
294
+ values.astype(np.float64),
295
+ configuration.roi_size,
296
+ configuration.sw_batch_size,
297
+ window,
298
+ overlap=configuration.overlap,
299
+ mode=configuration.mode,
300
+ padding_mode=configuration.padding_mode,
301
+ predictor_input="numpy",
302
+ )
303
+ return logits.astype(np.float32)
segresnet_mlx/py.typed ADDED
File without changes
@@ -0,0 +1,166 @@
1
+ """Bounded progress records and explicit SegResNet inference lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ import tempfile
9
+ from collections.abc import Callable, Generator
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Literal, Protocol, cast
13
+
14
+ from segresnet_mlx._records import Json
15
+
16
+ if TYPE_CHECKING:
17
+ from ignite.engine import Events
18
+
19
+ import numpy as np
20
+
21
+
22
+ class EventEngine(Protocol):
23
+ def add_event_handler(self, event_name: Events, handler: Callable[..., None]) -> object: ...
24
+
25
+
26
+ SCHEMA_VERSION = "medmlx.segresnet.mlx.v1"
27
+
28
+
29
+ class WindowTelemetry:
30
+ """Atomic JSON progress record for sliding-window inference, times in seconds.
31
+
32
+ ``record`` is updated in place and written to ``path`` (parent directories are
33
+ created). While running it holds window counts and totals, refreshed after the
34
+ first window and then every 32nd. The raw ``window_seconds`` samples and their
35
+ p50/p95 are written only when the session finishes: samples stay in memory so the
36
+ growing list is not rewritten per window.
37
+ """
38
+
39
+ def __init__(self, path: Path, record: dict[str, Json]) -> None:
40
+ self.path = path
41
+ self.record = record
42
+ self._metadata = {"schema_version": SCHEMA_VERSION, **record}
43
+ self.timings: list[float] = []
44
+ self.path.parent.mkdir(parents=True, exist_ok=True)
45
+ self.begin()
46
+
47
+ def set_metadata(self, values: dict[str, Json]) -> None:
48
+ """Merge run-level metadata that is kept across ``begin`` calls."""
49
+ self._metadata.update(values)
50
+ self.record.update(values)
51
+ self._write()
52
+
53
+ def begin(self) -> None:
54
+ """Discard samples and start a new running record (``__init__`` calls this)."""
55
+ self.timings = []
56
+ self.record.clear()
57
+ self.record.update(self._metadata)
58
+ self.record.update(
59
+ status="running",
60
+ window_count=0,
61
+ window_total_seconds=0.0,
62
+ window_max_seconds=None,
63
+ peak_mlx_bytes=None,
64
+ window_samples_complete=False,
65
+ )
66
+ self._write()
67
+
68
+ def observe(self, seconds: float, peak_bytes: int | None) -> None:
69
+ """Add one window's duration (seconds) and the MLX peak memory so far (bytes).
70
+
71
+ Raises:
72
+ RuntimeError: If the session is closed.
73
+ ValueError: If ``seconds`` is negative or not finite.
74
+ """
75
+ if self.record["status"] != "running":
76
+ raise RuntimeError("SegResNet telemetry is closed; begin a new session")
77
+ if not math.isfinite(seconds) or seconds < 0:
78
+ raise ValueError("Window duration must be finite and nonnegative")
79
+ self.timings.append(seconds)
80
+ self.record["window_count"] = len(self.timings)
81
+ self.record["window_total_seconds"] = (
82
+ cast(float, self.record["window_total_seconds"]) + seconds
83
+ )
84
+ self.record["window_max_seconds"] = max(
85
+ cast(float | None, self.record["window_max_seconds"]) or 0, seconds
86
+ )
87
+ if peak_bytes is not None:
88
+ self.record["peak_mlx_bytes"] = max(
89
+ cast(int | None, self.record["peak_mlx_bytes"]) or 0, peak_bytes
90
+ )
91
+ # Constant-size snapshots. Quantiles and full raw samples are final-only.
92
+ if len(self.timings) == 1 or len(self.timings) % 32 == 0:
93
+ self._write()
94
+
95
+ def finish(self, status: Literal["completed", "failed", "interrupted"] = "completed") -> None:
96
+ """Close the record with ``status`` and write the raw samples and quantiles.
97
+
98
+ Does nothing if the record already has that status or has failed, or if a
99
+ completed finish follows an interruption.
100
+ """
101
+ previous = self.record["status"]
102
+ if previous in {status, "failed"} or (previous == "interrupted" and status == "completed"):
103
+ return
104
+ p50, p95 = np.percentile(self.timings, [50, 95]) if self.timings else (None, None)
105
+ self.record.update(
106
+ status=status,
107
+ window_seconds=list(self.timings),
108
+ window_samples_complete=True,
109
+ window_p50_seconds=None if p50 is None else float(p50),
110
+ window_p95_seconds=None if p95 is None else float(p95),
111
+ )
112
+ self._write()
113
+
114
+ def fail(self, error: BaseException) -> None:
115
+ """Record ``error``'s type and finish as failed, without masking ``error``."""
116
+ self.record["error_type"] = type(error).__name__
117
+ try:
118
+ self.finish("failed")
119
+ except Exception as record_error:
120
+ # An unavailable output directory must not replace the inference error.
121
+ error.add_note(f"Could not finalize SegResNet telemetry: {record_error!r}")
122
+
123
+ @contextmanager
124
+ def session(self) -> Generator[None]:
125
+ """Use around direct inferer calls that are not owned by a MONAI engine."""
126
+ self.begin()
127
+ try:
128
+ yield
129
+ self.finish()
130
+ except BaseException as error:
131
+ self.fail(error)
132
+ raise
133
+
134
+ def attach(self, engine: object, *, events: type[Events]) -> None:
135
+ """Attach caller-provided engine events before handlers that re-raise failures."""
136
+ attached = cast(EventEngine, engine)
137
+ attached.add_event_handler(events.STARTED, self._started)
138
+ attached.add_event_handler(events.COMPLETED, self._completed)
139
+ attached.add_event_handler(events.EXCEPTION_RAISED, self._failed)
140
+ for event in (events.TERMINATE, events.TERMINATE_SINGLE_EPOCH, events.INTERRUPT):
141
+ attached.add_event_handler(event, self._interrupted)
142
+
143
+ def _started(self, engine: object) -> None:
144
+ self.begin()
145
+
146
+ def _completed(self, engine: object) -> None:
147
+ self.finish()
148
+
149
+ def _failed(self, engine: object, error: BaseException) -> None:
150
+ self.fail(error)
151
+ # Registering EXCEPTION_RAISED otherwise makes Ignite swallow the error.
152
+ raise error
153
+
154
+ def _interrupted(self, engine: object, **kwargs: object) -> None:
155
+ self.finish("interrupted")
156
+
157
+ def _write(self) -> None:
158
+ # Atomic visibility for progress readers, not a crash-durability promise.
159
+ descriptor, name = tempfile.mkstemp(prefix=f".{self.path.name}.", dir=self.path.parent)
160
+ try:
161
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
162
+ json.dump(self.record, handle, indent=2, allow_nan=False)
163
+ handle.write("\n")
164
+ os.replace(name, self.path)
165
+ finally:
166
+ Path(name).unlink(missing_ok=True)