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 the noise may be predicted using an emulator equipped with a noise model that mimics the behavior of the real hardware. However, it is a good practice to step back and check the circuits before submitting to any quantum backends to consider the feasibility. InQuanto Protocols have methods that help the users perform such a circuit analysis for resource estimation.

The dataframe_circuit_shot() method is available in all Protocols with the get_circuits() and get_shots() methods. This method quickly displays a summary of circuits to be run, and is a helpful tool for quickly checking the effect of the circuit optimization techniques. Below is a simple example of resource estimation in a typical InQuanto workflow for the energy expectation value calculations.

In the example below we use pytket qiskit’s AerBackend, but we note that costs will depend on the backend due to differences in circuit compilation (e.g., for qubit architecture).

# Prepare the pytket Backend object.

from pytket.extensions.qiskit import AerBackend
backend = AerBackend()

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, prepare the Computable and Protocol objects. The summary of the circuit and the number of shots is displayed as a pandas dataframe.

# 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)

# Use the protocol research estimation tool.
protocol.dataframe_circuit_shot()
Qubits Depth Depth2q DepthCX Shots
0 4 38 21 21 8000
1 4 39 21 21 8000
2 4 39 21 21 8000
3 4 39 21 21 8000
4 4 39 21 21 8000
Sum - - - - 40000

The build_from() method uses pytket to optimize and compile circuits to the target backend, and dataframe_circuit_shot() displays basic information, such as the 2-qubit gate count. If further details are required, circuit analysis using pytket and its extensions may be performed as follows:

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.CX)
data['CX depth'] = circ.depth_by_type(OpType.CX)

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

For the NISQ algorithms, the number of two-qubit gates (e.g., CX, ZZPhase) is the primary limiting factor in obtaining sensible results. One should always check the error rate of the two-qubit gates for a backend in question. Note that any cost estimation is performed at the Protocol level, but the total cost depends on the algorithm executed. 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 cannot generally be predicted.

Some backends support estimation of the cost from the quota given by the backend provider. For example, the Quantinuum backend has the cost() method to estimate the cost in H-Series Quantum Credits (HQC):

from pytket.circuit import Circuit
from pytket.extensions.quantinuum import QuantinuumBackend

# Initialize the quantinuum backend.
qtnm_backend = QuantinuumBackend("H1-1E")
circ = Circuit(2)
circ.H(0).CX(0, 1).measure_all()

# Cost estimate.
qtnm_circ = qtnm_backend.get_compiled_circuit(circ) #login needed
qtnm_backend.cost(qtnm_circ, n_shots=8000, syntax_checker="H1-1SC")

# output: 57.8

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 [34]. See the QPE protocol manual 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 Symmetry for the method to taper off the qubits) 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

Now we generate the circuit with 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 560 10 50 0.5 305 102 203

Note

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

Now we generate the circuit encoded by the \([[6, 4, 2]]\) error detection code (dubbed iceberg code) [34]. The controlled unitary of the unencoded circuit includes two CRz=RzzRiz gates and one CRyy=RzyyRiyy gate that linearly scales as \(k\) increases. This part requires four ZZPhase and two CX gates to represent this operation.

We introduce (still in the snippet above) 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.

Then, we construct the encoded circuit and perform the resource estimation similarly to the unencoded circuits.

# 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 629 10 50 0.5 523 218 305

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 332 10 50 0.5 326 19 307

Note that CX count is reduced significantly (from 218 to 19) from the original straightforward encoding by using the optimization tools of the iceberg code which exploits the stabilizers.