QPU and QPU Topology¶
In a typical experiment, the quantum elements that we would like to control are on a quantum processing unit (QPU) with a given topology, which describes how the quantum elements are connected to each other. Understanding the properties of the QPU is important in a variety of situations, such as applying multi-qubit gates or compensating for crosstalk.
In LabOne Q, we use the dsl.QPU class to organize this information, which takes the given quantum elements and quantum operations as input arguments. The default QPU topology is then constructed on initialization from these quantum elements and is accessible via the topology attribute. Additional connections in the topology can be made at a later stage.
In this tutorial, we will go through the basic properties of the QPU class, including: how to initialize a QPU, how to modify its topology, and how this may be applied in the context of a real experiment.
Imports¶
import attrs
from laboneq.dsl.quantum import (
QPU,
Transmon,
QuantumOperations,
QuantumParameters,
QuantumElement,
)
from laboneq.simple import *
Define the quantum elements and operations¶
Following from the previous tutorials, we will demonstrate the functionality of the QPU class using Transmon qubits. We start by defining an example qubit template together with a set of example qubit operations.
def qubit_template(i):
return Transmon(
uid=f"q{i}",
signals={
"drive": f"q{i}/drive",
"measure": f"q{i}/measure",
"acquire": f"q{i}/acquire",
"flux": f"q{i}/flux",
},
parameters={"resonance_frequency_ge": i},
)
class TransmonOperations(QuantumOperations):
QUBIT_TYPES = Transmon
@dsl.quantum_operation
def flux_pulse(
self,
q: Transmon,
amplitude: float | SweepParameter,
length: float | SweepParameter,
phase: float = 0.0,
) -> None:
pulse_parameters = {"function": "gaussian_square", "sigma": 0.42}
flux_pulse = dsl.create_pulse(pulse_parameters, name="flux_pulse")
dsl.play(
q.signals["flux"],
amplitude=amplitude,
length=length,
phase=phase,
pulse=flux_pulse,
)
In addition to qubits, we may also have other quantum elements on the QPU. For example, we define a custom Coupler quantum element below, together with its associated CouplerOperations.
@attrs.define(kw_only=True)
class CouplerParameters(QuantumParameters):
amplitude: float = 0.5
length: float = 100e-9
pulse: dict = attrs.field(factory=lambda: {"function": "gaussian_square"})
class Coupler(QuantumElement):
PARAMETERS_TYPE = CouplerParameters
REQUIRED_SIGNALS = ("flux",)
class CouplerOperations(QuantumOperations):
QUBIT_TYPES = Coupler
@dsl.quantum_operation
def flux_pulse(
self,
q: Coupler,
amplitude: float | SweepParameter,
length: float | SweepParameter,
phase: float = 0.0,
) -> None:
pulse_parameters = {"function": "gaussian_square", "sigma": 0.5}
flux_pulse = dsl.create_pulse(pulse_parameters, name="flux_pulse")
dsl.play(
q.signals["flux"],
amplitude=amplitude,
length=length,
phase=phase,
pulse=flux_pulse,
)
c0 = Coupler(
uid="c0",
signals={"flux": "c0/flux"},
)
Define the QPU¶
A QPU object can be defined from a single quantum element, a sequence of quantum elements, or a dictionary of quantum element groups, together with a subclass, or list, of QuantumOperations. For example, we can define a QPU simply using a list of quantum elements, as shown below.
If a list of quantum operations is given, these are combined into a single class. For further details, please see the tutorial on combining quantum operations.
quantum_element_list = [qubit_template(i) for i in range(2)] + [c0]
qpu = QPU(
quantum_elements=quantum_element_list,
quantum_operations=[TransmonOperations, CouplerOperations],
)
qpu
In this case, we can see that we have three quantum elements on our QPU with UIDs: q0, q1, c0. This corresponds to two transmon qubits and one tunable coupler in the experiment. We can access these quantum elements directly from the QPU by UID, slice, or subclass.
qpu["q0"] # returns a single quantum element by UID
qpu[["q0", "q1"]] # returns a list of quantum elements by UID
qpu[:2] # returns the first two quantum elements by slice
qpu[Transmon] # returns the quantum elements of a given type
The set of quantum operations in qpu contains the combined sets TransmonOperations and CouplerOperations. Both of these contain a single operation called flux_pulse:
qpu.quantum_operations.keys()
The qpu.quantum_operations contains only one flux_pulse operation. However, the operation flux_pulse is a MultiMethod, which calls the correct operation corresponding to the quantum element that is passed when the operation is called. Below, you can see that the created flux_pulse Section is different when the operation is called with an instance of Transmon and with an instance of Coupler. To learn more about the MultiMethod functionality, check out the "Combining quantum operations" section in our tutorial on LabOne Q Quantum Operations.
qpu.quantum_operations.flux_pulse(qpu["q0"], 1, 50e-9)
qpu.quantum_operations.flux_pulse(qpu["c0"], 1, 50e-9)
If there are multiple kinds of quantum elements present in the QPU, then it may be useful to categorise them into groups. The advantage of this is that we can then conveniently retrieve these custom groups as attributes of qpu.groups. The behaviour of the QPU is otherwise unaffected.
quantum_element_dict = {
"qubits": [qubit_template(i) for i in range(2)],
"couplers": [c0],
}
qpu = QPU(
quantum_elements=quantum_element_dict,
quantum_operations=[TransmonOperations, CouplerOperations],
)
qpu.groups.qubits
qpu.groups.couplers
The instructions that our QPU supports are the TransmonOperations that we defined above. There are no connections between our quantum elements by default and so there are currently no edges in the topology attribute.
Define the QPU topology¶
By default, all of the quantum elements on the QPU are initialized as disconnected nodes in the QPU topology. We can check this by plotting the initial QPU topology graph with disconnected=True. Note that the quantum_elements argument for QPU is a complete list of all the quantum elements present on the QPU and therefore, nodes cannot be added or removed from the topology after the QPU has been defined.
qpu.topology.plot(disconnected=True);
Nodes¶
The information about the nodes can be looked up using the nodes and node_keys iterators. The nodes iterator generates the quantum elements at the nodes, and the node_keys iterator generates the UIDs of the quantum_elements at the nodes.
for node in qpu.topology.nodes():
print(node)
for node_key in qpu.topology.node_keys():
print(node_key)
It is also possible to retrieve the information on a specific node in the graph using the get_node method.
qpu.topology.get_node("q0")
The node retrieval methods in the QPUTopology class are provided for completeness. Accessing the nodes in QPUTopology is discouraged, in favour of equivalent methods in the QPU class.
For example, we recommend using qpu["q0"] instead of qpu.topology.get_node("q0").
Edges¶
Since the nodes represent the complete set of quantum elements on the QPU, and therefore cannot be changed after the QPU is defined, modifications to the QPU topology come in the form of adding and removing edges. An edge is a directed connection between two nodes. Optionally, an edge may also have its own set of parameters and/or its own associated quantum element.
Since there may be multiple edges between two nodes on the QPU, we provide each edge with a user-defined string called a tag. In this way, an edge may be accessed via the tuple (tag, source_node, target_node), where tag is a user-defined string, source_node is the UID of the source node, and target_node is the UID of the target node.
Here, we will look at a few examples to demonstrate how this works. We start by adding a single edge between nodes 0 and 1. The edge appears on the graph as an arrow going from the source to the target node. The edge tag is labeled on the arrow. Analogously, edges may be removed using the remove_edge method.
qpu.topology.add_edge("empty", "q0", "q1")
qpu.topology.plot();
In this fashion, we can continue to add edges to the graph until the topology of the QPU is accurately described. For example, we can add an additional edge from node 0 to node 1, and we can add an edge in the opposite direction, from node 1 to node 0. Each edge may also have a set of edge parameters and its own quantum element. In the example below, we add the coupler c0 to the edge going from q0 to q1. For clarity, the edge quantum element UID is printed next to the edge tag.
qpu.topology.add_edge("coupler", "q0", "q1", quantum_element="c0")
qpu.topology.add_edge("empty", "q1", "q0")
qpu.topology.plot();
Similar to the nodes, information about the edges may be looked up using the edges and edge_keys iterators. The edges iterator generates the edges in the graph, which are TopologyEdge objects, and the edge_keys iterator generates the keys of the edges, which are the (tag, source_node, target_node) tuples.
for edges in qpu.topology.edges():
print(edges)
for edge_key in qpu.topology.edge_keys():
print(edge_key)
It is also possible to retrieve the information on a particular edge directly from the QPU topology.
qpu.topology["coupler", "q0", "q1"]
Alternatively, we can retrieve information on multiple edges by replacing one or more of the edge key elements with null slices. For example, we can list all of the outgoing edges from "q0".
qpu.topology[:, "q0", :]
To improve the plot readability, we can fix the positions of the quantum elements, set an equal aspect ratio, and omit the edge tags.
qpu.topology.plot(
fixed_pos={"q0": (0, 0), "q1": (1, 0)}, equal_aspect=True, show_tags=False
);
We can also check and filter the list of neighboring nodes using the neighbors method. Using this, we can check for example, whether all qubits are connected before performing a quantum operation.
qpu.topology.neighbors("q0")
Edge parameters¶
We have seen above that the edges are directional (tag, source_node, target_node). This feature together with the ability to attach parameters to an edge is useful for defining directional two-qubit gates, such as iSWAP_q0_q1 and iSWAP_q1_q0. Let's see how we can do this using edge parameters.
In the example below, we pass a custom IswapParameters class to the add_edge method.
For further instructions on how to define your own subclass of QuantumParameters, please see the Quantum Elements tutorial.
# Define parameters
def default_control_pulse():
return {
"function": "gaussian_square",
"amplitude": 0.9,
}
def default_coupler_pulse():
return {
"function": "const",
"amplitude": 0.9,
}
@attrs.define(kw_only=True)
class IswapParameters(QuantumParameters):
control_pulse: dict = attrs.field(factory=default_control_pulse)
coupler_pulse: dict = attrs.field(factory=default_coupler_pulse)
length: float = 100e-9
Next, we create two new edges that are both called iswap and have the coupler c0 as a quantum element. One edge is directed from q0 to q1 and the other from q1 to q0. We add the IswapParameters to both edges:
qpu.topology.add_edge(
"iswap", "q0", "q1", quantum_element=c0, parameters=IswapParameters()
)
qpu.topology.add_edge(
"iswap", "q1", "q0", quantum_element=c0, parameters=IswapParameters()
)
qpu.topology.plot();
We can now set different values for the parameters of the two edges, corresponding to the different implementations of those two gates:
qpu.topology["iswap", "q0", "q1"].parameters.control_pulse["amplitude"] = 0.25
qpu.topology["iswap", "q0", "q1"].parameters.coupler_pulse["amplitude"] = 0.25
qpu.topology["iswap", "q1", "q0"].parameters.control_pulse["amplitude"] = 0.5
qpu.topology["iswap", "q1", "q0"].parameters.coupler_pulse["amplitude"] = 0.5
qpu.topology["iswap", "q0", "q1"]
qpu.topology["iswap", "q1", "q0"]
The parameters for the quantum elements and/or topology edges may also be modified at a later stage using the QPU.update method. For example, if we would like to set the iswap edge amplitudes in the example above both to 0.5, but the amplitude of the coupler to 1, we may write:
new_parameters = {
("iswap", "q0", "q1"): {
"control_pulse": {"function": "gaussian_square", "amplitude": 0.5},
"coupler_pulse": {"function": "const", "amplitude": 0.5},
},
"c0": {"amplitude": 1},
}
qpu.update(new_parameters)
qpu.topology["iswap", "q0", "q1"]
qpu["c0"]
Using the QPU topology with quantum operations¶
When working with several quantum elements in a QPU, keeping track of connections and multi-qubit gate parameters can quickly become overwhelming. The topology attribute alleviates both of these common pain-points. In the previous section, we reviewed how one can use the topology attribute to easily track passive and active connections on the QPU, and we saw how the edge parameters allow one to efficiently store and recall gate parameters associated with the quantum elements at the nodes. In this section, we demonstrate how this may be applied in practice.
Suppose that we want to implement an iSWAP gate, which swaps two qubit states and multiplies the phase of mixed state amplitudes by a factor of $i$. At the pulse level, this translates to simultaneous flux pulses played on the control qubit and coupler. The operation could look something like this:
control qubit --- [control_pulse] ---
target qubit -----------------------
coupler --- [coupler_pulse] ---
To achieve this, we can first create a new class to hold the parameters of our custom gate and then add a new edge to our topology using this parameters class. In this example, we re-use the IswapParameters class and iswap edge defined in the previous section.
We can now use this edge when creating custom quantum operations. Ultimately, the edge enables us to perform multi-qubit gates with mediating elements without needing to explicitly specify the mediating element or details about the gate parameters. This allows for easy application of a quantum operation to different qubit pairs without needing to alter the operation definition.
We can allow our quantum operation to accept arguments that override the edge parameters during an experiment. This is useful when writing an experiment to tune-up a multi-qubit gate where the parameters of the gate need to be swept.
Below, we define the iSWAP gate operation.
qops = qpu.quantum_operations
@qops.register
def iswap(
self,
target_q: Transmon,
control_q: Transmon,
amplitude_control_pulse: float | SweepParameter | None = None,
amplitude_coupler_pulse: float | SweepParameter | None = None,
length: float | SweepParameter | None = None,
):
"""iSWAP gate.
Arguments:
target_q:
Target qubit.
control_q:
Control qubit.
amplitude_control_pulse:
Amplitude of the pulse played on the control qubit. By default, this value is
inherited from the `control_pulse` parameter of the edge.
amplitude_coupler_pulse:
Amplitude of the pulse played on the coupler. By default, this value is
inherited from the `coupler_pulse` parameter of the edge.
length:
Duration of the gate in seconds. By default, this value is inherited from the
`length` parameter of the edge.
"""
# Retrieve the relevant edge and quantum elements from the QPU topology
edge = self.qpu.topology["iswap", target_q.uid, control_q.uid]
coupler = edge.quantum_element
# Extract the parameters stored in the edge
control_params = edge.parameters.control_pulse
coupler_params = edge.parameters.coupler_pulse
gate_length = edge.parameters.length
# Set the amplitude of the pulse equal to 1, unless the default value of the edge is used.
# This is done to avoid "double-setting" the amplitude.
# The amplitude in the pulse definition and that specified in the `dsl.play` command are
# multiplied to give the final output amplitude.
if amplitude_control_pulse is not None:
control_params["amplitude"] = 1
if amplitude_coupler_pulse is not None:
coupler_params["amplitude"] = 1
if length is not None:
length = gate_length
# Play control and coupler pulses
control_q_pulse = dsl.create_pulse(control_params, name="pulse_control_q")
coupler_pulse = dsl.create_pulse(coupler_params, name="pulse_coupler")
with dsl.section(
name=f"iswap_{target_q.uid}_{control_q.uid}",
alignment=SectionAlignment.RIGHT,
):
dsl.play(
signal=control_q.signals["flux"],
pulse=control_q_pulse,
length=length,
amplitude=amplitude_control_pulse,
)
dsl.play(
signal=coupler.signals["flux"],
pulse=coupler_pulse,
length=length,
amplitude=amplitude_coupler_pulse,
)
As usual, we can inspect the LabOne Q section created when we call our quantum operation by itself. Observe that the flux pulses are played on the correct signal lines without needing to specify the coupler in the quantum operation call.
qpu.quantum_operations.iswap(qpu["q0"], qpu["q1"])
Saving/loading the QPU¶
Finally, we can view the summary information for our newly-defined QPU by printing the QPU object.
qpu
Here, we can see that we have three edge tags in our topology graph: empty, coupler, and iswap. Since empty and iswap appear twice and coupler appears once, our topology graph has five edges in total.
Once we are finished, the QPU object may be saved and loaded just like other quantum objects in LabOne Q, using the save/load methods from laboneq.serializers.
For further information on designing your own experiment in LabOne Q, please see the LabOne Q Applications Library.