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 more resource efficient. Measurement circuits are built in InQuanto protocols and are as such compiled in the third stage of the protocol workflow, when the compile_circuits method is called. 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

With the instantiated and built protocol, we are now able to begin compiling circuits. 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 with 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 the compile_circuits() method of inquanto.protocols. This compilation is handled by pytket. Pytket extensions detail a set of default compilation passes for each specific backend. This ensures that the final circuit is executable on the particular architecture and in the gateset of the particular backend. These default passes can be invoked with the optimization_level argument, taking an integer value. Generally, the classical computational expense of the applied passes, and the resulting circuit resource reduction is expected to grow as this integer grows.

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. Therefore, the relevant information for the IBM backend used in this tutorial is on the pytket-qiskit extension page. Protocols compiled with qnexus backends also take advantage of the appropriate pytket compilaton but using Nexus hosted cloud compute.

The optimization_level compilation outlined above is not available for Helios, it’s emulators, and Selene (read more on these backends in the Helios tutorial). These backends do not have the default pytket compilation passes required for the optimization_level argument. However, The user can still define and apply their own set of compilation passes using the preoptimize_passes or compiler_passes keyword arguments described below.

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

By inspecting the compiled circuit above, 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).

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.