A Python module for optimally combining and distributing quantum circuits. See the included notebooks for examples and documentation.
For an operational overview, see the diagram below.
Motivation for this module comes from underutilization of increasingly large quantum processors in
quantum computing tasks that only require relatively few qubits at a time. The module merges several
independent circuits into a smaller set of wider circuits, which it then runs on available backends
in parallel. All of this happens behind the scenes, so ideally, the user can treat the module's
functionality as a parallelized drop-in replacement for Qiskit's backend.run().
As an example, consider a task that involves running a four-qubit circuit several times with different parameters. If each version of the circuit was naively submitted one by one to the 54-qubit VTT Q50, 54 - 4 = 50 qubits would remain unused in each job. This is very wasteful in terms of execution time, which then translates to unnecessary operational costs.
Instead, depending on the connectivity of the four-qubit circuit, at least nine such circuits can be packed into a single host circuit that covers as many of Q50's qubits as it can. Optionally, the parallelizer can also be instructed to leave "padding qubits" between packed circuits. This reduces crosstalk between circuits at the cost of lower packing density.
The package is on PyPI and can be installed with pip:
pip install qc_parallelizerHere is a brief and basic example:
# Define or load a number of circuits.
from qiskit import QuantumCircuit
circuits = [QuantumCircuit(...), QuantumCircuit(...), ...]
# Define backends for circuit execution. These can be any Qiskit-compatible backend objects,
# but here we define two simulators that mimic IQM's 5-qubit Adonis architecture.
import iqm.qiskit_iqm as iqm
backends = [iqm.IQMFakeAdonis(), iqm.IQMFakeAdonis()]
# Instantiate the parallelizer, define usable backends, and run the circuits.
from qc_parallelizer import Parallelizer
job = Parallelizer().across(backends).run(circuits)
# Fetch and handle results. This plots the first circuit's result histogram, for example.
results = job.result()
qiskit.visualization.plot_histogram(results.get_counts(0))
# Information about the execution is also available.
print("Job IDs:")
for job, id in job.remote_ids.items():
print(f"- {id} (on '{job.remote_backend.name}')")The parallelizer also supports emulation of a virtual parallelized backend that can be passed to other software. It functions as a regular backend, but automatically parallelizes any circuits that are submitted to it:
>>> backend = Parallelizer().across(backends).as_qiskit_backend
>>> isinstance(backend, BackendV2)
True
>>> backend.target
<qiskit.transpiler.target.Target object at 0x71f0deadbeef>
>>> job = backend.run(circuits)
>>> isinstance(job, JobV1)
True
>>> job.result()
Result(
backend_name='ParallelizedQiskitBackendAdapter',
backend_version='2',
job_id='8b019556-bb08-40b5-93ff-788ff1f7fb89',
results=[...]
)Likewise, the parallelized backend can be supplied to PennyLane with .as_pennylane_device. See
the PennyLane demo notebook for an example.
For the following commands, a virtual environment or equivalent isolation is recommended. This can be done with Conda, for example, with
conda create --name parallelizer python=3.11 pip
conda activate parallelizerThe package can then be installed from a local copy of the directory by running
pip install -e .from the repository root. If you additionally wish to run tests or the provided notebook(s), you can install dependencies for those with
pip install .[tests]
# and/or
pip install .[notebooks]For debugging, it can be extremely useful to enable logging. The project uses an internal logging library with a global configurable log level. You can enable everything with
from qc_parallelizer.util import Log
Log.set_level("debug")or use the other levels ("info", "warn", "fail") for less verbose logging. By default, it writes nicely formatted
and coloured output to stderr, but you can configure it with these two class attributes:
Log.color: bool # Enables color, True by default, but this can mess with some environments
Log.force_builtin: bool # Forwards everything to Python's logging module, False by defaultThe log lines are interpreted as follows:
2026-06-03 11:28:14.488510 | [T0]~~~> vf2.py:70 | DBUG | VF2++ generator created with `id_order` = False.
^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^ ^^^^ ^^^^^^ ^^ ^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
`--> Timestamp \ \ \ `-> Line number `-> Log level `-> Message
\ \ `-> Source file
\ `-> Relative stack depth
`-> Thread number (assigned T0, T1, T2, ... in order of appearance)
Running all tests is as simple as installing the required dependencies (see above) and running
pytestfrom the repository root. Additionally, there is a benchmarking script in the tests/ directory.
(In no particular order.)
- Make layout/placement algorithms favour better-performing qubits, as specified by calibration data. This could potentially be read from the given backend objects, or the user could specify the data manually. This is of course possible with custom packers already.
- Add more tests! Numerous configurations have been tested manually with notebooks but more should be covered with unit testing.
- Batch jobs with identical measurements together. On backends with relatively long initialization times, batching can make a big difference.
- Allow circuits to share physical qubits for temporally non-overlapping parts. This requires reset instructions and makes the problem computationally harder, so this a step for the distant future.
- Henri Ahola <firstname.lastname@vtt.fi>
