Skip to content

Don't wait for work that an event already ordered - #3328

Merged
maleadt merged 2 commits into
tb/device-handofffrom
tb/event-ownership
Oct 8, 2026
Merged

maleadt merged 2 commits into
tb/device-handofffrom
tb/event-ownership

Conversation

@maleadt

@maleadt maleadt commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

When memory moves between streams, CUDA.jl makes the new stream wait for everything the previous stream had queued. That's more than needed when the program already ordered the work with an event. KernelAbstractions.@spawn is the typical example (JuliaGPU/KernelAbstractions.jl#817): the parent records an event and the child's stream waits for it. Still, the child's first use of an array waited for all the work the parent queued after the event, so the two tasks didn't overlap:

a .= 1
e = CuEvent(); record(e)
more_work!(b)                   # the parent keeps going

Threads.@spawn begin            # what KernelAbstractions.@spawn does
    CUDA.wait(e)
    a .+= 1                     # doesn't wait for `more_work!` anymore
    MPI.Send(a, comm; dest=1)   # waits for `e`, but not for `more_work!`
end

To know what an event covers, each stream's work is split into epochs. Memory is stamped with the epoch of its last use, and recording an event or synchronizing a stream closes the current epoch. That gives two facts that make waiting unnecessary:

  • Completed: the host saw the work finish, by synchronizing the stream or the event, or because isdone(event) returned true.
  • Ordered: a stream waited for an event with CUDA.wait(event, stream). Work submitted to it afterwards runs after everything the event covers.

Memory covered by either fact moves to the new stream without waiting. A pointer conversion for code outside CUDA.jl, like the MPI call above, then synchronizes the new stream instead of the parent's, which only waits for what the event covered. Oceananigans can therefore drop its workaround of making the task adopt the parent's stream (CliMA/Oceananigans.jl#5897).

Operations that CUDA.jl submits (kernel launches, copies, fill!, graph launches) stamp memory when they take its pointer and again once the operation has been submitted. Between the two, an epoch closed by another task could appear to cover the operation, so an epoch only counts if the task that submits work to the stream closed it. Events recorded on another task's stream, or on a stream that several live tasks use, cover nothing. A stream whose task has finished can be taken over by another task, which recycling streams relies on. Graph capture doesn't produce either fact: recording an event on a stream that is being captured only adds a node to the graph, a wait on a capturing stream only orders the graph's operations, and captured operations don't stamp memory (launching the graph does).

A pointer taken outside of a CUDA.jl operation, e.g. p = pointer(a) passed to a kernel or library later, or an array passed to a library call, is only stamped once and can be used after the epoch it was stamped with has been closed: p = pointer(a); synchronize(); @cuda f(p) isn't covered by that synchronization. Such memory doesn't use either shortcut until it moves to another stream, which waits for everything submitted to the old one, as before. Work submitted through the old pointer after the array was used by another task isn't ordered with that task's work; that was already the case, and is now documented. Waiting for an event doesn't hold its lock, so other tasks can record or query it meanwhile; a wait only counts if the event wasn't recorded again in the meantime.

On an RTX 5080, record and CUDA.wait take about 150 and 210 ns, up from about 100 ns each, because of a lock and a table of the streams that were waited for. Kernel launches and copies cost the same as before.

The tests use pointer conversions and stream synchronization to check that events synchronized on the host, events waited for by a stream, and streams taken over from finished tasks avoid waiting. They also check that memory used after an event, events recorded by another task, events re-recorded elsewhere (also while another task waits for them), and launches through a pointer taken before the event or synchronization still wait.

@codecov

codecov Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.90909% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.98%. Comparing base (00a2218) to head (38d4a78).

Files with missing lines Patch % Lines
CUDACore/lib/cudadrv/events.jl 83.87% 5 Missing ⚠️
CUDACore/src/resources/managed.jl 88.00% 3 Missing ⚠️
CUDACore/lib/cudadrv/stream.jl 93.33% 2 Missing ⚠️
Additional details and impacted files
@@                  Coverage Diff                  @@
##           tb/device-handoff    #3328      +/-   ##
=====================================================
+ Coverage              84.94%   84.98%   +0.04%     
=====================================================
  Files                    194      194              
  Lines                  19858    19960     +102     
=====================================================
+ Hits                   16869    16964      +95     
- Misses                  2989     2996       +7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@maleadt
maleadt force-pushed the tb/event-ownership branch from 8c291ff to d129f2c Compare October 4, 2026 06:29
@maleadt
maleadt added this pull request to stack #3330 October 4, 2026 07:08
@maleadt
maleadt removed this pull request from stack #3330 October 4, 2026 08:26
@maleadt
maleadt force-pushed the tb/event-ownership branch from d129f2c to c1db358 Compare October 4, 2026 08:26
@maleadt
maleadt changed the base branch from main to tb/device-handoff October 4, 2026 08:26
@maleadt
maleadt added this pull request to stack #3333 October 4, 2026 08:26
@maleadt maleadt changed the title Don't synchronize memory that's already ordered by an event Don't wait for work that an event already ordered Oct 4, 2026
@maleadt
maleadt removed this pull request from stack #3333 October 4, 2026 09:03
@maleadt
maleadt force-pushed the tb/event-ownership branch from c1db358 to e2056f6 Compare October 4, 2026 09:04
@maleadt
maleadt added this pull request to stack #3334 October 4, 2026 09:04
@maleadt
maleadt force-pushed the tb/event-ownership branch from e2056f6 to a80c979 Compare October 4, 2026 09:04
@maleadt
maleadt force-pushed the tb/event-ownership branch from a80c979 to 598e849 Compare October 5, 2026 08:22
@maleadt
maleadt removed this pull request from stack #3334 October 5, 2026 08:33
@maleadt
maleadt added this pull request to stack #3338 October 5, 2026 08:33
@maleadt
maleadt marked this pull request as ready for review October 5, 2026 09:08
@maleadt
maleadt force-pushed the tb/event-ownership branch from 598e849 to 0343fe7 Compare October 5, 2026 18:16
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

CUDA.jl Benchmarks

Details
Benchmark suite Current: 38d4a78 Previous: ae637e5 Ratio
array/accumulate/Float32/1d 104846 ns 105001 ns 1.00
array/accumulate/Float32/dims=1 77193 ns 77262 ns 1.00
array/accumulate/Float32/dims=1L 1682465 ns 1684940 ns 1.00
array/accumulate/Float32/dims=2 148206 ns 148043 ns 1.00
array/accumulate/Float32/dims=2L 685134 ns 684338 ns 1.00
array/accumulate/Int64/1d 124001 ns 123503 ns 1.00
array/accumulate/Int64/dims=1 81498 ns 81902 ns 1.00
array/accumulate/Int64/dims=1L 1805414 ns 1803636 ns 1.00
array/accumulate/Int64/dims=2 163133 ns 163105 ns 1.00
array/accumulate/Int64/dims=2L 988121 ns 988275 ns 1.00
array/broadcast 17146 ns 16981 ns 1.01
array/broadcast launch 7845 ns 7497.25 ns 1.05
array/construct 976.8333333333334 ns 886.76 ns 1.10
array/copy 16891 ns 16325 ns 1.03
array/copyto!/cpu_to_gpu 206567 ns 206249 ns 1.00
array/copyto!/gpu_to_cpu 240789 ns 240560 ns 1.00
array/copyto!/gpu_to_gpu 8914 ns 8967 ns 0.99
array/iteration/findall/bool 140537 ns 135815 ns 1.03
array/iteration/findall/int 151930 ns 145400 ns 1.04
array/iteration/findfirst/bool 75027 ns 71077 ns 1.06
array/iteration/findfirst/int 77391 ns 73309 ns 1.06
array/iteration/findmin/1d 68571 ns 59845 ns 1.15
array/iteration/findmin/2d 103858 ns 103127 ns 1.01
array/iteration/logical 201703 ns 187405 ns 1.08
array/iteration/scalar 59057 ns 59115 ns 1.00
array/permutedims/2d 50030 ns 49364 ns 1.01
array/permutedims/3d 50870 ns 50795 ns 1.00
array/permutedims/4d 52559 ns 52271 ns 1.01
array/random/rand/Float32 11095 ns 11109 ns 1.00
array/random/rand/Int64 18320 ns 18411 ns 1.00
array/random/rand!/Float32 8217.333333333334 ns 8183 ns 1.00
array/random/rand!/Int64 15946 ns 15765 ns 1.01
array/random/randn/Float32 34741 ns 34501 ns 1.01
array/random/randn!/Float32 24694 ns 24403 ns 1.01
array/reductions/mapreduce/Float32/1d 33765 ns 30573 ns 1.10
array/reductions/mapreduce/Float32/dims=1 40266 ns 40213 ns 1.00
array/reductions/mapreduce/Float32/dims=1L 53760 ns 53356 ns 1.01
array/reductions/mapreduce/Float32/dims=2 59312 ns 59358 ns 1.00
array/reductions/mapreduce/Float32/dims=2L 71764 ns 71210 ns 1.01
array/reductions/mapreduce/Int64/1d 40226 ns 37469 ns 1.07
array/reductions/mapreduce/Int64/dims=1 43366 ns 42915 ns 1.01
array/reductions/mapreduce/Int64/dims=1L 89520 ns 89249 ns 1.00
array/reductions/mapreduce/Int64/dims=2 60502 ns 61370 ns 0.99
array/reductions/mapreduce/Int64/dims=2L 87674 ns 87543 ns 1.00
array/reductions/reduce/Float32/1d 33965 ns 30615 ns 1.11
array/reductions/reduce/Float32/dims=1 40493 ns 40264 ns 1.01
array/reductions/reduce/Float32/dims=1L 53102 ns 52907 ns 1.00
array/reductions/reduce/Float32/dims=2 59480 ns 59462 ns 1.00
array/reductions/reduce/Float32/dims=2L 71759 ns 71728 ns 1.00
array/reductions/reduce/Int64/1d 40062 ns 37570 ns 1.07
array/reductions/reduce/Int64/dims=1 43210 ns 42862 ns 1.01
array/reductions/reduce/Int64/dims=1L 89633 ns 89319 ns 1.00
array/reductions/reduce/Int64/dims=2 60783 ns 61437 ns 0.99
array/reductions/reduce/Int64/dims=2L 87754 ns 86880 ns 1.01
array/reverse/1d 16318 ns 16128 ns 1.01
array/reverse/1dL 70218 ns 70115 ns 1.00
array/reverse/1dL_inplace 67758 ns 67602 ns 1.00
array/reverse/1d_inplace 9399 ns 9293 ns 1.01
array/reverse/2d 21113 ns 21118 ns 1.00
array/reverse/2dL 75048 ns 74528 ns 1.01
array/reverse/2dL_inplace 67876 ns 67778 ns 1.00
array/reverse/2d_inplace 11001 ns 10676 ns 1.03
array/sorting/1d 2846860 ns 2846981 ns 1.00
array/sorting/2d 1089809 ns 1088944 ns 1.00
array/sorting/by 3399552 ns 3400388 ns 1.00
cuda/graph/capture 43762 ns
cuda/graph/eager 41538 ns
cuda/graph/launch 14344 ns
cuda/synchronization/context/auto 7914 ns 6766.6 ns 1.17
cuda/synchronization/context/blocking 882.952380952381 ns 793.7692307692307 ns 1.11
cuda/synchronization/context/nonblocking 7927.75 ns 6805.4 ns 1.16
cuda/synchronization/stream/auto 750.9736842105264 ns 691.0939597315436 ns 1.09
cuda/synchronization/stream/blocking 965 ns 868.875 ns 1.11
cuda/synchronization/stream/nonblocking 8469.333333333334 ns 7338.5 ns 1.15
integration/byval/reference 148072 ns 147882 ns 1.00
integration/byval/slices=1 149120 ns 149033 ns 1.00
integration/byval/slices=2 291458 ns 291277 ns 1.00
integration/byval/slices=3 433874 ns 434160 ns 1.00
integration/cudadevrt 105357 ns 105116 ns 1.00
integration/volumerhs 9201860 ns 9206594 ns 1.00
kernel/indexing 11664 ns 11855 ns 0.98
kernel/indexing_checked 12311 ns 12209 ns 1.01
kernel/launch 2565.1111111111113 ns 2433 ns 1.05
kernel/occupancy 1058.8 ns 1008.8235294117648 ns 1.05
kernel/rand 15340 ns 14899 ns 1.03
latency/import 4311120727 ns 4305717656 ns 1.00
latency/precompile 5095894836 ns 5095420673 ns 1.00
latency/ttfp 4793981580 ns 4793974717 ns 1.00

This comment was automatically generated by workflow using github-action-benchmark.

@maleadt
maleadt force-pushed the tb/event-ownership branch 2 times, most recently from 7b06014 to 636c841 Compare October 6, 2026 13:55
@maleadt
maleadt force-pushed the tb/event-ownership branch from 636c841 to 5903e78 Compare October 7, 2026 04:54
When memory moves to another stream, CUDA.jl orders the new stream after
the one that last used the memory, by waiting on the device or
synchronizing on the host. That's unnecessary when the host already knows
that the last use has completed, e.g., because it synchronized an event
recorded after it.

Divide each stream's work into epochs, stamp memory with the epoch of its
last use, and close an epoch when recording an event or synchronizing the
stream. Synchronizing a stream, or observing that an event has completed,
then tells us that all accesses up to the closed epoch have finished, so
memory last used in them doesn't need to be waited for anymore. Waiting
for an event doesn't hold its lock, so other tasks can record or query it
in the meantime; what the wait covers is only learned if the event wasn't
recorded again.

Operations that CUDA.jl submits itself (kernel launches, copies, fill!,
graph launches) stamp memory when taking its pointer, and again once the
operation has been submitted, also when that fails. Between the two, an
epoch closed by another task could appear to cover the operation, so only
the task submitting work to a stream closes epochs that count. Epochs
closed by another task, or on a stream that several live tasks submit
work to, therefore don't cover anything. A stream whose task has finished
can be taken over by another one, as happens when streams are recycled.

A pointer taken outside of such an operation, e.g. with `pointer(a)` or
when passing an array to a library call, is only stamped once and can be
used by work that is submitted after its epoch was closed. Such memory
isn't considered complete until it moves to another stream, which waits
for all work submitted to the old one so far.

Recording an event on a stream that is being captured only adds a node
to the graph, so it doesn't close an epoch, and captured operations
don't stamp the memory they use (launching the graph does). Memory used
by captures that CUDA.jl doesn't know about, and memory used on the
special streams, are never considered complete.
When a stream waits for an event, everything it executes afterwards is
ordered after the work the event covers. Memory last used in that work can
then move to that stream without waiting for the stream that used it.
That's what KernelAbstractions' `@spawn` relies on: the spawned task waits
for an event recorded by its parent, but its first use of the parent's
arrays currently also waits for everything the parent queued after the
event.

Have each stream remember, per stream it waited for, up to which epoch its
work is ordered, and don't wait again for memory whose last use is covered
by that. This applies to the memory that could be handed off on the
device anyway, and not to memory whose pointer escaped. The memory remains
marked as waiting on another stream, so that a pointer conversion for a
consumer outside of CUDA.jl, like MPI, synchronizes the new stream. That
only waits for the work the event covered, and not for everything the
other stream queued since, as synchronizing that stream would.
@maleadt
maleadt force-pushed the tb/event-ownership branch from 5903e78 to 38d4a78 Compare October 7, 2026 12:20
@maleadt
maleadt merged commit 85d6a55 into main Oct 8, 2026
17 checks passed
@maleadt
maleadt deleted the tb/event-ownership branch October 8, 2026 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant