Quantum Data Types: Qubits¶
In this tutorial, we will learn about the Qubits data type - another of the key classes used by Workbench programs.
Qubits data type: qubit registers¶
Qubits is the base type for all qubit variables in Workbench. If your program needs one or several "raw" qubits that don't represent a particular data type, you will use a Qubits object.
Workbench has several specialized data types to represent qubit registers that encode an integer or a fixed-point number, signed or unsigned. We will cover them in a later tutorial.
In this tutorial, we'll go over the internal representation of the Qubits objects, as well as the basics of allocating and releasing qubits and modifying Qubits objects. Subsequent tutorials will discuss applying gates and measurements, as well as using Qubits objects in debugging.
Allocating qubits¶
You can allocate a qubit register by calling the constructor Qubits() with three arguments: the number of qubits in the register, the register name used in debugging and visualization, and the QPU on which the qubits should be allocated. For example, the following code snippet will initialize a QPU with five qubits and allocate two registers on it, reg1 with three qubits and reg2 with two qubits:
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=5)
reg1 = Qubits(3, "reg1", qpu=qpu)
reg2 = Qubits(2, "reg2", qpu=qpu)
Workbench has another way to allocate qubits that is used for temporary allocation of auxiliary qubits inside Qubricks. We will introduce it later, when we talk about Qubricks and the best practices for auxiliary qubit management within them.
Internal structure of Qubits objects¶
A Qubits object is represented with three data fields:
- The QPU on which the qubits from this object are allocated. All qubits you manipulate within a program are typically allocated within the same QPU, so you don't need to access this field often. If you do, you can get it using the
qpufield. - The list of the qubits from this QPU that are represented by this object. You can get this list using the
qubit_indices()method, and the bit mask representation of this list - using themask()method. - The bit mask that specifies the behavior of this object when it is used as a control register for a quantum gate. (We will discuss this in detail in the Controlled Gates tutorial.) You can get and modify this mask using the
cond_xorfield.
Internally, a QPU represents all qubits it works with as a single array of qubits. Individual qubits can be accessed using their indices within this array, and sets of qubits - using arrays of indices or bit masks.
Qubits data type simplifies qubit access, so we recommend you use it for most of your code writing. However, sometimes you need to understand the internal representation of the program, for example, for interpreting the debugging information you get from Workbench.
num_qubits is another useful field of a Qubits object; it stores the number of qubits in the register.
Let's take a look at what values these fields take when we allocate two qubit registers using the code snippet above.
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=5)
reg1 = Qubits(3, "reg1", qpu=qpu)
reg2 = Qubits(2, "reg2", qpu=qpu)
print(reg1.num_qubits)
print(reg1.qubit_indices())
print(f"{reg1.mask()} = {bin(reg1.mask())}")
print(f"{reg1.cond_xor} = {bin(reg1.cond_xor)}")
print(reg2.num_qubits)
print(reg2.qubit_indices())
print(f"{reg2.mask()} = {bin(reg2.mask())}")
print(f"{reg2.cond_xor} = {bin(reg2.cond_xor)}")
qpu.nop(repeat=15)
qpu.draw()
3 (0, 1, 2) 7 = 0b111 0 = 0b0 2 (3, 4) 24 = 0b11000 0 = 0b0
The first Qubits object, reg1, has qubits with indices $0$, $1$, and $2$, and the second one, reg2 - with indices $3$ and $4$. The QPU allocates qubits in contiguous blocks in order from lower indices to higher ones. (On the circuit diagram, lower qubit indices correspond to higher wires; the top wire is the qubit with index $0$.)
The masks representation of these qubit lists match the indices representation. In each mask, the bits that correspond to the indices of qubits which are a part of that register are set to 1 (masks use little endian encoding, that is, the least significant bit in the mask corresponds to index $0$).
We didn't modify the cond_xor masks yet, so they are 0 for both objects, indicating that none of the qubits in them are inverted. We'll see how to work with these masks in the Controlled Gates tutorial.
Combining Qubits registers¶
You can concatenate two or more Qubits registers to create larger registers. To do this, use the concatenation operator |: if you have two registers reg1 and reg2, you can get their concatenation as reg1 | reg2. Notice that the order of arguments in concatenation matters: the expressions reg1 | reg2 and reg2 | reg1 create Qubits objects that consist of the same qubits in a different order.
For example, if reg1 is in the basis state $|001\rangle$ and reg2 is in the basis state $|10\rangle$, the combined register reg1 | reg2 will be in the state $|00110\rangle$, while the combined register reg2 | reg1 will be in the state $|10001\rangle$.
Creating Qubits objects without allocation¶
You can create Qubits objects without allocating new qubits, by referencing previously allocated Qubits. This can be convenient when manipulating the registers in the context of a program. For example, you can append a most significant bit to an unsigned integer to prevent overflow during addition or subtraction.
To create a new Qubits object as a reference, call the constructor Qubits() with two arguments: the Qubits object that supplies the underlying qubits and the name of the new object. You don't need to provide the QPU argument, since that can be deduced from the QPU of the Qubits object argument.
Creating references is convenient when re-typing quantum variables, converting their type from raw qubits to one of the types used in quantum arithmetic. We will see examples in a later tutorial.
The following example allocates several registers and shows how to combine two registers into one using concatenation. (reg.x() applies an $X$ gate to each qubit in a Qubits register. We will discuss applying gates in more detail in the next tutorial; here we're just using them to better illustrate different registers.)
Notice that both concat and ref are Qubits variables that refer to the same qubits in the same order, and you can use them to apply gates in the exact same way. However, concat was created via simple concatenation, so it doesn't show up on the circuit diagram. ref was created as a reference, so it shows up on the diagram.
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=3)
r1 = Qubits(1, "r1", qpu=qpu)
r2 = Qubits(1, "r2", qpu=qpu)
r3 = Qubits(1, "r3", qpu=qpu)
concat = r1 | r3
ref = Qubits(r1 | r3, "ref")
concat.x()
ref.z()
qpu.nop(repeat=12)
qpu.draw()
Accessing qubits within a Qubits object (slicing)¶
In Workbench programs, you will sometimes apply gates or measurements to the entire register represented by a Qubits object. However, it is more common to intermix such gates with gates applied only to some of the register's qubits or gates that use a part of a register as a control. To do this, you can access the necessary subarray of qubits using Python indexing and slicing syntax, just like you would do with an array.
If reg is a Qubits object, you can access its element with index j as reg[j]. reg[start:stop:step] produces a subarray of register elements starting with index start and incrementing the current index by step until it reaches or surpasses index stop. Both an element and a slice of a Qubits object are Qubits objects themselves.
The following example highlights indexing and slicing a register in different ways. You can also use slicing in more interesting scenarios which are tricky to illustrate using single-qubit gates. For example, reg[::-1] reverses the order of qubits in a register; if this register represents an unsigned integer, this allows you to switch between little-endian and big-endian encoding.
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=4)
r = Qubits(4, "r", qpu=qpu)
qpu.label("r[0]") # First qubit
r[0].x()
qpu.label("r[1:]") # All qubits except the first one
r[1:].x()
qpu.label("r[-1]") # Last qubit
r[-1].x()
qpu.label("r[:-1]") # All qubits except the last one
r[:-1].x()
qpu.label("r[0::2]") # Even qubits
r[0::2].x()
qpu.label("r[1::2]") # Odd qubits
r[1::2].x()
qpu.label("r[0]|r[-1]") # First and last qubits
(r[0] | r[-1]).x()
qpu.draw()
Releasing qubits¶
If you need a qubit register only temporarily, it often makes sense to release it before the end of the program. This way, you can allocate the next register you need in place of the one you released, thus reducing the total qubit requirements of your program. You can release qubits within a Qubits object using its release() method.
In the following example, the program allocates two registers, then releases the first one, and finally allocates two more. Though the program allocates a total of five qubits, the QPU only needs to be initialized with num_qubits=4, since the qubit that was released can be reused in a later allocation.
Notice that
reg3is allocated immediately afterreg1is released, but it doesn't reuse qubit $0$ (the top wire one). QPUs allocate qubits in contiguous blocks, so when the program requested two qubits forreg3, it received qubits with indices $2$ and $3$ instead of $0$ and $2$. The next allocation request,reg4with one qubit, could be satisfied with the unused qubit $0$.
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=4)
reg1 = Qubits(1, "reg1", qpu=qpu)
reg2 = Qubits(1, "reg2", qpu=qpu)
reg1.release()
reg3 = Qubits(2, "reg3", qpu=qpu)
reg4 = Qubits(1, "reg4", qpu=qpu)
qpu.nop(repeat=13)
qpu.draw()
Next steps¶
Now that you've learned about the Qubits objects, keep reading to learn about how to use them to apply basic quantum gates in a Workbench program.