[ROCm] Fix cross-stream race between H2D transfers and compute kernels#964
[ROCm] Fix cross-stream race between H2D transfers and compute kernels#964mminutoli wants to merge 1 commit into
Conversation
e7fc6c5 to
016b6f3
Compare
In the op-by-op execution path (StreamExecutorGpuClient::RunAsync), input buffers produced by H2D transfers have their definition event tied to the compute-stream sync_point counter rather than the actual host_to_device_stream. When the compute stream advances past that sync_point and evicts it from the sliding window, GetDefinitionEvent(nullptr_if_past=true) returns nullptr, causing RunAsync to skip the wait before dispatching the kernel. The kernel then reads the input buffer before the H2D transfer completes, producing zeros. Fix by tracking the actual producer stream per buffer: 1. Add SetProducerEvent()/producer_event_ to AllocatedRawSEDeviceMemory. GetDefinitionEvent returns this event (tied to the real producer stream) when set, falling back to the compute-stream sync_point otherwise. 2. In CopyRawHostToDeviceAndReturnEvent, pre-create the H2D completion event and call SetProducerEvent synchronously before scheduling the async task. Any consumer calling GetDefinitionEvent after this point sees the host_to_device_stream event, not the compute-stream sync_point. 3. In RunAsync, call WaitForAllocation for each input flat_argument before ExecuteThunks. For H2D-produced inputs this enqueues a hipStreamWaitEvent(compute_stream, h2d_event), preventing the kernel from starting until the transfer completes. Fixes flaky CI failure: tests/batching_test.py::BatchingTest::testConvGeneralDilated on the ROCm 4-GPU MI350X runner, where per_example_direct was all zeros due to this race under pytest-xdist single-GPU worker isolation. Co-authored-by: Manjunath Gaonkar <Manjunath.Gaonkar@amd.com>
016b6f3 to
74a9a4f
Compare
| // Overrides the definition event with one tied to the actual producer stream. | ||
| // Must be called before the buffer is made visible to consumers. | ||
| // The default is a no-op; only AllocatedRawSEDeviceMemory overrides this. | ||
| virtual void SetProducerEvent(BufferSequencingEventRef /*event*/) {} |
There was a problem hiding this comment.
Bug (medium severity): SlicedRawSEDeviceMemory inherits this no-op, so calling SetProducerEvent on a slice silently drops the event. More importantly, SlicedRawSEDeviceMemory does not override GetDefinitionEvent either — so even if the base buffer has a producer event set, a slice of it will not see it (GetDefinitionEvent on the slice falls through to the uninitialized sync_point_ path and returns an empty event).
If a buffer produced by CopyRawHostToDeviceAndReturnEvent is later sliced and then used as an argument in RunAsync, WaitForAllocation on the slice will not wait for the H2D transfer, reintroducing the race this PR aims to fix.
SlicedRawSEDeviceMemory should delegate both SetProducerEvent and GetDefinitionEvent to its base_ object.
| void SetProducerEvent(BufferSequencingEventRef event) override { | ||
| absl::MutexLock l(&producer_event_mu_); | ||
| producer_event_ = std::move(event); | ||
| } |
There was a problem hiding this comment.
Nit: SetProducerEvent unconditionally overwrites producer_event_. The comment says "Must be called before the buffer is visible to consumers," but nothing enforces single-call semantics. If a second call ever happens, the earlier event is silently dropped — any consumer that obtained the first event reference would still wait on the right thing, but the buffer would lose its ordering guarantee with respect to the first transfer.
Consider adding a DCHECK to catch violations during development:
| void SetProducerEvent(BufferSequencingEventRef event) override { | |
| absl::MutexLock l(&producer_event_mu_); | |
| producer_event_ = std::move(event); | |
| } | |
| void SetProducerEvent(BufferSequencingEventRef event) override { | |
| absl::MutexLock l(&producer_event_mu_); | |
| DCHECK(!producer_event_) << "SetProducerEvent called more than once"; | |
| producer_event_ = std::move(event); | |
| } |
| absl::MutexLock l(&producer_event_mu_); | ||
| if (producer_event_) { | ||
| if (nullptr_if_past && producer_event_->IsComplete()) { | ||
| return BufferSequencingEventRef(); |
There was a problem hiding this comment.
Potential issue: IsComplete() documentation says it "blocks the calling thread until the event has been recorded" if SetSequencingEvent has not yet been called. The device_event is created via BufferSequencingEvent::Create() and the sequencing event is only set later inside the async task via AllocateAndRecordEvent.
There is a window between SetProducerEvent (before the async task is scheduled) and when AllocateAndRecordEvent records the GPU event, during which IsComplete() could block the thread calling GetDefinitionEvent. If the async work runner is starved or queued behind other work, this blocks the RunAsync caller. With nullptr_if_past = true, the intent is a non-blocking optimization — the blocking behavior here contradicts that.
Consider guarding this with a non-blocking check (e.g., checking whether the underlying event has been allocated before calling IsComplete()), or document that this path may block.
| device_event.AndThen([device_buffer = device_buffer_]() {}); | ||
| client_->async_work_runner()->Schedule([client = client_, device_event, | ||
| local_device = local_device_, stream, | ||
| src, offset, transfer_size, | ||
| buf = tsl::FormRef(this)]() mutable { | ||
| se::DeviceAddressBase sub_buffer = buf->device_buffer_->mem(); | ||
| if (transfer_size < sub_buffer.size()) { | ||
| sub_buffer = sub_buffer.GetByteSlice(offset, transfer_size); | ||
| } | ||
| std::shared_ptr<void> staging_buffer; | ||
| auto status = [&]() -> absl::Status { | ||
| RETURN_IF_ERROR(client->WaitForAllocation(stream, *buf)); | ||
| if (transfer_size > 0) { | ||
| if (client->ShouldStageHostToDeviceTransfers(src, transfer_size)) { | ||
| if (client->GetHostMemoryAllocator() == nullptr) { | ||
| return absl::InvalidArgumentError( | ||
| "host_memory_allocator should be initialized for " | ||
| "staging buffer transfer."); | ||
|
|
||
| // Bind the pre-created event to this buffer before scheduling the async | ||
| // task. This ensures that any consumer calling GetDefinitionEvent after | ||
| // this point sees an event on host_to_device_stream rather than the | ||
| // compute-stream sync_point, closing the cross-stream race in RunAsync. | ||
| device_buffer_->SetProducerEvent(device_event); |
There was a problem hiding this comment.
Note (reference cycle): device_event.AndThen(...) on line 109 captures a copy of device_buffer_ (an AsyncValueRef<RawSEDeviceMemory>). Then SetProducerEvent on line 115 stores device_event into device_buffer_'s producer_event_ field. This creates a reference cycle: device_buffer_ → producer_event_ → (via AndThen callback) → device_buffer_.
The cycle should be broken when the event completes and the AndThen callback runs and releases its captured reference. But if the event never completes (e.g., GPU error causes the stream to fail without firing the callback), this could leak.
Verify that SetEventAsError (line 161) reliably triggers AndThen callbacks on the BufferSequencingEvent to break the cycle in all error paths.
Claude Review SummaryThis PR fixes a cross-stream race on ROCm where H2D transfers on Key finding: Other findings (see inline comments):
|
…d buffers Root cause: a compute-produced buffer's definition is tracked by a shared, lazily-recorded, evictable compute-stream sync_point. Under host-run-ahead (BFC AllowsAsynchronousDeallocation=true => no per-execute BlockHostUntilDone), the sync_point is evicted while the producer is still in flight, and WaitForAllocation (the D2H readback's only cross-stream sync) gets nullptr/stale => the readback races the in-flight MIOpen conv-backward dW and reads it after SetTensor(0) but before the atomic accumulation => all-zero gradient (jax testConvGeneralDilated, flaky on 4gpu/ROCm7.2). v1 (front event) and v2 (consumer-side fresh event) failed because the front event is stale and a consumer-side event can be recorded before the producer enqueues (the D2H is async-scheduled and not gated on producer enqueue). v3 (producer-side, timing-independent): in StreamExecutorGpuClient::RunAsync, after ExecuteThunks, record a real event on the compute stream and attach it to each compute-produced output buffer via RawSEDeviceMemory::SetProducerEvent. AllocatedRawSEDeviceMemory::GetDefinitionEvent returns this event when set, bypassing the sync_point. This runs before the launch publishes the buffer to any consumer (the launch promise is Set only after RunAsync returns), so there is no set-vs-read race. Keeps BFC fully asynchronous (compute analog of the H2D producer-event in #964). Files: tracked_device_buffer.{h,cc} (SetProducerEvent + producer_event_), se_gpu_pjrt_client.cc (record + attach after ExecuteThunks). Base: pin 1ee7be3.
mfrancepillois
left a comment
There was a problem hiding this comment.
Could you please ensure that this PR only contains actual changes? It appears that many lines have been changed unnecessarily, which make the PR review and code tracking more complex.
| { | ||
| absl::MutexLock l(&producer_event_mu_); | ||
| if (producer_event_) { | ||
| if (nullptr_if_past && producer_event_->IsComplete()) { |
There was a problem hiding this comment.
I don't think IsComplete() is the correct method to use here, as it is a blocking method that wait until the event is completed before returning, which is not the goal of nullptr_if_past which aims to speed-up the execution. You should instead consider implementing a function like the following to perform a non blocking check:
// Non-blocking: returns nullopt if not yet definable
std::optional<bool> TryIsComplete(BufferSequencingEvent& ev) {
if (!ev.IsDefined()) {
return std::nullopt; // SetSequencingEvent not called yet — state unknown
}
// event_ is now safely readable without blocking
if (ev.IsPredeterminedError()) {
return true;
}
return ev.event()->event.event()->PollForStatus() == se::Event::Status::kComplete;
}
|
@mfrancepillois I dont think this PR is fully valid now. we have found the main issue thats causing this race I have opened up draft solution ( which is still not final ) |
Summary
Hunting a flaky CI failure in
tests/batching_test.py::BatchingTest::testConvGeneralDilatedon the ROCm 4-GPU MI350X runner, whereper_example_direct(the Python loop reference) was all zeros while the vmap result was correct.What Could Happen
In the op-by-op execution path (
StreamExecutorGpuClient::RunAsync, used under pytest-xdist with single-GPU worker isolation), input buffers produced by H2D transfers have their definition event tied to the compute-stream sync_point counter — not tohost_to_device_stream_. When the compute stream advances past that sync_point and evicts it from the sliding window,GetDefinitionEvent(nullptr_if_past=true)returnsnullptr, andRunAsyncskips waiting before dispatching the kernel. The kernel then reads the input before the H2D transfer completes, getting zeros.The invariant broken is subtle:
AllocatedRawSEDeviceMemoryalways used the compute_stream counter to represent "buffer is ready", but H2D-produced buffers are written byhost_to_device_stream_, an independent HIP stream. "Compute stream past sync_point" does not imply "H2D transfer complete."Fix
xla/pjrt/tracked_device_buffer.cc/.h— AddSetProducerEvent()/producer_event_toAllocatedRawSEDeviceMemory.GetDefinitionEventreturns this event (on the real producer stream) when set, falling back to the existing compute-stream sync_point mechanism for compute outputs.xla/pjrt/se_raw_buffer.cc— InCopyRawHostToDeviceAndReturnEvent, pre-create the H2D completion event and callSetProducerEventsynchronously before scheduling the async task. Any consumer callingGetDefinitionEventafter this point sees thehost_to_device_stream_event, not a compute-stream sync_point.xla/pjrt/gpu/se_gpu_pjrt_client.cc— InRunAsync, callWaitForAllocationfor each input flat_argument beforeExecuteThunks. For H2D-produced inputs, this enqueueshipStreamWaitEvent(compute_stream, h2d_event), preventing the kernel from starting before the transfer completes. For already-complete or compute-stream buffers, the call is a no-op.Design notes
producer_event_: the event handle is pre-created and stored in the buffer before the async H2D task is scheduled, and before the buffer is returned to the caller. The mutex inGetDefinitionEventguards concurrent reads fromRunAsyncagainst the (rare) case whereSetProducerEventis called concurrently.sync_point_fallback is unchanged; no performance regression on the common path.ScheduleCopyTo,IntraClientCopyToWithDependencies) have the same cross-stream pattern but are not implicated in the known failure — left as follow-on work.