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:
Quantum function transform to remove any operations that are applied next to their (self-)inverses or adjoint. Quantum transform to move commuting gates past control and target qubits of controlled operations. Quantum function transform to combine amplitude embedding templates that act on different qubits. Quantum function transform to remove any operations that are applied next to their (self-)inverses or adjoint. Quantum transform to combine rotation gates of the same type that act sequentially. Function that applies the pattern matching algorithm and returns the list of maximal matches. Quantum transform to remove Barrier gates. Quantum function transform to fuse together groups of single-qubit operations into a general single-qubit unitary operation ( Quantum function transform to remove SWAP gates by running from right to left through the circuit changing the position of the qubits accordingly. Decomposes a quantum circuit into a user-specified gate set.
Rot
).
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.