How to run big quantum circuits on small quantum computers in PennyLane

Radoica Draškić (Xanadu Resident)

One of the biggest obstacles to practical applications of near-term quantum devices is the limited number of qubits available in the current, NISQ era. However, Peng et al. (2020) has proposed a method to cut big quantum circuits into smaller fragments, which require fewer qubits altogether. These fragments can then be run on a smaller quantum device and their execution results can be recombined, with an additional overhead in classical computation. PennyLane versions v0.22 and above allow you to introduce cuts into your circuit and make use of devices with a limited number of qubits. In this how-to, we will guide you through some examples of circuit cutting and its usage.

The basics

Circuits can be fragmented by introducing the resolution of the identity at the place of the cut. The figure below visualizes this procedure in detail (figure taken from Perlin et al. (2021)).

Here \(\mathcal{B} = \{I, X, Y, Z\}\) is the set of Pauli operators that form an orthogonal basis for the space of single-qubit operators, \(\lambda(M)\) is the spectrum of \(M \in \mathcal{B}\), and \(M_s = |M_s\rangle\langle M_s|\) is the projector onto an eigenstate \(|M_s\rangle\) of \(M\) with eigenvalue \(s\). Red boxes denote the projections and green boxes denote the preparations of a qubit in the corresponding state. This way, individual fragments can be simulated independently and their results recombined with the appropriate post-processing, as shown in the figure above.

Now let’s see how to cut circuits in PennyLane. First, let’s import the required packages.

import pennylane as qml
from pennylane import numpy as np

Circuit cutting is enabled by decorating a QNode with the @qml.cut_circuit transform. For example, to execute the three-wire circuit shown in the figure, which generates a GHZ state on a two-wire device, we can run the following:

dev = qml.device("default.qubit", wires=2)

@qml.cut_circuit
@qml.qnode(dev)
def ghz_circuit():
    qml.Hadamard(wires=0)
    qml.CNOT(wires=[0, 1])
    # cut the circuit into two fragments at wire 1
    qml.WireCut(wires=1)
    qml.CNOT(wires=[1, 2])
    return qml.expval(qml.grouping.string_to_pauli_word("XXX"))

Instead of executing the circuit directly, the circuit will be partitioned into smaller fragments according to the qml.WireCut locations. Combining the results of the fragment executions will recover the expected output of the original uncut circuit:

>>> ghz_circuit()
0.9999999999999992

If we now draw the resulting circuit, we will get a list of two-wire circuits obtained after the cut:

>>> print(qml.draw(ghz_circuit)())
0: ──H─╭●─┤ ╭<X@I> ╭<X@Z> 
1: ────╰X─┤ ╰<X@I> ╰<X@Z> 

0: ──H─╭●─┤ ╭<X@X> 
1: ────╰X─┤ ╰<X@X> 

0: ──H─╭●─┤ ╭<X@Y> 
1: ────╰X─┤ ╰<X@Y> 

0: ──I─╭●─┤ ╭<X@X> 
1: ────╰X─┤ ╰<X@X> 

0: ──X─╭●─┤ ╭<X@X> 
1: ────╰X─┤ ╰<X@X> 

0: ──H─╭●─┤ ╭<X@X> 
1: ────╰X─┤ ╰<X@X> 

0: ──H──S─╭●─┤ ╭<X@X> 
1: ───────╰X─┤ ╰<X@X>

Differentiable circuit cuts

Of course, qml.WireCut is differentiable and we can seamlessly take gradients of our fragmented circuits. For example, consider the following parameterized circuit that can be used for approximating the ground state of a hydrogen molecule:

dev = qml.device("default.qubit", wires=3)

@qml.cut_circuit()
@qml.qnode(dev)
def circuit(x):
    qml.PauliX(wires=0)
    qml.PauliX(wires=1)

    qml.RX(np.pi / 2, wires=0)
    for i in range(1, 4):
        qml.Hadamard(wires=i)
        qml.CNOT(wires=[i - 1, i])
        if i == 1: # we introduce a cut on wire 1
            qml.WireCut(wires=i)
    qml.RZ(2 * x, wires=3)
    for i in reversed(range(1, 4)):
        if i == 1: # we introduce another cut on wire 1
            qml.WireCut(wires=i)
        qml.CNOT(wires=[i - 1, i])
        qml.Hadamard(wires=i)
    qml.RX(-np.pi / 2, wires=0)

    return qml.expval(qml.grouping.string_to_pauli_word("XXYY"))

We can now calculate the gradient in the usual way:

>>> x = np.array(0.12, requires_grad=True)
>>> qml.grad(circuit)(x)
tensor(1.94267595, requires_grad=True)

Automatic circuit cuts

A quantum circuit can be seen as a graph where inputs, outputs, and gates are represented by nodes, with wires represented by edges. PennyLane makes use of this representation when performing circuit cuts. In addition to manually placing the cuts, the @qml.cut_circuit transform also supports automatic cutting via the auto_cutter argument. By specifying the auto_cutter argument, PennyLane will automatically find the locations where the circuit can be cut such that it can be run on a smaller device. The default automatic cutter search algorithm utilizes the KaHyPar package to determine the best cut locations and requires KaHyPar to be installed separately. Let’s consider the circuit that calculates the ground state of a hydrogen molecule again, and use this argument to remove the manual cut at wire 1:

dev = qml.device("default.qubit", wires=3)

@qml.cut_circuit(auto_cutter=True)
@qml.qnode(dev)
def circuit(x):
    qml.PauliX(wires=0)
    qml.PauliX(wires=1)

    qml.RX(np.pi / 2, wires=0)
    for i in range(1, 4):
        qml.Hadamard(wires=i)
        qml.CNOT(wires=[i - 1, i])
    qml.RZ(2 * x, wires=3)
    for i in reversed(range(1, 4)):
        qml.CNOT(wires=[i - 1, i])
        qml.Hadamard(wires=i)
    qml.RX(-np.pi / 2, wires=0)

    return qml.expval(qml.grouping.string_to_pauli_word("XXYY"))

We can now calculate the expectation value and its gradient as usual, obtaining the same results as above:

>>> x = np.array(0.12, requires_grad=True)
>>> circuit(x)
0.23770262642713402
>>> qml.grad(circuit)(x)
tensor(1.94267595, requires_grad=True)

You can also pass custom cutting functions to the auto_cutter argument; these functions should accept a NetworkX MultiDiGraph representing the circuit and return the list of edges to cut. Additional arguments to such custom cutting functions can also be directly supplied as keyword arguments to @qml.cut_circuit.

For more details on the built-in KaHyPar automatic cut placement algorithm, please see qml.transforms.qcut.find_and_place_cuts().

Cutting sampling circuits

In case you want to cut sample-based circuits, you can do so with the help of Monte Carlo methods, through the @qml.cut_circuit_mc transform. You can also provide processing functions that allow the ‘cut’ samples to be processed into expectation values. For example, if wanted to calculate the occupancy of the first orbital of the hydrogen molecule, we could do something like the following:

dev = qml.device("default.qubit", wires=3, shots=10000)

def Z_expectation(bitstring):
    return (-1) ** np.sum(bitstring)

@qml.cut_circuit_mc(classical_processing_fn=Z_expectation)
@qml.qnode(dev)
def circuit(x):
    qml.PauliX(wires=0)
    qml.PauliX(wires=1)

    qml.RX(np.pi / 2, wires=0)
    for i in range(1, 4):
        qml.Hadamard(wires=i)
        qml.CNOT(wires=[i - 1, i])
        if i == 1:
            qml.WireCut(wires=i)
    qml.RZ(2 * x, wires=3)
    for i in reversed(range(1, 4)):
        if i == 1:
            qml.WireCut(wires=i)
        qml.CNOT(wires=[i - 1, i])
        qml.Hadamard(wires=i)
    qml.RX(-np.pi / 2, wires=0)

    return qml.sample(wires=[0])
>>> x = np.array(0.12, requires_grad=True)
>>> circuit(x)
tensor(-0.944, requires_grad=True)

Automatic cut placement is supported with @qml.cut_circuit_mc as well by passing the auto_cutter=True keyword argument.

We just saw how cutting circuits can be very useful and easily performed with PennyLane. However, it also involves a classical computational overhead that depends on how easy it is to cut the circuit into distinct parts. This means that a small circuit with lots of connections between qubits (i.e., multi-qubit gates) can sometimes take longer to run than a larger circuit with fewer connections. If you are interested in digging deeper into quantum circuit cutting, you can take a look at this demo.

Radoica Draškić

Radoica Draškić

Radoica is a trained theoretical physicist and a wannabe computer scientist. He worked as a Xanadu Resident in the summer of 2022.