AllGather: All-to-All Slices¶
Every rank publishes its slice, every rank ends with the rank-ordered concatenation of all slices — the all-gather half of two-phase all-reduce — then the builtin does it in one call.
Prerequisites: 17-broadcast. Any number of devices ≥ 2 (the examples use 2 and 4 sim devices).
Suggested reading order: 01 → … → 12 → 13 — this page is step 13.
The idea¶
Allgather inverts broadcast's asymmetry: every rank is both a producer and a
consumer. Each rank contributes one slice (N/P elements), and every rank
ends with the rank-ordered concatenation [x[0], x[1], …, x[P-1]].
| Aspect | AllGather |
|---|---|
| Data in | Every rank's slice |
| Data out | Concatenation of all slices, on every rank |
| Pattern | Stage your slice → barrier → read every peer's slice |
| Cost | Each rank sends N/P to every peer: (P-1)/P · N received |
You met this pattern once before: step 09's two-phase all-reduce is reduce-scatter followed by allgather. This step builds the allgather half on its own; step 14 builds the reduce-scatter half.
Run it¶
# Hand-rolled: stage, barrier, remote_load every peer.
python examples/distributed/13_allgather.py -p a2a3sim -d 0,1
# Reveal: pld.tensor.allgather in one call.
python examples/distributed/13_allgather.py -p a2a3sim -d 0,1 --mode builtin
# The same source at P=4:
python examples/distributed/13_allgather.py -p a2a3sim -d 0,1,2,3
python examples/distributed/13_allgather.py -p a2a3sim -d 0,1,2,3 --mode builtin
Expected output:
The golden is the rank-ordered concatenation — identical on every rank — so any rank producing the wrong order (or its own slice) fails.
Walkthrough¶
Both modes share one [nr, SIZE] window: each rank stages at its own row and
reads every row back. The hand-rolled kernel:
@pl.function(type=pl.FunctionType.InCore)
def hand_step(self, x, y, data, signal):
ctx = pld.get_comm_ctx(data)
my_rank = pld.rank(ctx)
nranks = pld.nranks(ctx)
# Phase 1 — stage this rank's slice into its own row.
local = pl.load(x, [0, 0], [1, SIZE])
data = pl.store(local, [my_rank, 0], data)
# Phase 2 — barrier: notify every peer, wait on every peer slot.
for peer in pl.range(nranks):
if peer != my_rank:
pld.system.notify(signal, peer=peer, offsets=[my_rank, 0],
value=1, op=pld.NotifyOp.AtomicAdd)
for src in pl.range(nranks):
if src != my_rank:
pld.system.wait(signal, offsets=[src, 0], expected=1,
cmp=pld.WaitCmp.Ge)
# Phase 3 — gather: pull every peer's row into the output.
for peer in pl.range(nranks):
recv = pld.tile.remote_load(data, peer=peer, offsets=[peer, 0], shape=[1, SIZE])
y = pl.store(recv, [0, peer * SIZE], y)
return y
- Row
my_rankis your slot. Staging at[my_rank, 0]instead of broadcast's single root slot is what makes the exchange symmetric: every rank writes a distinct row, so no two ranks ever collide. - The gather is a loop over peers —
remote_loadpeerpat rowp, stored at output offsetp * SIZE. The output is the rank-ordered concatenation, which is why the loop order (and the offset arithmetic) matter: slotpmust hold rankp's slice.
The reveal replaces phases 2–3 with one call — the push-based form:
data = pld.tensor.allgather(x, data, signal) # stage + barrier + gather
for src in pl.range(nranks):
chunk = pl.load(data, [src, 0], [1, SIZE])
y = pl.store(chunk, [0, src * SIZE], y)
- The source is your local
x(a plainpl.Tensor), not the window. The push-based allgather stages it for you; the target window becomes the[nr, SIZE]result (rowsrc= ranksrc's slice). - Same row-per-rank layout, opposite direction. Row
srcstill holds ranksrc's slice, so the read loop below is unchanged — but the builtin pushes where your version pulled, and that moves the barrier. See the IR diff.
The IR diff (the teaching artifact)¶
This is the first step where the diff is genuinely interesting: the two modes move the same bytes into the same layout in opposite directions, and the barrier lands on the other side of the transfer.
--mode handlowers to the three phases above — a pull: one store into your row, the notify/wait barrier, thenPpld.tile.remote_loads — one per peer, and note the gather loop does not skip your own row, so your slice is read back through the same path. The barrier comes before the data movement, because you must not read a peer that has not staged yet.--mode builtinlowers to a push: a loop ofPpld.tile.puts, each writing this rank's[1, SIZE]chunk into that peer's window at rowmy_rank, and then the notify/wait barrier on the[nr, 1]signal. There is noremote_loadin the expansion at all — thepl.loads in the snippet above are ordinary local reads of a window the peers have already filled. The barrier comes after the transfers, because here it is what tells you every peer's push has landed.- The push carries two details worth seeing in the IR: each
pld.tile.putstreams through a shared VEC staging tile (tile.create), so a row larger than that tile is auto-chunked by pto-isa; and the self-rank iteration (peer == my_rank) is not special-cased — it goes through the same TPUT path via HCCL identity mapping. - After the barrier the composite emits a self-clearing epilogue that subtracts this call's credits back out, which is why the same signal can be reused by a later collective (see step 16).
Cost card (per rank): each rank sends N/P bytes to every peer, so each
rank receives (P-1)/P · N bytes — the gather half of two-phase all-reduce
(step 09) moved (P-1)/P · N per phase too.
Edge cases¶
Fatal pitfall — gathering into the wrong slot. If the output offset does not match the peer's rank (
y[peer]written from peerpbut offset byp+1), every rank is internally consistent and the golden still fails — order is the contract. Fix: offsetpeer * SIZEfor peerpeer.
| Symptom | Likely cause | Fix |
|---|---|---|
| Rows in the wrong order | Output offset ≠ peer rank | Store peer p at [0, p * SIZE] |
| Every rank shows its own slice | Read from own window instead of peers | remote_load each peer at [peer, 0] |
pld.tensor.allgather local_data must be a plain Tensor |
A DistributedTensor window passed as the source on the InCore path |
The source must be a plain pl.Tensor [1, SIZE] distinct from target; the window form is accepted only on the HOST path |
pld.tensor.allgather input must be a Tensor or DistributedTensor |
A tile passed as the source — rejected earlier, by the type deducer | Pass the tensor itself, not a pl.load of it |
| Concatenation has gaps/overlaps | Stage/gather offset mismatch | Stage row my_rank; read row peer |
| Stale data at P=4 | Barrier missing between stage and gather | Notify/wait covers all nr peers before the read loop |
See also¶
- 05-tutorials — the tutorial index (this step = row 13)
- 01-collectives §AllGather — the full API
- 14-allreduce_two_phase — the gather half of the two-phase all-reduce this step isolates
- 04-debugging — the canonical failure catalog for distributed programs
- Next step: 19-reduce_scatter — the reduce half