Repository navigation
Conversation
5f88cc7 to
6b6d871
Compare
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
2 open issues
What changed in this PR
This PR reduces per-round host overhead for TriggeredPlan by preparing the next round early and making round transitions/waits stream-ordered, while improving queue accounting to support multiple prepared rounds safely.
Changes:
- Add
prepare_next()and stream-orderedwait(blocking=False)/next_round()behavior with deferred error reporting. - Introduce per-plan queue “holds” and per-round queue ledger tracking to avoid repeated capacity checks and barriers.
- Rework copy-chain arming to build chains once and re-arm them each round with updated thresholds.
| File | Description |
|---|---|
| tests/unittests/test_queue_ledger.py | Adds unit coverage for holds, per-round ordering, and release semantics in the queue ledger. |
| tests/unittests/test_plan.py | Adds new multi-round/stream-ordered tests for prepare_next(), deferred errors, and close behavior. |
| iris/host/queue_ledger.py | Implements held-capacity accounting and per-round ordering checks for prepared copies. |
| iris/experimental/plan/triggered.py | Adds overlapping-round API and stream-ordered barriers/waits; defers errors to later blocking operations/close. |
| iris/experimental/plan/kernels.py | Adds GPU-side error recording for stream-ordered waits and replaces budgeted polling with time-based polling. |
| iris/experimental/plan/armer.py | Introduces Chain objects to reuse per-chain fixed arguments and re-arm each round with a new threshold. |
| docs/reference/host/plan.md | Exposes prepare_next in docs for TriggeredPlan. |
| docs/conceptual/triggered-plans.md | Documents overlapping rounds and updated semantics for device-barrier round transitions and deferred errors. |
Review effort: Lite
c378346 to
a9a1655
Compare
a9a1655 to
c852648
Compare
nirvedhmeshram
left a comment
There was a problem hiding this comment.
I will review more thoroughly in a bit, just leaving one comment that I noticed while reviewing #616
c852648 to
3745d95
Compare
nirvedhmeshram
left a comment
There was a problem hiding this comment.
Thanks, this is great progress towards using triggered plans performantly.
| self._prepare_next() | ||
| # The round's gates are open (its producer is queued), so its copies drain on their own | ||
| self._ledger.release(self, self.round) | ||
| self.ctx.device_barrier() |
There was a problem hiding this comment.
next_round() now fences with ctx.device_barrier(), and that kernel has no real timeout: the spin loop in _device_barrier_kernel only has a tl.device_assert on MAX_SPINS, which is compiled out unless debug is on, so it spins forever. Before this PR the fence was a host torch.distributed barrier, which at least has a process-group timeout.
Concrete case: rank 0 raises anywhere between wait() and next_round() (a TimeoutError from a blocking wait that only rank 0 hit near the deadline, or any exception in the user's consumer code). The with block sends rank 0 into close(). Rank 1 has already called next_round(), so its stream now holds a device barrier that rank 0 will never enter. Rank 1 then calls start() and wait(), and wait() immediately does self._error.tolist(), which syncs the stream and blocks forever behind the spinning barrier. Rank 0 sits in close() at ctx.barrier() waiting for rank 1. The job hangs with a kernel spinning on rank 1's GPU. It also leaves the pairwise device-barrier counters off by one, so every later ctx.device_barrier() in the process is out of step.
Could the plan use its own barrier kernel bounded by the plan timeout (read_realtime, same as wait_kernel) that records TIMED_OUT into self._error and exits, instead of ctx.device_barrier()? Then the next blocking wait() or close() would raise instead of hanging, and the shared barrier counters would not be touched.
There was a problem hiding this comment.
Agreed, fixed. next_round() no longer calls ctx.device_barrier(). The plan has its own fence
kernel with one counter per peer, kept in the plan's gates. Each rank adds 1 to its counter on every
peer, then waits up to the plan's timeout (read_realtime, as in wait_kernel) for every peer's
counter to reach the round. A peer that never arrives is recorded in the error word as
TimeoutError("round r: rank i did not reach next_round()"), which the next blocking wait() or
close() raises. Because the counters belong to the plan, a fence that times out leaves no shared
barrier out of step. A rank whose plan already holds an error skips the fence, so its peers record a
timeout instead of waiting forever. test_round_fence_times_out covers your case: one rank leaves
before next_round(), the other rank's next wait() raises, and ctx.device_barrier() still
works afterwards.
While testing this I found that read_realtime() could make these loops exit at once. On gfx9,
Triton's memrealtime() waits on vmcnt after s_memrealtime, which completes on lgkmcnt, so in the
fence kernel the next instruction read the clock register before the value had landed, and the fence
recorded a timeout in under a second. Triton fixed this in triton-lang/triton#12179, but no release
or Iris pin has it yet, so #624 works around it in Iris. Until it merges, this PR's CI can show
those false timeouts.
| if self.state is not State.CLOSING: | ||
| self._release_own() | ||
| if self.state is State.CLOSING and self._blocked(): | ||
| if self._blocked(): |
There was a problem hiding this comment.
When this plan is queued behind another plan that has not started, close() returns here with the plan CLOSING and never looks at self._error. The plan is later finished by the other plan's close() through _drain_closing and _mark_closed, but that path only raises the closing plan's own deferred error, so a timeout or wrong mark count recorded by this plan's stream-ordered wait() is silently lost. Could _drain_closing collect the deferred errors of the plans it finishes and have close() raise them too (or at least warn), or could the early return check self._error once the queue is flushed?
There was a problem hiding this comment.
Fixed. When close() finishes plans that were waiting as CLOSING, it now reads each one's error
word and warns with the error. It can't raise them, since they belong to other plans, and the call
that closes one plan shouldn't fail for another's error.
| """ | ||
| pid = tl.program_id(0) | ||
| cv = TriggeredView.initialize(view) | ||
| t0 = read_realtime() |
There was a problem hiding this comment.
In a loop that never synchronizes, once a chunk goes missing (a peer died, a dropped mark) every later round's wait_kernel also spins for the whole plan timeout, 30 s by default, and the host can't notice because it never syncs. 100 queued rounds means close to an hour of GPU time before close() reports the first error. Since the error word is sticky anyway, could the kernel load error[0] at entry and exit immediately (with status 0) when it is already non-zero? Then the stream reaches close() in roughly one timeout.
There was a problem hiding this comment.
Done. In stream-ordered mode the wait kernel checks the error word first, and if it is set, writes
status 0 and returns. Only the stream-ordered path does this: the flush in close() uses the same
kernel to wait for this rank's queues, and it has to wait whatever was recorded.
| also checks that each rank's schedule stays inside its own rows of `out` (by default it sends them as | ||
| one chunk, `whole()`). See `examples/34_sdma_all_gather`. | ||
|
|
||
| ### Overlapping rounds |
There was a problem hiding this comment.
The stream-ordered loop (prepare_next, wait(blocking=False), next_round on the device) is the main use case for this PR, but outside this docs snippet it only appears in tests, and nothing in the stack up to #618 adds an example. Could examples/34_sdma_all_gather get a variant that uses it, with a consumer kernel reading out instead of a host check and the results checked once after the loop? That would also give the host-time numbers in the description something reproducible to point at. Fine as a follow-up once the stack lands if that is easier.
There was a problem hiding this comment.
Added it in this PR rather than as a follow-up. examples/34_sdma_all_gather has a third part that
runs the same plan with prepare_ahead=True: prepare_next() after the producer,
wait(blocking=False), a consumer kernel that adds each round's output into a float64 sum, then
next_round(). The sum is checked once after the loop, and rank 0 prints the host time per round
(excluding round 1, which compiles the kernels). On MI300X at 2 ranks with the defaults it printed
0.2 to 0.4 ms per round. The table in the description, from a longer benchmark, is updated too.
…the device - prepare_next() prepares the next round on this rank while the current one runs; its copies wait behind gates no producer opens before every rank has passed next_round(). - next_round() is a device-side barrier on the current stream (ctx.device_barrier()), preparing first if prepare_next() was not called: no host synchronization, no allgather. - wait(blocking=False) queues the wait on the stream. A timeout or a wrong mark count is recorded on the GPU and raised by the next blocking wait() or by close(). - Each plan holds queue space for two rounds from creation (agreed collectively), so later rounds are prepared without involving the other ranks. A prepare that still fails is raised by next_round() after its barrier, so no rank is left waiting in it. - The ledger tracks prepared rounds individually; start() and close() order checks are per round. - Chains are built once and re-armed every round with a new threshold, instead of translating every region again. Host time per round (MI300X, tile_chunks of 64x64 tiles, 4096 regions per rank): 15.9 ms -> 2.1 ms at 2 ranks, 41.5 ms -> 2.1 ms at 4 ranks, mostly from re-arming built chains. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
test_stream_ordered_rounds now queues a 0.5 s kernel after a warm-up round and requires the host to get through the remaining rounds' plan calls well before it finishes. Without the queued round fill, start() waited for that kernel. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rank 0 must time out waiting for the other ranks' chunks before close() flushes them. Their flush is queued after their own stream-ordered wait, so give that wait a longer timeout than rank 0's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…message The own-marks wait-kernel test calls the kernel with this PR's arguments (error word, clock budget), and a blocking wait() now reports chunks "not complete", since it also covers this rank's own chunks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
3745d95 to
3dc195d
Compare
…ck budget The test queued a 0.5 s sleep and required the host to finish the remaining rounds in 0.25 s. At 8 ranks on a busy CI node that budget can fail with no plan call waiting for the GPU. A kernel now spins on a pinned host flag that the host sets after the loop; if any call had waited for the GPU, the hold gives up after 10 s instead of being released, and the test says so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1d89309 to
ef966da
Compare
… opt-in - next_round() fences with the plan's own counters instead of ctx.device_barrier(), waiting at most the plan's timeout and recording a peer that never arrived. A rank that cannot prepare the next round marks an abort on its peers in the fence, and their wait() for that round raises even if its flush has delivered old data. - prepare_next() needs TriggeredPlan(..., prepare_ahead=True); otherwise a plan holds one round of queue space, released before the next is prepared. - The error word keeps the first error, a stream-ordered wait returns at once once an error is recorded, a short mark count is a timeout as in a blocking wait(), and a recorded error is raised once. close() warns about errors of the plans it finishes for others. - A blocking wait() releases its round from the ledger, so host put() works between wait() and next_round(). Ledger holds are keyed by plan. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
prepare_all_gather() takes prepare_ahead. The example's third part runs the plan with prepare_next(), wait(blocking=False), a consumer kernel and next_round(), checks the results once after the loop, and prints the host time per round. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ef966da to
2eafdbe
Compare

What changes
prepare_next()prepares the next round on this rank while the current one runs. It needs a plancreated with
prepare_ahead=True, which holds queue space for two rounds instead of one. Itscopies wait behind gates that no producer can open before every rank has passed
next_round().next_round()is a round fence on the current stream, so the host never waits. The fence is akernel with counters of the plan's own: it waits at most the plan's timeout, and a peer that never
arrives is recorded as an error rather than hanging the stream.
next_round()prepares the nextround first if
prepare_next()wasn't called.wait(blocking=False)queues the wait on the stream. Errors are recorded on the GPU (the firstone wins) and raised once, by the next blocking
wait()or byclose():TimeoutErroras a blockingwait());Once an error is recorded, later stream-ordered waits return at once.
If preparing fails on one rank, that rank still takes part in the fence and marks an abort on its
peers. Their
wait()for that round then raises, even if the failed rank'sclose()has flushedold data to them; then the failed rank's
next_round()raises.The queue ledger tracks each prepared round separately, and
start()andclose()check order perround. A blocking
wait()releases its round, so hostput()works again beforenext_round().Chains are built once and re-armed every round with a new threshold, instead of translating and
wrapping every region again. Most of the host time saved comes from this.
prepare_all_gathertakesprepare_ahead, andexamples/34_sdma_all_gathergains astream-ordered loop (
prepare_next(),wait(blocking=False), a consumer kernel,next_round()),checked once after the loop. It prints the host time per round.
Host time per round (MI300X,
tile_chunksof 64×64 tiles, 304 tiles per chunk, 4096 tiles perrank; the producer only opens the gates):
wait()prepare_next()Wall time per round over 100 rounds, re-measured after the review changes (the #616 column is from
before them).
Testing (MI300X, torch allocator)
test_plan.pyat 2 and 4 ranks. New tests:prepare_next()beforewait();prepare_aheadbeing opt-in;put()betweenwait()andnext_round();signal failing.