Sweeping Relay BP Stopping Criteria From a Single Run
The NV-qLDPC Relay BP decoder walks through a schedule of gamma sets (“legs”) and can converge to a valid solution multiple times along the way; the stopping_criterion and stop_nconv parameters decide how many of those convergences to collect before returning the minimum-weight one (the RelayBP-N algorithm). stop_nconv is a genuine tuning knob: a larger N can lower the logical error rate (more candidate solutions to pick the minimum-weight answer from) but costs more BP iterations per shot, so choosing it means measuring the whole accuracy/latency trade-off curve.
Measured directly, that curve is expensive: every candidate N requires re-decoding the same syndromes in a separate full GPU run, and each run discards everything about the convergences except the single winning solution. The relay_solutions option removes this waste. When the decoder is constructed with opt_results={"relay_solutions": True}, one run records every relay convergence — the cumulative iteration count at which it happened, the LLR weight of its hard decision, and the hard decision itself — and the cudaq_qec.relay_solutions post-processing helper replays the entire stop_nconv sweep offline from that recording. One GPU run, plus milliseconds of NumPy, answers every N at once. The reconstruction is exact, not approximate: with repeatable=True the reconstructed logical error rate and iteration statistics match a real stop_nconv=N run bit-for-bit, for every N.
Recording a run
The recording run must not itself stop early — it uses stopping_criterion="All" so every convergence in the schedule is observed — and it should return per-shot iteration counts (num_iter), which the sweep uses for shots that exhaust the schedule. Everything else is the decoder configuration you are tuning. The example below uses the [[144,12,12]] bivariate-bicycle (gross) code with the canonical Relay BP settings for that code:
import cudaq_qec as qec
from cudaq_qec import relay_solutions
# H, O, error_rates, syndromes, obs_truth come from a circuit-level DEM
decoder = qec.get_decoder(
"nv-qldpc-decoder", H, error_rate_vec=error_rates,
use_sparsity=True, bp_method=3, composition=1, max_iterations=60,
gamma0=0.125, gamma_dist=[-0.24, 0.66], clip_value=200.0,
repeatable=True, proc_float="fp32",
O=O, # records become obs flips
srelay_config={"pre_iter": 80, "num_sets": 60,
"stopping_criterion": "All"}, # observe every leg
opt_results={"relay_solutions": True, # record all solutions
"num_iter": True},
bp_batch_size=1000)
results = decoder.decode_batch(syndromes)
# Replay the whole RelayBP-N sweep from the one recording. n_values
# defaults to every N up to the deepest recording (the largest number
# of convergences any shot produced).
sweep = relay_solutions.stop_nconv_sweep(
results, obs_truth, percentiles=[50, 99])
for j, n in enumerate(sweep.n):
print(f"stop_nconv={n}: LER={sweep.ler[j]:.2e}, "
f"mean iters={sweep.avg_iters[j]:.1f}, "
f"p99 iters={sweep.iters_percentiles[1, j]:.0f}")
For every N, stop_nconv_sweep() reproduces exactly what stopping_criterion="NConv", stop_nconv=N would have returned per shot: the prediction is the minimum-weight record among the first N convergences, and the iteration count is the cumulative count at the Nth convergence (or the shot’s full-schedule num_iter when it produced fewer than N). It returns the logical error rate, mean iterations, any requested iteration percentiles, and the fraction of shots that exhausted the schedule, for each N. The raw records are also available directly — unpack() reconstructs the per-shot record axes from results.batch_opt_results — for custom analyses such as weight-spectrum or convergence-time studies.
Example: RelayBP-N on the gross code
We decode the [[144,12,12]] gross code at p = 0.002 with the configuration above, using split decoding: the Z-only memory circuit from the Relay-BP reference implementation (vendored under assets/benchmarks/), whose detector error model covers the Z-stabilizer detectors only.
Parameter |
Value |
|---|---|
GPU |
NVIDIA RTX PRO 3000 (Blackwell, laptop) |
Code |
|
Noise model |
uniform circuit-level, |
Shots |
100,000 |
|
3 |
|
1 |
|
0.125 |
|
|
|
200.0 |
|
60 (per leg) |
|
True / True |
|
|
|
80 / 60 |
|
1,000 |
The single recording run took 256 s (2.6 ms/shot) for the 100,000 shots, and the offline sweep over every N from 1 to 61 took 414 ms. A few points on the curve:
|
Logical errors (of 100,000) |
Mean iterations |
p50 iterations |
p99 iterations |
Schedule exhausted |
|---|---|---|---|---|---|
1 |
5 |
12.9 |
10 |
51 |
0% |
2 |
3 |
26.6 |
20 |
118 |
0% |
3 |
2 |
39.5 |
31 |
162 |
0% |
5 |
1 |
65.5 |
52 |
243 |
0.01% |
15 |
1 |
193.0 |
164 |
646 |
0.06% |
50 |
1 |
622.5 |
554 |
1890 |
1.51% |
The sweep shows the trade-off directly: the logical error rate falls roughly 5x between N = 1 and N = 5 (5e-5 to 1e-5) and then saturates, while the iteration cost keeps growing linearly in N — so for this code and noise, stop_nconv=5 buys all of the measured accuracy at a small fraction of the cost of larger N. Every row of this table — and the 55 other N values on the curve — comes from the same single GPU run. Measuring the curve directly would instead cost one full decode of the same 100,000 syndromes per N — cheap for small N, increasingly close to the recording run’s cost as N grows, and always another full GPU pass for every additional point on the curve. The recording also captures strictly more information than the sweep consumes — per-record weights and convergence times for every shot — so later questions (a different percentile, a subset of shots, a custom tie-breaking rule) are answered from the same data with no further GPU time.
Practical notes
Record observables, not corrections. Constructing the decoder with the observables matrix
Omakes each recordO @ correction (mod 2)— for the gross-code DEM above this is 12 bits per record instead of 8,784, shrinking readback and post-processing cost by orders of magnitude. WithoutO, pass the matrix tostop_nconv_sweep(..., observables=O)instead so it can score the logical error rate.Memory.
relay_solutions=Truerecords every convergence, bounded bynum_sets(+1 ifpre_iter > 0) records per shot. To bound memory on long schedules, pass an integer cap (e.g.relay_solutions=16); the sweep will refuse N values beyond a cap that actually truncated records, so cap at or above the largest N you intend to sweep.Repeatability. With
repeatable=True(and a non-zeroclip_value) the reconstruction is bit-for-bit identical to realstop_nconv=Nruns. Without it, runs differ by floating-point non-determinism, and the reconstruction matches a real run only statistically.
See Also
Quantum Low-Density Parity-Check Decoder – the nv-qldpc-decoder overview
C++ and Python API reference – the
relay_solutionsoption underopt_resultsImproving Relay BP Decoding With Gamma Ensembles – a complementary Relay BP performance study