Simulation of kicked Ising Hamiltonian with dynamic circuits
דף זה טרם תורגם. התוכן מוצג באנגלית.
Usage estimate: 7.5 minutes on a Heron r3 processor. (NOTE: This is an estimate only. Your runtime may vary.) Dynamic circuits are circuits with classical feedforward - in other words, they are mid-circuit measurements followed by classical logic operations that determine quantum operations conditioned on the classical output. In this tutorial, we simulate the kicked Ising model on a hexagonal lattice of spins and use dynamic circuits to realize interactions beyond the physical connectivity of the hardware.
The Ising model has been studied extensively across areas of physics. It models spins that undergo Ising interactions between lattice sites, as well as kicks from the local magnetic field on each site. The Trotterized time evolution of the spins considered in this tutorial, taken from [1], is given by the following unitary:
To probe the spin dynamics, we study the average magnetization of the spins at each site as a function of Trotter steps. Hence, we construct the following observable:
To realize the ZZ interaction between lattice sites, we present a solution using the dynamic circuit feature, leading to a significantly shorter two-qubit depth compared to the standard routing method with SWAP gates. On the other hand, the classical feedforward operations in dynamic circuits typically have longer execution times than quantum gates; hence, dynamic circuits have limitations and tradeoffs. We also present a way to add a dynamical decoupling sequence on idling qubits during the classical feedforward operation using the stretch duration.
Requirements
Before starting this tutorial, ensure that you have the following installed:
- Qiskit SDK v2.0 or later with visualization support
- Qiskit Runtime v0.37 or later with visualization support (
pip install 'qiskit-ibm-runtime[visualization]') - Rustworkx graph library (
pip install rustworkx) - Qiskit Aer (
pip install qiskit-aer)
Set up
import numpy as np
from typing import List
import rustworkx as rx
import matplotlib.pyplot as plt
from rustworkx.visualization import mpl_draw
from qiskit.circuit import (
Parameter,
QuantumCircuit,
QuantumRegister,
ClassicalRegister,
)
from qiskit.transpiler import CouplingMap
from qiskit.quantum_info import SparsePauliOp
from qiskit.circuit.classical import expr
from qiskit.transpiler.preset_passmanagers import (
generate_preset_pass_manager,
)
from qiskit.transpiler import PassManager
from qiskit.circuit.library import RZGate, XGate
from qiskit.transpiler.passes import (
ALAPScheduleAnalysis,
PadDynamicalDecoupling,
)
from qiskit.transpiler.basepasses import TransformationPass
from qiskit.circuit.measure import Measure
from qiskit.transpiler.passes.utils.remove_final_measurements import (
calc_final_ops,
)
from qiskit.circuit import Instruction
from qiskit.visualization import plot_circuit_layout
from qiskit.circuit.tools import pi_check
from qiskit_aer import AerSimulator
from qiskit_aer.primitives import SamplerV2 as Aer_Sampler
from qiskit_ibm_runtime import (
QiskitRuntimeService,
Batch,
SamplerV2 as Sampler,
)
from qiskit_ibm_runtime.exceptions import QiskitBackendNotFoundError
from qiskit_ibm_runtime.visualization import (
draw_circuit_schedule_timing,
)
Step 1: Map classical inputs to a quantum circuit
We start by defining the lattice to simulate. We choose to work with the honeycomb (also called hexagonal) lattice, which is a planar graph with nodes of degree 3. Here, we specify the size of the lattice, the relevant circuit parameters of interest in the Trotterized dynamics. We simulate the Trotterized time evolution under the Ising model under three different values of the local magnetic field.
hex_rows = 3 # specify lattice size
hex_cols = 5
depths = range(9) # specify Trotter steps
zz_angle = np.pi / 8 # parameter for ZZ interaction
max_angle = np.pi / 2 # max theta angle
points = 3 # number of theta parameters
θ = Parameter("θ")
params = np.linspace(0, max_angle, points)
def make_hex_lattice(hex_rows=1, hex_cols=1):
"""Define hexagon lattice."""
hex_cmap = CouplingMap.from_hexagonal_lattice(
hex_rows, hex_cols, bidirectional=False
)
data = list(hex_cmap.physical_qubits)
graph = hex_cmap.graph.to_undirected(multigraph=False)
edge_colors = rx.graph_misra_gries_edge_color(graph)
layer_edges = {color: [] for color in edge_colors.values()}
for edge_index, color in edge_colors.items():
layer_edges[color].append(graph.edge_list()[edge_index])
return data, layer_edges, hex_cmap, graph
Let's start with a small test example:
hex_rows_test = 1
hex_cols_test = 2
data_test, layer_edges_test, hex_cmap_test, graph_test = make_hex_lattice(
hex_rows=hex_rows_test, hex_cols=hex_cols_test
)
# display a small example for illustration
node_colors_test = ["lightblue"] * len(graph_test.node_indices())
pos = rx.graph_spring_layout(
graph_test,
k=5 / np.sqrt(len(graph_test.nodes())),
repulsive_exponent=1,
num_iter=150,
)
mpl_draw(graph_test, node_color=node_colors_test, pos=pos)
We will use the small example for illustration and simulation. Below we also construct a large example to show the workflow can be extended to large sizes.
data, layer_edges, hex_cmap, graph = make_hex_lattice(
hex_rows=hex_rows, hex_cols=hex_cols
)
num_qubits = len(data)
print(f"num_qubits = {num_qubits}")
# display the honeycomb lattice to simulate
node_colors = ["lightblue"] * len(graph.node_indices())
pos = rx.graph_spring_layout(
graph,
k=5 / np.sqrt(num_qubits),
repulsive_exponent=1,
num_iter=150,
)
mpl_draw(graph, node_color=node_colors, pos=pos)
plt.show()
num_qubits = 46
Build unitary circuits
With the problem size and parameters specified, we are now ready to build the parametrized circuit that simulates the Trotterized time evolution of with different Trotter steps, specified by the depth argument. The circuit we construct has alternating layers of Rx() gates and Rzz gates. The Rzz gates realize the ZZ interactions between coupled spins, which will be placed between each lattice site specified by the layer_edges argument.
def gen_hex_unitary(
num_qubits=6,
zz_angle=np.pi / 8,
layer_edges=[
[(0, 1), (2, 3), (4, 5)],
[(1, 2), (3, 4), (5, 0)],
],
θ=Parameter("θ"),
depth=1,
measure=False,
final_rot=True,
):
"""Build unitary circuit."""
circuit = QuantumCircuit(num_qubits)
# Build trotter layers
for _ in range(depth):
for i in range(num_qubits):
circuit.rx(θ, i)
circuit.barrier()
for coloring in layer_edges.keys():
for e in layer_edges[coloring]:
circuit.rzz(zz_angle, e[0], e[1])
circuit.barrier()
# Optional final rotation, set True to be consistent with Ref. [1]
if final_rot:
for i in range(num_qubits):
circuit.rx(θ, i)
if measure:
circuit.measure_all()
return circuit
Visualize the small test circuit:
circ_unitary_test = gen_hex_unitary(
num_qubits=len(data_test),
layer_edges=layer_edges_test,
θ=Parameter("θ"),
depth=1,
measure=True,
)
circ_unitary_test.draw(output="mpl", fold=-1)
Similarly, construct the unitary circuits of the large example at different Trotter steps and the observable to estimate the expectation value.
circuits_unitary = []
for depth in depths:
circ = gen_hex_unitary(
num_qubits=num_qubits,
layer_edges=layer_edges,
θ=Parameter("θ"),
depth=depth,
measure=True,
)
circuits_unitary.append(circ)
observables_unitary = SparsePauliOp.from_sparse_list(
[("Z", [i], 1 / num_qubits) for i in range(num_qubits)],
num_qubits=num_qubits,
)
Build dynamic circuit implementation
This section demonstrates the main dynamic circuit implementation to simulate the same Trotterized time evolution. Note that the honeycomb lattice we want to simulate does not match the heavy lattice of the hardware qubits. One straightforward way to map the circuit to the hardware is to introduce a series of SWAP operations to bring interacting qubits next to each other, to realize the ZZ interaction. Here we highlight an alternative approach using dynamic circuits as a solution, which illustrates that we can use the combination of quantum and real-time classical computation within a circuit in Qiskit to realize interactions beyond nearest-neighbor.
In the dynamic circuit implementation, the ZZ interaction is effectively implemented by using ancilla qubits, mid-circuit measurement, and feedforward. To understand this, note that the ZZ rotations apply a phase factor to the state based on its parity. For two qubits, the computational basis states are , , , and . The ZZ rotation gate applies a phase factor to states and whose parity (the number of ones in the state) is odd and leaves even-parity states unchanged. The following describes how we can effectively implement ZZ interactions on two qubits using dynamic circuits.
-
Compute parity into an ancilla qubit: instead of directly applying ZZ to two qubits, we introduce a third qubit, the ancilla qubit, to store the parity information of the two data qubits. We entangle the ancilla with each data qubit using CX gates from the data qubit to the ancilla qubit.
-
Apply a single-qubit Z rotation to the ancilla qubit: this is because the ancilla has the parity information of the two data qubits, which effectively implements the ZZ rotation on the data qubits.
-
Measure the ancilla qubit in the X basis: this is the key step that collapses the state of the ancilla qubit, and the measurement outcome tells us what has happened:
-
Measure 0: when a 0 outcome is observed, we have in fact correctly applied a rotation to our data qubits.
-
Measure 1: when a 1 outcome is observed, We've applied instead.
-
-
Apply correction gate when measuring 1: If we measured 1, we apply Z gates to the data qubits to "fix" the extra phase.
The resulting circuit is the following:
When we adopt this approach to simulate a honeycomb lattice, the resulting circuit embeds perfectly into the hardware with a heavy-hex lattice: all data qubits reside on the degree-3 sites of the lattice, which forms a hexagonal lattice. Every pair of data qubits shares an ancilla qubit residing on a degree-2 site. Below, we construct the qubit lattice for the dynamic circuit implementation, introducing ancilla qubits (shown in the darker purple circles).
def make_lattice(hex_rows=1, hex_cols=1):
"""Define heavy-hex lattice and corresponding lists of data and ancilla nodes."""
hex_cmap = CouplingMap.from_hexagonal_lattice(
hex_rows, hex_cols, bidirectional=False
)
data = list(hex_cmap.physical_qubits)
heavyhex_cmap = CouplingMap()
for d in data:
heavyhex_cmap.add_physical_qubit(d)
# make coupling map
a = len(data)
for edge in hex_cmap.get_edges():
heavyhex_cmap.add_physical_qubit(a)
heavyhex_cmap.add_edge(edge[0], a)
heavyhex_cmap.add_edge(edge[1], a)
a += 1
ancilla = list(range(len(data), a))
qubits = data + ancilla
# color edges
graph = heavyhex_cmap.graph.to_undirected(multigraph=False)
edge_colors = rx.graph_misra_gries_edge_color(graph)
layer_edges = {color: [] for color in edge_colors.values()}
for edge_index, color in edge_colors.items():
layer_edges[color].append(graph.edge_list()[edge_index])
# construct observable
obs_hex = SparsePauliOp.from_sparse_list(
[("Z", [i], 1 / len(data)) for i in data],
num_qubits=len(qubits),
)
return (data, qubits, ancilla, layer_edges, heavyhex_cmap, graph, obs_hex)
Visualize the heavy-hex lattice for data qubits and ancilla qubits at a small scale:
(data, qubits, ancilla, layer_edges, heavyhex_cmap, graph, obs_hex) = (
make_lattice(hex_rows=hex_rows, hex_cols=hex_cols)
)
print(f"number of data qubits = {len(data)}")
print(f"number of ancilla qubits = {len(ancilla)}")
node_colors = []
for node in graph.node_indices():
if node in ancilla:
node_colors.append("purple")
else:
node_colors.append("lightblue")
pos = rx.graph_spring_layout(
graph,
k=1 / np.sqrt(len(qubits)),
repulsive_exponent=2,
num_iter=200,
)
# Visualize the graph, blue circles are data qubits and purple circles are ancillas
mpl_draw(graph, node_color=node_colors, pos=pos)
plt.show()
number of data qubits = 46
number of ancilla qubits = 60

Below, we construct the dynamic circuit for the Trotterized time evolution. The RZZ gates are replaced with the dynamic circuit implementation using the steps described above.
def gen_hex_dynamic(
depth=1,
zz_angle=np.pi / 8,
θ=Parameter("θ"),
hex_rows=1,
hex_cols=1,
measure=False,
add_dd=True,
):
"""Build dynamic circuits."""
(data, qubits, ancilla, layer_edges, heavyhex_cmap, graph, obs_hex) = (
make_lattice(hex_rows=hex_rows, hex_cols=hex_cols)
)
# Initialize circuit
qr = QuantumRegister(len(qubits), "qr")
cr = ClassicalRegister(len(ancilla), "cr")
circuit = QuantumCircuit(qr, cr)
for k in range(depth):
# Single-qubit Rx layer
for d in data:
circuit.rx(θ, d)
circuit.barrier()
# CX gates from data qubits to ancilla qubits
for same_color_edges in layer_edges.values():
for e in same_color_edges:
circuit.cx(e[0], e[1])
circuit.barrier()
# Apply Rz rotation on ancilla qubits and rotate into X basis
for a in ancilla:
circuit.rz(zz_angle, a)
circuit.h(a)
# Add barrier to align terminal measurement
circuit.barrier()
# Measure ancilla qubits
for i, a in enumerate(ancilla):
circuit.measure(a, i)
d2ros = {}
a2ro = {}
# Retrieve ancilla measurement outcomes
for a in ancilla:
a2ro[a] = cr[ancilla.index(a)]
# For each data qubit, retrieve measurement outcomes of neighboring ancilla qubits
for d in data:
ros = [a2ro[a] for a in heavyhex_cmap.neighbors(d)]
d2ros[d] = ros
# Build classical feedforward operations (optionally add DD on idling data qubits)
for d in data:
if add_dd:
circuit = add_stretch_dd(circuit, d, f"data_{d}_depth_{k}")
# # XOR the neighboring readouts of the data qubit; if True, apply Z to it
ros = d2ros[d]
parity = ros[0]
for ro in ros[1:]:
parity = expr.bit_xor(parity, ro)
with circuit.if_test(expr.equal(parity, True)):
circuit.z(d)
# Reset the ancilla if its readout is 1
for a in ancilla:
with circuit.if_test(expr.equal(a2ro[a], True)):
circuit.x(a)
circuit.barrier()
# Final single-qubit Rx layer to match the unitary circuits
for d in data:
circuit.rx(θ, d)
if measure:
circuit.measure_all()
return circuit, obs_hex
def add_stretch_dd(qc, q, name):
"""Add XpXm DD sequence."""
s = qc.add_stretch(name)
qc.delay(s, q)
qc.x(q)
qc.delay(s, q)
qc.delay(s, q)
qc.rz(np.pi, q)
qc.x(q)
qc.rz(-np.pi, q)
qc.delay(s, q)
return qc
Dynamical decoupling (DD) and support for stretch duration
One caveat of using the dynamic circuit implementation to realize the ZZ interaction is that the mid-circuit measurement and the classical feedforward operations typically take a longer time to execute than quantum gates. To suppress qubit decoherence during the idling time for the classical operations to happen, we added a dynamical decoupling (DD) sequence after the measurement operation on the ancilla qubits, and before the conditional Z operation on the data qubit, before the if_test statement.
The DD sequence is added by the function add_stretch_dd(), which uses the stretch durations to determine the time intervals between the DD gates. A stretch duration is a way to specify a stretchable time duration for the delay operation such that the delay duration can grow to fill up the qubit idling time. The duration variables specified by stretch are resolved at compile time into desired durations that satisfy a certain constraint. This is very useful when the timing of DD sequences is essential to achieve good error suppression performance. For more details on the stretch type, see the OpenQASM documentation. Currently, support for the stretch type in Qiskit Runtime is experimental. For details on its usage constraints, please refer to the limitations section of the stretch documentation.
Using the functions defined above, we build the Trotterized time evolution circuits, with and without DD, and the corresponding observables. We start by visualizing the dynamic circuit of a small example:
hex_rows_test = 1
hex_cols_test = 1
(
data_test,
qubits_test,
ancilla_test,
layer_edges_test,
heavyhex_cmap_test,
graph_test,
obs_hex_test,
) = make_lattice(hex_rows=hex_rows_test, hex_cols=hex_cols_test)
node_colors = []
for node in graph_test.node_indices():
if node in ancilla_test:
node_colors.append("purple")
else:
node_colors.append("lightblue")
pos = rx.graph_spring_layout(
graph_test,
k=5 / np.sqrt(len(qubits_test)),
repulsive_exponent=2,
num_iter=150,
)
# display a small example for illustration
node_colors_test = ["lightblue"] * len(graph_test.node_indices())
mpl_draw(graph_test, node_color=node_colors, pos=pos)
circuit_dynamic_test, obs_dynamic_test = gen_hex_dynamic(
depth=1,
θ=Parameter("θ"),
hex_rows=hex_rows_test,
hex_cols=hex_cols_test,
measure=False,
add_dd=False,
)
circuit_dynamic_test.draw("mpl", fold=-1)

circuit_dynamic_dd_test, _ = gen_hex_dynamic(
depth=1,
θ=Parameter("θ"),
hex_rows=hex_rows_test,
hex_cols=hex_cols_test,
measure=False,
add_dd=True,
)
circuit_dynamic_dd_test.draw("mpl", fold=-1)

Similarly, construct the dynamic circuits for the large example:
circuits_dynamic = []
circuits_dynamic_dd = []
observables_dynamic = []
for depth in depths:
circuit, obs = gen_hex_dynamic(
depth=depth,
θ=Parameter("θ"),
hex_rows=hex_rows,
hex_cols=hex_cols,
measure=True,
add_dd=False,
)
circuits_dynamic.append(circuit)
circuit_dd, _ = gen_hex_dynamic(
depth=depth,
θ=Parameter("θ"),
hex_rows=hex_rows,
hex_cols=hex_cols,
measure=True,
add_dd=True,
)
circuits_dynamic_dd.append(circuit_dd)
observables_dynamic.append(obs)
Step 2: Optimize problem for hardware execution
We are now ready to transpile the circuit to the hardware. We will transpile both the unitary standard implementation and the dynamic circuit implementation to the hardware.
To transpile to hardware, we first instantiate the backend. If available, we will choose a backend where the MidCircuitMeasure (measure_2) instruction is supported.
service = QiskitRuntimeService()
try:
backend = service.least_busy(
operational=True,
simulator=False,
use_fractional_gates=True,
filters=lambda b: "measure_2" in b.supported_instructions,
)
except QiskitBackendNotFoundError:
backend = service.least_busy(
operational=True,
simulator=False,
use_fractional_gates=True,
)