Communication primitives

NetQMPI has three families of quantum communication primitive. All of them are methods on the QMPICommunicator and take the circuit as their first argument.

Family

Calls

Semantics

MPI analogue

Point-to-point

qsend / qrecv

Moves qubits between two ranks

MPI_Send / MPI_Recv

Rooted collectives

qscatter / qgather

Moves qubits between a root and the rest

MPI_Scatter / MPI_Gather

Telegate window

expose / unexpose

Lends a control qubit, no move

MPI_Bcast, roughly

All the collectives are barriers

qscatter, qgather, expose and unexpose must be reached by every participating rank, in the same order. A rank that skips one, or reaches them in a different order, deadlocks the run — NetQMPI detects the common cases at translation time and names the missing rank.

qsend / qrecv — moving a qubit

comm.qsend(circuit, qubits, dest_rank)
comm.qrecv(circuit, qubits, src_rank)

The sender names the local qubits to hand over; the receiver names the local qubits the incoming states land on. The backend fills in the teledata protocol — EPR-pair generation, Bell measurement, classical corrections — behind those two calls.

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

    if rank == 0:
        circuit.h(0)                            # prepare |+>
        comm.qsend(circuit, [0], 1)             # and give it away
    else:
        comm.qrecv(circuit, [0], 0)
        circuit.measure(0, 0)

Each qubit is transferred by its own protocol block, which borrows one communication qubit and two protocol classical bits and returns them immediately, so a sequence of transfers reuses the same slot.

Sends and receives must pair up

A qsend with no matching qrecv is caught at translation time on CUNQA:

RuntimeError: rank 0 sends qubit 0 to rank 1, which never receives it:
rank 0 traced 1 qsend to rank 1, and rank 1 traced 0 qrecvs from rank 0

The qubits a transfer lands on must be in |0⟩ when the call is reached. Whatever they held is destroyed, not saved.

qscatter / qgather — moving a buffer

Both are collective (every rank calls them) and rooted (one rank passes the whole buffer, the others pass their own chunk). Both return the local qubits holding this rank’s share.

qscatter

mine = comm.qscatter(circuit, qubits, root)

The root’s buffer is split into one chunk per rank other than the root, in rank order.

This is where qscatter parts company with MPI_Scatter

Because qubits cannot be copied, the chunks are moved. The root keeps nothing: scatter two qubits over two other ranks and the root ends empty-handed, its slots back in |0⟩. The call returns an empty list on the root for exactly that reason.

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)                                    # |1> on every qubit
        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)                         # reads 1

qgather

whole = comm.qgather(circuit, qubits, root)

The mirror image: the root passes the whole buffer the chunks land on — with its own contribution already sitting at position root — and every other rank passes the qubits it contributes. The contributors are left with |0⟩.

with comm:
    if rank == ROOT:
        circuit = env.create_circuit(num_qubits=size, num_clbits=size)
        circuit.x(ROOT)                                     # its own contribution
        comm.qgather(circuit, list(range(size)), root=ROOT)
        circuit.measure_all()                               # reads 1 everywhere
    else:
        circuit = env.create_circuit(num_qubits=1, num_clbits=1)
        circuit.x(0)
        comm.qgather(circuit, [0], root=ROOT)
        circuit.measure(0, 0)                               # reads 0

Rules and errors

The root’s buffer must split evenly among the holders — the other ranks for a scatter, every rank for a gather — and only data qubits can be moved:

ValueError: the root of a qscatter must hold one chunk per receiving rank:
            3 qubits do not split evenly among 2 ranks.
ValueError: qscatter root 7 is not a rank of the communicator [0, 3).
ValueError: qscatter needs at least one rank besides the root (0).
IndexError: Qubit index 3 is not a data qubit (expected [0, 2)).

A whole scatter costs the same resources as a single qsend: each transfer borrows one communication qubit and two protocol classical bits and gives them straight back.

expose / unexpose — sharing a control

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 through a shared GHZ state. This is a telegate.

control = comm.expose(circuit, qubit, ranks, root=None)
comm.unexpose(circuit, ranks, root=None)

Both are collective across [root] + ranks. root names the rank lending the qubit and defaults to the calling rank. The qubit argument is read on the root only; the other participants may pass anything.

The call returns the index each rank must use as control — the root’s own data qubit on the root, a freshly reserved communication qubit on every receiver — so the gate 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

Unlike a transfer, the root keeps its state: it gets the qubit back untouched when the window closes.

Nested windows

Windows unwind like scopes — unexpose pairs with the innermost matching expose for the same participant group. 5_qft_expose.py builds a full 3-rank QFT that way, with rank 2’s window spanning rank 1’s:

control_2 = comm.expose(circuit, 0, [0, 1], root=2)   # outer window
control_1 = comm.expose(circuit, 0, [0], root=1)      # inner window

if rank == 0:
    circuit.h(0)
    circuit.cp(control_1, 0, np.pi/2)   # CS
    circuit.cp(control_2, 0, np.pi/4)   # CT

comm.unexpose(circuit, [0], root=1)     # closes the inner one

if rank == 1:
    circuit.h(0)
    circuit.cp(control_2, 0, np.pi/2)   # CS

comm.unexpose(circuit, [0, 1], root=2)  # closes the outer one

Resources are held for as long as the window is open, so overlapping windows do add up — unlike transfers, which give their slot back immediately. Windows that do not overlap reuse the same communication qubit.

Rules and errors

RuntimeError: rank 0 called unexpose(ranks=[0], root=1) without a matching open expose window.
IndexError:   Qubit index 1 is a communication qubit whose expose window is already closed.
ValueError:   expose needs at least one rank besides the root (0).
RuntimeError: rank 0 called expose(ranks=[7], root=0), which names rank 7, but this
              run has 2 ranks, numbered 0 to 1.

Using an exposed control after its window has closed is caught at trace time, pointing straight at the offending line. A window that only some ranks open is caught at translation time, as a deadlock report naming who is missing — see Troubleshooting.

Backend support

Only CUNQA implements all three families today.

Primitive

CUNQA

NetQASM

Aer

Qoala

qsend / qrecv

✅

✅

⚠️ SWAP-based

✅

qscatter / qgather

✅

❌

❌

❌

expose / unexpose

✅

❌

❌

❌

On CUNQA the telegate window is expanded by translate_group(), because CUNQA’s cat_entangler / cat_disentangler helpers write into every participating circuit in a single call. That is why the per-rank _translate_expose hook of the CUNQA adapter deliberately raises instead of emitting anything.