Gate Streaming

Note

Gate streaming is not available to all hardware users. Users are granted access based on their use case. Please reach out to QCSupport@quantinuum.com for more information.

Overview

This document provides overview, reference and demonstration of the Gate Streaming capability. Gate Streaming use a software component, called Remote Procedure Call (RPC), enabled via proprietary extensions for Guppy and Selene, but also accessible on Helios and hardware-tier Helios emulators. RPC enables server-side arbitrary classical computation during real-time execution of a Guppy program on Quantinuum Systems. Selene, a emulation framework for Quantinuum Systems, enables replication of the RPC functionality. End users are required to supply Rust code to run on the RPC server.

RPC uses the User Datagram Protocol (UDP) to submit connectionless and trustless requests to a user-specified server. UDP is a simpler and faster communication protocol than TCP (Transmission Control Protocol). End users must necessarily use UDP within their RPC server script to listen to requests. For RPC client scripts, direct UDP consumption is not required. The end user is required to interact with the RPC plugins for Guppy and Selene to submit and receive requests.

RPC Schematic

This schematic displays the setup of the server-side hardware stack.

The CPU, coupled to the Quantum Control System (QCS), is used to perform classical computation defined natively in Guppy. RPC computation requires the control system to submit requests and receive responses to a co-located classical server, via Local Area Network (LAN).

Gate streaming is a unique capability enabled by Helios real-time qubit routing. A quantum program running on the QPU may reach out to an external server to obtain additional instructions including quantum gates to execute. There are several known use cases for this capability:

  • Blind computation protocol. Program is submitted without the measurements, measurement basis is provided once the program has finished executing using the RPC call to customer-controlled server.

  • Better certified randomness. Single-shot random circuit programs can be submitted using the gate streaming approach reducing the latency between circuit submission and results, which in turn increases classical adversary resource requirements.

  • Topological data analysis. A large data set (graph) may not fit into classical memory restricting classical algorithm capability to analyze it. Quantum algorithms exist that can process the data incrementally avoiding the need to have complete data set in memory at once.

Software Requirements

Python

The following table specifies the software requirements to setup and use Quantinuum’s Gate Streaming capability. The user requires an instance of Python 3.10 or greater.

Software

Description

License

guppylang

A Python-like programming language for quantum and classical computation, embedded within Python

Opensource

guppylang-internals

A Guppy dependency that contains internals of the Guppy compiler.

Opensource

selene-anduril

Proprietary extension for Selene to use Anduril

Proprietary

selene-core

Extension for Selene to enable core

Opensource

selene-hugr-qis-compiler

Extension for Selene to enable HUGR to LLVM compilation adhering to Quantinuum’s Quantum Instruction Set (QIS).

Opensource

selene-sim

Provides emulation, resource estimation and debugging capabilities for Guppy programs

Opensource

The opensource libraries can be installed with the specified command. Note, the Python package, guppylang, enables usage of the Guppy language. Selene is available for installation and consumption on local infrastructure, as a Python package, selene_sim.

Proprietary software is distributed directly by Quantinuum, and installed using the Python package manager, pip.

Rust

Rust is required for the RPC server code. The Cargo toolchain can be installed from here. Cargo is responsible for compiling Rust code into a machine native binary. All dependencies are specified within cargo.toml. For Rust projects, this file is auto-named cargo.toml file. Third-party dependencies, such as rand are specified in the dependencies table. A Rust project can be initialized using cargo init.

cargo-features = ["edition2024"]

[package]
name = "rpc_server"
version = "0.1.0"
edition = "2024"

[dependencies]
rand = "~0.9"

Rust version 1.85.0 or greater supports Rust 2024, which is required for this project. Binaries can be built using cargo build. Users can also build and run Rust source using cargo run --bin <project_name>, where <project_name> corresponds to the value of the name variable in the TOML file.

Code Sample

Gate streaming RPC protocol works in the following way:

  1. A quantum program is loaded to the QPU. The program may contain a fixed set of gates and an instruction processor making RPC calls to a remote server and processing additional instructions.

  2. An RPC server waits for requests from a quantum program and provides RPC responses instructing quantum program to perform additional tasks.

The capability is limited to sending and receiving arrays in both directions allowing the RPC client and server to establish their own data serialization protocol and assign specific semantic meaning to the instructions exchanged by the two parties.

Guppy Program

The code below specifies the Guppy program submitting RPC requests to a remote server. The response from the RPC call is used to conditionally perform quantum operations on qubits. The proprietary qtm_platform package provides the RPC client.

import math

from guppylang import guppy
from guppylang.std.builtins import array, nat, result, bytecast_nat_to_float, comptime
from guppylang.std.angles import angle
from guppylang.std.qsystem import phased_x, zz_phase, rz
from guppylang.std.quantum import qubit, measure

from qtm_platform.ops import RPC

The main method is the entry point for the Guppy program and is responsible for initializing and managing the RPC session, in addition to the gate streaming workflow. It specifies the server address and port number and inputs into the RPC client. A 100-iterations loop performs a gatestreaming workflow via the boolean _exec function. If the boolean is False (failure), the loop is terminated and the boolean is tagged as output within the result stream. The RPC session is terminated using the instance method RPC.stop. If the iterative loop completes successfully, all qubits are measured destructively and discarded. The measurement result is tagged as measureResults and reported in the results stream.

@guppy
def main() -> None:
    addr: nat = comptime(get_addr(IP_ADDR))
    port: nat = comptime(PORT_NUM)
    rpc = RPC(addr, port)
    
    qubit_array: array[qubit, comptime(NUM_QUBITS)] = array(qubit() for _ in range(comptime(NUM_QUBITS)))

    for j in range(comptime(NUM_ROUNDS)):
        output = _exec(nat(j), rpc, qubit_array)
        if not output:
            result("output", output)
            break
    
    for q in qubit_array:
        b = measure(q)
        result("measureResults", b)
    rpc.stop()

The main method uses comptime to perform classical computation on client infrastructure during the local compilation from guppy to HUGR. Global Python variables are used by the main method. The python method, get_addr, is used to convert the global IP address into a Python integer. The specified IP address, 127.0.0.1, should be modified to the server address for production.

IP_ADDR: str = "127.0.0.1"
PORT_NUM: int = 11111
NUM_QUBITS = 25
NUM_ROUNDS = 10

def get_addr(ip_addr: str) -> int:
    from functools import reduce
    parts = list(map(int, ip_addr.split(".")))
    return reduce(lambda acc, x: (acc << 8) | x, parts)

The _exec function is responsible for submitting requests to the RPC server via the instance method RPC.rpc. The response from the RPC server results in the following actions:

  • Exit the current shot iteration with an error message and exit code. The workflow does not terminate and the next shot iteration starts executing.

  • Perform native parameterized quantum 1- and 2-qubit gate operations (zz_phase, phased_x, rz). The gate angle and the qubit for the operation is specified by the RPC response.

  • Exit the iterative loop and the function with a False. All qubits are discarded prior to the function returning False.

Each element in the RPC response corresponds to an unsigned 64-bit (8 bytes). 1 element stores the op, q1 and q2. 4 bytes are used to store the op, and 2 bytes each for q1 and q2. Gate angles are stored in 1 unsigned 64-bit integer.

@guppy
def _exec(
    round: nat,
    rpc_instance: RPC,
    qubit_array: array[qubit, comptime(NUM_QUBITS)]
) -> bool:
    r"""
    Execute the RPC request and build a 
    dynamic program based on RPC 
    response.

    Args:
        round (nat): Input A 64-bit unsigned 
            integer to use as seed for RPC request.
        rpc_instance (RPC): Python 
            instance of RPC client.
    
    Returns:
        bool
    """

    request: array[nat, 2] = array(nat(round), comptime(NUM_QUBITS))
    response: array[nat, comptime(3 * NUM_QUBITS + 1)] = rpc_instance.rpc(request)

    QUIT: nat = 0xDEADBEEFDEADBEEF
    RZ: nat = 1
    RXY: nat = 2
    RZZ: nat = 3
    END: nat = 0xCAFEBABECAFEBABE

    i: int = 0
    while response[i] != END:
        op: int =_get_op(response[i])
        result("iteration", i)
        
        if op == QUIT:
            result("QUIT", op)
            return False
        
        elif op == RZ:
            q1: int = _get_q1(response[i])
            a1: float = bytecast_nat_to_float(response[i+1])
            # result("rz.response", response[i])
            result("rz.q1", q1)
            result("rz.theta", a1 )
            if not check(q1):
                exit("Received out of bounds rz.q1", 1)
            i += 2
            rz(qubit_array[q1], angle(a1))

        elif op == RXY:
            q1: int = _get_q1(response[i])
            a1: float = bytecast_nat_to_float(response[i+1])
            a2: float = bytecast_nat_to_float(response[i+2])
            result("rxy.q1", q1)
            result("rxy.theta", a1 )
            result("rxy.phi", a2 )
            if not check(q1):
                exit("Received out of bounds rxy.q1", 1)
            i += 3
            phased_x(qubit_array[q1], angle(a1), angle(a2))

        elif op == RZZ:
            q1: int = _get_q1(response[i])
            # result("rzz.response", response[i])
            result("rzz.q1", q1)
            if not check(q1):
                exit("Received out of bounds rzz.q1", 1)
            q2: int = _get_q2(response[i])
            result("rzz.q2", q2)
            if not check(q2):
                exit("Received out of bounds rzz.q2", 1)
            a1: float = bytecast_nat_to_float(response[i+1])
            result("rzz.theta", a1 )
            i += 2
            zz_phase(qubit_array[q1], qubit_array[q2], angle(a1))

        else:
            result("Error!", 100)
            return False
    
    result("END", END)
    return True

The _process function uses shift decoding to determine actions (quantum operations, early exit or loop termination) and parameters for those actions (qubits). The check function ensures the qubit index does not exceed the array size.

@guppy
def _process(
    x: nat,
    mask: nat,
    shift: nat
) -> int:
    out = (x & mask) >> shift
    return out


@guppy
def check(q1: int) -> bool:
    if q1 >= comptime(NUM_QUBITS):
        return False
    else:
        return True

The convenience methods, _get_op, _get_q1 and _get_q2, consume _process to perform bit masking and shifting operations on the RPC response data (unsigned integer).

@guppy
def _get_op(x: nat) -> int:
    op_mask: nat = 0xFFFFFFFF00000000
    op_shift: nat = nat(32)
    return _process(x, op_mask, op_shift)


@guppy
def _get_q1(
    x: nat
) -> int:
    q1_mask: nat = 0x000000000000FFFF
    q1_shift: nat = 0
    return _process(x, q1_mask, q1_shift)


@guppy
def _get_q2(
    x: nat
) -> int:
    q2_mask: nat = 0x00000000FFFF0000
    q2_shift: nat = nat(16)
    return _process(x, q2_mask, q2_shift)

The response from the RPC server contains a list of unsigned 64-bit integers (8 bytes), as specified in the schematic below. The shift and masking operations are used to decode 1 element from the response into op, q1 and q2.

Shift decoding for gate streaming

This schematic shows shift decoding to extract parameters for program building from the RPC server response. The RPC response is converted to a hexadecimal representation, and bit masking and shifting is used to isolate and recover the op, q1 and q2 parameters.

For example, an RPC response of 12884901888 (0x300020001) corresponds to an \(RZZ\) operation on qubits 1 and 2. The following shift operations are used to decode the op, q1 and q2:

  • op: (12885032961 & 0xFFFFFFFF00000000) >> 32 = 12884901888 >> 32 = 3

  • q1: (12885032961 & 0x00000000FFFF0000) >> 16 = >> 16 = 2

  • q2: (12885032961 & 0x000000000000FFFF) = 1

The gate angle is 0x3ff1b94d8424c1b3 half-turns (float stored in u64 integer). The RPC response can be converted from an u64 integer type representation to a float (8-byte) using bytecast_nat_to_float, resulting in a \(RZZ\) gate angle of 1.108 half-turns.

Angle response for gate streaming

Each gate angle is stored as float (8 bytes) and is a new element in the RPC server response (list). Each gate angle is the i+1 (i+2) for the ith operation.

HUGR can be successfully generated from this module.

hugr = main.compile()

The register_eldarion function registers the eldarion build steps with the Selene build planner. Subsequently, the generated HUGR can be lowered to LLVM using build, via the eldarion compiler. This also returns a python instance of Selene to locally run shots. The QtmPlatformPlugin is required to submit requests to the user-specified IP address. The utilities keyword accepts the QtmPlatformPlugin as an argument (List[Utility]) to enable RPC communication during shots simulation.

from selene_sim import build, Coinflip
from selene_eldarion import QtmPlatformPlugin, register_eldarion
from selene_anduril import AndurilRuntimePlugin
from hugr.qsystem.result import QsysResult

register_eldarion()
selene_instance = build(hugr, eldarion=True, utilities=[QtmPlatformPlugin()])

run_shots submits a program of maximum width (2-qubits) to the Quest simulator with 1-shot. QsysResult is a wrapper to format results. Results tagged as rpcResult are reported to Python stdout.

qsys_result = QsysResult(selene_instance.run_shots(
    simulator=Coinflip(), 
    runtime=AndurilRuntimePlugin(),
    n_qubits=NUM_QUBITS, 
    n_shots=10, 
    verbose=True
))

collated_shots = qsys_result.collated_shots()
print(f"# Keys to access data for a specific shot:\n{collated_shots[-1].keys()}\n")

rzz_theta = collated_shots[-1].get("rzz.theta")
rzz_q1 = collated_shots[-1].get("rzz.q1")
rzz_q2 = collated_shots[-1].get("rzz.q2")
print(f"# Data for Rzz gates for the last shot\nQubit 1: {rzz_q1}\n Qubit 2: {rzz_q2}\n Angle: {rzz_theta}\n")

print(f"# shot data for the last shot:\n{collated_shots[-1]}")

The following output is returned.

{'iteration': [0, 0, 2, 0, 3, 5, 0, 2, 4, 6, 0, 3, 5, 7, 9, 0, 3, 6, 9, 11, 13, 0, 2, 4, 7, 9, 12, 14, 0, 3, 5, 7, 9, 12, 14, 17, 0, 2, 5, 7, 10, 12, 14, 16, 19, 0, 3, 5, 7, 10, 12, 14, 17, 19, 21], 'rz.q1': [12, 4, 2, 24, 14, 7, 11, 1, 7, 9, 23, 11, 22, 1, 8, 6, 2, 8, 5, 14, 19, 14, 11], 'rz.theta': [-1.7653820086540262, -2.850016966380291, -1.8216668020093347, -2.777852221168907, -0.7895544872381276, -3.126178015377164, -2.74708604465009, -2.532392741928888, -1.1294647703434493, -0.9944300646865489, -2.1252987939052517, -1.2102790795799638, -0.48956866937491084, -1.7611105232215367, -0.4076124540863883, -1.0083696377679556, -0.8352381293354705, -2.8011213328935676, -1.865362489164064, -0.29409863840776945, -2.845621928234547, -3.1054581685003217, -0.09990182314963088], 'END': [-3819410105351357762, -3819410105351357762, -3819410105351357762, -3819410105351357762, -3819410105351357762, -3819410105351357762, -3819410105351357762, -3819410105351357762, -3819410105351357762, -3819410105351357762], 'rzz.q1': [13, 3, 17, 20, 12, 20, 20, 5, 10, 10, 6, 4, 8, 1], 'rzz.q2': [14, 4, 18, 21, 13, 21, 21, 6, 11, 11, 7, 5, 9, 2], 'rzz.theta': [-1.7021018600611426, -1.4886228746507197, -0.001956873130552651, -2.165905421768394, -2.9335405425552135, -1.997470494162721, -2.049201379423876, -1.299349833705227, -1.5830128398268055, -1.3256107419587975, -0.5715385636679106, -1.0898553941460525, -1.2248355199791938, -1.7371148400913028], 'rxy.q1': [18, 2, 21, 24, 17, 15, 7, 10, 7, 1, 19, 12, 8, 6, 7, 14, 17, 19], 'rxy.theta': [-0.6611719283537832, -0.9439871170438674, -2.07157800220101, -2.98230271299144, -1.317536220754336, -2.0182556834544636, -0.5440331620670025, -0.6264394085240773, -2.104380654253726, -3.108763882400997, -2.379216648639908, -1.2786204773832708, -0.9469817468455691, -2.7059408464065666, -2.292987651086077, -0.10621740163006287, -0.16412710913852183, -3.064762323924815], 'rxy.phi': [-1.9851995649251233, -0.5119977028986431, -2.0322888586302033, -2.664369605970677, -3.077677471226447, -0.6988151093154172, -1.6272633244599572, -2.728028388280036, -0.2822421567733603, -3.1316885434308688, -1.4446990036918377, -1.2113889137839302, -2.626875304827185, -0.9579282227533831, -2.9970423169030753, -0.24152705468570715, -2.9577132539713795, -1.877309962077936], 'measureResults': [0, 0, 0, 1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0]}

The following keys can be used to access the data within the python dictionary.

  • iteration: List of integers

  • rz.q1: List of integers

  • rz.theta: List of floats

  • END: List of integers

  • rzz.q1: List of integers

  • rzz.q2: List of integers

  • rzz.theta: List of floats

  • rxy.q1: List of integers

  • rxy.theta: List of floats

  • rxy.phi: List of floats

  • measureResults: List of Boolean

  • output: Boolean

Rust Program

The Rust standard library provides UDP functionality (std::net::UdpSocket). An port number must be provided within the Rust source, and is necessarily equivalent to the port specification in the Guppy source. A third-party library, rand, generates a sequence of random number for consumption within Rust source. The method, encode_ops, is called by the main method, to generate a vector of <u8> elements. This vector is sent back to the Guppy program. The port number in the rust and Guppy source are equivalent. The IP address in the rust source is 0.0.0.0 to allows incoming remote connections to the server.

use core::f64;
use std::error::Error;
use std::net::UdpSocket;

use rand::Rng;
use rand::rngs::ThreadRng;

// Quantum operation encodings
const RZ_OP: u64 = 1;
const RXY_OP: u64 = 2;
const RZZ_OP: u64 = 3;
const QUIT_OP: u64 = 0xDEADBEEFDEADBEEF;
const END_RESPONSE: u64 = 0xCAFEBABECAFEBABE;

// Server configuration
const HOST: &str = "0.0.0.0";
const PORT: u64 = 11111;

// Shift lengths for response encoding
const OP_SHIFT: u64 = 32;
const Q1_SHIFT: u64 = 0;
const Q2_SHIFT: u64 = 16;

type Result<T> = std::result::Result<T, Box<dyn Error>>;

fn main() -> Result<()> {
    // Create UDP socket for communication with control system
    let socket = UdpSocket::bind(format!("{HOST}:{PORT}"))?;

    // Initialize RNG for generating random operations
    let mut rng = rand::rng();

    println!("Waiting for requests...");
    let mut stop = false;
    while !stop {
        // Receive message from control system
        let mut buf = [0; 16];
        let (_, src) = socket
            .recv_from(&mut buf)
            .expect("Failed to receive message");
        // Extract round and qubit length from the received message
        let round = u64::from_le_bytes(buf[0..8].try_into().unwrap());
        let nqubits = u64::from_le_bytes(buf[8..16].try_into().unwrap());
        let out = if round >= 20 {
            Vec::from(QUIT_OP.to_le_bytes())
        } else {
            encode_ops(round, nqubits, &mut rng)
        };

        // Send to control system
        if let Err(_e) = socket.send_to(&out, src) {
            // Stop on error
            stop = true;
        }
    }

    Ok(())
}

fn encode_ops(round: u64, nqubits: u64, rng: &mut ThreadRng) -> Vec<u8> {
    let mut out = vec![];
    for _ in 0..(round + 1) {
        let op = rng.random_range(0..RZZ_OP) + 1;
        println!("{}", op);
        println!("{}", op << OP_SHIFT);
        let q1 = rng.random_range(0..nqubits);
        let q2 = (q1 + 1) % nqubits;
        let theta: f64 = rng.random_range(0.0..2.0);
        match op {
            RZ_OP => {
                let enc_op = (op << OP_SHIFT) | (q1 << Q1_SHIFT);
                out.extend(enc_op.to_le_bytes());
                out.extend(theta.to_le_bytes());
                println!("--> rz({q1}, {theta})");
            }
            RXY_OP => {
                let enc_op = (op << OP_SHIFT) | (q1 << Q1_SHIFT);
                let phi: f64 = rng.random_range(0.0..2.0);
                out.extend(enc_op.to_le_bytes());
                out.extend(phi.to_le_bytes());
                out.extend(theta.to_le_bytes());
                println!("--> rxy({q1}, {phi}, {theta})");
            }
            RZZ_OP => {
                let enc_op = (op << OP_SHIFT) | (q1 << Q1_SHIFT) | (q2 << Q2_SHIFT);
                out.extend(enc_op.to_le_bytes());
                out.extend(theta.to_le_bytes());
                println!("--> rzz({q1}, {q2}, {theta})");
            }
            _ => {
                panic!("Invalid random op {op} generated");
            }
        }
    }
    out.extend(END_RESPONSE.to_le_bytes());
    out
}

API Reference