Skip to content

Repository files navigation

NetQMPI logo.

NetQMPI is a Python library that brings the classical MPI (Message Passing Interface) programming model to Distributed Quantum Computing (DQC). Following a Single-Program, Multiple-Data (SPMD) paradigm, the developer writes a single script that runs across N quantum nodes and coordinates them through message-passing primitives — including quantum-aware ones such as qsend, qrecv and quantum collectives — without manually orchestrating low-level entanglement, teleportation or classical messaging.

Crucially, NetQMPI is now backend-agnostic: the same, unmodified application runs on several execution engines (a quantum-network simulator, an HPC emulator, a circuit simulator, …) simply by selecting a backend on the command line.

📖 Full documentation — installation, programming model, communication primitives, per-backend guides and the complete API reference.

Contributors

Table of Contents

Architecture: a decoupled design

Earlier versions of NetQMPI were implemented directly on top of the NetQASM SDK, which tied programs to a single execution stack. NetQMPI has since been restructured into a decoupled architecture that strictly separates what a distributed quantum program does from how and where it runs (see Vázquez-Pérez et al.):

NetQMPI architecture overview

  • SDK (user-facing). Backend-agnostic abstractions: Environment (the local node context and a factory for circuits), Circuit (a fluent gate + qsend/ qrecv API that records operations into an OperationContainer), and the QMPICommunicator (rank/size and communication primitives). Application code depends only on these.
  • Runtime (execution-facing). Selects and drives a concrete backend through the Adapter pattern + dependency injection: an Executor bootstraps the processes and injects a backend-specific communicator into the Environment; a CircuitAdapter translates the recorded operations into native backend instructions; a concrete Communicator maps ranks and communication onto the platform's resources.

Because the boundary between the two layers is strict, the same app.py runs on any backend by switching a flag — no changes to application logic.

Available backends

Backend CLI flag What it targets Key dependencies
NetQASM / SquidASM --netqasm Low-level quantum-network simulation (EPR sockets, NetQASM routines) squidasm, netsquid, netqasm 2.x, Python ≥ 3.9
NetQASM / SquidASM (legacy) --netqasm1.0 The same backend against the older NetQASM release squidasm, netsquid, netqasm 1.x
CUNQA --cunqa HPC emulation of DQC through virtual QPUs (vQPUs) cunqa (HPC / Slurm environment)
Qiskit Aer --aer Shot-based circuit simulation (swap- or teleportation-based transfer) qiskit, qiskit-aer
Qoala --qoala Quantum-internet node execution environment with task scheduling & multitasking — simulation only qoala, netsquid, netqasm 2.x, Python 3.10–3.12

NetSquid account. The NetQASM and Qoala backends depend on NetSquid, which requires a (free) account and is installed from its private index: pip install netsquid --extra-index-url https://<user>:<pwd>@pypi.netsquid.org.

NetQASM 1.x vs 2.x — and why they still need separate environments. --netqasm targets NetQASM 2.x and --netqasm1.0 the older 1.x. Both drive the same adapter: the API this backend uses — Qubit, EPRSocket, NetQASMConnection, Socket, the Application / ApplicationInstance / Program trio and SquidASM's simulate_application — is unchanged between the two releases, and SquidASM is a pure-Python wheel that accepts either. The flag selects an environment, and the run stops immediately, naming both versions, if the one installed is not the one asked for.

Two boundaries are real and worth knowing:

  • NetQASM 2.x needs Python ≥ 3.9 (it uses PEP 585 generics), so it cannot be dropped into a 3.8 environment built for 1.x.
  • SquidASM and Qoala still cannot share an environment, but not because of NetQASM: they require incompatible majors of netsquid-magic (15.x and 14.x respectively). Installing one over the other leaves the displaced backend unable to build a link layer.

The adapter also stays inside the instruction set both NetQASM releases share. 2.x adds more — Qubit.swap, for one — but the SquidASM release available here does not execute those, and reaching for one hangs the simulation rather than failing, so a swap is still assembled from three CNOTs. CUNQA and Aer have no such constraints.

Qoala is simulation-only. It models the software/hardware architecture of a quantum-internet node on NetSquid; it is not a path to real-hardware execution.

Installation

Install the core package with pip:

pip install netqmpi

Then install the backend(s) you intend to use. Each backend lives behind a lazy import, so you only need the dependencies of the backend you actually run.

NetQASM / SquidASM backend
pip install squidasm --extra-index-url https://<user>:<pwd>@pypi.netsquid.org
# pulls in netsquid and netqasm 1.x

See the NetQASM installation docs.

Qoala backend (separate environment, simulation only)
conda create -n qoala python=3.11 -y
conda activate qoala
pip install netsquid --extra-index-url https://<user>:<pwd>@pypi.netsquid.org
pip install qoala   --extra-index-url https://<user>:<pwd>@pypi.netsquid.org  # netqasm 2.x
pip install netqmpi
CUNQA backend (HPC, or a container on your own machine)

On a cluster: install and configure CUNQA (it provisions vQPUs via the job scheduler), then install netqmpi in the same environment.

On an ordinary computer, use the container — it packs a single-node SLURM, CUNQA and NetQMPI together, so your machine stands in for the HPC environment:

docker pull jvazquezperez/cunqa_netqmpi
docker run --rm -it -p 8888:8888 jvazquezperez/cunqa_netqmpi

SLURM and a Jupyter server on port 8888 (token cunqa) come up, and you get a shell. It is the real backend, so it also runs out of room like one: a single machine tops out around five or six ranks. See the CUNQA backend page for mounting your own checkout and sizing the vQPUs.

Qiskit Aer backend
pip install qiskit qiskit-aer

Quick start

NetQMPI is launched with an MPI-like command that selects the number of nodes and the backend:

netqmpi -n <NUM_NODES> app.py --netqasm            # quantum-network simulation
netqmpi -n <NUM_NODES> app.py --cunqa --shots 1024 # HPC vQPU emulation
netqmpi -n <NUM_NODES> app.py --aer   --shots 1024 # circuit simulation
netqmpi -n <NUM_NODES> app.py --qoala --shots 100  # Qoala node exec. environment

The example below (examples/1_send_recv.py) prepares a qubit in superposition on one node and teleports it to a neighbour with qsend/qrecv. It uses only SDK abstractions, so the very same file runs on every backend:

from netqmpi.sdk.environment import Environment

def main(env: Environment = None):
    comm = env.comm
    rank = comm.rank

    next_rank = comm.get_next_rank(rank)
    previous_rank = comm.get_prev_rank(rank)

    with comm:  # everything inside this block is executed on the backend
        if rank == 0:
            circuit = env.create_circuit(num_qubits=1, num_clbits=1)
            circuit.h(0)                          # prepare |+>
            comm.qsend(circuit, [0], next_rank)   # teleport the qubit
        else:
            circuit = env.create_circuit(num_qubits=1, num_clbits=1)
            comm.qrecv(circuit, [0], previous_rank)
            circuit.measure(0, 0)

    results = comm.results

    if rank != 0:
        print(f"measure: {results}")
    else:
        print("teleportation complete")
netqmpi -n 2 examples/1_send_recv.py --netqasm
netqmpi -n 2 examples/1_send_recv.py --qoala --shots 100

The programmer only invokes comm.qsend() / comm.qrecv(); entanglement generation, teleportation and classical corrections are handled by the selected backend adapter.

Moving qubits collectively: qscatter / qgather

qscatter and qgather are MPI_Scatter and MPI_Gather with qubits instead of bytes. Both are collective — every rank of the communicator must call them — and rooted: the root passes the whole buffer and every other rank passes its own chunk. The call returns the local qubits holding this rank's share.

Since a quantum state cannot be copied, the chunks are moved: each one is teleported with the same qsend/qrecv machinery as above, so the sending side does not keep the data. That is also where qscatter parts company with MPI_Scatter: the root's buffer is split into one chunk per rank other than the root, which keeps nothing back — scatter two qubits over two other ranks and the root ends up empty-handed, its slots back in |0>. A qgather is the other way round: the root's buffer has one slot per rank, its own contribution already in place, and the contributors are left with |0> once their qubits have moved. The qubits a chunk lands on must be in |0> when the call is reached, exactly as for a plain qrecv.

with comm:
    if rank == ROOT:
        # One qubit for each of the other ranks; the root gives them all away.
        circuit = env.create_circuit(num_qubits=size - 1, num_clbits=size - 1)
        for q in range(size - 1):
            circuit.x(q)
        comm.qscatter(circuit, list(range(size - 1)), root=ROOT)
        circuit.measure_all()                          # reads 0 everywhere
    else:
        circuit = env.create_circuit(num_qubits=1, num_clbits=1)
        mine = comm.qscatter(circuit, [0], root=ROOT)  # lands on qubit 0
        circuit.measure(mine[0], 0)

Each transfer borrows one communication qubit and two protocol classical bits and gives them straight back, so a whole scatter costs the same resources as a single qsend. examples/3_scatter.py and examples/4_gather.py run the two collectives end to end.

Sharing a control qubit: expose / unexpose

Moving a qubit is not always what a distributed algorithm needs. When several ranks only want to apply gates controlled by a remote qubit — the crossing rotations of a QFT, for instance — the qubit can stay where it is and be lent to them instead, through a shared GHZ state (telegate).

expose and unexpose are collective, like an MPI_Bcast: every rank of the window must call them, and root names the rank lending the qubit. The call returns the index each rank must use as control — its own data qubit on the root, a freshly reserved communication qubit on every receiver — so the gate itself is written exactly like a local one:

with comm:
    circuit = env.create_circuit(num_qubits=1, num_clbits=1)

    # Rank 1 lends its qubit 0 to rank 0, which drives a CS with it.
    control = comm.expose(circuit, 0, [0], root=1)

    if rank == 0:
        circuit.h(0)
        circuit.cs(control, 0)

    comm.unexpose(circuit, [0], root=1)   # the control goes back untouched

Communication qubits and the classical bits carrying the protocol corrections are reserved when a window opens and released when it closes, so windows that do not overlap reuse the same resources and user classical bits are never clobbered. examples/5_qft_expose.py builds a full 3-rank QFT this way.

Backend hardware configuration (--config)

Backend-specific parameters are passed through a single YAML file with --config (instead of a proliferation of per-backend flags). The file has generic settings at the top level plus an optional block named after the backend; only the block for the selected backend is read. For example, configuring the Qoala qdevice and its entanglement link:

# config.yaml
shots: 1000
seed: 7
qoala:
  link_fidelity: 0.8          # EPR-pair fidelity in [0.25, 1.0]
  hardware:                   # qdevice noise model (omit for a perfect device)
    t1: 0
    t2: 0
    single_qubit_gate_depolar_prob: 0.1
    two_qubit_gate_depolar_prob: 0.0
netqmpi -n 2 app.py --qoala --config config.yaml

CUNQA: which vQPUs the run uses

A run needs one vQPU per rank, and by default it expects them to be already raised, so one allocation can serve many runs:

qraise -n 3 -t 00:10:00 --quantum_comm --co-located    # once
netqmpi -n 3 examples/3_scatter.py --cunqa       # as often as you like

The family may hold more vQPUs than the run needs — three raised, -n 2 run — and the extra ones cost the program nothing. CUNQA runs a single executor per family and it starts a round only once every vQPU of that family has submitted something, so a vQPU left out would not sit idle, it would hang the run; NetQMPI hands each spare one a trivial circuit instead and discards its counts.

What a run cannot do is spread across families, since each family is executed on its own. If vQPUs of several families are up, name the one to use with family: in the cunqa block.

If there are no vQPUs up at all, the run stops before building anything and says what to raise.

To have NetQMPI raise them for the run and drop them again afterwards, ask for it in the cunqa block. backend is the vQPU definition file the vQPUs are raised with, which is what fixes the qubit budget of the run:

# cunqa.yaml
shots: 1024
cunqa:
  qraise: true                                # raise for this run, drop after it
  backend: examples/qft_expose.json   # vQPU definition (qubit budget)
  time: "00:10:00"                            # SLURM reservation
  simulator: Munich
netqmpi -n 3 examples/3_scatter.py --cunqa --config cunqa.yaml

Size that definition to what the program needs. The executor simulates the whole family in one register, spanning every qubit each vQPU declares whether the circuits use it or not, so the cost of a run is set by num_qubits × the number of ranks — not by the circuits. With a statevector simulator that register is 2^N amplitudes:

vQPU definition -n 2 -n 3 -n 4 -n 5
[4, 4] — 8 qubits each 1 MiB 256 MiB 64 GiB 16 TiB
[3, 2] — 5 qubits each 16 KiB 512 KiB 16 MiB 512 MiB

That is why simulator matters. NetQMPI defaults to Munich, whose decision diagrams keep a mostly-idle register small, so oversized vQPUs go unnoticed. CUNQA's own default, Aer, allocates the dense statevector and reinitialises it once per shot, so the same program on generous vQPUs turns into a run that never seems to finish — it is waiting on the simulator, not deadlocked. examples/cunqa_backend.json is sized for the examples and runs on either. It fits them all up to three ranks; 4_gather.py's root holds one qubit per rank, so a four-rank run of it wants [4, 2].

family picks which raised vQPUs to attach to, or names the family to raise, and co_located has to match how they were raised. backend, time and simulator only mean anything when qraise: true, so setting them while attaching to running vQPUs is reported rather than silently ignored.

Writing a new backend

Adding a backend never requires touching the SDK. Following the architecture above, you provide three Runtime components under netqmpi/runtime/adapters/<backend>/ (mirroring the existing netqasm/, cunqa/, aer/, qoala/ packages):

  1. Executor (subclass of netqmpi.runtime.executor.Executor) — bootstraps the execution environment, discovers resources, and injects a backend-specific communicator into each node's Environment (build_apps + run).
  2. CircuitAdapter (subclass of netqmpi.sdk.circuit.Circuit) — implements the _translate_* hooks that map the abstract operations recorded in the OperationContainer (local gates, measure, qsend/qrecv, …) to the backend's native instructions. Operations deriving from CollectiveOperation (expose/unexpose) are the exception: if the backend expands them into all the participating circuits at once, as CUNQA's cat-entangler does, the adapter translates the ranks jointly, stopping each of them at the matching collective (see translate_group in the CUNQA adapter).
  3. QMPICommunicator (subclass of the abstract communicator) — maps rank / size and the communication primitives onto the backend's real resources, and triggers execution on context exit.

Finally, register a --<backend> flag in netqmpi/runtime/cli.py (with a lazy import so users without that backend's dependencies are unaffected). The integration workflow is identical for every backend; only the realization of these three components differs.

Examples

Ready-to-run scripts live in examples/: 1_send_recv.py (distributed superposition / teleportation), 2_round_robin.py, 3_scatter.py, 4_gather.py, 5_qft_expose.py.

examples/frequent_errors/ collects programs that are meant to fail, one failure mode each, with a run_all.py that reports what NetQMPI says about every one of them without needing any vQPU.

Validation experiments for the Qoala backend (hardware-parameter propagation, EPR fidelity sweep, and scheduling/multitasking) are documented in scripts/experiments/.

Cite this work

If you use NetQMPI in your research, please cite the following works:

NetQMPI: An MPI-Inspired Library for Programming Distributed Quantum Applications Over Quantum Networks Using NetQASM SDK

F. Javier Cardama, Jorge Vázquez-Pérez, Tomás F. Pena, Andrés Gómez IEEE Access, Vol. 14, 2026, pp. 125459-125475 DOI: 10.1109/ACCESS.2026.3723566


Emulating NetQMPI applications with CUNQA: A Decoupled Architecture for HPC Environments

Jorge Vázquez-Pérez, F. Javier Cardama, Tomás F. Pena, Andrés Gómez Proceedings of the IEEE International Conference on Distributed Computer Systems (ICDCS 2026). PDF: PDF Paper


NetQIR: An Extension of QIR for Distributed Quantum Computing

F. Javier Cardama, Jorge Vázquez-Pérez, C. Piñeiro, T. F. Pena, J. C. Pichel, Andrés Gómez Future Generation Computer Systems, Vol. 174, 2026, Article 107989 DOI: 10.1016/j.future.2025.107989


About

NetQMPI is a high-level Python library for distributed quantum computing that implements QMPI and NetQIR abstractions over the NetQASM backend, enabling MPI-style programming of quantum networks.

Resources

Stars

2 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages