Circuit optimization, gate alignment, and spin echoes

View on QuantumAI Run in Google Colab View source on GitHub Download notebook

This tutorial shows how to prepare circuits to run on the Quantum Computing Service (QCS) and optimize them to improve performace, showing an example of the procedure outlined in the Best practices guide. This is an "advanced" tutorial where you will learn to perform the following optimization techniques:

  1. Converting to target gateset
  2. Ejecting single-qubit operations
  3. Aligning gates in moments
  4. Inserting spin echoes to reduce leakage & cross-talk.


    import cirq
except ImportError:
    print("installing cirq...")
    !pip install --quiet cirq
    print("installed cirq.")
import matplotlib.pyplot as plt
import numpy as np

import cirq
import cirq_google as cg

Getting OAuth2 credentials.
Press enter after entering the verification code.
Authentication complete.
Successful authentication to Google Cloud.

Preparing circuits to run on QCS

For the sake of this tutorial, we will use a circuit structure created by the create_benchmark_circuit function defined above.

"""Create an example circuit."""
qubits = cirq.GridQubit.rect(2, 3)  # [cirq.GridQubit(x, y) for (x, y) in [(3, 2), (4, 2), (4, 1), (5, 1), (6, 1), (6, 2), (5, 2)]]
circuit = create_benchmark_circuit(qubits, twoq_gate=cirq.ISWAP, cycles=3, seed=1)

print("Example benchmark circuit:\n")
Example benchmark circuit:

This circuit divides the qubits into two registers: a single ancilla (top qubit) as the first register, and the remaining qubits as the second register. First, the ancilla is excited into the \(|1\rangle\) state and coupled to the second register. Then, a Loschmidt echo is performed on the second register. Last, the ancilla is uncoupled from the second register and measured. Without any noise, the only measurement result should be \(1\).

"""Without noise, only the 1 state is measured."""
result = cirq.Simulator().run(circuit, repetitions=1000)
Counter({1: 1000})

We choose this circuit as an example for two reasons:

  1. Each gate in the circuit is in its own cirq.Moment, so this is a poor circuit structure to run on devices without any optimization / alignment.

  2. The ancilla qubit is idle except at the start and end of the circuit, so this is a prime example where adding spin echoes can improve performance.

A similar circuit was used in Information Scrambling in Computationally Complex Quantum Circuits (see Fig. S10) to benchmark the performance of spin echoes.

Starting from this circuit, we show how to optimize gates, align moments, and insert spin echoes to improve the performance on a real device.

Convert to target gateset

To run on a device, all gates in the circuit will be converted to a gateset supported by that device. (See the Device specifications guide for information on supported gatesets.)

We will use the \(\sqrt{\text{iSWAP} }\) gateset in this tutorial. You can see this gateset and others as follows.

# Create an Engine object to use.
spec = cg.Engine(project_id).get_processor(processor_id).get_device_specification()

# Iterate through each gate set valid on the device.
for gateset in spec.valid_gate_sets:
    # Prints each gate valid in the set with its duration
    for gate in gateset.valid_gates:
        print('%s %d' % (, gate.gate_duration_picos))
syc 12000
xy 25000
xy_pi 25000
xy_half_pi 25000
z 0
xyz 25000
meas 4000000
wait 0

fsim_pi_4 32000
inv_fsim_pi_4 32000
xy 25000
z 0
xyz 25000
meas 4000000
wait 0

fsim 0
xy 25000
z 0
xyz 25000
meas 4000000
wait 0

xy 25000
z 0
xyz 25000
cz 0
meas 4000000

To convert gates to this gateset, use the cirq.MergeInteractionsToSqrtIswap optimizer. This optimizer merges all consecutive (one- and two-qubit) interactions on two qubits into a unitary matrix and then decomposes this unitary using \(\sqrt{\text{iSWAP} }\) gates in an attempt to (a) convert to the target gateset and (b) reduce the circuit depth by reducing the number of operations.

"""Compile an arbitrary two-qubit operation to the sqrt_iswap gateset."""
ops = cirq.two_qubit_matrix_to_sqrt_iswap_operations(
    q0=qubits[0], q1=qubits[1], mat=cirq.testing.random_unitary(dim=4, random_state=1)

Eject single-qubit operations

After converting to a target gateset, you can use various circuit optimizers to attempt to reduce the number of gates as shown below.

The cirq.eject_phased_paulis optimizer pushes cirq.X, cirq.Y, and cirq.PhasedXPowGate gates towards the end of the circuit.

circuit = cirq.eject_phased_paulis(circuit)

Note that, for example, the back-to-back cirq.X gates on the ancilla have been removed from the start of the circuit.

You can also use the cirq.eject_z optimizer to attempt to push cirq.Z gates towards the end of the circuit.

circuit = cirq.eject_z(circuit)

Note that, for example, the cirq.Z gate immediately before the ancilla measurement has been removed.

Align gates in moments

After optimizing, gates should be aligned into cirq.Moments to satisfy the following criteria:

  • The fewer moments the better (generally speaking).

    • Each moment is a discrete time slice, so fewer moments means shorter circuit execution time.
  • Moments should consist of gates with similar durations.

    • Otherwise some qubits will be idle for part of the moment.
    • It's best to align one-qubit gates in their own moment and two-qubit gates in their own moment if possible.
  • All measurements should be terminal and in a single moment.

    • Intermediate measurements are not currently supported, and measurement operation times are roughly two orders of magnitude longer than other gate times (see the above cell which prints out gatesets and gate times).

To align gates into moments and push them as far left as possible, use cirq.align_left.

left_aligned_circuit = cirq.align_left(circuit)

Note how many fewer moments this aligned circuit has.

print(f"Original circuit has {len(circuit)} moments.")
print(f"Aligned circuit has {len(left_aligned_circuit)} moments.")
Original circuit has 54 moments.
Aligned circuit has 14 moments.

You can also align gates and push them to the right with cirq.align_right.

right_aligned_circuit = cirq.align_right(circuit)

Also, you can use cirq.stratified_circuit to align operations into similar categories. For example, you can align single-qubit and two-qubit operations in separate moments as follows.

circuit = cirq.stratified_circuit(
    circuit, categories=[lambda op : len(op.qubits) == 1, lambda op : len(op.qubits) == 2]

Note that each moment now only contains single-qubit gates or two-qubit gates.

Drop moments

To drop moments that have a tiny effect or moments that are empty, you can use the following optimizers.

circuit = cirq.drop_negligible_operations(circuit)
circuit = cirq.drop_empty_moments(circuit)

Synchronize terminal measurements

You can use the cirq.synchronize_terminal_measurements to move all measurements to the final moment if it can accommodate them (without overlapping with other operations).

circuit = cirq.synchronize_terminal_measurements(circuit)

Adding spin echoes

Dynamical decoupling applies a series of spin echoes to otherwise idle qubits to reduce decoherent effects. As mentioned above, spin echoes were used as an effective error mitigation technique in Information Scrambling in Computationally Complex Quantum Circuits, and the performance of any circuit with idle qubits can potentially be improved by adding spin echoes.

The following codeblock shows how to insert spin echoes on the ancilla qubit.

# Gates for spin echoes. Note that these gates are self-inverse.
pi_pulses = [
    cirq.PhasedXPowGate(phase_exponent=p, exponent=1.0) for p in (-0.5, 0.0, 0.5, 1.0)

# Generate spin echoes on ancilla.
num_echoes = 3
random_state = np.random.RandomState(1)

spin_echo = []
for _ in range(num_echoes):
    op = random_state.choice(pi_pulses).on(qubits[0])
    spin_echo += [op, cirq.inverse(op)]

# Insert spin echo operations to circuit.
optimized_circuit_with_spin_echoes = circuit.copy()
optimized_circuit_with_spin_echoes.insert(5, spin_echo)

# Align single-qubit spin echo gates into other moments of single-qubit gates.
optimized_circuit_with_spin_echoes = cirq.stratified_circuit(
    categories=[lambda op : len(op.qubits) == 1, lambda op : len(op.qubits) == 2]

The ancilla now has spin echoes between the two-qubit gates at the start/end of the circuit instead of remaining idle.


Now that we have discussed how to remove uncessary gates, align gates, and insert spin echoes, we run an experiment to benchmark the results. First we get a line of qubits, list of cycle values (one circuit per cycle value), and set other experimental parameters.

"""Set experiment parameters."""
qubits = cg.line_on_device(device_sampler.device, length=7)
cycle_values = range(0, 100 + 1, 4)
nreps = 20_000
seed = 1

The create_benchmark_circuit defined at the start of this tutorial has options to optimize the circuit and insert spin echoes on the ancilla as we have discussed above. Without any optimization or spin echoes, an example circuit looks like this:

circuit = create_benchmark_circuit(qubits, cycles=2, seed=1)
print(f"Unoptimized circuit ({len(circuit)} moments):\n")
Unoptimized circuit (47 moments):

After removing unnecessary gates (optimization) and aligning gates, the same circuit looks like this:

optimized_circuit = create_benchmark_circuit(qubits, cycles=2, seed=1, with_optimization=True, with_alignment=True)
print(f"Circuit with optimization + alignment ({len(optimized_circuit)} moments):\n")
Circuit with optimization + alignment (15 moments):

And with optimization + alginment + spin echoes on the ancilla, the same circuit looks like this:

optimized_circuit_with_spin_echoes = create_benchmark_circuit(qubits, cycles=2, seed=1, with_optimization=True, with_alignment=True, with_spin_echoes=True)
print(f"Circuit with optimization + alignment + spin echoes ({len(optimized_circuit_with_spin_echoes)} moments):\n")
Circuit with optimization + alignment + spin echoes (15 moments):

Now we create circuits for all cycle values without optimization, with optimization + alignment, and with optimization + alignment + spin echoes.

"""Create all circuits."""
batch = [
    create_benchmark_circuit(qubits, cycles=c, seed=seed)
    for c in cycle_values
batch_with_optimization = [
    create_benchmark_circuit(qubits, cycles=c, seed=seed, with_optimization=True, with_alignment=True)
    for c in cycle_values
batch_with_optimization_and_spin_echoes = [
    create_benchmark_circuit(qubits, cycles=c, seed=seed, with_optimization=True, with_alignment=True, with_spin_echoes=True)
    for c in cycle_values

The next cell runs them on the device.

"""Run all circuits."""
all_probs = []
for b in (batch, batch_with_optimization, batch_with_optimization_and_spin_echoes):
    results = device_sampler.sampler.run_batch(b, repetitions=nreps)
    all_probs.append([to_survival_prob(*res) for res in results])

And the next cell plots the results.

"""Plot results."""
labels = ["Unoptimized", "Optimization + Alignment", "Optimization + Alignment + Spin echoes"]

for (probs, label) in zip(all_probs, labels):
    plt.plot(cycle_values, probs, "-o", label=label)

plt.ylabel("Survival probability")


Recall that without any noise, the survival probability (ratio of \(1\)s measured to all measurements) should be \(1.0\), so higher on this plot is better. The unoptimized circuit performs the worst, the circuit with optimization + alignment performs better, and the circuit with optimization + alignment + spin echoes performs the best.