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/__init__.py +34 -0
- pelagos/_process_worker.py +56 -0
- pelagos/adapter.py +858 -0
- pelagos/autotune.py +793 -0
- pelagos/decoder.py +3891 -0
- pelagos/graph.py +479 -0
- pelagos/kernels.py +2208 -0
- pelagos/process.py +287 -0
- pelagos/stim/__init__.py +11 -0
- pelagos/stim/sinter/__init__.py +11 -0
- pelagos/stim/sinter/check_matrices.py +112 -0
- pelagos/utils.py +995 -0
- pelagos_decoder-0.1.0.dist-info/METADATA +317 -0
- pelagos_decoder-0.1.0.dist-info/RECORD +16 -0
- pelagos_decoder-0.1.0.dist-info/WHEEL +4 -0
- pelagos_decoder-0.1.0.dist-info/licenses/LICENSE +201 -0
pelagos/utils.py
ADDED
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
# (C) Copyright IBM 2025, 2026
|
|
2
|
+
# Modified 2026 by AMD to report ROCm device information.
|
|
3
|
+
#
|
|
4
|
+
# This code is licensed under the Apache License, Version 2.0. You may
|
|
5
|
+
# obtain a copy of this license in the LICENSE file in the root directory
|
|
6
|
+
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
|
|
7
|
+
#
|
|
8
|
+
# Any modifications or derivative works of this code must retain this
|
|
9
|
+
# copyright notice, and modified files need to carry a notice indicating
|
|
10
|
+
# that they have been altered from the originals.
|
|
11
|
+
|
|
12
|
+
"""Utility functions for Relay-BP-S decoding.
|
|
13
|
+
|
|
14
|
+
This module provides utility functions for quantum error correction decoding,
|
|
15
|
+
including error prior computation, gamma sampling, and matrix validation.
|
|
16
|
+
These functions support the main Relay-BP algorithm implementation.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
import logging
|
|
21
|
+
import torch
|
|
22
|
+
import numpy as np
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any, Dict, Optional, Tuple, Union, List
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from scipy.sparse import csc_matrix, csr_matrix, coo_matrix
|
|
27
|
+
from scipy.sparse.csgraph import connected_components
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def bitpack_errors(errors: torch.Tensor, bits_per_word: int = 32) -> torch.Tensor:
|
|
33
|
+
"""Pack error bits into words for memory efficiency.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
errors: [B, V] binary tensor (0/1)
|
|
37
|
+
bits_per_word: number of bits per packed word
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
[B, (V + bits_per_word - 1) // bits_per_word] packed tensor
|
|
41
|
+
"""
|
|
42
|
+
B, V = errors.shape
|
|
43
|
+
words_per_batch = (V + bits_per_word - 1) // bits_per_word
|
|
44
|
+
|
|
45
|
+
# Reshape to [B, words_per_batch, bits_per_word]
|
|
46
|
+
padded_errors = torch.zeros(
|
|
47
|
+
B, words_per_batch * bits_per_word, dtype=errors.dtype, device=errors.device
|
|
48
|
+
)
|
|
49
|
+
padded_errors[:, :V] = errors
|
|
50
|
+
|
|
51
|
+
# Reshape and pack
|
|
52
|
+
packed = padded_errors.view(B, words_per_batch, bits_per_word)
|
|
53
|
+
|
|
54
|
+
# Convert to int32 words (CUDA supports lshift on int32)
|
|
55
|
+
result = torch.zeros(B, words_per_batch, dtype=torch.int32, device=errors.device)
|
|
56
|
+
|
|
57
|
+
for i in range(bits_per_word):
|
|
58
|
+
result |= packed[:, :, i].to(torch.int32) << i
|
|
59
|
+
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def bitunpack_errors(packed: torch.Tensor, V: int, bits_per_word: int = 32) -> torch.Tensor:
|
|
64
|
+
"""Unpack error bits from words.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
packed: [B, words_per_batch] packed tensor
|
|
68
|
+
V: number of variables
|
|
69
|
+
bits_per_word: number of bits per packed word
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
[B, V] binary tensor (0/1)
|
|
73
|
+
"""
|
|
74
|
+
B, words_per_batch = packed.shape
|
|
75
|
+
|
|
76
|
+
# Convert to binary representation
|
|
77
|
+
bits = torch.zeros(B, words_per_batch, bits_per_word, dtype=torch.uint8, device=packed.device)
|
|
78
|
+
|
|
79
|
+
for i in range(bits_per_word):
|
|
80
|
+
bits[:, :, i] = ((packed >> i) & 1).to(torch.uint8)
|
|
81
|
+
|
|
82
|
+
# Flatten and truncate to V
|
|
83
|
+
errors = bits.view(B, -1)[:, :V]
|
|
84
|
+
|
|
85
|
+
return errors
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def compute_log_prior_ratios(error_priors: np.ndarray) -> np.ndarray:
|
|
89
|
+
"""Compute log prior ratios from error probabilities.
|
|
90
|
+
|
|
91
|
+
This function converts error probabilities to log-likelihood ratios used
|
|
92
|
+
in belief propagation, corresponding to the log_prior_ratios computation
|
|
93
|
+
in Rust min_sum.rs.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
error_priors: [V] error probabilities in (0, 0.5) for each qubit
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
[V] log prior ratios log((1-p)/p) for belief propagation
|
|
100
|
+
"""
|
|
101
|
+
# Clip probabilities to avoid numerical issues
|
|
102
|
+
p = np.clip(error_priors, 1e-10, 0.5 - 1e-10)
|
|
103
|
+
|
|
104
|
+
# Compute log prior ratios
|
|
105
|
+
log_ratios = np.log((1 - p) / p)
|
|
106
|
+
|
|
107
|
+
return log_ratios.astype(np.float32)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def compute_decoding_weights(
|
|
111
|
+
hard_dec: torch.Tensor, log_prior_ratios: torch.Tensor
|
|
112
|
+
) -> torch.Tensor:
|
|
113
|
+
"""Compute decoding weights for solution selection.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
hard_dec: [B, V] hard decisions (0/1)
|
|
117
|
+
log_prior_ratios: [V] log prior ratios
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
[B] decoding weights (lower is better)
|
|
121
|
+
"""
|
|
122
|
+
# Weight = sum of log prior ratios for error bits
|
|
123
|
+
weights = torch.sum(hard_dec.float() * log_prior_ratios.unsqueeze(0), dim=1)
|
|
124
|
+
|
|
125
|
+
return weights
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def sample_gamma_uniform(
|
|
129
|
+
rows: int, V: int, gamma_min: float, gamma_max: float, device: str, seed: Optional[int] = None
|
|
130
|
+
) -> torch.Tensor:
|
|
131
|
+
"""Sample gamma values from uniform distribution for disordered memory.
|
|
132
|
+
|
|
133
|
+
This function samples memory strength values for the disordered memory phase
|
|
134
|
+
of Relay-BP, corresponding to the gamma sampling in Rust relay.rs.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
rows: number of gamma rows to draw (1 for gamma_draw="broadcast", the batch size
|
|
138
|
+
for "per_row")
|
|
139
|
+
V: number of variables (qubits)
|
|
140
|
+
gamma_min: minimum gamma value for memory strength
|
|
141
|
+
gamma_max: maximum gamma value for memory strength
|
|
142
|
+
device: target GPU device
|
|
143
|
+
seed: random seed for reproducibility. Seeds a generator private to this call;
|
|
144
|
+
the global RNG state is neither used nor changed.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
[rows, V] gamma values for memory mixing
|
|
148
|
+
"""
|
|
149
|
+
gen = None
|
|
150
|
+
if seed is not None:
|
|
151
|
+
gen = torch.Generator(device=device)
|
|
152
|
+
gen.manual_seed(seed)
|
|
153
|
+
|
|
154
|
+
# Sample uniform random values
|
|
155
|
+
uniform = torch.rand(rows, V, device=device, generator=gen)
|
|
156
|
+
|
|
157
|
+
# Scale to [gamma_min, gamma_max]
|
|
158
|
+
gamma = gamma_min + (gamma_max - gamma_min) * uniform
|
|
159
|
+
|
|
160
|
+
return gamma
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def sample_gamma_scalar(B: int, V: int, gamma_value: float, device: str) -> torch.Tensor:
|
|
164
|
+
"""Sample scalar gamma values for ordered memory phase.
|
|
165
|
+
|
|
166
|
+
This function creates uniform gamma values for the ordered memory phase
|
|
167
|
+
of Relay-BP, corresponding to the gamma0 parameter in Rust relay.rs.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
B: batch size
|
|
171
|
+
V: number of variables (qubits)
|
|
172
|
+
gamma_value: uniform gamma value for memory strength
|
|
173
|
+
device: target GPU device
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
[B, V] gamma values (all identical)
|
|
177
|
+
"""
|
|
178
|
+
return torch.full((B, V), gamma_value, device=device)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def sample_gamma_per_variable(
|
|
182
|
+
rows: int,
|
|
183
|
+
V: int,
|
|
184
|
+
gamma_intervals: torch.Tensor,
|
|
185
|
+
device: str,
|
|
186
|
+
seed: Optional[int] = None,
|
|
187
|
+
) -> torch.Tensor:
|
|
188
|
+
"""Sample gamma values from per-variable uniform intervals.
|
|
189
|
+
|
|
190
|
+
Each variable v has its own
|
|
191
|
+
interval [low_v, high_v] and we sample:
|
|
192
|
+
gamma[r, v] ~ Uniform(low_v, high_v)
|
|
193
|
+
independently for each row r.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
rows: number of gamma rows to draw (1 for gamma_draw="broadcast", the batch size
|
|
197
|
+
for "per_row")
|
|
198
|
+
V: number of variables (qubits)
|
|
199
|
+
gamma_intervals: [V, 2] tensor with [:,0]=low, [:,1]=high bounds
|
|
200
|
+
device: target GPU device
|
|
201
|
+
seed: random seed for reproducibility. Seeds a generator private to this call;
|
|
202
|
+
the global RNG state is neither used nor changed.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
[rows, V] gamma values for memory mixing
|
|
206
|
+
"""
|
|
207
|
+
gen = None
|
|
208
|
+
if seed is not None:
|
|
209
|
+
gen = torch.Generator(device=device)
|
|
210
|
+
gen.manual_seed(seed)
|
|
211
|
+
|
|
212
|
+
if gamma_intervals.ndim != 2 or gamma_intervals.shape[0] != V or gamma_intervals.shape[1] != 2:
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"gamma_intervals must have shape (V={V}, 2), got {tuple(gamma_intervals.shape)}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# [rows, V] uniform samples in [0, 1]
|
|
218
|
+
uniform = torch.rand(rows, V, device=device, generator=gen)
|
|
219
|
+
|
|
220
|
+
# Broadcast per-variable intervals: low, high are [1, V]
|
|
221
|
+
low = gamma_intervals[:, 0].unsqueeze(0)
|
|
222
|
+
high = gamma_intervals[:, 1].unsqueeze(0)
|
|
223
|
+
|
|
224
|
+
gamma = low + (high - low) * uniform
|
|
225
|
+
return gamma
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
GAMMA_DRAW_MODES = ("broadcast", "per_row")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def validate_error_priors(error_priors: np.ndarray) -> np.ndarray:
|
|
232
|
+
"""Validate and clip error priors.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
error_priors: [V] error probabilities
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
[V] validated and clipped error probabilities
|
|
239
|
+
"""
|
|
240
|
+
# Check shape
|
|
241
|
+
if error_priors.ndim != 1:
|
|
242
|
+
raise ValueError("error_priors must be 1D array")
|
|
243
|
+
|
|
244
|
+
# Check range and clip
|
|
245
|
+
clipped = np.clip(error_priors, 1e-10, 0.5 - 1e-10)
|
|
246
|
+
|
|
247
|
+
# Warn if clipping occurred
|
|
248
|
+
if np.any(clipped != error_priors):
|
|
249
|
+
print("Warning: error_priors were clipped to avoid numerical issues")
|
|
250
|
+
|
|
251
|
+
return clipped
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def validate_csr_matrix(H_csr) -> bool:
|
|
255
|
+
"""Validate CSR matrix format and properties; coerce CSR if needed."""
|
|
256
|
+
from scipy.sparse import csr_matrix, issparse
|
|
257
|
+
|
|
258
|
+
if not issparse(H_csr):
|
|
259
|
+
raise ValueError("Input must be a scipy.sparse matrix (any format)")
|
|
260
|
+
|
|
261
|
+
# Coerce to CSR if it's e.g. csr_array/coo/csc/etc.
|
|
262
|
+
if not isinstance(H_csr, csr_matrix):
|
|
263
|
+
H_csr = H_csr.tocsr()
|
|
264
|
+
|
|
265
|
+
if H_csr.shape[0] == 0 or H_csr.shape[1] == 0:
|
|
266
|
+
raise ValueError("Matrix cannot be empty")
|
|
267
|
+
if H_csr.nnz == 0:
|
|
268
|
+
raise ValueError("Matrix cannot have zero non-zero elements")
|
|
269
|
+
if not np.all((H_csr.data == 0) | (H_csr.data == 1)):
|
|
270
|
+
print("Warning: Matrix contains non-binary values, will be treated as binary")
|
|
271
|
+
|
|
272
|
+
return True
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def get_device_info(device: str) -> dict:
|
|
276
|
+
"""Get device information for debugging.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
device: device string ("cuda" or "rocm")
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Dictionary with device information
|
|
283
|
+
"""
|
|
284
|
+
if device == "cuda":
|
|
285
|
+
if torch.cuda.is_available():
|
|
286
|
+
return {
|
|
287
|
+
"device": device,
|
|
288
|
+
"available": True,
|
|
289
|
+
"name": torch.cuda.get_device_name(),
|
|
290
|
+
"memory_total": torch.cuda.get_device_properties(0).total_memory,
|
|
291
|
+
"memory_allocated": torch.cuda.memory_allocated(),
|
|
292
|
+
"memory_reserved": torch.cuda.memory_reserved(),
|
|
293
|
+
}
|
|
294
|
+
else:
|
|
295
|
+
return {"device": device, "available": False}
|
|
296
|
+
|
|
297
|
+
elif device == "rocm":
|
|
298
|
+
if not torch.version.hip or not torch.cuda.is_available():
|
|
299
|
+
return {"device": device, "available": False}
|
|
300
|
+
|
|
301
|
+
# PyTorch exposes HIP devices through the torch.cuda API.
|
|
302
|
+
device_index = torch.cuda.current_device()
|
|
303
|
+
properties = torch.cuda.get_device_properties(device_index)
|
|
304
|
+
return {
|
|
305
|
+
"device": device,
|
|
306
|
+
"available": True,
|
|
307
|
+
"name": properties.name,
|
|
308
|
+
"memory_total": properties.total_memory,
|
|
309
|
+
"memory_allocated": torch.cuda.memory_allocated(device_index),
|
|
310
|
+
"memory_reserved": torch.cuda.memory_reserved(device_index),
|
|
311
|
+
"hip_version": torch.version.hip,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
else:
|
|
315
|
+
raise ValueError(f"Unsupported device: {device}")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def format_memory_size(bytes: int) -> str:
|
|
319
|
+
"""Format memory size in human-readable format.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
bytes: memory size in bytes
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Formatted string
|
|
326
|
+
"""
|
|
327
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
328
|
+
if bytes < 1024.0:
|
|
329
|
+
return f"{bytes:.1f} {unit}"
|
|
330
|
+
bytes /= 1024.0
|
|
331
|
+
return f"{bytes:.1f} PB"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _deg_stats(arr: np.ndarray) -> Tuple[int, float, int]:
|
|
335
|
+
if arr.size == 0:
|
|
336
|
+
return 0, 0.0, 0
|
|
337
|
+
return int(arr.min()), float(arr.mean()), int(arr.max())
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _hist_small_ints(deg: np.ndarray, bins=(2, 3, 4, 5, 6, 8, 12, 16, 24, 32)) -> Dict[str, int]:
|
|
341
|
+
if deg.size == 0:
|
|
342
|
+
return {}
|
|
343
|
+
out = {}
|
|
344
|
+
prev = 0
|
|
345
|
+
for b in bins:
|
|
346
|
+
out[f"≤{b}"] = int((deg <= b).sum() - prev)
|
|
347
|
+
prev = (deg <= b).sum()
|
|
348
|
+
out[f">{bins[-1]}"] = int((deg > bins[-1]).sum())
|
|
349
|
+
return out
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _rank_gf2_via_reduction(H_csr: csr_matrix) -> int:
|
|
353
|
+
"""
|
|
354
|
+
Compute rank over GF(2) via sparse row-reduction.
|
|
355
|
+
WARNING: potentially expensive for very large matrices; enable only if you need it.
|
|
356
|
+
"""
|
|
357
|
+
# convert to COO for simple row-ops, then to dense bit rows per pivot band
|
|
358
|
+
H = H_csr.copy().astype(np.uint8)
|
|
359
|
+
M, N = H.shape
|
|
360
|
+
pivcol = 0
|
|
361
|
+
rank = 0
|
|
362
|
+
indptr, indices = H.indptr, H.indices
|
|
363
|
+
# Build list of sets for quick XOR row ops
|
|
364
|
+
rows = [set(indices[indptr[i] : indptr[i + 1]]) for i in range(M)]
|
|
365
|
+
while pivcol < N and rank < M:
|
|
366
|
+
# find a row with a 1 in pivcol at or below 'rank'
|
|
367
|
+
pivot = -1
|
|
368
|
+
for r in range(rank, M):
|
|
369
|
+
if pivcol in rows[r]:
|
|
370
|
+
pivot = r
|
|
371
|
+
break
|
|
372
|
+
if pivot == -1:
|
|
373
|
+
pivcol += 1
|
|
374
|
+
continue
|
|
375
|
+
# swap rows (as sets)
|
|
376
|
+
if pivot != rank:
|
|
377
|
+
rows[rank], rows[pivot] = rows[pivot], rows[rank]
|
|
378
|
+
# eliminate below
|
|
379
|
+
for r in range(rank + 1, M):
|
|
380
|
+
if pivcol in rows[r]:
|
|
381
|
+
rows[r] ^= rows[rank] # symmetric diff = XOR over GF(2)
|
|
382
|
+
rank += 1
|
|
383
|
+
pivcol += 1
|
|
384
|
+
return rank
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def describe_check_matrices(
|
|
388
|
+
check_matrices,
|
|
389
|
+
*,
|
|
390
|
+
dem: Optional[Any] = None, # expects .num_detectors / .num_observables if given
|
|
391
|
+
rounds: Optional[int] = None,
|
|
392
|
+
compute_components: bool = False, # compute connected components of Tanner graph
|
|
393
|
+
compute_rank: bool = False, # GF(2) rank of H (can be expensive)
|
|
394
|
+
file=None,
|
|
395
|
+
return_dict: bool = False,
|
|
396
|
+
) -> Dict[str, Any]:
|
|
397
|
+
"""
|
|
398
|
+
Derive & print detailed stats from real matrices only (no name heuristics).
|
|
399
|
+
"""
|
|
400
|
+
H = check_matrices.check_matrix # usually CSC
|
|
401
|
+
O = check_matrices.observables_matrix # usually CSC
|
|
402
|
+
pri = np.asarray(check_matrices.error_priors)
|
|
403
|
+
|
|
404
|
+
# Ensure both CSR and CSC forms
|
|
405
|
+
H_csr: csr_matrix = H if isinstance(H, csr_matrix) else H.tocsr(copy=False)
|
|
406
|
+
H_csc: csc_matrix = H if isinstance(H, csc_matrix) else H.tocsc(copy=False)
|
|
407
|
+
|
|
408
|
+
M, N = H_csr.shape
|
|
409
|
+
E = int(H_csr.nnz)
|
|
410
|
+
density = (E / (M * N)) if (M and N) else 0.0
|
|
411
|
+
|
|
412
|
+
# Tanner degrees
|
|
413
|
+
row_deg = np.diff(H_csr.indptr) # errors per check
|
|
414
|
+
col_deg = np.diff(H_csc.indptr) # checks per error
|
|
415
|
+
rmin, rmean, rmax = _deg_stats(row_deg)
|
|
416
|
+
cmin, cmean, cmax = _deg_stats(col_deg)
|
|
417
|
+
is_row_reg = rmin == rmax and M > 0
|
|
418
|
+
is_col_reg = cmin == cmax and N > 0
|
|
419
|
+
regularity = (
|
|
420
|
+
f"(d_v={cmin}, d_c={rmin})-regular"
|
|
421
|
+
if (is_row_reg and is_col_reg)
|
|
422
|
+
else ("row-regular" if is_row_reg else ("column-regular" if is_col_reg else None))
|
|
423
|
+
)
|
|
424
|
+
col_hist = _hist_small_ints(col_deg)
|
|
425
|
+
row_hist = _hist_small_ints(row_deg)
|
|
426
|
+
|
|
427
|
+
# Observables matrix stats
|
|
428
|
+
O_rows, O_cols = O.shape
|
|
429
|
+
O_nnz = int(O.nnz)
|
|
430
|
+
O_density = (O_nnz / (O_rows * O_cols)) if (O_rows and O_cols) else 0.0
|
|
431
|
+
|
|
432
|
+
# Priors
|
|
433
|
+
finite_mask = np.isfinite(pri)
|
|
434
|
+
pri_stats = {
|
|
435
|
+
"length": int(pri.size),
|
|
436
|
+
"finite_fraction": float(finite_mask.mean() if pri.size else 1.0),
|
|
437
|
+
"min": float(np.min(pri[finite_mask])) if finite_mask.any() else float("nan"),
|
|
438
|
+
"mean": float(np.mean(pri[finite_mask])) if finite_mask.any() else float("nan"),
|
|
439
|
+
"max": float(np.max(pri[finite_mask])) if finite_mask.any() else float("nan"),
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
# Per-cycle detectors from DEM if provided
|
|
443
|
+
dets_total = getattr(dem, "num_detectors", None) if dem is not None else None
|
|
444
|
+
per_cycle_detectors = (dets_total // rounds) if (dets_total is not None and rounds) else None
|
|
445
|
+
|
|
446
|
+
# Optional: connected components of Tanner graph (bipartite graph with M+N nodes)
|
|
447
|
+
comp_info = None
|
|
448
|
+
if compute_components and E > 0:
|
|
449
|
+
# build bipartite adjacency: blocks [0:M) checks, [M:M+N) variables
|
|
450
|
+
H_coo: coo_matrix = H_csr.tocoo(copy=False)
|
|
451
|
+
rows = H_coo.row
|
|
452
|
+
cols = H_coo.col + M
|
|
453
|
+
data = np.ones_like(rows, dtype=np.uint8)
|
|
454
|
+
# symmetric adjacency
|
|
455
|
+
i_idx = np.concatenate([rows, cols])
|
|
456
|
+
j_idx = np.concatenate([cols, rows])
|
|
457
|
+
A = coo_matrix(
|
|
458
|
+
(np.ones_like(i_idx, dtype=np.uint8), (i_idx, j_idx)), shape=(M + N, M + N)
|
|
459
|
+
).tocsr()
|
|
460
|
+
n_comp, labels = connected_components(A, directed=False, return_labels=True)
|
|
461
|
+
comp_sizes = np.bincount(labels)
|
|
462
|
+
comp_info = {
|
|
463
|
+
"num_components": int(n_comp),
|
|
464
|
+
"component_sizes_desc": np.sort(comp_sizes)[::-1].tolist(),
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
# Optional: GF(2) rank and k ≈ N - rank(H)
|
|
468
|
+
rank_info = None
|
|
469
|
+
if compute_rank and E > 0:
|
|
470
|
+
rank_h = _rank_gf2_via_reduction(H_csr.astype(np.uint8))
|
|
471
|
+
k_est = int(N - rank_h)
|
|
472
|
+
rank_info = {"rank_gf2": int(rank_h), "k_estimate": k_est}
|
|
473
|
+
|
|
474
|
+
# ----- printing -----
|
|
475
|
+
pf = print if file is None else (lambda *a, **k: print(*a, **k, file=file))
|
|
476
|
+
|
|
477
|
+
pf(f"H (checks × errors): {M} × {N}")
|
|
478
|
+
pf(f" nonzeros (E): {E} | density: {density:.9f}")
|
|
479
|
+
pf(f" avg column weight (checks per error): {cmean:.2f}")
|
|
480
|
+
pf(f" avg row weight (errors per check): {rmean:.2f}")
|
|
481
|
+
pf(f" column weight min/mean/max: {cmin} / {cmean:.2f} / {cmax}")
|
|
482
|
+
pf(f" row weight min/mean/max: {rmin} / {rmean:.2f} / {rmax}")
|
|
483
|
+
if regularity:
|
|
484
|
+
pf(f" regularity: {regularity}")
|
|
485
|
+
pf(f" column weight hist: {col_hist}")
|
|
486
|
+
pf(f" row weight hist: {row_hist}")
|
|
487
|
+
|
|
488
|
+
pf(f"O (observables × errors): {O_rows} × {O_cols} (nnz={O_nnz}, density={O_density:.9f})")
|
|
489
|
+
|
|
490
|
+
if rounds is not None:
|
|
491
|
+
if per_cycle_detectors is not None:
|
|
492
|
+
pf(
|
|
493
|
+
f"per-cycle detectors: {per_cycle_detectors} (from DEM: {dets_total} / rounds: {rounds})"
|
|
494
|
+
)
|
|
495
|
+
else:
|
|
496
|
+
pf(f"per-cycle detectors: unavailable (need DEM with num_detectors)")
|
|
497
|
+
|
|
498
|
+
if rank_info:
|
|
499
|
+
pf(f"rank_GF2(H) = {rank_info['rank_gf2']} => k_est ≈ {rank_info['k_estimate']}")
|
|
500
|
+
|
|
501
|
+
if comp_info:
|
|
502
|
+
pf(
|
|
503
|
+
f"Tanner graph connected components: {comp_info['num_components']} "
|
|
504
|
+
f"(largest sizes: {comp_info['component_sizes_desc'][:5]})"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
pf(
|
|
508
|
+
f"priors: N={pri_stats['length']}, finite={pri_stats['finite_fraction'] * 100:.1f}% | "
|
|
509
|
+
f"min/mean/max={pri_stats['min']}/{pri_stats['mean']}/{pri_stats['max']}"
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
out = {
|
|
513
|
+
"H": {
|
|
514
|
+
"shape": (M, N),
|
|
515
|
+
"nonzeros": E,
|
|
516
|
+
"density": density,
|
|
517
|
+
"avg_column_weight": cmean,
|
|
518
|
+
"avg_row_weight": rmean,
|
|
519
|
+
"column_weight_stats": {"min": cmin, "mean": cmean, "max": cmax},
|
|
520
|
+
"row_weight_stats": {"min": rmin, "mean": rmean, "max": rmax},
|
|
521
|
+
"column_weight_hist": col_hist,
|
|
522
|
+
"row_weight_hist": row_hist,
|
|
523
|
+
"regularity": regularity,
|
|
524
|
+
},
|
|
525
|
+
"O": {"shape": (O_rows, O_cols), "nonzeros": O_nnz, "density": O_density},
|
|
526
|
+
"priors": pri_stats,
|
|
527
|
+
"rounds": rounds,
|
|
528
|
+
"per_cycle_detectors": per_cycle_detectors,
|
|
529
|
+
"rank": rank_info,
|
|
530
|
+
"components": comp_info,
|
|
531
|
+
}
|
|
532
|
+
return out if return_dict else out
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def warmup_decoder_options_overrides(options: dict) -> dict:
|
|
536
|
+
"""Iteration-limit overrides that turn any relay-bp config into a warmup config."""
|
|
537
|
+
is_relay = (options.get("num_sets") or 0) > 0
|
|
538
|
+
return {
|
|
539
|
+
"pre_iter": 1,
|
|
540
|
+
"num_sets": 1 if is_relay else 0,
|
|
541
|
+
"set_max_iter": 1 if is_relay else 0,
|
|
542
|
+
"stop_nconv": 1,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def warmup_build_and_decode(
|
|
547
|
+
*,
|
|
548
|
+
check_matrices: Any,
|
|
549
|
+
dem: Any,
|
|
550
|
+
backend: str,
|
|
551
|
+
algo: str,
|
|
552
|
+
perf: str,
|
|
553
|
+
dtype: str,
|
|
554
|
+
pre_iter: int,
|
|
555
|
+
num_sets: int,
|
|
556
|
+
set_max_iter: int,
|
|
557
|
+
stop_nconv: int,
|
|
558
|
+
gamma0: float | None,
|
|
559
|
+
gamma_dist_interval: tuple[float, float] | None,
|
|
560
|
+
alpha: float | None,
|
|
561
|
+
beta: float | None,
|
|
562
|
+
device: Union[str, List[int], int] = [0],
|
|
563
|
+
seed: int = 0,
|
|
564
|
+
repeats: int = 3,
|
|
565
|
+
batch: int = 8,
|
|
566
|
+
parallel: bool = True,
|
|
567
|
+
) -> None:
|
|
568
|
+
"""
|
|
569
|
+
Build a fresh decoder with iteration limits forced to 1 and run warmup decodes.
|
|
570
|
+
Prints start/end and per-repeat timing.
|
|
571
|
+
"""
|
|
572
|
+
sampler = dem.compile_sampler(seed=seed)
|
|
573
|
+
|
|
574
|
+
if backend == "triton":
|
|
575
|
+
from .adapter import RelayDecoder as _RelayDecoder
|
|
576
|
+
from .adapter import ObservableDecoderRunner as _ObservableDecoderRunner
|
|
577
|
+
|
|
578
|
+
_plain = (num_sets == 0) or (algo == "plain")
|
|
579
|
+
|
|
580
|
+
# Build Min-sum and Relay config dicts mirroring the Rust bindings, but
|
|
581
|
+
# with iteration limits reduced to 1 for warmup.
|
|
582
|
+
# Note: Preserve alpha value even when _plain=True (num_sets=0) to ensure
|
|
583
|
+
# warmup decoder uses same configuration as actual decoder for autotuning cache.
|
|
584
|
+
msl_config = {
|
|
585
|
+
"error_priors": check_matrices.error_priors,
|
|
586
|
+
"alpha": None if (algo == "plain") else (alpha if alpha not in (None, 0.0) else 0.0),
|
|
587
|
+
"gamma0": (0.0 if _plain else (gamma0 or 0.0)),
|
|
588
|
+
}
|
|
589
|
+
relay_config = {
|
|
590
|
+
"pre_iter": 1,
|
|
591
|
+
"num_sets": 0 if _plain else num_sets,
|
|
592
|
+
"set_max_iter": 0 if _plain else 1,
|
|
593
|
+
"gamma_dist_interval": (0.0, 0.0) if _plain else (gamma_dist_interval or (0.0, 0.0)),
|
|
594
|
+
"stop_nconv": 1 if _plain else max(1, int(stop_nconv)),
|
|
595
|
+
"logging": False,
|
|
596
|
+
"seed": int(seed or 0),
|
|
597
|
+
}
|
|
598
|
+
backend_config = {
|
|
599
|
+
"dtype": dtype,
|
|
600
|
+
"perf": perf,
|
|
601
|
+
"device": device,
|
|
602
|
+
"algo": ("plain" if _plain else "relay"),
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
dec = _RelayDecoder(
|
|
606
|
+
check_matrices.check_matrix,
|
|
607
|
+
msl_config=msl_config,
|
|
608
|
+
relay_config=relay_config,
|
|
609
|
+
data_type="float64" if dtype == "fp64" else "float32",
|
|
610
|
+
backend_config=backend_config,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
observable_decoder = _ObservableDecoderRunner(
|
|
614
|
+
dec,
|
|
615
|
+
check_matrices.observables_matrix,
|
|
616
|
+
include_decode_result=True,
|
|
617
|
+
)
|
|
618
|
+
else:
|
|
619
|
+
import relay_bp as _rb
|
|
620
|
+
|
|
621
|
+
suffix = {"fp16": "F16", "fp32": "F32", "fp64": "F64"}.get(dtype)
|
|
622
|
+
if suffix is None:
|
|
623
|
+
raise ValueError("Unsupported dtype for rust warmup: " + str(dtype))
|
|
624
|
+
if algo == "plain":
|
|
625
|
+
minsum_name = f"MinSumBPDecoder{suffix}"
|
|
626
|
+
if not hasattr(_rb, minsum_name):
|
|
627
|
+
raise ValueError(f"Requested Rust plain decoder '{minsum_name}' not available")
|
|
628
|
+
MinSum = getattr(_rb, minsum_name)
|
|
629
|
+
dec = MinSum(
|
|
630
|
+
check_matrices.check_matrix,
|
|
631
|
+
error_priors=check_matrices.error_priors,
|
|
632
|
+
max_iter=1,
|
|
633
|
+
alpha=None,
|
|
634
|
+
gamma0=None,
|
|
635
|
+
)
|
|
636
|
+
else:
|
|
637
|
+
relay_name = f"RelayDecoder{suffix}"
|
|
638
|
+
if not hasattr(_rb, relay_name):
|
|
639
|
+
raise ValueError(f"Requested Rust relay decoder '{relay_name}' not available")
|
|
640
|
+
Relay = getattr(_rb, relay_name)
|
|
641
|
+
dec = Relay(
|
|
642
|
+
check_matrices.check_matrix,
|
|
643
|
+
error_priors=check_matrices.error_priors,
|
|
644
|
+
gamma0=(gamma0 or 0.0),
|
|
645
|
+
pre_iter=1,
|
|
646
|
+
num_sets=max(1, int(num_sets)),
|
|
647
|
+
set_max_iter=1,
|
|
648
|
+
gamma_dist_interval=(gamma_dist_interval or (0.0, 0.0)),
|
|
649
|
+
stop_nconv=max(1, int(stop_nconv)),
|
|
650
|
+
seed=int(seed or 0),
|
|
651
|
+
alpha=(alpha if alpha not in (None, 0.0) else 0.0),
|
|
652
|
+
beta=beta,
|
|
653
|
+
)
|
|
654
|
+
from relay_bp import ObservableDecoderRunner as _ObservableDecoderRunner
|
|
655
|
+
|
|
656
|
+
observable_decoder = _ObservableDecoderRunner(
|
|
657
|
+
dec,
|
|
658
|
+
check_matrices.observables_matrix,
|
|
659
|
+
include_decode_result=True,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
total_start = time.perf_counter()
|
|
663
|
+
print(
|
|
664
|
+
f"[warmup] starting (repeats={int(repeats)}, batch={int(batch)}, backend={backend}, algo={algo}, perf={perf}, dtype={dtype})"
|
|
665
|
+
)
|
|
666
|
+
for i in range(int(repeats)):
|
|
667
|
+
rep_start = time.perf_counter()
|
|
668
|
+
_det, _obs, _warm_errors = sampler.sample(int(batch), return_errors=True)
|
|
669
|
+
_ = observable_decoder.from_errors_decode_observables_detailed_batch(
|
|
670
|
+
_warm_errors.astype(np.uint8, copy=False), parallel=parallel
|
|
671
|
+
)
|
|
672
|
+
rep_end = time.perf_counter()
|
|
673
|
+
ms = (rep_end - rep_start) * 1e3
|
|
674
|
+
print(f"[warmup] repeat {i + 1}/{int(repeats)} took {ms:.3f} ms")
|
|
675
|
+
total_end = time.perf_counter()
|
|
676
|
+
total_ms = (total_end - total_start) * 1e3
|
|
677
|
+
print(f"[warmup] finished in {total_ms:.3f} ms")
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def load_explicit_gammas(
|
|
681
|
+
explicit_gammas: Union[str, np.ndarray, None],
|
|
682
|
+
num_variables: int,
|
|
683
|
+
backend: str = "triton",
|
|
684
|
+
num_sets: Optional[int] = None,
|
|
685
|
+
mode: str = "relay",
|
|
686
|
+
) -> Optional[np.ndarray]:
|
|
687
|
+
"""Load and validate explicit gamma values for Relay-BP.
|
|
688
|
+
|
|
689
|
+
This function loads explicit gamma values from a file or numpy array,
|
|
690
|
+
validates the shape, and converts to the appropriate dtype for the backend.
|
|
691
|
+
|
|
692
|
+
Args:
|
|
693
|
+
explicit_gammas: File path (str), numpy array, or None
|
|
694
|
+
num_variables: Number of variables (V) in the code (for validation)
|
|
695
|
+
backend: Backend type ("rust" or "triton") - determines dtype conversion
|
|
696
|
+
num_sets: Number of relay sets (for warning if K < num_sets)
|
|
697
|
+
mode: Decoding mode ("relay" or "plain") - only used for warnings
|
|
698
|
+
|
|
699
|
+
Returns:
|
|
700
|
+
numpy array of shape (K, V) with dtype float64 (rust) or float32 (triton),
|
|
701
|
+
or None if explicit_gammas is None
|
|
702
|
+
|
|
703
|
+
Raises:
|
|
704
|
+
FileNotFoundError: If file path doesn't exist
|
|
705
|
+
ValueError: If file format is unsupported or shape is invalid
|
|
706
|
+
TypeError: If explicit_gammas is not str, np.ndarray, or None
|
|
707
|
+
"""
|
|
708
|
+
if explicit_gammas is None:
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
explicit_gammas_array = None
|
|
712
|
+
|
|
713
|
+
if isinstance(explicit_gammas, str):
|
|
714
|
+
# Load from file
|
|
715
|
+
gamma_path = Path(explicit_gammas)
|
|
716
|
+
if not gamma_path.exists():
|
|
717
|
+
raise FileNotFoundError(f"Explicit gammas file not found: {explicit_gammas}")
|
|
718
|
+
|
|
719
|
+
if gamma_path.suffix == ".npy":
|
|
720
|
+
explicit_gammas_array = np.load(gamma_path)
|
|
721
|
+
elif gamma_path.suffix == ".npz":
|
|
722
|
+
# For .npz, try common keys or use first array
|
|
723
|
+
data = np.load(gamma_path)
|
|
724
|
+
if "gammas" in data:
|
|
725
|
+
explicit_gammas_array = data["gammas"]
|
|
726
|
+
elif "arr_0" in data:
|
|
727
|
+
explicit_gammas_array = data["arr_0"]
|
|
728
|
+
else:
|
|
729
|
+
keys = list(data.keys())
|
|
730
|
+
if keys:
|
|
731
|
+
explicit_gammas_array = data[keys[0]]
|
|
732
|
+
else:
|
|
733
|
+
raise ValueError(f"No arrays found in .npz file: {explicit_gammas}")
|
|
734
|
+
data.close()
|
|
735
|
+
elif gamma_path.suffix == ".csv":
|
|
736
|
+
try:
|
|
737
|
+
import pandas as pd
|
|
738
|
+
|
|
739
|
+
explicit_gammas_array = pd.read_csv(gamma_path, header=None).values
|
|
740
|
+
except ImportError:
|
|
741
|
+
raise ImportError(
|
|
742
|
+
"pandas is required to load .csv files. Install with: pip install pandas"
|
|
743
|
+
)
|
|
744
|
+
else:
|
|
745
|
+
raise ValueError(
|
|
746
|
+
f"Unsupported file format for explicit gammas: {gamma_path.suffix}. Use .npy, .npz, or .csv"
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
log.info(
|
|
750
|
+
f"Loaded explicit gammas from '{explicit_gammas}': shape {explicit_gammas_array.shape}"
|
|
751
|
+
)
|
|
752
|
+
elif isinstance(explicit_gammas, np.ndarray):
|
|
753
|
+
explicit_gammas_array = explicit_gammas
|
|
754
|
+
else:
|
|
755
|
+
raise TypeError(
|
|
756
|
+
f"explicit_gammas must be a file path (str) or numpy array, got {type(explicit_gammas)}"
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
# Validate shape: must be (K, V) where V matches number of variables
|
|
760
|
+
if explicit_gammas_array.ndim != 2:
|
|
761
|
+
raise ValueError(
|
|
762
|
+
f"explicit_gammas must be 2D array (K, V), got shape {explicit_gammas_array.shape}"
|
|
763
|
+
)
|
|
764
|
+
if explicit_gammas_array.shape[1] != num_variables:
|
|
765
|
+
raise ValueError(
|
|
766
|
+
f"explicit_gammas must have V={num_variables} columns (number of variables), "
|
|
767
|
+
f"got {explicit_gammas_array.shape[1]}"
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
K = explicit_gammas_array.shape[0]
|
|
771
|
+
if mode == "relay" and num_sets is not None and num_sets > 0 and K < num_sets:
|
|
772
|
+
log.warning(
|
|
773
|
+
f"Number of gamma sets ({K}) is less than num_sets ({num_sets}). "
|
|
774
|
+
f"Gammas will cycle (relay leg l uses row (l + 1) % {K})."
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
# Convert to appropriate dtype for backend
|
|
778
|
+
if backend == "rust":
|
|
779
|
+
explicit_gammas_array = explicit_gammas_array.astype(np.float64)
|
|
780
|
+
else:
|
|
781
|
+
explicit_gammas_array = explicit_gammas_array.astype(np.float32)
|
|
782
|
+
|
|
783
|
+
return explicit_gammas_array
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def load_gamma_dist_by_error(
|
|
787
|
+
gamma_dist_by_error: Union[str, np.ndarray, None],
|
|
788
|
+
num_variables: int,
|
|
789
|
+
) -> Optional[np.ndarray]:
|
|
790
|
+
"""Load and validate per-variable gamma intervals for Relay-BP.
|
|
791
|
+
|
|
792
|
+
Returns a (V, 2) array of [low, high] intervals per variable.
|
|
793
|
+
"""
|
|
794
|
+
if gamma_dist_by_error is None:
|
|
795
|
+
return None
|
|
796
|
+
|
|
797
|
+
arr: Optional[np.ndarray] = None
|
|
798
|
+
|
|
799
|
+
if isinstance(gamma_dist_by_error, str):
|
|
800
|
+
gamma_path = Path(gamma_dist_by_error)
|
|
801
|
+
if not gamma_path.exists():
|
|
802
|
+
raise FileNotFoundError(f"gamma_dist_by_error file not found: {gamma_dist_by_error}")
|
|
803
|
+
|
|
804
|
+
if gamma_path.suffix == ".npy":
|
|
805
|
+
arr = np.load(gamma_path)
|
|
806
|
+
elif gamma_path.suffix == ".npz":
|
|
807
|
+
data = np.load(gamma_path)
|
|
808
|
+
if "gammas" in data:
|
|
809
|
+
arr = data["gammas"]
|
|
810
|
+
elif "arr_0" in data:
|
|
811
|
+
arr = data["arr_0"]
|
|
812
|
+
else:
|
|
813
|
+
keys = list(data.keys())
|
|
814
|
+
if keys:
|
|
815
|
+
arr = data[keys[0]]
|
|
816
|
+
else:
|
|
817
|
+
raise ValueError(f"No arrays found in .npz file: {gamma_dist_by_error}")
|
|
818
|
+
data.close()
|
|
819
|
+
elif gamma_path.suffix == ".csv":
|
|
820
|
+
try:
|
|
821
|
+
import pandas as pd
|
|
822
|
+
|
|
823
|
+
arr = pd.read_csv(gamma_path, header=None).values
|
|
824
|
+
except ImportError:
|
|
825
|
+
raise ImportError(
|
|
826
|
+
"pandas is required to load .csv files for gamma_dist_by_error. "
|
|
827
|
+
"Install with: pip install pandas"
|
|
828
|
+
)
|
|
829
|
+
else:
|
|
830
|
+
raise ValueError(
|
|
831
|
+
f"Unsupported file format for gamma_dist_by_error: {gamma_path.suffix}. "
|
|
832
|
+
"Use .npy, .npz, or .csv"
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
log.info(f"Loaded gamma_dist_by_error from '{gamma_dist_by_error}': shape {arr.shape}")
|
|
836
|
+
elif isinstance(gamma_dist_by_error, np.ndarray):
|
|
837
|
+
arr = gamma_dist_by_error
|
|
838
|
+
else:
|
|
839
|
+
raise TypeError(
|
|
840
|
+
f"gamma_dist_by_error must be a file path (str) or numpy array, got {type(gamma_dist_by_error)}"
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
# Validate shape: (V, 2)
|
|
844
|
+
if arr.ndim != 2 or arr.shape[0] != num_variables or arr.shape[1] != 2:
|
|
845
|
+
raise ValueError(
|
|
846
|
+
f"gamma_dist_by_error must have shape (V={num_variables}, 2), got {arr.shape}"
|
|
847
|
+
)
|
|
848
|
+
if np.any(arr[:, 0] > arr[:, 1]):
|
|
849
|
+
raise ValueError("gamma_dist_by_error: lower bounds must be <= upper bounds in each row")
|
|
850
|
+
|
|
851
|
+
# Use float32 for compatibility with Triton; Rust bindings will up-cast as needed.
|
|
852
|
+
return arr.astype(np.float32)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def format_batch_per_worker(batch: int, num_workers: int) -> Optional[str]:
|
|
856
|
+
"""Format batch distribution per worker as 'X' or 'X-Y'.
|
|
857
|
+
|
|
858
|
+
This helper is used to report how a batch of size B is split across
|
|
859
|
+
a given number of workers (GPUs or CPU cores), without encoding any
|
|
860
|
+
backend-specific logic. It returns:
|
|
861
|
+
|
|
862
|
+
- None if num_workers <= 0
|
|
863
|
+
- 'X' if B is evenly divisible by num_workers
|
|
864
|
+
- 'X-Y' if not divisible, indicating some workers process X and some X+1
|
|
865
|
+
"""
|
|
866
|
+
if num_workers <= 0:
|
|
867
|
+
return None
|
|
868
|
+
base = batch // num_workers
|
|
869
|
+
remainder = batch % num_workers
|
|
870
|
+
if remainder == 0:
|
|
871
|
+
return str(base)
|
|
872
|
+
return f"{base}-{base + 1}"
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def format_batch_per_device_from_decoder(batch: int, decoder) -> Optional[str]:
|
|
876
|
+
"""Infer device count from decoder (GPUs) and format batch per device."""
|
|
877
|
+
if decoder is None:
|
|
878
|
+
return None
|
|
879
|
+
try:
|
|
880
|
+
if hasattr(decoder, "_dec") and hasattr(decoder._dec, "num_gpus"):
|
|
881
|
+
num_gpus = int(decoder._dec.num_gpus)
|
|
882
|
+
else:
|
|
883
|
+
num_gpus = 1
|
|
884
|
+
except Exception:
|
|
885
|
+
num_gpus = 1
|
|
886
|
+
return format_batch_per_worker(batch, num_gpus)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def has_rust() -> bool:
|
|
890
|
+
"""Return True when the Rust ``relay_bp`` reference decoder is importable.
|
|
891
|
+
|
|
892
|
+
The Rust implementation is an optional dependency. Callers gate on this
|
|
893
|
+
rather than catching ImportError at each use site.
|
|
894
|
+
"""
|
|
895
|
+
try:
|
|
896
|
+
import relay_bp # noqa: F401
|
|
897
|
+
except Exception:
|
|
898
|
+
return False
|
|
899
|
+
return True
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def compare_with_rust(
|
|
903
|
+
H_csr,
|
|
904
|
+
error_priors: np.ndarray,
|
|
905
|
+
syndromes: np.ndarray,
|
|
906
|
+
**decoder_kwargs,
|
|
907
|
+
) -> dict:
|
|
908
|
+
"""Decode ``syndromes`` with both backends and compare the results.
|
|
909
|
+
|
|
910
|
+
Both decoders receive the same check matrix, priors and configuration, so a
|
|
911
|
+
disagreement reflects a real difference rather than a setup skew.
|
|
912
|
+
|
|
913
|
+
Pass every parameter that affects gamma sampling explicitly — in particular
|
|
914
|
+
``gamma_dist_interval`` alongside ``gamma0`` and ``seed``. If one is left to
|
|
915
|
+
a default the two implementations may pick different disordered memory
|
|
916
|
+
strengths, and the comparison will report a mismatch that is a
|
|
917
|
+
configuration difference rather than a decoder disagreement.
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
H_csr: Check matrix (C x V) in CSR format.
|
|
921
|
+
error_priors: [V] error probabilities in (0, 0.5).
|
|
922
|
+
syndromes: [B, C] uint8 syndromes. The Triton decoder requires a
|
|
923
|
+
torch tensor on its device; a numpy array is accepted here and
|
|
924
|
+
converted for the Rust side.
|
|
925
|
+
**decoder_kwargs: Forwarded to both decoders. ``data_type`` selects the
|
|
926
|
+
Rust precision variant ("float16" / "float32" / "float64").
|
|
927
|
+
|
|
928
|
+
Returns:
|
|
929
|
+
A dict with ``agree``, ``mismatched_shots``, ``total_shots``,
|
|
930
|
+
``triton_e_hat`` and ``rust_e_hat``.
|
|
931
|
+
|
|
932
|
+
Raises:
|
|
933
|
+
RuntimeError: if the Rust decoder is not installed, or the installed
|
|
934
|
+
build lacks the precision variant requested.
|
|
935
|
+
ValueError: on an unsupported ``data_type``.
|
|
936
|
+
"""
|
|
937
|
+
if not has_rust():
|
|
938
|
+
raise RuntimeError(
|
|
939
|
+
"compare_with_rust requires the Rust reference decoder. "
|
|
940
|
+
"Install it with the 'rust' extra."
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
import relay_bp as _rb
|
|
944
|
+
|
|
945
|
+
from .decoder import RelayBPDecoder as _TritonDecoder
|
|
946
|
+
|
|
947
|
+
dtype = str(decoder_kwargs.get("data_type", "float32"))
|
|
948
|
+
suffix = {"float16": "F16", "float32": "F32", "float64": "F64"}.get(dtype)
|
|
949
|
+
if suffix is None:
|
|
950
|
+
raise ValueError(f"unsupported data_type for comparison: {dtype!r}")
|
|
951
|
+
|
|
952
|
+
rust_cls_name = f"RelayDecoder{suffix}"
|
|
953
|
+
if not hasattr(_rb, rust_cls_name):
|
|
954
|
+
raise RuntimeError(f"the installed relay_bp has no {rust_cls_name}")
|
|
955
|
+
|
|
956
|
+
triton_out = _TritonDecoder(
|
|
957
|
+
H_csr=H_csr, error_priors=error_priors, **decoder_kwargs
|
|
958
|
+
).decode(syndromes)
|
|
959
|
+
_triton_raw = triton_out["errors"] if isinstance(triton_out, dict) else triton_out
|
|
960
|
+
# The Triton decoder returns device tensors; bring them to host for comparison.
|
|
961
|
+
triton_e_hat = (
|
|
962
|
+
_triton_raw.cpu().numpy() if hasattr(_triton_raw, "cpu") else np.asarray(_triton_raw)
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
_RUST_KEYS = (
|
|
966
|
+
"pre_iter",
|
|
967
|
+
"num_sets",
|
|
968
|
+
"set_max_iter",
|
|
969
|
+
"gamma0",
|
|
970
|
+
"gamma_dist_interval",
|
|
971
|
+
"stop_nconv",
|
|
972
|
+
"normalized_min_sum_alpha",
|
|
973
|
+
"offset_min_sum_beta",
|
|
974
|
+
"seed",
|
|
975
|
+
)
|
|
976
|
+
rust_kwargs = {k: v for k, v in decoder_kwargs.items() if k in _RUST_KEYS}
|
|
977
|
+
rust_decoder = getattr(_rb, rust_cls_name)(
|
|
978
|
+
H_csr, error_priors=error_priors, **rust_kwargs
|
|
979
|
+
)
|
|
980
|
+
# The Rust decoder is a CPU implementation and needs host-side numpy, while
|
|
981
|
+
# the Triton decoder needs a device tensor. Accept either input form.
|
|
982
|
+
if hasattr(syndromes, "cpu"):
|
|
983
|
+
syndromes_np = syndromes.cpu().numpy()
|
|
984
|
+
else:
|
|
985
|
+
syndromes_np = np.asarray(syndromes)
|
|
986
|
+
rust_e_hat = np.asarray(rust_decoder.decode_batch(syndromes_np))
|
|
987
|
+
|
|
988
|
+
mismatched = int(np.count_nonzero((triton_e_hat != rust_e_hat).any(axis=-1)))
|
|
989
|
+
return {
|
|
990
|
+
"agree": mismatched == 0,
|
|
991
|
+
"mismatched_shots": mismatched,
|
|
992
|
+
"total_shots": int(triton_e_hat.shape[0]),
|
|
993
|
+
"triton_e_hat": triton_e_hat,
|
|
994
|
+
"rust_e_hat": rust_e_hat,
|
|
995
|
+
}
|