Gate Cutting to Reduce Circuit Depth

In this tutorial, we will simulate some expectation values on a circuit of a certain depth by cutting gates resulting in swap gates and executing subexperiments on shallower circuits.

Like any circuit knitting technique, gate cutting can be described as three consecutive steps:

  • decompose some non-local gates in the circuit and possibly separate the circuit into subcircuits

  • execute many sampled subexperiments using the Qiskit Sampler primitive

  • reconstruct the expectation value of the full-sized circuit

Create a circuit to run on the backend

[1]:
from qiskit.circuit.library import EfficientSU2

circuit = EfficientSU2(num_qubits=4, entanglement="circular").decompose()
circuit.assign_parameters([0.4] * len(circuit.parameters), inplace=True)
circuit.draw("mpl", scale=0.8)
[1]:
../../_images/circuit_cutting_tutorials_02_gate_cutting_to_reduce_circuit_depth_2_0.png

Specify an observable

[2]:
from qiskit.quantum_info import SparsePauliOp

observable = SparsePauliOp(["ZZII", "IZZI", "-IIZZ", "XIXI", "ZIZZ", "IXIX"])

Specify a backend

You can provide either a fake backend or a hardware backend from Qiskit Runtime.

[3]:
from qiskit_ibm_runtime.fake_provider import FakeManilaV2

backend = FakeManilaV2()

Transpile the circuit, visualize the swaps, and note the depth

We choose a layout that requires two swaps to execute the gates between qubits 3 and 0 and another two swaps to return the qubits to their initial positions.

[4]:
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager

pass_manager = generate_preset_pass_manager(
    optimization_level=1, backend=backend, initial_layout=[0, 1, 2, 3]
)

transpiled_qc = pass_manager.run(circuit)
print(f"Transpiled circuit depth: {transpiled_qc.depth(lambda x: len(x[1]) >= 2)}")
Transpiled circuit depth: 30
[5]:
transpiled_qc.draw("mpl", scale=0.4, idle_wires=False, fold=-1)
[5]:
../../_images/circuit_cutting_tutorials_02_gate_cutting_to_reduce_circuit_depth_9_0.png

Replace distant gates with TwoQubitQPDGates by specifying their indices

cut_gates will replace the gates in the specified indices with TwoQubitQPDGates and also return a list of QPDBasis instances – one for each gate decomposition.

[6]:
from circuit_knitting.cutting import cut_gates

# Find the indices of the distant gates
cut_indices = [
    i
    for i, instruction in enumerate(circuit.data)
    if {circuit.find_bit(q)[0] for q in instruction.qubits} == {0, 3}
]

# Decompose distant CNOTs into TwoQubitQPDGate instances
qpd_circuit, bases = cut_gates(circuit, cut_indices)

qpd_circuit.draw("mpl", scale=0.8)
[6]:
../../_images/circuit_cutting_tutorials_02_gate_cutting_to_reduce_circuit_depth_11_0.png

Generate the subexperiments to run on the backend

generate_cutting_experiments accepts a circuit containing TwoQubitQPDGate instances and observables as a PauliList.

To simulate the expectation value of the full-sized circuit, many subexperiments are generated from the decomposed gates’ joint quasiprobability distribution and then executed on one or more backends. The number of samples taken from the distribution is controlled by num_samples, and one combined coefficient is given for each unique sample. For more information on how the coefficients are calculated, refer to the explanatory material.

Note: The observables kwarg to generate_cutting_experiments is of type PauliList. Observable term coefficients and phases are ignored during decomposition of the problem and execution of the subexperiments. They may be re-applied during reconstruction of the expectation value.

[7]:
import numpy as np
from circuit_knitting.cutting import generate_cutting_experiments

# Generate the subexperiments and sampling coefficients
subexperiments, coefficients = generate_cutting_experiments(
    circuits=qpd_circuit, observables=observable.paulis, num_samples=np.inf
)

Calculate the sampling overhead for the chosen cuts

Here we cut three CNOT gates, resulting in a sampling overhead of \(9^3\).

For more on the sampling overhead incurred by circuit cutting, refer to the explanatory material.

[8]:
print(f"Sampling overhead: {np.prod([basis.overhead for basis in bases])}")
Sampling overhead: 729.0

Demonstrate that the QPD subexperiments will be shallower after cutting distant gates

Here is an example of an arbitrarily chosen subexperiment generated from the QPD circuit. Its depth has been reduced by more than half. Many of these probabilistic subexperiments must be generated and evaluated in order to reconstruct an expectation value of the deeper circuit.

[9]:
# Transpile the decomposed circuit to the same layout
transpiled_qpd_circuit = pass_manager.run(subexperiments[100])

print(
    f"Original circuit depth after transpile: {transpiled_qc.depth(lambda x: len(x[1]) >= 2)}"
)
print(
    f"QPD subexperiment depth after transpile: {transpiled_qpd_circuit.depth(lambda x: len(x[1]) >= 2)}"
)
transpiled_qpd_circuit.draw("mpl", scale=0.8, idle_wires=False, fold=-1)
Original circuit depth after transpile: 30
QPD subexperiment depth after transpile: 7
[9]:
../../_images/circuit_cutting_tutorials_02_gate_cutting_to_reduce_circuit_depth_17_1.png

Prepare subexperiments for the backend and run them using the Qiskit Runtime Sampler primitive

[10]:
from qiskit_ibm_runtime import SamplerV2

# Transpile the subeperiments to the backend's instruction set architecture (ISA)
isa_subexperiments = pass_manager.run(subexperiments)

# Set up the Qiskit Runtime Sampler primitive.  For a fake backend, this will use a local simulator.
sampler = SamplerV2(backend=backend)

# Submit the subexperiments
job = sampler.run(isa_subexperiments)

# Retrieve the results
results = job.result()

Reconstruct the expectation values

Reconstruct expectation values for each observable term and combine them to reconstruct the expectation value for the original observable.

[11]:
from circuit_knitting.cutting import reconstruct_expectation_values

reconstructed_expvals = reconstruct_expectation_values(
    results,
    coefficients,
    observable.paulis,
)
# Reconstruct final expectation value
final_expval = np.dot(reconstructed_expvals, observable.coeffs)

Compare the reconstructed expectation values with the exact expectation value from the original circuit and observable

[12]:
from qiskit_aer.primitives import EstimatorV2

estimator = EstimatorV2()
exact_expval = estimator.run([(circuit, observable)]).result()[0].data.evs
print(f"Reconstructed expectation value: {np.real(np.round(final_expval, 8))}")
print(f"Exact expectation value: {np.round(exact_expval, 8)}")
print(f"Error in estimation: {np.real(np.round(final_expval-exact_expval, 8))}")
print(
    f"Relative error in estimation: {np.real(np.round((final_expval-exact_expval) / exact_expval, 8))}"
)
Reconstructed expectation value: 0.87939453
Exact expectation value: 0.50497603
Error in estimation: 0.3744185
Relative error in estimation: 0.74145796