Basic Gates¶
In this tutorial, we will discuss the basic gates Workbench offers and using them in your code.
Gates are the basic building blocks of Workbench programs which correspond to basic quantum logic gates in quantum circuit model of computation. We distinguish them from routines - sequences of quantum and classical operations that express more complicated computations. We will discuss Workbench implementation of routines in a later tutorial.
In Workbench, gates are represented as the methods of the Qubits class. The same method can be used to invoke several related gates, depending on the arguments passed to it. For example, the x() method can be used to apply a single $X$ gate to one qubit, several $X$ gates to different qubits, a controlled-$X$ gate, or a sequence of controlled-$X$ gates. In this tutorial, we will focus on "basic" gates - gates that are not represented as controlled variants of other gates. We will discuss controlled gates in the next tutorial.
Applying a single gate¶
You can apply a single gate to a qubit by calling the appropriate method of any single-qubit Qubits object. This object can be created explicitly or by indexing into a larger Qubits object. The following example shows how to do this for a Qubits object of length $1$ and for a slice of a larger Qubits object.
In the first few sections of this tutorial, we'll use the $X$ gate, applied via the
x()method, to illustrate which qubits the gates are applied to. A complete list of Workbench built-in gate methods is included later in the tutorial.
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=3)
reg1, reg2 = Qubits(1, "reg1", qpu=qpu), Qubits(2, "reg2", qpu=qpu)
reg1.x()
reg2[1].x()
qpu.nop(repeat=11)
qpu.draw()
Applying a gate to several qubits (multi-target gates)¶
You can apply the same gate to multiple qubits by calling the appropriate method of the Qubits object that describes the set of qubits to which you want to apply these gates. Again, you can do this for an entire Qubits object or for its slice. (The previous scenario, applying a single gate, is just a special case of this one!)
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=3)
reg = Qubits(3, "reg", qpu=qpu)
reg.x()
reg[0::2].x()
qpu.nop(repeat=10)
qpu.draw()
Applying a gate to a subset of qubits using bit masks¶
If you need to apply a gate to a subset of qubits in a register, you have two main ways to do that:
- Use slicing to construct a
Qubitsobject that represents the right subset of qubits and apply the gate to this object, as we've seen above. - Apply the gate to the entire register and use a bit mask to specify the subset of qubits to which this gate should be applied.
When you call a gate method with a bit mask argument (an integer, provided either as a positional first argument or as a named argument tgt_bits), Workbench processes this as follows:
- Convert the given integer bit mask into a binary bit string using little-endian notation. For example, for a three-qubit register bit mask $3$ will be converted into $110$ (least significant bits stored first).
- Apply the gate to only qubits in positions where the bit string has $1$ bits. In the example above, this would mean the qubits in the two least significant positions, shown as the top two lines of the circuit.
The following example shows several pairs of equivalent gate applications done via slicing and via bit mask use.
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=3)
reg = Qubits(3, "reg", qpu=qpu)
# Apply gate to all qubits
qpu.label("All")
reg.x()
reg.x((1 << 3) - 1)
# Apply gate to the first (least significant) qubit
qpu.label("LSB")
reg[0].x()
reg.x(0x1)
# Apply gate to the last (most significant) qubit
qpu.label("MSB")
reg[-1].x()
reg.x(1 << 2)
# Apply gate to all qubits except the first one
qpu.label("Tail")
reg[1:].x()
reg.x((1 << 3) - 2)
# Apply gate to all qubits except the last one
qpu.label("Most")
reg[:-1].x()
reg.x((1 << 2) - 1)
qpu.draw()
Using bit masks to address specific qubits within a register is an advanced technique, and a lot of time simple slicing works perfectly fine. However, in some scenarios this technique is extremely convenient and leads to much more compact code.
The following example shows how to iterate over a range of masks and apply gates based on each mask. You will see more such scenarios later, when we discuss using controlled gates and more complicated code patterns.
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=3)
reg = Qubits(3, "reg", qpu=qpu)
for index in range(1, 8):
qpu.label(f"{index}")
reg.x(index)
qpu.draw()
Non-parameterized single-qubit gates¶
Here is a list of methods of the Qubits data type which correspond to non-parameterized single-qubit gates. They all behave in the same manner, so we will not discuss each one individually.
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=3)
reg = Qubits(3, "reg", qpu=qpu)
qpu.label("I")
reg.identity()
qpu.label("H")
reg.had()
qpu.label("Pauli")
reg[0].x()
reg[1].y()
reg[2].z()
qpu.label("Pauli root")
reg[0].rootx()
reg[1].rooty()
reg[2].s()
qpu.label("Pauli root†")
reg[0].rootx_inv()
reg[1].rooty_inv()
reg[2].s_inv()
qpu.label("T")
reg[2].t()
qpu.label("T†")
reg[2].t_inv()
qpu.draw()
Parameterized single-qubit gates (rotation gates)¶
The next group of gates we'll discuss are rotation gates. These gates are parameterized by the angle of the rotation that needs to be applied, so the signatures of the methods that implement them are slightly different from the ones we've seen so far. These methods take an extra argument: the rotation angle.
Rotation angles in Workbench¶
By default, Workbench programs use degrees to specify rotation angles. A plain number provided as a rotation angle argument will be interpreted as a number of degrees. However, you can specify other units to make your code more readable:
- To specify a rotation angle
thetain radians, use the valuetheta * Units.rad. (Unitsis a utility module defined inpsiqworkbench.) - To specify a rotation angle
thetaas a fraction of $\pi$, use a tuple(numerator, denominator).
The following example shows the different rotation gates with the rotation angle provided as constants of different types. Notice that for rotation angles that are multiples of $\tfrac{\pi}{8}$ the phase gate will be replaced with equivalent non-parameterized gates to make the circuit easier to read.
from math import pi
from psiqworkbench import QPU, Qubits, Units
qpu = QPU(num_qubits=3)
reg = Qubits(3, "reg", qpu=qpu)
qpu.label("Rx")
qpu.nop()
reg[0].rx(90)
reg[1].rx(pi / 2 * Units.rad)
reg[2].rx((1, 2))
qpu.nop()
qpu.label("Ry")
qpu.nop()
reg[0].ry(45)
reg[1].ry(pi / 4 * Units.rad)
reg[2].ry((1, 4))
qpu.nop()
qpu.label("Rz")
qpu.nop()
reg[0].rz(22.5)
reg[1].rz(pi / 8 * Units.rad)
reg[2].rz((1, 8))
qpu.nop()
qpu.label("Phase")
qpu.nop()
reg[0].phase(125)
reg[1].phase(pi / 2 * Units.rad)
reg[2].phase((1, 4))
reg[0].phase(180)
reg[1].phase(3 * pi / 2 * Units.rad)
reg[2].phase((-1, 4))
qpu.nop()
qpu.draw()
Swap gate¶
The swap() method implements the SWAP gate. It can be used to swap either individual qubits or registers of matching size, as shown in the following example:
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=4)
reg1 = Qubits(2, "reg1", qpu=qpu)
reg2 = Qubits(2, "reg2", qpu=qpu)
qpu.label("Swap qubits")
reg1[1].swap(reg2[0])
reg1[0].swap(reg2[1])
qpu.label("Swap registers")
reg1.swap(reg2)
qpu.label()
qpu.nop(repeat=6)
qpu.draw()
Reflect operation¶
The reflect() method implements the operation of selecting one basis state and applying a relative phase to it. (This operation implements reflection about a state, hence the name.) This operation is not usually considered a "primitive" gate, but it appears in quantum algorithms so frequently that Workbench has a dedicated method for it.
This method takes two extra arguments. The rotation angle theta specifies the relative phase $e^{i\theta}$ to be applied to the basis state. Same as for rotation gates, it can be specified as degrees, radians, or fractions of $\pi$. The default angle is $180$ degrees, that is, multiply the basis state by $-1$. The following example shows how to use the reflect method to apply different relative phases to different basis states in the Z basis.
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=3)
reg = Qubits(3, "reg", qpu)
reg.had()
qpu.label(" -1⋅|111⟩ ")
reg.reflect() # Equivalent to CZ
qpu.label(" -1⋅|000⟩ ")
(~reg).reflect()
qpu.label(" i⋅|100⟩ ")
(reg[0] | ~reg[1:]).reflect(theta=90)
qpu.label(" e^(iπ/4)|110⟩ ")
(reg==3).reflect(theta=(1, 4))
qpu.print_state_vector()
qpu.draw()
|reg> |0> -0.353553-0.000000j |1> 0.000000+0.353553j |2> 0.353553+0.000000j |3> 0.250000+0.250000j |4> 0.353553+0.000000j |5> 0.353553+0.000000j |6> 0.353553+0.000000j |7> -0.353553-0.000000j
The second additional argument is the reflection basis basis - "x", "y", or "z". The default is "z", that is, reflection about the $|1...1\rangle$ basis state. You can also modify the basis state for reflection by modifying the Qubits register on which it is applied, as you have seen in the previous code example.
The following example shows how to use the reflect method to implement Grover's search algorithm. It is used twice: with the default basis to implement the phase oracle and with the "x" basis to implement reflection about the mean.
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=3)
reg = Qubits(3, "reg", qpu)
# Prepare the initial state - an equal superposition of all states
qpu.label("Initial")
reg.had()
# Apply phase oracle - mark basis states |2⟩ and |6⟩
qpu.label(" Oracle ")
(reg[:2]==2).reflect()
# Apply reflection about the mean
qpu.label("Reflection about mean")
(~reg).reflect(basis='x')
# The marked states are exactly 1/4 of the search space,
# so after one iteration the system state is
# an equal superposition of only the marked states
qpu.print_state_vector()
qpu.draw()
|reg> |2> -0.707107+0.000000j |6> -0.707107+0.000000j
Write method¶
The write() method takes an integer as an input and sets the state of the Qubits object to a basis state that corresponds to the binary notation of this integer, in little-endian notation. For example, calling the write(1) method on a two-qubit register will set its state to $|1_{10}\rangle = |10_2\rangle$. If the argument of the method is greater than the register can store, that is, $2^n$ or greater for an $n$-qubit register, the register is set to the value of that integer modulo $2^n$ with a warning.
This method is not a quantum gate as such, since it does not correspond to a unitary transformation. The first step performed by the
write()method is measurement; if the qubits to which it is applied are entangled with others, using this method will change the state of those qubits, same as measurement would. However, it is often used in Workbench programs, so we wanted to include it in the list of commonly used gates.
The following example prepares four qubits in the $\tfrac1{\sqrt2}(|0000\rangle + |1111\rangle)$ state and then uses the write() method to set the state of the first two to $|1_{10}\rangle = |10_2\rangle$. Notice that at the end of the program the first two qubits are always in the $|10\rangle$ state, while the last two qubits alternate between $|00\rangle$ and $|11\rangle$ states as a result of measurement.
from psiqworkbench import QPU, Qubits
qpu = QPU(num_qubits=4)
reg1 = Qubits(2, "reg1", qpu=qpu)
reg2 = Qubits(2, "reg2", qpu=qpu)
# Prepare a |0000> + |1111> state on the two registers
reg1[0].had()
(reg1[1] | reg2).x(cond=reg1[0])
# Measure the first register and set it to state 1 = 0b01
reg1.write(1)
qpu.print_state_vector()
qpu.nop(repeat=10)
qpu.draw()
|reg1|reg2> |1|3> 1.000000+0.000000j
Next steps¶
You have learned about the basic Workbench gates and applying them in your programs. In the next tutorial, you will learn how to use controlled variants of these gates.