> *"A circuit you build once and evaluate ten thousand times is not a circuit. It is a function, and
Prerequisites
- 1
- 2
- 3
- 4
- 5
- 6
- 7
Learning Objectives
- Build parameterized circuits with Parameter and ParameterVector, and write parameter expressions.
- Bind parameters by dictionary and by sequence, and explain why sequence binding is a hazard with plainly named parameters.
- Choose between compose and append, and state what each does to circuit structure and depth.
- Define custom gates and control them, and know what to preserve when you do.
- Use the modern function-based circuit library and explain why the class-based version was deprecated.
- Select an entanglement pattern for an ansatz and justify it in two-qubit gate cost.
- Use barriers correctly, and demonstrate that they block optimization.
- Build the hardware-efficient ansatz the rest of the book uses, and measure its cost after transpilation.
In This Chapter
Chapter 8: Building Complex Circuits
"A circuit you build once and evaluate ten thousand times is not a circuit. It is a function, and it should be designed like one."
Overview
Everything so far has been circuits built gate by gate, with fixed numbers. That works to about a dozen gates and then stops scaling — as a way of writing, and as a way of thinking.
This chapter is about the tools that replace it. Parameters turn a circuit into a function of its angles, which is the prerequisite for every variational algorithm in the book. Composition lets you build circuits from named pieces instead of flat instruction lists. The circuit library gives you the standard constructions — ansätze, feature maps, the QFT — already built and already correct. And barriers let you tell the optimizer where not to look.
Two things in this chapter will save you real time.
The first is a naming hazard. qc.parameters returns parameters sorted by name, which means
theta10 comes before theta2. Bind by position and every angle lands in the wrong gate — silently,
with no error, producing a circuit that runs perfectly and computes something else. §8.2 demonstrates
it and gives the one-line habit that makes it impossible.
The second is that the circuit library changed shape. The class-based constructions you will find
in every tutorial — EfficientSU2, TwoLocal, QFT — are deprecated as of Qiskit 2.1 and will be
removed in 3.0, replaced by functions. §8.5 teaches the current API and explains why the change was
an improvement rather than churn.
By the end you will have built the hardware-efficient ansatz that the project carries to Chapter 36, and measured what it actually costs on hardware — which is considerably more than the logical circuit suggests.
In this chapter, you will learn to:
- Build with
Parameter,ParameterVector, and parameter expressions. - Bind by dictionary and by sequence, and avoid the ordering hazard.
- Choose between
composeandappend, and know what each does to structure. - Define and control custom gates.
- Use the modern function-based circuit library.
- Select an entanglement pattern, priced in two-qubit gates.
- Use barriers, and demonstrate that they block optimization.
- Build the project's ansatz and measure its transpiled cost.
Learning Paths
How to read this chapter by track. - 🔰 Beginner — §8.1, §8.2, §8.3, and §8.5. The ansatz in §8.8 is worth building even if you skip the reasoning behind it. - 🔬 Researcher — §8.2's ordering hazard has silently corrupted published results. §8.5's deprecation matters for anything you want to still run in two years. - 🤖 Quantum ML — this is your chapter. The ansatz of §8.8 and the feature maps of §8.5 are the two objects Part VI is built from, and §8.7's entanglement patterns are a design decision you will make repeatedly. - 🏗️ Quantum Engineer — §8.6 (barriers) and §8.8's transpiled-cost measurement. The gap between logical and ISA depth is the number that governs feasibility. - 🔐 Security — skim §8.1 and §8.3; the rest is not on your path.
8.1 Parameters
A Parameter is a named placeholder that stands where a number would go.
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
theta = Parameter("theta")
qc = QuantumCircuit(1)
qc.ry(theta, 0)
print(qc.parameters) # ParameterView([Parameter(theta)])
print(qc.draw())
┌────────────┐
q: ┤ Ry(theta) ├
└────────────┘
The circuit is now a function of theta. It cannot be simulated or run until the parameter is
given a value, and it can be transpiled — which is the entire basis of Chapter 7 §7.7's
transpile-once pattern.
Expressions
Parameters support arithmetic, producing a ParameterExpression:
a = Parameter("a")
qc = QuantumCircuit(1)
qc.ry(2 * a + 1, 0)
print(qc.data[0].operation.params[0]) # 1 + 2*a
print(qc.assign_parameters({a: 1.0}).data[0].operation.params[0]) # 3.0
1 + 2*a
3.0
This is more useful than it looks. A single independent parameter can drive several gates with different coefficients — which is exactly what happens when a physical rotation angle appears in a Hamiltonian with a coefficient, and it means the optimizer sees one variable rather than three.
What the expression algebra supports
A ParameterExpression is a small symbolic algebra, and it is worth knowing its boundaries before
you design around it. Every one of these is a legal expression that Qiskit will store now and
evaluate later:
a + b a - b -a 2 * a a * b
a / 2 a / b a ** 2 2 ** a np.pi * a
a.sin() a.cos() a.tan() a.arcsin()
a.exp() a.log() a.abs() abs(a)
Each returns another ParameterExpression, so they compose freely: (2 * a + b).sin() / 3 is fine.
Two things there are worth pausing on. Division by a parameter is allowed — a / b is a legal
angle expression — and so is a parameter in the exponent, 2 ** a. The algebra is not restricted to
the affine forms that show up in tutorials.
The transcendental methods are the ones people are surprised to find, and they earn their place. A Trotterised evolution or an amplitude-encoding routine frequently wants an angle of the form $\arcsin(\text{something})$, and writing it symbolically means the optimizer still sees one variable instead of a pre-computed constant that has to be recomputed and re-bound by hand every iteration.
There is also .gradient(), which differentiates an expression with respect to one of its
parameters — a.gradient(a) returns 1.0. That is the symbolic half of the machinery underneath
parameter-shift gradients in Part VI.
What does not work is anything requiring a concrete value, and both of the obvious cases raise loudly:
float(a) TypeError: Parameter expression with unbound parameters ...
a > 0 TypeError: '>' not supported between instances of ...
That is correct behaviour. One case does not raise, and it is the one to watch.
⚠️ Common Pitfall —
if parameter:is always true, and never warns.
bool(a)on an unboundParameterreturnsTrue. It does not raise the wayfloat(a)anda > 0do, because Python's default truthiness for an object isTrueand nothing overrides it.
python if theta: # ALWAYS True -- theta is an object, not a number qc.rz(theta, 0)The branch is taken whatever
thetaeventually binds to, including zero. This appears in ansatz builders that try to skip zero-angle rotations, and in code that tests a parameter to see whether it has been "set yet".The fix: decide a circuit's structure from concrete arguments, never from parameters. Structure is a build-time decision; parameter values are a run-time one, and separating those two is the entire point of §8.1. If you genuinely need structure that depends on a measured value, you need Chapter 9's dynamic circuits, not an
if.
One parameter, many gates
The expression algebra buys something concrete. Three gates, one free variable:
a = Parameter("a")
qc = QuantumCircuit(2)
qc.ry(a, 0)
qc.ry(2 * a, 1)
qc.rz(-a, 0)
print([p.name for p in qc.parameters])
print([float(i.operation.params[0]) for i in qc.assign_parameters([0.5]).data])
['a']
[0.5, 1.0, -0.5]
One number in, three angles out, in fixed ratio. The optimizer's search space is one-dimensional rather than three-dimensional, and the ratios are enforced by construction instead of being a constraint the optimizer has to discover for itself.
This is not a trick; it is what a physical model usually looks like. When one coupling constant appears in several Hamiltonian terms with different coefficients — the normal case in Chapter 36's chemistry — the corresponding rotation angles are related, and the expression is the place to say so. Every ratio you can express is a dimension the optimizer does not have to search, and Chapter 32 §32.5's measured collapse of gradient variance from 1.03e-01 at 2 qubits to 1.17e-03 at 10 — a factor of 88 — is the reason dimensions are the thing you cannot afford.
ParameterVector
For an ansatz with dozens of parameters, naming them individually is unworkable:
from qiskit.circuit import ParameterVector
thetas = ParameterVector("theta", 4)
print([p.name for p in thetas])
['theta[0]', 'theta[1]', 'theta[2]', 'theta[3]']
ParameterVector is not merely a convenience. It is a correctness feature, and §8.2 explains
why.
8.2 Binding — and the Ordering Hazard
Two ways to supply values:
qc.assign_parameters({thetas[0]: 0.1, thetas[1]: 0.2}) # by dictionary
qc.assign_parameters([0.1, 0.2, 0.3, 0.4]) # by sequence
Dictionary binding is unambiguous. Sequence binding is faster to write, faster to execute, and is what every optimizer produces — an optimizer hands you an array, not a dictionary.
And sequence binding is bound to qc.parameters, which is sorted by name.
from qiskit.circuit import Parameter
theta10 = Parameter("theta10")
theta2 = Parameter("theta2")
qc = QuantumCircuit(1)
qc.ry(theta10, 0) # created FIRST, used FIRST
qc.rz(theta2, 0)
print([p.name for p in qc.parameters])
['theta10', 'theta2']
theta10 sorts before theta2, because sorting is lexicographic and '1' < '2'. So:
bound = qc.assign_parameters([1.0, 2.0])
# ry gets 1.0 <- theta10
# rz gets 2.0 <- theta2
In this two-parameter example that happens to match creation order. Now scale it to twelve
parameters named theta1 through theta12, applied to twelve gates in order, and bind
[1, 2, 3, …, 12]:
gate uses you intended it got
----------------------------------
theta1 1 1
theta2 2 5 <-- WRONG
theta3 3 6 <-- WRONG
theta4 4 7 <-- WRONG
theta5 5 8 <-- WRONG
theta6 6 9 <-- WRONG
theta7 7 10 <-- WRONG
theta8 8 11 <-- WRONG
theta9 9 12 <-- WRONG
theta10 10 2 <-- WRONG
theta11 11 3 <-- WRONG
theta12 12 4 <-- WRONG
11 of 12 gates received the wrong value.
Eleven of twelve. The sorted order is
theta1, theta10, theta11, theta12, theta2, theta3, …, theta9, so an optimizer's array — delivered
in the order it thinks the parameters are in — lands almost entirely in the wrong gates.
⚠️ Common Pitfall — The bug that produces a circuit that runs perfectly and computes something else.
Symptom: a VQE converges to the wrong energy, or converges suspiciously slowly, or gives different results depending on how many parameters the ansatz has.
Cause:
qc.parametersis sorted lexicographically by name, and sequence binding follows that order. Hand-named parameterstheta1 … theta12sort astheta1, theta10, theta11, theta12, theta2, …— so the optimizer's third value goes into the eleventh gate.Why nothing catches it: the circuit is valid, the parameter count is right, every value is a legal angle, and the result is a perfectly well-defined quantum state. There is no error to raise.
The fix, and it is one line: use
ParameterVector.
python thetas = ParameterVector("theta", 12) # theta[0] ... theta[11]Qiskit sorts
ParameterVectorelements by index, not by the string form of the name, sotheta[2]correctly precedestheta[10]. The hazard disappears.The general rule: if you will ever bind by sequence — and you will, because that is what optimizers produce — never hand-name a family of parameters. Use a
ParameterVector.And if you must use individually named parameters, bind by dictionary, which is immune:
python qc.assign_parameters(dict(zip(my_ordered_parameters, values)))
Binding returns a new circuit
assign_parameters does not modify the circuit you call it on. It returns a new one, and the
original keeps its free parameters:
theta = Parameter("theta")
qc = QuantumCircuit(1)
qc.ry(theta, 0)
bound = qc.assign_parameters({theta: 0.5})
print(qc.num_parameters, bound.num_parameters, bound is qc)
1 0 False
That is the right default. A template you intend to bind ten thousand times has to survive being bound, and Chapter 7 §7.7's transpile-once pattern depends on it: you transpile the parameterized circuit once, then bind it repeatedly, and each binding must leave the expensive artefact intact.
But it sets a trap worth naming, because it is the second silent failure in this chapter.
qc.assign_parameters({theta: 0.5}) # return value DISCARDED -- does nothing
That line is a no-op. It constructs a bound circuit, drops it on the floor, and leaves qc
exactly as it was. The statement looks like a mutation because most QuantumCircuit methods are
mutations — qc.h(0), qc.barrier(), qc.measure_all() all modify in place and return nothing you
would keep. assign_parameters is the exception, and that inconsistency is where the bug comes
from.
If you want mutation, ask for it:
qc.assign_parameters({theta: 0.5}, inplace=True) # returns None, modifies qc
And note the mirror-image mistake: inplace=True returns None, so
bound = qc.assign_parameters(v, inplace=True) sets bound to None. That one at least fails on
the next line.
🐛 Debug This —
TypeError: Parameter expression with unbound parameters.Symptom: you bound the circuit, and the simulator or primitive still insists parameters are unbound.
text TypeError: Parameter expression with unbound parameters {...} is not bound to a valueCause, most of the time: the return value was discarded.
python qc.assign_parameters(values) # WRONG -- result thrown away result = estimator.run([(qc, H)]) # qc is still parameterizedThe fix:
python bound = qc.assign_parameters(values) # keep it result = estimator.run([(bound, H)])The check that makes it impossible to miss:
python assert bound.num_parameters == 0, f"{bound.num_parameters} parameters still free"One line, and it converts a confusing
TypeErrorraised from somewhere inside a primitive into a clear assertion at the place the mistake was actually made. Chapter 26 §26.6 makes the general argument for putting assertions this close to the operation they check.Now contrast this bug with §8.2's ordering hazard. This one is annoying and loud — something eventually raises, and the message names the problem. The ordering hazard is quiet and produces a valid circuit. When you are deciding where to spend defensive code, spend it on the silent failures; the loud ones defend themselves.
Partial binding
You do not have to bind everything at once. Binding a subset returns a circuit that is still a function of the rest:
a, b = Parameter("a"), Parameter("b")
qc = QuantumCircuit(1)
qc.ry(a + b, 0)
half = qc.assign_parameters({a: 1.0})
print([p.name for p in half.parameters], "|", half.data[0].operation.params[0])
print(half.assign_parameters({b: 2.0}).data[0].operation.params[0])
['b'] | 1 + b
3.0
The expression a + b becomes 1 + b — still symbolic, still bindable, and the circuit's parameter
count drops from two to one. Partial binding is how you freeze one kind of parameter and leave
another free, which is precisely the shape of a quantum classifier: a feature map whose parameters
are the data (§8.5) composed with an ansatz whose parameters are trainable. Bind the data once per
sample; bind the weights once per optimizer step. Chapter 33 §33.2 builds exactly that object.
Binding cost
Binding is cheap, but it is not free, and it happens on every optimizer iteration:
80 parameters, 200 bindings: by sequence 7.4 ms by dict 28.1 ms
Sequence binding is roughly four times faster here — building the dictionary is itself work — and it is what you will use, because it is what optimizers produce. Which is exactly why the ordering hazard above matters: the fast, natural, universal way to bind is the one that silently misassigns hand-named parameters.
The important comparison is Chapter 7 §7.7's: binding 200 times costs a few milliseconds, while re-transpiling 200 times costs seconds. That is why the transpile-once pattern wins.
8.3 Composition: compose Versus append
Two ways to put a subcircuit into a larger one, and they do genuinely different things.
sub = QuantumCircuit(2, name="block")
sub.h(0)
sub.cx(0, 1)
a = QuantumCircuit(3)
a.compose(sub, qubits=[0, 1], inplace=True) # INLINES the instructions
b = QuantumCircuit(3)
b.append(sub.to_gate(), [0, 1]) # inserts ONE named instruction
compose -> ops {'h': 1, 'cx': 1} depth 2
append -> ops {'block': 1} depth 1
same unitary: True
compose flattens. The subcircuit's instructions become instructions of the parent. What you see
in count_ops and depth is the truth.
append encapsulates. The subcircuit becomes a single opaque instruction with a name and a
definition. depth reports 1, which is not the truth about what will execute — it is the truth
about the abstraction level you are looking at.
compose |
append (of a gate) |
|
|---|---|---|
| Structure | flattened | one named instruction |
count_ops |
real gates | the block name |
depth |
real depth | 1 per block |
| Readable diagram | more gates | one labeled box |
| Can be controlled | no | yes — .control() |
| Survives QASM export | as gates | as a gate definition (Ch. 6 §6.6) |
Use append when the block is a meaningful unit — an oracle, an ansatz layer, a QFT — because
the diagram stays readable, the QASM stays structured, and you can control it. Use compose when
you want the flattened truth, or when you are about to count gates.
⚠️ Common Pitfall —
depth()on a circuit of appended blocks is not the depth.
python qc = QuantumCircuit(4) for _ in range(10): qc.append(some_block.to_gate(), range(4)) print(qc.depth()) # 10 -- and utterly meaninglessTen appended blocks report depth 10 whether each block is one gate or two hundred. To get the real number, decompose first:
python print(qc.decompose().depth()) # one level down print(transpile(qc, backend).depth()) # the number that actually mattersOnly the transpiled depth is the depth, and §8.8 shows how large the gap can be.
What composition does to parameters
Composition merges more than instructions. It merges parameter tables, and the rule is that
Parameter identity is by object, not by name.
Two blocks built from the same Parameter object become one variable when composed:
theta = Parameter("theta")
blockA = QuantumCircuit(1, name="A"); blockA.ry(theta, 0)
blockB = QuantumCircuit(1, name="B"); blockB.rz(theta, 0)
joined = QuantumCircuit(2)
joined.compose(blockA, qubits=[0], inplace=True)
joined.compose(blockB, qubits=[1], inplace=True)
print(joined.num_parameters, [p.name for p in joined.parameters])
print([float(i.operation.params[0]) for i in joined.assign_parameters([0.7]).data])
1 ['theta']
[0.7, 0.7]
One variable now drives both gates, on two different qubits, in two blocks that were written
independently. Sometimes that is exactly what you want — a shared rotation angle across a symmetric
ansatz is a real construction, and it halves the search space. Sometimes it is a bug you will spend
an afternoon on, because the two blocks came from a factory function that reused a module-level
Parameter.
The rule: a factory that returns parameterized blocks must create fresh parameters on each call, or document loudly that it does not.
The opposite mistake fails immediately, which is a mercy:
th1 = Parameter("theta")
th2 = Parameter("theta") # a DIFFERENT object with the same name
print(th1 == th2) # False
qc = QuantumCircuit(2)
qc.ry(th1, 0)
qc.rz(th2, 1) # CircuitError: name conflict adding parameter 'theta'
False
CircuitError: name conflict adding parameter 'theta'
Two distinct objects sharing a name cannot coexist in one circuit — Qiskit refuses, because
qc.parameters is keyed by name and the result would be ambiguous. So the name space is global to
a circuit and the identity is not. Those two facts together are why ParameterVector("theta", 12)
is safer than twelve hand-built parameters in a third way, beyond §8.2's ordering: a vector is one
object with one name, and it cannot collide with itself.
📊 What the Numbers Say —
num_parametersafter a compose is a check, not a formality.If you compose two blocks of $k$ parameters each and the result reports $2k$, nothing merged. If it reports $k$, everything merged. If it reports something in between, some parameters were shared and you should find out which before you go any further.
python assert joined.num_parameters == expected, ( f"expected {expected}, got {joined.num_parameters}: " f"{[p.name for p in joined.parameters]}")This is the cheapest possible test of an ansatz factory, it runs in microseconds, and it catches the shared-
Parameterbug at construction rather than at convergence.
8.4 Custom Gates
Any circuit becomes a gate:
sub = QuantumCircuit(2, name="entangler")
sub.h(0)
sub.cx(0, 1)
gate = sub.to_gate()
gate.label = "E" # what appears on the diagram
qc = QuantumCircuit(3)
qc.append(gate, [0, 1])
qc.append(gate.inverse(), [0, 1]) # inverse for free
qc.append(gate.control(1), [2, 0, 1]) # controlled, with qubit 2 as control
Three things come free: .inverse(), .control(n), and .power(k). That is a large convenience —
uncomputation is everywhere in quantum algorithms (Chapter 19 §19.6), and writing inverses by hand is
tedious and a reliable source of bugs.
⚠️ Common Pitfall — Controlling a gate makes its global phase observable.
Chapter 3 §3.7 and Chapter 6 §6.6 both ended with the same rule, and this is the third place it matters:
gate.control()promotes a global phase to a relative one.If your subcircuit carries a
global_phase— and transpiled subcircuits routinely do — then.control()will faithfully include it, which is correct. What is not correct is a subcircuit whose phase was stripped somewhere upstream (by a QASM round trip, say) and then controlled. Chapter 6's Case Study 1 is that failure end to end.The habit: before controlling a gate, check
sub.global_phase. If it is nonzero and you do not know where it came from, find out.
to_gate() versus to_instruction()
There are two conversions, and the difference is not cosmetic.
sub = QuantumCircuit(2, name="entangler")
sub.h(0)
sub.cx(0, 1)
print(type(sub.to_gate()).__name__, hasattr(sub.to_gate(), "control"))
print(type(sub.to_instruction()).__name__, hasattr(sub.to_instruction(), "control"))
Gate True
Instruction False
A Gate is a unitary. An Instruction is anything a circuit can contain. Gate is a subclass
of Instruction that adds the three conveniences of §8.4 — .inverse(), .control(), .power() —
and every one of them is a promise that only a unitary can keep. You cannot control a measurement;
there is nothing to control.
Which is why the conversion enforces it:
meas = QuantumCircuit(2, 2, name="withmeas")
meas.h(0)
meas.measure(0, 0)
meas.to_gate() # QiskitError: Circuit with classical bits cannot be converted to gate.
meas.to_instruction() # fine -- returns an Instruction
to_gate() QiskitError: 'Circuit with classical bits cannot be converted to gate.'
to_instruction() OK, Instruction
The error message names the mechanism rather than the principle — Qiskit checks for classical bits because a circuit that has them can measure, reset, or branch. But the principle is the one to carry: if a block measures, it is not a gate, and asking for its inverse is a category error, not a missing feature.
This matters at exactly one place in practice, and it is a big one. Chapter 19's oracle-based
algorithms are built entirely from to_gate() blocks so that they can be inverted for uncomputation
(§19.6) and controlled for phase estimation (Chapter 22). The moment you put a mid-circuit
measurement inside an oracle — which Chapter 9 shows is sometimes tempting, because it can save
qubits — you lose both. That trade is real and occasionally worth making; it is not free, and the
type system is telling you so.
Parameters survive both conversions, and so does controllability:
psub = QuantumCircuit(2, name="pblock")
p = ParameterVector("p", 2)
psub.ry(p[0], 0)
psub.crx(p[1], 0, 1)
host = QuantumCircuit(3)
host.append(psub.to_gate().control(1), [2, 0, 1])
print(host.num_parameters, [q.name for q in host.parameters])
print(host.assign_parameters([0.3, 0.4]).num_parameters)
2 ['p[0]', 'p[1]']
0
A parameterized block can be converted to a gate, controlled, and bound afterwards. The free parameters propagate up through the encapsulation and out to the parent circuit, so the whole transpile-once/bind-many pattern still works on a circuit built from controlled custom blocks. That is what makes the pattern usable for anything larger than a toy.
8.5 The Circuit Library
Qiskit ships the standard constructions. Do not rebuild them — they are correct, tested, and optimized in ways your first attempt will not be.
🗝️ Version Note — The circuit library became functions in Qiskit 2.1.
This is the most consequential API change since 1.0's removals, and it is very recent, so nearly everything you find online uses the deprecated form.
```python
DEPRECATED in Qiskit 2.1, to be REMOVED in 3.0
from qiskit.circuit.library import EfficientSU2, RealAmplitudes, TwoLocal, QFT, ZZFeatureMap ansatz = EfficientSU2(4, reps=2)
CURRENT
from qiskit.circuit.library import efficient_su2, real_amplitudes, n_local, zz_feature_map, QFTGate ansatz = efficient_su2(4, reps=2) ```
Deprecated class Current replacement EfficientSU2efficient_su2()RealAmplitudesreal_amplitudes()TwoLocaln_local()ZZFeatureMapzz_feature_map()QFTQFTGate, orqiskit.synthesis.synth_qft_full()Why the change is an improvement. The classes were blueprint circuits — lazily constructed objects that rebuilt themselves when you changed an attribute. That flexibility caused real problems: a blueprint circuit is not a plain
QuantumCircuit, so it behaved subtly differently in composition, serialization, and equality; and the laziness made errors surface far from their cause. The functions return plainQuantumCircuitobjects, which behave like every other circuit you have built.Running the deprecated form emits a
DeprecationWarningtoday and will fail in 3.0. If you are reading a tutorial that uses the classes, it still works — for now — and you should translate it.
The workhorses
from qiskit.circuit.library import efficient_su2, real_amplitudes, n_local, zz_feature_map
for name, circuit in [
("efficient_su2(4)", efficient_su2(4)),
("real_amplitudes(4)", real_amplitudes(4)),
("n_local(4, 'ry', 'cz', reps=2)", n_local(4, "ry", "cz", reps=2)),
("zz_feature_map(4)", zz_feature_map(4)),
]:
d = circuit.decompose()
print(f" {name:<32} params {circuit.num_parameters:>3} depth {d.depth():>3} "
f"ops {dict(d.count_ops())}")
efficient_su2(4) params 32 depth 15 ops {'r': 16, 'p': 16, 'cx': 9}
real_amplitudes(4) params 16 depth 11 ops {'r': 16, 'cx': 9}
n_local(4, 'ry', 'cz', reps=2) params 12 depth 23 ops {'h': 24, 'r': 12, 'cx': 12}
zz_feature_map(4) params 4 depth 31 ops {'u': 28, 'cx': 24}
| Construction | What it is | Use for |
|---|---|---|
efficient_su2 |
$R_y$ and $R_z$ rotations + entangling layers | general-purpose ansatz; the default |
real_amplitudes |
$R_y$ only, so amplitudes stay real | when the target state is real — including molecular ground states, which is why the project uses it |
n_local |
fully configurable rotation and entangling blocks | when you need a specific structure |
zz_feature_map |
data encoding with $ZZ$ interactions | quantum kernels and classifiers (Ch. 33, 34) |
QFTGate |
the quantum Fourier transform | Ch. 22, 23 |
Note the parameter counts: efficient_su2(4) has 32 parameters and real_amplitudes(4) has
16, because the former uses two rotations per qubit per layer and the latter one. Twice the
expressiveness, twice the optimization problem — and Chapter 35's barren-plateau discussion is about
exactly that trade.
Also note: zz_feature_map(4) has only 4 parameters for 24 CNOTs. Feature maps are not
ansätze; their parameters are the data, not free variables to optimize. Chapter 33 §33.2 makes the
distinction properly.
What a blueprint circuit actually was
The 🗝️ Version Note above says the classes were blueprint circuits and that this caused problems. It is worth seeing the mechanism, because the deprecation looks like churn until you do.
from qiskit.circuit.library import EfficientSU2, efficient_su2
print(type(efficient_su2(4)).__name__, type(efficient_su2(4)) is QuantumCircuit)
print(type(EfficientSU2(4)).__name__, type(EfficientSU2(4)) is QuantumCircuit)
print([c.__name__ for c in type(EfficientSU2(4)).__mro__][:5])
QuantumCircuit True
EfficientSU2 False
['EfficientSU2', 'TwoLocal', 'NLocal', 'BlueprintCircuit', 'QuantumCircuit']
The function returns a QuantumCircuit. The class returned a four-deep subclass of one. It
passed isinstance(x, QuantumCircuit), so every type check you might write said yes, and it still
behaved differently — because BlueprintCircuit overrode the machinery that decides when the
circuit's instruction list exists.
Here is what that bought, and what it cost:
bp = EfficientSU2(4, reps=1)
print(bp.num_parameters) # 16
bp.reps = 3 # mutate an attribute...
print(bp.num_parameters) # 32 -- the circuit rebuilt itself
16
32
Assigning to an attribute changed the number of gates in the object. That is the "blueprint"
idea: the circuit was a lazily-evaluated recipe, and touching a knob invalidated and re-expanded it.
It is genuinely convenient for interactive exploration — build once, sweep reps in a loop — and it
is a considerable hazard everywhere else.
Three concrete consequences, and each of them produced real bugs:
Laziness moves errors away from their cause. An invalid combination of arguments did not fail at
construction; it failed at the first operation that forced expansion, which might be a depth() call
in a logging line three functions away.
Mutability breaks the assumption that a circuit is a value. Hand a blueprint circuit to a function and it can come back a different size. Every caching, memoisation, and equality scheme in your code silently assumed otherwise.
Serialization and composition saw a subclass. Round-tripping through QPY or QASM (Chapter 6), or composing into a plain circuit, produced an object of a different type from the one you started with — and the differences surfaced as equality failures rather than as errors.
A plain QuantumCircuit has none of this. It has no reps attribute at all
(hasattr(efficient_su2(4), "reps") is False), which is the point: once the function returns,
the size of the circuit is a fact and not a setting. If you want a different reps, call the
function again. That is a strictly worse API for interactive knob-twiddling and a strictly better one
for everything that has to be correct.
📐 Math Aside — The library's sizes, in closed form.
You do not have to measure these. All three follow from the construction, and all three were checked against the library across a range of $n$.
efficient_su2(n, reps=r)applies two rotations per qubit per rotation layer, and there are $r+1$ rotation layers (one after each entangling layer, plus one before the first):$$\text{parameters} = 2n(r+1)$$
text n reps measured 2n(reps+1) 4 1 16 16 4 2 24 24 4 3 32 32 6 2 36 36 8 3 64 64 14 2 84 84
real_amplitudesuses one rotation per qubit per layer, so it is exactly half: $n(r+1)$. That is the whole content of "efficient_su2(4)has 32 parameters andreal_amplitudes(4)has 16" — $2 \cdot 4 \cdot 4 = 32$ against $4 \cdot 4 = 16$, withrepsdefaulting to 3.
fullentanglement places one CNOT on every unordered pair, which is the handshake count:$$\text{CNOTs per layer} = \binom{n}{2} = \frac{n(n-1)}{2}$$
text n 2 3 4 5 6 8 10 14 cx 1 3 6 10 15 28 45 91 n(n-1)/2 1 3 6 10 15 28 45 91
zz_feature_map(n)takes $n$ parameters — one per feature, because the parameters are the data — and places a $ZZ$ interaction on every pair, each costing two CNOTs, over two repetitions by default:$$\text{CNOTs} = 2 \cdot 2 \cdot \binom{n}{2} = 2n(n-1)$$
text n params measured cx 2n(n-1) 2 2 4 4 3 3 12 12 4 4 24 24 6 6 60 60 8 8 112 112Read the last table again. At 8 features the feature map costs 112 CNOTs before the classifier does anything. Chapter 39 §39.6 measured a 14-qubit circuit whose whole two-qubit budget was 49 to 112 gates, and Chapter 1 §1.5's ceiling is a few hundred. A quadratic feature map spends the entire error budget on encoding, which is one of the concrete reasons Chapters 33 and 34's quantum classifiers do not beat their classical baselines.
📐 Math Aside — What a QFT costs in CNOTs, derived and checked.
QFTGate(n)is the one library construction whose cost you should be able to write from memory, because Chapters 22 and 23 depend on it. One level of decomposition shows the textbook structure:
text n=4: {'cp': 6, 'h': 4, 'swap': 2} n=8: {'cp': 28, 'h': 8, 'swap': 4}That is exactly the standard circuit: one Hadamard per qubit ($n$), one controlled-phase on every pair ($\binom{n}{2}$), and a final bit-reversal of $\lfloor n/2 \rfloor$ swaps. Check: $\binom{4}{2} = 6$ and $\binom{8}{2} = 28$. ✓
Now translate to CNOTs. A controlled-phase is two CNOTs plus single-qubit phases; a swap is three CNOTs. So:
$$\text{CNOTs} = 2\binom{n}{2} + 3\left\lfloor \tfrac{n}{2} \right\rfloor = n(n-1) + 3\left\lfloor \tfrac{n}{2} \right\rfloor$$
Measured against the formula for $n = 2 \ldots 10$:
text n measured n(n-1) 3*floor(n/2) predicted 2 5 2 3 5 3 9 6 3 9 4 18 12 6 18 5 26 20 6 26 6 39 30 9 39 7 51 42 9 51 8 68 56 12 68 9 84 72 12 84 10 105 90 15 105Nine for nine. The QFT is quadratic in CNOTs, and the swap term is a rounding correction that never matters asymptotically.
Two consequences worth carrying. First, the quadratic term is why Chapter 22's approximate QFT exists — dropping the small-angle controlled-phases removes the tail of the $\binom{n}{2}$ sum, and §22.5's measurement is that a cutoff of 3 retains 97% fidelity for 36% fewer gates. Second, at $n = 8$ a single QFT is 68 CNOTs logical, and Chapter 39 §39.2's scheduled QFT-8 came to 137 two-qubit gates and 43.20 ms at 4,096 shots. Routing doubled it, which is §8.7's subject.
8.6 Barriers
A barrier is an instruction that does nothing to the qubits and everything to the compiler.
from qiskit import transpile
without = QuantumCircuit(1)
without.x(0)
without.x(0)
with_barrier = QuantumCircuit(1)
with_barrier.x(0)
with_barrier.barrier()
with_barrier.x(0)
for label, qc in (("no barrier", without), ("with barrier", with_barrier)):
t = transpile(qc, basis_gates=["rz", "sx", "x"], optimization_level=3)
print(f" {label:<14} -> {dict(t.count_ops())} depth {t.depth()}")
no barrier -> {} depth 0
with barrier -> {'x': 2, 'barrier': 1} depth 2
Without the barrier the optimizer notices $XX = I$ and deletes the entire circuit. With it, both gates survive.
A barrier is a directive, not an operation
The thing that makes barriers confusing is that they occupy a slot in the instruction list and look like gates on the diagram, while being neither.
from qiskit.circuit import Barrier, Gate, Instruction
bar = Barrier(3)
print(isinstance(bar, Gate), isinstance(bar, Instruction), bar._directive)
False True True
Not a Gate. An Instruction with _directive = True. §8.4's distinction applies exactly: a
Gate is a unitary and gets .inverse(), .control(), .power(); a barrier is not a unitary
operation on the qubits at all, so it gets none of them. The flag _directive is Qiskit's own word
for "this instruction is a message to the compiler."
Three consequences follow, and they are the reasons barriers behave the way they do.
It is not in any backend's basis gate set. "barrier" in backend.operation_names is False, and
yet a transpiled circuit still contains one:
transpiled ops: {'rz': 10, 'sx': 5, 'barrier': 1, 'ecr': 1}
That is not a basis-translation failure. The barrier passes through untranslated because there is nothing to translate — it is stripped at the point the circuit becomes pulses, and the device never sees it.
Its duration is zero. Scheduling a barriered circuit and asking the backend for the instruction's
length gives 0. A barrier constrains when things may be scheduled without itself consuming any
time. That is what makes it usable for the timing work in Chapter 31, where the whole subject is
where the idle windows are.
It costs nothing to run and something to compile. The cost of a barrier is never a pulse; it is
always an optimization the compiler was not allowed to perform. Which means the cost is invisible in
count_ops and shows up only in depth.
What a barrier actually blocks
It blocks movement across itself, and nothing else. This is more surgical than most people assume, and two circuits demonstrate it:
a = QuantumCircuit(1) # x x | barrier | x x
a.x(0); a.x(0); a.barrier(); a.x(0); a.x(0)
b = QuantumCircuit(1) # x | barrier | x x | barrier | x
b.x(0); b.barrier(); b.x(0); b.x(0); b.barrier(); b.x(0)
for qc in (a, b):
t = transpile(qc, basis_gates=["rz", "sx", "x"], optimization_level=3)
print(dict(t.count_ops()), t.depth())
x x | barrier | x x -> {'barrier': 1} depth 0
x | barrier | x x | barrier | x -> {'x': 2, 'barrier': 2} depth 2
Both pairs still cancelled. The barrier separated two regions, and inside each region the
optimizer worked normally — XX = I on the left, XX = I on the right, and the whole circuit
evaporated apart from the directive. In the second circuit the middle pair cancels and the two
outer x gates survive, because each of them is alone in its region.
So a barrier does not "protect the gates near it." It protects nothing; it forbids reordering and merging across a line, and whether that saves a gate depends entirely on where the line falls relative to the cancellation you were trying to prevent.
The same logic applies across qubits. A barrier can span a subset:
q = QuantumCircuit(2)
q.x(0); q.x(1)
q.barrier(0) # qubit 0 only
q.x(0); q.x(1)
barrier(0) -> {'x': 2, 'barrier': 1} depth 2
barrier() -> {'x': 4, 'barrier': 1} depth 2
┌───┐ ░ ┌───┐
q_0: ┤ X ├─░─┤ X ├
└───┘ ░ └───┘
q_1: ─────────────
Qubit 0's pair survived and qubit 1's vanished. qc.barrier() with no arguments spans every
qubit in the circuit; qc.barrier(1, 3) spans two. If you want to protect a specific sequence,
barrier the qubits it acts on — barriering the whole register when you meant one qubit is a common
and expensive over-application, because it blocks parallelization on every other wire too.
⚙️ Under the Transpiler — When not using a barrier costs you the experiment.
Chapter 25 §25.2 hit this and had to correct a published-looking result because of it.
Aer attaches noise to gates. To inject noise at a chosen point in an error-correction circuit, you mark the point with an identity gate and attach an $X$ error to
id. But an identity gate is by construction removable, and the transpiler removes it:
text optimization_level=0: id gates surviving = 3 <- noise attaches here optimization_level=1: id gates surviving = 0 <- NOISE SLOTS DELETED optimization_level=2: id gates surviving = 0 optimization_level=3: id gates surviving = 0Only level 0 preserves them, and level 0 is not the default. The circuit ran, the simulator reported results, and the results showed perfect correction at a physical error rate where a three-qubit code cannot correct anything — because the noise had nowhere to attach and a noiseless circuit had been executed.
The transpiler did nothing wrong.
idmeans both "noise goes here" and "do nothing", and only one of those two meanings survives optimization. A barrier means only one thing, which is why it is the more robust marker: it is a directive, so no optimization level deletes it, and §8.6's measurement above is the proof.Chapter 25's own fix was
optimization_level=0plus an assertion, which is the more direct repair when you need theidgates themselves. The transferable lesson is the general one:
python assert t.count_ops().get("id", 0) == expected, f"noise slots optimized away: {dict(t.count_ops())}"If your experiment depends on a gate existing after transpilation, assert that it does. The transpiler is allowed to delete anything that does not change the unitary, and "it marks where I want noise" is not a property of the unitary.
That is the whole mechanism, and it has three legitimate uses and one bad one.
Legitimate: diagram readability. A barrier draws a vertical line, separating an ansatz's layers or an algorithm's phases. Costless if you strip it before transpiling.
Legitimate: preventing a correct-but-unwanted optimization. In a benchmarking experiment (Chapter 30) or a calibration routine, you often need a specific gate sequence to actually execute, including gates that cancel algebraically. The barrier is how you say so.
Legitimate: controlling scheduling. With timing-aware transpilation (Chapter 29), a barrier constrains what can be moved across it.
Bad: as a debugging habit you forget to remove. Barriers inhibit optimization, and an ansatz littered with them transpiles to a deeper, noisier circuit than it needs to. Chapter 28 has measured examples.
🧪 Run It — Find out what your barriers cost.
Take an ansatz you have built with barriers between layers. Transpile it twice — once as written, once with
qc.remove_final_measurements()and barriers stripped — and compare depth and two-qubit gate counts:
python from qiskit.transpiler.passes import RemoveBarriers stripped = RemoveBarriers()(qc)If the numbers differ, your barriers are costing you fidelity. Decide deliberately whether that is the trade you want.
What that experiment returns
Running it on the ansatz this chapter builds — 4 qubits, depth 3, a barrier after every entangling
layer, FakeSherbrooke, seed_transpiler=42, optimization level 3:
ISA depth 2q gates real pulses free rz
with barriers 61 9 56 80
stripped 56 9 56 84
Five layers of depth, and nothing else. Same two-qubit count. Same number of real pulses. The barriers did not add a single instruction that costs a pulse.
📊 What the Numbers Say — A barrier's cost is depth, and only depth.
The naive reading of "barriers inhibit optimization" is that they leave gates in the circuit that would otherwise have been removed. That is not what the measurement shows here. The pulse count is identical at 56 either way; the two-qubit count is identical at 9. What changed is that the stripped circuit could pack the same operations into 56 layers instead of 61 — and, incidentally, ended up with four more free
rzgates while being shallower, which is the transpiler choosing a different but cheaper decomposition once it was allowed to move things across the layer boundaries.So the right question is not "how many gates did the barriers cost?" It is "how much longer is the qubit idle?" Depth is exposure time, and exposure time is decoherence. Chapter 39 §39.2 measured
sxat 32–64 ns andczat 68–184 ns, against the $T_1$ values of 15.2 to 483.0 µs that §39.6 found on the same class of device — so five extra layers on this circuit is a small fraction of a coherence time. A real cost, and a modest one at this size.Whether it stays modest is not something this measurement can tell you. The barrier count here is one per entangling layer, so it grows with depth and not with qubit count; the circuit's total depth grows with both. Which of those two wins at Chapter 36's sizes is an empirical question, and the answer is a re-run of the two-transpile comparison at the size you actually care about — not an extrapolation from 8.2% on a four-qubit toy.
And the honest version of the conclusion: this measurement is one circuit on one backend at one seed. It says barriers on this ansatz cost depth and not gates. It does not establish that barriers never cost gates — put one in the middle of a sequence that would have cancelled, as §8.6's
x; barrier; xdoes, and the cost is two gates that would not have existed. The cost of a barrier depends on what it prevented, and the only way to know is the two-transpile comparison above.
8.7 Entanglement Patterns
An ansatz's entangling layer has to decide which pairs to entangle, and the choice is a real design decision with a measurable cost.
for pattern in ("full", "linear", "circular", "pairwise", "reverse_linear"):
c = efficient_su2(4, entanglement=pattern, reps=1)
d = c.decompose()
print(f" {pattern:<16} params {c.num_parameters} "
f"cx {dict(d.count_ops()).get('cx', 0)} depth {d.depth()}")
full params 16 cx 6 depth 9
linear params 16 cx 3 depth 7
circular params 16 cx 4 depth 8
pairwise params 16 cx 3 depth 6
reverse_linear params 16 cx 3 depth 7
| Pattern | Pairs | CNOTs for $n$ qubits | Notes |
|---|---|---|---|
full |
every pair | $n(n-1)/2$ — quadratic | most expressive, most expensive |
linear |
0–1, 1–2, 2–3, … | $n-1$ | matches a 1-D device topology |
circular |
linear plus $n{-}1$–0 | $n$ | adds one long-range link |
pairwise |
alternating even/odd pairs | $n-1$, in 2 layers | shallowest — parallelizable |
reverse_linear |
linear, reversed order | $n-1$ | same cost, different structure |
Note that the parameter count does not change — 16 in every case. The entangling layer has no parameters; only the cost and the connectivity change.
The practical guidance:
- Start with
linearorpairwise. They match real hardware connectivity (Chapter 4 §4.6: every non-adjacent pair costs a SWAP, at three CNOTs), andpairwiseis the shallowest. - Use
fullonly on small circuits or all-to-all hardware (trapped ions — Chapter 17). At 10 qubits it is 45 CNOTs per layer against 9 for linear. - More entanglement is not automatically better. Chapter 32 §32.5 shows that highly expressive ansätze suffer worse barren plateaus — the very expressiveness that lets them represent the answer makes the answer harder to find.
📐 Math Aside — Where the four counts come from, in one line each.
Every entry in the CNOT column of the table above is a counting argument, and each was checked against the library across $n = 2 \ldots 14$.
fullplaces one CNOT on every unordered pair. That is the handshake count, $\binom{n}{2} = n(n-1)/2$. Measured: 1, 3, 6, 10, 15, 28, 45, 91 at $n = 2, 3, 4, 5, 6, 8, 10, 14$ — all eight exact.
linearplaces one CNOT on each adjacent pair of a path: $0\text{–}1, 1\text{–}2, \ldots, (n{-}2)\text{–}(n{-}1)$. A path on $n$ vertices has $n-1$ edges.
circularis the path plus the wraparound edge $(n{-}1)\text{–}0$, closing it into a cycle. A cycle on $n$ vertices has $n$ edges. Exactly one more thanlinear— remember that number; the rest of this section is largely about what that one edge costs.
pairwiseuses the same $n-1$ edges aslinear, but splits them into the even-indexed pairs and the odd-indexed pairs. Those two sets are each a matching — no vertex appears twice — so each set executes in a single layer. Same edges, same count, two layers.Multiply any of these by
repsfor the full ansatz. For the $n = 14$,reps=2circuit that the rest of this section and Chapter 39 §39.6 both use:linear$= 13 \times 2 = 26$,circular$= 14 \times 2 = 28$,full$= 91 \times 2 = 182$.
★ The pattern whose depth does not grow
The table above measures one width, $n = 4$, where pairwise looks like a modest win: depth 6
against linear's 7. Widen it and the win changes character entirely.
n linear cx linear depth pairwise cx pairwise depth
4 3 7 3 6
6 5 9 5 6
8 7 11 7 6
10 9 13 9 6
14 13 17 13 6
pairwise is depth 6 at every width measured. linear grows as $n + 3$; pairwise does not
grow at all.
The reason is the matching argument from the 📐 Math Aside. A linear entangling layer applies CNOTs
$0\text{–}1$, then $1\text{–}2$, then $2\text{–}3$ — and consecutive gates share a qubit, so they
cannot execute simultaneously. The layer serialises into $n-1$ steps. A pairwise layer splits the
same edges into two disjoint sets: $\{0\text{–}1, 2\text{–}3, 4\text{–}5, \ldots\}$ touches each
qubit at most once, and so does $\{1\text{–}2, 3\text{–}4, \ldots\}$. Two layers, whatever $n$ is.
So pairwise costs the same gates as linear and buys $O(1)$ entangling depth instead of $O(n)$.
Since depth is exposure time and exposure time is decoherence, that is a real and free gain, and it
is why Case Study 2 recommends pairwise over linear for an eight-qubit design.
There is a caveat, and it is the subject of the next subsection: these are logical numbers, and logical numbers do not price connectivity.
★★ What the logical count hides
Every number in this section so far assumed any qubit can talk to any other. Real superconducting hardware does not work that way, and the transpiler pays the difference in SWAPs at three two-qubit gates each (Chapter 4 §4.6).
Transpile the $n = 14$, reps=2 ansatz in each pattern for FakeSherbrooke, seed_transpiler=42,
optimization level 1, and compare what you wrote against what runs:
pattern logical cx ISA ecr overhead swaps ISA depth
linear 26 26 0 0 63
pairwise 26 26 0 0 34
circular 28 49 21 7 157
full 182 698 516 172 751
Four things in that table, and the third is the one to take away.
linear and pairwise route for free. Twenty-six logical CNOTs become twenty-six ECR gates.
Zero overhead, zero SWAPs. A path of fourteen qubits embeds into a heavy-hex lattice exactly, so
there is no routing work to do — the pattern was designed for this and the design holds.
pairwise keeps its depth advantage after transpilation: ISA depth 34 against linear's 63,
for identical gate counts. The logical result survives contact with the hardware.
circular costs 21 extra gates — seven SWAPs — for one extra logical CNOT. That is the whole
finding. The wraparound edge $13\text{–}0$ connects the two ends of a chain that has been laid out
along the chip, and the router has to walk one qubit the length of the register to close the loop.
One edge, seven SWAPs, a 75% increase in two-qubit gates. The logical table said circular costs
one more CNOT than linear. It costs twenty-one.
full is seven times linear on paper and twenty-seven times in practice. Logically it is
$182 / 26 = 7\times$; after routing it is $698 / 26 = 26.8\times$. The overhead is 516 gates, which is
172 SWAPs — and 516 divides by three exactly, the arithmetic signature of SWAP insertion and nothing
else. The advice above to avoid full on hardware understated the case: the quadratic term is the
logical cost, and routing multiplies it again.
⚙️ Under the Transpiler — The same circuit, twelve different seeds.
Routing is a heuristic search, and the seed changes where it starts. Twelve seeds, same circuit, same backend, optimization level 1:
text pattern logical min ecr median max ecr max/min linear 26 26 26 26 1.00 circular 28 49 100 112 2.29 full 182 653 686 716 1.10
linearshows literally no variance.circularvaries by 2.29×. Re-runlinearacross all four optimization levels as well and it is 26 every single time — thirty-two transpilations, one answer.That is the practical form of the guidance. A pattern that matches the device's connectivity is not merely cheaper on average; it is deterministic. A pattern that does not match is a lottery whose ticket you buy at submission time.
Note also that
full's spread is only 1.10×, despite being by far the most expensive. Once essentially every pair needs routing, the seed has little left to get right or wrong — the cost is saturated. The seed matters most in the middle, where a good layout can avoid most of the routing and a bad one cannot.And the optimization level matters more than the seed for this circuit:
text circular, n=14, 8 seeds min ecr max ecr ratio optimization_level=0 172 193 1.12 optimization_level=1 49 112 2.29 optimization_level=2 49 115 2.35 optimization_level=3 49 112 2.29Level 0's best result is worse than level 1's worst. Level 0 does trivial routing and no layout selection, so it never finds the good embedding; levels 1–3 sometimes do. This is the same shape as Chapter 29's result that a hardware-aware level 1 (0.9116) beats a naive level 3 (0.7720) — the level number is not a quality dial, it selects which passes run.
This is where Chapter 39's headline number comes from. §39.6 swept a 14-qubit EfficientSU2
across 24 transpiler seeds and reported a two-qubit gate count range of 49 to 112, a 2.03× error
ratio, and fidelities from 0.5755 to 0.7911. The circuit was
efficient_su2(14, reps=2, entanglement="circular") — the exact construction in the table above.
The measurement here reproduces that range on a different heavy-hex device with a different native two-qubit gate, which is worth a moment. It is not luck. Both endpoints are forced:
28 logical CX + 7 swaps x 3 = 49 the best embedding found
28 logical CX + 28 swaps x 3 = 112 the worst
Only multiples of three are reachable above 28, because SWAP is the only thing being inserted and a SWAP is three two-qubit gates. The achievable set is $\{28, 31, 34, \ldots\}$, and the endpoints are set by the graph, not by the chip's calibration. Two heavy-hex devices asked to close a fourteen-cycle face the same combinatorial problem and land in the same place.
So this section's design decision and Chapter 39's cost variance are the same fact seen from two
ends.
Choosing circular over linear in an ansatz-builder — a one-word change that looks like it buys
one extra CNOT — is what put that circuit into the regime where the seed is worth 2.03× in error and,
under a credit-based pricing model (§39.4), a materially larger invoice for the worse answer.
🔬 Honest Assessment — What this measurement does and does not establish.
It establishes that on this backend, at these sizes,
linearandpairwiseroute with zero overhead and zero seed-variance,circularpays seven to twenty-eight SWAPs for one edge, andfullpays 172. The SWAP arithmetic divides by three exactly in every case, which is strong evidence that routing — not link quality, not basis translation — is the mechanism.It does not establish that
linearis free on every device. It is free on a heavy-hex lattice because a path embeds in one. On a device whose coupling graph has no long induced path, it would not be. The transferable claim is the conditional one: a pattern that matches the device's connectivity graph routes for free, and the way to find out is to transpile it, not to reason about it.And one place where the neighbouring chapter's caution needs refining. §39.6 warns that layout variance is a large-circuit phenomenon, citing a 4-qubit control that showed zero variation across all 24 seeds. Repeating that control here, on
FakeSherbrookewith 12 seeds at optimization level 1:
text n=4, reps=2 logical ISA ecr values observed linear 6 {6} circular 8 {17, 26} full 12 {30}
linearandfullare indeed deterministic at 4 qubits.circularis not — it takes one of two values, a 1.53× spread. So "zero variation on small circuits" is a property of a particular device and pattern rather than a law, and the conclusion that survives is the weaker, safer one: variance grows with circuit size, and the only reliable way to know whether your circuit has any is to sweep the seed. Two distinct values at $n = 4$ against a 2.29× continuum at $n = 14$ is the same story told quietly.This is the book's recurring warning in its cheapest form. A single transpilation is a draw from a distribution, and a 4-qubit circuit that happens to be deterministic will teach you that the distribution does not exist.
8.8 Building the Ansatz
Now put it together. The hardware-efficient ansatz is the structure Chapter 4 §4.7 introduced, generalized: alternating layers of parameterized single-qubit rotations and fixed entangling gates.
from qiskit import QuantumCircuit
from qiskit.circuit import ParameterVector
def hardware_efficient_ansatz(n: int, depth: int, entanglement: str = "linear") -> QuantumCircuit:
"""Ry rotation layers alternating with CNOT entangling layers.
Ry only -- so the amplitudes stay real, which is what a molecular ground
state needs (Ch. 36). ParameterVector -- so sequence binding is safe (8.2).
"""
thetas = ParameterVector("theta", n * (depth + 1))
qc = QuantumCircuit(n, name=f"hea_{n}x{depth}")
k = 0
for layer in range(depth + 1):
for q in range(n):
qc.ry(thetas[k], q)
k += 1
if layer < depth:
if entanglement == "linear":
for q in range(n - 1):
qc.cx(q, q + 1)
elif entanglement == "pairwise":
for offset in (0, 1):
for q in range(offset, n - 1, 2):
qc.cx(q, q + 1)
else:
raise ValueError(f"unknown entanglement {entanglement!r}")
return qc
That is real_amplitudes in twenty lines, and writing it once is worth it — you now know exactly
what the library function does, and you can modify it, which you will need to in Chapter 36.
What it costs on hardware
The number that matters, and it is not the one the logical circuit reports:
from qiskit.circuit.library import efficient_su2
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit_ibm_runtime.fake_provider import FakeSherbrooke
backend = FakeSherbrooke()
pm = generate_preset_pass_manager(optimization_level=1, backend=backend, seed_transpiler=42)
for n in (2, 4, 6):
ansatz = efficient_su2(n, reps=2)
isa = pm.run(ansatz)
ops = dict(isa.count_ops())
print(f" n={n}: params {ansatz.num_parameters:>3} logical depth "
f"{ansatz.decompose().depth():>3} -> ISA depth {isa.depth():>3} "
f"2q gates {ops.get('ecr', 0):>3}")
n=2: params 12 logical depth 8 -> ISA depth 27 2q gates 2
n=4: params 24 logical depth 11 -> ISA depth 35 2q gates 6
n=6: params 36 logical depth 13 -> ISA depth 42 2q gates 10
⚙️ Under the Transpiler — The gap is a factor of three, and it is structural.
A logical depth of 8 becomes an ISA depth of 27. That is not the transpiler doing a poor job — it is Chapter 3's arithmetic applied at scale: every single-qubit gate costs three
rzand twosx, and anefficient_su2layer is two single-qubit rotations per qubit.The consolation is Chapter 3 §3.8's other half: most of that depth is free
rzgates. Count the real pulses rather than the instructions, exactly as Chapter 6 §6.5 taught, and the picture is much better.The number to watch is the two-qubit gate count, which grows as $(n-1) \times \text{reps}$ for linear entanglement and is the term that actually consumes your error budget. At $n=6$,
reps=2, that is 10 ECR gates — comfortably inside the few-hundred budget of Chapter 1 §1.5, which is why variational algorithms at this scale are runnable at all.Now project forward: Chapter 36's H₂ ansatz needs 4 qubits, which is fine. LiH needs 8 to 12, and the two-qubit count starts to bite. This is the arithmetic that decides which molecules are reachable, and it is why Chapter 28's depth reduction is not an optimization but a requirement.
A result that should change your reasoning
Compare the two rotation choices after transpilation:
n=4 d=2 linear ry params 12 ISA depth 41 2q 6 pulses 40 free rz 59
n=4 d=2 linear ryrz params 24 ISA depth 41 2q 6 pulses 38 free rz 62
Doubling the parameters cost nothing. Same ISA depth, same two-qubit count, and fewer real pulses.
That is not a fluke — it is Chapter 3's Case Study 2 arriving with consequences. An arbitrary
single-qubit gate costs exactly three rz and two sx on this hardware, regardless of how
complicated it is. So ry(θ) and ry(θ)·rz(φ) both merge into one arbitrary single-qubit unitary
and cost the same two pulses. The second rotation rides along for free.
So the $R_y$-versus-$R_yR_z$ decision is not a circuit-cost decision. It is entirely an optimization decision:
ry |
ryrz |
|
|---|---|---|
| Hardware cost | 2 pulses per qubit per layer | the same |
| Parameters to optimize | $n(d{+}1)$ | $2n(d{+}1)$ |
| Reachable states | real amplitudes only | complex amplitudes |
| Optimizer iterations | fewer | roughly twice as many |
| Barren-plateau risk | lower | higher (Ch. 32 §32.5) |
Choose ry when the answer has real amplitudes — which is the case for a molecular ground state
in a real basis, and is why the project uses it. The saving is not in the circuit; it is in the
search.
This is a good example of a general habit worth building: measure the cost of a design decision rather than assuming it. The intuition "more gates means more error" is correct in general and happened to be wrong here, because of a specific property of the hardware's basis set.
🧱 Project Checkpoint —
circuits.pyv2: the real ansatz.Replace the two-qubit toy from Chapter 4 with the general construction:
python def hardware_efficient_ansatz(n, depth, entanglement="linear", rotation="ry"): ...Three design decisions, each with a reason you can now state:
ParameterVector, not named parameters — because the optimizer will bind by sequence, and §8.2's ordering hazard is silent.$R_y$ only, not $R_y R_z$ — because a molecular ground state in a real basis has real amplitudes, so the extra $R_z$ parameters double the search space to reach states the answer does not need. This is
real_amplitudes, and Chapter 36 explains the chemistry.
linearentanglement by default — because it matches hardware connectivity, and because Chapter 35's barren-plateau result argues against more expressiveness than the problem requires.The checkpoint also adds
ansatz_report(n, depth, backend), which prints parameters, logical depth, ISA depth, and two-qubit gate count in one line — the four numbers you will want every time you consider changing the ansatz.
8.9 Summary
Parameter turns a circuit into a function of its angles, which is the prerequisite for every
variational algorithm and for Chapter 7's transpile-once pattern. Parameters support arithmetic,
so one variable can drive several gates with different coefficients.
⚠️ qc.parameters is sorted lexicographically by name, and sequence binding follows that order.
Parameters named theta1 … theta12 sort as theta1, theta10, theta11, theta12, theta2, …, so an
optimizer's array lands in the wrong gates — silently, with a valid circuit and a well-defined wrong
answer. Use ParameterVector, whose elements sort by index. If you must hand-name, bind by
dictionary.
compose flattens; append encapsulates. Appended blocks can be inverted, controlled, and
raised to powers — a large convenience, since uncomputation is everywhere. But depth() on a
circuit of appended blocks is meaningless; only the transpiled depth is the depth.
🗝️ The circuit library became functions in Qiskit 2.1. EfficientSU2 → efficient_su2(),
TwoLocal → n_local(), QFT → QFTGate. The classes are deprecated and will be removed in 3.0.
The functions return plain QuantumCircuit objects instead of lazily-rebuilding blueprint circuits,
which removes a class of subtle bugs in composition and serialization.
Barriers block optimization, demonstrably: x; x transpiles to nothing, x; barrier; x keeps
both. Use them for diagram clarity, to force a specific sequence to execute, and to constrain
scheduling — and strip them before transpiling unless you meant the cost.
Entanglement patterns cost differently and change no parameter count: full is $n(n-1)/2$ CNOTs,
linear and pairwise are $n-1$, and pairwise is shallowest. Start with linear; use full only
on small circuits or all-to-all hardware; and remember that more entanglement is not automatically
better — Chapter 35's barren plateaus argue the opposite.
The hardware-efficient ansatz — $R_y$ layers alternating with CNOT layers — is the structure the
project carries to Chapter 36. Its logical depth understates its cost by roughly a factor of three,
but most of that is free rz gates. The number to watch is the two-qubit gate count, which is
$(n-1) \times \text{reps}$ for linear entanglement and which decides which molecules are reachable.
Next: Chapter 9 — mid-circuit measurement and classical feedforward. Quantum teleportation, superdense coding, qubit reuse, and an honest account of what dynamic circuits cost on today's hardware.