The programming model¶
Anatomy of a NetQMPI application¶
from netqmpi.sdk.environment import Environment # 1. the only import you need
def main(env: Environment = None): # 2. the entry point
comm = env.comm
rank, size = comm.rank, comm.size # 3. who am I
with comm: # 4. the distributed block
circuit = env.create_circuit(2, 2) # 5. circuits
... # gates and primitives
print(comm.results) # 6. results, after the block
1. Imports¶
Application code imports from netqmpi.sdk and nothing else. No backend package
appears anywhere, which is what makes the same file run on every backend. If you
find yourself importing qiskit, netqasm or cunqa in an application, the
program has stopped being portable.
2. The entry point¶
NetQMPI loads your script with runpy and looks for a callable named main. A
script without one is rejected before anything is built:
ValueError: app.py does not define a main() function
The env: Environment = None default is a convention: it documents the injected
type and lets the file be imported without running.
3. Rank and size¶
The same code runs on every node; rank is what makes each behave differently.
This is the SPMD paradigm inherited from classical MPI.
4. The with comm: block¶
Inside the block, circuit calls are traced, not executed. Each call appends
an Operation to the circuit’s
OperationContainer. The container is
a Composite: nested blocks (a qscatter, say) are sub-containers holding the
individual transfers they expand into.
When the block exits, the communicator’s __exit__ takes over: the operations
are translated into the backend’s native instructions and executed. The ranks
rendezvous there — circuits are submitted jointly once the last rank has left.
5. Circuits¶
create_circuit() returns a
backend-specific subclass of Circuit behind a
uniform interface, and registers it with the communicator so the runtime can
find it at translation time. Most ranks create exactly one circuit; creating
several is supported by the CUNQA backend, where the i-th circuit of every
rank forms one distributed program.
Every rank must create the same number of circuits
Circuits are paired across ranks by creation order. If the ranks disagree, the run fails at translation time:
RuntimeError: Every rank must create the same number of circuits so that they
can be paired into distributed programs, got {0: 2, 1: 1}.
The Qoala backend currently supports exactly one circuit per rank.
6. Reading results¶
comm.results is populated when the joint execution finishes. Its shape
depends on the backend, which is the one place the abstraction is not
watertight:
Backend |
Shape of |
Populated on |
|---|---|---|
CUNQA |
|
every rank |
Aer |
|
every rank, identically |
Qoala |
|
every rank |
NetQASM |
|
the rank itself |
The portable idiom, used by every shipped example, is to guard on a non-empty result and let whichever rank has it do the printing:
if comm.results:
for other, counts in comm.results.items():
print(f"rank_{other}: {counts}")
Tracing versus execution¶
Understanding when things happen explains most of NetQMPI’s error messages.
sequenceDiagram
participant U as Your main()
participant C as Circuit (SDK)
participant A as CircuitAdapter
participant B as Backend
Note over U,C: inside `with comm:`
U->>C: circuit.h(0)
C->>C: record Gate('H', [0])
U->>C: comm.qsend(circuit, [0], 1)
C->>C: record QSend + reserve comm qubit/clbits
Note over U,B: on `with` exit — the last rank triggers it
C->>A: translate(ops)
A->>B: native instructions
B-->>C: counts
There are three moments at which a program can fail, and they differ sharply in how helpful the error is:
- Trace time
Raised at the offending line while your
main()runs, so the traceback points into your own code. Out-of-range qubits, too few classical bits, a mismatchedunexpose. These are the good ones.- Translation time
Raised from
__exit__of the last rank to leave its block, because that is when the ranks’ circuits are translated together. The traceback anchors atwith comm:, never at the call that caused it, and the rank it is attributed to need not be the guilty one. Deadlocked collectives and unmatched transfers land here.- Run time
Raised inside the backend, in the backend’s own vocabulary, with nothing tying it back to a line of your code.
Troubleshooting catalogues what falls into each tier.
Resource accounting¶
You size the data qubits and classical bits; the runtime sizes everything else.
circuit = env.create_circuit(num_qubits=2, num_clbits=2)
circuit.num_qubits # 2 — what you asked for
circuit.num_clbits # 2 — what you asked for
circuit.num_comm_qubits # communication qubits the protocols need
circuit.num_protocol_clbits # classical bits the corrections need
The last two are high-water marks: the maximum number held at the same
time, not the total ever used. A qsend borrows one communication qubit and
two protocol classical bits and gives them straight back, so ten sequential
qsends still cost one communication qubit — and so does a whole qscatter.
An expose window, by contrast, holds its slot until the matching unexpose,
so overlapping windows do add up.
Both values are only final once the circuit has been fully traced, which is why
backends read them at translation time. The allocator behind them is
IndexPool.