from qat.lang.AQASM import *
Temporary/ancilla qubits management¶
In reversible computation in general, and quantum computation in particular, there is a need for careful usage of temporary memory. This leads to particularly intricated code design where the programmer needs to keep track of the current state of the temporary memory in order to efficiently reuse previously allocated bits.
pyAQASM
's QRoutine
s come with a built-in structure that allows the programmer to partially unload this complexity and postpone the ancilla allocation to the circuit extraction stage (the to_circ
method of the Program
class).
The idea is to tag some wires of a QRoutine
as ancillae. Tagging a wire can be seen as a "contract" between the routine and the outer-scope saying : "Give me a fresh qubit in state $|0\rangle$, and I promise that I'll give it back to you in state $|0\rangle$".
This allows the circuit extraction routine to manage the temporary qubits on its own. The user will not have to allocate these additional qubits.
In practice¶
Let us define a simple routine that uses an ancilla to perform a RZ
on a 2-qubit state if and only if the and
of the two qubits is true (basically a controled RZ
with a twist).
First, let us define a routine that do this the naive way:
routine = QRoutine()
input_wires = routine.new_wires(2) # These are our 2 input qubits
temp_wire = routine.new_wires(1) # This is our temporary qubit
routine.apply(CCNOT, input_wires, temp_wire) # We apply a Toffoli gate to compute the logical and of our inputs
routine.apply(RZ(0.4), temp_wire) # We apply our rotation
routine.apply(CCNOT, input_wires, temp_wire) # We apply the Toffoli to uncompute the and.
print("This routine has arity", routine.arity)
routine.display()
This routine has arity 3
We can see that the routine has arity 3. This means that we will need to allocate 3 qubits inside a Program
in order to use this routine:
prog = Program()
qbits = prog.qalloc(3) # My two inputs, and the ancilla
prog.apply(routine, qbits) # This works
try:
prog = Program()
qbits = prog.qalloc(2) # Only my two inputs
prog.apply(routine, qbits) # This raises an InvalidGateArgument exception
except Exception as e:
print("Something went wrong:")
print(type(e), e)
Something went wrong: <class 'qat.lang.AQASM.aqasm_util.InvalidGateArguments'> Gate None of arity 3 cannot be applied on [0,1]
In order to avoid having to allocate this temporary qubit by hand, we can simply "tag" it using the set_ancillae
method of the QRoutine
class.
# Like so
routine.set_ancillae(temp_wire)
print("Now the routine has arity", routine.arity)
prog = Program()
qbits = prog.qalloc(2)
prog.apply(routine, qbits) # No exceptions!
circ = prog.to_circ()
print("But the circuit has arity", circ.nbqbits)
circ.display()
Now the routine has arity 2 But the circuit has arity 3
Notice that the final circuit is identical. Things start to get interesting when several routines using ancillae are used inside the same program, or recursively:
A = QRoutine()
A.apply(CNOT, 0, 1)
A.apply(Z, 1)
A.apply(CNOT, 0, 1)
A.set_ancillae(1)
B = QRoutine()
B.apply(CNOT, 0, 1)
B.apply(A, 1) # Remember, from the "outside" A has arity 1
B.apply(CNOT, 0, 1)
B.set_ancillae(1)
prog = Program()
qbits = prog.qalloc(1) # B has arity 1
prog.apply(B, qbits)
circ = prog.to_circ()
circ.display()
In the following example, we can see that the circuit extraction routine re-used some ancilla (q2) during the application of routine B
.
prog = Program()
qbits = prog.qalloc(2)
prog.apply(A, qbits[0])
prog.apply(B, qbits[1])
circ = prog.to_circ()
circ.display()
A note on the qubit allocation algorithm¶
The circuit extraction routine implements a really simple and naive qubit allocation algorithm to allocate ancillae. The algorithms maintains a list of available ancillae and allocate a fresh qubit only if none are currently available.
This limits the total number of allocated qubits.
We could consider other strategies optimizing other metrics such as the final circuit depth.
For instance, the previous example could be written:
prog_shallow = Program()
qbits_A, qbits_B = prog_shallow.qalloc(2), prog_shallow.qalloc(3)
prog_shallow.apply(CNOT, qbits_A[0], qbits_A[1])
prog_shallow.apply(Z, qbits_A[1])
prog_shallow.apply(CNOT, qbits_A[0], qbits_A[1])
prog_shallow.apply(CNOT, qbits_B[0], qbits_B[1])
prog_shallow.apply(CNOT, qbits_B[1], qbits_B[2])
prog_shallow.apply(Z, qbits_B[2])
prog_shallow.apply(CNOT, qbits_B[1], qbits_B[2])
prog_shallow.apply(CNOT, qbits_B[0], qbits_B[1])
circ_shallow = prog_shallow.to_circ()
circ_shallow.display()
These circuits are (almost) equivalent, but the latter has depth 5 while the former has depth 8.
This tradeoff qubits/depth can be exploited to refine the circuit extraction routine. This is however not yet implemented inside the Program
class.
However, it is possible to generate circuits with additional information attached in order to, later on, play with these trade-offs (see below).
Lock & release operations¶
In order to have a proper idea of what's going on, we can ask the extraction routine to leave markers inside the circuit, specifying lock and release operations for the temporary resources:
circ = prog.to_circ(include_locks=True)
circ.display()
We can now see that qubit q2 was locked once for the application of A
, then released, and locked again for the application of B
.
Locks & release operations can then be removed by calling the .remove_locks
method of the Circuit
object:
circ.remove_locks()
circ.display()
Ancilla map and qubits management¶
The data structure underlying the (rather naive) qubit allocation algorithm is accessible in the namespace qat.lang.linking.util
.
We give here a quick overview of the type of information we can extract from a circuit.
from qat.lang.linking.util import AncillaMap
## We need the lock and release operations in order to create the ancilla map
circ = prog.to_circ(include_locks=True)
anc_map = AncillaMap(circ)
for qbit, lock_ranges in anc_map.locked_ranges.items():
print("Qbit", qbit, "is locked in ranges", lock_ranges)
Qbit 2 is locked in ranges [(0, 4), (5, 13)] Qbit 3 is locked in ranges [(7, 11)]