Skip to content

[WIP][ROCm] PJRT: Defer device buffer free until the compute stream drains#979

Draft
magaonka-amd wants to merge 1 commit into
mainfrom
fix/pjrt-defer-free-while-compute-inflight
Draft

[WIP][ROCm] PJRT: Defer device buffer free until the compute stream drains#979
magaonka-amd wants to merge 1 commit into
mainfrom
fix/pjrt-defer-free-while-compute-inflight

Conversation

@magaonka-amd

@magaonka-amd magaonka-amd commented Jun 22, 2026

Copy link
Copy Markdown

📝 Summary of Changes

Defer device-buffer deallocation under the kComputeSynchronized allocation model until the compute stream drains past the buffer's last use, instead of returning the chunk to the allocator the moment its host-side refcount hits zero.
This closes a use-after-free where a freed chunk is re-served to a new allocation (possibly on another stream) and overwritten while a kernel that still uses it is in flight on the GPU.

🎯 Justification

On ROCm the eager backward-filter conv gradient in tests/batching_test.py::testConvGeneralDilated (JAX) intermittently came back all-zero (42/450 elements). Root cause: ~AllocatedRawSEDeviceMemory frees the buffer at host-enqueue time, but under async dispatch the producing kernel is still running. The conv autotuner then drew scratch from the same shared BFC pool on a non-compute stream, and a candidate's SetTensor(dW, 0) zeroed the still-live gradient. The early free is the prerequisite; deferring it removes the hazard.

In my opinion dilated conv operation being bit heavy and slow with MIOpen exposes this rare race condition. In theory bug exist in CUDA too but none of the Unit tests from JAX side expose this problem.
One more thing, dilated conv is not the only test that affected by this bug, there are many other conv related tests run into this flakyness , but among those dilated conv has had more frequency.

For more info on what the test is , math behind it and bit more detail on this bug : https://amd.atlassian.net/wiki/spaces/MLSE/pages/1747138095/Conv+flake+test+in+JAX+CI

🚀 Kind of Contribution

🐛 Bug Fix

Tests to highlight the problem

JAX test that exposes this bug is flaky and extremly rare. It only comes up in our CI nodes.
So I had to mimic a standalone, deterministic reproducer is included below as an example. It uses the real
MultiDeviceAdapter / tsl::BFCAllocator, and a long Memset32 "delay" keeps a producer kernel in flight while a buffer is freed and re-served:

  • Immediate free: the allocator re-serves the freed buffer's exact address
    while the producer's event is still kPending, and a cross-stream MemZero
    clobbers it → the original buffer reads back 0.

  • Deferred free (this fix): the chunk is withheld during the in-flight
    window, so the new allocation gets a different address and the data survives.

  • This example exaggerates situation a bit to highlight the bug.
    Sample output:

========== async-dealloc UAF [IMMEDIATE free (the bug)] ==========
  allocated A   = 0x7ef7a7200000 (180 bytes)
  freed A EARLY (returned to BFC while kernel may be live)
  allocated B   = 0x7ef7a7200000  (B re-serves A's address: YES)
  producer event at realloc: PENDING (producer kernel still in flight)
  MemZero(B) on a second stream (models SetTensor(0))
  readback A    = 0x00000000  -> ZEROED (use-after-free!)

========== async-dealloc UAF [DEFERRED free (the fix)] ==========
  withheld A's chunk (deferred free until event drains)
  allocated B   = 0x7ef7a7200100  (B re-serves A's address: no)
  readback A    = 0x3f800000  -> sentinel preserved

expand the section below to see XLA level test that shows the problem.

Standalone reproducer (async_dealloc_uaf_test.cc)
// Deterministic GPU reproducer for the use-after-free behind the ROCm "conv-zero"
// flake: a device buffer freed while its compute-stream kernel is still in flight
// gets re-served to a new allocation, and a cross-stream write then clobbers the
// still-live data.

#include <cstdint>
#include <cstdio>
#include <memory>
#include <utility>
#include <vector>

#include "absl/status/status.h"
#include "absl/status/statusor.h"
#include "absl/strings/string_view.h"
#include <gtest/gtest.h>
#include "xla/stream_executor/device_address.h"
#include "xla/stream_executor/device_address_allocator.h"
#include "xla/stream_executor/event.h"
#include "xla/stream_executor/integrations/device_mem_allocator.h"
#include "xla/stream_executor/integrations/tf_allocator_adapter.h"
#include "xla/stream_executor/platform.h"
#include "xla/stream_executor/platform_manager.h"
#include "xla/stream_executor/stream.h"
#include "xla/stream_executor/stream_executor.h"
#include "xla/tsl/framework/allocator.h"
#include "xla/tsl/framework/bfc_allocator.h"
#include "xla/tsl/framework/device_id.h"
#include "xla/tsl/platform/errors.h"
#include "xla/tsl/platform/statusor.h"
#include "xla/tsl/platform/test.h"

namespace stream_executor {
namespace {

constexpr uint32_t kSentinel = 0x3f800000u;  // 1.0f
constexpr int kElems = 45;                    // 3*3*1*5 dW = 180 bytes
constexpr uint64_t kBytes = kElems * sizeof(uint32_t);

// Long enough that the producer event is still pending across the host-side
// free + realloc + query (microseconds).
constexpr uint64_t kScratchBytes = uint64_t{256} << 20;
constexpr int kDelayIters = 256;

absl::StatusOr<std::pair<Platform*, StreamExecutor*>> GpuExecutor() {
  for (absl::string_view name : {"CUDA", "ROCM"}) {
    absl::StatusOr<Platform*> platform =
        PlatformManager::PlatformWithName(name);
    if (!platform.ok()) continue;
    absl::StatusOr<StreamExecutor*> executor =
        (*platform)->ExecutorForDevice(0);
    if (executor.ok()) return std::make_pair(*platform, *executor);
  }
  return absl::NotFoundError("No GPU platform (CUDA/ROCM) available");
}

struct Outcome {
  bool reuse_same_addr;
  Event::Status status_at_realloc;
  uint32_t readback;
};

const char* StatusName(Event::Status s) {
  switch (s) {
    case Event::Status::kPending:
      return "PENDING (producer kernel still in flight)";
    case Event::Status::kComplete:
      return "complete (producer kernel finished)";
    case Event::Status::kError:
      return "error";
    default:
      return "unknown";
  }
}

absl::StatusOr<Outcome> RunOnce(Platform* platform, StreamExecutor* exec,
                                bool deferred) {
  const int ord = exec->device_ordinal();
  std::fprintf(stderr, "\n========== async-dealloc UAF [%s] ==========\n",
               deferred ? "DEFERRED free (the fix)"
                        : "IMMEDIATE free (the bug)");

  auto sub =
      std::make_unique<DeviceMemAllocator>(exec, tsl::PlatformDeviceId(ord));
  tsl::BFCAllocator::Options opts;
  opts.allow_growth = true;
  auto bfc = std::make_shared<tsl::BFCAllocator>(
      std::move(sub), uint64_t{4} << 30, "uaf_test_bfc", opts);

  TF_ASSIGN_OR_RETURN(std::unique_ptr<Stream> compute, exec->CreateStream());
  TF_ASSIGN_OR_RETURN(std::unique_ptr<Stream> consumer, exec->CreateStream());
  TF_ASSIGN_OR_RETURN(std::unique_ptr<Event> event, exec->CreateEvent());

  std::vector<MultiDeviceAdapter::AllocatorInfo> infos;
  infos.push_back({bfc, compute.get(), /*memory_space=*/int64_t{0},
                   /*device_ordinal=*/ord});
  MultiDeviceAdapter adapter(platform, std::move(infos));

  // Allocate scratch first so A is the lowest small free chunk and BFC best-fit
  // deterministically re-serves it to B.
  TF_ASSIGN_OR_RETURN(ScopedDeviceAddress<uint8_t> scratch_scoped,
                      adapter.Allocate(ord, kScratchBytes, true, 0));
  DeviceAddress<uint8_t> scratch = scratch_scoped.Release();
  std::fprintf(stderr, "  allocated scratch = %p (delay buffer)\n",
               scratch.opaque());

  TF_ASSIGN_OR_RETURN(ScopedDeviceAddress<uint8_t> a_scoped,
                      adapter.Allocate(ord, kBytes, true, 0));
  DeviceAddress<uint8_t> a = a_scoped.Release();
  std::fprintf(stderr, "  allocated A   = %p (%lu bytes)\n", a.opaque(),
               static_cast<unsigned long>(kBytes));

  TF_RETURN_IF_ERROR(compute->Memset32(&a, kSentinel, kBytes));
  for (int i = 0; i < kDelayIters; ++i) {
    TF_RETURN_IF_ERROR(compute->Memset32(&scratch, 0xccccccccu, kScratchBytes));
  }
  TF_RETURN_IF_ERROR(compute->RecordEvent(event.get()));
  std::fprintf(stderr,
               "  producer enqueued: Memset32(A, 0x%08x) + %d-iter delay; "
               "event recorded\n",
               kSentinel, kDelayIters);

  if (!deferred) {
    TF_RETURN_IF_ERROR(adapter.Deallocate(ord, a));
    std::fprintf(stderr,
                 "  freed A EARLY (returned to BFC while kernel may be live)\n");
  } else {
    std::fprintf(stderr,
                 "  withheld A's chunk (deferred free until event drains)\n");
  }

  TF_ASSIGN_OR_RETURN(ScopedDeviceAddress<uint8_t> b_scoped,
                      adapter.Allocate(ord, kBytes, true, 0));
  DeviceAddress<uint8_t> b = b_scoped.Release();

  Outcome out;
  out.reuse_same_addr = (b.opaque() == a.opaque());
  out.status_at_realloc = event->PollForStatus();
  std::fprintf(stderr, "  allocated B   = %p  (B re-serves A's address: %s)\n",
               b.opaque(), out.reuse_same_addr ? "YES" : "no");
  std::fprintf(stderr, "  producer event at realloc: %s\n",
               StatusName(out.status_at_realloc));

  TF_RETURN_IF_ERROR(consumer->MemZero(&b, kBytes));
  std::fprintf(stderr,
               "  MemZero(B) on a second stream (models SetTensor(0))\n");
  TF_RETURN_IF_ERROR(consumer->BlockHostUntilDone());
  TF_RETURN_IF_ERROR(compute->BlockHostUntilDone());

  if (deferred) {
    TF_RETURN_IF_ERROR(adapter.Deallocate(ord, a));
  }

  TF_RETURN_IF_ERROR(compute->Memcpy(&out.readback, a, sizeof(uint32_t)));
  TF_RETURN_IF_ERROR(compute->BlockHostUntilDone());
  std::fprintf(stderr, "  readback A    = 0x%08x  -> %s\n", out.readback,
               out.readback == 0u           ? "ZEROED (use-after-free!)"
               : out.readback == kSentinel  ? "sentinel preserved"
                                            : "unexpected");

  TF_RETURN_IF_ERROR(adapter.Deallocate(ord, b));
  TF_RETURN_IF_ERROR(adapter.Deallocate(ord, scratch));
  return out;
}

TEST(AsyncDeallocUaf, ImmediateFreeReusesLiveChunkAndZeroesIt) {
  absl::StatusOr<std::pair<Platform*, StreamExecutor*>> gpu = GpuExecutor();
  if (!gpu.ok()) GTEST_SKIP() << gpu.status();

  TF_ASSERT_OK_AND_ASSIGN(
      Outcome out, RunOnce(gpu->first, gpu->second, /*deferred=*/false));

  EXPECT_TRUE(out.reuse_same_addr);
  EXPECT_EQ(out.status_at_realloc, Event::Status::kPending);
  EXPECT_EQ(out.readback, 0u);
}

TEST(AsyncDeallocUaf, DeferredFreeWithholdsChunkAndPreservesData) {
  absl::StatusOr<std::pair<Platform*, StreamExecutor*>> gpu = GpuExecutor();
  if (!gpu.ok()) GTEST_SKIP() << gpu.status();

  TF_ASSERT_OK_AND_ASSIGN(
      Outcome out, RunOnce(gpu->first, gpu->second, /*deferred=*/true));

  EXPECT_FALSE(out.reuse_same_addr);
  EXPECT_EQ(out.readback, kSentinel);
}

}  // namespace
}  // namespace stream_executor

🧪 Execution Tests

testConvGeneralDilated on a 4-GPU gfx950 runner (ROCm 7.2.0) no longer produces
the all-zero gradient with this fix applied.

Under the kComputeSynchronized allocation model the host runs ahead of the
device, so an AllocatedRawSEDeviceMemory's last compute-stream use may still be
in flight when its host-side refcount drops to zero. Freeing the chunk
immediately returns it to the allocator, where a subsequent allocation
(possibly on a different stream) can reuse and overwrite memory that a live
kernel still owns -- a use-after-free.

This was observed on ROCm as an intermittent all-zero backward-filter conv
gradient (tests/batching_test.py::testConvGeneralDilated): the freed buffer was
re-served to the conv autotuner, whose candidate zero-initialized scratch
(SetTensor(dW, 0)) aliased the still-live gradient. The early free is
platform-agnostic (the contract is shared with CUDA).

Defer the deallocation via LocalDeviceState::ThenExecuteCallback on a callback
stream that waits on the compute stream, so the chunk is not returned to the
allocator until the producing kernel has finished. The compute stream only
records a lightweight event (it is not stalled) and the host never blocks, so
the allocator remains asynchronous. Falls back to an immediate free if the
callback cannot be enqueued.
@magaonka-amd magaonka-amd added the claude-review Request a Claude AI code review for this PR label Jun 22, 2026
Comment on lines +106 to +111
if (s.ok()) {
return;
}
// Fall through to an immediate free if the callback could not be
// enqueued.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: When ThenExecuteCallback fails, the error status s is silently discarded before falling through to the immediate free. Consider logging it so operators can diagnose why the deferred path was skipped:

Suggested change
if (s.ok()) {
return;
}
// Fall through to an immediate free if the callback could not be
// enqueued.
}
if (s.ok()) {
return;
}
LOG(WARNING)
<< "Could not enqueue deferred deallocation, falling back to "
"immediate free: "
<< s;
// Fall through to an immediate free if the callback could not be
// enqueued.

Comment on lines +100 to +103
[allocator, device_ordinal, memory]() {
absl::Status status = allocator->Deallocate(device_ordinal, memory);
if (!status.ok()) {
LOG(ERROR) << "Deferred buffer deallocation failed: " << status;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

observation: The lambda captures allocator (a raw pointer to DeviceAddressAllocator) by value. This is safe because LocalDeviceState::~LocalDeviceState() calls SynchronizeAllActivity() which drains all callback streams before the allocator (owned by PjRtStreamExecutorClient::owned_allocator_) is destroyed. However, the correctness depends on the member declaration order in PjRtStreamExecutorClient (owned_devices_ before owned_allocator_), which controls destruction order. Consider adding a comment on this line noting the lifetime dependency, so a future reordering doesn't silently introduce a use-after-free in the callback.

Comment on lines +91 to +98
// different stream) reuse and overwrite memory that a live kernel still
// owns. Defer the free until the compute stream drains past this point.
// ThenExecuteCallback runs the deallocation from a callback stream that
// waits on the compute stream, so the compute stream only records a
// lightweight event (it is not stalled) and the host never blocks.
if (local_device_->allocation_model() ==
LocalDeviceState::kComputeSynchronized) {
absl::Status s = local_device_->ThenExecuteCallback(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Under kComputeSynchronized, the original contract (per local_device_state.h) allows freeing a buffer once its last compute-stream use is enqueued (not completed). This change strengthens that by deferring the free until the compute stream actually drains past the deallocation point. While this is the right fix for the race, it does extend effective memory lifetimes — under heavy allocation churn, freed buffers are withheld from the pool until the compute stream catches up, which could increase peak device memory. The compute_semaphore_ bounds the host-ahead-of-device window and limits this effect, but it would be useful to call out this trade-off (perhaps in the comment block above) for future readers.

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Summary

Clean, well-motivated fix for a real use-after-free race under kComputeSynchronized. The approach of deferring deallocation via ThenExecuteCallback on the compute stream is sound — it synchronizes the free without stalling the compute stream or blocking the host.

3 inline comments posted:

  1. Log on fallback path — the ThenExecuteCallback failure status is silently dropped before falling through to immediate free; suggest logging it.
  2. Lifetime fragility — the captured raw allocator pointer is safe today due to member destruction order, but this dependency is implicit and worth documenting.
  3. Memory lifetime trade-off — deferring frees extends effective buffer lifetimes under churn; the compute_semaphore_ bounds this, but the trade-off is worth calling out in comments.

No correctness bugs found. The change is small, well-scoped, and the inline comments are all suggestions for hardening, not blockers.

@github-actions github-actions Bot removed the claude-review Request a Claude AI code review for this PR label Jun 22, 2026
@magaonka-amd magaonka-amd marked this pull request as draft June 23, 2026 00:08
@magaonka-amd magaonka-amd changed the title [ROCm] PJRT: Defer device buffer free until the compute stream drains [WIP][ROCm] PJRT: Defer device buffer free until the compute stream drains Jun 23, 2026
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