Compiling circuits

PennyLane offers multiple tools for compiling circuits. We use the term “compilation” here in a loose sense as the process of transforming one circuit into one or more differing circuits. A circuit could be either a quantum function or a sequence of operators. For example, such a transformation could replace a gate type with another, fuse gates, exploit mathematical relations that simplify an observable, or replace a large circuit by a number of smaller circuits.

Compilation functionality is mostly designed as transforms; see the the transforms documentation for more details, as well as information on how to write your own custom transforms.

In addition to quantum circuit transforms, PennyLane also supports experimental just-in-time compilation, via the qjit() decorator and Catalyst. This is more general, and supports full hybrid compilation — compiling both the classical and quantum components of your workflow into a binary that can be run close to the accelerators. that you are using. More details can be found in compiling workflows.

Simplifying Operators

PennyLane provides the simplify() function to simplify single operators, quantum functions, QNodes and tapes. This function has several purposes:

  • Reducing the arithmetic depth of the given operators to its minimum.

  • Grouping like terms in sums and products.

  • Resolving products of Pauli operators.

  • Combining identical rotation gates by summing its angles.

Here are some simple simplification routines:

>>> qml.simplify(qml.RX(4*np.pi+0.1, 0 ))
RX(0.09999999999999964, wires=[0])
>>> qml.simplify(qml.adjoint(qml.RX(1.23, 0)))
RX(11.336370614359172, wires=[0])
>>> qml.simplify(qml.ops.Pow(qml.RX(1, 0), 3))
RX(3.0, wires=[0])
>>> qml.simplify(qml.sum(qml.Y(3), qml.Y(3)))
2.0 * Y(3)
>>> qml.simplify(qml.RX(1, 0) @ qml.RX(1, 0))
RX(2.0, wires=[0])
>>> qml.simplify(qml.prod(qml.X(0), qml.Z(0)))
-1j * Y(0)

Now lets simplify a nested operator:

>>> sum_op = qml.RX(1, 0) + qml.X(0)
>>> prod1 = qml.X(0) @ sum_op
>>> nested_op = qml.prod(prod1, qml.RX(1, 0))
>>> qml.simplify(nested_op)
(X(0) @ RX(2.0, wires=[0])) + RX(1.0, wires=[0])

Several simplifications steps are happening here. First of all, the nested products are removed:

qml.prod(qml.X(0), qml.sum(qml.RX(1, 0), qml.X(0)), qml.RX(1, 0))

Then the product of sums is transformed into a sum of products:

qml.sum(qml.prod(qml.X(0), qml.RX(1, 0), qml.RX(1, 0)), qml.prod(qml.X(0), qml.X(0), qml.RX(1, 0)))

And finally like terms in the obtained products are grouped together, removing all identities:

qml.sum(qml.prod(qml.X(0), qml.RX(2, 0)), qml.RX(1, 0))

As mentioned earlier we can also simplify QNode objects to, for example, group rotation gates:

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

@qml.simplify
@qml.qnode(dev)
def circuit(x):
    (
        qml.RX(x[0], wires=0)
        @ qml.RY(x[1], wires=1)
        @ qml.RZ(x[2], wires=2)
        @ qml.RX(-1, wires=0)
        @ qml.RY(-2, wires=1)
        @ qml.RZ(2, wires=2)
    )
    return qml.probs([0, 1, 2])
>>> x = [1, 2, 3]
>>> print(qml.draw(circuit)(x))
0: ───────────┤ ╭Probs
1: ───────────┤ ├Probs
2: ──RZ(5.00)─┤ ╰Probs

Compilation transforms for circuit optimization

PennyLane includes multiple transforms that take quantum functions and return new quantum functions of optimized circuits:

cancel_inverses

Quantum function transform to remove any operations that are applied next to their (self-)inverses or adjoint.

commute_controlled

Quantum transform to move commuting gates past control and target qubits of controlled operations.

merge_amplitude_embedding

Quantum function transform to combine amplitude embedding templates that act on different qubits.

cancel_inverses

Quantum function transform to remove any operations that are applied next to their (self-)inverses or adjoint.

merge_rotations

Quantum transform to combine rotation gates of the same type that act sequentially.

pattern_matching

Function that applies the pattern matching algorithm and returns the list of maximal matches.

remove_barrier

Quantum transform to remove Barrier gates.

single_qubit_fusion

Quantum function transform to fuse together groups of single-qubit operations into a general single-qubit unitary operation (Rot).

undo_swaps

Quantum function transform to remove SWAP gates by running from right to left through the circuit changing the position of the qubits accordingly.

decompose

Decomposes a quantum circuit into a user-specified gate set.

Note

Most compilation transforms support just-in-time compilation with jax.jit.

The compile() transform allows you to chain together sequences of quantum function transforms into custom circuit optimization pipelines.

For example, take the following decorated quantum function:

dev = qml.device('default.qubit', wires=[0, 1, 2])

@qml.compile
@qml.qnode(dev)
def circuit(x, y, z):
    qml.Hadamard(wires=0)
    qml.Hadamard(wires=1)
    qml.Hadamard(wires=2)
    qml.RZ(z, wires=2)
    qml.CNOT(wires=[2, 1])
    qml.RX(z, wires=0)
    qml.CNOT(wires=[1, 0])
    qml.RX(x, wires=0)
    qml.CNOT(wires=[1, 0])
    qml.RZ(-z, wires=2)
    qml.RX(y, wires=2)
    qml.Y(wires=2)
    qml.CZ(wires=[1, 2])
    return qml.expval(qml.Z(wires=0))

The default behaviour of compile() applies a sequence of three transforms: commute_controlled(), cancel_inverses(), and then merge_rotations().

>>> print(qml.draw(circuit)(0.2, 0.3, 0.4))
0: ──H──RX(0.60)─────────────────┤  <Z>
1: ──H─╭X─────────────────────╭●─┤
2: ──H─╰●─────────RX(0.30)──Y─╰Z─┤

The compile() transform is flexible and accepts a custom pipeline of quantum function transforms (you can even write your own!). For example, if we wanted to only push single-qubit gates through controlled gates and cancel adjacent inverses, we could do:

from pennylane.transforms import commute_controlled, cancel_inverses
from functools import partial

pipeline = [commute_controlled, cancel_inverses]

@partial(qml.compile, pipeline=pipeline)
@qml.qnode(dev)
def qfunc(x, y, z):
    qml.Hadamard(wires=0)
    qml.Hadamard(wires=1)
    qml.Hadamard(wires=2)
    qml.RZ(z, wires=2)
    qml.CNOT(wires=[2, 1])
    qml.RX(z, wires=0)
    qml.CNOT(wires=[1, 0])
    qml.RX(x, wires=0)
    qml.CNOT(wires=[1, 0])
    qml.RZ(-z, wires=2)
    qml.RX(y, wires=2)
    qml.Y(wires=2)
    qml.CZ(wires=[1, 2])
    return qml.expval(qml.Z(wires=0))
>>> print(qml.draw(qfunc)(0.2, 0.3, 0.4))
0: ──H──RX(0.40)──RX(0.20)────────────────────────────┤  <Z>
1: ──H─╭X──────────────────────────────────────────╭●─┤
2: ──H─╰●─────────RZ(0.40)──RZ(-0.40)──RX(0.30)──Y─╰Z─┤

Note

The Barrier operator can be used to prevent blocks of code from being merged during compilation.

For more details on compile() and the available compilation transforms, visit the compilation documentation.

Custom Operator Decomposition

PennyLane decomposes gates unknown to the device into other, “lower-level” gates. As a user, you may want to fine-tune this mechanism. For example, you may wish your circuit to use different fundamental gates.

For example, suppose we would like to implement the following QNode:

def circuit(weights):
    qml.BasicEntanglerLayers(weights, wires=[0, 1, 2])
    return qml.expval(qml.Z(0))

original_dev = qml.device("default.qubit", wires=3)
original_qnode = qml.QNode(circuit, original_dev)
>>> weights = np.array([[0.4, 0.5, 0.6]])
>>> print(qml.draw(original_qnode, level="device")(weights))
0: ──RX(0.40)─╭●────╭X─┤  <Z>
1: ──RX(0.50)─╰X─╭●─│──┤
2: ──RX(0.60)────╰X─╰●─┤

Now, let’s swap out PennyLane’s default decomposition of the CNOT gate into CZ and Hadamard. We define the custom decompositions like so, and pass them to a device:

def custom_cnot(wires, **_):
    return [
        qml.Hadamard(wires=wires[1]),
        qml.CZ(wires=[wires[0], wires[1]]),
        qml.Hadamard(wires=wires[1])
    ]

custom_decomps = {qml.CNOT: custom_cnot}

decomp_dev = qml.device("default.qubit", wires=3, custom_decomps=custom_decomps)
decomp_qnode = qml.QNode(circuit, decomp_dev)

Note that custom decomposition functions should accept keyword arguments even when it is not used.

Now when we draw or run a QNode on this device, the gates will be expanded according to our specifications:

>>> print(qml.draw(decomp_qnode, level="device")(weights))
0: ──RX(0.40)────╭●──H───────╭Z──H─┤  <Z>
1: ──RX(0.50)──H─╰Z──H─╭●────│─────┤
2: ──RX(0.60)──H───────╰Z──H─╰●────┤

Note

If the custom decomposition is only supposed to be used in a specific code context, a separate context manager set_decomposition() can be used.

Circuit Decomposition

When compiling a circuit it is often beneficial to decompose the circuit into a set of basis gates. To do this, we can use the decompose() function, which enables decomposition of circuits into a set of gates defined either by their name, type, or by a set of rules they must follow.

Using a gate set

The example below demonstrates how a three-wire circuit can be decomposed using a pre-defined set of gates:

from pennylane.transforms import decompose
from functools import partial

dev = qml.device('default.qubit')
allowed_gates = {qml.Toffoli, qml.RX, qml.RZ}

@partial(decompose, gate_set=allowed_gates)
@qml.qnode(dev)
def circuit():
    qml.Hadamard(wires=[0])
    qml.Toffoli(wires=[0,1,2])
    return qml.expval(qml.Z(0))

With the Hadamard gate not in our gate set, it will be decomposed into the respective rotation gate operators.

>>> print(qml.draw(circuit)())
0: ──RZ(1.57)──RX(1.57)──RZ(1.57)─╭●─┤  <Z>
1: ───────────────────────────────├●─┤
2: ───────────────────────────────╰X─┤

Using a gate rule

The example below demonstrates how a three-wire circuit can be decomposed into single or two-qubit gates using a rule:

@partial(decompose, gate_set=lambda op: len(op.wires)<=2)
@qml.qnode(dev)

def circuit():
    qml.Toffoli(wires=[0,1,2])
    return qml.expval(qml.Z(0))
>>> print(qml.draw(circuit)())
0: ───────────╭●───────────╭●────╭●──T──╭●─┤  <Z>
1: ────╭●─────│─────╭●─────│───T─╰X──T†─╰X─┤
2: ──H─╰X──T†─╰X──T─╰X──T†─╰X──T──H────────┤

Decomposition in stages

You can use the max_expansion argument to control the number of decomposition stages applied to the circuit. By default, the function will decompose the circuit until the desired gate set is reached.

The example below shows how the user can visualize the decomposition. We begin with creating a QuantumPhaseEstimation circuit:

phase = 1
target_wires = [0]
unitary = qml.RX(phase, wires=0).matrix()
n_estimation_wires = 3
estimation_wires = range(1, n_estimation_wires + 1)

@qml.qnode(qml.device('default.qubit'))
def circuit():
    # Start in the |+> eigenstate of the unitary
    qml.Hadamard(wires=target_wires)
    qml.QuantumPhaseEstimation(
        unitary,
        target_wires=target_wires,
        estimation_wires=estimation_wires,
    )

From here, we can iterate through the stages of decomposition:

>>> print(qml.draw(decompose(circuit, max_expansion=0))())
0: ──H─╭QuantumPhaseEstimation─┤
1: ────├QuantumPhaseEstimation─┤
2: ────├QuantumPhaseEstimation─┤
3: ────╰QuantumPhaseEstimation─┤
>>> print(qml.draw(decompose(circuit, max_expansion=1))())
0: ──H─╭U(M0)⁴─╭U(M0)²─╭U(M0)¹───────┤
1: ──H─╰●──────│───────│───────╭QFT†─┤
2: ──H─────────╰●──────│───────├QFT†─┤
3: ──H─────────────────╰●──────╰QFT†─┤
>>> print(qml.draw(decompose(circuit, max_expansion=2))())
0: ──H──RZ(11.00)──RY(1.14)─╭X──RY(-1.14)──RZ(-9.42)─╭X──RZ(-1.57)──RZ(1.57)──RY(1.00)─╭X──RY(-1.00)
1: ──H──────────────────────╰●───────────────────────╰●────────────────────────────────│────────────
2: ──H─────────────────────────────────────────────────────────────────────────────────╰●───────────
3: ──H──────────────────────────────────────────────────────────────────────────────────────────────
───RZ(-6.28)─╭X──RZ(4.71)──RZ(1.57)──RY(0.50)─╭X──RY(-0.50)──RZ(-6.28)─╭X──RZ(4.71)─────────────────
─────────────│────────────────────────────────│────────────────────────│──╭SWAP†────────────────────
─────────────╰●───────────────────────────────│────────────────────────│──│─────────────╭(Rϕ(1.57))†
──────────────────────────────────────────────╰●───────────────────────╰●─╰SWAP†─────H†─╰●──────────
────────────────────────────────────┤
──────╭(Rϕ(0.79))†─╭(Rϕ(1.57))†──H†─┤
───H†─│────────────╰●───────────────┤
──────╰●────────────────────────────┤

Circuit cutting

Circuit cutting allows you to replace a circuit with N wires by a set of circuits with less than N wires (see also Peng et. al). Of course this comes with a cost: The smaller circuits require a greater number of device executions to be evaluated.

In PennyLane, circuit cutting can be activated by positioning WireCut operators at the desired cut locations, and by decorating the QNode with the cut_circuit() transform.

The example below shows how a three-wire circuit can be run on a two-wire device:

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

@qml.cut_circuit
@qml.qnode(dev)
def circuit(x):
    qml.RX(x, wires=0)
    qml.RY(0.9, wires=1)
    qml.RX(0.3, wires=2)

    qml.CZ(wires=[0, 1])
    qml.RY(-0.4, wires=0)

    qml.WireCut(wires=1)

    qml.CZ(wires=[1, 2])

    return qml.expval(qml.pauli.string_to_pauli_word("ZZZ"))

Instead of being executed directly, the circuit will be partitioned into smaller fragments according to the WireCut locations, and each fragment will be executed multiple times. PennyLane automatically combines the results of the fragment executions to recover the expected output of the original uncut circuit.

>>> x = np.array(0.531, requires_grad=True)
>>> circuit(0.531)
0.47165198882111165

Circuit cutting support is also differentiable:

>>> qml.grad(circuit)(x)
-0.276982865449393

Note

Simulated quantum circuits that produce samples can be cut using the cut_circuit_mc() transform, which is based on the Monte Carlo method.

Groups of commuting Pauli words

Mutually commuting Pauli words can be measured simultaneously on a quantum computer. Finding groups of mutually commuting observables can therefore reduce the number of circuit executions, and is an example of how observables can be “compiled”.

PennyLane contains different functionalities for this purpose, ranging from higher-level transforms acting on QNodes to lower-level functions acting on operators.

An example of a transform manipulating QNodes is split_non_commuting(). It turns a QNode that measures non-commuting observables into a QNode that internally uses multiple circuit executions with qubit-wise commuting groups. The transform is used by devices to make such measurements possible.

On a lower level, the group_observables() function can be used to split lists of observables and coefficients:

>>> obs = [qml.Y(0), qml.X(0) @ qml.X(1), qml.Z(1)]
>>> coeffs = [1.43, 4.21, 0.97]
>>> groupings = qml.pauli.group_observables(obs, coeffs, 'anticommuting', 'lf')
>>> obs_groupings, coeffs_groupings = groupings
>>> obs_groupings
[[Z(1), X(0) @ X(1)], [Y(0)]]
>>> coeffs_groupings
[[0.97, 4.21], [1.43]]

This and more logic to manipulate Pauli observables is found in the pauli module.