Download
Download this file as Jupyter notebook: advanced_compiler.ipynb.
Example: Advanced Custom Compilers
This example builds on the Example: Defining Custom Compilers example and explores several more
Pass
options for the
Compiler
that allow us to implement very complex
compilation instructions in a simple and readable fashion.
True-Q™'s compilation tools work by applying an ordered list of built-in
and/or user-specified Pass
objects to a circuit in
order. A Pass
defines rules for how to decompose,
replace, or remove a Gate
(or more generally, an
Operation
), while possibly also adding or removing
Cycle
s.
Let’s go through a concrete example. We start by defining a custom gateset through
a Config
object and then develop a highly customized compiler
with a total of 15 passes that takes an example circuit and compiles it to our
chosen gateset with a series of additional configuration options.
We restrict our gateset to consist only of a single-qubit \(X\) and \(Z\) gate and use the \(CX\) gate as our native entangling gate:
[2]:
import trueq as tq
import trueq.compilation as tqc
config = tq.Config.from_yaml(
"""
Mode: ZXZ
Dimension: 2
Gates:
- X(theta):
Hamiltonian:
- [X, theta]
- Z(phi):
Hamiltonian:
- [Z, phi]
- CX:
Matrix:
- [1.0, 0.0, 0.0, 0.0]
- [0.0, 1.0, 0.0, 0.0]
- [0.0, 0.0, 0.0, 1.0]
- [0.0, 0.0, 1.0, 0.0]
Involving:
(0, 1): 2
(1, 2): 0
"""
)
Here, we also impose a restriction on single-qubit operations
during the entangling gates (see our Example: Configuring Native Gates example for more
information on Config
definitions).
Using this config
object, we now define our compiler as follows:
[3]:
custom_passes = [
tqc.CycleReplacement(
target=tq.Cycle({(0, 1): tq.Gate.cz}),
replacement=3 * [tq.Cycle({(0, 1): tq.Gate.cz})],
),
tqc.Merge(max_sys=2),
tqc.Native2Q(factories=config.factories, mode="ZXZ"),
tqc.Merge(max_sys=1),
tqc.Native1Q(factories=config.factories, mode="ZXZ"),
tqc.RemoveId(),
tqc.Justify(),
tqc.InvolvingRestrictions(factories=config.factories),
tqc.MarkCycles(),
tqc.Justify(),
tqc.CycleReplacement(
target=tq.Cycle({(1, 2): tq.Gate.cx}),
replacement=tq.Cycle({(1, 2): tq.Gate.cz, 0: tq.Gate.id}, marker=2),
),
tqc.Native2Q(factories=config.factories, mode="ZXZ"),
tqc.Merge(max_sys=2, marker=2),
tqc.RemoveId(marker=2),
tqc.Merge(max_sys=1),
]
# now define a compiler which executes those passes in order
custom_compiler = tqc.Compiler(passes=custom_passes)
We will see the effect of each pass in due time, but first notice that some passes
are applied twice, for example the Justify
pass is
applied as both the 7th pass and 10th pass.
This emphasizes the fact that the passes that are given to a
Compiler
are applied sequentially and that the order in
which they appear in the list matters. To see this more explicitly, and to
understand to effect of each pass that appears in custom_passes
, let’s decompose
the action of custom_compiler
to show what what happens for every pass.
First, let’s define an example circuit:
[4]:
circuit = tq.Circuit(
cycles=[
tq.Cycle({(0, 1): tq.Gate.cz}),
tq.Cycle({0: tq.Gate.h, 1: tq.Gate.h}),
tq.Cycle({(2, 1): tq.Gate.cx}),
tq.Cycle({(0, 1): tq.Gate.cy, 2: tq.Gate.z}),
]
)
circuit.draw(interactive=False)
[4]:
With this example circuit in mind, let’s go through the action of each pass appearing
in the custom_compiler
.
Replacing cycles in a circuit
The first pass is a CycleReplacement
pass that
replaces a target cycle with an alternate cycle or a list of sequential cycles. Any
pass can be applied to a circuit by calling the apply
method. To show the effects
of the first pass in the custom_compiler
, we will first recreate the pass and
then use the apply
method to recompile the example circuit according to the pass.
Note
While we can use apply
to apply individual compiler passes to a circuit, it
is more performant to use Compiler
when applying
multiple passes.
[5]:
# define a target cycle, CZ on (0, 1), and its replacement, 3 sequential CZs on (0, 1)
target_cycle = tq.Cycle({(0, 1): tq.Gate.cz})
replacement_cycles = 3 * [target_cycle]
# this is the first pass in the custom_compiler
pass1 = tqc.CycleReplacement(target=target_cycle, replacement=replacement_cycles)
# now apply the CycleReplacement pass to the example circuit
compiled_circuit = pass1.apply(circuit)
compiled_circuit.draw(interactive=False)
[5]:
The circuit drawn above is the form the example circuit will take after the first pass
of the custom compiler is applied. The CycleReplacement
pass replaced the target cycle, \(CZ\) on the pair (0, 1), by the replacement
cycles, three sequential \(CZ\)s (which acts equivalently to a single
\(CZ\) in the absence of any noise).
Merging cycles
The second pass is a Merge
pass that takes a
max_sys
integer argument. Merge(max_sys=n)
looks for any sequential gates that
act on a subset of at most n
qubits and merges them into a single gate.
[6]:
pass2 = tqc.Merge(max_sys=2)
compiled_circuit = pass2.apply(compiled_circuit)
compiled_circuit.draw()
[6]:
In the above example, applying Merge(max_sys=2)
reduces the circuit to three
(non-native) entangling gates. Also note that the single-qubit \(H\) and \(Z\)
gates were absorbed into the merged two-qubit gates.
Converting to a different two-qubit gateset
The third pass of the custom_compiler
is a Native2Q
pass. This pass takes any two-qubit gates appearing in the circuit, and expresses them
in terms of the native entanglers defined by our config
object (in this case
\(CX\) gates):
[7]:
pass3 = tqc.Native2Q(factories=config.factories, mode="ZXZ")
compiled_circuit = pass3.apply(compiled_circuit)
compiled_circuit.draw()
[7]:
Note
Single-qubit gates are not converted by this pass and the resulting single-qubit
gates may therefore not be native gates. To transpile the single-qubit gates, we can
use the Native1Q
(as shown in the 5th pass below).
The fourth pass is another Merge
pass, this time with
the argument max_sys=1
. Applying this pass to the example circuit yields:
[8]:
pass4 = tqc.Merge(max_sys=1)
compiled_circuit = pass4.apply(compiled_circuit)
compiled_circuit.draw()
[8]:
As you can see, Merge(max_sys=1)
takes subsequent single-qubit gates and
merges them into a single undivided gate, as has happened here to e.g. qubit 0.
Converting to a different single-qubit gateset
The 5th pass is a Native1Q
pass that re-expresses
single-qubit gates into the native gates defined by our config
object from above
(i.e. \(X\) and \(Z\) gates). In this example, we have set mode=ZXZ
to
ensure that single qubit gates are expressed in the form
\(Z(\alpha)X(\beta)Z(\gamma)\). Applying the 5th pass to the example circuit
yields:
[9]:
pass5 = tqc.Native1Q(factories=config.factories, mode="ZXZ")
compiled_circuit = pass5.apply(compiled_circuit)
compiled_circuit.draw()
[9]:
Removing identities
The 6th pass in the custom_compiler
is a RemoveId
pass that removes the identity gates appearing in the circuit decomposition:
[10]:
pass6 = tqc.RemoveId()
compiled_circuit = pass6.apply(compiled_circuit)
compiled_circuit.draw(interactive=False)
[10]:
Justify
The 7th pass of the custom_compiler
is a Justify
pass. As seen above, the removal of identity gates has left some cycles that consist
of just a single single-qubit gate. To compress these cycles, we use
Justify
which has two effects. First, it moves every
gate as far to the right as possible before entering a cycle with non-trivial action
on that qubit. Second, it removes any leftover empty cycles. Here is the resulting
circuit:
[11]:
# apply a Justify pass to the example circuit
pass7 = tqc.Justify()
compiled_circuit = pass7.apply(compiled_circuit)
compiled_circuit.draw(interactive=False)
[11]:
Note that the application of the Justify
pass has
shortened the circuit by two cycles.
Restricting simultaneous operations
The 8th pass of the custom_compiler
is a
InvolvingRestrictions
pass. The action of
Justify
has moved some entangling gates and
single-qubit gates into the same cycle. Some quantum processors however may have
physical restrictions that prevent such simultaneity.
These restrictions are usually specified in the Config
object.
InvolvingRestrictions
enforces such restrictions:
[12]:
pass8 = tqc.InvolvingRestrictions(factories=config.factories)
compiled_circuit = pass8.apply(compiled_circuit)
compiled_circuit.draw(interactive=False)
[12]:
Note that there are no longer any simultaneous single-qubit gates in cycles with \(CX\) gates which was forbidden by the configuration we defined above.
Adding cycle markers
The 9th pass of the custom_compiler
is a
MarkCycles
pass that adds a counting marker to every
cycle that contains at least one entangling gate (in the Randomized Compiling
formalism those cycles are usually referred to as “hard cycles”. Take a look at the
Example: Customizing Randomized Compiling with Cycle Markers example for more information):
[13]:
pass9 = tqc.MarkCycles()
compiled_circuit = pass9.apply(compiled_circuit)
compiled_circuit.draw(interactive=False)
[13]:
This is the circuit produced by applying the first nine passes of the
custom_compiler
to the example circuit. Markers for cycles are shown as a blue
number above the marked cycles. Marked cycles are shielded from some
Pass
es.
For example, marked cycles do not mix with unmarked cycles when a
Justify
pass is applied, as in the 10th pass of the
custom_compiler
:
[14]:
pass10 = tqc.Justify()
compiled_circuit = pass10.apply(compiled_circuit)
compiled_circuit.draw(interactive=False)
[14]:
Marked cycles can still be replaced by a
CycleReplacement
pass, as in the 11th pass of the
custom_compiler
where we replace the second \(CX\) gate in the circuit with
a \(CZ\) gate and an identity:
[15]:
pass11 = tqc.CycleReplacement(
target=tq.Cycle({(1, 2): tq.Gate.cx}),
replacement=tq.Cycle({(1, 2): tq.Gate.cz, 0: tq.Gate.id}, marker=2),
)
compiled_circuit = pass11.apply(compiled_circuit)
compiled_circuit.draw(interactive=False)
[15]:
Note how the cycle retained its marker value.
For any Pass
that would expand a marked cycle
as a sequence of multiple cycles, the added cycles inherit the marker from the
original cycle. This can happen when applying
Native1Q
, Native2Q
or
InvolvingRestrictions
. Here, we apply another
Native2Q
pass to replace the \(CZ\) gate with a
\(CX\) gate again, so the marked cycle will expand to accommodate the new
single-qubit gates required for the conversion:
[16]:
pass12 = tqc.Native2Q(factories=config.factories, mode="ZXZ")
compiled_circuit = pass12.apply(compiled_circuit)
compiled_circuit.draw(interactive=False)
[16]:
The 13th pass is another
Merge
pass. Naturally,
Merge
doesn’t merge distinctly marked cycles. However,
it can target a specific marker value and only merge cycles with that marker:
[17]:
pass13 = tqc.Merge(max_sys=2, marker=2)
compiled_circuit = pass13.apply(compiled_circuit)
compiled_circuit.draw(interactive=False)
[17]:
The 14th pass of the custom_compiler
is a another
RemoveId
pass. This pass too would normally not affect
marked cycles unless the marker matches that specified by the marker
option:
[18]:
pass14 = tqc.RemoveId(marker=2)
compiled_circuit = pass14.apply(compiled_circuit)
compiled_circuit.draw(interactive=False)
[18]:
Finally, the 15th pass of the custom_compiler
is a another
Merge
pass for single-qubit gates:
[19]:
pass15 = tqc.Merge(max_sys=1)
compiled_circuit = pass15.apply(compiled_circuit)
compiled_circuit.draw(interactive=False)
[19]:
Notice that the circuit above, which was obtained by sequentially applying each pass
appearing in custom_passes
, is the same as the circuit obtained
by compiling our initially defined circuit
with our custom_compiler
:
[20]:
compiled_circuit = custom_compiler.compile(circuit)
compiled_circuit.draw(interactive=False)
[20]:
Download
Download this file as Jupyter notebook: advanced_compiler.ipynb.