pelagos-decoder 0.1.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.
pelagos/process.py ADDED
@@ -0,0 +1,287 @@
1
+ # Copyright (c) 2026 Advanced Micro Devices, Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Opt-in process-per-GPU orchestration; the single-GPU decoder is unchanged.
4
+
5
+ Results are owned CPU tensors. Each decoder instance reuses its workers until
6
+ close(). Device indices refer to the caller's visible device list.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ import os
12
+ from pathlib import Path
13
+ import socket
14
+ import subprocess
15
+ import sys
16
+ import shutil
17
+ import tempfile
18
+ import threading
19
+ import time
20
+ from concurrent.futures import ThreadPoolExecutor
21
+ from multiprocessing import shared_memory
22
+ from multiprocessing.connection import Connection
23
+
24
+ import numpy as np
25
+
26
+
27
+ def layout_copy(array):
28
+ """Return a C-contiguous copy of a matrix.
29
+
30
+ A strided (e.g. transposed) uint8 matrix is copied in blocks of 32 columns, which
31
+ keeps memory access cache-friendly: for syndrome batches this is 3-8x faster than
32
+ ``np.ascontiguousarray``. Other inputs fall back to ``np.ascontiguousarray``.
33
+ """
34
+ array = np.asarray(array)
35
+ if array.ndim != 2 or array.dtype != np.uint8 or array.flags.c_contiguous:
36
+ return np.ascontiguousarray(array)
37
+ result = np.empty(array.shape, dtype=array.dtype)
38
+ for column in range(0, array.shape[1], 32):
39
+ result[:, column:column + 32] = array[:, column:column + 32]
40
+ return result
41
+
42
+
43
+ def split_batch(batch, workers):
44
+ if batch < 0 or workers < 1:
45
+ raise ValueError("batch must be nonnegative and workers positive")
46
+ boundaries = []
47
+ start = 0
48
+ for rank in range(workers):
49
+ end = start + batch // workers + (rank < batch % workers)
50
+ boundaries.append((start, end))
51
+ start = end
52
+ return boundaries
53
+
54
+
55
+ def prepare_batch(check_matrix, observables, errors, workers, multiply_mod2):
56
+ """Prepare disjoint host shards concurrently, preserving original row order."""
57
+ def prepare(bounds):
58
+ start, end = bounds
59
+ shard = errors[start:end]
60
+ obs = multiply_mod2(observables, shard.T).T
61
+ syndromes = np.asarray((check_matrix @ shard.T) % 2, dtype=np.uint8).T
62
+ return obs, syndromes
63
+
64
+ splits = [(start, end) for start, end in split_batch(len(errors), workers) if start < end]
65
+ if not splits:
66
+ raise ValueError("empty error batch")
67
+ if len(splits) == 1:
68
+ obs, syndromes = prepare(splits[0])
69
+ else:
70
+ with ThreadPoolExecutor(max_workers=len(splits)) as executor:
71
+ parts = list(executor.map(prepare, splits))
72
+ obs = np.concatenate([p[0] for p in parts], axis=0)
73
+ syndromes = np.concatenate([p[1] for p in parts], axis=0)
74
+ return obs, layout_copy(syndromes)
75
+
76
+
77
+ def worker_environment(device, hip, environment=None):
78
+ env = dict(os.environ if environment is None else environment)
79
+ key = "ROCR_VISIBLE_DEVICES" if hip else "CUDA_VISIBLE_DEVICES"
80
+ # HIP visibility is relative to ROCR visibility. Resolve it before narrowing.
81
+ visible = env.get(key)
82
+ devices = visible.split(",") if visible is not None else None
83
+ if hip:
84
+ secondary = env.get("HIP_VISIBLE_DEVICES", env.get("CUDA_VISIBLE_DEVICES"))
85
+ if secondary is not None:
86
+ indices = [int(x) for x in secondary.split(",") if x]
87
+ if any(x < 0 for x in indices):
88
+ raise ValueError("No usable visible GPU")
89
+ devices = [devices[x] if devices is not None else str(x) for x in indices]
90
+ if device < 0 or (devices is not None and device >= len(devices)):
91
+ raise ValueError("device is outside the caller's visible GPU list")
92
+ env[key] = devices[device] if devices is not None else str(device)
93
+ if hip:
94
+ env["HIP_VISIBLE_DEVICES"] = "0"
95
+ env["CUDA_VISIBLE_DEVICES"] = "0"
96
+ return env
97
+
98
+
99
+ def validate_payload(payload):
100
+ for key, array in payload.items():
101
+ if not np.issubdtype(array.dtype, np.inexact):
102
+ continue
103
+ bad = ~np.isfinite(array)
104
+ if not bad.any():
105
+ continue
106
+ valid = payload.get("valid_mask")
107
+ if (key != "weights" or valid is None or valid.shape != array.shape
108
+ or np.any(bad & valid) or not np.all(np.isposinf(array[bad]))):
109
+ raise ValueError(f"nonfinite decoder output: {key}")
110
+ if np.any(payload["iterations"] < 0):
111
+ raise ValueError("negative iteration count")
112
+
113
+
114
+ class ProcessRelayBPDecoder:
115
+ """Persistent Linux GPU workers with static sharding and shared error output.
116
+
117
+ Constructor arguments match RelayBPDecoder, plus worker_timeout_s. Advanced
118
+ per-call decode overrides and layered scheduling are explicitly unsupported.
119
+ No global monkeypatches, shared pool cache, or autotuning policy changes.
120
+ """
121
+
122
+ def __init__(self, *args, worker_timeout_s=300, **kwargs):
123
+ import torch
124
+ from .decoder import RelayBPDecoder
125
+
126
+ if os.name != "posix":
127
+ raise RuntimeError("Process backend requires POSIX descriptor passing")
128
+ bound = inspect.signature(RelayBPDecoder).bind(*args, **kwargs)
129
+ bound.apply_defaults()
130
+ ctor = dict(bound.arguments)
131
+ devices = ctor.pop("device")
132
+ if isinstance(devices, int):
133
+ devices = list(range(devices))
134
+ elif isinstance(devices, str):
135
+ devices = [0] if devices in ("auto", "cuda") else [int(x) for x in devices.split(",")]
136
+ self.device_ids = list(devices)
137
+ if not self.device_ids or len(set(self.device_ids)) != len(self.device_ids):
138
+ raise ValueError("device list must be nonempty and unique")
139
+ if any(not isinstance(x, int) or x < 0 for x in self.device_ids):
140
+ raise ValueError("device indices must be nonnegative integers")
141
+ if ctor["scheduling"] != "flood-fill":
142
+ raise ValueError("process backend currently supports flood-fill only")
143
+ if not np.isfinite(worker_timeout_s) or worker_timeout_s <= 0:
144
+ raise ValueError("worker_timeout_s must be finite and positive")
145
+ self.num_gpus = len(self.device_ids)
146
+ self.pre_iter = ctor["pre_iter"]
147
+ self.num_sets = ctor["num_sets"]
148
+ self.fast_max_unconverged_pct = ctor["fast_max_unconverged_pct"]
149
+ self.bitpack_output = ctor["bitpack_output"]
150
+ self.check_permutation = None
151
+ self._shape = ctor["H_csr"].shape
152
+ self._timeout = worker_timeout_s
153
+ self._workers = []
154
+ self._shared = None
155
+ self._shared_array = None
156
+ self._lock = threading.Lock()
157
+ self._closed = False
158
+ self._cache = tempfile.TemporaryDirectory(prefix="pelagos-process-")
159
+ try:
160
+ for device in self.device_ids:
161
+ env = worker_environment(device, torch.version.hip is not None)
162
+ # Preserve the selected checkout when launched without installation.
163
+ root = str(Path(__file__).resolve().parents[1])
164
+ env["PYTHONPATH"] = os.pathsep.join([root, env.get("PYTHONPATH", "")])
165
+ cache = Path(self._cache.name) / str(device)
166
+ cache.mkdir()
167
+ env["TRITON_CACHE_DIR"] = str(cache / "triton")
168
+ tune = env.get("RELAY_TUNE_CACHE")
169
+ if tune and Path(tune).is_file():
170
+ shutil.copyfile(tune, cache / "tune.json")
171
+ env["RELAY_TUNE_CACHE"] = str(cache / "tune.json")
172
+ env["RELAY_TUNE_LOG_CSV"] = str(cache / "autotune.csv")
173
+ left, right = socket.socketpair()
174
+ try:
175
+ proc = subprocess.Popen(
176
+ [sys.executable, "-m", "pelagos._process_worker", str(right.fileno())],
177
+ pass_fds=(right.fileno(),), env=env,
178
+ )
179
+ conn = Connection(left.detach())
180
+ finally:
181
+ left.close()
182
+ right.close()
183
+ self._workers.append((proc, conn))
184
+ conn.send(ctor)
185
+ deadline = time.monotonic() + self._timeout
186
+ for _, conn in self._workers:
187
+ metadata = self._receive(conn, "ready", deadline)
188
+ self.wj = torch.from_numpy(metadata["wj"])
189
+ except BaseException:
190
+ self.close()
191
+ raise
192
+
193
+ def _receive(self, conn, expected, deadline):
194
+ if not conn.poll(max(0, deadline - time.monotonic())):
195
+ raise TimeoutError("process decoder worker timed out")
196
+ try:
197
+ status, payload = conn.recv()
198
+ except EOFError as exc:
199
+ raise RuntimeError("process decoder worker exited") from exc
200
+ if status != expected:
201
+ raise RuntimeError(f"process decoder worker: {status}: {payload}")
202
+ return payload
203
+
204
+ def decode(self, syndromes, **kwargs):
205
+ import torch
206
+ if kwargs:
207
+ raise ValueError("process backend does not support per-call decode overrides")
208
+ if not self._lock.acquire(blocking=False):
209
+ raise RuntimeError("concurrent decode calls are not supported")
210
+ try:
211
+ if self._closed:
212
+ raise RuntimeError("decoder is closed")
213
+ if isinstance(syndromes, torch.Tensor):
214
+ if syndromes.device.type != "cpu":
215
+ raise ValueError("process backend requires host syndromes")
216
+ syndromes = syndromes.detach().numpy()
217
+ array = np.asarray(syndromes)
218
+ if array.ndim != 2 or array.shape[1] != self._shape[0] or not array.shape[0]:
219
+ raise ValueError("expected nonempty [batch, checks] syndromes")
220
+ if not np.all((array == 0) | (array == 1)):
221
+ raise ValueError("syndromes must be binary")
222
+ array = layout_copy(array.astype(np.uint8, copy=False))
223
+ batch = len(array)
224
+ if not self.bitpack_output:
225
+ shape = (batch, self._shape[1])
226
+ if self._shared_array is None or self._shared_array.shape != shape:
227
+ self._release_shared()
228
+ self._shared = shared_memory.SharedMemory(create=True, size=int(np.prod(shape)))
229
+ self._shared_array = np.ndarray(shape, dtype=np.uint8, buffer=self._shared.buf)
230
+ parts = []
231
+ deadline = time.monotonic() + self._timeout
232
+ for (_, conn), (start, end) in zip(self._workers, split_batch(batch, self.num_gpus)):
233
+ if start == end:
234
+ continue
235
+ shared = None if self.bitpack_output else (self._shared.name, self._shared_array.shape, start, end)
236
+ conn.send((array[start:end].copy(), shared))
237
+ parts.append((start, conn))
238
+ results = [(start, self._receive(conn, "ok", deadline)) for start, conn in parts]
239
+ keys = set(results[0][1])
240
+ if any(set(result) != keys for _, result in results):
241
+ raise RuntimeError("inconsistent worker result keys")
242
+ output = {}
243
+ for key in keys:
244
+ chunks = [value[key] + start if key == "unconverged_indices" else value[key]
245
+ for start, value in results]
246
+ output[key] = torch.from_numpy(np.concatenate(chunks, axis=0))
247
+ if not self.bitpack_output:
248
+ output["errors"] = torch.from_numpy(self._shared_array.copy())
249
+ return output
250
+ except (RuntimeError, TimeoutError, EOFError, BrokenPipeError):
251
+ self.close()
252
+ raise
253
+ finally:
254
+ self._lock.release()
255
+
256
+ def _release_shared(self):
257
+ self._shared_array = None
258
+ if self._shared is not None:
259
+ self._shared.close()
260
+ self._shared.unlink()
261
+ self._shared = None
262
+
263
+ def close(self):
264
+ self._closed = True
265
+ for proc, conn in self._workers:
266
+ conn.close()
267
+ if proc.poll() is None:
268
+ proc.terminate()
269
+ try:
270
+ proc.wait(timeout=5)
271
+ except subprocess.TimeoutExpired:
272
+ proc.kill()
273
+ proc.wait()
274
+ self._workers.clear()
275
+ self._release_shared()
276
+ if hasattr(self, "_cache"):
277
+ self._cache.cleanup()
278
+
279
+ def __enter__(self):
280
+ return self
281
+
282
+ def __exit__(self, *exc):
283
+ self.close()
284
+
285
+ def __del__(self):
286
+ if hasattr(self, "_workers"):
287
+ self.close()
@@ -0,0 +1,11 @@
1
+ # (C) Copyright IBM 2026
2
+ #
3
+ # This code is licensed under the Apache License, Version 2.0. You may
4
+ # obtain a copy of this license in the LICENSE file in the root directory
5
+ # of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
6
+ #
7
+ # Any modifications or derivative works of this code must retain this
8
+ # copyright notice, and modified files need to carry a notice indicating
9
+ # that they have been altered from the originals.
10
+
11
+ """Namespace package."""
@@ -0,0 +1,11 @@
1
+ # (C) Copyright IBM 2026
2
+ #
3
+ # This code is licensed under the Apache License, Version 2.0. You may
4
+ # obtain a copy of this license in the LICENSE file in the root directory
5
+ # of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
6
+ #
7
+ # Any modifications or derivative works of this code must retain this
8
+ # copyright notice, and modified files need to carry a notice indicating
9
+ # that they have been altered from the originals.
10
+
11
+ """Namespace package."""
@@ -0,0 +1,112 @@
1
+ # (C) Copyright IBM 2025, 2026
2
+ #
3
+ # This code is licensed under the Apache License, Version 2.0. You may
4
+ # obtain a copy of this license in the LICENSE file in the root directory
5
+ # of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
6
+ #
7
+ # Any modifications or derivative works of this code must retain this
8
+ # copyright notice, and modified files need to carry a notice indicating
9
+ # that they have been altered from the originals.
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ from scipy.sparse import csc_matrix
16
+ import numpy as np
17
+
18
+ # We use belief matchings utilities to extract the decoding matrices
19
+ from beliefmatching import detector_error_model_to_check_matrices
20
+
21
+
22
+ @dataclass
23
+ class CheckMatrices:
24
+ check_matrix: csc_matrix
25
+ observables_matrix: csc_matrix
26
+ error_priors: np.ndarray
27
+ syndrome_bias: np.ndarray | None = None
28
+ observables_bias: np.ndarray | None = None
29
+
30
+ @classmethod
31
+ def from_dem(
32
+ cls,
33
+ dem,
34
+ decomposed_hyperedges: bool | None = None,
35
+ prune_decided_errors: bool = True,
36
+ threshold: float = 0.0,
37
+ ) -> CheckMatrices:
38
+ """Get decoding check matrices from dem.
39
+
40
+ Args:
41
+ dem: Detector error model to extract matrices from.
42
+ decomposed_hyperedges: If errors have been decomposed we return only the remaining errors impacting observables.
43
+ If none, we try to detect if decomposable to reduce decoding time.
44
+ prune_decided_errors: Prune decided errors at construction time.
45
+ threshold: A threshold magnitude to clamp probabilities of 0+threshold -> 0 and 1-threshold -> 1 for error pruning.
46
+ """
47
+ # We use belief matchings utilities to extract the decoding matrices
48
+ try:
49
+ dem_matrices = detector_error_model_to_check_matrices(
50
+ dem,
51
+ allow_undecomposed_hyperedges=decomposed_hyperedges is not None
52
+ and not decomposed_hyperedges,
53
+ )
54
+ decomposed_hyperedges = True
55
+ except ValueError as err:
56
+ # the dem was not CSS, try again allowing for undecomposed errors
57
+ if decomposed_hyperedges is None:
58
+ decomposed_hyperedges = False
59
+ dem_matrices = detector_error_model_to_check_matrices(
60
+ dem, allow_undecomposed_hyperedges=True
61
+ )
62
+ else:
63
+ raise ValueError("The input dem is not decomposable") from err
64
+
65
+ if decomposed_hyperedges:
66
+ observables_matrix = dem_matrices.edge_observables_matrix
67
+ check_matrix = dem_matrices.edge_check_matrix
68
+ error_priors = dem_matrices.hyperedge_to_edge_matrix @ dem_matrices.priors
69
+ else:
70
+ observables_matrix = dem_matrices.observables_matrix
71
+ check_matrix = dem_matrices.check_matrix
72
+ error_priors = dem_matrices.priors
73
+
74
+ check_matrices = cls(
75
+ check_matrix=check_matrix,
76
+ observables_matrix=observables_matrix,
77
+ error_priors=error_priors.astype(np.float64),
78
+ )
79
+ if prune_decided_errors:
80
+ check_matrices = check_matrices.prune_decided_errors(threshold=threshold)
81
+
82
+ return check_matrices
83
+
84
+ def prune_decided_errors(self, threshold: float = 0.0) -> CheckMatrices:
85
+ """Prune decided errors from the check matrices.
86
+
87
+ Args:
88
+ threshold: A threshold magnitude to clamp probabilities of 0+threshold -> 0 and 1-threshold -> 1 for error pruning.
89
+ """
90
+ to_zero = np.argwhere(self.error_priors <= 0.0 + threshold).reshape(-1)
91
+ to_one = np.argwhere(self.error_priors >= 1.0 - threshold).reshape(-1)
92
+
93
+ if len(to_one):
94
+ syndrome_bias = np.sum(self.check_matrix[:, to_one], axis=1).reshape(-1) % 2
95
+ observables_bias = np.sum(self.check_matrix[:, to_one], axis=1).reshape(-1) % 2
96
+ else:
97
+ syndrome_bias = None
98
+ observables_bias = None
99
+
100
+ prune = np.concatenate([to_zero, to_one])
101
+ keep = np.where(np.logical_not(np.isin(np.arange(len(self.error_priors)), prune)))[0]
102
+ check_matrix = self.check_matrix[:, keep]
103
+ observables_matrix = self.observables_matrix[:, keep]
104
+ error_priors = self.error_priors[keep]
105
+
106
+ return CheckMatrices(
107
+ check_matrix=check_matrix,
108
+ observables_matrix=observables_matrix,
109
+ error_priors=error_priors,
110
+ syndrome_bias=syndrome_bias,
111
+ observables_bias=observables_bias,
112
+ )