Resource estimation

Jobs submitted to quantum backends need to be designed to obtain sensible results within a realistic time. In principle, the resource requirements of an experiment can be estimated based on the circuits and the number of shots to be run. The effect of noise may be predicted using an emulator equipped with a noise model that mimics the behavior of the real hardware. However, it is good practice to consider the feasibility of executing the circuits before submitting to any quantum backends. InQuanto protocols have methods that help the user perform circuit analysis for resource estimation.

All shot based InQuanto protocols include the dataframe_circuit_shot() method. This method quickly displays a summary of the resources for the circuits to be run. It is a helpful tool for quickly checking the effect of the circuit optimization. Below is a simple example of resource estimation in a typical InQuanto workflow for the calculation of an expectation value.

In the example below we use H2-1LE, a noiseless emulator of Quantinuum Systems H2-1 that can be run locally. This backend is accessed through the pytket-quantinuum extension to pytket.

Note

To take advantage of this local emulator, install pytket-quantinuum with the pecos option:

pip install pytket-quantinuum[pecos]

# Prepare the Quantinuum backend
from pytket.extensions.quantinuum import QuantinuumBackend, QuantinuumAPIOffline

backend = QuantinuumBackend(device_name="H2-1LE", api_handler=QuantinuumAPIOffline())

We prepare the qubit Hamiltonian and ansatz as follows, with help from the express module:

# Evaluate the Hamiltonian.
from inquanto.express import get_system
from inquanto.ansatzes import FermionSpaceAnsatzUCCSD

fham, fsp, fst_hf = get_system('h2_sto3g.h5')
qham = fham.qubit_encode()
ansatz = FermionSpaceAnsatzUCCSD(fsp, fst_hf)
params = ansatz.state_symbols.construct_random()

Then, we build the Protocol from a Computable and the previously constructed parameters.

# Prepare the energy expectation value calculations with PauliAveraging.
from inquanto.computables import ExpectationValue
from inquanto.protocols import PauliAveraging

computable = ExpectationValue(ansatz, qham)
protocol = PauliAveraging(backend=backend, shots_per_circuit=8000)
protocol.build_from(params, computable)
<inquanto.protocols.averaging._pauli_averaging.PauliAveraging at 0x7f2f5b3574d0>

While at this point the protocol contains the built circuits, they must be compiled before being executed on the backend. This step can have a large impact on the resources of the executed circuit. The compile_circuits() method will apply a set of pyket backend specific compilation passes. The set of compilation passes applied also depends on the optimisation_level argument passed. See the circuit compilation tutorial to learn more about comilation in InQuanto.

With the protocol compiled, we can use the dataframe_circuit_shot() method to display basic information.

protocol.compile_circuits(optimization_level=1)
# Pull out circuit resources
protocol.dataframe_circuit_shot()
Qubits Depth Count Depth2q Count2q DepthCX CountCX Shots
0 4 41 62 21 22 0 0 8000
1 4 41 63 21 22 0 0 8000
2 4 41 63 21 22 0 0 8000
3 4 41 63 21 22 0 0 8000
4 4 41 63 21 22 0 0 8000
Sum - - - - - - - 40000

One might notice that these circuits do not have any CNOT gates (see CountCX and DepthCX). This is because the circuits have been compiled to the native gate set of System H2, which does not include CNOT gates. However, this gate set does include another two-qubit gate; the \(ZZ Phase\) gate. To do more specific circuit resource analysis one can use pytket directly, for example:

from pytket.circuit import OpType

# Get the circuits to be measured.
circuits = protocol.get_circuits()
shots = protocol.get_shots()

# Start the analysis.
circ = circuits[0]
shot = shots[0]
data = {}
data['shots'] = shot
data['depth'] = circ.depth()
data['CX count'] = circ.n_gates_of_type(OpType.ZZPhase)
data['CX depth'] = circ.depth_by_type(OpType.ZZPhase)

# Show data.
for k, v in data.items():
    print(f"{k:10s}: {v}")
shots     : 8000
depth     : 41
CX count  : 22
CX depth  : 21

The number of two-qubit gates (e.g., CX, ZZPhase) is the primary indicator of cost and source of noise for NISQ algorithms. Therefore, one should always check the specific backends two-qubit gates error rate.

Some backends support estimation of the cost of executing a given circuit. To estimate the cost of a circuit to be executed on a Quantinuum backend, one must use qnexus to retrieve backend information from Quantinuum Nexus.

One can estimate the cost, in Quantinuums hardware quantum credits (HQC), of running an individual circuit with the cost method.

Note

Here we are estimating the cost of running a particular circuit for a number of shots at the protocol level. However, a protocol may contain multiple circuits and its evalutation within an algorithm may require running each set of shots a number of times, in a loop. For example, VQE needs more shots to drive the feedback loop, and Bayesian QPE needs more shots with growing circuits. The number of repeats/loops needed for a desired accuracy cannot generally be predicted.

import qnexus as qnx
from datetime import datetime

qnx.auth.login()

# Create a reference to a Nexus project
my_project_ref = qnx.projects.get_or_create(name="InQuanto Documentation")

# Upload compiled circuit(s) to project
my_circuit_ref = qnx.circuits.upload(
    name=f"My Circuit from {datetime.now()}",
    circuit=circ,
    project=my_project_ref,
)

# Define the backend
qnx_backend = qnx.QuantinuumConfig(device_name="H2-1E")

# Cost estimate.
qnx.circuits.cost(
    my_circuit_ref,
    n_shots=8000,
    backend_config=qnx_backend,
    syntax_checker="H2-1SC"
)

# output: 478.6

Encoded Circuit Optimization

Circuit optimization may also be performed prior to optimization at the pytket level. Here we demonstrate such an optimization performed outside of pytket. We use an example demonstrating basic usage of the iterative quantum phase estimation (QPE) protocol with an error detection code [35]. See the QPE protocol page for details. Here, we focus on showing the potential for circuit optimization; for theoretical details, see ref [20]. Let us prepare the two-qubit Hamiltonian (See Z2 Tapering for more on qubit tapering) describing the molecular hydrogen molecule as an example:

# Prepare the target system.
from pytket.circuit import Circuit, PauliExpBox, Pauli
from inquanto.operators import QubitOperator
from inquanto.ansatzes import CircuitAnsatz

# Qubit operator.
# Two-qubit H2 with the equilibrium geometry.
qop = QubitOperator.from_string(
    "(-0.398, Z0), (-0.398, Z1), (-0.1809, Y0 Y1)",
)
qop_totally_commuting = QubitOperator.from_string(
    "(0.0112, Z0 Z1), (-0.3322, )",
)

# Parameters for constructing a function to return controlled unitary.
time = 0.1
n_trotter = 1
evo_ope_exp = qop.trotterize(trotter_number=n_trotter) * time
eoe_tot_com = qop_totally_commuting.trotterize(trotter_number=n_trotter) * time
k = 50
beta = 0.5
ansatz_parameter = -0.07113

We then generate the (unencoded) physical circuits for reference:

from inquanto.protocols import IterativePhaseEstimationQuantinuum, CircuitEncoderQuantinuum

# Prepare the IterativePhaseEstimationQuantinuum object.
from pytket.circuit import Qubit
from pytket.pauli import QubitPauliString

# State preparation circuit.
# Introduce the dummy qubit to satisfy the requirement of the iceberg code by the Hamiltonian terms mapping: P -> IP
# There is no effect for the unencoded circuits.
terms_map = {
    QubitPauliString([Qubit(0)], [Pauli.Z]): QubitPauliString([Qubit(1)], [Pauli.Z]),
    QubitPauliString([Qubit(1)], [Pauli.Z]): QubitPauliString([Qubit(2)], [Pauli.Z]),
    QubitPauliString([Qubit(0), Qubit(1)], [Pauli.Y, Pauli.Y]): QubitPauliString(
        [Qubit(1), Qubit(2)], [Pauli.Y, Pauli.Y]
    ),
    QubitPauliString([Qubit(0), Qubit(1)], [Pauli.Z, Pauli.Z]): QubitPauliString(
        [Qubit(1), Qubit(2)], [Pauli.Z, Pauli.Z]
    ),
    QubitPauliString(): QubitPauliString(),
}

# State preparation circuit.
state = Circuit(3)
state.add_pauliexpbox(
    PauliExpBox([Pauli.I, Pauli.Y, Pauli.X], ansatz_parameter),
    state.qubits,
)
state_prep = CircuitAnsatz(state)

# Preparing the protocol without circuit encoding.
protocol = IterativePhaseEstimationQuantinuum(
    backend=backend,
    optimisation_level=0,   # For the clear comparison
    n_shots=10,
)
protocol.build(
    state=state_prep,
    evolution_operator_exponents=evo_ope_exp,
    eoe_totally_commuting=eoe_tot_com,
    encoding_method=CircuitEncoderQuantinuum.PLAIN,  # Meaning no logical qubit encoding is performed.
    terms_map=terms_map,
)
protocol.update_k_and_beta(k=k, beta=beta)

# Show the circuit and shot information.
protocol.dataframe_circuit_shot()
Qubits Depth Shots k beta TQ CX ZZPhase
0 4 867 10 50 0.5 305 0 305

Note

If the purpose is to generate an unencoded circuit for physical qubit experiments, the general purpose IterativePhaseEstimation will simplify the code.

Above, we introduced a dummy qubit with no logical operation to make the system consist of an even number of qubits. The terms_map option may be used for introducing the dummy qubit without changing the qubit Hamiltonian defined as a logical operator. The state preparation circuit needs to take the dummy qubit into account.

Now, we generate the circuit encoded by the \([[6, 4, 2]]\) error detection code (dubbed iceberg code) [35]. The controlled unitary of the unencoded circuit includes two \(CR_{Z}=R_{ZZ}R_{iZ}\) gates and one \(CR_{YY}=R_{ZYY}R_{iYY}\) gate that linearly scales as \(k\) increases. This part requires four \(ZZPhase\) and two \(CX\) gates to represent this operation.

# Prepare the IterativePhaseEstimationQuantinuum object.
protocol = IterativePhaseEstimationQuantinuum(
    backend=backend,
    optimisation_level=0,   # For the clear comparison
    n_shots=10,
)
protocol.build(
    state=state_prep,
    evolution_operator_exponents=evo_ope_exp,
    eoe_totally_commuting=eoe_tot_com,
    encoding_method=CircuitEncoderQuantinuum.ICEBERG,
    terms_map=terms_map,
)
protocol.update_k_and_beta(k=k, beta=beta)
protocol.dataframe_circuit_shot()
Qubits Depth Shots k beta TQ CX ZZPhase
0 8 1187 10 50 0.5 523 0 523

Now, we perform some circuit optimization at the logical circuit level and then analyze the resource requirements. This optimization consists of the following:

  • Basis rotation: \(Y \to X\) (\(X\) is cheaper than \(Y\) in the iceberg code)

  • Initialize the dummy qubit in the logical \(|+\rangle\) state so that the Pauli \(X\)

acting on this qubit becomes a stabilizer. - Replace the logical \(ZIXX\) with \(ZXXX\) for the more efficient encoding to reduce the number of CXs.

from inquanto.protocols import IcebergOptions

# Change the basis: X -> Y.
terms_map_y2x = terms_map.copy()
terms_map_y2x = {
    QubitPauliString([Qubit(0), Qubit(1)], [Pauli.Y, Pauli.Y]): QubitPauliString(
        [Qubit(1), Qubit(2)], [Pauli.X, Pauli.X]
    ),
}

# State preparation circuit.
state = Circuit(3)
state.add_pauliexpbox(
    PauliExpBox([Pauli.I, Pauli.Y, Pauli.X], -0.07113),
    state.qubits,
)
state.Sdg(1)
state.Sdg(2)
state_prep = CircuitAnsatz(state)

# Use the optimization technique with the dummy qubit.
# Pauli operator mapping primary for the iceberg code for the circuit optimization.
qubits = [Qubit(i) for i in range(4)]
paulis_map = {
    QubitPauliString(qubits, [Pauli.Z, Pauli.I, Pauli.X, Pauli.X]): QubitPauliString(
        qubits, [Pauli.Z, Pauli.X, Pauli.X, Pauli.X]
    )
}
protocol = IterativePhaseEstimationQuantinuum(
    backend=backend,
    optimisation_level=0,   # For the clear comparison
    n_shots=10,
)
protocol.build(
    state=state_prep,
    evolution_operator_exponents=evo_ope_exp,
    eoe_totally_commuting=eoe_tot_com,
    encoding_method=CircuitEncoderQuantinuum.ICEBERG,
    encoding_options=IcebergOptions(
        n_plus_states=2,
    ),
    terms_map=terms_map_y2x,
    paulis_map=paulis_map,
)
protocol.update_k_and_beta(k=k, beta=beta)
protocol.dataframe_circuit_shot()
Qubits Depth Shots k beta TQ CX ZZPhase
0 8 443 10 50 0.5 326 0 326

Note that the two-qubit gate count (\(ZZ Phase\)) is reduced (525 to 326) from the original straightforward encoding by using the optimization tools of the iceberg code which exploits the stabilizers.