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 Cycles.

Note

You can find an exhaustive list of all available Pass options in the Compiler API reference.

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.

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 Passes.

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]: