Circuits

Conceptual Overview

The primary representation of quantum programs in Cirq is the Circuit class. A Circuit is a collection of Moments. A Moment is a collection of Operations that all act during the same abstract time slice. An Operation is a some effect that operates on a specific subset of Qubits, the most common type of Operation is a GateOperation.

[ ]:
!pip install --quiet cirq
import cirq

Circuits and Moments

Let’s unpack this.

At the base of this construction is the notion of a qubit. In Cirq, qubits and other quantum objects are identified by instances of subclasses of the Qid base class. Different subclasses of Qid can be used for different purposes. For example, the qubits that Google’s Xmon devices use are often arranged on the vertices of a square grid. For this, the class GridQubit subclasses Qid. For example, we can create a 3 by 3 grid of qubits using

[20]:
import cirq
qubits = [cirq.GridQubit(x, y) for x in range(3) for y in range(3)]

print(qubits[0])
(0, 0)

The next level up is the notion of a Gate. A Gate represents a physical process that occurs on a Qubit. The important property of a Gate is that it can be applied to one or more qubits. This can be done via the Gate.on method itself or via (), and doing this turns the Gate into a GateOperation.

[21]:
# This is an Pauli X gate. It is an object instance.
x_gate = cirq.X
# Applying it to the qubit at location (0, 0) (defined above)
# turns it into an operation.
x_op = x_gate(qubits[0])

print(x_op)
X((0, 0))

A Moment is simply a collection of operations, each of which operates on a different set of qubits, and which conceptually represents these operations as occurring during this abstract time slice. The Moment structure itself is not required to be related to the actual scheduling of the operations on a quantum computer, or via a simulator, though it can be. For example, here is a Moment in which Pauli X and a CZ gate operate on three qubits:

[22]:
cz = cirq.CZ(qubits[0], qubits[1])
x = cirq.X(qubits[2])
moment = cirq.Moment([x, cz])

print(moment)
X((0, 2)) and CZ((0, 0), (0, 1))

The above is not the only way one can construct moments, nor even the typical method, but illustrates that a Moment is just a collection of operations on disjoint sets of qubits.

Finally, at the top level a Circuit is an ordered series of Moment objects. The first Moment in this series contains the first `Operations that will be applied. Here, for example, is a simple circuit made up of two moments:

[23]:
cz01 = cirq.CZ(qubits[0], qubits[1])
x2 = cirq.X(qubits[2])
cz12 = cirq.CZ(qubits[1], qubits[2])
moment0 = cirq.Moment([cz01, x2])
moment1 = cirq.Moment([cz12])
circuit = cirq.Circuit((moment0, moment1))

print(circuit)
(0, 0): ───@───────
           │
(0, 1): ───@───@───
               │
(0, 2): ───X───@───

Note that the above is one of the many ways to construct a Circuit, which illustrates the concept that a Circuit is an iterable of Moment objects.

Constructing circuits

Constructing Circuits as a series of Moment objects, with each Moment being hand-crafted, is tedious. Instead, we provide a variety of different ways to create a Circuit.

One of the most useful ways to construct a Circuit is by appending onto the Circuit with the Circuit.append method.

[24]:
from cirq.ops import CZ, H
q0, q1, q2 = [cirq.GridQubit(i, 0) for i in range(3)]
circuit = cirq.Circuit()
circuit.append([CZ(q0, q1), H(q2)])

print(circuit)
(0, 0): ───@───
           │
(1, 0): ───@───

(2, 0): ───H───

This appended a new moment to the qubit, which we can continue to do:

[25]:
circuit.append([H(q0), CZ(q1, q2)])

print(circuit)
(0, 0): ───@───H───
           │
(1, 0): ───@───@───
               │
(2, 0): ───H───@───

In these two examples, we appended full moments, what happens when we append all of these at once?

[26]:
circuit = cirq.Circuit()
circuit.append([CZ(q0, q1), H(q2), H(q0), CZ(q1, q2)])

print(circuit)
(0, 0): ───@───H───
           │
(1, 0): ───@───@───
               │
(2, 0): ───H───@───

We see that here we have again created two Moment objects. How did Circuit know how to do this? Circuit’s Circuit.append method (and its cousin, Circuit.insert) both take an argument called the InsertStrategy. By default, InsertStrategy is InsertStrategy.NEW_THEN_INLINE.

InsertStrategies

InsertStrategy defines how Operations are placed in a Circuit when requested to be inserted at a given location. Here, a location is identified by the index of the Moment (in the Circuit) where the insertion is requested to be placed at (in the case of Circuit.append, this means inserting at the Moment, at an index one greater than the maximum moment index in the Circuit).

There are four such strategies: InsertStrategy.EARLIEST, InsertStrategy.NEW, InsertStrategy.INLINE and InsertStrategy.NEW_THEN_INLINE.

InsertStrategy.EARLIEST is defined as:

Scans backward from the insert location until a moment with operations touching qubits affected by the operation to insert is found. The operation is added to the moment just after that location.

For example, if we first create an Operation in a single moment, and then use InsertStrategy.EARLIEST, Operation can slide back to this first Moment if there is space:

[27]:
from cirq.circuits import InsertStrategy
circuit = cirq.Circuit()
circuit.append([CZ(q0, q1)])
circuit.append([H(q0), H(q2)], strategy=InsertStrategy.EARLIEST)

print(circuit)
(0, 0): ───@───H───
           │
(1, 0): ───@───────

(2, 0): ───H───────

After creating the first moment with a CZ gate, the second append uses the InsertStrategy.EARLIEST strategy. The H on q0 cannot slide back, while the H on q2 can and so ends up in the first Moment.

Contrast this with the InsertStrategy.NEW InsertStrategy:

Every operation that is inserted is created in a new moment.

[28]:
circuit = cirq.Circuit()
circuit.append([H(q0), H(q1), H(q2)], strategy=InsertStrategy.NEW)

print(circuit)
(0, 0): ───H───────────

(1, 0): ───────H───────

(2, 0): ───────────H───

Here every operator processed by the append ends up in a new moment. InsertStrategy.NEW is most useful when you are inserting a single operation and do not want it to interfere with other Moments.

Another strategy is InsertStrategy.INLINE:

Attempts to add the operation to insert into the moment just before the desired insert location. But, if there’s already an existing operation affecting any of the qubits touched by the operation to insert, a new moment is created instead.

[29]:
circuit = cirq.Circuit()
circuit.append([CZ(q1, q2)])
circuit.append([CZ(q1, q2)])
circuit.append([H(q0), H(q1), H(q2)], strategy=InsertStrategy.INLINE)

print(circuit)
(0, 0): ───────H───────

(1, 0): ───@───@───H───
           │   │
(2, 0): ───@───@───H───

After two initial CZ between the second and third qubit, we try to insert three H Operations. We see that the H on the first qubit is inserted into the previous Moment, but the H on the second and third qubits cannot be inserted into the previous Moment, so a new Moment is created.

Finally, we turn to the default strategy:

Creates a new moment at the desired insert location for the first operation, but then switches to inserting operations according to ``InsertStrategy.INLINE``.

[30]:
circuit = cirq.Circuit()
circuit.append([H(q0)])
circuit.append([CZ(q1,q2), H(q0)], strategy=InsertStrategy.NEW_THEN_INLINE)

print(circuit)
(0, 0): ───H───H───

(1, 0): ───────@───
               │
(2, 0): ───────@───

The first append creates a single moment with an H on the first qubit. Then, the append with the InsertStrategy.NEW_THEN_INLINE strategy begins by inserting the CZ in a new Moment (the InsertStrategy.NEW in InsertStrategy.NEW_THEN_INLINE). Subsequent appending is done InsertStrategy.INLINE, so the next H on the first qubit is appending in the just created Moment.

Patterns for Arguments to Append and Insert

In the above examples, we used a series of Circuit.appendcalls with a list of different Operations added to the circuit. However, the argument where we have supplied a list can also take more than just list values. For instance:

[31]:
def my_layer():
    yield CZ(q0, q1)
    yield [H(q) for q in (q0, q1, q2)]
    yield [CZ(q1, q2)]
    yield [H(q0), [CZ(q1, q2)]]

circuit = cirq.Circuit()
circuit.append(my_layer())

for x in my_layer():
    print(x)
CZ((0, 0), (1, 0))
[cirq.H.on(cirq.GridQubit(0, 0)), cirq.H.on(cirq.GridQubit(1, 0)), cirq.H.on(cirq.GridQubit(2, 0))]
[cirq.CZ.on(cirq.GridQubit(1, 0), cirq.GridQubit(2, 0))]
[cirq.H.on(cirq.GridQubit(0, 0)), [cirq.CZ.on(cirq.GridQubit(1, 0), cirq.GridQubit(2, 0))]]
[32]:
print(circuit)
(0, 0): ───@───H───H───────
           │
(1, 0): ───@───H───@───@───
                   │   │
(2, 0): ───H───────@───@───

Recall that Python functions with a yield are generators. Generators are functions that act as iterators. In the above example, we see that we can iterate over my_layer(). In this case, each of the yield returns produces what was yielded, and here these are:

  • Operations,

  • lists of Operations,

  • or lists of Operations mixed with lists of Operations.

When we pass an iterator to the append method, Circuit is able to flatten all of these and pass them as one giant list to Circuit.append (this also works for Circuit.insert).

The above idea uses the concept of OP_TREE. An OP_TREE is not a class, but a contract. The basic idea is that, if the input can be iteratively flattened into a list of operations, then the input is an OP_TREE.

A very nice pattern emerges from this structure: define generators for sub-circuits, which can vary by size or Operation parameters.

Another useful method to construct a Circuit fully formed from an OP_TREE is to pass the OP_TREE into Circuit when initializing it:

[33]:
circuit = cirq.Circuit(H(q0), H(q1))
print(circuit)
(0, 0): ───H───

(1, 0): ───H───

Slicing and Iterating over Circuits

Circuits can be iterated over and sliced. When they are iterated, each item in the iteration is a moment:

[34]:
circuit = cirq.Circuit(H(q0), CZ(q0, q1))
for moment in circuit:
    print(moment)
H((0, 0))
CZ((0, 0), (1, 0))

Slicing a Circuit, on the other hand, produces a new Circuit with only the moments corresponding to the slice:

[35]:
circuit = cirq.Circuit(H(q0), CZ(q0, q1), H(q1), CZ(q0, q1))
print(circuit[1:3])
(0, 0): ───@───────
           │
(1, 0): ───@───H───

Especially useful is dropping the last moment (which are often just measurements): circuit[:-1], or reversing a circuit: circuit[::-1].

Circuit Optimizers

Cirq comes with the concept of an optimizer. Optimizers will pass over a circuit and perform tasks that will modify the circuit in place. These can be used to transform a circuit in specific ways, such as combining single-qubit gates, commuting Z gates through the circuit, or readying the circuit for certain hardware or gate set configurations.

Optimizers will have a function optimize_circuit() that can be used to perform this optimization. Here is a simple example that removes empty moments:

[36]:
c=cirq.Circuit()
c.append(cirq.Moment([]))
c.append(cirq.Moment([cirq.X(cirq.GridQubit(1,1))]))
c.append(cirq.Moment([]))
print(f'Before optimization, Circuit has {len(c)} moments')

cirq.DropEmptyMoments().optimize_circuit(circuit=c)
print(f'After optimization, Circuit has {len(c)} moments')
Before optimization, Circuit has 3 moments
After optimization, Circuit has 1 moments

Optimizers that come with cirq can be found in the cirq.optimizers package.

A few notable examples are:

  • ConvertToCzAndSingleGates: Attempts to convert a circuit into CZ gates and single qubit gates. This uses gate’s unitary and decompose methods to transform them into CZ + single qubit gates.

  • DropEmptyMoments / DropNegligible: Removes moments that are empty or have very small effects, respectively.

  • EjectPhasedPaulis: Pushes X, Y, and PhasedX gates towards the end of the circuit, potentially absorbing Z gates and modifying gates along the way.

  • EjectZ: Pushes Z gates towards the end of the circuit, potentially adjusting phases of gates that they pass through.

  • ExpandComposite: Uses cirq.decompose to expand composite gates.

  • MergeInteractions: Combines series of adjacent one and two-qubit gates acting on a pair of qubits.

  • MergeSingleQubitGates: Combines series of adjacent unitary 1-qubit operations

  • SynchronizeTerminalMeasurements: Moves all measurements in a circuit to the final moment if possible.

Creating your own optimizers

You can create your own optimizers to transform and modify circuits to fit hardware, gate sets, or other requirements. Optimizers can also be used to generate noise. See noise for details.

You can do this by implementing the function optimize_circuit.

If your optimizer is a local optimizer and depends primarily on operator being examined, you can alternatively inherit cirq.PointOptimizer and implement the function optimization_at(self, circuit, index, op) that optimizes a single operation.

Below is an example of implementing a simple PointOptimizer that removes measurements.

[37]:
class RemoveMeasurements(cirq.PointOptimizer):
    def optimization_at(self, circuit: cirq.Circuit, index: int, op: cirq.Operation):
      if isinstance(op.gate, cirq.MeasurementGate):
        return cirq.PointOptimizationSummary(clear_span=1,
                                             new_operations=[],
                                             clear_qubits=op.qubits)
      else:
        return None

q=cirq.LineQubit(0)
c=cirq.Circuit(cirq.X(q), cirq.measure(q))
print('Before optimization')
print(c)
RemoveMeasurements().optimize_circuit(c)
print('After optimization')
print(c)

Before optimization
0: ───X───M───
After optimization
0: ───X───────