You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
User's reported environment: Red Hat Enterprise Linux 9, x86_64, 16-core CPU
Reproduction environment: macOS 26.4, arm64, 16-core CPU (same Aer / Qiskit versions). Both platforms exhibit the same bugs; absolute timings differ but the mechanism is identical.
What is the current behavior?
EstimatorV2 exhibits two independent, simultaneous bugs that together turn a workload that should take ~1 minute (per-process parallel) into a ~17 minute single-threaded run on a 15-qubit, 54-parameter HHFM circuit with 100,000 parameter rows and 15 observables, MPS backend, 16-core machine:
flat_indices=list(param_indices.ravel()) # 100k-element list of tuples
...
forindexinnp.ndindex(*bc_param_ind.shape): # 100k * 15 = 1.5M iterationsparam_index=bc_param_ind[index]
flat_index=flat_indices.index(param_index) # O(N) linear scan per iter
...
For 100k rows × 15 observables this performs ~7.5 × 10¹⁰ comparisons — about 750 s of pure-Python work at ~10 ns/op, matching the ~530 s post-loop time observed in py-spy flame graphs of the 1033 s internal_sv run.
py-spy flame graph (worker thread, 6,742 samples, 67 s wall):
samples
% of worker
location
3,420
50.7 %
estimator_v2.py:143 — flat_indices.index(...)
1,998
29.6 %
cpp_execute_circuits (the actual C++ simulation)
The pure-Python post-loop (51 %) costs more wall time than the C++ simulation (30 %). Splitting the same 100k-row work into 16 PUBs drops the post-loop's share from 51 % → 7 %, confirming it scales worse than the simulation with n_lines.
This module-scope singleton is constructed at import time. In any long-lived Python process (Jupyter kernel, IPython, services), ordinary kernel activity warms the executor — its worker thread is alive and its internal queue has been touched — before the user's code runs.
os.fork() then copies the executor object's memory (including its lock state and queue) but only the calling thread — the worker thread does not survive. Every forked child inherits a DEFAULT_EXECUTOR that looks normal but has no live worker. The first call into EstimatorV2.run() enqueues to a queue nothing will ever consume, and the relevant call path raises during setup.
Direct evidence from a py-spy record --threads --idle --subprocesses capture of a live Jupyter kernel running the user's notebook (1,284,652 raw samples):
process
role
samples
share
parent kernel
doing all the work alone, single-threaded
1,257,037
97.9 %
16 forked workers
combined
27,615
2.1 %
Every one of the 16 children's leaf stack ends in:
That call path is reachable only from the except branch of _process_worker. Every child's run(estimator, [pub]) raised; the worker is now formatting the traceback (slow because linecache reads source files line by line). Across all 16 children:
fewer than 100 samples reach qiskit_aer code total,
zero samples reach cpp_execute_circuits — not a single child ever ran any quantum simulation.
So the user's "external parallelisation" cell was a no-op for parallelism. The 16 workers were ghosts; the parent did 100 % of the work alone, single-threaded, hitting the O(N²) post-loop from bug #1. From the outside it looked like "16 workers running"; in reality the workers contributed zero useful work.
The standalone-script reproduction works (~60 s) because nothing in a fresh Python process warms the executor's worker thread before fork. In Jupyter the kernel warms it implicitly before any cell runs, so the same code path silently breaks.
Steps to reproduce the problem
A complete reproduction (benchmark.py and run_bench.py) is available; minimal repro for each bug below.
importnumpyasnpfromqiskitimportQuantumCircuitfromqiskit.circuitimportParameterfromqiskit.quantum_infoimportSparsePauliOpfromqiskit.primitives.containers.estimator_pubimportEstimatorPubfromqiskit_aer.primitivesimportEstimatorV2n_qubits=4n_rows=50_000# already painful; user hit 100ktheta= [Parameter(f"t{i}") foriinrange(n_qubits)]
qc=QuantumCircuit(n_qubits)
fori, pinenumerate(theta):
qc.ry(p, i)
obs= [SparsePauliOp.from_list([("Z"*n_qubits, 1.0)])]
params=np.random.default_rng(0).uniform(-1, 1, size=(n_rows, n_qubits))
est=EstimatorV2()
pub=EstimatorPub.coerce((qc, obs, params))
importtime; t0=time.perf_counter()
est.run([pub]).result()
print(f"wall: {time.perf_counter() -t0:.1f} s")
# Wall time grows quadratically with n_rows. Profile with py-spy and# you will see >50% of samples in estimator_v2.py:143.
importmultiprocessingasmpfromqiskitimportQuantumCircuitfromqiskit_aerimportAerSimulatordefchild_work():
qc=QuantumCircuit(2); qc.h(0); qc.cx(0, 1); qc.measure_all()
AerSimulator().run(qc).result()
print("child done")
if__name__=="__main__":
# Warm DEFAULT_EXECUTOR in the parent (any Aer .run() call does this;# in Jupyter it happens automatically before any user cell runs).qc=QuantumCircuit(2); qc.h(0); qc.cx(0, 1); qc.measure_all()
AerSimulator().run(qc).result()
ctx=mp.get_context("fork")
p=ctx.Process(target=child_work)
p.start(); p.join(timeout=30)
print("exitcode:", p.exitcode)
# Without the fix: child raises in EstimatorV2.run() / AerJob.submit()# because the inherited ThreadPoolExecutor has no live worker thread.
To see the full Jupyter manifestation, run a notebook cell that warms an AerSimulator, then a second cell that submits 16 EstimatorV2.run() calls via ProcessPoolExecutor(mp_context="fork"). The parent will silently do all the work; the children will sit in format_exception with zero useful CPU work.
What is the expected behavior?
For bug Error with OpenMP #1: EstimatorV2._run_pub should perform parameter-index lookup in O(1), not O(N). The post-loop should scale linearly with n_rows × n_observables, not quadratically. On the 100k × 15 workload the post-loop should consume <5 % of wall time (vs the current 51 %), and internal_sv wall should drop from ~17 minutes to ~8 minutes (with the C++ simulation now correctly identified as the bottleneck).
For bug Update README and build instructions on Mac #2: mp_context="fork" workers should be able to use Aer's AerJob / EstimatorV2 without silently failing. A user submitting 16 jobs via ProcessPoolExecutor(mp_context="fork") should actually get 16× parallelism — not a serial parent doing all the work while 16 children format tracebacks.
Suggested solutions
Fix #1 — Replace the O(N²) list scan with a dict lookup + 1D fast path
~10 lines, no public API change, no caller changes; from qiskit_aer.jobs.utils import DEFAULT_EXECUTOR continues to work because the module-level binding is updated in place. Guarded with hasattr so the import is safe on platforms without os.register_at_fork (Windows).
I have implemented and tested both fixes locally and am preparing a PR.
Workarounds for users until the fixes land
Always chunk parameter rows. A 16-PUB split (np.array_split(data, N_CPUS)) cuts the post-loop O(N²) cost by 16² = 256× and explains the ~5× speedup observed for the multi-PUB internal case.
Prefer mp_context="spawn" over "fork" in any long-lived Python process (Jupyter, IPython). Spawn workers do not inherit the parent's DEFAULT_EXECUTOR state at all. Cost: ~1–2 s of import overhead per worker.
Run benchmarks in standalone scripts, not Jupyter cells, when measuring runtime. Jupyter kernels accumulate threadpool state across cells; this can silently turn parallel cells into serial ones (bug Update README and build instructions on Mac #2) and change the timing of subsequent cells in non-obvious ways.
References
qiskit_aer/primitives/estimator_v2.py:138,143 — bug Error with OpenMP #1 location
Informations
What is the current behavior?
EstimatorV2exhibits two independent, simultaneous bugs that together turn a workload that should take ~1 minute (per-process parallel) into a ~17 minute single-threaded run on a 15-qubit, 54-parameter HHFM circuit with 100,000 parameter rows and 15 observables, MPS backend, 16-core machine:max_parallel_threads=N_CPUS)ProcessPoolExecutor,max_parallel_threads=1Internal parallelism with
max_parallel_threads=N_CPUSshould approach the per-process result. It does not.Bug #1 — O(N²) post-processing loop in
EstimatorV2._run_pubqiskit_aer/primitives/estimator_v2.py:138,143:For 100k rows × 15 observables this performs ~7.5 × 10¹⁰ comparisons — about 750 s of pure-Python work at ~10 ns/op, matching the ~530 s post-loop time observed in py-spy flame graphs of the 1033 s
internal_svrun.py-spy flame graph (worker thread, 6,742 samples, 67 s wall):
estimator_v2.py:143—flat_indices.index(...)cpp_execute_circuits(the actual C++ simulation)The pure-Python post-loop (51 %) costs more wall time than the C++ simulation (30 %). Splitting the same 100k-row work into 16 PUBs drops the post-loop's share from 51 % → 7 %, confirming it scales worse than the simulation with
n_lines.Bug #2 —
DEFAULT_EXECUTORis not fork-safeqiskit_aer/jobs/utils.py:19:This module-scope singleton is constructed at import time. In any long-lived Python process (Jupyter kernel, IPython, services), ordinary kernel activity warms the executor — its worker thread is alive and its internal queue has been touched — before the user's code runs.
os.fork()then copies the executor object's memory (including its lock state and queue) but only the calling thread — the worker thread does not survive. Every forked child inherits aDEFAULT_EXECUTORthat looks normal but has no live worker. The first call intoEstimatorV2.run()enqueues to a queue nothing will ever consume, and the relevant call path raises during setup.Direct evidence from a
py-spy record --threads --idle --subprocessescapture of a live Jupyter kernel running the user's notebook (1,284,652 raw samples):Every one of the 16 children's leaf stack ends in:
That call path is reachable only from the
exceptbranch of_process_worker. Every child'srun(estimator, [pub])raised; the worker is now formatting the traceback (slow becauselinecachereads source files line by line). Across all 16 children:qiskit_aercode total,cpp_execute_circuits— not a single child ever ran any quantum simulation.So the user's "external parallelisation" cell was a no-op for parallelism. The 16 workers were ghosts; the parent did 100 % of the work alone, single-threaded, hitting the O(N²) post-loop from bug #1. From the outside it looked like "16 workers running"; in reality the workers contributed zero useful work.
The standalone-script reproduction works (~60 s) because nothing in a fresh Python process warms the executor's worker thread before fork. In Jupyter the kernel warms it implicitly before any cell runs, so the same code path silently breaks.
Steps to reproduce the problem
A complete reproduction (
benchmark.pyandrun_bench.py) is available; minimal repro for each bug below.Bug #1 — O(N²) post-loop
Bug #2 —
DEFAULT_EXECUTORafter forkTo see the full Jupyter manifestation, run a notebook cell that warms an
AerSimulator, then a second cell that submits 16EstimatorV2.run()calls viaProcessPoolExecutor(mp_context="fork"). The parent will silently do all the work; the children will sit informat_exceptionwith zero useful CPU work.What is the expected behavior?
For bug Error with OpenMP #1:
EstimatorV2._run_pubshould perform parameter-index lookup in O(1), not O(N). The post-loop should scale linearly withn_rows × n_observables, not quadratically. On the 100k × 15 workload the post-loop should consume <5 % of wall time (vs the current 51 %), andinternal_svwall should drop from ~17 minutes to ~8 minutes (with the C++ simulation now correctly identified as the bottleneck).For bug Update README and build instructions on Mac #2:
mp_context="fork"workers should be able to use Aer'sAerJob/EstimatorV2without silently failing. A user submitting 16 jobs viaProcessPoolExecutor(mp_context="fork")should actually get 16× parallelism — not a serial parent doing all the work while 16 children format tracebacks.Suggested solutions
Fix #1 — Replace the O(N²) list scan with a dict lookup + 1D fast path
Pure bugfix — semantics identical, numerical output bit-for-bit unchanged.
Fix #2 — Reset
DEFAULT_EXECUTORin every forked child~10 lines, no public API change, no caller changes;
from qiskit_aer.jobs.utils import DEFAULT_EXECUTORcontinues to work because the module-level binding is updated in place. Guarded withhasattrso the import is safe on platforms withoutos.register_at_fork(Windows).I have implemented and tested both fixes locally and am preparing a PR.
Workarounds for users until the fixes land
np.array_split(data, N_CPUS)) cuts the post-loop O(N²) cost by 16² = 256× and explains the ~5× speedup observed for the multi-PUB internal case.mp_context="spawn"over"fork"in any long-lived Python process (Jupyter, IPython). Spawn workers do not inherit the parent'sDEFAULT_EXECUTORstate at all. Cost: ~1–2 s of import overhead per worker.References
qiskit_aer/primitives/estimator_v2.py:138,143— bug Error with OpenMP #1 locationqiskit_aer/jobs/utils.py:19— bug Update README and build instructions on Mac #2 locationqiskit_aer/jobs/aerjob.py:61— caller ofDEFAULT_EXECUTOR, unchangedpy-spy capture, platform comparison data) available on request.