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/adapter.py ADDED
@@ -0,0 +1,858 @@
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
+ # Modified 2026 by AMD: opt-in process-per-GPU dispatch and parallel preparation.
12
+
13
+ """
14
+ Triton adapter for Relay-BP decoder providing compatibility with relay_bp interface.
15
+
16
+ This module provides adapter classes that wrap the Triton GPU implementation
17
+ to match the interface expected by the relay_bp package, enabling seamless
18
+ integration with existing benchmarking and analysis tools.
19
+
20
+ The adapter handles:
21
+ - Interface compatibility between Triton and Rust backends
22
+ - Observable decoding with error detection
23
+ - Batch processing with detailed iteration tracking
24
+ - Matrix format conversion and validation
25
+ """
26
+
27
+ import numpy as np
28
+ import torch
29
+ from dataclasses import dataclass
30
+ from typing import Dict, List, Optional
31
+
32
+ try:
33
+ import scipy.sparse as sp
34
+ except Exception:
35
+ sp = None
36
+
37
+ from .decoder import RelayBPDecoder as TritonDecoder
38
+
39
+
40
+ @dataclass
41
+ class DecodeResult:
42
+ """Result container for decoding with detailed statistics.
43
+
44
+ This is intentionally shaped to look like the Rust ``DecodeResult`` /
45
+ ``ObservableDecodeResult`` exposed through the relay_bp Python bindings.
46
+ This allows higher-level code to treat Triton
47
+ and Rust results uniformly.
48
+
49
+ Canonical (Rust-compatible) fields:
50
+ decoding: Physical error pattern (Array1<Bit> in Rust)
51
+ success: Whether the decoder successfully satisfied all checks
52
+ iterations: Number of BP iterations performed
53
+
54
+ Additional Triton-only convenience fields:
55
+ converged: Whether the lane converged according to Relay-BP stopping rule
56
+ error_detected: Whether a logical error was detected (from observables mismatch)
57
+ observables: Predicted observable values
58
+ unconverged_indices: Optional batch-level array of indices for unconverged syndromes.
59
+ found_count: Number of valid solutions found for the shot (0..stop_nconv). Set by the
60
+ detailed-batch methods of RelayDecoder.
61
+ """
62
+
63
+ # Rust-compatible public API
64
+ decoding: np.ndarray
65
+ success: bool
66
+ iterations: int
67
+
68
+ # Quality metric
69
+ decoding_quality: float = float("inf")
70
+
71
+ # Only populated when true errors are available (from_errors path).
72
+ unconverged_no_error: Optional[bool] = None
73
+ unconverged_logical_error: Optional[bool] = None
74
+ better_decoding_quality_error: Optional[bool] = None
75
+ worse_decoding_quality_error: Optional[bool] = None
76
+
77
+ # Triton-specific extras
78
+ converged: bool = False
79
+ error_detected: bool = False
80
+ observables: np.ndarray = None # type: ignore[assignment]
81
+ unconverged_indices: Optional[np.ndarray] = None
82
+ found_count: Optional[int] = None
83
+
84
+
85
+
86
+ class RelayDecoder:
87
+ """Adapter for Triton Relay-BP decoder providing relay_bp interface compatibility.
88
+
89
+ This class wraps the Triton GPU implementation to match the interface expected
90
+ by the relay_bp package, enabling seamless integration with existing tools.
91
+
92
+ The adapter handles matrix format conversion, parameter mapping, and provides
93
+ the same interface as the Rust implementation for benchmarking consistency.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ check_matrix,
99
+ *,
100
+ # High-level configuration dictionaries
101
+ msl_config: Optional[dict] = None,
102
+ relay_config: Optional[dict] = None,
103
+ data_type: Optional[str] = None,
104
+ backend_config: Optional[dict] = None,
105
+ **kwargs,
106
+ ):
107
+ """Create a Triton Relay-BP decoder.
108
+
109
+ High-level *config* style:
110
+ - ``msl_config``: Min-sum configuration dict (error_priors, alpha, gamma0, ...)
111
+ - ``relay_config``: Relay-BP configuration dict
112
+ - ``data_type``: decoder numeric type ("float", "float32", "int", ...)
113
+ - ``backend_config``: Triton backend-specific knobs (dtype, perf, device, fast_max_unconverged_pct, ...)
114
+ """
115
+ if msl_config is None or relay_config is None:
116
+ raise ValueError("msl_config and relay_config must be provided to Triton RelayDecoder")
117
+
118
+ msl_config = dict(msl_config)
119
+ relay_config = dict(relay_config)
120
+ backend_config = dict(backend_config or {})
121
+
122
+ # Min-sum / memory configuration
123
+ error_priors = msl_config.get("error_priors")
124
+ if error_priors is None:
125
+ raise ValueError("msl_config must contain 'error_priors'")
126
+
127
+ alpha = msl_config.get("alpha", None)
128
+ beta = msl_config.get("beta", None)
129
+ alpha_iteration_scaling_factor = msl_config.get("alpha_iteration_scaling_factor", 1.0)
130
+ gamma0 = msl_config.get("gamma0", 0.1)
131
+
132
+ # Relay configuration
133
+ pre_iter = relay_config.get("pre_iter", 80)
134
+ num_sets = relay_config.get("num_sets", 300)
135
+ set_max_iter = relay_config.get("set_max_iter", 60)
136
+ gamma_dist_interval = tuple(relay_config.get("gamma_dist_interval", (-0.24, 0.66)))
137
+ explicit_gammas = relay_config.get("explicit_gammas", None)
138
+ gamma_dist_interval_by_error = relay_config.get("gamma_dist_interval_by_error", None)
139
+ stop_nconv = int(relay_config.get("stop_nconv", 1))
140
+ self.stop_nconv = stop_nconv
141
+ seed = relay_config.get("seed", 0)
142
+ gamma_draw = str(relay_config.get("gamma_draw", "broadcast"))
143
+ # Correction for shots with no valid solution: "first_leg" or "last_leg".
144
+ unconverged_correction = str(relay_config.get("unconverged_correction", "first_leg"))
145
+
146
+ # Backend/Triton-specific configuration
147
+ data_type_cfg = backend_config.get("data_type", data_type)
148
+ # Normalize common aliases
149
+ _dtype_aliases = {
150
+ "float": "float32",
151
+ "half": "float16",
152
+ "double": "float64",
153
+ "fp16": "float16",
154
+ "fp32": "float32",
155
+ "fp64": "float64",
156
+ }
157
+ data_type_cfg = _dtype_aliases.get(data_type_cfg, data_type_cfg)
158
+ if data_type_cfg not in ("float16", "float32", "float64"):
159
+ raise ValueError(f"Unsupported data_type for Triton backend: {data_type_cfg!r}")
160
+ self.data_type = data_type_cfg
161
+
162
+ perf = backend_config.get("perf", "throughput")
163
+ device = backend_config.get("device", (0,))
164
+ # Normalize device specification so that the underlying Triton decoder
165
+ # always sees a type it can handle. Its _parse_device_spec expects a
166
+ # List[int], int, or str. Older call sites (and some defaults here)
167
+ # may use a tuple, so we convert tuples to lists.
168
+ # Also handle edge case where device might be integer 0 (should be [0])
169
+ if isinstance(device, tuple):
170
+ device = list(device)
171
+ elif isinstance(device, int):
172
+ # If device is a single integer, treat it as a single GPU ID [device]
173
+ # (not "auto-detect device GPUs" which would fail for device=0)
174
+ device = [device]
175
+ fast_max_unconverged_pct = backend_config.get("fast_max_unconverged_pct", None)
176
+ bitpack_output = bool(backend_config.get("bitpack_output", False))
177
+ scheduling = backend_config.get("scheduling", "flood-fill")
178
+ layered_k_iters = backend_config.get("layered_k_iters", 0)
179
+ layered_block_policy = backend_config.get("layered_block_policy", "contiguous")
180
+ layered_num_blocks = backend_config.get("layered_num_blocks", None)
181
+ layered_target_edges_per_block = backend_config.get("layered_target_edges_per_block", None)
182
+ layered_block_seed = backend_config.get("layered_block_seed", 42)
183
+
184
+ # Algorithm mode: "relay" (default) or "plain"
185
+ algo = backend_config.get("algo", "relay")
186
+ # Convert input to CSR matrix format (handles various sparse matrix types)
187
+ if sp is not None:
188
+ self.H_csr = sp.csr_matrix(check_matrix)
189
+ else:
190
+ # Fallback for environments without SciPy (not recommended)
191
+ H = np.asarray(check_matrix, dtype=np.uint8)
192
+ from scipy.sparse import csr_matrix as _csr # raises if SciPy missing
193
+
194
+ self.H_csr = _csr(H)
195
+
196
+ self.N = int(self.H_csr.shape[1])
197
+
198
+
199
+ _plain = num_sets == 0
200
+ _gamma_interval = (0.0, 0.0) if _plain else tuple(gamma_dist_interval)
201
+
202
+ # Initialize underlying Triton decoder with mapped parameters
203
+ execution = backend_config.get("execution", "thread")
204
+ decoder_type = TritonDecoder
205
+ process_options = {}
206
+ if execution == "process":
207
+ from .process import ProcessRelayBPDecoder
208
+ decoder_type = ProcessRelayBPDecoder
209
+ process_options["worker_timeout_s"] = backend_config.get("worker_timeout_s", 300)
210
+ elif execution != "thread":
211
+ raise ValueError("execution must be 'thread' or 'process'")
212
+ self._process_execution = execution == "process"
213
+ self._dec = decoder_type(
214
+ self.H_csr,
215
+ error_priors=error_priors,
216
+ pre_iter=int(pre_iter),
217
+ num_sets=int(num_sets),
218
+ set_max_iter=int(set_max_iter),
219
+ gamma0=float(gamma0),
220
+ gamma_dist_interval=_gamma_interval,
221
+ stop_nconv=int(stop_nconv),
222
+ normalized_min_sum_alpha=alpha,
223
+ offset_min_sum_beta=beta,
224
+ data_type=self.data_type,
225
+ device=device,
226
+ seed=int(seed) if seed is not None else 0,
227
+ gamma_draw=gamma_draw,
228
+ bitpack_output=bitpack_output,
229
+ algo=algo,
230
+ perf=perf,
231
+ alpha_iteration_scaling_factor=float(alpha_iteration_scaling_factor),
232
+ explicit_gammas=explicit_gammas,
233
+ gamma_dist_interval_by_error=gamma_dist_interval_by_error,
234
+ fast_max_unconverged_pct=fast_max_unconverged_pct,
235
+ scheduling=scheduling,
236
+ layered_k_iters=layered_k_iters,
237
+ layered_block_policy=layered_block_policy,
238
+ layered_num_blocks=layered_num_blocks,
239
+ layered_target_edges_per_block=layered_target_edges_per_block,
240
+ layered_block_seed=layered_block_seed,
241
+ unconverged_correction=unconverged_correction,
242
+ **process_options,
243
+ )
244
+
245
+ def close(self):
246
+ """Release persistent workers when using process execution."""
247
+ if self._process_execution:
248
+ self._dec.close()
249
+
250
+ def __enter__(self):
251
+ return self
252
+
253
+ def __exit__(self, *exc):
254
+ self.close()
255
+
256
+ def _decode_inner(self, S_torch):
257
+ """Single seam: dispatch to the Triton decoder.
258
+
259
+ Returns a result dict with the keys consumed by the rest of the
260
+ adapter (errors, iterations, found_count, valid_mask,
261
+ unconverged_indices).
262
+ """
263
+ return self._dec.decode(S_torch)
264
+
265
+ def log_prior_ratios(self) -> np.ndarray:
266
+ """Return the decoder's log-prior-ratios: ln((1-p)/p).
267
+
268
+ These match the Rust ``Decoder::log_prior_ratios()`` semantics and
269
+ are used for quality metric computation. Non-finite values
270
+ are already zeroed during decoder initialisation.
271
+ """
272
+ return self._dec.wj.cpu().numpy().astype(np.float64)
273
+
274
+ def override_llr_columns(self, cols, value: float):
275
+ """Override prior LLR on given columns (see TritonDecoder.override_llr_columns).
276
+
277
+ Returns a restore-callable, so a caller can patch per-column
278
+ priors for a round and then revert them.
279
+ """
280
+ return self._dec.override_llr_columns(cols, value)
281
+
282
+ def decode(self, syndrome: np.ndarray | torch.Tensor):
283
+ """Decode syndrome using Triton GPU implementation.
284
+
285
+ Args:
286
+ syndrome: Syndrome vector as numpy array or torch tensor
287
+
288
+ Returns:
289
+ Dict containing decoding results with relay_bp interface compatibility.
290
+ If fast_max_unconverged_pct is set in decoder, also includes "unconverged_indices" key.
291
+ """
292
+ # Triton decoder expects batched input; wrap single syndrome as batch of size 1.
293
+ # Keep on CPU. decode() handles GPU placement.
294
+ if isinstance(syndrome, np.ndarray):
295
+ s = torch.from_numpy(syndrome.astype(np.uint8, copy=False)).view(1, -1)
296
+ else:
297
+ s = syndrome.to(dtype=torch.uint8).view(1, -1)
298
+
299
+ out = self._dec.decode(s)
300
+ bits = out["errors"][0].to(torch.int8)
301
+ # Use found_count > 0 for converged (at least one valid solution found).
302
+ converged = bool(out["found_count"][0].item() > 0)
303
+ iters = int(out.get("iterations", torch.tensor([self._dec.pre_iter]))[0].item())
304
+
305
+ result = {
306
+ "bits": bits,
307
+ "posterior_llr": None,
308
+ "converged": converged,
309
+ "num_ensembles": 0,
310
+ "iterations": iters,
311
+ "selected_idx": 0,
312
+ }
313
+ # Include unconverged_indices if present (when fast_max_unconverged_pct is set)
314
+ if "unconverged_indices" in out:
315
+ result["unconverged_indices"] = out["unconverged_indices"].cpu().numpy()
316
+ return result
317
+
318
+ def decode_detailed(self, detectors: np.ndarray | torch.Tensor):
319
+ """Decode detectors with detailed results (matches Rust interface).
320
+
321
+ Args:
322
+ detectors: Detector vector as numpy array or torch tensor
323
+
324
+ Returns:
325
+ DecodeResult object with detailed decoding information
326
+ """
327
+ result = self.decode(detectors)
328
+ bits = result["bits"].cpu().numpy()
329
+ converged = bool(result["converged"])
330
+ iters = int(result["iterations"])
331
+
332
+ # For single-shot decode we expose "decoding" as the physical error pattern and
333
+ # "success" as convergence. This matches the Rust DecodeResult fields consumed
334
+ # by BaseRelayDecoder._produce_output.
335
+ return DecodeResult(
336
+ decoding=bits,
337
+ success=converged,
338
+ iterations=iters,
339
+ converged=converged,
340
+ error_detected=not converged,
341
+ observables=bits,
342
+ unconverged_indices=result.get("unconverged_indices"),
343
+ )
344
+
345
+ def decode_batch(self, detectors: np.ndarray | torch.Tensor, return_iterations=False):
346
+ """Decode batch of detectors (matches Rust interface).
347
+
348
+ Args:
349
+ detectors: Batch of detector vectors as numpy array or torch tensor
350
+ return_iterations: when True, also return the per-shot BP iteration
351
+
352
+ Returns:
353
+ Batch of decoded error vectors as numpy array.
354
+ If fast_max_unconverged_pct is set in decoder, returns tuple (errors, unconverged_indices).
355
+ If return_iterations is True, the per-shot iteration array is appended.
356
+ """
357
+ # Keep on CPU. decode() handles GPU placement.
358
+ if isinstance(detectors, np.ndarray):
359
+ detectors = torch.from_numpy(detectors.astype(np.uint8, copy=False))
360
+ else:
361
+ detectors = detectors.to(dtype=torch.uint8)
362
+
363
+ out = self._dec.decode(detectors)
364
+ errors = out["errors"].to(torch.int8).cpu().numpy()
365
+ result = [errors]
366
+ if self._dec.fast_max_unconverged_pct is not None and "unconverged_indices" in out:
367
+ result.append(out["unconverged_indices"].cpu().numpy())
368
+ if return_iterations:
369
+ iters_t = out.get("iterations")
370
+ if iters_t is None:
371
+ iters = np.full(errors.shape[0], int(self._dec.pre_iter), dtype=np.int32)
372
+ else:
373
+ iters = iters_t.to(torch.int32).cpu().numpy()
374
+ result.append(iters)
375
+ return result[0] if len(result) == 1 else tuple(result)
376
+
377
+ def decode_batch_with_prior_tensors(
378
+ self, detectors: torch.Tensor, prior_llr: torch.Tensor, *, return_iterations=False
379
+ ):
380
+ """Decode a batch with a per-shot [B, V] prior LLR, returning also the
381
+ posterior. GPU-resident.
382
+ """
383
+ out = self._dec.decode(
384
+ detectors, return_last_posterior=True, prior_llr=prior_llr
385
+ )
386
+ errors = out["errors"]
387
+ post = out["last_posterior"]
388
+ if return_iterations:
389
+ iters_t = out.get("iterations")
390
+ if iters_t is None:
391
+ iters_t = torch.full(
392
+ (detectors.shape[0],),
393
+ int(self._dec.pre_iter),
394
+ dtype=torch.int32,
395
+ device=errors.device,
396
+ )
397
+ return errors, post, iters_t
398
+ return errors, post
399
+
400
+ def decode_detailed_batch(self, detectors: np.ndarray | torch.Tensor):
401
+ """Decode batch of detectors with detailed results (matches Rust interface).
402
+
403
+ Args:
404
+ detectors: Batch of detector vectors as numpy array or torch tensor
405
+
406
+ Returns:
407
+ List of DecodeResult objects with detailed decoding information.
408
+ If fast_max_unconverged_pct is set in decoder, returns tuple (results, unconverged_indices).
409
+ """
410
+ # Keep on CPU. decode() handles GPU placement.
411
+ if isinstance(detectors, np.ndarray):
412
+ detectors = torch.from_numpy(detectors.astype(np.uint8, copy=False))
413
+ else:
414
+ detectors = detectors.to(dtype=torch.uint8)
415
+
416
+ out = self._dec.decode(detectors)
417
+
418
+ # Extract unconverged_indices if available
419
+ unconverged_indices = None
420
+ if self._dec.fast_max_unconverged_pct is not None and "unconverged_indices" in out:
421
+ unconverged_indices = out["unconverged_indices"].cpu().numpy()
422
+
423
+ # Use found_count > 0 for success (at least one valid solution found).
424
+ B = detectors.shape[0]
425
+ found_count = out["found_count"].cpu().numpy()
426
+ success_mask = found_count > 0
427
+
428
+ results: List[DecodeResult] = []
429
+ for i in range(B):
430
+ bits = out["errors"][i].to(torch.int8)
431
+ success = bool(success_mask[i])
432
+ iters = int(
433
+ out.get(
434
+ "iterations",
435
+ torch.tensor([self._dec.pre_iter]),
436
+ )[i].item()
437
+ )
438
+ bits_np = bits.cpu().numpy()
439
+ results.append(
440
+ DecodeResult(
441
+ decoding=bits_np,
442
+ success=success,
443
+ iterations=iters,
444
+ converged=success,
445
+ error_detected=not success,
446
+ observables=bits_np,
447
+ unconverged_indices=unconverged_indices,
448
+ found_count=int(found_count[i]),
449
+ )
450
+ )
451
+ return results
452
+
453
+ def decode_detailed_batch_from_syndromes(
454
+ self, detectors: np.ndarray | torch.Tensor
455
+ ) -> List[DecodeResult]:
456
+ """Decode a batch of detector syndromes and return Rust-compatible results.
457
+
458
+ This is the primary entry point for higher-level decoders
459
+ that expect DecodeResult-like objects given detector error model syndromes.
460
+
461
+ Success/converged is determined by found_count > 0 (at least one valid solution found)
462
+ """
463
+ # Keep on CPU. decode() handles GPU placement.
464
+ if isinstance(detectors, np.ndarray):
465
+ S_torch = torch.from_numpy(detectors.astype(np.uint8, copy=False))
466
+ else:
467
+ S_torch = detectors.to(dtype=torch.uint8)
468
+
469
+ out = self._decode_inner(S_torch)
470
+ B = S_torch.shape[0]
471
+
472
+
473
+ errors = out["errors"].to(torch.int8)
474
+ iters = out.get(
475
+ "iterations",
476
+ torch.full((B,), int(self._dec.pre_iter), dtype=torch.int32),
477
+ )
478
+ found_count = out["found_count"].cpu().numpy()
479
+ success_mask = found_count > 0
480
+
481
+ # Also get unconverged_indices for compatibility (used by fast_max_unconverged_pct feature)
482
+ unconverged_indices_gpu = out.get("unconverged_indices", None)
483
+ unconverged_indices_np = None
484
+ if unconverged_indices_gpu is not None and unconverged_indices_gpu.numel() > 0:
485
+ unconverged_indices_np = unconverged_indices_gpu.cpu().numpy()
486
+
487
+ # Transfer decoded errors to CPU and recompute quality
488
+ all_bits = errors.cpu().numpy() # (B, V) int8
489
+ all_iters = iters.cpu().numpy()
490
+ wj_np = self.log_prior_ratios() # (N,) float64
491
+
492
+
493
+ results: List[DecodeResult] = []
494
+ for i in range(B):
495
+ bits_np = all_bits[i]
496
+ success = bool(success_mask[i])
497
+ iterations = int(all_iters[i])
498
+ # Recompute quality
499
+ dq = float(np.dot(bits_np.astype(np.float64), wj_np)) if success else float("inf")
500
+ result = DecodeResult(
501
+ decoding=bits_np,
502
+ success=success,
503
+ iterations=iterations,
504
+ decoding_quality=dq,
505
+ converged=success,
506
+ error_detected=not success,
507
+ observables=bits_np,
508
+ unconverged_indices=unconverged_indices_np,
509
+ found_count=int(found_count[i]),
510
+ )
511
+ results.append(result)
512
+ return results
513
+
514
+
515
+ class ObservableDecoderRunner:
516
+ """Observable decoder runner providing batch processing with error detection.
517
+
518
+ This class handles batch decoding of error patterns and computes observable
519
+ predictions with logical error detection, matching the relay_bp interface.
520
+ """
521
+
522
+ def __init__(
523
+ self,
524
+ decoder: RelayDecoder,
525
+ observables_matrix,
526
+ include_decode_result: bool = True,
527
+ **kwargs,
528
+ ):
529
+ self.decoder = decoder
530
+ if sp is not None and sp.issparse(observables_matrix):
531
+ self._obs = observables_matrix.tocsr()
532
+ else:
533
+ self._obs = (
534
+ sp.csr_matrix(observables_matrix)
535
+ if sp is not None
536
+ else np.asarray(observables_matrix, dtype=np.uint8)
537
+ )
538
+ self.O, self.N = self._obs.shape
539
+ self.M = int(decoder.H_csr.shape[0])
540
+ # Cache for batched concatenation to avoid allocation overhead
541
+ self._cached_concat_errors = None
542
+ self._cached_concat_iterations = None
543
+ self._cached_concat_valid = None
544
+
545
+ def _mul_mod2(self, A_csr, x: np.ndarray | torch.Tensor) -> np.ndarray:
546
+ """Compute matrix-vector multiplication modulo 2 for observable computation.
547
+
548
+ Args:
549
+ A_csr: Sparse matrix (observables or check matrix)
550
+ x: Vector (error pattern or decoded bits)
551
+
552
+ Returns:
553
+ Result of A @ x mod 2 as uint8 array
554
+ """
555
+ if sp is not None and sp.issparse(A_csr):
556
+ y = A_csr @ (np.asarray(x, dtype=np.int8))
557
+ return (np.asarray(y) % 2).astype(np.uint8)
558
+ A = np.asarray(A_csr, dtype=np.uint8)
559
+ y = (A @ np.asarray(x, dtype=np.int8)) % 2
560
+ return y.astype(np.uint8)
561
+
562
+ def from_errors_decode_observables_detailed_batch(
563
+ self, errors: np.ndarray, parallel: bool = False
564
+ ) -> List[DecodeResult]:
565
+ """Decode batch of error patterns and compute observable predictions with error detection.
566
+
567
+ This method implements the core benchmarking functionality, processing a batch
568
+ of error patterns and returning detailed statistics for each pattern including
569
+ iteration counts, convergence status, and logical error detection.
570
+
571
+ Args:
572
+ errors: [B,N] array of error patterns to decode
573
+ parallel: Whether to use parallel processing (unused in Triton implementation)
574
+
575
+ Returns:
576
+ List of DecodeResult objects containing detailed decoding statistics.
577
+ If fast_max_unconverged_pct is set and unconverged cases exist, the list will have an
578
+ 'unconverged_indices' attribute containing the indices of unconverged syndromes.
579
+ """
580
+ # Use new methods to avoid code duplication
581
+ gpu_result = self._decode_gpu_only(errors)
582
+ return self._process_gpu_results(gpu_result)
583
+
584
+ def _decode_gpu_only(self, errors: np.ndarray) -> Dict[str, torch.Tensor]:
585
+ """Decode batch on GPU and return GPU tensors (no CPU transfer).
586
+
587
+ Args:
588
+ errors: [B,N] array of error patterns to decode
589
+
590
+ Returns:
591
+ Dictionary with GPU tensors: errors, true_obs, and decoder output dict
592
+ """
593
+ B, N = errors.shape
594
+ assert N == self.decoder.N, f"N mismatch: errors has {N}, decoder has {self.decoder.N}"
595
+
596
+ if self.decoder._process_execution:
597
+ from .process import prepare_batch
598
+ true_obs, syndromes = prepare_batch(
599
+ self.decoder.H_csr, self._obs, errors, self.decoder._dec.num_gpus,
600
+ self._mul_mod2,
601
+ )
602
+ return {"out": self.decoder._decode_inner(torch.from_numpy(syndromes)),
603
+ "true_obs": true_obs, "errors": errors}
604
+
605
+ # Compute true observables from error patterns
606
+ true_obs = self._mul_mod2(self._obs, errors.T).T # (B,O)
607
+
608
+ # Build syndromes from errors via H @ e mod 2
609
+ H = self.decoder.H_csr
610
+ if sp is not None and sp.issparse(H):
611
+ S = (H @ errors.T) % 2
612
+ S = np.asarray(S, dtype=np.uint8).T
613
+ else:
614
+ H_np = np.asarray(H, dtype=np.uint8)
615
+ S = ((H_np @ errors.T) % 2).astype(np.uint8).T
616
+
617
+ # If decoder uses check permutation, apply it to syndromes to match permuted graph
618
+ # The graph was built with H[check_permutation, :], so syndromes need to be permuted too
619
+ if self.decoder._dec.check_permutation is not None:
620
+ perm = self.decoder._dec.check_permutation
621
+ S = S[:, perm]
622
+
623
+ # Decode on GPU (multi-GPU handled transparently by decode()).
624
+ # Keep tensor on CPU here. decode() places each GPU's slice directly,
625
+ # avoiding a redundant CPU -> GPU 0 -> GPU N round-trip.
626
+ S_torch = torch.from_numpy(S)
627
+ out = self.decoder._decode_inner(S_torch)
628
+
629
+ return {
630
+ "out": out,
631
+ "true_obs": true_obs,
632
+ "errors": errors,
633
+ }
634
+
635
+
636
+ def _process_gpu_results(self, gpu_result: Dict[str, torch.Tensor]) -> List[DecodeResult]:
637
+ """Transfer GPU results to CPU and process into DecodeResult list.
638
+
639
+ Args:
640
+ gpu_result: Dictionary from _decode_gpu_only() with out, true_obs, errors
641
+
642
+ Returns:
643
+ List of DecodeResult objects (same as from_errors_decode_observables_detailed_batch)
644
+ """
645
+ out = gpu_result["out"]
646
+ true_obs = gpu_result["true_obs"]
647
+ errors = gpu_result["errors"]
648
+ B = errors.shape[0]
649
+
650
+ # Batch CPU transfers (single sync point)
651
+ errors_gpu = out["errors"].to(torch.uint8)
652
+ iterations_gpu = out.get(
653
+ "iterations",
654
+ torch.full(
655
+ (B,),
656
+ int(self.decoder._dec.pre_iter),
657
+ device=out["errors"].device,
658
+ dtype=torch.int32,
659
+ ),
660
+ ).to(torch.int32)
661
+ found_count_gpu = out["found_count"].to(torch.int32)
662
+ unconverged_indices_gpu = out.get("unconverged_indices", None)
663
+
664
+ # Single batched transfer (all tensors transferred together)
665
+ bits = errors_gpu.cpu().numpy()
666
+ iters = iterations_gpu.cpu().numpy()
667
+ found_count = found_count_gpu.cpu().numpy()
668
+
669
+ # Use found_count > 0 for success (at least one valid solution found).
670
+ success_mask = found_count > 0
671
+
672
+ unconverged_indices = None
673
+ if (
674
+ self.decoder._dec.fast_max_unconverged_pct is not None
675
+ and unconverged_indices_gpu is not None
676
+ ):
677
+ unconverged_indices = unconverged_indices_gpu.cpu().numpy()
678
+
679
+ pred_obs = self._mul_mod2(self._obs, bits.T).T
680
+
681
+ # Compute quality metrics
682
+ # Rust: get_decoding_quality(errors) = sum(log_prior_ratios[i] for i where errors[i]==1 and is_finite)
683
+ # Our log_prior_ratios() already zeros non-finite entries, so the dot product is equivalent.
684
+ wj_np = self.decoder.log_prior_ratios() # (N,) float64
685
+ decoded_quality = (bits.astype(np.float64) * wj_np).sum(axis=1) # (B,) float64
686
+ true_quality = (errors.astype(np.float64) * wj_np).sum(axis=1) # (B,) float64
687
+
688
+ res: List[DecodeResult] = []
689
+ for i in range(B):
690
+ err_det = bool((true_obs[i] != pred_obs[i]).any())
691
+ success = bool(success_mask[i])
692
+ it = int(iters[i])
693
+ obs = pred_obs[i].astype(np.uint8)
694
+ dq = float(decoded_quality[i]) if success else float("inf")
695
+ better_quality = dq < true_quality[i]
696
+ res.append(
697
+ DecodeResult(
698
+ decoding=bits[i],
699
+ success=success,
700
+ iterations=it,
701
+ decoding_quality=dq,
702
+ unconverged_no_error=bool(not err_det and not success),
703
+ unconverged_logical_error=bool(err_det and not success),
704
+ better_decoding_quality_error=bool(success and err_det and better_quality),
705
+ worse_decoding_quality_error=bool(success and err_det and not better_quality),
706
+ converged=success,
707
+ error_detected=err_det,
708
+ observables=obs,
709
+ unconverged_indices=unconverged_indices,
710
+ )
711
+ )
712
+
713
+ return res
714
+
715
+ def _process_gpu_results_batched(
716
+ self,
717
+ chunk_gpu_results: List[Dict[str, torch.Tensor]],
718
+ chunk_errors_list: List[np.ndarray],
719
+ chunk_true_obs_list: List[np.ndarray],
720
+ ) -> List[List[DecodeResult]]:
721
+ """Process multiple GPU results with a single batched transfer.
722
+
723
+ Args:
724
+ chunk_gpu_results: List of GPU result dictionaries from _decode_gpu_only()
725
+ chunk_errors_list: List of error arrays (CPU, already available)
726
+ chunk_true_obs_list: List of true observable arrays (CPU, already available)
727
+
728
+ Returns:
729
+ List of DecodeResult lists (one per batch)
730
+ """
731
+ if not chunk_gpu_results:
732
+ return []
733
+
734
+ # Concatenate all batches for single transfer
735
+ # Ensure all tensors are on the same device before concatenation
736
+ errors_list = []
737
+ iterations_list = []
738
+ found_count_list = []
739
+ batch_sizes = []
740
+
741
+ for r in chunk_gpu_results:
742
+ B = r["out"]["errors"].shape[0]
743
+ batch_sizes.append(B)
744
+ errors_list.append(r["out"]["errors"].to(torch.uint8))
745
+ iterations_list.append(
746
+ r["out"]
747
+ .get(
748
+ "iterations",
749
+ torch.full(
750
+ (B,),
751
+ int(self.decoder._dec.pre_iter),
752
+ device=r["out"]["errors"].device,
753
+ dtype=torch.int32,
754
+ ),
755
+ )
756
+ .to(torch.int32)
757
+ )
758
+ found_count_list.append(r["out"]["found_count"].to(torch.int32))
759
+
760
+ # Concatenate using pre-allocated buffers to avoid allocation overhead
761
+ total_B = sum(batch_sizes)
762
+ V = errors_list[0].shape[1]
763
+ device_str = str(errors_list[0].device)
764
+
765
+ # Pre-allocate buffers if needed
766
+ if self._cached_concat_errors is None or self._cached_concat_errors.shape[0] < total_B:
767
+ self._cached_concat_errors = torch.empty(
768
+ (total_B, V), dtype=torch.uint8, device=device_str
769
+ )
770
+ self._cached_concat_iterations = torch.empty(
771
+ (total_B,), dtype=torch.int32, device=device_str
772
+ )
773
+ self._cached_concat_valid = torch.empty(
774
+ (total_B,), dtype=torch.int32, device=device_str
775
+ )
776
+
777
+ # Copy into pre-allocated buffers
778
+ offset = 0
779
+ for err, it, fc in zip(errors_list, iterations_list, found_count_list):
780
+ B = err.shape[0]
781
+ self._cached_concat_errors[offset : offset + B] = err
782
+ self._cached_concat_iterations[offset : offset + B] = it
783
+ self._cached_concat_valid[offset : offset + B] = fc
784
+ offset += B
785
+
786
+ all_errors_gpu = self._cached_concat_errors[:total_B]
787
+ all_iterations_gpu = self._cached_concat_iterations[:total_B]
788
+ all_found_count_gpu = self._cached_concat_valid[:total_B]
789
+
790
+ # single batched transfer (one sync point for all batches)
791
+ all_bits = all_errors_gpu.cpu().numpy()
792
+ all_iters = all_iterations_gpu.cpu().numpy()
793
+ all_found_count = all_found_count_gpu.cpu().numpy()
794
+
795
+ # Gamma-scaled LLRs for quality computation
796
+ wj_np = self.decoder.log_prior_ratios() # (N,) float64
797
+
798
+ # Process each batch (unconverged_indices handled per batch since they're batch-local)
799
+ results = []
800
+ batch_start = 0
801
+ for batch_idx, (gpu_result, errors, true_obs) in enumerate(
802
+ zip(chunk_gpu_results, chunk_errors_list, chunk_true_obs_list)
803
+ ):
804
+ B = errors.shape[0]
805
+ batch_end = batch_start + B
806
+
807
+ # Extract batch slice from batched transfer
808
+ bits = all_bits[batch_start:batch_end]
809
+ iters = all_iters[batch_start:batch_end]
810
+ found_count = all_found_count[batch_start:batch_end]
811
+
812
+ # Use found_count > 0 for success (at least one valid solution found).
813
+ success_mask = found_count > 0
814
+
815
+ # Handle unconverged_indices for this batch (small, transfer separately)
816
+ unconverged_indices = None
817
+ if self.decoder._dec.fast_max_unconverged_pct is not None:
818
+ uidx_gpu = gpu_result["out"].get("unconverged_indices", None)
819
+ if uidx_gpu is not None and len(uidx_gpu) > 0:
820
+ unconverged_indices = uidx_gpu.cpu().numpy()
821
+
822
+ pred_obs = self._mul_mod2(self._obs, bits.T).T
823
+
824
+ # Compute quality
825
+ decoded_quality = (bits.astype(np.float64) * wj_np).sum(axis=1)
826
+ true_quality = (errors.astype(np.float64) * wj_np).sum(axis=1)
827
+
828
+ res: List[DecodeResult] = []
829
+ for i in range(B):
830
+ err_det = bool((true_obs[i] != pred_obs[i]).any())
831
+ success = bool(success_mask[i])
832
+ it = int(iters[i])
833
+ obs = pred_obs[i].astype(np.uint8)
834
+ dq = float(decoded_quality[i]) if success else float("inf")
835
+ better_quality = dq < true_quality[i]
836
+ res.append(
837
+ DecodeResult(
838
+ decoding=bits[i],
839
+ success=success,
840
+ iterations=it,
841
+ decoding_quality=dq,
842
+ unconverged_no_error=bool(not err_det and not success),
843
+ unconverged_logical_error=bool(err_det and not success),
844
+ better_decoding_quality_error=bool(success and err_det and better_quality),
845
+ worse_decoding_quality_error=bool(
846
+ success and err_det and not better_quality
847
+ ),
848
+ converged=success,
849
+ error_detected=err_det,
850
+ observables=obs,
851
+ unconverged_indices=unconverged_indices,
852
+ )
853
+ )
854
+
855
+ results.append(res)
856
+ batch_start = batch_end
857
+
858
+ return results