Custom gate implementations#
QuantumOp#
Quantum gates are represented by QuantumOp
data classes, containing the required
metadata to define the gate. A QuantumOp is identified by its name
, and
arity
defines number of locus components the operation acts on. For example,
the PRX operation (Phased X Rotation) is a single-qubit operation, so its arity is 1, whereas the CZ (Controlled-Z) gate
acts on two qubits, having arity 2. Arity 0 has a special meaning that the operation in question can act on any number
of components (for example Barrier
).
The attribute symmetric
defines whether the effect of the quantum operation
is symmetric with respect to changing the order of its locus components. As an example, the CZ gate is a symmetric
two-qubit gate, whereas CNOT (Controlled-NOT) is not symmetric.
Some quantum operations are defined as “functions”, taking one or more parameters to define the effect. These
arguments are stored in the attribute params
. As an example, the PRX gate
takes two arguments, angle
(the rotation angle with respect to the z-axis of the Bloch sphere), and phase
(the rotation phase in the rotating frame). On the other hand, many operations do not require any parameters, in
which case this field is an empty tuple (e.g. the CZ gate).
A QuantumOp has unambiguous definition in terms of its intended effect on the computational subspace of the
QPU component, but it can be implemented in various ways. Each implementation is represented as a
GateImplementation
subclass. A QuantumOp stores its known implementations in the
field implementations
. Note that even though
QuantumOp
is a frozen data class, the implementations dictionary can be modified, e.g.
to add new implementations or to change their order (usually programmatically by some client procedure, but nothing as
such prevents the user from manipulating the contents manually). The default implementation is how the user prefers
to implement the operation unless otherwise specified (in effect, this is what will get called in most cases the
operation is invoked). In the implementations dict, the default implementation is defined as the first entry.
QuantumOp contains helpful methods that allow setting and returning the default implementation for specific cases:
set_default_implementation()
,
get_default_implementation_for_locus()
, and
set_default_implementation_for_locus()
.
The attribute unitary
stores a function that can be used to get the unitary
matrix representing the quantum operation in question. The unitary function must have the same arguments
as defined in params
, such that for each collection of these parameters it
gives the associated unitary matrix. Note that not all QuantumOps necessarily even represent a unitary gate (e.g.
the measure operation is not one), or the exact form of the unitary matrix might not be known. In these cases, the
field can be left None
. The unitary does not need to be defined for most of the basic usage of a QuantumOp, but certain
algorithmic methods (e.g. some implementations of Randomized Benchmarking) may require the unitary matrices to be known,
and such operations that do not define the getter function cannot then be used in these contexts.
For more information, see the API docs of QuantumOp
for the full list of fields needed
to define a quantum operation and the available class methods.
Custom gate implementations#
GateImplementation class#
While QuantumOp
represents an abstract quantum operation, its implementations contain
the concrete logic of how to make that operation happen using QC hardware. Gate implementations are subclasses of
GateImplementation
. In this section, the main features of that class are
introduced (for a full list of class methods see the API docs), with the emphasis being on how to create your own
gate implementations.
Starting with __init__()
, it is important to note that the init
methods of all gate implementations must have the exact same signature:
def __init__(
self,
parent: QuantumOp,
name: str,
locus: tuple[str,...],
calibration_data: OILCalibrationData,
builder: ScheduleBuilder
):
Here, parent
is the QuantumOp
this gate implementation implements, and name
is the implementation’s name in
the dictionary implementations
. locus
is the set of (usually logical) components
the QuantumOp acts on (the size of the locus must be consistent with the parent
’s
arity
), while calibration_data
gives the required calibration data values
for this implementation and locus
(can be empty in case the implementation needs no calibration data). Finally,
The implementations store a reference to the ScheduleBuilder
that created it. This is
because GateImplementations are practically never created manually by calling the init method itself. Instead, one
needs a builder and uses get_implementation()
.
The responsibility of the init method is to (at least) store the calibration_data
provided from the builder for
further use, but in many cases, one might want to create some intermediate objects like pulses or instructions from
that calibration data already at this point. Note that ScheduleBuilder caches its GateImplementations per each locus and
calibration_data
, so as long as the calibration is not changed, the code in init will be called just once per locus.
GateImplementations are Callables, i.e. they implement the __call__ method. It should take as its arguments at least
the QuantumOpt parameters defined for the parent
in params
, but in
addition it may have optional extra arguments. The call method should return a TimeBox
object
that contains the pulses, instructions and other logic required to implement the quantum operation in question. The
typical usage of gate implementations then looks like this (See Using ScheduleBuilder and Pulse timing for more
info on scheduling and the ScheduleBuilder):
# this initializes the _default implementation_ class of PRX for QB1
default_prx_QB1 = builder.get_implementation("prx", ("QB1",))
# this initializes a specific PRX implementation for QB1, not necessarily the default
special_prx_QB1 = builder.get_implementation("prx", ("QB1",), impl_name="my_special_PRX")
# calling the implementation with the QuantumOp param values creates a TimeBox that can be then scheduled with
# the normal scheduling logic
default_box = default_prx_QB1(angle=np.pi, phase=np.pi/2)
# the initialization of the impl class and the call can of course be also chained together like this:
default_cz_box = builder..get_implementation("cz", ("QB1", "QB2"))() # CZ has no QuantumOp params!
The base class __call__()
method does automatic TimeBox caching based
on the unique values of the call arguments, and in many cases, one does not want to reimplement this caching in their own
implementations. For this reason, there is the method _call
which contains just the pure TimeBox creation logic.
Developers can choose to override that instead of __call__
in cases where the call args are hashable python types,
and then they can utilize the default caching of TimeBoxes from the base class.
When writing a GateImplementation, a developer should consider what parts of the logic should go to the class init and
what to the __call__
or _call
method. A general rule of thumb would be that any parts that can be precomputed
and do not depend on the call arguments can go to init, and the rest to call.
As an example, let’s go through a simple PRX _call
method (note that the default PRX implementations do not
use this exact call method, as this is a simplified example for educational purposes):
def _call(self, angle: float, phase: float = 0.0) -> TimeBox:
instruction = IQPulse( # create the Instruction using the calibration data
scale_i=angle, # pulse amplitudes from the inputted angle
scale_q=angle,
wave_i=TruncatedGaussian(**self.calibration_data), # pulse i waveform (normalized to one)
wave_q=TruncatedGaussianDerivative(**self.calibration_data), # pulse q waveform (normalized to one)
phase=phase,
)
# create the TimeBox
return TimeBox.atomic(
schedule=Schedule({self.channel: [instruction]}), # atomic Schedule created from the pulse
locus_components=self.locus,
label=f"{self.__class__.__name__} on {self.locus}", # (optional) label for identifying the TimeBox
)
Here, we first create an IQPulse
object which is a low-level Instruction. IQPulse
means a “complex pulse” which has two orthogonal components i and q – this what drive pulses look like in general. In
this simplified example, we have hardcoded the pulse waveforms into TruncatedGaussian
and
TruncatedGaussianDerivative
for the i and q components, respectively (this is a DRAG implementation, so the
q component is the derivative of the i component). The waveforms are parametrized by the calibration_data
for the
given locus
(see the next subsection for more info on Waveforms and calibration data). The PRX QuantumOp param
angle
scales the pulse amplitude linearly (the waveforms are normalized to one), and the param phase
defines relative
phase modulation. Then the returned TimeBox is created out of the instruction
. Note that
since we override _call
here, instead of __call__
, so this implementation would utilize the default base class
caching such that the TimeBoxes are cached per unique values of (angle, phase)
.
Another important concept is a the so called locus mapping of a gate implementation. Locus mappings define on which
loci, i.e. groups of components, a given implementation can be defined. They are used to relay the information which
loci are supported to a client application (e.g. EXA). In addition, the gate implementation itself can programmatically
use this information self.builder.chip_topology
.
For example, a PRX can be defined on all single components that are connected to a drive line, and CZ can be defined on
connected pairs of qubits. Locus mappings live in ScheduleBuilder.chip_topology
which is a
ChipTopology
object. Locus mapping is a dict whose keys are the loci
(tuple[str, ...]
keys denote asymmetric loci where the order of the components matter, and frozenset[str]
type
loci denote symmetric ones), and the values are groups of components, typed tuple[str, ...]
, where each locus can be
mapped with some additional components that are needed for the operation of the implementation. For example, some CZ
implementation that tries to correct for crosstalk could map the non-locus components that see this crosstalk here.
The values of the dict can be left empty or just replicate the key components in case such extra information is not
needed.
GateImplementations can define their locus mappings via
get_custom_locus_mapping()
or if a client application already
adds the mapping, we can just return its name via get_locus_mapping_name()
.
If neither of these methods are overridden in a GateImplementation class, the default behaviour will be such that an
arity==1
loci will be assumed to use the mapping where all single qubits are the keys, and arity==2
loci the
(symmetric) mapping where the keys are all pairs of connected qubits. For other arities there is no default behaviour,
so it is then mandatory to define the mapping explicitly using the aforementioned methods.
Instructions, Waveforms and calibration data#
In order to implement most QuantumOps, one has to physically alter the state of the QPU. This is typically done by playing
specified and correctly calibrated pulses via the control electronics (this applies to all typical logical gates such as
e.g. PRX or CZ – non-physcial metaoperations such as Barrier are an exception). In defining these pulses, there are two
levels of abstractions: Waveform
and Instruction
.
Waveform represents the physical form of the control pulse, typically normalized to the interval [-1.0, 1.0]
. The
Each Waveform
subclass can define any number of waveform parameters as class
attributes, which can be used to programmatically define the waveform. For example, a Gaussian could be defined in terms
of the average mu
and spread sigma
. A Waveform class then essentially contains just the parameters
and a recipe for computing the samples as an np.ndarray
. As an example, here is how one writes the Waveform class
for Gaussian
:
class Gaussian(Waveform):
# waveform parameters as class attributes
sigma: float
mu: float = 0.0
def _sample(self, sample_coords: np.ndarray) -> np.ndarray:
offset_coords = sample_coords - self.center_offset
return np.exp(-0.5 * (offset_coords / self.sigma) ** 2)
The Instructions RealPulse
and
IQPulse
allow handling the amplitudes (via the attribute scale
) without
having to resample the waveform for every different amplitude value. However, one can always choose to include
the amplitude into the sampling and then use scale=1
.
The waveform parameters (like sigma
in the above Gaussian) typically require calibration when the Waveform is used
in a quantum gate. However, the GateImplementation usually has other calibrated parameters as well defined in the
implementation itself. As an example, here are the implementation-level parameters of the default PRX implementation,
defined as class attribute:
parameters: dict[str, Parameter | Setting] = {
"duration": Parameter("", "pi pulse duration", "s"),
"amplitude_i": Parameter("", "pi pulse I channel amplitude", ""),
"amplitude_q": Parameter("", "pi pulse Q channel amplitude", ""),
}
Note the amplitudes are defined here on this level, since the default PRX uses normalized Waveforms and factors in the
amplitudes via scale
. In these parameters, the unit is not just metadata. The control electronics understand time
in terms of samples and their sample rate, while human users typically want to input seconds instead of doing the sample
conversion manually. For this reason, there is logic that converts anything that has the unit "s"
into samples.
Similarly, parameters with "Hz"
units are converted to 1/sample
. For the Waveform parameters, the same logic
applies, but by default it is assumed that all parameters are time-like and this converted from seconds to samples.
If some Waveform parameters needs to be made unitless or e.g. frequency-like (with "Hz"
units), it can be achieved
with the method non_timelike_attributes()
:
def non_timelike_attributes() -> dict[str, str]:
return {
"frequency": "Hz",
"scalar_coeffiecient", ""
}
In the above dict, the keys should be the attribute names and values their units.
More base classes#
To make creating new GateImplementations more comfortable, there are additional base classes on top of
GateImplementation
itself.
CompositeGate
allows quick implementation of gates in terms of other gates,
using a similar syntax as with creating/scheduling several TimeBoxes together (see Using ScheduleBuilder). At it
simplest, a ComposteGate is just the _call method:
class CompositeHadamard(CompositeGate):
"""Composite Hadamard that uses PRX"""
registered_gates = ["prx"]
# registering member gates is not mandatory, but allows calibrating them specifically inside _this_ composite
def _call(self) -> TimeBox:
member_prx = self.build("prx", self.locus)
return member_prx(np.pi / 2, np.pi / 2 ) + member_prx(np.pi, 0.0)
Here, one could use also builder.get_implementation
instead of
build()
, but the latter allows calibrating the member gates
case specifically for this composite if they are first registered via
registered_gates
(in this case, there is
just one member, PRX).
Creating new implementations for the PRX, CZ and Measure gates often means just coming up with new waveforms for the
control pulses. If this is the case, there are helpful base classes that make those implementations into oneliners
(outside of defining the Waveforms themselves): PRX_CustomWaveforms
,
FluxPulseGate
, and Measure_CustomWaveforms
. Using these
base classes at its simplest looks like this:
class PRX_MyCoolWaveforms(PRX_CustomWaveForms, wave_i=CoolWaveformI, wave_q=CoolWaveformQ):
"""PRX with my cool custom waveforms for the i and q drive pulse components"""
class CZ_MyCoolWaveforms(FluxPulseGate, coupler_wave=CoolCouplerWaveform, qubit_wave=CoolQubitWaveform):
"""CZ with my cool qubit and coupler flux pulse waveforms"""
class Measure_MyCoolWaveforms(Measure_CustomWaveforms, wave_i=CoolWaveformI, wave_q=CoolWaveformQ):
"""Measure with my cool custom waveforms for the i and q probe pulse components"""
All of these classes automatically include the associated Waveform parameters into the calibration parameters of
the implementation itself. There is also a general base class for any gate that implements a single IQPulse
(both PRX_CustomWaveForms and Measure_MyCoolWaveforms actually inherit from it), regardless of the context:
CustomIQWaveforms
.
Registering gates and implementations#
Gate definitions (i.e. QuantumOps) are stored in ScheduleBuilder
’s attribute
op_table
. When the builder is created, the op_table
comes preloaded with the all the basic QuantumOps needed for
typical circuit execution and their default implementations. These include e.g. the PRX gate, the CZ gate, the measure
operation, the conditional prx operation, the reset operation, and the barrier operation.
In order to add custom operations, there is a helpful function register_implementation()
that
in addition to adding new implementations allows one to add altogether new quantum operations.
As an example here is a snippet that adds the CNOT gate, and its implementation, into an existing builder:
cnot_matrix = np.array([[1, 0, 0, 0], # the unitary is not strictly necessary for basic use, but since
[0, 1, 0, 0], # we do know its form for CNOT, why not add it
[0, 0, 0, 1],
[0, 0, 1, 0]], dtype=complex)
cnot_op = QuantumOp(name="cnot", arity=2, symmetric=False, unitary=lambda: cnot_matrix)
register_implementation(
operations=my_builder.op_table,
gate_name="cnot",
impl_name="my_cnot_impl",
impl_class=MyCNotClass,
quantum_op_specs=cnot_op
)
Here, the CNOT implementation MyCNotClass
needs to be of course defined first (a QuantumOp always needs at least one
implementation).
Note: The end user cannot modify the canonical mapping (defined in iqm-pulse) between implementation_name
and
implementation_class
.
Note that often ScheduleBuilder
is created and operated by some client application, and the same application usually
has its own interface for adding/manipulating QuantumOps. However, if the user has access to the builder object, the
above method will always work.