Repository navigation
Recycle the streams of finished tasks - #1120
Conversation
c6854d5 to
5803e7b
Compare
gbaraldi
left a comment
There was a problem hiding this comment.
Segfault: a free from another task gets recorded into a capture on the recycled stream.
using AMDGPU
a = fetch(Threads.@spawn (x = AMDGPU.ones(Float32, 1 << 20); AMDGPU.synchronize(); x))
capturing, freed = Base.Event(), Base.Event()
t = Threads.@spawn begin # gets the finished task's stream from the pool
b = AMDGPU.zeros(Float32, 16); AMDGPU.synchronize()
AMDGPU.capture() do
notify(capturing); wait(freed)
b .+= 1f0
end
end
wait(capturing)
AMDGPU.unsafe_free!(a) # hipFreeAsync on a's last stream = t's capturing stream
notify(freed)
g = fetch(t) # graph now contains a MemFree node
AMDGPU.HIP.launch(AMDGPU.HIP.instantiate(g)) # segfault in hipGraphLaunchjulia -t 8, MI300A + MI250, ROCm 7.2.4: segfaults every run. On the base commit the free errors (HIP error 900) and leaks, but doesn't crash. Suggested fix: if recycled(managed), free on the caller's stream instead of managed.stream.
gbaraldi
left a comment
There was a problem hiding this comment.
Other findings (MI300A + MI250):
- Skipped wait (
take_ownership!). It reads the generation fromstream, but keeps the oldmanaged.streamwhen the two compare equal (same handle). Withw = HIPStream(AMDGPU.stream().stream); stream!(w), a 2 s kernel, and then a read from another task: the read doesn't wait and returns 0 instead of 42. Always assignmanaged.stream = stream, or read the generation frommanaged.stream. priority!holds pool slots.priority!(p)/priority!(f, p)leave the swapped-out entry owned by a live task. 40priority!(:high) do … endblocks on main fill the:highpool, after which every other task creates a fresh stream. More generally, the cap counts streams of live tasks, so 32 long-lived workers disable recycling. Release the entry on restore, and cap idle entries rather than the total.- Stream creation under the lock.
HIPStream(priority)runs while holdingSTREAM_POOL_LOCK. With 64 concurrent first touches, the median wait is 51 ms (MI300A) vs 1.5 ms on base, and when creation is slow every task queues behind it. Reserve the slot under the lock and create the stream outside it. - Nits:
- A stream in an error state is never
isidle, so its slot is lost and re-queried on every lookup. - A user-
finalized pooled stream could be handed out; checkisvalid. - The test's "~1s at 100 MHz":
memrealtimeis 25 MHz on MI250, sotls.jltakes 37 s there.
- A stream in an error state is never
5803e7b to
2db957c
Compare
|
Review addressed. |
2db957c to
9c39b62
Compare
|
Error seems here as well related to now passing Int128 test (possibly fixed by having AMDGPU_LLVM_Backend_jll on v23.1.1+3 ?). For the remaining, LGTM after having addressed the review. |
9c39b62 to
310284d
Compare
Every task got its own HIP stream, created on first use and only destroyed when the GC finalized it. HIP streams are expensive: creating one takes milliseconds and pins ~8 MiB of host memory. Since the GC is in no hurry to collect finished tasks, code that spawns many short GPU tasks piles up thousands of streams, making stream creation take over 100ms each and eventually hanging the GPU when pinned memory runs out. Instead, keep the streams of tasks in a pool per device and priority, and hand the stream of a task that has finished, and whose work has completed, to the next task that needs one. Tasks running at the same time never share a stream, and up to 32 idle streams are kept around. Handing a stream to another task bumps its generation, so that memory last used by a recycled stream knows that its work has finished. Such memory doesn't wait for the stream's new owner, and isn't freed on that stream either, since the new owner may be capturing it.
310284d to
6608b6a
Compare
|
CI was failing because the new |
AMDGPU.jl BenchmarksDetails
This comment was automatically generated by workflow using github-action-benchmark. |
AMDGPU.jl gives every Julia task its own HIP stream. A task creates its stream the first time it touches the GPU, and the stream is destroyed when the GC finalizes it. CUDA.jl uses the same model, but it works out badly with HIP, because HIP streams are expensive. Creating one takes milliseconds and pins about 8 MiB of host memory: a kernel-argument pool plus another 4 MiB buffer. The GC doesn't know about that memory, so it sees no reason to collect the streams of finished tasks. Code that spawns many short tasks that use the GPU, like Dagger.jl, piles up thousands of streams.
To measure this, I ran a producer/consumer loop in which each iteration spawns one task that fills an array and another that reduces and frees it, with four such loops running concurrently. On a Ryzen 9950X iGPU with ROCm 7.2.4:
By then, creating a single stream took more than 100 ms. Much of that time was spent in the kernel, compacting memory to pin the new pages for the GPU. In a test that only creates streams, the process died somewhere between 2,000 and 4,000 live streams with:
Dagger ran into this earlier and worked around it on its side in JuliaParallel/Dagger.jl@4ebcb22, describing how stream creation "stalls inside the driver, after which unrelated HIP calls start reporting illegal addresses". With the Dagger version from just before that commit, a 4096×4096 stencil sweep reproduces it here: on a single chunk it takes 282 ms per iteration, and with 2×2 chunks it hangs, with every thread stuck in
hipStreamCreateWithPriority.With this PR, task streams come from a pool per device and priority. A task that needs a stream gets the stream of a task that has finished and whose work on that stream has completed. Only when no such stream exists does it create a new one. Tasks that run at the same time never share a stream, however many there are. Once they have finished, up to 32 idle streams per device and priority are kept for reuse, and the rest are left to the GC. A task that switches priorities with
priority!gets its own stream for that priority back each time. Results on the same machine:Arrays remember which stream last used them, so that other tasks can wait for that work. Each stream now also has a generation, which is bumped whenever the stream goes to another task. If an array's stream has changed hands since the array last used it, that work must have finished. The array then doesn't wait for whatever the stream's new owner is doing, although it still checks for kernel exceptions. It also doesn't free its memory on that stream anymore. If the new owner is capturing a graph, the free becomes part of the graph, and launching that graph segfaults (see @gbaraldi's reproducer in the review). Freeing an array that a live task is using while that task captures a graph has the same problem, with or without recycling; that's #1123.
HIPStream(handle)now returns the pool's object for the handle of a task's stream, so that such wrappers also notice when the stream is recycled.The visible change is that a task's default stream may be passed on to another task after it finishes. Code that keeps using a task's stream elsewhere, for example by handing
AMDGPU.stream()to another task, should create a stream explicitly withAMDGPU.HIPStream()instead. The docs now say so.I first implemented this with a fixed pool of 32 streams shared round-robin between tasks, like PyTorch's stream pool. That also fixed the benchmarks, but concurrently running tasks could end up sharing a stream. Work captured into a graph by one task could then include another task's work, and
synchronize()would wait for other tasks. CUDA.jl has the same per-task stream problem in a milder form, and gets the same fix in JuliaGPU/CUDA.jl#3317.The full test suite passes on the gfx1036 iGPU (with
AMD_OPT_FLUSH=0, see #1119). The new tests cover reusing the streams of finished tasks, distinct streams for concurrent tasks, the limit on idle streams, long-lived tasks and priority switches not using up the pool, streams that still have work queued or can't be used anymore, arrays that last used a recycled stream, wrapped handles, and graph capture.