PennyLane
Install
Install

Related materials

  • Related contentQuantum low-density parity-check (qLDPC) codes for quantum error correction
  • Related contentDecoding Quantum Errors on the Steane code with Belief Propagation & Catalyst
  • Related contentAdjoint Differentiation – Supplementary Material

Contents

  1. Getting started
  2. Backlines, coprocessors, and controllers
  3. Local CPU-to-CPU with a pre-compiled kernel
  4. Remote CPU-to-GPU with a Python defined kernel
  5. Remote GPU-to-FPGA with a pre-compiled kernel
  6. Bring your own Triton decoder
  7. Conclusion
  8. About the authors

Downloads

  • Download Python script
  • Download Notebook
  • View on GitHub
  1. Demos/
  2. Quantum Hardware/
  3. How to use PennyLane and Backline for low-latency quantum error correction on GPUs and FPGAs

How to use PennyLane and Backline for low-latency quantum error correction on GPUs and FPGAs

Josh Izaac

Josh Izaac

Mehrdad Malek

Mehrdad Malek

Joseph Lee

Joseph Lee

Kalman Szenes

Kalman Szenes

Published: September 09, 2026. Last updated: September 10, 2026.

The challenge today? Quantum is never purely quantum. Large-scale systems built for quantum computing, sensing, and networking rely heavily on robust classical processing, from processing of applications to low-level hardware control. Not only that, but tight communication and feedback between quantum processors and classical accelerators are essential to identify and correct errors — a process known as quantum error correction (QEC) — before fragile physical information degrades.

In this demo, we will take a quantum error correction application and push it through increasing levels of complexity and optimization, entirely from Python — scaling from local prototyping with CPUs, to low-latency remote hardware execution with FPGAs, GPUs, and Triton.

../../_images/architecture.png

Getting started

Backline is an open platform for compilation and low-latency execution — built within PennyLane and Catalyst — to dynamically connect quantum workloads to the right classical engine (such as CPUs, GPUs, FPGAs, and ASICs). It allows you to write high-level algorithmic logic in Python, and easily jump through abstraction layers to write custom, highly optimized low-level hardware kernels.

To showcase this, we’ll take an example of a workflow that requires low-latency execution — quantum error correction — and begin prototyping immediately with CPUs, before progressively including consumer-grade and enterprise GPUs and FPGAs — all from the same software environment.

To start with, we need to install the latest versions of PennyLane and Catalyst. We will need to install these from source; we can follow the build instructions available in the Catalyst documentation. To execute all of the demos demo, including GPUs and FPGAs examples, you will need to make sure you have the required hardware and software, including:

  • A server with an AMD Instinct™ GPU, ROCm 6 or newer, and an RDMA NIC;

  • An AMD Versal™ Premium Series VPK120 FPGA board.

If you would like to simply prototype using local CPUs (the first example in this demo), the above hardware requirements are not necessary.

Now that you have Backline installed, let’s get started!

Backlines, coprocessors, and controllers

There are three main components to Backline:

  • Controllers: This is the classical hardware (such as a CPU or FPGA) that controls the QPU (a quantum hardware or simulator device()), receives quantum measurement results, and initiates data transfers with other hardware devices (coprocessors). For example, it might perform QEC syndrome measurements on the QPU, and send these to a coprocessor for decoding.

  • Coprocessors: These are hardware devices (such as CPUs or GPUs) that receive information from a controller for processing. They run specific coprocessing functions, potentially as a persistent kernel.

  • Backline: A representation of the complete hardware infrastructure supporting the quantum-classical program. The backline includes a controller, one or more coprocessors, and a transport method. A backline object is given directly to a QNode in place of a traditional QNode device().

In the examples below, we’ll see how to create our controller, coprocessors, and backline, and how to use these to execute PennyLane programs.

Local CPU-to-CPU with a pre-compiled kernel

First, we’ll start with a local CPU-CPU interaction — a great place to begin prototyping, as it comes with significantly fewer specialized hardware barriers. The best thing is, this workflow easily extends to remote workflows on specialized hardware (such as GPUs and FPGAs) with minimal changes.

For the first example, we will encode a logical circuit using the Steane code (\([[7, 1, 3]]\)), a specific type of Calderbank-Shor-Steane (CSS) code based on the classical \([7, 4, 3]\) Hamming code. The Steane code encodes one logical qubit using 7 physical qubits, and has the ability to correct arbitrary single qubit errors.

Conveniently, we can leverage PennyLane to simply define a logical circuit, and point Catalyst and Backline to existing optimization passes (for quantum error correction encoding) and pre-compiled libraries (for decoding), to allow the error correction to occur automatically within the stack.

We’ll start by registering the Steane decoder. We will point to a pre-compiled library that comes with Catalyst, but you can point this to any pre-compiled decoding library you wish to use.

import os
from pathlib import Path

import pennylane as qp

STEANE_LIB_CPU = str(
    Path(os.environ.get("CATALYST_ROOT", "~/catalyst")).expanduser()
        / "runtime/build/lib"
        / "libsteane_coprocessor_cpu.so"
)

steane_decode = qp.CoprocessorFunction("steane_coprocessor", STEANE_LIB_CPU)

Note the use of the CoprocessorFunction. This allows us to register a coprocessing function that will be run on a coprocessor.

Next, we can create our two CPUs: the controller, and the coprocessor. The controller will run quantum instructions on the PennyLane Lightning simulator, while the coprocessor will be performing the decoding.

qdev = qp.device("lightning.qubit", wires=3)
CPU1 = qp.Controller(device=qdev)
CPU2 = qp.Coprocessor(coprocessor_fn=steane_decode)

And that’s it — we can create our backline (using memcpy as our transport mechanism, although RDMA is another option), register it to our QNode, and run our workflow. Let’s create a logical GHZ state:

dev = qp.Backline(controller=CPU1, coprocessors=[CPU2], transport="memcpy", qec_code="steane")

@qp.qjit(capture=True)
@qp.set_shots(10)
@qp.qnode(dev, mcm_method="one-shot")
def ghz():
    qp.Hadamard(0)
    qp.CNOT([0, 1])
    qp.CNOT([1, 2])
    return qp.sample([qp.measure(0), qp.measure(1), qp.measure(2)])

print("samples:", ghz())
samples: [[0 0 0]
 [1 1 1]
 [1 1 1]
 [0 0 0]
 [1 1 1]
 [1 1 1]
 [1 1 1]
 [0 0 0]
 [0 0 0]
 [0 0 0]]

Note that, as we define qec_code="steane", QEC encoding and decoding will be automatically applied; the former during MLIR optimization, and the latter on our coprocessor.

Remote CPU-to-GPU with a Python defined kernel

Next, we’ll consider a remote CPU-to-GPU interaction using a Python-defined QEC decoding kernel, written using Triton. In this example, the controller and coprocessor are no longer local, but on a remote server. Furthermore, we’ll show how you can program the GPU coprocessor directly from Python using the Triton library for writing optimized GPU kernels.

To further increase the complexity, this time we will upgrade our QEC encoding to use a qLDPC code, which leverages non-local connectivity between distant qubits to drastically reduce qubit overheads. In particular, we will use the \([[13, 1, 3]]\) Hypergraph Product code, a well-known family of qLDPC codes that uses 13 physical wires for encoding, and a single auxiliary wire to extract syndromes. To decode the qLDPC code, we will use a belief propagation decoder — an iterative message-passing algorithm used to decode errors by working on the Tanner graph of the code.

To start with, we define our belief propagation decoder. We do so using two parity check matrices:

import numpy as np
import pennylane as qp

Hx = np.array([[1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0],
               [0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0],
               [0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0],
               [0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0],
               [0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 1],
               [0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1]])

Hz = np.array([[1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
               [0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
               [0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0],
               [0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1],
               [0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 0],
               [0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1]])

Next, we make use of the css_bp_decoder function to easily compile a CSS code’s Tanner graph into a GPU-compatible belief propagation decoder function.

bp_decoder = qp.backline.css_bp_decoder(Hx, Hz, postprocess="osd", num_iters=10, platform="hip:gfx90a:64")

Note

Behind the scenes, this function utilizes @triton.jit to compile an optimized GPU kernel — feel free to look under-the-hood at the source code to see how Triton is being used. Later in this demo, we will also show you how to compile your own Triton function for Backline coprocessing.

With the decoder defined, we can now create our controller and coprocessor. To start, we’ll define our server configuration, representing our remote server carrying an AMD GPU and an RDMA capable NIC (the server configuration will need to be updated as per your specific server details):

SERVER = {
    'host':"192.168.3.14",  # The address of the remote server
    'user': 'username',  # The SSH account of the user on the remote machine
    'triple': "x86_64-unknown-linux-gnu",  # The LLVM target triple for compilation
    'sudo': True,
    'deploy': [Path(os.environ["BACKLINE_BUNDLES"]) / "threadripper-bundle"],
    'executor_bin': "numactl -N 0 -m 0 ./catalyst-executor",
    'env': {"LD_LIBRARY_PATH": "."}
}

Here, deploy and executor_bin specify the target workspace directory (including artifacts) and the starting command, respectively. Next, we define a remote controller on the server. As in the previous example, the controller is in charge of executing the quantum instructions, the only difference is that now it is on a remote server rather than on the same local machine where the quantum-classical workflow is defined.

N = 13  # data wires
AUX = N  # index of the single re-used auxiliary wire
qdev = qp.device("lightning.qubit", wires=N + 1)

CPU = qp.Controller(
    name="cpu-controller",
    device=qdev,
    remote=True,
    executor_options={**SERVER, "port": 8810},
    init_args={"config": "dev=mlx5_1;gid=3;cpu_pin=4;rt=1"}
)

executor_options specifies the remote machine and how it is reached, and init_args specifies the backend-specific initialization arguments, which are forwarded to the transport backend. For more details, see the Controller documentation.

We can now define a remote GPU coprocessor on the server, and specify the Python-defined belief propagation decoding function it will be executing:

GPU = qp.Coprocessor(
    name="gpu-coproc",
    coprocessor_fn=bp_decoder,  # our Python-defined BP decoder
    remote=True,
    endpoint=qp.Endpoint("192.168.1.2", 7760),
    executor_options={**SERVER, "port": 8813},
    hardware="gpu",
    init_args={"config": "dev=mlx5_1;gid=3;cpu_pin=4;rt=1;gpu=0"}
)

Here, the endpoint argument specifies the RDMA connection between the controller and the coprocessor (which is distinct from the SERVER connection). We now have all the pieces to define our backline!

dev = qp.Backline(controller=CPU, coprocessors=[GPU], transport="rdma")

Since we did not specify any automatic quantum error correction, we need to explicitly perform encoding and decoding. As a result, we will:

  • Manually encode our logical circuit (in effect, defining a physical circuit to be compiled and executed on the backline); and

  • Manually measure the syndromes, decode, and apply correction.

Luckily, PennyLane and Catalyst make this easy! The AutoGraph feature will automatically capture and convert native Python control flow for compilation, even if the control flow depends on quantum measurements.

encoder = {
    0: [6, 9, 11],
    1: [7, 9, 10, 11, 12],
    2: [8, 10, 12],
    3: [6, 11],
    4: [7, 11, 12],
    5: [8, 12],
}

@qp.qjit(capture=True, autograph=True)
@qp.set_shots(1)
@qp.qnode(dev, mcm_method="one-shot")
def encoded_decoded_circuit(error_kind):
    # ========= Encoded logical circuit =========
    # encode a logical 0 state
    cnots = np.array([[pivot, target] for pivot, targets in encoder.items() for target in targets])

    for pivot in sorted(encoder):
        qp.Hadamard(wires=pivot)
    for control, target in cnots:
        qp.CNOT(wires=[control, target])

    # encode a logical X gate
    for w in [6, 7, 8]:
        qp.X(wires=w)

    # encode a logical H gate
    for w in range(N):
        qp.Hadamard(wires=w)
    for a, b in [(1, 3), (2, 6), (5, 7), (10, 11)]:
        qp.SWAP(wires=[a, b])

    # encode a logical Z gate
    for w in [2, 5, 8]:
        qp.Z(wires=w)

    # encode a logical H gate
    for w in range(N):
        qp.Hadamard(wires=w)
    for a, b in [(1, 3), (2, 6), (5, 7), (10, 11)]:
        qp.SWAP(wires=[a, b])

    # QEC decoding using our belief propagation decoder
    correction_rounds()

    return (qp.expval(mean_stabilizer(Hz, qp.Z)), qp.expval(mean_stabilizer(Hx, qp.X)))

We won’t work through the details of the encoding here, but check out tutorial_qldpc_codes if you would like to learn more.

You may notice a few functions that have not yet been defined, such as correction_rounds and mean_stabilizer. Let’s define them now.

First up, correction_rounds. This is a for loop over wires that:

  1. Injects single qubit errors onto each data wire;

  2. Performs measurements in order to extract the syndrome;

  3. Calls the coprocessor to perform QEC decoding (via the decode function);

  4. Applies the corrections to the quantum device.

Note

decode makes a series of runtime calls directly on the coprocessor. The ability to manually perform runtime calls is also available via runtime_call. If you are curious to see the internal runtime calls, see the corresponding demo in the Backline repository. This demo expands out decode into explicit get_session, stage_payload, post, and collect calls.

def correction_rounds(error_kind):
    for error_qubit in range(N):

        # ========= Inject errors =========
        # Apply I/X/Y/Z to one chosen data wire.
        if error_kind == 1:
            qp.X(wires=error_qubit)
        elif error_kind == 2:
            qp.Y(wires=error_qubit)
        elif error_kind == 3:
            qp.Z(wires=error_qubit)

        # ========= Decoding ==============
        z_syndrome, x_syndrome = extract_syndromes()

        correction_z = qp.backline.decode(x_syndrome, decoder_id=0)
        correction_x = qp.backline.decode(z_syndrome, decoder_id=1)

        # Apply X to every data qubit whose correction bit is set
        for q in range(N):
            if correction_x[q]:
                qp.X(wires=q)

        # Apply Z to every data qubit whose correction bit is set
        for q in range(N):
            if correction_z[q]:
                qp.Z(wires=q)

def extract_syndromes():
    z_syndrome = np.zeros(len(Hz), dtype=int)

    for check, row in enumerate(Hz):
        for q in range(N):

            if row[q]:
                qp.CNOT(wires=[q, AUX])

        z_syndrome[check] = qp.measure(AUX, reset=True)

    x_syndrome = np.zeros(len(Hx), dtype=int)

    for check, row in enumerate(Hx):
        qp.Hadamard(wires=AUX)

        for q in range(N):
            if row[q]:
                qp.CNOT(wires=[AUX, q])

        qp.Hadamard(wires=AUX)
        x_syndrome[check] = qp.measure(AUX, reset=True)

    return z_syndrome, x_syndrome

Next, mean_stabilizers, which returns a linear combination of \(X\) and \(Z\) stabilizers from the parity check matrices. By returning the expectation value of these operators from the QNode, we are able to check if the injected physical errors are corrected; acting as a high-level diagnostic metric of “how healthy” the error-corrected quantum state is.

In a perfect quantum error-correcting code with zero errors, the expectation value of every single stabilizer is exactly 1. Because single Pauli errors are injected, some of those stabilizers will flip to -1, reducing the overall expectation value. So if the QNode is correctly error-encoded, the returned value should be 1.

def mean_stabilizer(checks, pauli):
    return qp.dot(
        [1 / len(checks)] * len(checks),
        [qp.prod(*(pauli(wires=int(q)) for q in np.flatnonzero(row))) for row in checks],
    )

We now have all the pieces in place to run our explicit QEC encoding and decoding example on the backline:

for error_kind, error_name in enumerate(["I", "X", "Y", "Z"]):
    print(error_name, encoded_decoded_circuit(error_kind))
scp: Connection closed
[scp] the SFTP backend is declining. Retrying it with the legacy protocol (scp -O), which hosts without an SFTP subsystem need
[cpu-controller] catalyst-executor: loaded /home/catalyst-exec/librt_transport.so
[cpu-controller] catalyst-executor: loaded /home/catalyst-exec/librt_capi.so
[cpu-controller] catalyst-executor: loaded /home/catalyst-exec/liblightning_qubit_catalyst.so
[cpu-controller] Listening on 127.0.0.1:8810
[cpu-controller] [127.0.0.1:8810] executor ready, waiting for connections
scp: Connection closed
[scp] the SFTP backend is declining. Retrying it with the legacy protocol (scp -O), which hosts without an SFTP subsystem need
[gpu-coproc] catalyst-executor: loaded /home/catalyst-exec/librt_transport.so
[gpu-coproc] catalyst-executor: loaded /home/catalyst-exec/librt_capi.so
[gpu-coproc] catalyst-executor: loaded /home/catalyst-exec/librdma_triton_decoder.so
[gpu-coproc] Listening on 127.0.0.1:8813
[gpu-coproc] [127.0.0.1:8813] executor ready, waiting for connections
[gpu-coproc] [127.0.0.1:8813] accepted connection from 127.0.0.1:56120 on pid 1546394, waiting for next connection
[cpu-controller] [127.0.0.1:8810] accepted connection from 127.0.0.1:56154 on pid 1546399, waiting for next connection
I (Array(1., dtype=float64), Array(1., dtype=float64))
X (Array(1., dtype=float64), Array(1., dtype=float64))
Y (Array(1., dtype=float64), Array(1., dtype=float64))
Z (Array(1., dtype=float64), Array(1., dtype=float64))
JIT session error: FD-transport disconnected
JIT session error: disconnecting
JIT session error: FD-transport disconnected
JIT session error: disconnecting

Remote GPU-to-FPGA with a pre-compiled kernel

We can now scale the exact same logic to a remote FPGA-to-GPU environment. For simplicity, let’s return to compiling a logical circuit with PennyLane, encoding the Steane code with Catalyst, and using a pre-compiled decoder function.

First, we create the FPGA controller. Here, it is a AMD Versal™ Premium Series VPK120 board:

FPGA_SERVER = {
    'host': "192.168.3.15",
    'user': "username",  # user on the FPGA server
    'triple': "aarch64-unknown-linux-gnu",
    'sudo': True,
    'deploy': [Path(os.environ["BACKLINE_BUNDLES"]) / "vpk-bundle"],
    'env': {
        "XMM_SQ_TYPE": "PL", "XMM_RQ_TYPE": "PL", "XMM_CQ_TYPE": "PL",
        "XMM_APP_MAX_QP": "4", "XMM_APP_RQ_SGE": "1", "XMM_SQ_DEPTH": "64",
        "XMM_RQ_DEPTH": "128", "HWHS_RTT_WARMUP": "0"
    }
}

FPGA = qp.Controller(
    name="fpga-controller",
    remote=True,
    hardware="fpga",
    executor_options={**FPGA_SERVER, "port": 8811},
    init_args={"config": "dev=xib_0;gid=1;sq_mem=bram;data_mem=bram;reply_mem=bram;reply_poll=hw;stride_log2=6"}
)

Note that since we haven’t provided a quantum device, the controller will default to using null.qubit, a convenient dummy device that performs no quantum processing (but simply accepts quantum operations and returns 0). We can use the same remote GPU coprocessor we set up earlier, but replace coprocessor_fn with 'gpu_steane_launcher' (a convenient alias for Catalyst’s precompiled Steane decoder, which is already loaded and available for use).

GPU = qp.Coprocessor(
    name="gpu-coproc",
    coprocessor_fn="gpu_steane_launcher",
    remote=True,
    endpoint=qp.Endpoint("192.168.1.2", 7760),
    executor_options={**SERVER, "port": 8813},
    hardware="gpu",
    init_args={"config": "dev=mlx5_1;gid=3;cpu_pin=4;rt=1;gpu=0"}
)

Defining the backline, this time we provide our FPGA as the controller:

dev = qp.Backline(controller=FPGA, coprocessors=[GPU], transport="rdma", qec_code="steane")

Our backline infrastructure looks as follows:

../../_images/server-setup.png

We can now create and execute our logical quantum circuit:

@qp.qjit(capture=True)
@qp.set_shots(1000)
@qp.qnode(dev, mcm_method="one-shot")
def ghz():
    qp.Hadamard(0)
    qp.CNOT([0, 1])
    qp.CNOT([1, 2])
    return qp.sample([qp.measure(0), qp.measure(1), qp.measure(2)])

print("samples:", ghz())
[fpga-controller] catalyst-executor: loaded /home/petalinux/catalyst-exec/librt_transport.so
[fpga-controller] catalyst-executor: loaded /home/petalinux/catalyst-exec/librt_capi.so
[fpga-controller] catalyst-executor: loaded /home/petalinux/catalyst-exec/librtd_null_qubit.so
[fpga-controller] Listening on 127.0.0.1:7811
[fpga-controller] [127.0.0.1:7811] executor ready, waiting for connections
scp: Connection closed
[gpu-coproc] catalyst-executor: loaded /home/catalyst-exec/librt_transport.so
[gpu-coproc] catalyst-executor: loaded /home/catalyst-exec/librt_capi.so
[gpu-coproc] Listening on 127.0.0.1:7813
[gpu-coproc] [127.0.0.1:7813#3133915] Accepted connection
[fpga-controller] [127.0.0.1:7811] accepted connection from 127.0.0.1:39924 on pid 1209, waiting for next connection
[fpga-controller]
[fpga-controller]=== engine RTT (n=16000, 0 warmup dropped, hardware handshake) ===
[fpga-controller]   min          4245 ns
[fpga-controller]   p50          4415 ns
[fpga-controller]   p95          4690 ns
[fpga-controller]   p99          5015 ns
[fpga-controller]   p99.9        5165 ns
[fpga-controller]   max      268509050 ns
[fpga-controller]   mean        21249 ns
samples: [[0 0 0]
 [0 0 0]
 [0 0 0]
 ...
 [0 0 0]
 [0 0 0]
 [0 0 0]]
JIT session error: FD-transport disconnected
JIT session error: disconnecting
JIT session error: FD-transport disconnected
JIT session error: disconnecting

Note that the max number is particularly large for the very first round, which pays for the connection initialization. By setting HWHS_RTT_WARMUP=1 in the board’s environment, we can examine the steady state latency values.

During execution, backline is managing the following communication pathways:

../../_images/communications.png

Bring your own Triton decoder

With a simple modification to the above remote GPU-FPGA example, we can replace the pre-compiled Steane decoder with a custom Triton decoder — all written from Python.

Since we are using the Steane code, a relatively simple QEC code that uses only 7 wires to encode a logical qubit, the error detection splits into two independent 3-bit checks: one for bit-flips (\(X\)) and one for phase-flips (\(Z\)). Since \(X\) and \(Z\) errors don’t interfere, each check has only 8 possible outcomes.

Due to this low number, one decoding strategy is to simply pre-calculate every solution and store them in a lookup table. We can create an highly efficient Triton function that maps each three‑bit syndrome to a weight‑1 error.

import triton
import triton.language as tl

# The Steane decoding table: the qubit to flip for each three-bit syndrome, with -1 meaning the
# syndrome was zero and nothing needs correcting.
STEANE_QUBIT_BY_SYNDROME = (-1, 0, 4, 1, 6, 3, 5, 2)

def pack_lookup_table(values, no_error=0xF):
    """Pack the lookup table into one integer, four bits per entry."""
    word = 0
    for i, value in enumerate(values):
        word |= (no_error if value < 0 else value) << (4 * i)
    return word

# A compile-time constant, so the lookup below becomes a shift and a mask with no memory access.
STEANE_LUT = tl.constexpr(pack_lookup_table(STEANE_QUBIT_BY_SYNDROME))

def steane_lookup(syndrome):
    """Return the qubit to correct for one syndrome, or -1 if there is nothing to do."""
    idx = tl.cast(0, tl.uint32)
    for i in tl.static_range(3):
        idx |= tl.cast((syndrome >> (8 * i)) & 1, tl.uint32) << i
    qubit = (tl.cast(STEANE_LUT, tl.uint32) >> (idx * 4)) & 0xF

    # All ones is -1 read as unsigned, which is how the controller recognises "no correction".
    no_error = tl.cast(0xFFFFFFFFFFFFFFFF, tl.uint64)
    return tl.where(qubit == 0xF, no_error, tl.cast(qubit, tl.uint64))

We can use the provided decode function to compile this for our target system using triton.jit, and then it is simply a matter of providing the compiled steane_triton_decoder as our coprocessing function when defining the GPU coprocessor. Note that we provide it twice — while the lookup table works for decoding both \(X\) and \(Z\) errors, this demonstrates native support for general CSS codes with potentially different decoder functions.

steane_triton_decoder = qp.backline.triton_decoder(
    (steane_lookup, steane_lookup),
    platform="hip:gfx90a:64",
)

GPU = qp.Coprocessor(
    name="gpu-coproc",
    coprocessor_fn=steane_triton_decoder,
    remote=True,
    endpoint=qp.Endpoint("192.168.1.2", 7760),
    executor_options={**SERVER, "port": 8813},
    hardware="gpu",
    init_args={"config": "dev=mlx5_1;gid=3;cpu_pin=4;rt=1;gpu=0"}
)


dev = qp.Backline(controller=FPGA, coprocessors=[GPU], transport="rdma", qec_code="steane")

@qp.qjit(capture=True)
@qp.set_shots(1000)
@qp.qnode(dev, mcm_method="one-shot")
def ghz():
    qp.Hadamard(0)
    qp.CNOT([0, 1])
    qp.CNOT([1, 2])
    return qp.sample([qp.measure(0), qp.measure(1), qp.measure(2)])

print("samples:", ghz())
[fpga-controller] catalyst-executor: loaded /home/petalinux/catalyst-exec/librt_transport.so
[fpga-controller] catalyst-executor: loaded /home/petalinux/catalyst-exec/librt_capi.so
[fpga-controller] catalyst-executor: loaded /home/petalinux/catalyst-exec/librtd_null_qubit.so
[fpga-controller] Listening on 127.0.0.1:7811
[fpga-controller] [127.0.0.1:7811] executor ready, waiting for connections
scp: Connection closed
[gpu-coproc] catalyst-executor: loaded /home/catalyst-exec/librt_transport.so
[gpu-coproc] catalyst-executor: loaded /home/catalyst-exec/librt_capi.so
[gpu-coproc] Listening on 127.0.0.1:7813
[gpu-coproc] [127.0.0.1:7813#3133915] Accepted connection
[fpga-controller] [127.0.0.1:7811] accepted connection from 127.0.0.1:39924 on pid 1209, waiting for next connection
[fpga-controller]
[fpga-controller] === engine RTT (n=16000, 0 warmup dropped, hardware handshake) ===
[fpga-controller]   min          4595 ns
[fpga-controller]   p50          4750 ns
[fpga-controller]   p95          5050 ns
[fpga-controller]   p99          5350 ns
[fpga-controller]   p99.9        5530 ns
[fpga-controller]   max      96567640 ns
[fpga-controller]   mean        10843 ns
samples: [[0 0 0]
[0 0 0]
[0 0 0]
...
[0 0 0]
[0 0 0]
[0 0 0]]
JIT session error: FD-transport disconnected
JIT session error: disconnecting
JIT session error: FD-transport disconnected
JIT session error: disconnecting

Conclusion

In this demo, we demonstrated how PennyLane, Backline, and Catalyst eliminate the bottleneck of transitioning from local quantum error correction (QEC) prototyping to production-grade hardware deployment. By keeping the entire workflow within Python, you can dynamically connect quantum workloads to classical engines — such as CPUs, GPUs, and FPGAs — without rewriting control logic into low-level languages. Start with simple CPU-CPU prototypes with explicit quantum encoding and decoding directly in Python, and rapidly scale up to remote classical accelerators, optimized MLIR passes, and pre-compiled GPU decoder kernels.

Furthermore, the integration with Triton allows for the rapid creation of highly optimized, custom hardware kernels all from Python.

To continue exploring heterogeneous quantum compilation and execution, check out the following resources:

  • Read the Backline technical documentation.

  • View additional Backline demos in the Backline repository on GitHub, including CPU-CPU interactions over RDMA, and explicit usage of runtime_call to execute coprocessor functions.

  • Read the Backline technical paper to get a technical overview of the infrastructure and performance.

  • Check out the PennyLane blog post to learn more about Backline.

Instinct and Versal are trademarks of Advanced Micro Devices, Inc.

About the authors

Josh Izaac
Josh Izaac

Josh Izaac

Josh is a theoretical physicist, software tinkerer, and occasional baker. At Xanadu, he contributes to the development and growth of Xanadu’s open-source quantum software products.

Mehrdad Malek
Mehrdad Malek

Mehrdad Malek

Compilation Tech Lead

Joseph Lee
Joseph Lee

Joseph Lee

Joseph is a physicist and software developer. At Xanadu, he's part of the performance team and tries to make PennyLane go zoom.

Kalman Szenes
Kalman Szenes

Kalman Szenes

Kalman is a 2026 Xanadu summer resident on the Performance team and a PhD candidate in Quantum Chemistry at ETH Zurich focusing on tensor network methods.

Share demo

Ask a question on the forum

Related Demos

Quantum low-density parity-check (qLDPC) codes for quantum error correction

Decoding Quantum Errors on the Steane code with Belief Propagation & Catalyst

Adjoint Differentiation – Supplementary Material

Running GPU-accelerated quantum circuit simulations on Covalent Cloud using PennyLane

Pulse programming on Rydberg atom hardware

Introducing the Surface Code

How to use Qiskit 1.0 with PennyLane

Computing gradients in parallel with Amazon Braket

Getting started with the Amazon Braket Hybrid Jobs

QJIT compilation with Qrack and Catalyst

Never miss a milestone

Get the latest quantum updates delivered to your inbox.

Join the list
PennyLane

PennyLane is an open-source quantum software platform for quantum computing, quantum machine learning, and quantum chemistry. Create meaningful quantum algorithms, from inspiration to implementation.

Created with ❤️ by Xanadu.

Research

  • Research

  • Performance

  • Hardware and simulators

  • Demos library

  • Compilation hub

  • Quantum datasets

Education

  • Teach

  • Learn

  • Codebook

  • Coding challenges

  • Videos

  • Glossary

Software

  • Install

  • Features

  • PennyLane documentation

  • Catalyst documentation

  • Development guide

  • How-to guides

  • API

  • GitHub


Xanadu

© Copyright 2026 | Xanadu | All rights reserved

TensorFlow, the TensorFlow logo and any related marks are trademarks of Google Inc.

Privacy policyTerms of serviceCookies policyCode of conduct