AWS Quantum Technologies Blog

AWS open-sources OQpy to make it easier to write quantum programs in OpenQASM 3

In September 2021, we announced that AWS would be joining the OpenQASM 3 Technical Steering Committee in an effort to establish a consistent, industry-wide approach for describing quantum programs. In that blog post we also shared our plans to help extend the OpenQASM ecosystem to work with hardware being developed at the AWS Center for Quantum Computing. Today, we’re excited to announce that we have released OQpy, an open-source library designed to make it easy to generate OpenQASM 3 programs in Python.

Pulse-level programming of quantum computers

At the AWS Center for Quantum Computing, one of our goals is to develop an error-corrected quantum computer based on superconducting qubits. These devices (and many other qubit modalities) are controlled via shaped radio frequency pulses. However, the original OpenQASM specification only supported quantum programs written at the gate level, and had no way to describe them at the pulse level. To enable this, OpenQASM 3 introduced the notion of calibration grammars, which allow quantum hardware builders to extend the language to support hardware-specific directives via cal and defcal blocks. One such grammar is OpenPulse, which provides the instructions required for pulse-based control of quantum devices.

To help make the OpenPulse grammar specification concrete, we first developed (and open-sourced) a Python reference implementation of its abstract syntax tree (AST) representation. The AST delivers on OpenQASM’s goal of having a shared intermediate representation (IR) that can be emitted by front-end toolchains and consumed by compilers. However, manipulating the AST directly is a less-than-optimal user experience; a more familiar approach for quantum programmers would be to use a builder pattern to iteratively construct an object housing the quantum program (this pattern is used in the Amazon Braket SDKQiskit, and many other quantum programming libraries).

OQpy (“ock-pie”) provides just that — its main interface is a Program class, which exposes methods for manipulating the underlying program state (internally represented as AST nodes). In addition, it provides various context managers for easily expressing control flow and creating cal and defcal blocks. At the AWS Center for Quantum Computing, building OQpy has enabled us to start adopting OpenQASM 3 and OpenPulse as the IR for our in-house quantum software stack. In the future, we hope to use OpenQASM 3 as the foundation for developing the real-time controls needed to implement quantum error correction.

In open-sourcing OQpy as part of the OpenQASM organization on GitHub, we hope that others too can integrate it into their existing quantum software stacks and take advantage of the expressivity and interoperability that OpenQASM 3 offers. Amazon Braket migrated to using OpenQASM as its primary IR earlier this year, and this has enabled Braket customers to tap into the rich ecosystem of open-source libraries that work with OpenQASM. Today, Amazon Braket launched support for pulse-level control via Braket Pulse, which uses OQpy as its foundation for describing pulse programs.

In this blog post, we show how OQpy helped enable today’s launch of Braket Pulse, and how you can join in the effort to make OpenQASM a unified IR for gate-level and pulse-level programming of quantum computers.

Writing a Ramsey interferometry experiment with OQpy

Before we get to Braket Pulse, let’s walk through an example use case of OQpy to better understand the kinds of programs we can write with it. At the AWS Center for Quantum Computing, one of the primary use cases for OQpy is to describe experiments for characterizing the properties of a quantum system. A common and useful experiment for qubit characterization is Ramsey interferometry, which can be used for two purposes: performing a careful measurement of a qubit’s resonant frequency, and for investigating how long a qubit retains its coherence.

Ramsey interferometry is conceptually similar to the Mach-Zehnder interferometer except that what is being interfered is a two-level system (i.e., a qubit) instead of a beam of light. Instead of two beam splitters separated in space, Ramsey interferometry uses two π/2 pulses separated in time. Depending on the phase accumulated by the system between the two pulses, the net effect is to either bring the qubit from the ground state to the excited state, leave the qubit in the ground state, or (for intermediate values of phase) leave the qubit in a partial superposition.

In a typical Ramsey experiment, one varies the length of the delay between the two exciting pulses. As a result, the accumulated phase between the pulses varies, and therefore the final probability of measuring an excited qubit varies. The experimentalist can observe that varying excited state probability and infer the relationship between time and phase. Normally, this relationship is linear and corresponds to the resonant frequency of the qubit.

Experiment data collected from running a Ramsey interferometry experiment.

We’re going to go over the process of creating a Ramsey interferometry experiment in OpenQASM 3 using OQpy. As part of this, we’ll use the OpenPulse grammar to allow this experiment to specify its operation implementations at the calibrated pulse level.

To begin, you can install OQpy and its dependencies via the command pip install oqpy in an environment with Python version 3.7 or greater. With OQpy installed, let’s start by defining the basic sequence in terms of gates and delays on a particular qubit. For now we’ll take a fixed delay length of 1000 nanoseconds (ns), a delay comparable to the length of a few entangling gates in a superconducting qubit system. The basic structure of creating an OQpy program is to first instantiate a new instance of oqpy.Program and then to invoke methods on that instance, which mutate the program by adding instructions to it. Most methods return the instance itself allowing for a method chaining style:

import oqpy
ramsey_prog = oqpy.Program()    # create a new oqpy program
qubit = oqpy.PhysicalQubits[1]  # get physical qubit 1

(ramsey_prog.reset(qubit)       # prepare in ground state
 .gate(qubit, "x90")            # pi/2 pulse (90° rotation about the x-axis)
 .delay(1000e-9, qubit)         # 1000 ns
 .gate(qubit, "x90")            # pi/2 pulse (90° rotation about the x-axis)
 .measure(qubit))               # final measurement

Now let’s transform this program into a sweep over the delay length by using oqpy.ForIn. Notice that this is different than a Python for loop. We don’t want the for loop to be run in Python; instead, we want to generate a for loop represented in OpenQASM. We can then choose a specific range of delays, and a general guideline is to take the maximum as a time comparable to the coherence time, and to take steps of size inverse to the desired precision in the frequency. For this example, let’s scan from 0 ns to 10000 ns with steps of size 100 ns:

import oqpy
ramsey_prog = oqpy.Program()                    # create a new oqpy program
qubit = oqpy.PhysicalQubits[1]                  # get physical qubit 1
delay_time = oqpy.DurationVar(0, "delay_time")  # initialize a duration variable

# Loop over delays
with oqpy.ForIn(ramsey_prog, range(101), "delay_index"):
    (ramsey_prog.reset(qubit)                   # prepare in ground state
     .gate(qubit, "x90")                        # pi/2 pulse
     .delay(delay_time, qubit)                  # variable delay
     .gate(qubit, "x90")                        # pi/2 pulse
     .measure(qubit)                            # final measurement
     .increment(delay_time, 100e-9))            # increase delay by 100 ns

Now if we want to gather statistics on the output probabilities, we may need to repeat the experiment several times. We can do that by adding another loop, being careful to reset the delay_time variable:

import oqpy
ramsey_prog = oqpy.Program()                    # create a new oqpy program
qubit = oqpy.PhysicalQubits[1]                  # get physical qubit 1
delay_time = oqpy.DurationVar(0, "delay_time")  # initialize a duration

# Loop over shots (i.e., repetitions)
with oqpy.ForIn(ramsey_prog, range(100), "shot_index"):
    ramsey_prog.set(delay_time, 0)              # reset delay time to zero
    # Loop over delays
    with oqpy.ForIn(ramsey_prog, range(101), "delay_index"):
        (ramsey_prog.reset(qubit)               # prepare in ground state
         .gate(qubit, "x90")                    # pi/2 pulse
         .delay(delay_time, qubit)              # variable delay
         .gate(qubit, "x90")                    # pi/2 pulse
         .measure(qubit)                        # final measurement
         .increment(delay_time, 100e-9))        # increase delay by 100 ns

So far, we have created a description of a Ramsey experiment at the gate level. In the code snippet we used the gate name “x90”, and so if we wanted to understand this circuit at the unitary level, we would need to provide a gate definition. But, what if we want to specify more concretely how to physically implement the gate operations used above? We can use the OpenPulse extensions to OpenQASM in order to describe where and how time varying signals (waveforms) are sent in order to enact a certain operation. In particular, here we need to implement the resetmeasure, and x90 operations. We can do this using oqpy.defcal:

import oqpy
defcals_prog = oqpy.Program()   # create a new oqpy program
qubit = oqpy.PhysicalQubits[1]  # get physical qubit 1

# Declare frames: transmon driving frame and readout receive/transmit frames
xy_frame = oqpy.FrameVar(oqpy.PortVar("dac0"), 6.431e9, name="xy_frame")
rx_frame = oqpy.FrameVar(oqpy.PortVar("adc0"), 5.752e9, name="rx_frame")
tx_frame = oqpy.FrameVar(oqpy.PortVar("dac1"), 5.752e9, name="tx_frame")

# Declare the type of waveform we are working with
# It is up to the backend receiving the openqasm to specify what waveforms are allowed
constant_waveform = oqpy.declare_waveform_generator(
    "constant", [("length", oqpy.duration), ("amplitude", oqpy.float64)],
)
gaussian_waveform = oqpy.declare_waveform_generator(
    "gaussian", [("length", oqpy.duration), ("sigma", oqpy.duration), ("amplitude", oqpy.float64)],
)

# Provide gate / operation definitions as defcals
with oqpy.defcal(defcals_prog, qubit, "reset"):
    defcals_prog.delay(1e-3)  # reset to ground state by waiting 1 millisecond

with oqpy.defcal(defcals_prog, qubit, "measure"):
    defcals_prog.play(tx_frame, constant_waveform(2.4e-6, 0.2))
    defcals_prog.capture(rx_frame, constant_waveform(2.4e-6, 1))

with oqpy.defcal(defcals_prog, qubit, "x90"):
    defcals_prog.play(xy_frame, gaussian_waveform(32e-9, 8e-9, 0.2063))

Finally, leveraging the fact that we can concatenate Program objects using the + operator, we can combine the program containing the Ramsey experiment definition with the program containing the defcal blocks into a single “full” program. Continuing from the prior snippets that defined defcals_prog and ramsey_prog, we can simply write full_prog = defcals_prog + ramsey_prog. With this full program in hand, we can generate its corresponding OpenQASM 3 text using the to_qasm() method. Evaluating print(full_prog.to_qasm(encal_declarations=True)) gives the following:

OPENQASM 3.0;
defcalgrammar "openpulse";
cal {
    extern constant(duration, float[64]) -> waveform;
    extern gaussian(duration, duration, float[64]) -> waveform;
    port dac1;
    port adc0;
    port dac0;
    frame tx_frame = newframe(dac1, 5752000000.0, 0);
    frame rx_frame = newframe(adc0, 5752000000.0, 0);
    frame xy_frame = newframe(dac0, 6431000000.0, 0);
}
duration delay_time = 0.0ns;
defcal reset $1 {
    delay[1000000.0ns];
}
defcal measure $1 {
    play(tx_frame, constant(2400.0ns, 0.2));
    capture(rx_frame, constant(2400.0ns, 1));
}
defcal x90 $1 {
    play(xy_frame, gaussian(32.0ns, 8.0ns, 0.2063));
}
for int shot_index in [0:99] {
    delay_time = 0.0ns;
    for int delay_index in [0:100] {
        reset $1;
        x90 $1;
        delay[delay_time] $1;
        x90 $1;
        measure $1;
        delay_time += 100.0ns;
    }
}

In the OpenQASM output above, we can see the following features:

  1. A declaration of the OpenQASM version number (3.0).
  2. A declaration of the calibration grammar used (“openpulse”).
  3. Declarations of any extern functions used (here just the waveforms, e.g. constant).
  4. Declarations of any variables used in the program (e.g. delay_time).
  5. cal block, which is used to contain expressions which are valid in OpenPulse, but not OpenQASM. This block was automatically added because we passed encal_declarations=True when generating the text.
  6. The body of the full program, including the defcal statements and Ramsey experiment definition.

Now that we have a better understanding of what is possible with OQpy, we can talk about how we used it to help enable today’s launch of Braket Pulse.

Enabling pulse-level control on Amazon Braket

Braket Pulse is a feature of Amazon Braket that lets you run quantum programs at the pulse level against different quantum processing units. Braket Pulse extends the Amazon Braket Python SDK by offering a new interface, the PulseSequence class, which allows you to manipulate or visualize properties of the pulse sequences you’d like to run. The PulseSequence class leans on the construction of an OQpy Program and its utilities to manipulate the program state as seen above. Consequently, as you build up a pulse sequence with Braket Pulse, you will also be constructing its AST representation, which is readily available in the associated OQpy Program using the to_ast() method. Later, we will demonstrate how direct access to the AST representation of a pulse sequence can be a powerful tool.

As customers desire greater flexibility and control over quantum processors, capabilities for pulse-level manipulation and visualization will be important in enabling the exploration of quantum error mitigation techniques like zero-noise extrapolation (ZNE). We will use this technique to illustrate how Braket Pulse uses the AST representation generated by OQpy to manipulate a pulse sequence. The ZNE algorithm enhances computational accuracy by extrapolating the results of repeated computations at varying levels of noise. To demonstrate, we can use Braket Pulse’s library of predefined waveforms and amplify the noise of each measurement by successively stretching the pulse lengths.

In order to carry out this series of computations, we will take advantage of the Amazon Braket SDK’s use of FreeParameter objects (or, more generally, FreeParameterExpression objects) to parameterize gate-level circuits, and extend its use to pulse sequences. Parameterizing pulse sequences in this way allows us to re-use the same pulse sequence “template” with different input parameters, allowing us to succinctly implement a sweep over a range of values. First, we define our free parameter:

from braket.parametric import FreeParameter
length = FreeParameter("length")

We can then use this free parameter length to define a gaussian waveform:

from braket.pulse.waveforms import GaussianWaveform
gaussian_wfm = GaussianWaveform(length=length, sigma=25e-9, amplitude=0.1)

While Braket Pulse allows free parameters to be used upon construction of a PulseSequence, these parameters must be substituted for numerical values prior to runtime:

import numpy as np
pulse_sequence = ...  # pulse sequence of interest
lengths = np.linspace(1e-7, 5e-7, 100)
zne_sequences = [pulse_sequence(length=length) for length in lengths]

Behind the scenes, implementing this substitution for any number of parameter values is easy to do as it just involves walking the associated OQpy Program’s AST representation and replacing any nodes that represent a FreeParameterExpression with one that eventually only contains a numerical value. In this way, the PulseSequence can delay the numerical definition of these parameters until they are needed during execution as the entire program state is always captured in its AST representation.

Access to the AST representation is also particularly advantageous in visualizing a time-dependent pulse sequence across various frames. Using Braket Pulse, we can generate time series data corresponding to the amplitude, frequency, and phase of a waveform on a particular frame from one of the ZNE pulse sequences:

data = zne_sequences[0].to_time_trace()

Generating the time series data of the pulse sequence, again, takes advantage of simply walking the AST. The to_time_trace() function walks the AST to build up the time-dependent output signal amplitude, frequency, and phase for each frame. For example, visiting an AST node that represents playing a waveform on a frame will record the applied waveform’s amplitude for each time step along with the frequency and phase of the frame at that time. As timing information across frames can be crucial in understanding complex pulse sequences, this data can then be used for visualization using any standard plotting library (e.g., matplotlib.pyplot).

Finally, it is straightforward to run these quantum programs on Amazon Braket. Upon creating a quantum task for running a PulseSequence, its AST representation will be converted to OpenQASM IR and sent to the Braket service:

from braket.aws import AwsDevice
device = AwsDevice("arn:aws:braket:us-west-1::device/qpu/rigetti/Aspen-M-2")
batch = device.run_batch(zne_sequences, shots=100)

Contributing to OQpy and the OpenQASM community

In this blog post, you saw an example of what is possible using OQpy and OpenQASM 3, and how using these tools helped enable support for pulse-level control on Amazon Braket. Now that OQpy is open source, you too can start integrating OpenQASM 3 and OpenPulse into your Python-based quantum software projects. There is still much to do to develop a unified IR for quantum computing, and so we welcome contributions to OQpy and the OpenQASM 3 spec. If you’re interested, check out the OpenQASM organization on GitHub, and join us in the effort to program the next generation of quantum computers.

Building OQpy and the OpenPulse AST was a collective effort, involving many additional contributors from both AWS and IBM. In particular, we’d like to acknowledge the work of Jake Lishman from IBM, and the work of Kshitij Chhabra, Yunong Shi, and Prasahnt Sivarajah from AWS.