Skip to content

Circuits with Bloqade

In this tutorial we will demonstrate how to write circuits and quantum executions with Bloqade. Specifically, we will use the SQUIN dialect set from the compiler toolchain Kirin. SQUIN stands for Structural Quantum INstruction set and is the circuit-level representation of quantum executions. It is built on top of the Kirin framework, an open-source compiler toolchain for embedded domain-specific languages (eDSLs) that target scientific computing kernels. A key feature of SQUIN is the kernel, which can roughly be seen as the object which will be executed on the target hardware. Naturally, this hardware could be a quantum computer, but it also extends to classical execution as well, such as mid-circuit feedforward or even non-quantum execution such as robotics.

These kernels can be built using decorators of Python functions. We will use the @squin.kernel decorator in this notebook but keep in mind that other eDSLs have different decorators inherited from base Kirin decorators. The decorator lowers Python’s abstract syntax tree (AST) into a static single assignment (SSA) representation in Kirin, which is a useful intermediate representation for compiler analysis. You don’t have to worry too much about SSA or compilers here, but if you want to learn more check out the Kirin documentation.

from typing import Any
import numpy as np
import bloqade.types
from kirin.ir import Method
from bloqade.types import Qubit, MeasurementResult
# Types used in annotations below
from kirin.dialects.ilist import IList
from bloqade import squin
Register = IList[Qubit, Any]
@squin.kernel
def hello_world(theta: float) -> IList[MeasurementResult, Any]:
"""
Prepare a Bell state, rotate one qubit, and measure both qubits.
"""
qubits = squin.qalloc(2)
squin.h(qubits[0])
squin.cx(qubits[0], qubits[1])
squin.rx(theta, qubits[0])
bits = squin.broadcast.measure(qubits)
return bits
# [kernel].print() prints the raw SSA, which is the intermediate representation
# of the kernel as used internally by Kirin.
hello_world.print()
func.func @hello_world(theta : !py.float) -> !py.IList[!py.MeasurementResult, ~N : !py.int] {
^0(%hello_world_self, %theta):
│ %0 = py.constant.constant 2 : !py.int
│ %qubits = func.invoke qalloc(%0) : !py.IList[!py.Qubit, !Any] maybe_pure=False
│ %1 = py.constant.constant 0 : !py.int
│ %2 = py.indexing.getitem(%qubits, %1) : !py.Qubit
│ %3 = func.invoke h(%2) : !py.NoneType maybe_pure=False
│ %4 = py.constant.constant 0 : !py.int
│ %5 = py.indexing.getitem(%qubits, %4) : !py.Qubit
│ %6 = py.constant.constant 1 : !py.int
│ %7 = py.indexing.getitem(%qubits, %6) : !py.Qubit
│ %8 = func.invoke cx(%5, %7) : !py.NoneType maybe_pure=False
│ %9 = py.constant.constant 0 : !py.int
│ %10 = py.indexing.getitem(%qubits, %9) : !py.Qubit
│ %11 = func.invoke rx(%theta : !py.float, %10) : !py.NoneType maybe_pure=False
│ %bits = func.invoke measure(%qubits) : !py.IList[!py.MeasurementResult, ~N : !py.int] maybe_pure=False
│ func.return %bits
} // func.func hello_world

A kernel is a representation of a hybrid quantum/classical execution that will run “on hardware”. The decorator @squin.kernel can be considered as the “label” to represent this fact. A kernel can take arguments (such as theta here) and can return values (such as bits here). Annotating a kernel’s parameters and return value with types helps the compiler, although simple kernels can often be compiled without these annotations. The return values can be considered as the “results” which are returned from the quantum computer, though they can also be intermediate values that are used as a part of a larger computation (e.g. a function call).

A kernel can represent both quantum and classical execution: while lots of classical computation is supported (control flow, algebra, etc.) there is less support for arbitrary Python calls, as this won’t necessarily be supported on the microcontroller “on hardware”. Note that shot-level execution and averaging is handled outside of the kernel itself: averaging over multiple shots within a kernel is an anti-pattern to avoid.

To represent quantum executions, we write and construct SQUIN kernels. These kernels can use the typical structure of Python functions — inputs, outputs, for loops, control flow, subroutines, and so forth — as a feature of the underlying base Kirin statements. Because one can intermix control flow with operations and measurement, arbitrary mid-circuit feed forward comes for “free”.

There are three dialects that comprise the domain-specific language:

  1. squin.qubit - Manipulation and declaration of the qubits themselves, mainly allocating new ones and measuring them.
  2. squin.gate - Application of quantum gates on qubits.
  3. squin.noise - Representing noise effects on the qubits.

While you can interact with squin.qubit directly, the other two dialects are exposed via wrapper functions that are available directly under the squin namespace, or under squin.broadcast for parallelized versions of the gates and noise processes, respectively.

Refer to the API documentation for a full list of the available functionality. Note that you are also able to define your own custom gates by defining similar functions.

A key feature of kernels is the ability to do complex control flow similar to how one might program Python. For example, one can use a for loop to apply the same gate to multiple qubits to prepare a GHZ state.

A useful pattern is to use factory functions that return Bloqade kernels. This way, you can fix parameters (such as the number of qubits) pythonically without needing to introduce the variable directly into the kernel itself, using a closure.

# GHZ state preparation.
def GHZ_method_factory(nqubits: int) -> Method:
@squin.kernel
def GHZ_state() -> Register:
qubits = squin.qalloc(nqubits)
squin.h(qubits[0])
for i in range(nqubits - 1):
squin.cx(qubits[i], qubits[i + 1])
return qubits
return GHZ_state
kernel = GHZ_method_factory(8)
# kernel.print()

Alternatively, a kernel can take nqubits as an argument and allocate that many qubits when it runs. Supply the value when creating a task or when calling the kernel from another kernel.

@squin.kernel
def GHZ_state_factory(nqubits: int) -> Register:
qubits = squin.qalloc(nqubits)
squin.h(qubits[0])
for i in range(nqubits - 1):
squin.cx(qubits[i], qubits[i + 1])
return qubits
# GHZ_state_factory.print()

Instead of writing your circuit directly in Bloqade, you may build circuits using Cirq and then lower them to and from Bloqade kernels. This has the advantage of being able to leverage the excellent and in-depth resources of transpilation and circuit optimization without having to reinvent the wheel. However, for certain programs, such as those requiring more complex mid-circuit feed-forward, it is still required to write Bloqade kernels as there is no adequate representation in other SDKs. Cirq is our initial choice of SDK, and other transformations are coming soon — though in principle interoperability with many SDKs is possible through an intermediate Cirq representation.

Let us begin by writing a simple GHZ state preparation circuit, in analogy to the Bloqade kernel above. Observe that the resulting object is a static representation of a circuit, similar to the GHZ_state kernel, and differentiated from the dynamic GHZ_state_factory kernel which can return a dynamically sized GHZ state.

import cirq
def ghz_prep(nqubits: int) -> cirq.Circuit:
"""
Builder function that returns a simple N-qubit
GHZ state preparation circuit
"""
qubits = cirq.LineQubit.range(nqubits)
output = cirq.Circuit()
output.append(cirq.H(qubits[0]))
for i in range(nqubits - 1):
output.append(cirq.CX(qubits[i], qubits[i + 1]))
return output
print(ghz_prep(4))
0: ───H───@───────────
│
1: ───────X───@───────
│
2: ───────────X───@───
│
3: ───────────────X───

The Cirq circuit can be converted to a Bloqade kernel with the transpilation function load_circuit. The kernel can be considered as a transformation on the register of qubits it is applied to as arguments, with the return being the qubits that still persist.

from bloqade.cirq_utils import emit_circuit, load_circuit
# Load a Cirq circuit into SQUIN
kernel = load_circuit(
ghz_prep(4),
kernel_name="ghz_prep_cirq", # Define the name of the kernel as if one were using @squin.kernel on a function
register_as_argument=False, # If the resulting kernel should take in a qubit register (True) or make a new one (False)
return_register=True, # If the resulting kernel should return the register of the qubits it acts on.
)
# Then, we can convert the circuit back to Cirq.
# General Bloqade control flow cannot always be converted back to a Cirq circuit.
circuit2: cirq.Circuit = emit_circuit(kernel, ignore_returns=True)
print(circuit2)
0: ───H───@───────────
│
1: ───────X───@───────
│
2: ───────────X───@───
│
3: ───────────────X───

The circuit loading also works with classical feed forward, though it is generally more difficult to extract a Cirq circuit from a generic Bloqade kernel with feedforward. For example, the T teleportation gadget can be written and loaded as:

reg = cirq.LineQubit.range(2)
circuit = cirq.Circuit()
circuit.append(cirq.T(reg[0]))
circuit.append(cirq.CNOT(reg[1], reg[0]))
circuit.append(cirq.measure(reg[0], key="m"))
circuit.append(cirq.S(reg[1]).with_classical_controls("m"))
circuit.append(cirq.X(reg[1]).with_classical_controls("m"))
print(circuit)
kernel = load_circuit(
circuit, kernel_name="teleport", register_as_argument=True, return_register=True
)
# kernel.print()
0: ───T───X───M───────────
│ ║
1: ───────@───╫───S───X───
║ ║ ║
m: ═══════════@═══^═══^═══

Cirq supports measurement-conditioned gates, as shown above. The emitter accepts only certain forms of classical conditions; the following kernel fails validation when converted back to Cirq.

@squin.kernel
def t_teleport_noargs() -> None:
"""
An example of mid-circuit feedforward with a condition the Cirq emitter cannot convert.
"""
ancilla = squin.qalloc(1)[0]
target = squin.qalloc(1)[0]
squin.t(ancilla)
squin.cx(target, ancilla)
if squin.measure(target):
squin.s(ancilla)
squin.x(ancilla)
from kirin.ir.exception import ValidationErrorGroup
try:
print(emit_circuit(t_teleport_noargs))
except ValidationErrorGroup as e:
print("Expected error:", e)
else:
raise AssertionError("emit_circuit unexpectedly accepted mid-circuit feed-forward")
# Though measurement without feedforward is possible
@squin.kernel
def coinflip() -> MeasurementResult:
qubit = squin.qalloc(1)[0]
squin.h(qubit)
return squin.measure(qubit)
circuit = emit_circuit(coinflip, ignore_returns=True)
print(circuit)
Expected error:
Validation failed with 1 violation(s):
Cirq Classical Control Validation:
- IfElse condition must compare a single measurement result to 0 or 1 using ==, !=, is_one, or is_zero.
File "/tmp/ipykernel_2929/594274578.py", line 12, col 16
0: ───H───M───

A kernel is simply a representation of an execution and is not much use without being able to analyze and execute that kernel. We can simulate the action of kernels using concrete interpreters. The emulator must a) keep track of the classical state of the variables, b) keep track of the quantum state of the qubits, and thus c) faithfully represent the execution of that program as if it was run on a hybrid quantum/classical computer. Bloqade’s emulator is built on top of the excellent PyQrack quantum simulator and satisfies the three goals above.

There are four main objects when considering simulation:

  1. The emulator object - Representing the thing that some kernel is going to be executed on. Today it is the PyQrack simulator, but eventually it could also include other simulators or physical hardware. The *.task method of the emulator object builds a task object.
  2. The task object - Binding together the emulator, kernel, and input parameters for that kernel. This task is not executed until the *.run method is called. Upon calling run, the kernel is interpreted, the quantum circuit is executed, any classical co-processing is done, and the kernel completes, returning its result. Repeated calling of run will “reset” the executor into its initial state and rerun the kernel. Alternatively, one could call *.batch_run to run the kernel repeatedly and estimate outcome probabilities. A task can produce both results and quantum-state data.
  3. The results - task.run() returns the kernel’s return value with the same type. task.batch_run() returns a dictionary mapping each hashable result to its estimated probability. A result could be a qubit, a register of qubits, or a classical value.
  4. The QuantumState object - The final quantum state of the emulator object. While this is a nonphysical quantity, the QuantumState is useful for debugging and analysis. This can be extracted from either the emulator.quantum_state method (for a single run after the run() method), or with task.batch_state (for a stochastic average over many samples). The quantum state is efficiently represented as an eigensystem of a reduced density matrix.
from bloqade.pyqrack import StackMemorySimulator, DynamicMemorySimulator
# StackMemorySimulator - static number of qubits
# DynamicMemorySimulator - dynamic number of qubits, but slower. Use if you don't know how many qubits you need in advance.
emulator = StackMemorySimulator(min_qubits=8)
task = emulator.task(GHZ_state_factory, args=(4,))
results = task.run()
# The results are the same ResultType as the kernel return.
# In this case, it is a list of qubits.
print(results)
IList([PyQrackQubit(addr=0, sim_reg=<pyqrack.qrack_simulator.QrackSimulator object at 0x7efe29b95880>, state=<QubitState.Active: 1>), PyQrackQubit(addr=1, sim_reg=<pyqrack.qrack_simulator.QrackSimulator object at 0x7efe29b95880>, state=<QubitState.Active: 1>), PyQrackQubit(addr=2, sim_reg=<pyqrack.qrack_simulator.QrackSimulator object at 0x7efe29b95880>, state=<QubitState.Active: 1>), PyQrackQubit(addr=3, sim_reg=<pyqrack.qrack_simulator.QrackSimulator object at 0x7efe29b95880>, state=<QubitState.Active: 1>)])

Note that, while it is instinctive to simply call GHZ_state_factory(4) and expect it to run, this isn’t necessarily the correct abstraction. emulator.task links the kernel to a specific interpreter— for example, if you wanted to run your program on a noisy emulator vs a perfect emulator vs. real hardware, this is the way you would specify it. Furthermore, the instantiation of the task is not necessarily linked with the execution of that task. For example, if that task must be run asynchronously on hardware, there must be an object which represents the run itself as well as the (future) results.

For simpler kernels that only use Kirin built-in statements — such as control flow, for loops, arithmetic, and the like — it is possible to directly call the kernel and use the default Kirin interpreter.

@squin.kernel
def foo(x: int, y: int) -> bool:
return x < y
assert foo(1, 2)

PyQrack is mainly intended as an emulator, attempting to recreate the physical action and interaction of quantum hardware with classical controls. However, it is often useful for user analysis and debugging to extract out non-physical values, such as the quantum statevector, fidelity, or exact expectation values of observables and correlation functions.

These nonphysical values are available because the simulator lets us directly inspect and evaluate the quantum state. The state can be extracted via the PyQrackSimulator.quantum_state method, with an input signature of a list of (PyQrack) qubits. These qubits can be generated as a return value from a kernel (as is the case for the GHZ_state_factory function) or from the task.qubits() method.

The resulting state is a reduced density matrix represented by its eigensystem of eigenvalues and eigenvectors, or a dense 2N×2N2^N \times 2^N array.

from bloqade.pyqrack import PyQrackQubit
# This kernel returns a list of Qubit objects. We can use these to analyze the
# quantum state of the register.
results: list[PyQrackQubit] = task.run()
# If the kernel does not return the qubit register, one can still collect it. Note that
# you must determine which qubits are which.
# Qubits are typically added sequentially, so if you make multiple registers, the qubits
# will be in the order they were added. The StackMemorySimulator may have extra qubits.
qubits: list[PyQrackQubit] = task.qubits()
# Get the state as an eigensystem and as a reduced density matrix. The qubits
# themselves refer to the emulator’s internal state.
state = emulator.quantum_state(results)
density_matrix = emulator.reduced_density_matrix(results)
# Note that the RDM is represented in its eigenbasis for efficiency.
# If the state is pure, there is only one nonzero eigenvalue. This is the case for the GHZ state.
print("A pure state has one eigenvalue approximately equal to 1:", state.eigenvalues)
statevector = state.eigenvectors[:, 0]
print("The GHZ statevector has nonzero amplitudes only at |0…0⟩ and |1…1⟩.")
print(statevector)
A pure state has one eigenvalue approximately equal to 1: [1.00000007]
The GHZ statevector has nonzero amplitudes only at |0…0⟩ and |1…1⟩.
[-0.50547395-0.49446545j 0. +0.j 0. +0.j
0. +0.j 0. +0.j 0. +0.j
0. +0.j 0. +0.j 0. +0.j
0. +0.j 0. +0.j 0. +0.j
0. +0.j 0. +0.j 0. +0.j
-0.50547395-0.49446545j]

If the output is randomized, one can average over many runs using task.batch_run. This returns a dictionary of probabilities of each output. Note that the output must be hashable.

# Define the emulator and task
emulator = StackMemorySimulator(min_qubits=1)
task = emulator.task(coinflip)
results = task.batch_run(shots=1000)
state = task.batch_state(shots=1000)
print("Results:", results)
print("State:", state)
Results: {<MeasurementResultValue.Zero: 0>: 0.498, <MeasurementResultValue.One: 1>: 0.502}
State: QuantumState(eigenvalues=array([0.518, 0.482]), eigenvectors=array([[0.+0.j, 1.+0.j],
[1.+0.j, 0.+0.j]]))

Bloqade kernels allow all the typical syntax of for loops, if-else statements, function calls, and other powerful abstractions. Let us use this to write efficient representations of complex circuits.

For this example, we will use a Trotterization of the 1D transverse Ising model.

The first option we will explore is to write the entire circuit in Cirq and then convert it into a Bloqade kernel using the load_circuit lowering. Observe that the return objects of these builder functions are static objects.

def trotter_layer(
qubits: list[cirq.Qid], dt: float = 0.01, J: float = 1, h: float = 1
) -> cirq.Circuit:
"""
Cirq builder function that returns a circuit of
a Trotter step of the 1D transverse Ising model
"""
op_zz = cirq.ZZ ** (dt * J / np.pi)
op_x = cirq.X ** (dt * h / np.pi)
circuit = cirq.Circuit()
for i in range(0, len(qubits) - 1, 2):
circuit.append(op_zz.on(qubits[i], qubits[i + 1]))
for i in range(1, len(qubits) - 1, 2):
circuit.append(op_zz.on(qubits[i], qubits[i + 1]))
for i in range(len(qubits)):
circuit.append(op_x.on(qubits[i]))
return circuit
def trotter_circuit(
N: int, steps: int = 10, dt: float = 0.01, J: float = 1, h: float = 1
) -> cirq.Circuit:
qubits = cirq.LineQubit.range(N)
circuit = cirq.Circuit()
for _ in range(steps):
circuit += trotter_layer(qubits, dt, J, h)
return circuit
cirq_trotter_circuit = trotter_circuit(N=8, steps=4, dt=0.01, J=1, h=1)
print(cirq_trotter_circuit)
# Convert the circuit to a Bloqade kernel
bloqade_trotter_circuit = load_circuit(
cirq_trotter_circuit,
kernel_name="trotter",
register_as_argument=False,
return_register=True,
)
0: ───ZZ─────────X^0.003──────────────ZZ─────────X^0.003──────────────ZZ─────────X^0.003──────────────ZZ─────────X^0.003──────────────
│ │ │ │
1: ───ZZ^0.003───ZZ─────────X^0.003───ZZ^0.003───ZZ─────────X^0.003───ZZ^0.003───ZZ─────────X^0.003───ZZ^0.003───ZZ─────────X^0.003───
│ │ │ │
2: ───ZZ─────────ZZ^0.003───X^0.003───ZZ─────────ZZ^0.003───X^0.003───ZZ─────────ZZ^0.003───X^0.003───ZZ─────────ZZ^0.003───X^0.003───
│ │ │ │
3: ───ZZ^0.003───ZZ─────────X^0.003───ZZ^0.003───ZZ─────────X^0.003───ZZ^0.003───ZZ─────────X^0.003───ZZ^0.003───ZZ─────────X^0.003───
│ │ │ │
4: ───ZZ─────────ZZ^0.003───X^0.003───ZZ─────────ZZ^0.003───X^0.003───ZZ─────────ZZ^0.003───X^0.003───ZZ─────────ZZ^0.003───X^0.003───
│ │ │ │
5: ───ZZ^0.003───ZZ─────────X^0.003───ZZ^0.003───ZZ─────────X^0.003───ZZ^0.003───ZZ─────────X^0.003───ZZ^0.003───ZZ─────────X^0.003───
│ │ │ │
6: ───ZZ─────────ZZ^0.003───X^0.003───ZZ─────────ZZ^0.003───X^0.003───ZZ─────────ZZ^0.003───X^0.003───ZZ─────────ZZ^0.003───X^0.003───
│ │ │ │
7: ───ZZ^0.003───X^0.003──────────────ZZ^0.003───X^0.003──────────────ZZ^0.003───X^0.003──────────────ZZ^0.003───X^0.003──────────────

As an intermediate approach, one can combine kernels converted from Cirq circuits with kernels written directly in Bloqade. For example, each layer has fixed parameters as defined by a Cirq circuit, but a variable number of layers as parameterized by a kernel input and for loop. This option has the benefit of being able to use Cirq infrastructure to optimize and represent individual layers, while still being able to use Bloqade kernels to represent parameterized circuits. In this case, the output kernel has the timestep and Ising parameters fixed (as they are fixed in the Cirq circuit), but the number of steps is variable.

def factory_trotter(N: int, dt: float = 0.01, J: float = 1, h: float = 1) -> Method:
bloqade_trotter_layer = load_circuit(
trotter_layer(qubits=cirq.LineQubit.range(N), dt=dt, J=J, h=h),
kernel_name="trotter",
register_as_argument=True,
return_register=True,
)
@squin.kernel
def trotter_for_loop(steps: int) -> Register:
"""
Main function that runs the Trotter circuit for a given number of steps
"""
qubits = squin.qalloc(N)
for _ in range(steps):
qubits = bloqade_trotter_layer(qubits)
return qubits
return trotter_for_loop

Alternatively, you could just write everything directly as a Bloqade kernel. Note that the ZZ operator that is native to Cirq must be expanded into its own “helper” kernel via a decomposition into cx / z / cx. The resulting kernel is fully parameterized, with the values not actually evaluated until runtime (or further compilation and folding).

# Define an operator that looks like the ZZ power gate
@squin.kernel
def op_zz(theta: float, qb1: bloqade.types.Qubit, qb2: bloqade.types.Qubit) -> None:
"""
Apply ZZ ** (theta / pi), up to a global phase.
"""
squin.cx(qb1, qb2)
squin.rz(theta, qb2)
squin.cx(qb1, qb2)
@squin.kernel
def bloqade_trotter(
N: int, steps: int, dt: float = 0.01, J: float = 1, h: float = 1
) -> Register:
"""
Main function that runs the Trotter circuit for a given number of steps
"""
qubits = squin.qalloc(N)
for _ in range(steps):
for i in range(0, len(qubits) - 1):
op_zz(theta=dt * J, qb1=qubits[i], qb2=qubits[i + 1])
for i in range(0, len(qubits)):
squin.rx(angle=dt * h, qubit=qubits[i])
return qubits

The Cirq circuit and the converted Bloqade kernel implement the same operations, so their output states should agree within numerical precision.

cirq_trotter = trotter_circuit(N=12, steps=10, dt=0.01, J=1, h=1)
cirq_statevector = cirq.Simulator().simulate(cirq_trotter).state_vector()
# Alternatively, convert to a Bloqade kernel and simulate with PyQrack
cirq_trotter_kernel = load_circuit(
cirq_trotter,
kernel_name="cirq_trotter",
register_as_argument=False,
return_register=True,
)
cirq_trotter_qubits = (
StackMemorySimulator(min_qubits=12).task(cirq_trotter_kernel).run()
)
cirq_bloqade_state = StackMemorySimulator.quantum_state(cirq_trotter_qubits)
# Their state vectors should have an overlap close to 1. Extract the pure-state eigenvector from the RDM.
print(
"Overlap:",
np.abs(np.dot(np.conj(cirq_statevector), cirq_bloqade_state.eigenvectors[:, 0]))
** 2,
)
Overlap: 1.0000001953162074

Similarly, the circuit written in Cirq and the kernel written directly in Bloqade produce output states that agree within numerical precision. Note that for the bloqade_trotter kernel, the argument values are not supplied until the simulator is run.

cirq_trotter_qubits = StackMemorySimulator(min_qubits=12).run(
bloqade_trotter, args=(12, 10, 0.01, 1, 1)
)
cirq_bloqade_state = StackMemorySimulator.quantum_state(cirq_trotter_qubits)
print(
"Overlap:",
np.abs(np.dot(np.conj(cirq_statevector), cirq_bloqade_state.eigenvectors[:, 0]))
** 2,
)
Overlap: 1.0000001953161866

Bloqade kernels can natively represent mid-circuit feed-forward using control flow represented by standard Python if-else and while structures. While the possibilities are endless, including measurement-based quantum computing and error correction, we show two examples here.

The first example uses a T magic state to apply a T gate to the target through a Clifford CNOT, measurement, and feedforward. The magic state is the non-Clifford resource. With encoded operations and fault-tolerant magic-state preparation, this pattern can implement a fault-tolerant T gate.

@squin.kernel
def t_teleport(target: squin.qubit.Qubit) -> squin.qubit.Qubit:
ancilla = squin.qalloc(1)[0]
squin.h(ancilla)
squin.t(ancilla)
squin.cx(control=target, target=ancilla)
bit = squin.measure(ancilla)
if bit:
squin.s(target)
return target # The target now carries the T-rotated input state
# And now let’s wrap it into a larger context to run. In this case,
# apply to a |+> state and see that we get a T|+> state out.
@squin.kernel
def t_teleport_wrapper() -> squin.qubit.Qubit:
target = squin.qalloc(1)[0]
squin.h(target)
target = t_teleport(target)
return target
# And run it. Observe that the batch_state uses a qubit_map to select which qubits to include in the batch state.
# This is important because there are two qubits total (the target and the ancilla) but we only want to inspect
# the state of the output qubit.
emulator = StackMemorySimulator(min_qubits=2)
task = emulator.task(t_teleport_wrapper)
state = task.batch_state(shots=1000, qubit_map=lambda x: [x])
# Even though there is measurement and feedforward, the final state is still pure. Neat!
print(state)
QuantumState(eigenvalues=array([1.00000006]), eigenvectors=array([[-0.70710678+3.31290798e-31j],
[-0.5 -4.99999999e-01j]]))

Remarkably, it is also possible to prepare a GHZ state with constant quantum gate depth. At first glance, this seems impossible: quantum information can only propagate as fast as the information lightcone, and so a constant depth circuit can only have a small amount of entanglement—not enough to prepare the long-range correlated GHZ state. The trick is, we can replace quantum gates with ancillas and classical feedforward measurements so that the information is propagated classically instead of quantumly. For more details, check out [1] and [2].

[1] Efficient Long-Range Entanglement Using Dynamic Circuits

[2] Constant-Depth Preparation of Matrix Product States with Adaptive Quantum Circuits

The explicit circuit for the GHZ circuit is shown in Fig. 5 of [1]. There is classical feedforward in the form of a parity check, which requires a classical XOR operation that is not directly representable in Cirq.

def ghz_constant_depth(n_qubits: int):
@squin.kernel
def main() -> Register:
qreg = squin.qalloc(n_qubits)
ancilla = squin.qalloc(n_qubits - 1)
for i in range(n_qubits):
squin.h(qreg[i])
for i in range(n_qubits - 1):
squin.cx(qreg[i], ancilla[i])
for i in range(n_qubits - 1):
squin.cx(qreg[i + 1], ancilla[i])
parity: int = 0
bits = squin.broadcast.measure(ancilla)
for i in range(n_qubits - 1):
parity = parity ^ bits[i]
if parity == 1:
squin.x(qreg[i + 1])
return qreg
return main
# At this point, you know the drill. We can simulate this with batch runs via PyQrack
emulator = StackMemorySimulator(min_qubits=7)
task = emulator.task(ghz_constant_depth(3))
state = task.batch_state(shots=1000, qubit_map=lambda x: x)
# Even though there is measurement and feedforward, the final state is still pure. Neat!
print(state.eigenvalues)
[1.00000008]

As a final note, consider how difficult it would be to represent this circuit in Cirq. In particular, there is a for loop, where inside the for loop there is an algebraic operation (XOR) that feeds forward onto a variable (parity). This circuit is very hard to express in Cirq without some serious hacking of ancilla registers.