Architecture¶
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.
Two layers, one boundary¶
SDK — user-facing¶
Backend-agnostic abstractions. Application code depends on these and nothing else.
EnvironmentThe local node context, and a factory for circuits. Injected into every
main().CircuitA fluent gate +
qsend/qrecvAPI that records operations into anOperationContainerrather than executing them.QMPICommunicatorRank/size and the communication primitives, plus the context manager that delimits the distributed program.
Runtime — execution-facing¶
Selects and drives a concrete backend through the Adapter pattern + dependency injection.
ExecutorBootstraps the processes, discovers resources, and injects a backend-specific communicator into each node’s
Environment.CircuitAdapter(aCircuitsubclass)Translates the recorded operations into native backend instructions.
Communicator(aQMPICommunicatorsubclass)Maps ranks and communication onto the platform’s resources, and triggers execution on context exit.
Because the boundary is strict, the same app.py runs on any backend by
switching a flag — no changes to application logic.
The lifecycle of a run¶
sequenceDiagram
autonumber
participant CLI as netqmpi CLI
participant Ex as Executor
participant App as your main()
participant Ci as Circuit
participant Ad as CircuitAdapter
participant B as Backend
CLI->>Ex: instantiate (flag + RunConfig)
CLI->>Ex: build_apps(script, size)
Ex->>Ex: load_main(script), acquire resources
Ex->>App: one Environment per rank
loop per rank
App->>Ci: env.create_circuit(...)
App->>Ci: gates and primitives (recorded)
App->>Ex: leaves `with comm:`
end
Note over Ex,B: the last rank triggers it
Ex->>Ad: translate recorded operations
Ad->>B: native instructions
B-->>App: counts in comm.results
Design patterns in play¶
- Command —
Operation Each operation encapsulates everything needed to describe one quantum action, independently of any backend. That is what makes recording-then-translating possible.
- Composite —
OperationContainer Holds both leaf operations and nested containers, so a block that means more than its parts — a
QScatterexpanding into individual transfers — survives as the block it is.flatten()walks the leaves;childrenpreserves the nesting.- Adapter + dependency injection — the whole Runtime layer
The SDK never names a backend. The executor injects a concrete communicator into the
Environment, andcreate_circuitreturns a concrete adapter behind the abstractCircuitinterface.- Object pool —
IndexPool Communication qubits and protocol classical bits are borrowed and returned, so the footprint is the maximum held simultaneously rather than the total ever acquired.
How collectives stay consistent without communication¶
The ranks trace independently — there is no inter-rank communication at trace time — yet each has to name a collective exactly as its peers do, so the sides of a call can be paired at translation time.
They manage it by deriving the name from data every participant already knows: the kind of call, the ranks involved, and a per-key counter.
tag = self._next_tag("teledata", (self._comm.rank, dest_rank))
# -> "teledata_0_1_0", then "teledata_0_1_1", ...
Since every rank increments the same counter for the same key in the same order,
the n-th qsend from rank 0 to rank 1 gets the same tag on both sides. The
same mechanism names expose windows, with the group (root, *receivers) as the
key. This is why collectives must be reached in the same order on every rank:
the counters would otherwise drift apart.
Resource allocation¶
Both pools hand out indices from a free list first and only then from a fresh counter:
pool = IndexPool()
a = pool.acquire(2) # [0, 1]
pool.release(a)
b = pool.acquire(1) # [0] -- reused
pool.size # 2 -- high-water mark
A teledata block acquires one communication qubit and two protocol classical bits
and releases them immediately, so sequential transfers reuse one slot. An expose
window holds its slot until the matching unexpose, so nested windows stack.
The backend reads
num_comm_qubits and
num_protocol_clbits at translation time,
when the trace is complete, and reserves exactly that much.
Where the abstraction leaks¶
Three places, all documented rather than hidden:
comm.resultsdiffers per backend — keyed by rank on CUNQA, a single global histogram on Aer, this rank’s own on Qoala and NetQASM. See the comparison.Collectives that a backend expands jointly need the circuits of all ranks at once.
CircuitAdaptertherefore allows a group-translation entry point alongside the per-operation hooks — CUNQA’stranslate_group.Not every backend implements every operation. The SDK accepts everything at trace time; unsupported operations surface at translation time. See the support matrix.