Long-range entanglement with dynamic circuits
Usage estimate: 16 seconds on IBM Pittsburgh (NOTE: This is an estimate only. Your runtime may vary.)
Background
Long-range entanglement between distant qubits on a quantum processor can be a challenging task for devices with limited qubit connectivity. This tutorial shows three different ways that can be used to generate long-range entanglement between qubits on a line, at varying distances between each other:
- a unitary-based implementation, which uses SWAP operations to reduce the distance between distant qubits and entangle them directly,
- a measurement-based implementation with dynamic circuits, which uses measurement and feedforward of information during the quantum computation to entangle the qubits. In particular, the results show the effectiveness of dynamic circuits in generating long-range entanglement between two unconnected qubits at utility scales. The notebook uses the ideas and results from [1] by Elisa Bäumer et al.
Requirements
Before starting this tutorial, ensure that you have the following installed:
- Qiskit SDK 2.0 or later, with visualization support (
pip install 'qiskit[visualization]'
) - Qiskit Runtime (
pip install qiskit-ibm-runtime
) 0.37 or later
Setup
import random
from typing import List, Optional
import matplotlib.pyplot as plt
import numpy as np
from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister
from qiskit.circuit import IfElseOp
from qiskit.circuit.classical import expr
from qiskit.primitives import BitArray
from qiskit.transpiler import CouplingMap
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit_ibm_runtime import SamplerV2 as Sampler
from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_ibm_runtime.transpiler.passes.scheduling import (
DynamicCircuitInstructionDurations,
)
Step 1: Map classical inputs to a quantum problem
In this tutorial you will run a gate teleportation circuit in three different setups, where you always assume a line of n qubits (for varying n with n-2 empty ancillas in the middle and a CNOT to apply between the two ends):
- Unitary-based implementation swapping the qubits to the middle
- Measurement-based implementation with dynamic circuits
For each implementation you measure the average state fidelity of the Bell pair prepared on the qubits at the edge of the chain to compare among the different implementations.
Experimental setup
The experiments in this notebook use a 1-D line of qubits with a coupling map that ensures that no shortcuts can be taken.
Define 1-D line
First, set up a line of qubits through the machine that you intend to use such that you avoid broken qubits or areas with high readout errors. To do this, examine the calibration data (which can be found online or via the command plot_error_map(backend)
).
We will describe the line as a simple list of integer indices. In this tutorial, we will choose a random qubit line using the following function.
def get_1d_qubit_line(
coupling_map: CouplingMap, start_qubit: int = 0
) -> List[int]:
"""
Use random search to find a 1D line, repeating until we find a line of sufficient length.
This is very inefficient, but it's fine for this demo.
"""
def get_random_line(
coupling_map: CouplingMap, start_qubit: int
) -> List[int]:
"""
Do a random, self-avoiding walk on the coupling map to get a 1D line.
"""
edge_list = list(coupling_map.get_edges())
path = [start_qubit]
while True:
# Get edges connected to the current qubit that don't connect to a qubit we've already visited
current_qubit = path[-1]
previously_visited_qubits = path[:-1]
candidate_edges = [
edge
for edge in edge_list
if current_qubit in edge
and not any(
qubit in previously_visited_qubits for qubit in edge
)
]
if candidate_edges == []:
return path
next_edge = random.choice(candidate_edges)
edge_list.remove(next_edge)
next_qubit = next(
qubit for qubit in next_edge if qubit != path[-1]
)
path.append(next_qubit)
# Now repeat the random walk many times to find a long line
return max(
[get_random_line(coupling_map, start_qubit) for _ in range(1000)],
key=lambda x: len(x),
)
Set primary parameters
In this section are definitions for some common parameters that you will use later. You'll need to specify these parameters for a particular backend. In order to do so, you will need an account on IBM Quantum® Platform. More details on how to initialize your account can be found in the documentation.
def coupling_map_from_qubit_line(
coupling_map: CouplingMap, qubit_line: List[List[int]]
) -> CouplingMap:
"""
Modify the full coupling map to force linearity in the qubit layout
"""
new_coupling_map = []
# Iterate the line pair-wise and append edges that contain both qubits
for qubit, next_qubit in zip(qubit_line, qubit_line[1:]):
edge = next(
edge
for edge in coupling_map.get_edges()
if qubit in edge and next_qubit in edge
)
new_coupling_map.append(edge)
return CouplingMap(new_coupling_map)
# Set up access to IBM Quantum devices
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, dynamic_circuits=True
)
if "if_else" not in backend.target.operation_names:
backend.target.add_instruction(IfElseOp, name="if_else")
# Set qubit line and coupling map
QUBIT_LINE = get_1d_qubit_line(backend.coupling_map)
COUPLING_MAP_1D = coupling_map_from_qubit_line(
backend.coupling_map, QUBIT_LINE
)
MAX_POSSIBLE_QUBITS_BTW_CNOT = len(QUBIT_LINE) - 2
# Use this duration class to get appropriate durations for dynamic
# circuit backend scheduling
DURATIONS = DynamicCircuitInstructionDurations.from_backend(backend)
print(f"Backend is: {backend.name}")
print(f"Found line of length {len(QUBIT_LINE)}.")
print("Using coupling map:\n", COUPLING_MAP_1D)
print(
f"Maximum number of qubits between CNOT for {backend.name} is {MAX_POSSIBLE_QUBITS_BTW_CNOT} with the given qubit line."
)
Next, set the global scope of the experiment. These variables can be used in each circuit type or can be set individually in each experiment that will override these globals.
# Level of optimizations the transpiler uses: There are 4 optimization levels ranging from 0 to 3,
# where higher optimization levels take more time and computational effort but may yield a more
# optimal circuit. level 0 does no explicit optimization, it will just try to make a circuit
# runnable by transforming it to match a topology and basis gate set, if necessary.
# Level 1, 2 and 3 do light, medium and heavy optimization, using a combination of passes, and by
# configuring the passes to search for better solutions.
OPTIMIZATION_LEVEL = 0
# Set the number of repetitions of each circuit, for sampling.
# The number of qubits between the control and target are grouped into blocks
# of length 4. The provided min and max number of qubits will be modified to
# align with these block sizes.
SHOTS = 1000
# Set the number of times the experimental counts are resampled.
# This is used to calculate mean and standard deviations of results.
BOOTSTRAP_SAMPLES = 10
# The min number of qubits between the control and target qubits on line
MIN_NUMBER_QUBITS = 0
# The max number of qubits between the control and target qubits on line
# The max for MIN_NUMBER_QUBITS is len(QUBIT_LINE) - 2
# max_number_qubits must satisfy MAX_NUMBER_QUBITS - MIN_NUMBER_QUBITS = 3 (mod 4)
# at this point. This is just to make things easier to break jobs up. Not a real limitation.
MAX_NUMBER_QUBITS = 15
assert (
(MAX_NUMBER_QUBITS - MIN_NUMBER_QUBITS) % 4 == 3
), "MAX_NUMBER_QUBITS must satisfy MAX_NUMBER_QUBITS - MIN_NUMBER_QUBITS = 3 (mod 4)"
assert (MAX_NUMBER_QUBITS + 2) <= len(
QUBIT_LINE
), "MAX_NUMBER_QUBITS must satisfy MAX_NUMBER_QUBITS + 2 <= len(QUBIT_LINE)"
Unitary-based implementation swapping the qubits to the middle
First, examine the case where a long-range CNOT gate is implemented using nearest-neighbor connections and unitary gates. In the following figure, on the left is a circuit for a long-range CNOT gate spanning a 1D chain of n-qubits subject to nearest-neighbor connections only. On the right is an equivalent unitary decomposition implementable with local CNOT gates, circuit depth O(n).

The circuit on the right can be implemented as follows:
def CNOT_unitary(
qc: QuantumCircuit, control_qubit: int, target_qubit: int
) -> QuantumCircuit:
"""Generate a CNOT gate between data qubit control_qubit and data qubit target_qubit using local CNOTs
Assumes that the long-range CNOT gate will be spanning a 1D chain of n-qubits subject to nearest-neighbor
connections only with the chain starting at the control qubit and finishing at the target qubit.
Assumes that control_qubit < target_qubit (as integers) and that the provided circuit qc has |0> set
qubits control_qubit+1, ..., target_qubit-1
Args:
qc (QuantumCicruit) : A Quantum Circuit to add the long range localized unitary CNOT
control_qubit (int) : The qubit used as the control.
target_qubi (int) : The qubit targeted by the gate.
Example:
qc = QuantumCircuit(8,2)
qc = CNOT_unitary(qc, 0, 7)
Returns:
QuantumCircuit
"""
assert target_qubit > control_qubit
n = target_qubit - control_qubit - 1
k = int(n / 2)
qc.barrier()
for i in range(control_qubit, control_qubit + k):
qc.cx(i, i + 1)
qc.cx(i + 1, i)
qc.cx(-i - 1, -i - 2)
qc.cx(-i - 2, -i - 1)
if n % 2 == 1:
qc.cx(k + 2, k + 1)
qc.cx(k + 1, k + 2)
qc.barrier()
qc.cx(k, k + 1)
for i in range(control_qubit, control_qubit + k):
qc.cx(k - i, k - 1 - i)
qc.cx(k - 1 - i, k - i)
qc.cx(k + i + 1, k + i + 2)
qc.cx(k + i + 2, k + i + 1)
if n % 2 == 1:
qc.cx(-2, -1)
qc.cx(-1, -2)
qc.barrier()
return qc
The build_circuits_uni
method therefore builds a list of circuits to run with different number of qubits in the chain.
def build_circuits_uni(n: int) -> List[QuantumCircuit]:
"""Builds the unitary circuits needed to estimate the average gate fidelity
Args:
n (int): Number of qubits between the control and target of the CNOT
"""
assert n >= 0, "Error: n needs to be a non-negative integer"
circuits = []
for i in range(n):
circuit = QuantumCircuit(i + 2, 2)
circuit.h(0)
circuit = CNOT_unitary(circuit, 0, i + 1) # Add long range CNOT
circuit.measure([0, i + 1], [0, 1]) # Prepare circuits to measure
circuits.append(circuit)
return circuits
Now build all unitary circuits
circuits_uni = build_circuits_uni(MAX_NUMBER_QUBITS)
circuits_uni[-1].draw("mpl", fold=-1)
Output:

Long-range measurement-based CNOT with feedforward
Then, examine the case where a long-range CNOT gate is implemented using measurement-based CNOT with feedforward (dynamic circuits). In the following figure, on the left is a circuit for a long-range CNOT gate spanning a 1D chain of n-qubits subject to nearest-neighbor connections only. On the right is an equivalent implementable with local CNOT gates, measurement-based CNOT with feedforward (dynamic circuits).

The circuit on the right can be implemented as follows:
def CNOT_dyn(
qc: QuantumCircuit,
control_qubit: int,
target_qubit: int,
c1: Optional[ClassicalRegister] = None,
c2: Optional[ClassicalRegister] = None,
add_barriers: Optional[bool] = True,
) -> QuantumCircuit:
"""Generate a CNOT gate between data qubit control_qubit and data qubit target_qubit using Bell Pairs.
Post processing is used to enable the CNOT gate via the provided classical registers c1 and c2
Assumes that the long-range CNOT gate will be spanning a 1D chain of n-qubits subject to nearest-neighbor
connections only with the chain starting at the control qubit and finishing at the target qubit.
Assumes that control_qubit < target_qubit (as integers) and that the provided circuit qc has |0> set
qubits control_qubit+1, ..., target_qubit-1
n = target_qubit - control_qubit - 1 : Number of qubits between the target and control qubits
k = int(n/2) : Number of Bell pairs created
Args:
qc (QuantumCicruit) : A Quantum Circuit to add the long range localized unitary CNOT
control_qubit (int) : The qubit used as the control.
target_qubi (int) : The qubit targeted by the gate.
Optional Args:
c1 (ClassicalRegister) : Default = None. Required if n > 1. Register requires k bits
c2 (ClassicalRegister) : Default = None. Required if n > 0. Register requires n - k bits
add_barriers (bool) : Default = True. Include barriers before and after long range CNOT
Note: This approach uses two if_test statements. A better (more performant) approach is
to have the parity values combined into a single classical register and then use a switch
statement. This was done in the associated paper my modifying the qasm file directly. The ability
to use a switch statement via Qiskit in this way is a future release capability.
Returns:
QuantumCircuit
"""
assert target_qubit > control_qubit
n = target_qubit - control_qubit - 1
t = int(n / 2)
if add_barriers is True:
qc.barrier()
# Determine where to start the bell pairs and
# add an extra CNOT when n is odd
if n % 2 == 0:
x0 = 1
else:
x0 = 2
qc.cx(0, 1)
# Create t Bell pairs
for i in range(t):
qc.h(x0 + 2 * i)
qc.cx(x0 + 2 * i, x0 + 2 * i + 1)
# Entangle Bell pairs and data qubits and measure
for i in range(t + 1):
qc.cx(x0 - 1 + 2 * i, x0 + 2 * i)
for i in range(1, t + x0):
if i == 1:
qc.h(2 * i + 1 - x0)
qc.measure(2 * i + 1 - x0, c2[i - 1])
parity_control = expr.lift(c2[i - 1])
else:
qc.h(2 * i + 1 - x0)
qc.measure(2 * i + 1 - x0, c2[i - 1])
parity_control = expr.bit_xor(c2[i - 1], parity_control)
for i in range(t):
if i == 0:
qc.measure(2 * i + x0, c1[i])
parity_target = expr.lift(c1[i])
else:
qc.measure(2 * i + x0, c1[i])
parity_target = expr.bit_xor(c1[i], parity_target)
if n > 0:
with qc.if_test(parity_control):
qc.z(control_qubit)
if n > 1:
with qc.if_test(parity_target):
qc.x(target_qubit)
if add_barriers is True:
qc.barrier()
return qc
Put it together with to create circuit for a chain of qubits of varying length
def build_circuits_dyn(n: int) -> List[QuantumCircuit]:
""" """
assert n >= 0, "Error: n needs to be a non-negative integer"
circuits = []
qr = QuantumRegister(
n + 2, name="q"
) # Circuit with n qubits between control and target
cr = ClassicalRegister(
2, name="cr"
) # Classical register for measuring long range CNOT
k = int(n / 2) # Number of Bell States to be used
c1 = ClassicalRegister(
k, name="c1"
) # Classical register needed for post processing
c2 = ClassicalRegister(
n - k, name="c2"
) # Classical register needed for post processing
for i in range(n):
if n > 1:
circuit = QuantumCircuit(qr, cr, c1, c2, name="CNOT")
elif n == 1:
circuit = QuantumCircuit(qr, cr, c2, name="CNOT")
elif n == 0:
circuit = QuantumCircuit(qr, cr, name="CNOT")
circuit.h(0)
circuit = CNOT_dyn(
qc=circuit, control_qubit=0, target_qubit=i + 1, c1=c1, c2=c2
)
circuit.measure(0, cr[0])
circuit.measure(i + 1, cr[1])
circuits.append(circuit)
return circuits
Collecting all the dynamic circuits
circuits_dyn = build_circuits_dyn(MAX_NUMBER_QUBITS)
circuits_dyn[-1].draw("mpl", fold=-1)
Output:

Step 2: Optimize problem for quantum hardware execution
Because you have already specified the physical qubit layout and built the circuits with a line topology in mind, there is no need to further optimize the circuits; we only need to make sure that the circuits are fully compatible with the chosen backend.
# Generate the ordered list of physical qubits in the chain
full_layout = set()
for edge in list(COUPLING_MAP_1D.get_edges()):
full_layout.update(set(edge))
full_layout = list(full_layout)
isa_circuits_uni, isa_circuits_postproc, isa_circuits_dyn = [], [], []
for circuit_uni, circuit_dyn in zip(circuits_uni, circuits_dyn):
# Generate the main Qiskit transpile passes.
pm_uni = generate_preset_pass_manager(
coupling_map=COUPLING_MAP_1D,
initial_layout=full_layout[: circuit_uni.num_qubits],
optimization_level=OPTIMIZATION_LEVEL,
backend=backend,
)
pm_dyn = generate_preset_pass_manager(
coupling_map=COUPLING_MAP_1D,
initial_layout=full_layout[: circuit_dyn.num_qubits],
optimization_level=OPTIMIZATION_LEVEL,
backend=backend,
)
isa_circuits_uni.append(pm_uni.run(circuit_uni))
isa_circuits_dyn.append(pm_dyn.run(circuit_dyn))
isa_circuits_dyn[4].draw("mpl", fold=-1, idle_wires=False)
Output:

Step 3: Execute using Qiskit primitives
In this step you execute the experiment on the specified backend. The gen3-experimental
path needs to be specified to access the latest dynamic circuit capability
sampler = Sampler(mode=backend)
sampler.options.experimental = {
"execution_path": "gen3-experimental",
}
job_uni = sampler.run(isa_circuits_uni, shots=SHOTS)
job_dyn = sampler.run(isa_circuits_dyn, shots=SHOTS)
Step 4: Post-process and return result in desired classical format
After the experiments have successfully executed, proceed to post-process the resulting counts to gain insight on the final results. You can take advantage of resampling techniques (also known as bootstrapping) to calculate average fidelities and deviations from the experimental counts.
from qiskit.quantum_info import hellinger_fidelity
BELL_DIST = {"00": 0.5, "11": 0.5}
def get_counts_from_bitarray(instance):
"""
Extract counts from result data
"""
for field, value in instance.__dict__.items():
if isinstance(value, BitArray):
return value.get_counts()
return None
def resample_single_dictionary(d):
"""Resample a single dictionary based on its weights."""
keys = list(d.keys())
weights = list(d.values())
sum_weights = sum(weights)
if sum_weights == 0:
return d
resampled_keys = random.choices(keys, weights=weights, k=sum_weights)
# Count the occurrences of each key in the resampled keys
resampled_counts = {}
for key in resampled_keys:
resampled_counts[key] = resampled_counts.get(key, 0) + 1
return resampled_counts
def resample_dict_list(dict_list, n_samples):
"""Resample the entire list of dictionaries n_samples times."""
resampled_lists = []
for _ in range(n_samples):
new_version = [resample_single_dictionary(d) for d in dict_list]
resampled_lists.append(new_version)
return resampled_lists
result_uni = job_uni.result()
uni_state_fid_mean, uni_state_fid_std = [], []
for i in range(len(result_uni)):
counts = get_counts_from_bitarray(result_uni[i].data)
state_fid_temp = []
for _ in range(BOOTSTRAP_SAMPLES):
resampled_counts = resample_single_dictionary(counts)
fid = hellinger_fidelity(resampled_counts, BELL_DIST)
state_fid_temp.append(fid)
mean, std = (
np.mean(np.array(state_fid_temp)),
np.std(np.array(state_fid_temp)),
)
uni_state_fid_mean.append(mean)
uni_state_fid_std.append(std)
print("State fidelities:")
print(["{0:0.3f}".format(i) for i in uni_state_fid_mean])
print("State fidelities std:")
print(["{0:0.3f}".format(i) for i in uni_state_fid_std])
Output:
State fidelities:
['0.968', '0.964', '0.933', '0.941', '0.847', '0.831', '0.738', '0.791', '0.631', '0.687', '0.621', '0.639', '0.545', '0.583', '0.510']
State fidelities std:
['0.003', '0.007', '0.008', '0.009', '0.008', '0.010', '0.011', '0.012', '0.010', '0.019', '0.010', '0.012', '0.011', '0.012', '0.016']
result_dyn = job_dyn.result()
dyn_state_fid_mean, dyn_state_fid_std = [], []
for i in range(len(result_dyn)):
counts = get_counts_from_bitarray(result_dyn[i].data)
state_fid_temp = []
for _ in range(BOOTSTRAP_SAMPLES):
resampled_counts = resample_single_dictionary(counts)
fid = hellinger_fidelity(resampled_counts, BELL_DIST)
state_fid_temp.append(fid)
mean, std = (
np.mean(np.array(state_fid_temp)),
np.std(np.array(state_fid_temp)),
)
dyn_state_fid_mean.append(mean)
dyn_state_fid_std.append(std)
print("State fidelities:")
print(["{0:0.3f}".format(i) for i in dyn_state_fid_mean])
print("State fidelities std:")
print(["{0:0.3f}".format(i) for i in dyn_state_fid_std])
Output:
State fidelities:
['0.974', '0.911', '0.888', '0.888', '0.878', '0.858', '0.855', '0.855', '0.681', '0.804', '0.764', '0.806', '0.770', '0.775', '0.750']
State fidelities std:
['0.005', '0.007', '0.010', '0.009', '0.010', '0.008', '0.007', '0.007', '0.012', '0.013', '0.008', '0.011', '0.013', '0.014', '0.017']
Plot the results
To appreciate the results visually, the cell below plots the estimated gate fidelities measured at varying distance between entangled qubits for the three different methods. In general, the fidelity will decrease with increasing distance. The results show that although the unitary method (using SWAPs to implement a long-range entangling interaction) performs better at short distances, there is a cross-over to a regime where dynamic circuits become a better option.
fig, ax = plt.subplots()
ax.errorbar(
range(MIN_NUMBER_QUBITS, MAX_NUMBER_QUBITS),
uni_state_fid_mean,
uni_state_fid_std,
fmt="o-.",
color="c",
label="Unitary",
)
ax.errorbar(
range(MIN_NUMBER_QUBITS, MAX_NUMBER_QUBITS),
dyn_state_fid_mean,
dyn_state_fid_std,
fmt="o-.",
color="m",
label="Dynamic",
)
ax.axhline(y=1 / 4, color="g", linestyle="--", label="Random gate")
legend = ax.legend(frameon=True)
for text in legend.get_texts():
text.set_color("black") # Set the legend text color to black
legend.get_frame().set_facecolor(
"white"
) # Set the legend background color to white
legend.get_frame().set_edgecolor(
"black"
) # Optional: set the legend border color to black
ax.set_xlabel("Number of qubits between control and target", color="black")
ax.set_ylabel("Bell state fidelity", color="black")
ax.grid(linestyle=":", linewidth=0.6, alpha=0.4, color="gray")
ax.set_ylim((0.2, 1))
ax.set_facecolor("white") # Set the background color of the axes
fig.patch.set_facecolor("white") # Set the background color of the figure
# Ensure the axis lines and ticks are visible
for spine in ax.spines.values():
spine.set_visible(True)
spine.set_color("black") # Set the color of the axis lines to black
ax.tick_params(axis="x", colors="black")
ax.tick_params(axis="y", colors="black")
plt.show()
Output:

References
[1] Efficient Long-Range Entanglement using Dynamic Circuits, by Elisa Bäumer, Vinay Tripathi, Derek S. Wang, Patrick Rall, Edward H. Chen, Swarnadeep Majumder, Alireza Seif, Zlatko K. Minev. IBM Quantum, (2023). https://arxiv.org/abs/2308.13065
Tutorial survey
Please take one minute to provide feedback on this tutorial. Your insights will help us improve our content offerings and user experience.