Repository navigation
Triggered SDMA Plans #573
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ariscript
wants to merge
6
commits into
ROCm:main
Choose a base branch
from
ariscript:arprakas/triggered-sdma
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,443
−0
Open
Triggered SDMA Plans #573
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
52fde4b
Add golden-byte tests for the SDMA copy, poll and NOP emitters
ariscript a0058cf
Add triggered SDMA plans (iris.triggered)
ariscript 602acfc
Document triggered SDMA
ariscript d1d3936
Use SHA-256 for the plan's schedule digest
ariscript 7138741
Document that arrived/wait/reusable must be called uniformly per CTA
ariscript 5f3200f
Refuse plans whose buffer or gates differ in heap offset across ranks
ariscript File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| # Triggered SDMA | ||
|
|
||
| Triggered SDMA moves data that a running kernel produces without spending compute units on the | ||
| transfer. The host pre-arms copy-engine (SDMA) chains that wait on *gates*; the kernel opens a | ||
| gate when its data is written, and the copy engine copies the data to every peer and signals its | ||
| arrival there. | ||
|
|
||
| ``` | ||
| kernel writes a batch ──► publish(batch) opens the gate | ||
| │ | ||
| copy engine: POLL_REGMEM(ready >= epoch) ─► COPY × segments ─► ATOMIC(peer arrival += 1) | ||
| │ | ||
| peer kernel: wait(batch) ◄────┘ | ||
| ``` | ||
|
|
||
| The API is `iris.triggered`; see the [reference](../reference/host/triggered.md). | ||
|
|
||
| ## Model | ||
|
|
||
| - A **segment** is one copy: a byte range, or a 2D sub-window (rows of a strided tensor). It is | ||
| written at the same offset in each peer's buffer. | ||
| - A **batch** is an ordered group of segments behind one gate, with one arrival signal per peer. | ||
| Batching amortizes the poll and the signal over many copies. | ||
| - A batch has **contributors**: the number of `publish` calls that complete it each epoch. One for | ||
| a batch written by a single CTA; the number of CTAs for a batch they write together. | ||
| - A **schedule** lists a rank's batches in the order its producer completes them. The plan arms | ||
| them in that order on each peer's queue. | ||
|
|
||
| `iris.triggered.schedule` builds schedules for common producers: `whole()` (one batch, published | ||
| once per program at the end), `chunks()` (row blocks completed in order) and `waves()` (one batch | ||
| per wave of a persistent tiled kernel, such as a GEMM). Any list of batches works. | ||
|
|
||
| ```python | ||
| from iris.triggered import TriggeredPlan, TriggeredView, waves | ||
|
|
||
| ctx = iris.iris(heap_size, allocator_type="vmem_chunked") | ||
| out = ctx.zeros(world_size * M, N, dtype=torch.bfloat16) | ||
| schedules = [waves(out[r * M : (r + 1) * M], (BM, BN), num_programs, base=out) for r in range(world_size)] | ||
| plan = TriggeredPlan(ctx, out, schedules) # collective: allocates gates, arms epoch 1 | ||
|
|
||
| for step in range(steps): | ||
| view = plan.begin_device_epoch() | ||
| producer[(num_programs,)](..., view) # TriggeredView.initialize(view).publish(batch) | ||
| plan.end_device_epoch() # every batch arrived and drained | ||
| ... | ||
| plan.next_epoch() # barrier, re-arm, barrier | ||
| plan.destroy() | ||
| ``` | ||
|
|
||
| ## The kernel author's contract | ||
|
|
||
| 1. **Batches complete in schedule order.** All of a rank's chains to one peer share one queue and | ||
| run strictly in arming order, so a batch completed early waits for every batch armed before it. | ||
| Out-of-order completion is still correct, only slower. A persistent kernel whose programs each | ||
| work through their tiles in order completes waves in order by construction. | ||
| 2. **Each batch gets exactly `contributors` publishes per epoch,** each after the caller's writes | ||
| to that batch. `publish` must be called by the whole CTA, outside divergent branches. | ||
| 3. **A producer never waits on its own plan's arrivals before it has published everything it | ||
| contributes to.** Its wait could depend on a chain parked behind one of its own unpublished | ||
| batches. | ||
| 4. **Segment sizes are fixed when the plan is created.** | ||
| 5. **`arrived`, `wait` and `reusable` are called uniformly across the CTA:** every wave makes the | ||
| same number of calls. Each is one atomic whose result reaches the other waves through a CTA | ||
| barrier, so a polling loop must not exit on a per-wave condition (such as a clock read): | ||
| waves would read the result before it is written, or deadlock. Count polls instead. | ||
|
|
||
| Plans that share queues must begin, and be aborted, in the order they were armed. While chains | ||
| are armed on a queue, host `Iris.put()` and `quiet()` to that peer raise rather than queue behind | ||
| them. | ||
|
|
||
| ## Memory ordering | ||
|
|
||
| Measured on 8× MI350X (gfx950) and 8× MI300X (gfx942). | ||
|
|
||
| **Producer.** Copy engines read memory behind the GPU's L2, so a batch's data must be written | ||
| back before its gate opens. A release fence only covers the issuing wave, so every wave of the | ||
| CTA must first complete its stores. `publish` does this: | ||
|
|
||
| ```python | ||
| wait_cnt() # s_waitcnt vmcnt(0): this wave's stores are done | ||
| tl.debug_barrier() # every wave's stores are done | ||
| tl.atomic_add(counter, 1, sem="acq_rel", scope="sys") # the last contributor then: | ||
| tl.atomic_xchg(ready, epoch, sem="release", scope="sys") | ||
| ``` | ||
|
|
||
| | Release recipe | Stale data at the peer (256 KiB and 4 MiB) | | ||
| | ----------------------------------------------- | ------------------------------------------ | | ||
| | `wait_cnt` + barrier + release (`gpu` or `sys`) | none in 350 epochs | | ||
| | release without the barrier | 80–100% of epochs | | ||
| | `.wt` store of the gate | every element, every epoch | | ||
| | plain store | none, but the gate stays in L2 until kernel exit | | ||
|
|
||
| **Consumer.** `arrived` / `wait` read the arrival gate with an acquire at system scope; data read | ||
| after it in the same kernel is fresh (no stale elements over 9,600 tile-epochs). | ||
|
|
||
| ## Gates and epochs | ||
|
|
||
| - Each batch has a 64-byte gate row: `ready`, `arrival`, `completion` (64-bit), `error`, and a | ||
| contributor counter. The copy engine polls the low 32 bits of `ready` with `>=`. | ||
| - Epochs start at 1 and add 1 per `next_epoch()`. Gates are never reset, so a plan is limited to | ||
| 2³² − 1 epochs. | ||
| - `end_device_epoch()` checks completion with a kernel: this rank's batches published and arrived | ||
| at every peer (each chain ends with that signal), and every other batch arrived here. | ||
|
|
||
| ## Requirements and limits | ||
|
|
||
| - **Stable peer mappings.** Armed chains hold peer addresses, so the heap must not re-map peers | ||
| while chains are armed. Create the context with `allocator_type="vmem_chunked"` (or `"vmem"`): | ||
| both keep peer addresses fixed, and the chunked allocator maps new chunks into each peer's | ||
| reserved range as the heap grows. The default torch allocator re-maps peer heaps on every | ||
| allocation and is rejected. | ||
| - **One queue per peer.** Plans use the host-initiated copy-engine queue to each peer, shared with | ||
| host `Iris.put()`. Kernel-initiated copies (`put(..., use_copy_engine=True)`) use a separate | ||
| queue and are unaffected. | ||
| - **Ring capacity.** Armed chains may use at most half of each queue's 8 MiB ring. | ||
| - **Single node only.** | ||
| - **Linear segments are at most 1 GiB** (SDMA 4.4's count field is 30 bits). The schedule builders | ||
| split larger ranges. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # Triggered SDMA | ||
|
|
||
| Pre-armed copy-engine transfers released by kernels. See | ||
| [Triggered SDMA](../../conceptual/triggered-sdma.md) for the model and its ordering rules. | ||
|
|
||
| ## TriggeredPlan | ||
| ```{eval-rst} | ||
| .. autoclass:: iris.triggered.TriggeredPlan | ||
| :members: begin_device_epoch, end_device_epoch, next_epoch, run, abort, destroy, batch_id | ||
| ``` | ||
|
|
||
| ## Schedules | ||
| ```{eval-rst} | ||
| .. autoclass:: iris.triggered.Segment | ||
| .. autoclass:: iris.triggered.Batch | ||
| .. autofunction:: iris.triggered.whole | ||
| .. autofunction:: iris.triggered.chunks | ||
| .. autofunction:: iris.triggered.waves | ||
| ``` | ||
|
|
||
| ## TriggeredView (device) | ||
| ```{eval-rst} | ||
| .. autoclass:: iris.triggered.TriggeredView | ||
| :members: initialize, publish, arrived, wait, reusable, can_publish, can_consume | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| # SPDX-License-Identifier: MIT | ||
| # Copyright (c) 2026 Advanced Micro Devices, Inc. All rights reserved. | ||
|
|
||
| """ | ||
| Device side of triggered SDMA plans (see ``iris.triggered``). | ||
|
|
||
| The host arms one chain per (batch, peer) on the copy engine: poll the batch's ``ready`` gate, | ||
| copy the batch's segments to the peer, then add 1 to the batch's ``arrival`` gate on the peer. | ||
| A kernel opens the gate with ``publish`` and observes arrivals with ``arrived`` / ``wait``. | ||
|
|
||
| View layout (int64 words): an 8-word header, then one 4-word record per batch. Gate table: one | ||
| 64-byte row per batch. | ||
| """ | ||
|
|
||
| import triton | ||
| import triton.language as tl | ||
| from triton.language.core import _aggregate as aggregate | ||
|
|
||
| from iris.mem.utils import wait_cnt | ||
|
|
||
| ABI_VERSION = 1 | ||
|
|
||
| # Header words | ||
| HEADER_WORDS = tl.constexpr(8) | ||
| H_VERSION = tl.constexpr(0) | ||
| H_NUM_BATCHES = tl.constexpr(1) | ||
| H_RANK = tl.constexpr(2) | ||
| H_EPOCH = tl.constexpr(3) | ||
| H_GATES = tl.constexpr(4) | ||
| H_WORLD_SIZE = tl.constexpr(5) | ||
|
|
||
| # Batch record words | ||
| RECORD_WORDS = tl.constexpr(4) | ||
| R_ROLE = tl.constexpr(0) | ||
| R_CONTRIBUTORS = tl.constexpr(1) | ||
| R_OWNER = tl.constexpr(2) | ||
|
|
||
| ROLE_PUBLISH = tl.constexpr(1) | ||
| ROLE_CONSUME = tl.constexpr(2) | ||
|
|
||
| # Gate row words | ||
| GATE_WORDS = tl.constexpr(8) | ||
| G_READY = tl.constexpr(0) | ||
| G_ARRIVAL = tl.constexpr(1) | ||
| G_COMPLETION = tl.constexpr(2) | ||
| G_ERROR = tl.constexpr(3) | ||
| G_COUNTER = tl.constexpr(4) | ||
|
|
||
|
|
||
| @aggregate | ||
| class TriggeredView: | ||
| """ | ||
| Device handle for one epoch of a triggered plan. | ||
|
|
||
| Usage:: | ||
|
|
||
| @triton.jit | ||
| def producer(out, view, ...): | ||
| tv = TriggeredView.initialize(view) | ||
| ... # write this program's part of batch b | ||
| tv.publish(b) # whole CTA, after its writes | ||
|
|
||
| @triton.jit | ||
| def consumer(out, view, ...): | ||
| tv = TriggeredView.initialize(view) | ||
| tv.wait(b) # batch b's data is now visible | ||
| x = tl.load(out + ...) | ||
| """ | ||
|
|
||
| view: tl.tensor | ||
| gates: tl.tensor | ||
| epoch: tl.tensor | ||
| rank: tl.tensor | ||
|
|
||
| @triton.constexpr_function | ||
| def __init__(self, view, gates, epoch, rank): | ||
| self.view = view | ||
| self.gates = gates | ||
| self.epoch = epoch | ||
| self.rank = rank | ||
|
|
||
| @staticmethod | ||
| @triton.jit | ||
| def initialize(view): | ||
| """Read the plan's view tensor (from ``TriggeredPlan.begin_device_epoch()``).""" | ||
| gates = tl.cast(tl.load(view + H_GATES), tl.pointer_type(tl.int64)) | ||
| return TriggeredView(view, gates, tl.load(view + H_EPOCH), tl.load(view + H_RANK)) | ||
|
|
||
| @triton.jit | ||
| def _record(self, batch, word): | ||
| return tl.load(self.view + HEADER_WORDS + batch * RECORD_WORDS + word) | ||
|
|
||
| @triton.jit | ||
| def can_publish(self, batch): | ||
| return (self._record(batch, R_ROLE) & ROLE_PUBLISH) != 0 | ||
|
|
||
| @triton.jit | ||
| def can_consume(self, batch): | ||
| return (self._record(batch, R_ROLE) & ROLE_CONSUME) != 0 | ||
|
|
||
| @triton.jit | ||
| def publish(self, batch): | ||
| """ | ||
| Contribute to batch ``batch``; the last of its contributors opens the gate. | ||
|
|
||
| Call from the whole CTA, outside divergent branches, after the CTA's writes to the batch. | ||
| """ | ||
| # Copy engines read behind L2: every wave's stores must land before the release writes back | ||
| wait_cnt() | ||
| tl.debug_barrier() | ||
| gate = self.gates + batch * GATE_WORDS | ||
| contributors = self._record(batch, R_CONTRIBUTORS) | ||
| ticket = tl.atomic_add(gate + G_COUNTER, 1, sem="acq_rel", scope="sys") | ||
| if ticket % contributors == contributors - 1: | ||
| tl.atomic_xchg(gate + G_READY, self.epoch, sem="release", scope="sys") | ||
|
|
||
| @triton.jit | ||
| def arrived(self, batch): | ||
| """ | ||
| True once batch ``batch`` is visible here this epoch (published, for this rank's own). | ||
|
|
||
| Call uniformly across the CTA: the result reaches the other waves through a CTA barrier, so | ||
| every wave must make the same number of calls (true of ``wait`` and ``reusable`` too). | ||
| """ | ||
| gate = self.gates + batch * GATE_WORDS | ||
| word = tl.where(self._record(batch, R_OWNER) == self.rank, G_READY, G_ARRIVAL) | ||
| return tl.atomic_add(gate + word, 0, sem="acquire", scope="sys") >= self.epoch | ||
|
|
||
| @triton.jit | ||
| def wait(self, batch): | ||
| """Spin until ``arrived(batch)``.""" | ||
| while self.arrived(batch) == 0: | ||
| pass | ||
|
|
||
| @triton.jit | ||
| def reusable(self, batch): | ||
| """True once batch ``batch`` is complete this epoch (set by ``end_device_epoch()``).""" | ||
| gate = self.gates + batch * GATE_WORDS | ||
| return tl.atomic_add(gate + G_COMPLETION, 0, sem="acquire", scope="sys") >= self.epoch |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to think a bit more about the naming/new concepts introduced here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this was mostly intended as a prototype to check if this is going in the right direction and pivot if necessary.