LowerHostTensorCollectives Pass¶
Overview¶
LowerHostTensorCollectives rewrites host-orchestrator calls to
pld.tensor.allreduce, pld.tensor.barrier, pld.tensor.broadcast,
pld.tensor.reduce_scatter, pld.tensor.allgather,
pld.tensor.all_to_all, and pld.tensor.all_to_all_v into compiler-internal
builtin chip dispatches. It runs
after MaterializeCommDomainScopes, so
each window-bound data tensor and explicit or synthesized signal tensor already has a
WindowBuffer back-reference and belongs to an inferred communication domain.
The pass does not change non-host functions. InCore allreduce calls continue to
use LowerCompositeOps.
Position in the pipeline¶
... -> SynthesizeAllReduceSignals -> MaterializeCommDomainScopes -> LowerHostTensorCollectives -> MaterializeDistTensorCtx -> Simplify (final) -> MaterializeRuntimeScopes
The final Simplify runs after this pass so any generated loop bounds or
constant expressions can still be folded before runtime scopes are inserted.
Behavior¶
For a host-orchestrator call:
data = pld.tensor.allreduce(data, signal, op=pld.ReduceOp.Sum)
data = pld.tensor.allreduce(data, signal, op=pld.ReduceOp.Sum, core_num=4)
data = pld.tensor.allreduce(data, signal, op=pld.ReduceOp.Sum, mode="ring")
signal = pld.tensor.barrier(signal)
data = pld.tensor.broadcast(data, signal, root=0)
data = pld.tensor.reduce_scatter(data, signal, op=pld.ReduceOp.Sum)
data = pld.tensor.allgather(stage, data, signal)
data = pld.tensor.all_to_all(stage, data, signal)
data = pld.tensor.all_to_all_v(input, target, signal, send_counts, recv_counts)
pld.tensor.allreduce dispatches on its mode kwarg: the default
mode="mesh" lowers to builtin.tensor.allreduce, while mode="ring" lowers
to builtin.tensor.allreduce_ring. Any other value is rejected as a user
error.
For allgather / all_to_all / all_to_all_v, stage/input (TPUT source)
and data/target (result) must be two distinct windows. For allgather the
stage window holds only this rank's single chunk and is [1, SIZE]; for
all_to_all it carries one per-destination chunk per row and is
[NR, SIZE]; for all_to_all_v it carries one MAX_RECV-row capacity block
per destination and is [NR*MAX_RECV, SIZE]. In both all_to_all /
all_to_all_v cases data/target is the peers'-push-in result window.
all_to_all_v additionally requires send_counts (window-bound at this
layer, LOCAL-only) and recv_counts (window-bound, published cross-rank via
pld.system.notify) — all five window args must resolve into the same
CommDomainScopeStmt and must be pairwise-distinct window allocations
(aliasing any pair is a cross-process race, whether data-vs-data,
data-vs-control, or control-vs-control).
The pass emits the corresponding builtin.tensor.* dispatch per participating
device (including builtin.tensor.allreduce /
builtin.tensor.allreduce_ring, builtin.tensor.barrier,
builtin.tensor.broadcast, builtin.tensor.reduce_scatter,
builtin.tensor.allgather, builtin.tensor.all_to_all, and
builtin.tensor.all_to_all_v). When the surrounding comm-domain scope has an
explicit device list, the pass emits a SeqStmts; otherwise it emits a
sequential for r in
pld.system.world_size() loop.
Each generated builtin call carries the collective-specific args and kwarg
attributes from the source pld.tensor.* call. Window-bound INOUT tensors
are threaded through as-is; scalar kwarg values (op, root, dtype, and
mesh-AllReduce core_num) are forwarded to the builtin. all_to_all_v's
MAX_RECV is not a lowering-time attribute: the HOST kernel derives it at
entry as target.shape[0] / nranks (the runtime comm-domain size), so no
per-MAX_RECV codegen variant mangling is needed and the block layout stays
consistent with the devices actually running.
Assignments preserve the user-facing rebind idiom by appending
<result> = <original expr> after the generated builtin calls.
Printed form¶
The builtin.tensor.* operators are internal_only in the registry: no DSL
wrapper spells them, and the user-facing op-creation path rejects them by name.
The python printer still has to render them, and does so under the pl.builtin
namespace — the same pl. prefix it puts on any non-pld registered operator:
for r_1 in pl.range(pl.const(0, pl.INT64), pld.system.world_size(), pl.const(1, pl.INT64)):
pl.builtin.tensor.allreduce(
data, signal, op=0, dtype=pl.FP32, core_num=1,
attrs={"op": 0, "dtype": pl.FP32, "core_num": 1, "device": r_1,
"arg_directions": [pl.adir.inout, pl.adir.inout]},
)
The parser reads that spelling back (ast_parser._parse_builtin_op), so the
lowered dispatch survives print -> parse. It is a machine-only surface, scoped
to names actually registered under builtin., and it accepts only what the
printer can write: the device and arg_directions attrs are required, since
orchestration codegen reads both back behind internal checks. A hand-written
call omitting them is rejected as a user error at parse time rather than
surfacing as a compiler-bug diagnostic during codegen. Write the composite
pld.tensor.* form instead.
Note that a whole-program assert_structural_equal round-trip is still blocked
one pass upstream: MaterializeCommDomainScopes
synthesizes CommDomainScopeStmt (printed as a leading comment) and the
WindowBuffer back-references on DistributedTensorType (not printed), and
neither has a DSL surface to parse back.
Checks¶
The pass requires both args to be materialized DistributedTensorType views in
the same CommDomainScopeStmt. The host allreduce builtin supports
ReduceOp.Sum, Max, Min, and Prod over FP16 or FP32 data and arbitrary
positive element counts. It processes 256-element chunks and rounds ragged FP16
and FP32 load spans to 32 bytes without changing the logical tensor shape.
Its INT32 signal tensor may be rank-1 [world_size] or rank-2
[world_size, signal_stride], with enough static capacity when the
participating device count is statically known. Because the signal is produced
by pld.window, it is packed by construction and the builtin indexes it as a
flat row-major array.
Mesh allreduce takes one signal lane per launched AIV block: a rank-1 signal is
valid only when core_num == 1, and a rank-2 signal needs a constant
signal_stride >= core_num (a wider stride is accepted, so an explicit signal
may carry spare lanes). core_num must also fit the configured backend's AIV
core count — the builtin is submitted as a standalone AIV kernel with
require_sync_start, so an over-subscribed launch could never be admitted and
would hang instead of failing. The bound is skipped when no backend is
configured (pure-IR tests). Multicore is mesh-only: mode="ring" requires
core_num == 1.
Ring allreduce (mode="ring") uses a rank-2 signal shaped
[2 * (NR - 1) + 1, NR], whose shape[0] must equal 2 * (NR - 1) + 1 when both
signal dimensions are compile-time constants, and must be at least
2 * (NR - 1) + 1 when only shape[0] is statically known (no static check when
both dims are dynamic). When the participating device count is statically known, the signal
must have enough static capacity. Ring allreduce partitions each rank's src into NR balanced,
potentially ragged chunks at runtime — chunk r spans [floor(numel*r/NR), floor(numel*(r+1)/NR)) — so
numel(src) need not be divisible by NR and the host-ring src shape need not be statically
known. Every TPUT transfer is narrowed to its chunk's exact extent via the staging tile's valid
shape, so ragged and dynamic inputs are fully supported.
Ring allreduce currently supports only ReduceOp.Sum with dtype=FP32.
ReduceOp.Max, ReduceOp.Min, ReduceOp.Prod, and FP16 are not yet available
with mode="ring". Ring allreduce also supports at most 16 participating
devices (world_size <= 16).
The builtin.tensor.allreduce_ring kernel is push-based: data movement uses
pto::comm::TPUT (remote write) — the reduce-scatter phase accumulates into the
right neighbour's slot via TPUT<AtomicAdd>, and the allgather phase forwards
each finalized chunk with a non-atomic TPUT, mirroring the in-tree allgather
/ all_to_all host builtins. Ordering is pipe_barrier(PIPE_ALL) around each
transfer plus dsb(DSB_DDR) before every TNOTIFY (not
pto.fence.barrier_all, which does not drain the MTE DMA pipe). Cross-rank
synchronisation uses the O(1) NeighborBarrier (notify/wait the two ring
neighbours only) — safe on NPU because the TPUT write pipeline orders the data
ahead of the signal, which the old pull model (TLOAD/TSTORE) did not.
The ring kernel is self-clearing: after the final barrier its epilogue
restores every used barrier row to zero (a local TNOTIFY(-1, AtomicAdd) on
each credited cell — the two neighbour cells per round for NeighborBarrier,
all P−1 cells for the RoundBarrier fallback), so a single signal buffer can
be reused across back-to-back calls exactly like the other host builtins
(#2279). For nranks == 2 both neighbours collapse onto one peer, so that
single credited cell carries two +1s and is restored with two −1s.
All window operands of a HOST collective — data and signal alike — must
resolve to pairwise distinct WindowBuffer allocations. Two pld.window()
views over the same alloc_window_buffer are a cross-process data race under
in-kernel TPUT/notify: data-vs-data is a reduce overwrite, data-vs-control
races a notify/count write against a kernel read, and control-vs-control races
a notify against a count publish. LowerHostTensorCollectives rejects any
aliasing pair before emitting the builtin dispatch.
broadcast's root kwarg is additionally bounds-checked when the
participating device count is statically known: on an explicit static device
subset it must satisfy root < participating device count. The fully-dynamic
"all device" domain cannot be checked at compile time (no device count is
known there) — the same documented limitation as the signal-capacity check.
Signals are reusable for the self-clearing host builtins: those kernels clear their barrier cells after every call (credit-barrier epilogue), so one synthesized or user-allocated signal can back any number of consecutive or loop-carried collective calls without re-allocation.
all_to_all_v's single-use Set(1)/wait≥1 signal cannot be reused across a
for/while loop in host_orch — MaterializeCommDomainScopes,
which runs immediately before this pass, rejects that case up front (the same
restriction LowerCompositeOps enforces on the InCore path). On an explicit
static device subset, all_to_all_v's signal shape[0] must exactly equal
the subset size (not merely >=, as required for the other collectives),
since MAX_RECV is derived as target.shape[0] / signal.shape[0] and an
over-provisioned signal would silently mis-lower.
Pass properties¶
| Field | Value |
|---|---|
required |
{IRProperty::CommDomainScopesMaterialized} |
produced |
{IRProperty::CommDomainScopesMaterialized} |
invalidated |
{} |