Circuit Compilation¶
This tutorial highlights circuit compilation in InQuanto. Circuit compilation is an important step in quantum computation. In this step measurement circuits are optimized, making them backend compatible and often cheaper to run. Compilation follows the instantiation and building of the protocol and precedes running the protocol and evaluating the results. In this tutorial we highlight the outcomes of different levels of compilation.
We take advantage of IBM’s Aer Backend through the pytket-qiskit
extension in this tutorial.
With some adjustment to how the protocols are run, this notebook can also take advantage of backends available through Quantinuum Nexus.
Refer to this page for instructions on how to run InQuanto calculations on other backends.
1.1. System Preparation¶
First, we import the Hamiltonian, space, and state of a converged mean-field (Hartree-Fock) simulation through the get_system()
method of inquanto.express
. Here we use H2 in the 6-31G basis set as our system.
from inquanto.express import get_system
from inquanto.ansatzes import FermionSpaceAnsatzUCCSD
fermion_hamiltonian, space, state = get_system("h2_631g_symmetry.h5")
# Jordan-Wigner encoding
qubit_hamiltonian = fermion_hamiltonian.qubit_encode()
ansatz = FermionSpaceAnsatzUCCSD(space, state)
# Define a set of random variables for the ansatz
params = ansatz.state_symbols.construct_random(seed=6)
1.2. Instancing and Building the Protocol¶
A number of different protocols are available in InQuanto.
There are state vector protocols, protocols for quantum phase estimation, and averaging protocols.
We use the shot-based PauliAveraging()
protocol, which can partition the Pauli gates that measure the Hamiltonian into sets that are simultaneously measurable.
More on the Pauli averaging protocol can be found here.
In this tutorial we build and then pickle a template of this protocol.
This allows us to use a clean instance of the protocol each time we demonstrate different compilations.
from pytket.extensions.qiskit import AerBackend
from inquanto.protocols import PauliAveraging
from pytket.partition import PauliPartitionStrat
# Shot based IBM backend
backend = AerBackend()
# Instantiate protocol
protocol_template = PauliAveraging(
backend,
shots_per_circuit=10,
pauli_partition_strategy=PauliPartitionStrat.CommutingSets
)
# Build protocol
protocol_template.build(params, ansatz, qubit_hamiltonian)
protocol_pickle = protocol_template.dumps()
1.3. Circuit Compilation¶
We are now able to begin compiling circuits with the instantiated and built protocol.
If we inspect a circuit from the built protocol we find that it is comprised of two Pauli X gates, a number of circuit boxes and then a measurement circuit.
The circuit boxes are abstractions built from sets of gates that pytket uses to exploit any higher-level structure within the circuit.
These circuit boxes are themselves comprised of Pauli exponential circuit boxes.
While all of these boxes can be useful to break large and complex circuits down into manageable sub-routines, they are not typically compatible circuit execution.
We can check the validity of our sample circuit with the Aer backend using the valid_circuit()
method from pytket, and see that the circuits in their current form are not compatible.
However, this will change once they are compiled.
from pytket.circuit.display import render_circuit_jupyter
# Note that the PauliAveraging protocol builds a number of destinct circuits,
# here we extract one from the set to examine. (The total number can be examined with protocol.n_circuit)
protocol = PauliAveraging.loads(protocol_pickle, backend)
example_circuit = protocol.get_circuits()[0]
render_circuit_jupyter(example_circuit)
print(f"Circuit is compatible with AER backend: {backend.valid_circuit(example_circuit)}")
Circuit is compatible with AER backend: False
InQuanto pools all circuit compilation into thecompile_circuits()
method of inquanto.protocols
.
This compilation is handled by pytket.
pytket-extensions detail a set default compilation passes for each architecture. These can be utilized using the optimization_level
argument.
This can take on three values: 0, 1, and 2. The classical computational expense of the applied passes, and the quality of improvement, is expected to scale up as this integer grows.
These default passes are also backend specific, ensuring that the final circuit is executable on the particular architecture and in the gateset of the particular backend.
More information on which compilation passes are applied at each optimization level for each backend can be found in the pytket extensions documentation. Pytket extensions seperate out the available backends by provider, relevant information for the IBM backend used in this tutorial is on the pytket-qiskit extension page.
By inspecting the circuit, we can see that it has changed quite drastically.
It is now compatible with the Aer backend and all the circuit boxes have been decomposed into gates.
One such gate, that may be unfamiliar to the user is the TK1 gate (grey) which is an arbitrary single qubit rotation gate native to TKET.
At this point, we can also start to get an idea of the resources required to execute these circuits on quantum backends.
We use the controlled Pauli X (CX) gate as a proxy for the noise in executing the circuit.
At optimization_level=0
one of the compiled circuits contains 123 CX gates. (Note that the exact numbers may change with pytket versioning and improvements to compilation passes).
from pytket import OpType
# Clears the protocol object of any previous instance.
protocol.clear()
# Load clean protocol from pickle object
protocol = PauliAveraging.loads(protocol_pickle, backend)
protocol.compile_circuits(optimization_level=0)
opt0_circuit = protocol.get_circuits()[0]
render_circuit_jupyter(opt0_circuit)
print(f"Circuit is compatible with AER backend: {backend.valid_circuit(opt0_circuit)}")
print(f"Number of circuits = {protocol.n_circuit}")
# Note: this print statement is for only one of the 11 compiled circuits
# Two-qubit gate count as a proxy for requred resources
cnot_gates = protocol.get_circuits()[0].n_gates_of_type(OpType.CX)
print(f"Optimization_level = 0 CNOT gates: {cnot_gates}")
# Creates a pickled bytes object of the compiled
# protocol for measuring later in the notebook.
opt0_protocol = protocol.dumps()
Circuit is compatible with AER backend: True
Number of circuits = 11
Optimization_level = 0 CNOT gates: 123
As we increase the optimization_level
, we see that the required resources decrease from 123 CX gates down to 97.
The types of gates that appear in the circuit also changes.
There are now only CX gates and the TK1 gates native to TKET mentioned previously.
protocol.clear()
protocol = PauliAveraging.loads(protocol_pickle, backend)
protocol.compile_circuits(optimization_level=2)
render_circuit_jupyter(protocol.get_circuits()[0])
cnot_gates = protocol.get_circuits()[0].n_gates_of_type(OpType.CX)
print(f"Optimization_level = 2 CNOT gates: {cnot_gates}")
opt2_protocol = protocol.dumps()
Optimization_level = 2 CNOT gates: 97
On top of the default optimization_level
passes that are applied, we can also specify passes to be applied before and/or after the default pytket
optimizations.
The pytket.passes
module provides a large number of compilation passes to experiment with.
Some of these passes impose predicates on the circuit that they are applied to and often they take optional arguments, allowing further customization.
The user can outline compilation passes to be applied before the default optimization_level
passes using the preoptimize_passes
keyword argument.
Here we demonstrate the pytket SequencePass()
method to apply a combination of DecomposeBoxes()
and then GreedyPauliSimp()
passses before the default optimization.
We can also apply passes after the default optimization using the compiler_passes
keyword argument.
This is demonstrated with the SynthesiseTK()
pass.
It is important to note that passing compiler_passes
sets optimization_level=0
.
from pytket import passes
protocol.clear()
protocol = PauliAveraging.loads(protocol_pickle, backend)
# strict=False required to apply GreedyPauliSimp().
seq = passes.SequencePass([passes.DecomposeBoxes(), passes.GreedyPauliSimp()], strict=False)
protocol.compile_circuits(preoptimize_passes=seq)
render_circuit_jupyter(protocol.get_circuits()[0])
cnot_gates = protocol.get_circuits()[0].n_gates_of_type(OpType.CX)
print(f"Pre-optimization with GreedyPauliSimp() CNOT gates: {cnot_gates}")
protocol.clear()
protocol = PauliAveraging.loads(protocol_pickle, backend)
protocol.compile_circuits(compiler_passes=passes.SynthesiseTK())
render_circuit_jupyter(protocol.get_circuits()[0])
tk2_gates = protocol.get_circuits()[0].n_gates_of_type(OpType.TK2)
Pre-optimization with GreedyPauliSimp() CNOT gates: 79
Compilation is an important step in any quantum computational chemistry workflow as it ultimately determines the resources required to run circuits. Here we have demonstrated how a user can compile circuits using default optimisation as well as defining their own sets of compilation passes.