Skip to content

[ROCm][VMM] Selective copy-vs-remap for small command-buffer slices + auto threshold#926

Open
phambinhfin wants to merge 4 commits into
phambinh/rocm-vmm-allocatorfrom
phambinh/balance_vmm
Open

[ROCm][VMM] Selective copy-vs-remap for small command-buffer slices + auto threshold#926
phambinhfin wants to merge 4 commits into
phambinh/rocm-vmm-allocatorfrom
phambinh/balance_vmm

Conversation

@phambinhfin

@phambinhfin phambinhfin commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Dependency (read first)

This PR is on top of the ROCm VMM allocator (branch phambinh/rocm-vmm-allocator, proposed upstream as openxla/xla openxla#40236). The base of this PR is intentionally that branch, not main, so the diff here is only this optimization (gpu_executable.{cc,h}, +220/-8). Targeting main would drag in the whole VMM series, since it isn't in main yet. It should merge after the VMM allocator lands.

dynamic_cast<se::DeviceAddressVmmAllocator*>(memory_allocator) != nullptr

On NVIDIA the cast is null, use_command_buffer_va_remapping is false, the function is never entered, and XLA_VMM_TMP_COPY_THRESHOLD has no effect.

Optimization

In the VMM command-buffer VA-remapping path, every slice whose physical allocation churns each step is re-mapped via hipMemUnmap/hipMemMap/SetAccess. For the tiny scalar/scale/metric buffers that churn every step, that driver round-trip dominates and serializes across peer devices. Large tensors keep stable addresses across steps, so their remap is already skipped (free).

This keeps the small churning slices in a stable shadow buffer (fixed address baked into the command buffer once) and refreshes them with a cheap device-to-device copy each step (copy-in before exec, copy-back after, so output scalars like loss/metrics propagate). Large slices stay on the remap path.

The cutoff is chosen automatically via XLA_VMM_TMP_COPY_THRESHOLD:

value behavior
0 disabled: remap every slice (original behavior)
1 auto: derive the cutoff from device HBM bandwidth + capacity and currently-free HBM (DeviceMemoryUsage)
>1 force that exact byte cutoff

Default (unset) is auto.

Auto algorithm (=1)

The threshold is derived once, per VA-range setup, with no searching. The goal is to copy only the small churning slices, with the extra per-step copy bounded to a negligible amount and the shadow pool guaranteed to fit in free memory.

  1. Read device characteristics: HBM bandwidth bw (bytes/s) and capacity from DeviceDescription, plus currently-free HBM via executor->DeviceMemoryUsage(&free, &total). If bw is unknown, fall back to a fixed 64 KB cutoff.
  2. Compute two budgets (a bidirectional copy moves 2 * size bytes per admitted slice):
    • Per-slice cap — a slice is eligible only if its copy costs less than ~8 us: size <= 8us * bw / 2. This excludes large tensors outright.
    • Aggregate budget = min(~15us * bw, 1% of free HBM). The time term bounds the worst-case per-step overhead; the memory term guarantees the shadow pool always fits (never OOM), and it scales down automatically when the device is nearly full.
  3. Sort the sizes of all VMM-managed command-buffer slices ascending.
  4. Greedily admit smallest-first: walk the sorted sizes accumulating 2 * size; stop at the first slice that either exceeds the per-slice cap or would push the running total past the aggregate budget. The largest admitted size becomes the threshold.
  5. At run time: any slice <= threshold takes the shadow-copy path; everything larger stays on the remap path.

Why this is both safe and near-optimal: the smallest slices are exactly the churning scalars/scales whose per-step remap is the bottleneck, so admitting them captures essentially all the benefit. Large tensors are excluded twice over -- they blow the caps, and their remap was already free (stable address, skipped). The per-step copy overhead is bounded (~15 us) on any model, and the shadow footprint can't exceed the free-memory cap, so there is no manual tuning and no OOM risk. Empirically auto lands on the same plateau as a hand-tuned fixed cutoff (see below).

Benchmarks (MI300X x8, 8 layers / 30 steps, median of steps >=3; 0 unmap errors everywhere)

Two speedup columns with different meanings:

  • speedup (vs disabled)disabled (=0)auto (=1), both on VMM. This isolates this PR's change (copy-vs-remap only) and is always a positive, ~6–13 ms win.
  • auto vs baseauto (=1) vs base (no VMM). This compares the whole VMM stack (VMM allocator + NEVER_UPDATE + this optimization) against plain command buffers with no VMM, i.e. it folds in the cost/benefit of the underlying VMM series, not just this PR. A negative value (e.g. llama3_8b) means the VMM stack itself carries a small overhead on that model that this optimization narrows but doesn't fully erase.

All base (no VMM) numbers are medians of 3 runs (steps ≥3), same as the other columns. On the small models (llama2_7b, llama3.1-8b, gemma2-9b) the VMM legs are dramatically faster than no-VMM because the per-step command-buffer remap is far cheaper than re-recording the buffer every step; on llama3_8b/mixtral_8x1b base and VMM are within noise.

auto + collective leg (coll_vmm): before (disabled) vs after (auto)

Model base (no VMM) disabled (=0) auto (=1) saved speedup (vs disabled) auto vs base
llama2_7b 289.0 ms 133.5 ms 121.0 ms 12.5 ms 9.4% +58.1%
llama3_8b 154.0 ms 168.0 ms 156.5 ms 11.5 ms 6.8% −1.6%
mixtral_8x1b 681.0 ms 690.5 ms 677.5 ms 13.0 ms 1.9% +0.5%
llama3.1-8b 761.0 ms 713.5 ms 704.5 ms 9.0 ms 1.3% +7.4%
gemma2-9b 1528.0 ms 1319.5 ms 1313.0 ms 6.5 ms 0.5% +14.1%

vmm leg (no collectives): before (disabled) vs after (auto)

Model base (no VMM) disabled (=0) auto (=1) saved speedup (vs disabled) auto vs base
llama2_7b 289.0 ms 138.5 ms 126.0 ms 12.5 ms 9.0% +56.4%
llama3_8b 154.0 ms 168.5 ms 158.0 ms 10.5 ms 6.2% −2.6%
mixtral_8x1b 681.0 ms 697.0 ms 685.0 ms 12.0 ms 1.7% −0.6%

The saving is a roughly fixed per-step cost (~6-13 ms; the work removed is the remap of the small churning slices, whose count is set by command-buffer structure, not model size). It shows up as a larger percentage on short-step models and a smaller one on heavy models, but is always a net win and never regresses. auto matches a hand-tuned fixed 4 KB cutoff (llama2_7b: auto 121 ms vs fixed-4K 120 ms).

Notes

  • Marked [EXPERIMENT] - behind an env knob, default auto, ROCm-gated.

…dow instead of VA-remapping

Adds an opt-in size threshold (env XLA_VMM_TMP_COPY_THRESHOLD, bytes; 0 =
disabled = current behavior) for the command-buffer VA remapping path. Slices
whose size is <= threshold (the tiny scale/scalar/metric buffers that churn
addresses every step) are excluded from the VA reservation and instead given a
stable shadow device buffer whose address is baked into the command buffer once.

Each step the slice is refreshed with a bidirectional device-to-device copy on
the execution stream: real->shadow before execution (fresh inputs) and
shadow->real after execution (propagate output scalars such as loss/token-count
back to the buffers the host reads). This avoids the expensive
hipMemUnmap/hipMemMap/hipMemSetAccess round-trip (which serializes across peer
devices on ROCm) for those small buffers, while large slices still remap.

This is the "balanced copy vs remap" idea discussed with the NVIDIA reviewer.

llama2_7b, FSDP=8, 8xMI300X, coll_vmm leg (30 steps), loss matches baseline:
  threshold=0     : 132 ms  (remap all)
  threshold=256B  : 120 ms
  threshold=4096B : 121 ms
  threshold=65536B: 122 ms
…sized to free HBM

Turn XLA_VMM_TMP_COPY_THRESHOLD into a 3-way control:
  0  -> disabled: remap every command-buffer slice (original behavior)
  1  -> auto: derive the byte cutoff from this device's HBM bandwidth and
        capacity AND the memory currently free (via DeviceMemoryUsage), so the
        stable shadow pool is always guaranteed to fit (no searching, no OOM)
  >1 -> force that exact byte cutoff (explicit override / sweeps)

The auto path admits the smallest slices first while bounding (a) per-slice copy
time, (b) aggregate per-step copy time, and (c) the shadow footprint by a
fraction of currently-free HBM. Large tensors keep stable addresses across steps
(their remap is already skipped/free), so they stay on the remap path; only the
tiny churning scalar/scale/metric slices are copied into a stable shadow.

ROCm-only: the entire path is gated on DeviceAddressVmmAllocator, so on NVIDIA
the cast is null, ExecuteThunksWithVaRemapping is never entered, and the env var
has no effect. NVIDIA behavior is unchanged.
Comment thread xla/service/gpu/gpu_executable.cc Outdated
Comment on lines +1548 to +1629
uint64_t copy_threshold = 0;
{
const char* e = std::getenv("XLA_VMM_TMP_COPY_THRESHOLD");
// Unset defaults to auto (mode 1) so the smart selector is the default.
uint64_t mode = 1;
if (e != nullptr) {
mode = std::strtoull(e, nullptr, 10);
}
if (mode == 0) {
copy_threshold = 0; // disabled
} else if (mode > 1) {
copy_threshold = mode; // explicit byte cutoff
} else {
// mode == 1: auto-derive from device properties + free memory.
//
// Greedy admit-smallest-first: pull command-buffer slices into the copy
// set in increasing size order while (a) no single slice's bidirectional
// copy exceeds kPerBufferCopyUs, and (b) the aggregate per-step copy stays
// under a budget that is the MIN of a copy-time cap and a memory cap. The
// memory cap is taken from the currently-free HBM (kFreeHbmFraction of
// free), which guarantees the shadow pool always fits -- "always enough
// space" -- rather than from a blind fraction of total capacity. Large
// tensors blow these budgets and stay on the remap path; since they keep
// stable addresses across steps their remap is already skipped (free).
constexpr double kPerBufferCopyUs = 8.0;
constexpr double kTotalCopyUs = 15.0;
constexpr double kShadowHbmFraction = 0.005; // of total (fallback)
constexpr double kFreeHbmFraction = 0.01; // of free (primary cap)
constexpr uint64_t kFallbackThreshold = 65536;
const se::DeviceDescription& dd = executor->GetDeviceDescription();
const int64_t bw = dd.memory_bandwidth(); // bytes/sec
const int64_t hbm = dd.device_memory_size(); // bytes
if (bw <= 0) {
// Bandwidth unknown: fall back to a conservative cutoff that still
// captures scalar/scale buffers.
copy_threshold = kFallbackThreshold;
} else {
// acc accumulates 2*size per admitted slice: bidirectional copy moves
// 2 bytes per shadow byte, and the shadow is allocated per VA set, so
// 2*size also bounds the double-buffered footprint.
const double per_buffer_cap =
kPerBufferCopyUs * 1e-6 * static_cast<double>(bw) / 2.0;
double total_budget = kTotalCopyUs * 1e-6 * static_cast<double>(bw);
// Memory cap: prefer the live free-memory reading so the shadow pool is
// bounded by what the device can actually spare right now.
int64_t free_mem = 0;
int64_t total_mem = 0;
double mem_cap = 0.0;
if (executor->DeviceMemoryUsage(&free_mem, &total_mem) &&
free_mem > 0) {
mem_cap = kFreeHbmFraction * static_cast<double>(free_mem);
} else if (hbm > 0) {
mem_cap = kShadowHbmFraction * static_cast<double>(hbm);
}
if (mem_cap > 0.0) {
total_budget = std::min(total_budget, mem_cap);
}
std::vector<uint64_t> sizes;
sizes.reserve(command_buffer_allocation_indexes_.size());
for (BufferAllocation::Index i : command_buffer_allocation_indexes_) {
se::DeviceAddressBase b = buffer_allocations.GetDeviceAddress(i);
if (vmm_allocator->GetRawAllocation(executor->device_ordinal(), b) ==
nullptr) {
continue;
}
sizes.push_back(b.size());
}
std::sort(sizes.begin(), sizes.end());
double acc = 0.0;
for (uint64_t s : sizes) {
if (static_cast<double>(s) > per_buffer_cap) break;
if (acc + 2.0 * static_cast<double>(s) > total_budget) break;
acc += 2.0 * static_cast<double>(s);
copy_threshold = s;
}
XLA_VLOG_DEVICE(2, executor->device_ordinal()) << absl::StreamFormat(
"VA remapping: auto copy threshold=%d B (bw=%d GB/s hbm=%d GB "
"free=%d GB budget=%.0f B)",
copy_threshold, bw / 1000000000, hbm / 1000000000,
free_mem / 1000000000, total_budget);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug (high): Threshold recomputed every step with non-deterministic inputs can cause VA reservation overflow.

copy_threshold is recalculated on every call because it depends on DeviceMemoryUsage(&free_mem, &total_mem), which changes as memory is allocated/freed. The VA reservation is sized once (line 1641 guard: va_ranges->va_reservation == nullptr) using the threshold from the first step. If free memory decreases on a later step, the auto threshold shrinks, fewer slices are classified as "small", and the offset-assignment loop (lines 1693–1711) computes a current_offset that exceeds the reserved VA size. The subsequent MapTo/Remap call would then reference offsets beyond the reservation — out-of-bounds VA mapping, potential memory corruption, or driver errors.

Suggested fix: Compute the threshold once and cache it in VaRanges, or re-validate that current_offset <= total_va_size and return an error / fall back if it overflows.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved ✓ — addressed in this revision. The threshold is now computed once in ComputeCopyThreshold() and frozen in VaRanges::copy_threshold via the initialized guard, so the VA reservation is sized exactly once and cannot be overflowed by later free-memory fluctuations.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved in this revision: threshold is now computed once in ComputeCopyThreshold() and frozen via the initialized guard, preventing the cross-step drift that could cause VA reservation overflow.

Comment on lines +354 to +355
absl::flat_hash_map<BufferAllocation::Index, se::DeviceAddressBase>
small_shadow;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug (high): Device memory leak of shadow buffers on GpuExecutable destruction.

The small_shadow map stores DeviceAddressBase values (raw pointer + size, no RAII). Each shadow buffer is allocated with executor->Allocate(sz) (line 1798 of gpu_executable.cc) but is never deallocated when the GpuExecutable is destroyed — the destructor does not iterate module_va_ranges_ to free shadow buffers. Over the lifetime of an application that creates and destroys executables, this leaks device memory.

Suggested fix: Add cleanup in the destructor that iterates module_va_ranges_ and deallocates every DeviceAddressBase in small_shadow. Alternatively, store DeviceAddressHandle (which has RAII semantics) instead of raw DeviceAddressBase.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved ✓ — addressed in this revision. VaRanges::~VaRanges() now deallocates all shadow buffers via the stored executor pointer, preventing device memory leaks on destruction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved in this revision: ~VaRanges() destructor now iterates small_shadow and calls executor->Deallocate() on each shadow buffer.

Comment thread xla/service/gpu/gpu_executable.cc Outdated
Comment on lines +1550 to +1554
const char* e = std::getenv("XLA_VMM_TMP_COPY_THRESHOLD");
// Unset defaults to auto (mode 1) so the smart selector is the default.
uint64_t mode = 1;
if (e != nullptr) {
mode = std::strtoull(e, nullptr, 10);

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 (low): std::getenv called on every step, and std::strtoull silently returns 0 on invalid input.

  1. std::getenv is not thread-safe if any thread calls setenv/putenv concurrently (UB per C standard). More practically, re-parsing the env var each step is unnecessary work — consider caching in a static local.

  2. If XLA_VMM_TMP_COPY_THRESHOLD is set to a non-numeric string (e.g., "auto", "true"), std::strtoull returns 0, which silently disables the optimization. Consider logging a warning or using absl::SimpleAtoi which provides explicit error reporting.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved ✓ — addressed in this revision. std::getenv is now called once per VA range (inside the !initialized guard) rather than every step, and absl::SimpleAtoi replaces std::strtoull with a proper parse-failure warning.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved in this revision: std::getenv is now called once per VA range init (not every step), and absl::SimpleAtoi replaces std::strtoull for safe parsing.

Comment on lines +1791 to +1811
if (is_small_copy_slice(original_buffer.size())) {
const uint64_t sz = original_buffer.size();
auto it = va_ranges->small_shadow.find(i);
if (it == va_ranges->small_shadow.end() || it->second.size() < sz) {
if (it != va_ranges->small_shadow.end()) {
executor->Deallocate(&it->second);
}
se::DeviceAddressBase shadow = executor->Allocate(sz);
if (shadow.is_null()) {
return Internal(
"Failed to allocate %d-byte shadow for small command-buffer "
"slice %d",
sz, i);
}
it = va_ranges->small_shadow.insert_or_assign(i, shadow).first;
}
small_copies.push_back({/*shadow=*/it->second, /*real=*/original_buffer,
/*size=*/sz});
small_mapped[i] = se::DeviceAddressBase(it->second.opaque(), sz);
continue;
}

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 (low): Shadow buffer lookup uses size() from DeviceAddressBase, which may not reflect the originally requested allocation size.

When checking it->second.size() < sz (line 1794), if executor->Allocate(sz) returns a buffer with a different (rounded-up) size than sz, the stored size reflects the allocator's rounded size, not the original sz. On the next call, this could prevent reuse of a perfectly adequate shadow or cause unnecessary reallocations. Consider storing the requested size separately or using >= with the original requested size.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved ✓ — addressed in this revision. The code now documents why size() is safe here: Allocate() returns a buffer whose size() is the allocated capacity (>= requested), so rounding only makes reuse more likely, and reallocating only triggers when capacity is genuinely too small.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved in this revision: the code now documents why size() comparison is safe (allocator rounding only makes reuse more likely, never less).

Comment thread xla/service/gpu/gpu_executable.cc Outdated
Comment on lines +1525 to +1633
// EXPERIMENT: automatic copy-vs-remap selection for small command-buffer
// slices. The tiny scale/scalar/metric buffers churn their physical address
// every step; remapping them costs an hipMemUnmap/Map/SetAccess round-trip
// that serializes across peer devices on ROCm. Instead we keep such a slice
// in a stable shadow buffer (fixed address baked into the command buffer once)
// and refresh it with a cheap device-to-device copy each step.
//
// The size cutoff is chosen AUTOMATICALLY from the device's HBM bandwidth and
// capacity (no manual tuning): admit command-buffer slices smallest-first
// into the copy set while (a) no single slice's copy exceeds kPerBufferCopyUs,
// (b) the total per-step copy stays under kTotalCopyUs, and (c) the shadow
// footprint stays under kShadowHbmFraction of HBM. Large tensors blow these
// budgets and stay on the remap path -- and since they keep stable addresses
// across steps, remapping them is already free (skipped). The per-step copy
// budget bounds the worst-case overhead, so "auto" is safe on any model.
//
// Control via env XLA_VMM_TMP_COPY_THRESHOLD (default = auto):
// "0" -> disabled: every slice stays on the remap path (original path).
// "1" -> auto: derive the byte cutoff from this device's HBM bandwidth and
// capacity AND the memory currently free, so the shadow pool is
// always guaranteed to fit (no searching, never risks OOM).
// ">1" -> force exactly that byte cutoff (explicit override / sweeps).
// We reserve "1" for auto because a literal 1-byte cutoff would admit nothing.
uint64_t copy_threshold = 0;
{
const char* e = std::getenv("XLA_VMM_TMP_COPY_THRESHOLD");
// Unset defaults to auto (mode 1) so the smart selector is the default.
uint64_t mode = 1;
if (e != nullptr) {
mode = std::strtoull(e, nullptr, 10);
}
if (mode == 0) {
copy_threshold = 0; // disabled
} else if (mode > 1) {
copy_threshold = mode; // explicit byte cutoff
} else {
// mode == 1: auto-derive from device properties + free memory.
//
// Greedy admit-smallest-first: pull command-buffer slices into the copy
// set in increasing size order while (a) no single slice's bidirectional
// copy exceeds kPerBufferCopyUs, and (b) the aggregate per-step copy stays
// under a budget that is the MIN of a copy-time cap and a memory cap. The
// memory cap is taken from the currently-free HBM (kFreeHbmFraction of
// free), which guarantees the shadow pool always fits -- "always enough
// space" -- rather than from a blind fraction of total capacity. Large
// tensors blow these budgets and stay on the remap path; since they keep
// stable addresses across steps their remap is already skipped (free).
constexpr double kPerBufferCopyUs = 8.0;
constexpr double kTotalCopyUs = 15.0;
constexpr double kShadowHbmFraction = 0.005; // of total (fallback)
constexpr double kFreeHbmFraction = 0.01; // of free (primary cap)
constexpr uint64_t kFallbackThreshold = 65536;
const se::DeviceDescription& dd = executor->GetDeviceDescription();
const int64_t bw = dd.memory_bandwidth(); // bytes/sec
const int64_t hbm = dd.device_memory_size(); // bytes
if (bw <= 0) {
// Bandwidth unknown: fall back to a conservative cutoff that still
// captures scalar/scale buffers.
copy_threshold = kFallbackThreshold;
} else {
// acc accumulates 2*size per admitted slice: bidirectional copy moves
// 2 bytes per shadow byte, and the shadow is allocated per VA set, so
// 2*size also bounds the double-buffered footprint.
const double per_buffer_cap =
kPerBufferCopyUs * 1e-6 * static_cast<double>(bw) / 2.0;
double total_budget = kTotalCopyUs * 1e-6 * static_cast<double>(bw);
// Memory cap: prefer the live free-memory reading so the shadow pool is
// bounded by what the device can actually spare right now.
int64_t free_mem = 0;
int64_t total_mem = 0;
double mem_cap = 0.0;
if (executor->DeviceMemoryUsage(&free_mem, &total_mem) &&
free_mem > 0) {
mem_cap = kFreeHbmFraction * static_cast<double>(free_mem);
} else if (hbm > 0) {
mem_cap = kShadowHbmFraction * static_cast<double>(hbm);
}
if (mem_cap > 0.0) {
total_budget = std::min(total_budget, mem_cap);
}
std::vector<uint64_t> sizes;
sizes.reserve(command_buffer_allocation_indexes_.size());
for (BufferAllocation::Index i : command_buffer_allocation_indexes_) {
se::DeviceAddressBase b = buffer_allocations.GetDeviceAddress(i);
if (vmm_allocator->GetRawAllocation(executor->device_ordinal(), b) ==
nullptr) {
continue;
}
sizes.push_back(b.size());
}
std::sort(sizes.begin(), sizes.end());
double acc = 0.0;
for (uint64_t s : sizes) {
if (static_cast<double>(s) > per_buffer_cap) break;
if (acc + 2.0 * static_cast<double>(s) > total_budget) break;
acc += 2.0 * static_cast<double>(s);
copy_threshold = s;
}
XLA_VLOG_DEVICE(2, executor->device_ordinal()) << absl::StreamFormat(
"VA remapping: auto copy threshold=%d B (bw=%d GB/s hbm=%d GB "
"free=%d GB budget=%.0f B)",
copy_threshold, bw / 1000000000, hbm / 1000000000,
free_mem / 1000000000, total_budget);
}
}
}
auto is_small_copy_slice = [copy_threshold](uint64_t size) {
return copy_threshold > 0 && size <= copy_threshold;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (low): ~110 lines of inline threshold logic in an already-long function.

Consider extracting the threshold computation into a separate helper method (e.g., ComputeCopyThreshold) to improve readability and testability. The helper could also be unit-tested independently with mock device descriptions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved ✓ — addressed in this revision. The threshold logic is now extracted into ComputeCopyThreshold() (member function) and MeasureCopyVsRemapBreakeven() (free function in anonymous namespace), significantly reducing inline complexity.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved in this revision: threshold logic extracted into ComputeCopyThreshold() and calibration into MeasureCopyVsRemapBreakeven(), improving readability and testability.

Comment on lines +1662 to 1686
// a slot in the contiguous VA reservation.
if (is_small_copy_slice(buf.size())) {
continue;
}
total_va_size += round_up_to_granularity(buf.size());
}

// Reserve a single large VA range for all command buffer allocations.
ASSIGN_OR_RETURN(va_ranges->va_reservation,
vmm_allocator->CreateReservation(executor, total_va_size));
// With the copy-to-shadow experiment a large threshold can make every
// slice "small", leaving nothing to reserve; skip reservation in that case
// (all slices then flow through the shadow copy path).
ASSIGN_OR_RETURN(va_ranges->unmap_event, executor->CreateEvent());

XLA_VLOG_DEVICE(3, executor->device_ordinal()) << absl::StreamFormat(
"VA remapping: Reserved single VA range for module %s "
"VA: %p total_size: %d granularity: %d",
module_name_, va_ranges->va_reservation->address().opaque(),
total_va_size, granularity);
if (total_va_size > 0) {
ASSIGN_OR_RETURN(
va_ranges->va_reservation,
vmm_allocator->CreateReservation(executor, total_va_size));
XLA_VLOG_DEVICE(3, executor->device_ordinal()) << absl::StreamFormat(
"VA remapping: Reserved single VA range for module %s "
"VA: %p total_size: %d granularity: %d",
module_name_, va_ranges->va_reservation->address().opaque(),
total_va_size, granularity);
}
}
// NOTE: the actual unmap of a previously established mapping is deferred to
// the decision point below, so it can be skipped entirely when the physical

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug (medium): Re-initialization on every step when all slices are "small."

The initialization guard is if (va_ranges->va_reservation == nullptr). When the copy threshold makes all slices "small" (e.g., a very large explicit threshold), va_reservation is never set (total_va_size == 0, skips the reservation). On subsequent steps, the guard fires again, re-scanning all sizes, re-creating unmap_event via ASSIGN_OR_RETURN. The old unique_ptr<Event> is destroyed by the assignment, but there is a window where the previously-recorded event (line 1984) may still be in-flight on the GPU when it is destroyed — potentially undefined behavior on the HIP runtime.

Additionally, the redundant per-step work undermines the "compute once" design intent.

Suggested fix: Add a separate bool initialized flag to VaRanges, or use unmap_event != nullptr as the guard instead of va_reservation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved ✓ — addressed in this revision. The init guard is now !va_ranges->initialized (an explicit bool) instead of va_reservation == nullptr, so the one-time setup block runs exactly once even when every slice is small and the reservation stays null.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved in this revision: init guard is now !va_ranges->initialized (a bool set once after setup) instead of va_reservation == nullptr, preventing re-initialization when all slices are small.

@claude

claude Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Summary

This PR adds a copy-vs-remap optimization for the VMM command-buffer VA-remapping path. Small buffer slices are kept in stable "shadow" device buffers refreshed via D2D copies, avoiding expensive hipMemUnmap/hipMemMap/SetAccess driver round-trips. The threshold is auto-derived from device bandwidth and free memory, controlled via XLA_VMM_TMP_COPY_THRESHOLD.

Key findings (see inline comments for details):

Severity Count Summary
High 2 VA reservation overflow when auto-threshold shrinks across steps; device memory leak of shadow buffers on destruction
Medium 1 Re-initialization every step when all slices are small (in-flight event destruction race)
Low 3 std::getenv per-step + silent parse failure; shadow size comparison mismatch; extractable helper function

The most critical issue is that the auto-computed copy_threshold can change between steps (free memory fluctuates), but the VA reservation is sized only once on the first step. If the threshold shrinks later, more slices go to the VA remap path than the reservation can hold, causing out-of-bounds VA mappings.

@github-actions github-actions Bot removed the claude-review Request a Claude AI code review for this PR label Jun 8, 2026
…s, robustify

Resolves the Claude review on PR #926:

- Compute the copy-vs-remap threshold ONCE and cache it in VaRanges
  (copy_threshold), frozen for the VA range's lifetime. The VA reservation
  is sized exactly once from it, so a later step recomputing a smaller
  threshold (free HBM fluctuates) can no longer push more slices onto the
  remap path than the reservation can hold. [high: VA reservation overflow]

- Add ~VaRanges() destructor that deallocates the small_shadow buffers via
  the captured executor. DeviceAddressBase has no RAII, so these leaked on
  GpuExecutable destruction. [high: shadow buffer leak]

- Gate one-time setup on an explicit `initialized` flag instead of
  `va_reservation == nullptr`. When every slice is small the reservation
  legitimately stays null, which previously re-ran setup each step and
  re-created unmap_event while a recorded copy could still be in flight.
  [medium: re-init / in-flight event destruction]

- Parse XLA_VMM_TMP_COPY_THRESHOLD once with absl::SimpleAtoi; a non-numeric
  value now logs a warning and falls back to auto instead of strtoull
  silently returning 0 (disable). Removes the per-step getenv. [low]

- Extract the threshold logic into GpuExecutable::ComputeCopyThreshold for
  readability/testability. [low]

- Clarify shadow-buffer reuse comment: small_shadow stores Allocate()
  capacity (>= requested), so rounding only aids reuse. [low]

NVIDIA unaffected (path still gated on DeviceAddressVmmAllocator).
@phambinhfin

phambinhfin commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author
# Severity Finding Fix
1 high Threshold recomputed each step → VA reservation overflow The threshold is now computed once in ComputeCopyThreshold and frozen in VaRanges::copy_threshold. The reservation is sized exactly once from this cached value, so a later step recomputing a smaller threshold (free HBM fluctuates) can never push more slices onto the remap path than the reservation holds.
2 high Shadow buffers leaked on GpuExecutable destruction Added ~VaRanges() which deallocates every small_shadow buffer via a captured executor (set at init). DeviceAddressBase has no RAII, hence the leak.
3 medium Re-init every step when all slices small → in-flight unmap_event destruction Init is now gated on an explicit bool initialized flag instead of va_reservation == nullptr (which legitimately stays null when everything is small), so setup + event creation run exactly once.
4 low std::getenv per step + strtoull silently → 0 Env is parsed once (inside the cached helper) with absl::SimpleAtoi; a non-numeric value logs a warning and falls back to auto rather than silently disabling.
5 low Shadow size() vs requested-size comparison Clarified with a comment: small_shadow stores the Allocate() capacity (>= requested), so any rounding only makes reuse more likely; we reallocate only when capacity is genuinely too small. No behavioral change.
6 low ~110 lines of inline threshold logic Extracted into GpuExecutable::ComputeCopyThreshold(...) (declared in the header), shrinking ExecuteThunksWithVaRemapping and making the logic independently testable.

Fixes 1, 3 and 4 share the same root cause (recomputing per step), so caching the threshold in VaRanges resolves all three together. NVIDIA remains unaffected — the whole path is still gated on DeviceAddressVmmAllocator. Benchmark numbers are unchanged since these are correctness/cleanup fixes that don't alter steady-state timing.

@phambinhfin phambinhfin added the claude-review Request a Claude AI code review for this PR label Jun 8, 2026
Comment thread xla/service/gpu/gpu_executable.cc Outdated
Comment on lines +1656 to +1670
static absl::Mutex* const kCalibMu = new absl::Mutex();
static auto* const kCalibCache = new absl::flat_hash_map<int, uint64_t>();
std::optional<uint64_t> measured;
{
absl::MutexLock l(kCalibMu);
auto it = kCalibCache->find(executor->device_ordinal());
if (it != kCalibCache->end()) measured = it->second;
}
if (!measured.has_value()) {
absl::StatusOr<uint64_t> m = MeasureCopyVsRemapBreakeven(
executor, vmm_allocator, stream, granularity);
if (m.ok()) {
measured = *m;
absl::MutexLock l(kCalibMu);
(*kCalibCache)[executor->device_ordinal()] = *m;

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 (low): TOCTOU in calibration cache — benign but avoidable with try_emplace.

Two VA range indices on the same executor hold different va_ranges->mutex locks, so both can enter ComputeCopyThreshold concurrently for the same device ordinal. Both will see !measured.has_value(), both will run the full MeasureCopyVsRemapBreakeven probe (~53 remap iterations + 20 copy iterations), and both will write the result.

This is functionally correct (the cache write is idempotent), but wastes ~1-2 ms of startup time per extra VA range index and temporarily doubles probe memory. A simple fix is to hold kCalibMu across the measurement (the probe runs once per device, not per step, so contention is negligible):

Suggested change
static absl::Mutex* const kCalibMu = new absl::Mutex();
static auto* const kCalibCache = new absl::flat_hash_map<int, uint64_t>();
std::optional<uint64_t> measured;
{
absl::MutexLock l(kCalibMu);
auto it = kCalibCache->find(executor->device_ordinal());
if (it != kCalibCache->end()) measured = it->second;
}
if (!measured.has_value()) {
absl::StatusOr<uint64_t> m = MeasureCopyVsRemapBreakeven(
executor, vmm_allocator, stream, granularity);
if (m.ok()) {
measured = *m;
absl::MutexLock l(kCalibMu);
(*kCalibCache)[executor->device_ordinal()] = *m;
static absl::Mutex* const kCalibMu = new absl::Mutex();
static auto* const kCalibCache = new absl::flat_hash_map<int, uint64_t>();
std::optional<uint64_t> measured;
{
absl::MutexLock l(kCalibMu);
auto [it, inserted] = kCalibCache->try_emplace(executor->device_ordinal(), 0);
if (!inserted) {
measured = it->second;
} else {
absl::StatusOr<uint64_t> m = MeasureCopyVsRemapBreakeven(
executor, vmm_allocator, stream, granularity);
if (m.ok()) {
it->second = *m;
measured = *m;
} else {
kCalibCache->erase(it);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved ✓ — addressed in this revision. The calibration cache now uses try_emplace under kCalibMu held across the probe, preventing duplicate measurements for the same device ordinal.

constexpr double kMinRemapUs = 0.05;
constexpr double kMaxRemapUs = 5000.0;
if (!(c_us > kMinRemapUs && c_us < kMaxRemapUs)) {
return Internal("calibration: implausible remap cost %.3f us", c_us);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug (medium): Internal() uses absl::StrCat, not printf-style formatting — %.3f will appear as a literal string.

Internal("calibration: implausible remap cost %.3f us", c_us) expands to absl::InternalError(absl::StrCat("calibration: implausible remap cost %.3f us", c_us)), producing something like "calibration: implausible remap cost %.3f us42.7" instead of the intended "calibration: implausible remap cost 42.700 us".

Suggested change
return Internal("calibration: implausible remap cost %.3f us", c_us);
return absl::InternalError(absl::StrFormat(
"calibration: implausible remap cost %.3f us", c_us));

Comment on lines +1986 to +1989
return Internal(
"Failed to allocate %d-byte shadow for small command-buffer "
"slice %d",
sz, i);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug (medium): Same Internal() format-string issue — %d specifiers are not expanded.

Internal() delegates to absl::StrCat, not printf. The error message will read something like "Failed to allocate %d-byte shadow for small command-buffer slice %d64016" instead of substituting the values. Use absl::StrFormat or absl::StrCat with separate arguments:

Suggested change
return Internal(
"Failed to allocate %d-byte shadow for small command-buffer "
"slice %d",
sz, i);
return absl::InternalError(absl::StrFormat(
"Failed to allocate %d-byte shadow for small command-buffer "
"slice %d",
sz, i));

Comment thread xla/service/gpu/gpu_executable.cc Outdated
Comment on lines +1693 to +1694
double total_budget =
measured.has_value() ? (mem_cap > 0.0 ? mem_cap : 1e18)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (medium): Unbounded shadow pool when device memory reporting fails.

When measured.has_value() is true but both DeviceMemoryUsage() and device_memory_size() return 0 (broken memory reporting), mem_cap stays 0 and total_budget is set to 1e18 — effectively infinite. The shadow pool then has no memory cap: every slice below the measured break-even is admitted regardless of aggregate footprint, which could cause OOM.

Consider falling back to a conservative fixed budget (e.g., 1 MB) rather than infinity, or at least logging a warning when neither memory source is available:

measured.has_value() ? (mem_cap > 0.0 ? mem_cap : 1048576.0)  // 1 MB safety cap
                     : kTotalCopyUs * 1e-6 * static_cast<double>(bw);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved ✓ — addressed in this revision. When mem_cap == 0 (broken memory reporting), the code now falls back to copy_time_budget instead of leaving the budget unbounded.

Comment on lines +1709 to +1717
std::sort(sizes.begin(), sizes.end());
uint64_t copy_threshold = 0;
double acc = 0.0;
for (uint64_t s : sizes) {
if (static_cast<double>(s) > per_buffer_cap) break;
if (acc + 2.0 * static_cast<double>(s) > total_budget) break;
acc += 2.0 * static_cast<double>(s);
copy_threshold = s;
}

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 (low): Greedy threshold can overrun the budget for duplicate-sized slices.

The loop sets copy_threshold = s after budgeting for one slice of size s. At runtime, is_small_copy_slice uses size <= copy_threshold, admitting all slices of that size — but the budget may have only afforded K out of N slices at the boundary size. The overrun is bounded by (N-K) * 2 * s bytes, which is likely negligible for the small scalars targeted here.

If you wanted strict budget adherence, you could count all slices at each distinct size before admitting that size bucket. Not urgent given the target use case.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved ✓ — addressed in this revision. The potential overrun is now explicitly documented (lines 1728-1732) as bounded by (count_at_size - 1) * 2 * size, which is negligible for the tiny scalar/scale slices this targets.

@claude

claude Bot commented Jun 8, 2026

Copy link
Copy Markdown

Re-review Summary (commit 30c2f2f)

All 6 findings from the previous review have been addressed in this revision — resolved replies posted on each thread. Key improvements: threshold frozen at init via initialized guard, ~VaRanges() destructor frees shadow buffers, absl::SimpleAtoi replaces strtoull, init guard no longer relies on va_reservation == nullptr.

4 new findings posted inline:

  • Internal() calls using printf-style format specifiers (%.3f, %d) that won't be expanded (medium)
  • Unbounded shadow pool when device memory reporting fails — total_budget set to 1e18 (medium)
  • Greedy threshold budget overrun for duplicate-sized slices at the boundary (low)

@github-actions github-actions Bot removed the claude-review Request a Claude AI code review for this PR label Jun 8, 2026
…ation TOCTOU

Resolves the actionable findings from the Claude re-review of PR #926
(commit 30c2f2f):

- Unbounded shadow pool when device memory reporting fails [medium]: when a
  measured break-even exists but DeviceMemoryUsage() and device_memory_size()
  both report 0, total_budget was set to 1e18 (effectively infinite), admitting
  every below-break-even slice regardless of aggregate footprint (OOM risk).
  Now falls back to the copy-time budget (~kTotalCopyUs of bandwidth), the same
  principled bound the no-measurement branch already uses.

- Calibration-cache TOCTOU [low]: two VA-range indices on the same executor
  could both run the expensive MeasureCopyVsRemapBreakeven probe concurrently.
  Now uses try_emplace under a single kCalibMu lock to reserve the slot before
  probing, erasing it on failure so a later call can retry.

- Greedy duplicate-size budget overrun [low]: documented the bounded, negligible
  overrun when multiple slices share the boundary size (reviewer: not urgent).

Not changed: the two "Internal() format specifiers won't expand" findings are
false positives. xla::Internal() is the absl::StrFormat overload (util.h);
the StrCat variant is the separate InternalStrCat type. %.3f/%d expand
correctly, as the pre-existing Internal("...%d...", i) calls in this file show.

Correctness/robustness only; steady-state timing and benchmark numbers unchanged.
NVIDIA unaffected (path still gated on DeviceAddressVmmAllocator).
@phambinhfin phambinhfin added the claude-review Request a Claude AI code review for this PR label Jun 9, 2026
@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

Re-review Summary (commit 8a464d0)

All 11 findings from the prior review have been addressed in this revision:

  • 9 resolved: threshold recomputation, shadow leak, getenv-per-step, re-init guard, extraction to helper, TOCTOU, unbounded shadow pool, greedy overrun, size() safety — all fixed or documented.
  • 2 still present (already flagged, no new comment): Internal() format-string mismatches at lines 1539 and 2006 — %.3f and %d are not expanded by absl::StrCat-style Internal().

No new issues found in this revision. The code quality has improved significantly.

🤖 Generated with Claude Code

// median of real unmap/map/setaccess round-trips on a probe allocation, and bw
// as a real D2D copy -- so the cutoff is self-calibrating instead of a guess.
// Returns the break-even size in bytes, or an error (caller falls back).
absl::StatusOr<uint64_t> MeasureCopyVsRemapBreakeven(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This can be soo nondeterminitic. This should be some good enough treashold value with an escape hatch for user to sets its own/disable it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks Dragan, let me try to find good threadhold.

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.

2 participants