Automatic Encoding for the Steane Code

Download this notebook - steane_encoding.ipynb

Guppy FT is an extension of the Guppy quantum programming language to aid writing, compiling and running fault-tolerant programs by providing a separation of concerns between writing quantum algorithms and quantum error correction (QEC). Researchers are able to write QEC-agnostic programs, and utilize QEC code architectures developed by experts to automatically encode into an error-protected program.

This notebook provides an introduction to the encoding features of Guppy FT through simple examples.

Encoding a Guppy program

Encoding happens in four stages:

  1. Write a computational Guppy program using guppylang.std.quantum operations.

  2. Run an encoding pass to convert the computational program into a logical program.

  3. (Optional) Run logical-aware optimization passes.

  4. Replace the logical operations with physical implementations.

Let’s see a full workflow with a quantum teleportation program.

from guppylang import guppy
from guppylang.std import quantum as qlib
from guppylang.std.builtins import array, owned
from guppylang.std.platform import output

First, we write our computational program. At this stage, we are not concerned with QEC. The program is written assuming that it will run on a noiseless quantum computer using guppylang.std operations.

@guppy
def teleport_primitive(src: qlib.qubit @ owned) -> qlib.qubit:
    # Create Bell pair
    tmp = qlib.qubit()
    tgt = qlib.qubit()
    qlib.h(tmp)
    qlib.cx(tmp, tgt)

    # Teleport
    qlib.cx(src, tmp)
    qlib.h(src)
    if qlib.measure(src).read():
        qlib.z(tgt)
    if qlib.measure(tmp).read():
        qlib.x(tgt)

    return tgt


@guppy
def teleport() -> None:
    # Qubit to be teleported
    src = qlib.qubit()

    # Teleport `src` to `tgt`
    tgt = teleport_primitive(src)
    output("tgt", qlib.measure(tgt).read())

Now we can define the QEC code architecture that we would use to encode our program to be fault-tolerant. An architecture includes the necessary encoding and implementation passes to convert our computational program into a runnable, physical package.

For this example, we will use the Steane architecture.

from guppyft.code.steane.encode import SteaneBuilder

# Define a Steane architecture instance.
# The only required parameter is an upper bound
# to the total number of logical blocks available
# during program execution.

steane = SteaneBuilder().build(n_blocks=3)

We can use our architecture to encode the program, running the full pass from computational to logical and finally to physical, producing a package that we can run.

teleport_unencoded = teleport.compile()
teleport_encoded = steane.encode(teleport_unencoded)

We can emulate the encoded program using Selene locally, or submit it to Nexus cloud to run on proprietary emulators or quantum devices. Here, we illustrate local emulation using Stim (a Clifford circuit simulator) and a simple depolarizing noise model.

from guppylang.emulator import EmulatorBuilder
from selene_sim.backends.bundled_error_models import DepolarizingErrorModel
from selene_sim.backends.bundled_simulators import Stim

output = (
    # Create an EmulatorBuilder and instantiate it with the encoded program
    EmulatorBuilder()
    .build(teleport_encoded, n_qubits=22)
    # Then, configure the emulator instance
    .with_simulator(Stim(random_seed=42))
    .with_error_model(DepolarizingErrorModel(random_seed=42, p_1q=1e-4, p_2q=1e-3))
    .with_shots(1000)
).run()

print(output.collated_counts())
Counter({(('tgt', '0'),): 999, (('tgt', '1'),): 1})

For convenience, the Steane architecture provides an emulator() method that automatically encodes the program and builds the emulator. See the API docs for emulator(), under SteaneInstance.

Furthermore, we can explore the trace of operations that have been emulated. A quick check confirms that the encoded program is considerably deeper.

from selene_sim.event_hooks.instruction_log import CircuitExtractor

# Create a CircuitExtractor to log the instructions that would run on the quantum device
logger = CircuitExtractor()

# Obtain the depth of the unencoded program
output = (
    EmulatorBuilder()
    .build(teleport_unencoded, n_qubits=3)
    .with_simulator(Stim(random_seed=42))  # Use stabilizer state simulation
    .with_event_hook(logger)
).run()

unencoded_depth = logger.shots[0].get_user_circuit().depth()
# Create a new CircuitExtractor instance
logger = CircuitExtractor()

# Obtain the depth of the encoded program
output = (
    EmulatorBuilder()
    .build(teleport_encoded, n_qubits=22)
    .with_simulator(Stim(random_seed=42))  # Use stabilizer state simulation
    .with_event_hook(logger)
).run()

encoded_depth = logger.shots[0].get_user_circuit().depth()
print("Depth (longest sequence of operations):")
print(f"\tUnencoded program: {unencoded_depth}")
print(f"\tEncoded program: {encoded_depth}")
Depth (longest sequence of operations):
	Unencoded program: 11
	Encoded program: 73

Dynamic allocation of logical qubits

Guppy FT provides support for arbitrary control flow through dynamic allocation of logical qubits. Below is an example of qubits being dynamically allocated based on the outcome of a qubit measurement.

@guppy
def dynamic_allocation() -> None:
    q0 = qlib.qubit()
    qlib.h(q0)
    if qlib.measure(q0):  # If true, allocate a single qubit
        q_arr = array(qlib.qubit())
        for q in q_arr:
            output("q", qlib.measure(q).read())
    else:  # Otherwise, allocate two qubits
        q_arr = array(qlib.qubit(), qlib.qubit())
        qlib.cx(q_arr[0], q_arr[1])
        for q in q_arr:
            output("q", qlib.measure(q).read())


dyn_alloc_encoded = (
    SteaneBuilder().build(n_blocks=3).encode(dynamic_allocation.compile())
)

Mid-circuit measurement and qubit reuse

The Quantinuum stack supports mid-circuit measurements and qubit reuse. This support continues at the logical level with Guppy FT.

We can demonstrate this using the teleport_primitive defined above to teleport a qubit twice. Without qubit reuse, this program would require 5 logical qubits. However, only 3 logical qubits are required at any one time, as demonstrated by imposing n_blocks=3.

@guppy
def qubit_reuse() -> None:
    # Qubit to teleport
    src = qlib.qubit()

    # Run teleportation twice
    tgt = teleport_primitive(src)
    src = teleport_primitive(tgt)

    output("src", qlib.measure(src).read())


# Encode with only a single logical block to demonstrate logical reuse.
reuse_encoded = SteaneBuilder().build(n_blocks=3).encode(qubit_reuse.compile())

Dynamic QEC cycle insertion

In quantum programs with complex control flow, it can be useful to use information obtained during runtime to determine when to insert a QEC cycle. Guppy FT supports dynamic insertion of QEC cycles depending on the logical gates that have been performed.

In this demonstration, each logical operation is assigned a cost, which is tracked on a per-block basis. At runtime, once a threshold is reached, a QEC cycle is inserted, and the tracking counter is reset to 0.

from guppyft.code.steane.encode import QECPolicy, QECStyle

# Define a QEC policy using Steane style syndrome extraction
# We set the threshold to be 2.
# Both the logical `H` and `CX` gates each have a cost of 1.
qec_policy = QECPolicy(style=QECStyle.Steane, threshold=2)
qec_policy.costs.h = 1.0
qec_policy.costs.cx = 1.0

# We can now provide the `qec_policy` to define our Steane architecture.
steane_qec = SteaneBuilder().with_qec_policy(qec_policy).build(n_blocks=2)
# Demonstration program to apply `H` and `CX` gates to two qubits.
@guppy
def my_program() -> None:
    q0 = qlib.qubit()
    q1 = qlib.qubit()

    # Track costs:      [q0 , q1 ]
    qlib.h(q0)  #       [1.0, 0.0]
    qlib.cx(q0, q1)  #  [2.0, 1.0]
    #                   [0.0, 1.0] <-- QEC cycle on q0
    qlib.h(q1)  #       [1.0, 2.0]
    #                   [1.0, 0.0] <-- QEC cycle on q1

    output("q0", qlib.measure(q0).read())
    output("q1", qlib.measure(q1).read())


# Encode two programs with, and without our QEC policy.
encoded_without_qec_cycles = (
    SteaneBuilder().build(n_blocks=2).encode(my_program.compile())
)
encoded_with_qec_cycles = (
    SteaneBuilder()
    .with_qec_policy(qec_policy)
    .build(n_blocks=2)
    .encode(my_program.compile())
)

Once again, we can use Selene’s CircuitExtractor to explore the operations that run and check that, indeed, QEC cycles were introduced.

In this case, we count the number of measurement operations.

# Create a new CircuitExtractor instance
logger = CircuitExtractor()

# Obtain the number of measurements executed in the case of no QEC cycles inserted
output = (
    EmulatorBuilder()
    .build(encoded_without_qec_cycles, n_qubits=22)
    .with_simulator(Stim(random_seed=42))  # Use stabilizer state simulation
    .with_event_hook(logger)
).run()

n_meas_no_qec = sum(
    1
    for cmd in logger.shots[0].get_user_circuit().get_commands()
    if "Measure" in str(cmd)
)
# Create a new CircuitExtractor instance
logger = CircuitExtractor()

# Obtain the number of measurements executed in the case of QEC cycles inserted
output = (
    EmulatorBuilder()
    .build(encoded_with_qec_cycles, n_qubits=22)
    .with_simulator(Stim(random_seed=42))  # Use stabilizer state simulation
    .with_event_hook(logger)
).run()

n_meas_qec = sum(
    1
    for cmd in logger.shots[0].get_user_circuit().get_commands()
    if "Measure" in str(cmd)
)
print("Number of physical measurements performed:")
print(f"\tWithout QEC cycles: {n_meas_no_qec}")
print(f"\tWith QEC cycles: {n_meas_qec}")
Number of physical measurements performed:
	Without QEC cycles: 16
	With QEC cycles: 48

Indeed, there are 32 more physical measurements: each QEC cycle uses (7+1)*2 = 16 physical measurements, since it prepares and measures two logical zero states (7 physical qubits, plus 1 flag ancilla), and we expect two QEC cycles to be introduced.

State factories

Fault-tolerant state preparation often uses a repeat-until-success (RUS) scheme that measures flag ancilla qubits to verify that the state preparation succeeds. In order to run state preparation in parallel, we need to defer reading the measurement outcome on these ancilla qubits, as described in Guppy’s language guide (see Measurement section). Our Steane architecture defines a StateFactory that handles deferral of measurements and parallelizes RUS state preparation. Users can configure the parameters of the factory both for zero states and magic states.

The following example configures an architecture instance that prepares zero states in batches of 2, making up to 5 RUS attempts per request.

from guppyft.code.steane.encode import RUSStateFactoryConf

# Define the state factory configuration.
factory_conf = RUSStateFactoryConf(size=2, max_attempts=5)

# Use the factory configuration to define our Steane architecture.
steane_code_factories = (
    SteaneBuilder()
    .with_qec_policy(qec_policy)
    .with_zero_factory_conf(factory_conf)
    .build(n_blocks=2)
)
qec_factory_encoded = steane_code_factories.encode(my_program.compile())

Some tools to visualize parallelism will be released in due time. Until then, users can explore the impact of parallelizing state preparation by running on Quantinuum Helios-1E emulator on Nexus cloud. Helios-1E includes memory noise and, hence, parallelization makes a positive impact on program performance thanks to a decrease in shot time.