Core concepts

Five ideas are enough to read and write any NetQMPI program.

Ranks and the SPMD model

You write one program. NetQMPI runs it on N quantum nodes, and each instance — a rank — distinguishes itself by its number:

rank = comm.rank    # 0 .. size-1, this node's index
size = comm.size    # how many nodes are in the run

-n 3 on the command line means three ranks, numbered 0, 1 and 2. Branching on rank is how a single file expresses different behaviour per node, exactly as in classical MPI.

For ring topologies, the communicator provides cyclic neighbour helpers:

comm.get_next_rank(rank)    # (rank + 1) % size
comm.get_prev_rank(rank)    # (rank - 1) % size

The environment

Every main() receives an Environment. It is the only object connecting your code to the runtime, and it has exactly two jobs:

def main(env: Environment = None):
    comm = env.comm                                       # communication
    circuit = env.create_circuit(num_qubits=2, num_clbits=2)  # circuit creation

comm is this rank’s QMPICommunicator, and create_circuit() is a factory that returns a backend-specific circuit behind a backend-agnostic interface. That indirection is the whole reason your application never imports a backend.

The communicator block

with comm:
    # everything here is part of the distributed program

The with comm: block delimits the distributed program. Circuit operations recorded inside it are traced, not executed: they accumulate in an operation container. When the block exits, the backend takes over — it translates the recorded operations into its native instructions and runs them.

This is why results are not available until after the block, and why the ranks synchronise there: the circuits of all ranks are submitted together when the last rank leaves its block.

Collectives must be reached by every rank

Because tracing is per-rank but execution is joint, a collective — qscatter, qgather, expose, unexpose — that only some ranks reach is a deadlock. NetQMPI detects the common cases at translation time and reports which rank is missing; see Troubleshooting.

Circuits, qubits and classical bits

A circuit is created with a number of data qubits and classical bits:

circuit = env.create_circuit(num_qubits=2, num_clbits=2)
circuit.h(0).cx(0, 1).measure(0, 0)     # fluent: each call returns the circuit

Under the hood there are two more resource pools you do not size yourself:

Communication qubits

Reserved by the runtime for distributed protocols. They are addressed right after the data qubits, and are only ever handed to you by expose(), which returns an index you can pass to any gate exactly like a local qubit.

Protocol classical bits

Carry the correction outcomes of teledata/telegate. They are additional to the num_clbits you requested, so a protocol never clobbers your own measurements.

Both are borrowed when a protocol block opens and given back when it closes, so non-overlapping protocols reuse the same physical resource. The allocator is IndexPool, and the totals a backend has to provide are exposed as num_comm_qubits and num_protocol_clbits.

Qubits move, they do not copy

This is the one place where the MPI analogy breaks, and it explains most surprising results. The no-cloning theorem means a quantum send is a move:

circuit.h(0)                        # rank 0 prepares |+>
comm.qsend(circuit, [0], 1)         # ... and no longer has it
                                    # rank 0's qubit 0 is back in |0>

The consequences show up throughout the API:

  • After a qsend, the sender’s qubit is in |0⟩.

  • After a qscatter, the root keeps nothing — unlike MPI_Scatter, its buffer is split among the other ranks only.

  • After a qgather, the contributors are left with |0⟩.

  • The qubits a transfer lands on must already be in |0⟩; whatever they held is destroyed, not saved.

The exception is expose(), which lends a qubit instead of moving it: the root keeps its state and gets it back untouched when the window closes. See Communication primitives.