Decoders

In quantum error correction, decoders are responsible for interpreting measurement outcomes (syndromes) to identify and correct quantum errors. We measure a set of stabilizers that give us information about what errors might have happened. The pattern of these measurements is called a syndrome, and the decoder’s task is to determine what errors most likely caused that syndrome.

The relationship between errors and syndromes is captured mathematically by the parity check matrix. Each row of this matrix represents a stabilizer measurement, while each column represents a possible error. When we multiply an error pattern by this matrix, we get the syndrome that would result from those errors.

A detector error model (DEM) describes how the errors in a QEC circuit produce the syndrome bits that detect them. The examples below work with DEMs in three ways: the first constructs a decoder directly from raw Stim .dem text; the second expands a DEM into a multi-round parity check matrix; and the third samples synthetic error and syndrome data from a DEM to exercise a decoder. See Detector Error Model for more details.

Decoding From Stim DEM Text

This example constructs a decoder from raw Stim .dem text and uses the matching parsed matrix for observable predictions. For what a detector error model is and how the text is parsed, see Decoding from Stim DEM Text.

import numpy as np
import cudaq_qec as qec

dem_text = """\
error(0.1) D0 L0
error(0.1) D1 L0
error(0.05) D0 D1
error(0.02) D0 ^ D1
"""

# Decoder construction uses default parsing (use_decomp_suggestions=False):
# '^' hints in the DEM text are ignored.
decoder = qec.get_decoder("single_error_lut", dem_text)
dem = qec.dem_from_stim_text(dem_text)

print("detectors:", dem.num_detectors())
print("error mechanisms (used by decoder):", dem.num_error_mechanisms())
print("observables:", dem.num_observables())

# Inspection only: show how many columns the matrix would have if '^'
# hints were honored. This does not affect the decoder above.
dem_decomposed = qec.dem_from_stim_text(dem_text, use_decomp_suggestions=True)

print("error mechanisms (if ^ hints honored):",
      dem_decomposed.num_error_mechanisms())

syndromes = np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=np.uint8)
results = decoder.decode_batch(syndromes)
error_predictions = np.array([r.result for r in results], dtype=np.uint8)
observable_predictions = (
    dem.observables_flips_matrix @ error_predictions.T) % 2

for syndrome, error, observable in zip(syndromes, error_predictions,
                                       observable_predictions.T):
    print(f"syndrome {syndrome.tolist()} -> "
          f"error {error.tolist()} -> "
          f"observable flip {observable.tolist()}")
// Compile and run with:
// nvq++ -lcudaq-qec -lcudaq-qec-decoders stim_dem_decoder.cpp
// ./a.out

#include "cudaq/qec/decoder.h"
#include "cudaq/qec/detector_error_model.h"

#include <cstdint>
#include <iostream>
#include <string>
#include <vector>

int main() {
  const std::string dem_text = R"(error(0.1) D0 L0
error(0.1) D1 L0
error(0.05) D0 D1
error(0.02) D0 ^ D1
)";

  // Decoder construction uses default parsing (use_decomp_suggestions=false):
  // '^' hints in the DEM text are ignored.
  auto decoder = cudaq::qec::get_decoder("single_error_lut", dem_text);
  auto dem = cudaq::qec::dem_from_stim_text(dem_text);

  std::cout << "detectors: " << dem.num_detectors() << "\n";
  std::cout << "error mechanisms (used by decoder): "
            << dem.num_error_mechanisms() << "\n";
  std::cout << "observables: " << dem.num_observables() << "\n";

  // Inspection only: show how many columns the matrix would have if
  // '^' hints were honored. This does not affect the decoder above.
  auto dem_decomposed =
      cudaq::qec::dem_from_stim_text(dem_text, /*use_decomp_suggestions=*/true);

  std::cout << "error mechanisms (if ^ hints honored): "
            << dem_decomposed.num_error_mechanisms() << "\n";

  const std::vector<std::vector<cudaq::qec::float_t>> syndromes = {
      {0.0, 0.0}, {1.0, 0.0}, {0.0, 1.0}, {1.0, 1.0}};

  for (const auto &syndrome : syndromes) {
    auto result = decoder->decode(syndrome);

    std::cout << "syndrome [" << syndrome[0] << ", " << syndrome[1] << "]"
              << " -> error [";
    for (std::size_t i = 0; i < result.result.size(); ++i) {
      if (i > 0)
        std::cout << ", ";
      std::cout << result.result[i];
    }
    std::cout << "] -> observable flip [";
    for (std::size_t obs = 0; obs < dem.num_observables(); ++obs) {
      if (obs > 0)
        std::cout << ", ";
      std::uint8_t flip = 0;
      for (std::size_t error = 0; error < result.result.size(); ++error)
        flip ^= static_cast<std::uint8_t>(
            dem.observables_flips_matrix.at({obs, error}) &&
            result.result[error] != 0.0);
      std::cout << static_cast<int>(flip);
    }
    std::cout << "]\n";
  }
}

Compile and run with

nvq++ -lcudaq-qec -lcudaq-qec-decoders stim_dem_decoder.cpp -o stim_dem_decoder
./stim_dem_decoder

Generating a Multi-Round Parity Check Matrix

A single-round DEM captures one measurement cycle. Under circuit-level noise, errors accumulate across many rounds, and the DEM expands into a multi-round parity check matrix. The following example constructs one for an error correction code in Python:

import cudaq
import cudaq_qec as qec
import numpy as np

# Set target simulator (Stim) for fast stabilizer circuit simulation
cudaq.set_target("stim")

distance = 3  # Code distance (number of physical qubits for repetition code)
nRounds = 6  # Number of syndrome measurement rounds
nShots = 10000  # Number of circuit samples to run

# Set verbosity based on shot count
verbose = nShots <= 10


def vprint(*args, **kwargs):
    if verbose:
        print(*args, **kwargs)


# Retrieve a 3-qubit repetition code instance
three_qubit_repetition_code = qec.get_code("repetition", distance=distance)

# Z logical observable (for repetition codes, only Z matters)
logical_single_round = three_qubit_repetition_code.get_observables_z()

# Use predefined state preparation (|1⟩ for logical '1')
statePrep = qec.operation.prep1

# Create a noise model instance
noise_model = cudaq.NoiseModel()

# Define physical gate error probability
p = 0.01
# Define measurement error probability (not activated by default)
p_per_mz = 0.001

# Inject depolarizing noise on CX gates
noise_model.add_all_qubit_channel("x", cudaq.Depolarization2(p), 1)
# noise_model.add_all_qubit_channel("mz", cudaq.BitFlipChannel(p_per_mz))  # Optional: measurement noise

# === Decoder Setup ===

# Generate full detector error model (DEM), tracking all observables
dem_rep_full = qec.dem_from_memory_circuit(three_qubit_repetition_code,
                                           statePrep, nRounds, noise_model)

# Generate Z-only detector error model (sufficient for repetition code)
dem_rep_z = qec.z_dem_from_memory_circuit(three_qubit_repetition_code,
                                          statePrep, nRounds, noise_model)

# Extract multi-round parity check matrix (H matrix)
H_pcm_from_dem_full = dem_rep_full.detector_error_matrix
H_pcm_from_dem_z = dem_rep_z.detector_error_matrix

# Sanity check: for repetition codes, full and Z-only matrices should match
assert (H_pcm_from_dem_z == H_pcm_from_dem_full).all()

# Retrieve observable flips matrix: maps physical errors to logical flips
Lz_observables_flips_matrix = dem_rep_z.observables_flips_matrix

# Instantiate a decoder: single-error lookup table (fast and sufficient for small codes)
decoder = qec.get_decoder("single_error_lut", H_pcm_from_dem_z)

# === Simulation ===

# Sample noisy executions of the code circuit
syndromes, data = qec.sample_memory_circuit(three_qubit_repetition_code,
                                            statePrep, nShots, nRounds,
                                            noise_model)

syndromes = syndromes.reshape((nShots, -1))

# Expected logical measurement (we prepared |1⟩)
expected_value = 1

# Counters for statistics
nLogicalErrorsWithoutDecoding = 0
nLogicalErrorsWDecoding = 0
nCorrections = 0

# === Loop over shots ===
for i in range(nShots):
    vprint(f"shot: {i}")

    data_i = data[i, :]  # Final data measurement
    vprint(f"data: {data_i}")

    results = decoder.decode(syndromes[i, :])
    convergence = results.converged
    result = results.result
    error_prediction = np.array(result, dtype=np.uint8)
    vprint(f"error_prediction: {error_prediction}")

    predicted_observable_flip = Lz_observables_flips_matrix @ error_prediction % 2
    vprint(f"predicted_observable_flip: {predicted_observable_flip}")

    measured_observable = logical_single_round @ data_i % 2
    vprint(f"measured_observable: {measured_observable}")

    if measured_observable != expected_value:
        nLogicalErrorsWithoutDecoding += 1

    predicted_observable = predicted_observable_flip ^ measured_observable
    vprint(f"predicted_observable: {predicted_observable}")

    if predicted_observable != expected_value:
        nLogicalErrorsWDecoding += 1

    nCorrections += int(predicted_observable_flip[0])

# === Summary statistics ===
print(
    f"{nLogicalErrorsWithoutDecoding} logical errors without decoding in {nShots} shots\n"
)
print(
    f"{nLogicalErrorsWDecoding} logical errors with decoding in {nShots} shots\n"
)
print(f"{nCorrections} corrections applied in {nShots} shots\n")

This example illustrates how to:

  • Retrieve and configure an error correction code Load a repetition code using qec.get_code(...) from the CUDA-Q QEC library, and define a custom circuit-level noise model using .add_all_qubit_channel(...).

  • Generate a multi-round parity check matrix Extend a single-round detector error model (DEM) across multiple rounds using qec.dem_from_memory_circuit(...). This captures syndrome evolution over time, including measurement noise, and provides:

    • detector_error_matrix – the multi-round parity check matrix

    • observables_flips_matrix – used to identify logical flips due to physical errors

  • Simulate circuit-level noise and collect data Run multiple shots of the memory experiment using qec.sample_memory_circuit(...) to sample both the data and syndrome measurements from noisy executions. The resulting bitstrings can be used for decoding and performance evaluation of the error correction scheme.

DEM Sampling — Monte-Carlo Sampling from Detector Error Models

This example samples synthetic error and syndrome data from a detector error model, then walks through the GPU-accelerated and CPU paths and the supported input types. For the sampling model itself, see DEM Sampling.

Example

import numpy as np
import cudaq_qec as qec

# Define a check matrix for a [3,1] repetition code.
# Rows = checks (stabilizers), columns = error mechanisms.
H = np.array([[1, 1, 0], [0, 1, 1]], dtype=np.uint8)

# Independent error probability for each mechanism.
error_probs = np.array([0.05, 0.10, 0.05])

num_shots = 10

# Sample syndromes and errors from the detector error model.
# backend="auto" (default) tries GPU first, then falls back to CPU.
syndromes, errors = qec.dem_sampling(H, num_shots, error_probs, seed=42)

print(f"Check matrix H ({H.shape[0]} checks x {H.shape[1]} mechanisms):")
print(H)
print(f"\nError probabilities: {error_probs}")
print(f"\nSampled errors  ({errors.shape}):\n{errors}")
print(f"\nSampled syndromes ({syndromes.shape}):\n{syndromes}")

# Verify: syndromes should equal (errors @ H^T) mod 2.
expected = (errors @ H.T) % 2
assert np.array_equal(syndromes, expected), "Mismatch!"
print("\nVerification passed: syndromes == (errors @ H^T) mod 2")

# Reproducibility: the same seed yields the same results.
s1, e1 = qec.dem_sampling(H, num_shots, error_probs, seed=123)
s2, e2 = qec.dem_sampling(H, num_shots, error_probs, seed=123)
assert np.array_equal(e1, e2)
print("Reproducibility check passed: same seed -> same output")

# Force the GPU backend explicitly. It raises RuntimeError when no GPU (or
# cuStabilizer) is available, so guard it for portability; backend="auto"
# instead falls back to CPU automatically.
try:
    syndromes_gpu, errors_gpu = qec.dem_sampling(H,
                                                 num_shots,
                                                 error_probs,
                                                 seed=42,
                                                 backend="gpu")
    print(f"\nGPU backend result shapes: syndromes {syndromes_gpu.shape}, "
          f"errors {errors_gpu.shape}")
except RuntimeError as err:
    print(
        f"\nGPU backend unavailable ({err}); backend='auto' falls back to CPU.")

# Force the CPU backend explicitly.
syndromes_cpu, errors_cpu = qec.dem_sampling(H,
                                             num_shots,
                                             error_probs,
                                             seed=42,
                                             backend="cpu")
print(f"\nCPU backend result shapes: syndromes {syndromes_cpu.shape}, "
      f"errors {errors_cpu.shape}")
// DEM Sampling — sample errors and syndromes from a detector error model.
//
// Compile and run with:
// nvq++ -lcudaq-qec dem_sampling.cpp
// ./a.out

#include <cstdint>
#include <iostream>
#include <vector>

#include "cudaq/qec/dem_sampling.h"

int main() {
  // [3,1] repetition code check matrix:
  //   H = | 1 1 0 |
  //       | 0 1 1 |
  std::vector<uint8_t> H_data = {1, 1, 0, 0, 1, 1};
  size_t num_checks = 2;
  size_t num_mechanisms = 3;

  cudaqx::tensor<uint8_t> H({num_checks, num_mechanisms});
  H.copy(H_data.data(), H.shape());

  std::vector<double> error_probs = {0.05, 0.10, 0.05};
  size_t num_shots = 10;
  unsigned seed = 42;

  // CPU sampling
  auto [syndromes, errors] =
      cudaq::qec::dem_sampler::cpu::sample_dem(H, num_shots, error_probs, seed);

  std::cout << "Syndromes [" << syndromes.shape()[0] << " x "
            << syndromes.shape()[1] << "]:\n";
  for (size_t shot = 0; shot < num_shots; shot++) {
    for (size_t c = 0; c < num_checks; c++)
      std::cout << static_cast<int>(syndromes.at({shot, c})) << " ";
    std::cout << "\n";
  }

  std::cout << "\nErrors [" << errors.shape()[0] << " x " << errors.shape()[1]
            << "]:\n";
  for (size_t shot = 0; shot < num_shots; shot++) {
    for (size_t e = 0; e < num_mechanisms; e++)
      std::cout << static_cast<int>(errors.at({shot, e})) << " ";
    std::cout << "\n";
  }

  // Verify: syndromes == (errors * H^T) mod 2
  bool ok = true;
  for (size_t shot = 0; shot < num_shots && ok; shot++) {
    for (size_t c = 0; c < num_checks && ok; c++) {
      uint8_t expected = 0;
      for (size_t e = 0; e < num_mechanisms; e++)
        expected ^= errors.at({shot, e}) & H.at({c, e});
      if (syndromes.at({shot, c}) != expected)
        ok = false;
    }
  }
  std::cout << "\nVerification: " << (ok ? "PASSED" : "FAILED") << "\n";

  return ok ? 0 : 1;
}

Compile and run with

nvq++ -lcudaq-qec dem_sampling.cpp
./a.out

GPU Acceleration

When a CUDA-capable GPU is available, dem_sampling keeps the sampling and syndrome computation on-device, which is significantly faster than per-shot CPU sampling, especially for large numbers of shots and sparse error models (low probabilities):

  1. Sparse Bernoulli sampling — Errors are generated directly in compressed sparse row (CSR) format. For low error probabilities the CSR representation is compact, and the sampler skips mechanisms with zero probability entirely rather than evaluating a Bernoulli trial for every mechanism in every shot.

  2. GF(2) sparse-dense matrix multiply — Syndromes are computed as \(\text{errors} \times H^T \pmod{2}\) using a sparse-dense multiply over GF(2). The check matrix \(H^T\) is stored in a bitpacked layout, reducing memory bandwidth by 8x compared to one byte per entry.

  3. On-device packing and unpacking\(H\) is transposed and bitpacked on the GPU in a single kernel. Syndromes are unpacked from the bitpacked result, and the dense error matrix is produced from the CSR representation via a fused zero-and-scatter kernel.

The CPU path uses std::bernoulli_distribution per mechanism per shot followed by a dense dot product for the syndrome.

Input Types and Backend Selection

The backend parameter controls where sampling runs:

  • "auto" (default) — try GPU first, fall back to CPU.

  • "gpu" — require GPU; raise RuntimeError if unavailable.

  • "cpu" — always use the CPU path.

The Python binding accepts several input types, each routed through a different code path:

  1. NumPy arrays (most common) — When the GPU is available the bindings automatically allocate device memory, copy inputs host-to-device, run cuStabilizer, and copy results back as NumPy uint8 arrays. With backend="cpu" the GPU path is skipped entirely. No user action is required beyond passing standard uint8 and float64 arrays.

  2. PyTorch CUDA tensors — The GPU path reads input device pointers directly via data_ptr() and writes outputs into torch.empty tensors on the same device, avoiding any host-device copies. This is the fastest path when inputs are already on the GPU. PyTorch is an optional dependency; install with pip install torch.

  3. PyTorch CPU tensors — With backend="gpu" the tensors are automatically moved to CUDA (via .to(device)) before sampling. With backend="auto" CPU tensors are rejected with an error; convert them to NumPy with .numpy() first.

The C++ API exposes two namespaces:

  • cudaq::qec::dem_sampler::cpu::sample_dem — takes a cudaqx::tensor check matrix and a std::vector<double> of probabilities; returns (syndromes, errors) as tensors.

  • cudaq::qec::dem_sampler::gpu::sample_dem — takes raw device pointers and writes results into caller-provided device buffers; returns false if cuStabilizer is not available at runtime.

The gpu overload works with device pointers that you allocate, populate, and free yourself. Guard the call behind a device-count check and fall back to the cpu overload when it returns false:

#include "cudaq/qec/dem_sampling.h"
#include <cuda_runtime.h>

// H: [num_checks x num_mechanisms] uint8, probs: [num_mechanisms] double.
uint8_t *d_H, *d_syndromes, *d_errors;
double *d_probs;
cudaMalloc(&d_H, num_checks * num_mechanisms);
cudaMalloc(&d_probs, num_mechanisms * sizeof(double));
cudaMalloc(&d_syndromes, num_shots * num_checks);
cudaMalloc(&d_errors, num_shots * num_mechanisms);
cudaMemcpy(d_H, h_data, num_checks * num_mechanisms, cudaMemcpyHostToDevice);
cudaMemcpy(d_probs, prob_data, num_mechanisms * sizeof(double),
           cudaMemcpyHostToDevice);

bool ok = cudaq::qec::dem_sampler::gpu::sample_dem(
    d_H, num_checks, num_mechanisms, d_probs, num_shots, /*seed=*/42,
    d_syndromes, d_errors);
if (!ok) {
  // cuStabilizer unavailable at runtime — use the cpu overload instead.
}
// Copy d_syndromes / d_errors back to host, then cudaFree each buffer.

See Also

Getting Started with the NVIDIA QLDPC Decoder

The remaining sections describe the built-in decoders that consume the parity check matrices and detector error models above. Each is selected by name through cudaq_qec.get_decoder() and targets a different regime, trading off speed, accuracy, and the class of codes it supports. We begin with the most general.

Starting with CUDA-Q QEC v0.2, a GPU-accelerated decoder is included with the CUDA-Q QEC library. The library follows the CUDA-Q decoder Python and C++ interfaces (namely cudaq_qec.Decoder for Python and cudaq::qec::decoder for C++), but as documented in the API sections (NVIDIA QLDPC Decoder for Python and NVIDIA QLDPC Decoder for C++), there are many configuration options that can be passed to the constructor.

Belief Propagation Methods

The nv-qldpc-decoder supports several belief-propagation algorithms – sum-product, min-sum, and memory-based variants, plus Sequential Relay BP – selected via bp_method and composition, with optional BP+OSD post-processing. For the complete list of methods, parameters, and defaults, see the nv-qldpc-decoder entries in the C++ and Python API reference.

Usage Example

The following example shows how to exercise the decoder using non-trivial pre-generated test data. The test data was generated using scripts originating from the GitHub repo for BivariateBicycleCodes [1]; it includes parity check matrices (PCMs) and test syndromes to exercise a decoder.

The example demonstrates:

  1. Basic decoder configuration with OSD post-processing

  2. All BP methods including Sequential Relay BP

  3. Batched decoding for improved performance


import numpy as np
from scipy.sparse import csr_matrix
import cudaq_qec as qec
import json
import time

# For fetching data
import requests
import bz2
import os

# Note: running this script will automatically download data if necessary.

### Helper functions ###


def parse_csr_mat(j, dims, mat_name):
    """
    Parse a CSR-style matrix from a JSON file using SciPy's sparse matrix utilities.
    """
    assert len(dims) == 2, "dims must be a tuple of two integers"

    # Extract indptr and indices from the JSON.
    indptr = np.array(j[f"{mat_name}_indptr"], dtype=int)
    indices = np.array(j[f"{mat_name}_indices"], dtype=int)

    # Check that the CSR structure is consistent.
    assert len(indptr) == dims[0] + 1, "indptr length must equal dims[0] + 1"
    assert np.all(
        indices < dims[1]), "All column indices must be less than dims[1]"

    # Create a data array of ones.
    data = np.ones(indptr[-1], dtype=np.uint8)

    # Build and return the sparse matrix directly — no dense allocation.
    return csr_matrix((data, indices, indptr), shape=dims, dtype=np.uint8)


def parse_H_csr(j, dims):
    """
    Parse a CSR-style parity check matrix from an input file in JSON format"
    """
    return parse_csr_mat(j, dims, "H")


def parse_obs_csr(j, dims):
    """
    Parse a CSR-style observable matrix from an input file in JSON format"
    """
    return parse_csr_mat(j, dims, "obs_mat").toarray()


### Main decoder loop ###


def run_decoder(filename, num_shots, run_as_batched):
    """
    Load a JSON file and decode "num_shots" syndromes.
    """
    t_load_begin = time.time()
    with open(filename, "r") as f:
        j = json.load(f)

    dims = j["shape"]
    assert len(dims) == 2

    # Read the Parity Check Matrix
    H = parse_H_csr(j, dims)
    syndrome_length, block_length = dims
    t_load_end = time.time()

    print(f"{filename} parsed in {1e3 * (t_load_end-t_load_begin)} ms")

    error_rate_vec = np.array(j["error_rate_vec"])
    assert len(error_rate_vec) == block_length
    obs_mat_dims = j["obs_mat_shape"]
    obs_mat = parse_obs_csr(j, obs_mat_dims)
    assert dims[1] == obs_mat_dims[0]
    file_num_trials = j["num_trials"]
    num_shots = min(num_shots, file_num_trials)
    print(
        f'Your JSON file has {file_num_trials} shots. Running {num_shots} now.')

    # osd_method: 0=Off, 1=OSD-0, 2=Exhaustive, 3=Combination Sweep
    osd_method = 1

    # When osd_method is:
    #  2) there are 2^osd_order additional error mechanisms checked.
    #  3) there are an additional k + osd_order*(osd_order-1)/2 error
    #     mechanisms checked.
    # Ref: https://arxiv.org/pdf/2005.07016
    osd_order = 0

    # Maximum number of BP iterations before attempting OSD (if necessary)
    max_iter = 50

    nv_dec_args = {
        "max_iterations": max_iter,
        "error_rate_vec": error_rate_vec,
        "use_sparsity": True,
        "use_osd": osd_method > 0,
        "osd_order": osd_order,
        "osd_method": osd_method
    }

    if run_as_batched:
        # Perform BP processing for up to 1000 syndromes per batch. If there
        # are more than 1000 syndromes, the decoder will chunk them up and
        # process each batch sequentially under the hood.
        nv_dec_args['bp_batch_size'] = min(1000, num_shots)

    try:
        nv_dec_gpu_and_cpu = qec.get_decoder("nv-qldpc-decoder", H,
                                             **nv_dec_args)
    except Exception as e:
        print(
            'The nv-qldpc-decoder is not available with your current CUDA-Q ' +
            'QEC installation.')
        exit(0)
    decoding_time = 0
    bp_converged_flags = []
    num_logical_errors = 0

    # Batched API
    if run_as_batched:
        syndrome_list = []
        obs_truth_list = []
        for i in range(num_shots):
            syndrome = j["trials"][i]["syndrome_truth"]
            obs_truth = j["trials"][i]["obs_truth"]
            syndrome_list.append(syndrome)
            obs_truth_list.append(obs_truth)
        t0 = time.time()
        results = nv_dec_gpu_and_cpu.decode_batch(syndrome_list)
        t1 = time.time()
        decoding_time += t1 - t0
        for r, obs_truth in zip(results, obs_truth_list):
            bp_converged_flags.append(r.converged)
            dec_result = np.array(r.result, dtype=np.uint8)

            # See if this prediction flipped the observable
            predicted_observable = obs_mat.T @ dec_result % 2
            print(f"predicted_observable: {predicted_observable}")

            # See if the observable was actually flipped according to the truth
            # data
            actual_observable = np.array(obs_truth, dtype=np.uint8)
            print(f"actual_observable:    {actual_observable}")

            if np.sum(predicted_observable != actual_observable) > 0:
                num_logical_errors += 1

    # Non-batched API
    else:
        for i in range(num_shots):
            syndrome = j["trials"][i]["syndrome_truth"]
            obs_truth = j["trials"][i]["obs_truth"]

            t0 = time.time()
            results = nv_dec_gpu_and_cpu.decode(syndrome)
            bp_converged = results.converged
            dec_result = results.result
            t1 = time.time()
            trial_diff = t1 - t0
            decoding_time += trial_diff

            dec_result = np.array(dec_result, dtype=np.uint8)
            bp_converged_flags.append(bp_converged)

            # See if this prediction flipped the observable
            predicted_observable = obs_mat.T @ dec_result % 2
            print(f"predicted_observable: {predicted_observable}")

            # See if the observable was actually flipped according to the truth
            # data
            actual_observable = np.array(obs_truth, dtype=np.uint8)
            print(f"actual_observable:    {actual_observable}")

            if np.sum(predicted_observable != actual_observable) > 0:
                num_logical_errors += 1

    # Count how many shots the decoder failed to correct the errors
    print(f"{num_logical_errors} logical errors in {num_shots} shots")
    print(
        f"Number of shots that converged with BP processing: {np.sum(np.array(bp_converged_flags))}"
    )
    print(
        f"Average decoding time for {num_shots} shots was {1e3 * decoding_time / num_shots} ms per shot"
    )


def demonstrate_bp_methods():
    """
    Demonstrate different BP methods available in nv-qldpc-decoder.
    Shows configurations for: sum-product, min-sum, memory BP, 
    disordered memory BP, and sequential relay BP.
    """
    # Simple 3x7 parity check matrix for demonstration
    H_list = [[1, 0, 0, 1, 0, 1, 1], [0, 1, 0, 1, 1, 0, 1],
              [0, 0, 1, 0, 1, 1, 1]]
    H = np.array(H_list, dtype=np.uint8)

    print("=" * 60)
    print("Demonstrating BP Methods in nv-qldpc-decoder")
    print("=" * 60)

    # Method 0: Sum-Product BP (default)
    print("\n1. Sum-Product BP (bp_method=0, default):")
    try:
        decoder_sp = qec.get_decoder("nv-qldpc-decoder",
                                     H,
                                     bp_method=0,
                                     max_iterations=30)
    except Exception as e:
        print(
            'The nv-qldpc-decoder is not available with your current CUDA-Q ' +
            'QEC installation.')
        exit(0)
    print("   Created decoder with sum-product BP")

    # Method 1: Min-Sum BP
    print("\n2. Min-Sum BP (bp_method=1):")
    decoder_ms = qec.get_decoder("nv-qldpc-decoder",
                                 H,
                                 bp_method=1,
                                 max_iterations=30,
                                 scale_factor=1.0)
    print("   Created decoder with min-sum BP")

    # Method 2: Min-Sum with uniform Memory (Mem-BP)
    print("\n3. Mem-BP (bp_method=2, uniform memory strength):")
    decoder_mem = qec.get_decoder("nv-qldpc-decoder",
                                  H,
                                  bp_method=2,
                                  max_iterations=30,
                                  use_sparsity=True,
                                  gamma0=0.5)
    print("   Created decoder with Mem-BP (gamma0=0.5)")

    # Method 3: Min-Sum with Disordered Memory (DMem-BP)
    print("\n4. DMem-BP (bp_method=3, disordered memory strength):")
    # Option A: Using gamma_dist (random gammas in range)
    decoder_dmem = qec.get_decoder("nv-qldpc-decoder",
                                   H,
                                   bp_method=3,
                                   max_iterations=30,
                                   use_sparsity=True,
                                   gamma_dist=[0.1, 0.5],
                                   bp_seed=42)
    print("   Created decoder with DMem-BP (gamma_dist=[0.1, 0.5])")

    # Option B: Using explicit_gammas (specify exact gamma for each variable)
    block_size = H.shape[1]
    explicit_gammas = [[0.1 + 0.05 * i for i in range(block_size)]]
    decoder_dmem_explicit = qec.get_decoder("nv-qldpc-decoder",
                                            H,
                                            bp_method=3,
                                            max_iterations=30,
                                            use_sparsity=True,
                                            explicit_gammas=explicit_gammas)
    print("   Created decoder with DMem-BP (explicit gammas)")

    # Method 4: Sequential Relay BP (composition=1)
    print("\n5. Sequential Relay BP (composition=1):")
    print("   Requires bp_method=3 and srelay_config")

    # Configure relay parameters
    srelay_config = {
        'pre_iter': 5,  # Run 5 iterations with gamma0 before relay legs
        'num_sets': 3,  # Use 3 relay legs
        'stopping_criterion': 'FirstConv'  # Stop after first convergence
    }

    # Option A: Using gamma_dist for relay legs
    decoder_relay = qec.get_decoder("nv-qldpc-decoder",
                                    H,
                                    bp_method=3,
                                    composition=1,
                                    max_iterations=50,
                                    use_sparsity=True,
                                    gamma0=0.3,
                                    gamma_dist=[0.1, 0.5],
                                    srelay_config=srelay_config,
                                    bp_seed=42)
    print("   Created decoder with Relay-BP (gamma_dist, FirstConv stopping)")

    # Option B: Using explicit gammas for each relay leg
    num_relay_legs = 3
    explicit_relay_gammas = [
        [0.1 + 0.02 * i for i in range(block_size)],  # First relay leg
        [0.2 + 0.03 * i for i in range(block_size)],  # Second relay leg
        [0.3 + 0.04 * i for i in range(block_size)]  # Third relay leg
    ]

    srelay_config_all = {
        'pre_iter': 10,
        'num_sets': 3,
        'stopping_criterion': 'All'  # Run all relay legs
    }

    decoder_relay_explicit = qec.get_decoder(
        "nv-qldpc-decoder",
        H,
        bp_method=3,
        composition=1,
        max_iterations=50,
        use_sparsity=True,
        gamma0=0.3,
        explicit_gammas=explicit_relay_gammas,
        srelay_config=srelay_config_all)
    print("   Created decoder with Relay-BP (explicit gammas, All legs)")

    # Option C: NConv stopping criterion
    srelay_config_nconv = {
        'pre_iter': 5,
        'num_sets': 10,
        'stopping_criterion': 'NConv',
        'stop_nconv': 3  # Stop after 3 convergences
    }

    decoder_relay_nconv = qec.get_decoder("nv-qldpc-decoder",
                                          H,
                                          bp_method=3,
                                          composition=1,
                                          max_iterations=50,
                                          use_sparsity=True,
                                          gamma0=0.3,
                                          gamma_dist=[0.1, 0.6],
                                          srelay_config=srelay_config_nconv,
                                          bp_seed=42)
    print("   Created decoder with Relay-BP (NConv stopping after 3)")

    print("\n" + "=" * 60)
    print("All decoder configurations created successfully!")
    print("=" * 60)


if __name__ == "__main__":
    # Demonstrate different BP methods (introduced in v0.5.0)
    print("\n### PART 1: BP Methods Demonstration ###\n")
    demonstrate_bp_methods()

    # Full decoding with test data
    print("\n\n### PART 2: Full Decoding Example with Test Data ###\n")

    # See other test data options in https://github.com/NVIDIA/cudaqx/releases/tag/0.2.0
    filename = 'osd_1008_8785_0.001.json'
    bz2filename = filename + '.bz2'
    if not os.path.exists(filename):
        url = f"https://github.com/NVIDIA/cudaqx/releases/download/0.2.0/{bz2filename}"

        print(f'Downloading data from {url}')

        # Download the file
        response = requests.get(url, stream=True)
        response.raise_for_status()  # Raise an error if download fails
        with open(bz2filename, "wb") as f:
            for chunk in response.iter_content(chunk_size=8192):
                f.write(chunk)

        print(f'Decompressing {bz2filename} into {filename}')

        # Decompress the file
        with bz2.BZ2File(bz2filename, "rb") as f_in, open(filename,
                                                          "wb") as f_out:
            f_out.write(f_in.read())

        print(f"Decompressed file saved as {filename}")

    num_shots = 100
    run_as_batched = True
    run_decoder(filename, num_shots, run_as_batched)

Footnotes

Exact Maximum Likelihood Decoding with NVIDIA Tensor Network Decoder

Where belief propagation trades exactness for speed, the tensor network decoder computes the exact maximum-likelihood correction — valuable as an accuracy baseline against which the faster decoders can be measured.

Starting with CUDA-Q QEC v0.4.0, a GPU-accelerated Maximum Likelihood Decoder is included with the CUDA-Q QEC library. The library follows the CUDA-Q decoder Python interface, namely cudaq_qec.Decoder. At this time, we only support the Python interface for the decoder, which is available at TensorNetworkDecoder. As documented in the API sections Tensor Network Decoder, there are many configuration options that can be passed to the constructor. The decoder requires Python 3.11 or higher.

In the following example, we show how to use the TensorNetworkDecoder class from the cudaq_qec library to decode a circuit-level noise problem derived from a Stim surface code circuit.

"""
Example usage of tensor_network_decoder from cudaq-qec.

This script demonstrates how to instantiate and use the tensor network decoder
to decode a circuit level noise problem derived from a Stim surface code experiment.

This example requires the `cudaq-qec` package and the optional tensor-network-decoder dependencies.
To install the required dependencies, run:

pip install cudaq-qec[tensor-network-decoder]

Additionaly, in this example, you will need `stim` and `beliefmatching` packages:
pip install stim beliefmatching

"""
import cudaq_qec as qec
import numpy as np

import platform
if platform.machine().lower() in ("arm64", "aarch64"):
    print(
        "Warning: stim is not supported on manylinux ARM64/aarch64. Skipping this example..."
    )
    sys.exit(0)

import stim

from beliefmatching.belief_matching import detector_error_model_to_check_matrices


def parse_detector_error_model(detector_error_model):
    matrices = detector_error_model_to_check_matrices(detector_error_model)

    out_H = np.zeros(matrices.check_matrix.shape)
    matrices.check_matrix.astype(np.float64).toarray(out=out_H)
    out_L = np.zeros(matrices.observables_matrix.shape)
    matrices.observables_matrix.astype(np.float64).toarray(out=out_L)

    return out_H, out_L, [float(p) for p in matrices.priors]


def main():
    circuit = stim.Circuit.generated("surface_code:rotated_memory_z",
                                     rounds=3,
                                     distance=3,
                                     after_clifford_depolarization=0.001,
                                     after_reset_flip_probability=0.01,
                                     before_measure_flip_probability=0.01,
                                     before_round_data_depolarization=0.01)

    detector_error_model = circuit.detector_error_model(decompose_errors=True)

    H, logicals, noise_model = parse_detector_error_model(detector_error_model)

    decoder = qec.get_decoder(
        "tensor_network_decoder",
        H,
        logical_obs=logicals,
        noise_model=noise_model,
        contract_noise_model=True,
    )

    num_shots = 5
    sampler = circuit.compile_detector_sampler()
    detection_events, observable_flips = sampler.sample(
        num_shots, separate_observables=True)

    res = decoder.decode_batch(detection_events)

    print("Tensor network prediction: ", [r.result[0] > 0.5 for r in res])
    print("Actual observable flips: ", [bool(o[0]) for o in observable_flips])


if __name__ == "__main__":
    main()

Output:

The decoder returns the probability that the logical observable has flipped for each syndrome. This can be used to assess the performance of the code and the decoder under different error scenarios.

See Also:

  • cudaq_qec.plugins.decoders.tensor_network_decoder

Deploying AI Decoders with TensorRT

The decoders above are algorithmic. CUDA-Q QEC can also deploy a learned decoder — a neural network trained on a specific code and noise model.

Starting with CUDA-Q QEC v0.5.0, a GPU-accelerated TensorRT-based decoder is included with the CUDA-Q QEC library. The TensorRT decoder (trt_decoder) enables users to leverage custom AI models for quantum error correction, providing a flexible framework for deploying trained models with optimized inference performance on NVIDIA GPUs.

Unlike traditional algorithmic decoders, neural network decoders can be trained on specific error models and code structures, potentially achieving superior performance for certain noise regimes. The TensorRT decoder supports loading models in ONNX format and provides configurable precision modes (fp16, bf16, int8, fp8, tf32) to balance accuracy and inference speed.

This tutorial demonstrates the complete workflow for training a simple multi-layer perceptron (MLP) to decode surface code syndromes using PyTorch and Stim, exporting the model to ONNX format, and deploying it with the TensorRT decoder for accelerated inference.

Overview of the Training-to-Deployment Pipeline

The workflow consists of three main stages:

  1. Data Generation: Use Stim to generate synthetic quantum error correction data by simulating surface code circuits with realistic noise models. This produces detector measurements (syndromes) and observable flips (logical errors) that serve as training data.

  2. Model Training: Train a neural network (in this case, an MLP) using PyTorch to learn the mapping from syndromes to logical error predictions. The model is trained with standard deep learning techniques including dropout regularization, learning rate scheduling, and validation monitoring.

  3. ONNX Export and Deployment: Export the trained PyTorch model to ONNX format, which can then be loaded by the TensorRT decoder for optimized GPU inference in production QEC workflows.

Training a Neural Network Decoder with PyTorch and Stim

The following example shows how to generate training data using Stim’s built-in surface code generator, train an MLP decoder with PyTorch, and export the model to ONNX format. For instructions on installing PyTorch, see Installing PyTorch.


import sys
import platform
if platform.machine().lower() in ("arm64", "aarch64"):
    print(
        "Warning: stim is not supported on manylinux ARM64/aarch64. Skipping this example..."
    )
    sys.exit(0)

import stim
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import TensorDataset, DataLoader

# --------------------------
# Parameters
# --------------------------
distance = 3  # Surface code distance (simpler for demo)
num_rounds = 3  # Rounds of stabilizer measurements
num_train_samples = 5000  # Training samples (more data)
num_val_samples = 1000  # Validation samples
num_test_samples = 1000  # Test samples
hidden_dim = 128  # Larger model capacity
error_prob = 0.005  # Balanced error rate for better learning

# --------------------------
# Build the surface code circuit
# --------------------------
# Use the built-in Stim surface code generator with noise
circuit = stim.Circuit.generated("surface_code:rotated_memory_x",
                                 distance=distance,
                                 rounds=num_rounds,
                                 after_clifford_depolarization=error_prob,
                                 after_reset_flip_probability=error_prob,
                                 before_measure_flip_probability=error_prob,
                                 before_round_data_depolarization=error_prob)

# Convert to detector error model
dem = circuit.detector_error_model()
num_detectors = dem.num_detectors
num_data_qubits = circuit.num_qubits - num_detectors

print(f"Num data qubits: {num_data_qubits}, Num detectors: {num_detectors}")

# --------------------------
# Sample training, validation, and test data
# --------------------------
sampler = circuit.compile_detector_sampler()


def sample_data(num_samples):
    """Sample detector outcomes and observable flips."""
    X_data = []
    Y_data = []

    detector_samples, observable_samples = sampler.sample(
        num_samples, separate_observables=True)

    for i in range(num_samples):
        detectors = torch.tensor(detector_samples[i], dtype=torch.float32)
        observable = torch.tensor(observable_samples[i], dtype=torch.float32)
        X_data.append(detectors)
        Y_data.append(observable)

    return torch.stack(X_data), torch.stack(Y_data)


print(f"Sampling {num_train_samples} training samples...")
X_train, Y_train = sample_data(num_train_samples)

print(f"Sampling {num_val_samples} validation samples...")
X_val, Y_val = sample_data(num_val_samples)

print(f"Sampling {num_test_samples} test samples...")
X_test, Y_test = sample_data(num_test_samples)

num_observables = Y_train.shape[1]
print(f"Num observables: {num_observables}")


# --------------------------
# Improved Torch NN decoder with dropout and deeper architecture
# --------------------------
class SurfaceCodeDecoder(nn.Module):

    def __init__(self, input_dim, output_dim, hidden_dim=128, dropout=0.3):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, hidden_dim * 2),  # 256
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(hidden_dim * 2, hidden_dim),  # 128
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(hidden_dim, hidden_dim // 2),  # 64
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(hidden_dim // 2, output_dim),
            nn.Sigmoid())

    def forward(self, x):
        return self.net(x)


model = SurfaceCodeDecoder(input_dim=num_detectors,
                           output_dim=num_observables,
                           hidden_dim=hidden_dim,
                           dropout=0.3)
optimizer = optim.Adam(model.parameters(), lr=5e-4)  # Lower learning rate
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer,
                                                 mode='min',
                                                 factor=0.5,
                                                 patience=20)
criterion = nn.BCELoss()

# Create DataLoaders for batch training
train_dataset = TensorDataset(X_train, Y_train)
val_dataset = TensorDataset(X_val, Y_val)
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=128, shuffle=False)


def compute_accuracy(predictions, targets, threshold=0.5):
    """Compute binary accuracy."""
    pred_binary = (predictions > threshold).float()
    correct = (pred_binary == targets).float().mean()
    return correct.item()


# --------------------------
# Train NN with validation
# --------------------------
epochs = 1000  # Train longer for better convergence
best_val_acc = 0.0
print("\nTraining started...")
print("=" * 70)

for epoch in range(epochs):
    # Training step with batches
    model.train()
    train_loss_total = 0.0
    train_correct = 0
    train_total = 0

    for batch_X, batch_Y in train_loader:
        optimizer.zero_grad()
        train_output = model(batch_X)
        train_loss = criterion(train_output, batch_Y)
        train_loss.backward()
        optimizer.step()

        train_loss_total += train_loss.item() * batch_X.size(0)
        train_correct += ((train_output > 0.5).float() == batch_Y).sum().item()
        train_total += batch_Y.numel()

    train_loss_avg = train_loss_total / len(train_loader.dataset)
    train_acc = train_correct / train_total

    # Validation step with batches
    model.eval()
    val_loss_total = 0.0
    val_correct = 0
    val_total = 0

    cum_ler = 0.0

    with torch.no_grad():

        for batch_X, batch_Y in val_loader:
            val_output = model(batch_X)
            val_loss = criterion(val_output, batch_Y)

            val_output_binary = (val_output > 0.5)
            ler = val_output_binary ^ (batch_Y > 0.5)
            # print(f"loss: {loss.sum().item() / loss.numel()}")
            cum_ler += ler.sum().item()
            # print(f"val_output_binary: {val_output_binary} ler: {ler} batch_Y: {batch_Y} ")

            # print(f"logical_error_rate (pred): {val_output.sum().item() / val_output.numel()}")
            # print(f"logical_error_rate (raw): {batch_Y.sum().item() / batch_Y.numel()}")

            val_loss_total += val_loss.item() * batch_X.size(0)
            val_correct += ((val_output > 0.5).float() == batch_Y).sum().item()
            val_total += batch_Y.numel()

    # print(f"logical_error_rate (raw): {batch_Y.sum().item() / batch_Y.numel()}")
    # print(f"cum_ler: {cum_ler / len(val_loader.dataset)}")

    val_loss_avg = val_loss_total / len(val_loader.dataset)
    val_acc = val_correct / val_total

    # Learning rate scheduling
    scheduler.step(val_loss_avg)

    # Save best model
    if val_acc > best_val_acc:
        best_val_acc = val_acc
        torch.save(model.state_dict(), "surface_code_decoder_best.pth")

    # Print progress every 10 epochs
    if (epoch + 1) % 10 == 0:
        print(
            f"Epoch {epoch+1:3d}/{epochs} | "
            f"Train Loss: {train_loss_avg:.4f} | Train Acc: {train_acc:.4f} | "
            f"Val Loss: {val_loss_avg:.4f} | Val Acc: {val_acc:.4f}")

print("=" * 70)
print(f"Training complete! Best validation accuracy: {best_val_acc:.4f}")

# --------------------------
# Load best model and evaluate on test set
# --------------------------
print("\nLoading best model and evaluating on test set...")
model.load_state_dict(torch.load("surface_code_decoder_best.pth"))
model.eval()

with torch.no_grad():
    test_output = model(X_test)
    test_loss = criterion(test_output, Y_test)
    test_acc = compute_accuracy(test_output, Y_test)

    # Additional metrics
    test_pred_binary = (test_output > 0.5).float()

    # Count logical errors
    total_actual_errors = Y_test.sum().item()
    total_predicted_errors = test_pred_binary.sum().item()
    correct_predictions = (test_pred_binary == Y_test).sum().item()
    total_predictions = Y_test.numel()

print("=" * 70)
print("TEST SET RESULTS:")
print("=" * 70)
print(f"Test Loss:                    {test_loss.item():.4f}")
print(f"Test Accuracy:                {test_acc:.4f} ({test_acc*100:.2f}%)")
print(
    f"Correct predictions:          {correct_predictions}/{total_predictions}")
print(f"Actual logical errors:        {int(total_actual_errors)}")
print(f"Predicted logical errors:     {int(total_predicted_errors)}")
print("=" * 70)

# --------------------------
# Export to ONNX
# --------------------------
print("\nExporting model to ONNX...")
torch.onnx.export(model,
                  X_train[:1],
                  "surface_code_decoder.onnx",
                  input_names=["detectors"],
                  output_names=["data_qubit_probs"],
                  opset_version=17)
print("ONNX model saved as surface_code_decoder.onnx")
print("PyTorch weights saved as surface_code_decoder_best.pth")

Using the TensorRT Decoder in CUDA-Q QEC

Once you have a trained ONNX model, you can load it with the TensorRT decoder for accelerated inference. The decoder can be used in both C++ and Python workflows.

Loading from ONNX (with automatic TensorRT optimization):

import cudaq_qec as qec
import numpy as np

# Note: The AI decoder doesn't use the parity check matrix.
# A placeholder matrix is provided here to satisfy the API.
H = np.array([[1, 0, 0, 1, 0, 1, 1],
              [0, 1, 0, 1, 1, 0, 1],
              [0, 0, 1, 0, 1, 1, 1]], dtype=np.uint8)

# Create TensorRT decoder from ONNX model
decoder = qec.get_decoder("trt_decoder", H,
                          onnx_load_path="ai_decoder.onnx")

# Decode a syndrome
syndrome = np.array([1.0, 0.0, 1.0], dtype=np.float32)
result = decoder.decode(syndrome)
print(f"Predicted error: {result}")
#include "cudaq/qec/decoder.h"
#include "cuda-qx/core/tensor.h"
#include "cuda-qx/core/heterogeneous_map.h"

int main() {
    // Note: The AI decoder doesn't use the parity check matrix.
    // A placeholder matrix is provided here to satisfy the API.
    std::vector<std::vector<uint8_t>> H_vec = {
        {1, 0, 0, 1, 0, 1, 1},
        {0, 1, 0, 1, 1, 0, 1},
        {0, 0, 1, 0, 1, 1, 1}
    };

    // Convert to tensor
    cudaqx::tensor<uint8_t> H({3, 7});
    for (size_t i = 0; i < 3; ++i) {
        for (size_t j = 0; j < 7; ++j) {
            H.at({i, j}) = H_vec[i][j];
        }
    }

    // Create decoder parameters
    cudaqx::heterogeneous_map params;
    params.insert("onnx_load_path", "ai_decoder.onnx");
    params.insert("precision", "fp16");

    // Create TensorRT decoder
    auto decoder = cudaq::qec::get_decoder("trt_decoder", H, params);

    // Decode syndrome
    std::vector<cudaq::qec::float_t> syndrome = {1.0, 0.0, 1.0};
    auto result = decoder->decode(syndrome);

    return 0;
}

Loading a pre-built TensorRT engine (for fastest initialization):

If you’ve already converted your ONNX model to a TensorRT engine using the provided utility script, you can load it directly:

decoder = qec.get_decoder("trt_decoder", H,
                          engine_load_path="surface_code_decoder.trt")

Converting ONNX Models to TensorRT Engines

For production deployments where initialization time is critical, you can pre-build a TensorRT engine from your ONNX model using the trtexec command-line tool that comes with TensorRT:

# Build with FP16 precision
trtexec --onnx=surface_code_decoder.onnx \
        --saveEngine=surface_code_decoder.trt \
        --fp16

# Build with best precision for your GPU
trtexec --onnx=surface_code_decoder.onnx \
        --saveEngine=surface_code_decoder.trt \
        --best

# Build with specific input shape (optional, for optimization)
trtexec --onnx=surface_code_decoder.onnx \
        --saveEngine=surface_code_decoder.trt \
        --fp16 \
        --shapes=detectors:1x24

Pre-built engines offer several advantages:

  • Faster initialization: Engine loading is significantly faster than ONNX parsing and optimization

  • Reproducible optimization: The same optimization decisions are made every time

  • Version control: Engines can be versioned alongside code for reproducible deployments

Dependencies and Requirements

The TensorRT decoder requires:

  • TensorRT: Version 10.13.3.9 or higher

  • CUDA: Version 12.0 or higher for x86 and 13.0 for ARM.

  • GPU: NVIDIA GPU with compute capability 6.0+ (Pascal architecture or newer)

For training:

  • PyTorch: Version 2.0+ recommended

  • Stim: For quantum circuit simulation and data generation

See Also

Matching-Based Decoding with PyMatching

For codes whose errors pair up into a matching graph, a dedicated matching decoder is often the simplest and fastest choice. Starting with CUDA-Q QEC v0.7.0, CUDA-Q QEC bundles a minimum-weight perfect matching (MWPM) decoder built on the open-source PyMatching library, suitable for matchable codes such as the surface code. It is selected by name through cudaq_qec.get_decoder() and takes a parity-check matrix whose columns each have one or two set entries:

import cudaq_qec as qec
import numpy as np

H = np.array([[1, 1, 0],
              [0, 1, 1]], dtype=np.uint8)

dec = qec.get_decoder("pymatching", H,
                      error_rate_vec=[0.1, 0.1, 0.1],
                      merge_strategy="smallest_weight")
result = dec.decode(syndrome)

Per-error priors are supplied via error_rate_vec (values in (0, 0.5]), and parallel edges are combined according to merge_strategy. See the PyMatching Decoder API for the full list of options.

Color-Code Decoding with Chromobius

Matching applies to surface-code-like codes; color codes call for a decoder built around their structure. Starting with CUDA-Q QEC v0.7.0, CUDA-Q QEC bundles a color-code decoder built on the open-source Chromobius Möbius decoder. Unlike the matrix-based decoders, Chromobius is detector-error-model native: it is constructed from Stim detector-error-model (DEM) text rather than a parity-check matrix, and predicts logical observable flips directly.

import cudaq_qec as qec

with open("color_code.dem") as f:
    dem_text = f.read()

dec = qec.get_decoder("chromobius", dem_text)
corrections = dec.decode(syndrome)  # predicted observable flips

Constructing Chromobius from a parity-check matrix is rejected with an error. See the Chromobius Decoder API for the available options.