Primitives¶
Most users call pld.tensor.* collectives directly — reach for these
lower-level primitives only when building a custom protocol.
Note: the notify/wait, put/get, and remote-load/store code blocks below are illustrative sketches — they omit
nranks/my_rankderivation and buffer setup and are not meant to run as-is. For runnable versions, see Runnable Examples below.
Types and Enums¶
| Name | Values | Description |
|---|---|---|
NotifyOp |
AtomicAdd, Set |
Signal deposit mode. AtomicAdd: atomically increment the peer's signal slot (use for multi-rank barriers). Set: overwrite the peer's signal slot (use for 1:1 handshakes). |
WaitCmp |
Eq, Ge |
Wait predicate. Eq: block until signal slot equals expected value. Ge: block until signal slot >= expected value. |
ReduceOp |
Sum, Max, Min, Prod |
Reduction operator for collective operations. Support is per-operation: allreduce accepts all four; reduce_scatter accepts all four on the InCore rail, while the HOST builtin rail currently lowers only Sum. |
AtomicType |
None_, Add |
Remote-store combine mode. None_: plain store. Add: atomically accumulate into peer's destination — requires an fp32/bf16/fp16/int32/int16/int8 destination, and a bf16 destination requires the Ascend910B (A2/A3) profile. |
DistributedTensor |
— | A tensor view bound to a comm-domain window buffer. Every collective and RMA op requires this type on the window side. |
CommCtx |
— | Communication context handle. Produced by get_comm_ctx(); consumed by rank() and nranks(). |
System Substrate (pld.system.*)¶
These are the lowest-level distributed primitives. Scope varies per op —
world_size is host-only; get_comm_ctx works in both host orchestrator and
InCore kernel code; rank, nranks, notify, wait, and defer_wait have codegen
support only in InCore kernel code (there is no host-orchestrator lowering
for them).
| Name | Signature | Description |
|---|---|---|
world_size |
() -> Scalar |
Host-only. Number of ranks in the distributed execution. Returns INT64. |
get_comm_ctx |
(dist_tensor: DT) -> Ctx |
Lift a DistributedTensor to its CommCtx handle. Works in host orchestrator and InCore code. The verifier rejects plain pl.Tensor. |
rank |
(ctx: Ctx) -> Scalar |
InCore-only. Local rank index (INT32). Lowers to a load of CommContext::rankId. |
nranks |
(ctx: Ctx) -> Scalar |
InCore-only. Number of ranks in this comm group (INT32). Lowers to a load of CommContext::rankNum. |
notify |
(target: DT, peer: IntLike, offsets: Sequence[IntLike], value: IntLike, *, op: NotifyOp) -> Call |
InCore-only. Cross-rank signal deposit. Side-effect-only — no return value. Lowers to TNOTIFY. |
wait |
(signal: DT, offsets: Sequence[IntLike], expected: IntLike, *, cmp: WaitCmp) -> Call |
InCore-only. Cross-rank wait. Side-effect-only — blocks until the local signal slot satisfies cmp(expected). Lowers to TWAIT. |
defer_wait |
(signal: DT, offsets: Sequence[IntLike], expected: IntLike, *, cmp: WaitCmp) -> Call |
Dedicated top-level pl.at(CORE_GROUP) waiter only. Registers signal[offsets] >= expected as a completion condition and returns without spinning the AIV. The enclosing task's TaskId remains incomplete until the condition is ready. |
Window Buffer Management (pld.tensor.*)¶
window and alloc_window_buffer live in pld.tensor.*, not pld.system.*,
even though they are as foundational as the substrate above.
| Name | Signature | Description |
|---|---|---|
window |
(buf: Ptr, shape: Sequence[IntLike], *, dtype: DataType) -> DT |
Materialise a window-buffer Ptr as a DistributedTensor view. buf comes from alloc_window_buffer. |
alloc_window_buffer |
(size: IntLike, *, name: str = "") -> Ptr |
Allocate a per-rank HCCL window buffer. Size is in bytes. The name kwarg is injected by the parser from the LHS assignment — never pass it explicitly. |
alloc_window_buffer |
(shape: Sequence[IntLike], *, dtype: DataType, name: str = "") -> Ptr |
Convenience overload. size = prod(shape) x dtype.get_byte() computed automatically. |
Notify & Wait: The Signal Handshake¶
The lowest-level synchronisation primitive. Each rank writes to a peer's signal cell, then blocks until its own cell has been written.
@pl.jit.incore
def handshake_step(
out: pl.Out[pl.Tensor[[1, 1], pl.INT32]],
signal: pl.InOut[pld.DistributedTensor[[1, 1], pl.INT32]],
peer: pl.Scalar[pl.INT32],
tag: pl.Scalar[pl.INT32],
) -> pl.Tensor[[1, 1], pl.INT32]:
# 1. Write our tag into the peer's signal cell.
pld.system.notify(
signal, peer=peer, offsets=[0, 0],
value=tag, op=pld.NotifyOp.Set,
)
# 2. Wait until our own cell has been written.
pld.system.wait(
signal=signal, offsets=[0, 0],
expected=1, cmp=pld.WaitCmp.Ge,
)
# 3. Read the received tag back out.
received = pl.read(signal, [0, 0])
pl.write(out, [0, 0], received)
return out
The
waitusesGewithexpected=1, which means the peer'stagmust be >= 1. Passingtag=0will cause a permanent hang.
notify in a mixed cube+vector kernel¶
Everything above assumes one notify runs once. In a kernel that mixes
pl.matmul with comm ops, that is not automatic, and nothing diagnoses it:
- Outside a
pl.split_aivregion, the notify has no declared core, so the compiler emits it on the cube lane and the vector lane. Put comm phases inside a region and the compiler keeps them off the cube lane. - Inside a
mode=NONEregion, the body runs on both AIV sub-lanes, so a notify still fires twice unless you shard it byaiv_idor guard it to one lane.
Both rules, the failure they prevent, and the ordering obligation the guarded form carries are in Scopes → pl.split_aiv. Read that before writing a notify next to cube work.
Choosing NotifyOp and WaitCmp¶
| Scenario | NotifyOp | WaitCmp | Why |
|---|---|---|---|
| 1:1 exchange (one writer per slot) | Set |
Eq or Ge |
Atomic increment not needed — overwrite is clear and fast. |
| N-to-1 barrier (many writers, one slot) | AtomicAdd |
Ge |
Every writer atomically adds its contribution. The sum increments monotonically; wait for the expected total. |
| Multi-round protocol | AtomicAdd |
Ge |
The counter advances across rounds without reset — each round uses a fresh row or the caller re-allocates the buffer. |
Expected output for 2 ranks: rank 0 writes tag=2, waits for tag 1 from rank 1:
outputs[0] == 1. Rank 1 writes tag=1, waits for tag 2 from rank 0:
outputs[1] == 2. Result: outputs == [[1], [2]].
Buffer re-use safety: Signal cells are zero-initialised by
alloc_window_buffer. Afternotify, the signal cell holds the written value; afterwaitreturns, the caller has observed the barrier. These tile-levelnotify/waitprimitives use monotonic counters that do not self-reset — allocate a fresh buffer per call. Thepld.tensor.*collectives are the exception: their signal buffers are self-clearing and reusable across back-to-back calls.
Deferred Completion: Release the Core, Keep the Task Pending¶
pld.system.wait is a blocking TWAIT: the AIV stays in the kernel and the
statements after the wait resume on that same AIV. pld.system.defer_wait has
a different contract. It registers a counter condition with the runtime and
returns; when the dedicated waiter kernel ends, its physical AIV is free, but
the waiter's logical TaskId is still incomplete. The scheduler resolves that
TaskId only after every registered condition is satisfied. The kernel is never
resumed, so continuation work must be a separate task.
# Each rank's publisher is independent: publish payload first, then the signal.
with pl.at(level=pl.Level.CORE_GROUP, name_hint="publish"):
pld.tensor.remote_store(payload_value, peer_payload, peer, [0, 0])
pld.system.notify(
signal, peer=peer, offsets=[my_rank, 0],
value=epoch, op=pld.NotifyOp.Set,
)
# Receiver: observe the peer publisher. There is deliberately no local
# publisher -> waiter dependency; add deps only for real local ordering.
with pl.at(
level=pl.Level.CORE_GROUP,
name_hint="payload_wait",
allow_early_resolve=False,
) as wait_tid:
pld.system.defer_wait(
signal, offsets=[peer, 0], expected=epoch,
cmp=pld.WaitCmp.Ge,
)
# The consumer is not dispatched until wait_tid is logically complete.
with pl.at(
level=pl.Level.CORE_GROUP,
name_hint="consume_payload",
deps=[wait_tid],
) as consume_tid:
payload_tile = pl.load(peer_payload, [0, 0], [1, WIDTH])
# ... consume payload_tile ...
An inline SPMD consumer uses the captured form as well:
with pl.spmd(
NUM_BLOCKS,
name_hint="consume_payload_spmd",
deps=[wait_tid],
) as consume_tid:
block = pl.get_block_idx()
# ... each AIV block reads its payload partition ...
There is no second dependency namespace for deferred completion. deps keeps
its normal strict TaskId meaning: Simpler dynamically delays completion of the
ordinary waiter TaskId after its AIV retires, so the existing dependency edge
does not release the consumer until the registered counter is ready. The
standard Simpler AICore executor already invalidates the entire data cache
immediately after it picks up every task (before the optional speculative gate
and before that task's kernel reads its inputs). The waiter is not eligible for
early resolve, so its direct consumer is never pre-staged at that gate: it is
picked up through the normal path only after the counter-backed TaskId
completes. Its task-start invalidation therefore happens after readiness. On
the producer side, all payload writes must still
become visible before the notify is published; task-start invalidation
cannot repair a notify-before-data bug.
Deferred-wait contract¶
- Put
defer_waitin a dedicated, top-level taskwith pl.at(level=pl.Level.CORE_GROUP) as wait_tid:scope. This task-level launch lets PyPTO validate single-block execution and provide the runtimeAsyncCtx. An unmarked direct@pl.jit.incore/ AIV use is rejected because it bypasses those contracts; programmatically constructed internal IR is accepted only after PyPTO revalidates the complete waiter body and orchestration call site. - The waiter must be pure AIV, cannot have a dispatch predicate, and cannot use
allow_early_resolve=True. Pure scalar bookkeeping and control flow may run between registrations, buttensor.read, payload/cache operations, and other communication cannot continue after registration begins. This follows Simpler's own early-dispatch contract: leaving the waiter atFalsedisqualifies its direct consumers from being pre-staged before the counter-backed TaskId completes. A normal consumer may choose its ownallow_early_resolvevalue; that value governs pre-staging of the consumer's downstream tasks, not whether it may bypass the waiter. signalmust be a direct, window-bound INT32DistributedTensorparameter; slices, views, and other aliases are not supported. V1 accepts onlycmp=pld.WaitCmp.Ge.- Conditions use monotonic uint32 polling (
counter >= expected) over INT32 signal storage. Bothexpectedand every published counter value must stay nonnegative in[0, INT32_MAX]; for example, storing-1is observed asUINT32_MAXand would satisfy every valid threshold. Dynamic expected values are checked at runtime. Do not reset or move a counter backwards while an older generation can still be pending, and do not rely on uint32 wraparound. - One waiter task may register at most 64 conditions. Separately, one runtime scheduler may track at most 64 concurrently deferred tasks. These are two different limits and neither is a physical-core count.
- Connect continuation work with the same ordinary
deps=[..., wait_tid]accepted for any TaskId dependency. Deferred completion does not impose a separate consumer kernel kind or dependency representation. A terminal waiter with no continuation may submit that task scope fire-and-forget and need not capture a TaskId. - If a producer never advances the counter to
expected, the AIV is not spinning, but the TaskId and every dependent consumer remain pending. The protocol must still guarantee eventual notification.
This mechanism is not asynchronous prefetch: pl.prefetch.* manages an SDMA
data-movement session/event, whereas deferred completion gates a scheduler
TaskId on a remote counter. It is also not host asynchronous execution—there
is no Python future, host callback, or host thread waiting for the signal.
Legacy pld.system.wait remains unchanged for code that must resume in the same
kernel and still supports both Eq and Ge.
Tile-Level RMA (pld.tile.*)¶
Low-level cross-rank remote memory access. These are tile-level primitives used to build
collectives; most users call pld.tensor.* collectives instead.
| Name | Signature | Description |
|---|---|---|
remote_load |
(target: DT, peer: IntLike, offsets: Sequence[IntLike], shape: Sequence[IntLike], valid_shape=None) -> Tile |
Load a region of peer rank's DT into a local tile. shape defines the tile dimensions. valid_shape keeps the physical tile fixed-size while a ragged tail reads only real data. Offsets must match what the peer stored — a 1-element misalignment causes silent corruption. |
remote_store |
(src_tile: Tile, target: DT, peer: IntLike, offsets: Sequence[IntLike], *, atomic=AtomicType.None_) -> Call |
Write a local tile into peer rank's DT. Side-effect-only. atomic=Add accumulates into the peer's region instead of overwriting. The pushed region must fit inside target at offsets. |
remote_store also exists one IR level up as pld.tensor.remote_store
(src: Tensor, target: DT, peer: IntLike, offsets, *, atomic=...), for pushing a
computed value out of a tensor-level @pl.jit kernel (where there are no tiles
to name). It lowers 1:1 to the tile form, so the value reaches the peer as a single
remote write with no global-memory round-trip. The short form pld.remote_store
dispatches between the two on the operand you pass.
Put and Get (pld.tensor.*)¶
One-sided bulk transfer — rank A writes to or reads from rank B's window without rank B participating in the transfer (beyond the signal barrier).
Put (Write to Peer)¶
| Name | Signature | Mutation | Description |
|---|---|---|---|
put |
(dst: DT, peer: IntLike, src: DT \| Tensor, dst_offsets=None, src_offsets=None, shape=None, *, atomic=AtomicType.None_, chunk_rows=0, chunk_cols=0, pipeline=False) -> Call |
dst: InOut, src: In |
Write local src into peer rank's dst. dst must be window-bound; src may be plain Tensor. With no offsets/shape, writes the full local slice. atomic=Add accumulates instead of overwriting (hardware atomic-add dtypes only: fp32/bf16/fp16/int32/int16/int8; bf16 is Ascend910B-only). |
Get (Read from Peer)¶
| Name | Signature | Mutation | Description |
|---|---|---|---|
get |
(dst: DT \| Tensor, peer: IntLike, src: DT, dst_offsets=None, src_offsets=None, shape=None, *, chunk_rows=0, chunk_cols=0, pipeline=False) -> Call |
dst: Out, src: In |
Read peer rank's src into local dst. src must be window-bound; dst may be plain Tensor. |
Chunking and Pipelining Constraints¶
chunk_rows/chunk_cols (0 = full extent) shrink the staging tile so a
transfer larger than the on-chip staging budget still moves in one call,
sliding through the smaller stage automatically.
Fatal pitfall:
pipeline=Truerequires bothchunk_rows > 0andchunk_cols > 0— the double-buffering benefit only exists when the transfer is actually chunked. Passingpipeline=Truewith either chunk dimension left at0raises aValueErrorbefore dispatch.
A dynamic transfer extent (a runtime-sized shape, or a full-slice
transfer where dst/src's own dims are dynamic) must be bounded by a
matching static chunk: a dynamic innermost dimension requires chunk_cols
to be set, and a dynamic leading dimension requires chunk_rows to be set —
the staging tile is allocated statically and can't size itself from a
runtime value.
Writing Your Own Collective¶
Every built-in collective is a composition of lower-level primitives. The mesh allreduce is: stage-in -> barrier -> remote-accumulate -> stage-out.
The Barrier in Isolation¶
# signal: pld.DistributedTensor[[NR, 1], pl.INT32]
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,
)
With offsets=[my_rank, 0], each rank owns a dedicated row — cell [r, 0]
in every peer's window has exactly one writer, rank r itself, so Set
would work identically here. AtomicAdd is shown because this is the same
notify call every barrier in this doc uses; the "many writers, one slot"
case that actually requires AtomicAdd is a shared-cell barrier (see the
table above) — give every rank a distinct offset like this one only when you
need to distinguish which peers have arrived, not just whether everyone
has.
Remote Accumulate¶
acc = pl.load(data, [0, 0], [1, SIZE])
for peer in pl.range(nranks):
if peer != my_rank:
peer_tile = pld.tile.remote_load(
data, peer=peer, offsets=[0, 0], shape=[1, SIZE]
)
acc = pl.add(acc, peer_tile)
remote_load reads the peer's window slice into a local tile. The offset
and shape must match what the peer stored — a mismatch reads garbage.
2-Segment vs 3-Segment Namespace¶
Short form (pld.*) |
Full path |
|---|---|
pld.world_size() |
pld.system.world_size() |
pld.rank(ctx) |
pld.system.rank(ctx) |
pld.nranks(ctx) |
pld.system.nranks(ctx) |
pld.get_comm_ctx(dt) |
pld.system.get_comm_ctx(dt) |
pld.alloc_window_buffer(...) |
pld.tensor.alloc_window_buffer(...) |
pld.window(...) |
pld.tensor.window(...) |
pld.remote_load(...) |
pld.tile.remote_load(...) |
pld.remote_store(...) |
pld.tile.remote_store(...) / pld.tensor.remote_store(...) (dispatches on src) |
No short form: pld.notify(...), pld.wait(...), pld.defer_wait(...), pld.put(...),
pld.get(...), pld.allreduce(...), and all other collective ops — these
require the full 3-segment namespace.
Runnable Examples¶
The tutorials teach each primitive by hand before any builtin is revealed (all steps 03–16 ship):
| Primitive | Tutorial step |
|---|---|
| window buffer | 08-window_buffer (step 03) |
| notify / wait | 09-barrier (step 04) |
| remote_load / remote_store | 10-remote_load_store (step 05) |
| put / get | 11-put_get (step 06) |
| Primitive | Test |
|---|---|
| notify / wait | test_l3_notify_wait.py |
| put / get | test_l3_put.py / test_l3_get.py |
| remote_store | test_l3_remote_store.py |
(paths relative to tests/st/distributed/)
See Also¶
- 01-collectives — The collectives built on these primitives
- 03-execution — DistributedWorker lifecycle and environment setup
- 04-debugging — Common failure patterns