Today, most hardware has connectivity constraints, two qubits gates can only be executed on few pairs of qubits. Pairs of qubits that can interact define the connectivity constraints of a hardware. Solving the swap insertion problem consists in inserting SWAP gates in a circuit to make it compliant with connectivity constraints of a specific hardware. The QLM provides a module qat.nnize implementing algorithms to solve the swap insertion problem.
For instance, considering a fake quantum computer which has few connectivity constraints defined by the following graph:
On this computer, two qubits gates can then only be executed using one of the following pairs: [(0, 1), (0, 2), (1, 3), (2, 3), (2, 4), (3, 4)]
(corresponding to the edges of the previous graph).
This notebook brings an answer to the question: "I have a circuit (a QFT). How can one adapt this circuit to make it compliant with the connectivity constraints of this computer?"
Connectivity constraints can be defined by the class qat.core.Topology
. Few topologies are predefined:
For instance, few topologies can be defined using the following code:
from qat.core import Topology, TopologyType
# Define a topology without connectivity constraints
no_constraint = Topology(type=TopologyType.ALL_TO_ALL)
# Define an LNN topology
lnn = Topology(type=TopologyType.LNN)
# Define custom topology
my_topology = Topology()
for i, j in [(0, 1), (0, 2), (1, 3),
(2, 3), (2, 4), (3, 4)]:
my_topology.add_edge(i, j)
To make a circuit compliant with the hardware specifications, one need to manage the hardware specifications (and not only the topology).
The Python class qat.core.HardwareSpecs
defines specifications of an hardware. For instance, the following code defines specifications of my hardware.
from qat.core import HardwareSpecs
# Define a hardware: 5 qubits + connectivity constraints
my_hardware = HardwareSpecs(nbqbits=5, topology=my_topology)
Since hardware specifications has been defined, one need to update a circuit to make it compliant with my_hardware
. First, let's define a QFT.
from qat.lang.AQASM import Program
from qat.lang.AQASM.qftarith import QFT
# Get the number of qubits
nb_qubits = my_hardware.nbqbits
# Define a program
prog = Program()
qubits = prog.qalloc(nb_qubits)
prog.apply(QFT(nb_qubits), qubits)
circ = prog.to_circ(inline=True)
# Display circuit
circ.display()
Few algorithm are implemented in qat.nnize to solve the swap insertion problem:
One will use the atos algorithm to solve the problem.
from qat.plugins import Nnizer
from qat.core import Batch
# Define nnizer
nnizer = Nnizer(method="atos")
# Wrap the circuit into a Job
job = circ.to_job()
# Start nnization
nnized_batch = nnizer.compile(Batch(jobs=[job]),
my_hardware)
# Display result
nnized_circ = nnized_batch.jobs[0].circuit
nnized_circ.display()
*Why the circuit is wrapped into a Job which is wrapped into a Batch?*
The nnizer is a plugin, which means that the nnizer can extend a QPU. A QPU executes batches which is a set of circuits with execution parameters for each circuit (like the number of samples, the list of measured qubits, ...). To be a plugin, the nnizer must receive batches
The nnizer will try to minimize the number of gates of the resulting circuit. Maybe, one wants to use another metric to improve the final circuit. A metric is a function which takes a circuit and returns a number. The nnizer will try to maximize this number.
The nnizer accepts any Python function. For instance, a metric could be define using few lines of code:
def my_metric(circuit):
""" Metric used to minimize the number of gates """
# Compute a score depending on the length of the circuit
return -len(circuit.ops)
In this example, one will use a predefined metric to minimize the duration of the final circuit. Suppose that our hardware:
def circ_depth(circ):
""" Computes the 2-qubits gates depth of a circuit """
depths = [0] * circ.nbqbits
for _, _, qbits in circ.iterate_simple():
if len(qbits) > 1:
gate_depth = max(depths[q] for q in qbits) + 1
for q in qbits:
depths[q] = gate_depth
return max(depths)
def metric(circ):
""" Metric to maximize """
return -circ_depth(circ)
The object metric
is now a Python function which takes a circuit and return a number. Since the nnizer will maximize this number, the result of the metric
function is the opposite of the duration.
# Print duration of the previous nnized circuit
print("Duration of the nnized circuit: %d" %
circ_depth(nnized_circ))
# Nnize the circuit setting "metric" as function to maximize
nnizer = Nnizer(method="atos", metric=metric)
final_nnized_batch = nnizer.compile(Batch(jobs=[job]),
my_hardware)
# Print duration of the final nnized circuit
print("Duration of the nnized circuit: %d" %
circ_depth(final_nnized_batch.jobs[0].circuit))
# Get circuit
final_nnized_circ = final_nnized_batch.jobs[0].circuit
final_nnized_circ.display()
The duration of the second nnized circuit is better than the first nnized circuit.
In default mode, the $i^{\;th}$ qubit of the circuit is mapped with the $i^{\;th}$ qubit of the hardware. This mapping can be updated automatically using different methods:
None
: default mode"annealing"
: the initial mapping is updated using a simulated annealing"reverse"
: the initial mapping is updated using the reverse traversal method described in Tackling the Qubit Mapping Problem for NISQ-Era Quantum Devices by Gushu Li, Yufei Ding and Yuan XieIf the nnizer must update the initial mapping of the circuit, the nnizer will try to maximize the metric. One can update the initial mapping of our QFT to minimize the duration of the nnized circuit. For instance:
# Define a nnizer which will update the initial mapping
# using a simulated annealing
nnizer = Nnizer(method="atos", metric=metric,
update_initial_order="annealing")
# Nnize the circuit
final_nnized_mapped_batch = nnizer.compile(Batch(jobs=[job]),
my_hardware)
# Print duration of the final nnized circuit
print("Duration of the nnized circuit: %d" %
circ_depth(final_nnized_batch.jobs[0].circuit))
# Get circuit
final_nnized_mapped_circ = final_nnized_mapped_batch.jobs[0].circuit
final_nnized_mapped_circ.display()