Skip to content

[xla:gpu][ROCm] VMM command-buffer: skip-remap + copy-into-shadow for launch-bound regimes#1001

Draft
phambinhfin wants to merge 10 commits into
main-mirrorfrom
phambinh/vmm-skip-copy-shadow
Draft

[xla:gpu][ROCm] VMM command-buffer: skip-remap + copy-into-shadow for launch-bound regimes#1001
phambinhfin wants to merge 10 commits into
main-mirrorfrom
phambinh/vmm-skip-copy-shadow

Conversation

@phambinhfin

@phambinhfin phambinhfin commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary (DRAFT — for review, not for merge yet)

Two ROCm-gated optimizations to the VMM command-buffer path (ExecuteWithVaRemapping) that make capturing the graph once and replaying it (NEVER_UPDATE) a net win in launch-bound (tensor-parallel) regimes, where per-step VA remapping was previously eating the benefit.

Both changes are ROCm-only; the CUDA path and default ROCm behavior are unchanged.

1. Skip remapping unchanged VA slots

The old path unmapped the whole reserved VA range and re-mapped every command-buffer allocation every step, even though most slots (weights) keep the same backing physical buffer step-to-step. On ROCm the unmap is the dominant per-step cost.

This caches each slot's source (BFC) address from the previous step and only marks remap_required for slots whose backing buffer actually moved, reusing the already-in-tree, unit-tested se::MemoryReservation::ScopedMapping::Remap() primitive. In steady state (nothing moved) it issues zero driver calls and skips the unmap-event sync.

  • Gate: XLA_VMM_SKIP_REMAPdefault ON for ROCm; set 0 to force the legacy path for A/B.

2. Copy-into-shadow for tiny churning slots

Small slices (scale/scalar/metric buffers) change address every step and are the worst case for remapping (a full unmap/map/SetAccess round-trip costs milliseconds even for a few bytes, and there can be many per step). This keeps them out of the contiguous VA reservation and gives each a stable shadow buffer (fixed address baked into the replayed command buffer), refreshed each step with a stream-ordered D2D copy instead of a remap.

  • Gate: XLA_VMM_TMP_COPY_THRESHOLD (bytes; 0 = disabled = default), opt-in.

Performance

Measured with MaxText (synthetic data, identical converged loss across configurations). Numbers are relative per-step speedups only (steady-state median, after warm-up), normalized to the default baseline configuration; absolute throughput is intentionally omitted.

Each model is compared as: the default baseline config (what people run today) vs. the VMM-optimized config (trace-once replay + collectives captured + per-step remap minimized). Baseline is normalized to 1.00×; higher is faster.

8×MI300X

model baseline (default) VMM-optimized
llama2-7b 1.00× 1.09×
llama3-8b 1.00× 1.16×
llama3.1-8b 1.00× 1.17×
mistral-7b 1.00× 1.14×
gemma2-9b 1.00× 1.23×
qwen3-8b 1.00× 1.13×
llama2-13b 1.00× 1.37×

8×MI355

model baseline (default) VMM-optimized
llama2-7b 1.00× 1.10×
llama3-8b 1.00× 1.16×
llama3.1-8b 1.00× 1.17×
mistral-7b 1.00× 1.37×
gemma2-9b 1.00× 1.53×
llama2-13b 1.00× 1.15×

A genuine end-to-end per-step improvement across popular dense LLMs on both platforms, largest on the most launch-bound models. Loss and correctness are unchanged in every case; in the bandwidth-bound FSDP regime the same configuration is performance-neutral (no regression).

Notes for reviewers

  • skip-remap is default-ON for ROCm; copy-into-shadow is opt-in (XLA_VMM_TMP_COPY_THRESHOLD=0 by default). Both are no-ops for CUDA and for existing default ROCm callers.
  • Correctness: loss matches the non-VMM baseline in all runs.
  • Pure env-var/decision logic is covered by a unit test (gpu_executable_buffer_allocator_test).
  • This is a draft to enable review and further ROCm profiling; not requesting merge yet.

Test plan

  • A/B with XLA_VMM_SKIP_REMAP=0/1 and XLA_VMM_TMP_COPY_THRESHOLD=0/65536 on llama2 / llama3
  • Confirm converged loss matches the non-VMM baseline
  • Re-profile under newer ROCm to confirm where remaining per-step time goes

The VA-remapping command-buffer path (ExecuteWithVaRemapping) unmapped the
entire reserved VA range and re-mapped every command-buffer allocation on
every step, even though most slots (model weights) keep the same backing
physical buffer step-to-step. On ROCm the VA unmap is the dominant per-step
cost, so this full remap is wasteful.

Wire the already-in-tree, unit-tested se::MemoryReservation::ScopedMapping::
Remap() per-slice skip primitive into the per-step path: cache each slot's
source (BFC) address from the previous step and mark remap_required only for
slots whose backing buffer moved. Unchanged slots keep their mapping (no
unmap/map/SetAccess), and when nothing changed the unmap-event sync is skipped
entirely (steady state issues zero driver calls).

ROCm-gated (VmmRemapSkipEnabled); the CUDA path keeps its existing
reset()+MapTo behavior. XLA_VMM_SKIP_REMAP=0 forces the legacy path for A/B.

Measured (8xMI300X, FSDP=8, 8 layers, NEVER_UPDATE, 30 steps), median ms/step:
  llama2-7b: 1096.5 -> 552.0  (1.99x)
  llama3-8b: 1284.0 -> 616.0  (2.08x)
Identical converged loss; no crash.
Small command-buffer slices (tiny scale/scalar/metric buffers that churn
their address every step) are the worst case for VMM per-step remapping: a
full hipMemUnmap/hipMemMap/hipMemSetAccess round-trip costs ~2-4ms even for a
few bytes, and there can be many such slices per step.

This change keeps those slices OUT of the contiguous VA reservation and instead
gives each a stable shadow device buffer (allocated once, fixed address baked
into the replayed command buffer). Each step the shadow is refreshed with a
stream-ordered D2D copy (real->shadow before execute, shadow->real after) rather
than a remap. Gated by XLA_VMM_TMP_COPY_THRESHOLD (bytes; 0 = disabled, default),
ROCm-only, so the CUDA path and default ROCm behavior are unchanged.

Combined with the per-slot skip-remap, this makes capturing collectives into the
command buffer (NEVER_UPDATE) a net win in launch-bound (tensor-parallel) regimes.
Loss/correctness unchanged.
@phambinhfin phambinhfin added the claude-review Request a Claude AI code review for this PR label Jun 29, 2026
Comment on lines +198 to +199
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: Shadow buffers are leaked on VaRanges destruction.

small_shadow holds DeviceAddressBase values (non-owning raw pointer+size) allocated via executor->Allocate() (.cc line 494), but VaRanges has no destructor and DeviceAddressBase does not free on destruction. When GpuExecutableBufferAllocator is destroyed, its module_va_ranges_ map destroys all VaRanges structs, but the device memory pointed to by small_shadow entries is never deallocated.

A destructor (or RAII wrapper like ScopedDeviceAddress) is needed to call executor->Deallocate() for each shadow buffer. This requires either storing the executor pointer in VaRanges or switching to a self-freeing wrapper type.

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 now has a destructor (lines 228-234 in the header) that iterates small_shadow and calls executor->Deallocate() for each entry, with the owning executor stored at line 225.

Comment on lines +493 to +494
}
se::DeviceAddressBase shadow = executor->Allocate(sz);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Concern: executor->Allocate(sz) uses the default memory_space=0.

The shadow buffer is always allocated in memory space 0, but the original buffer's allocation may have a non-zero color/memory_space (e.g., collective memory at color 1). If a small command-buffer slice happens to be a collective buffer, the shadow would be allocated in the wrong memory space.

This is unlikely for the "small scale/scalar/metric" use case described in the PR, but the code does not enforce the assumption. Consider either asserting allocation.color() == 0 for small-copy slices or passing the correct memory_space to Allocate.

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 shadow allocation now reads the allocation's color and passes it as memory_space (lines 526-530 in .cc), so non-zero-color buffers are no longer silently placed in space 0.

}
static const uint64_t threshold = []() -> uint64_t {
const char* v = std::getenv("XLA_VMM_TMP_COPY_THRESHOLD");
return (v != nullptr) ? std::strtoull(v, nullptr, 10) : 0;

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: std::strtoull silently returns 0 for non-numeric strings.

std::strtoull("auto", nullptr, 10) returns 0 and std::strtoull("999999999999999999999", nullptr, 10) returns ULLONG_MAX -- both silently. The comment on line 113 mentions a future "auto" mode.

If a user sets XLA_VMM_TMP_COPY_THRESHOLD=auto today, they get threshold=0 (disabled) with no warning. Consider logging a warning when parsing fails, or at least documenting that non-numeric values silently disable the feature.

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. ParseVmmCopyThresholdBytes now uses end-pointer and errno checking (lines 91-101 in .cc) and logs a WARNING for non-numeric values like "auto", making the failure visible instead of silent.

Comment on lines +88 to +95
bool VmmRemapSkipEnabled(absl::string_view platform_name) {
if (platform_name != "ROCM") {
return false;
}
static const bool enabled = [] {
const char* v = std::getenv("XLA_VMM_SKIP_REMAP");
if (v == nullptr) {
return true; // default ON for ROCm

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: PR description says "opt-in via env vars" but skip-remap is default-ON for ROCm.

VmmRemapSkipEnabled returns true by default on ROCm (line 95), meaning XLA_VMM_SKIP_REMAP=0 must be set to disable it. This is the opposite of the "opt-in" framing in the PR description.

The "Notes for reviewers" section also says "gated and default-off-equivalent for CUDA / existing ROCm callers" which is only accurate for the copy-into-shadow path (threshold=0 by default). The skip-remap optimization is default-ON for all ROCm callers. Consider updating the PR description to match the code, or making this opt-in as well if the intent is cautious rollout.

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 ✓ — the PR description now clearly states skip-remap is default ON for ROCm and only copy-into-shadow is opt-in, matching the code behavior.

Comment on lines +489 to +501
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Potential issue: Shadow reallocation races with GPU access.

If it->second.size() < sz (shadow is too small), the old shadow is deallocated (line 491) and a new one is allocated immediately. However, the old shadow address may still be baked into a previously-captured command buffer that the GPU is executing. The Deallocate call frees the device memory while the GPU may still be accessing it.

In practice, under NEVER_UPDATE modes with static shapes, sizes should not change, so this path should not be hit. But if it ever is, it would need an unmap_event->Synchronize() (or equivalent) before deallocating the old shadow. Consider adding a DCHECK that this path is not taken after the first step, or adding the synchronization.

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 shadow reallocation path (lines 534-544 in .cc) now synchronizes on unmap_event before deallocating the old shadow, preventing the GPU race. The code also adds a comment noting this branch is not expected under NEVER_UPDATE with static shapes.

@claude

claude Bot commented Jun 29, 2026

Copy link
Copy Markdown

Claude Review Summary

PR: Copy-into-shadow for small command-buffer VA slots (ROCm)

This PR adds two ROCm-only optimizations to the VMM command-buffer VA remapping path: (1) skip-remap, which caches each slot's source address and skips unmap/map for unchanged slots, and (2) copy-into-shadow, which replaces VA remapping of small churning buffers with stable shadow buffers refreshed via D2D copies. The CUDA path is correctly left unchanged. Overall well-structured with clear comments.

Key findings (5 inline comments):

  • 🔴 Shadow buffers in small_shadow map are leaked on VaRanges destruction (no destructor to call Deallocate)
  • 🟡 Shadow reallocation path (size < sz) could race with in-flight GPU work on the old shadow address
  • 🟡 Shadow buffers always allocated in memory_space=0 regardless of original allocation's color
  • 🟡 PR description says "opt-in via env vars" but skip-remap is default-ON for ROCm
  • 🔵 std::strtoull silently returns 0 for non-numeric env var values

No tests are included for the new helper functions (VmmRemapSkipEnabled, VmmCopyThresholdBytes) or the skip-remap decision logic, which are pure-logic and testable without hardware.

Non-test delta: +224/−31 = 255 lines — well within review scope.

@github-actions github-actions Bot removed the claude-review Request a Claude AI code review for this PR label Jun 29, 2026
Fixes from the PR #1001 review of the copy-into-shadow path:

- Fix shadow-buffer leak: VaRanges now stores its owning StreamExecutor and a
  destructor frees every small_shadow DeviceAddressBase (non-owning pointers
  were previously never deallocated). VaRanges is marked non-copyable.
- Avoid a use-after-free race when a shadow slot grows: synchronize the
  unmap_event before deallocating the old (still possibly GPU-referenced)
  shadow buffer.
- Allocate the shadow in the original buffer's memory space (color) instead of
  always memory_space=0, so a non-default-color slice is not silently misplaced.
- XLA_VMM_TMP_COPY_THRESHOLD now warns on empty/non-numeric values (e.g.
  "auto") instead of silently parsing to 0 via strtoull.
- Extract the env-var decision logic into pure, uncached vmm_internal helpers
  and add gpu_executable_buffer_allocator_test.cc covering them.

ROCm-gated; CUDA path and default ROCm behavior unchanged.
@phambinhfin phambinhfin added the claude-review Request a Claude AI code review for this PR label Jun 29, 2026
Comment on lines +78 to +80
absl::string_view s(env_value);
return !(s == "0" || s == "false" || s == "off" || s == "FALSE" ||
s == "OFF");

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: Case-insensitive env var parsing is incomplete.

ParseVmmRemapSkipEnabled handles "false"/"FALSE" and "off"/"OFF" but not mixed-case variants like "False", "Off", "No", "no", or "NO". Consider using absl::AsciiStrToLower() or absl::EqualsIgnoreCase() for a more robust comparison:

Suggested change
absl::string_view s(env_value);
return !(s == "0" || s == "false" || s == "off" || s == "FALSE" ||
s == "OFF");
absl::string_view s(env_value);
return !(s == "0" || absl::EqualsIgnoreCase(s, "false") ||
absl::EqualsIgnoreCase(s, "off"));

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. ParseVmmRemapSkipEnabled now uses absl::EqualsIgnoreCase for all string comparisons (including "no"/"No"/"NO"), and unit tests confirm mixed-case variants are handled correctly.

Comment on lines +548 to +551
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: Internal() uses absl::StrCat, not printf-style formatting — %d placeholders are emitted literally.

Internal(fmt, sz, i) is an overload that calls absl::StrCat(fmt, sz, i), so the error message will read something like "Failed to allocate %d-byte shadow for small command-buffer slice %d655" — the %d tokens are not substituted.

Same issue on line 519 ("Command buffer allocation %d has null address").

Fix: use absl::StrCat/absl::StrFormat explicitly, or just concatenate:

Suggested change
return Internal(
"Failed to allocate %d-byte shadow for small command-buffer "
"slice %d",
sz, i);
return Internal(
absl::StrCat("Failed to allocate ", sz,
"-byte shadow for small command-buffer slice ",
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.

Resolved — this was a false positive. The xla::Internal() used here is defined via XLA_ERROR_WITH_STRFORMAT_AND_BACKTRACE in xla/util.h (line 302), which uses absl::StrFormat, not absl::StrCat. The %d placeholders are correctly substituted. The tsl::errors::Internal in tsl/platform/errors.h (StrCat-based) is a different, deprecated overload that is not the one resolved here. Apologies for the noise.

Comment on lines +526 to +530
const int64_t memory_space =
(i < static_cast<BufferAllocation::Index>(
owner_->allocations_.size()))
? static_cast<int64_t>(owner_->allocations_[i]->color())
: 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Concern: memory_space silently falls back to 0 when index is out of range.

If i >= owner_->allocations_.size(), the code defaults memory_space to 0 instead of reporting an error. This would silently place a shadow buffer in the wrong memory space, masking a bug in the allocation index tracking. Consider returning an error instead:

Suggested change
const int64_t memory_space =
(i < static_cast<BufferAllocation::Index>(
owner_->allocations_.size()))
? static_cast<int64_t>(owner_->allocations_[i]->color())
: 0;
const int64_t memory_space =
(i < static_cast<BufferAllocation::Index>(
owner_->allocations_.size()))
? static_cast<int64_t>(owner_->allocations_[i]->color())
: [&]() -> int64_t { LOG(WARNING) << "Allocation index " << i << " out of range, defaulting memory_space to 0"; return 0; }();

Or more cleanly, return Internal(...) since this condition would indicate a logic bug.

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. Out-of-range allocation index now returns an Internal error (lines 537-541 in .cc) instead of silently defaulting memory_space to 0.

Comment on lines +532 to +553
if (it == va_ranges_->small_shadow.end() ||
it->second.size() < sz) {
if (it != va_ranges_->small_shadow.end()) {
// The old shadow address may still be baked into a
// previously-captured command buffer the GPU is replaying. This
// branch is only reachable on a later step (the slice grew), so
// unmap_event has been recorded; wait for that GPU work before
// freeing the old shadow. Under NEVER_UPDATE with static shapes
// this is not expected to be hit.
if (va_ranges_->unmap_event != nullptr) {
RETURN_IF_ERROR(va_ranges_->unmap_event->Synchronize());
}
executor->Deallocate(&it->second);
}
se::DeviceAddressBase shadow = executor->Allocate(sz, memory_space);
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Concern: Shadow reallocation changes the baked-in command buffer address under NEVER_UPDATE.

The previous review flagged the GPU race on shadow deallocation (now fixed with the unmap_event sync on line 541-542). However, a second issue remains: when a shadow is reallocated (because the slice grew), executor->Allocate(sz, memory_space) returns a new device address. Under NEVER_UPDATE, the command buffer was captured once and has the old shadow address baked in — it will not be re-captured, so subsequent replays read/write the freed address.

The comment on line 539-540 acknowledges this ("Under NEVER_UPDATE with static shapes this is not expected to be hit"), which is correct in practice. But if it does get hit, the result is silent memory corruption rather than a clear failure. Consider returning an error in this branch when update_mode_ == NEVER_UPDATE, so a shape change is caught immediately rather than manifesting as a mysterious data corruption.

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. Instead of reallocating to a new address (causing silent corruption under NEVER_UPDATE), the code now returns an Internal error when a small-copy slice grows (lines 553-565 in .cc), failing clearly rather than silently.

Comment on lines +689 to +705
for (SmallCopy& c : small_copies) {
RETURN_IF_ERROR(exec_stream->Memcpy(&c.shadow, c.real, c.size));
}
XLA_VLOG_DEVICE(2, device_ordinal) << absl::StreamFormat(
"VA remapping: copied %d small slices to shadow (threshold=%d B)",
static_cast<int>(small_copies.size()), copy_threshold);
}

RETURN_IF_ERROR(execute(remapped_buffer_allocations));

// Copy-BACK small-slice outputs (shadow->real), stream-ordered after the
// command buffer wrote results (e.g. loss / token-count scalars) into the
// stable shadow, propagating them to the real buffers the host/later thunks
// read. Runs before the unmap event is recorded.
if (!small_copies.empty()) {
se::Stream* exec_stream = run_options_->stream();
for (SmallCopy& c : small_copies) {

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: Copy-in and copy-back run unconditionally for all small-copy slices.

Every small-copy slice gets both real->shadow (before execute) and shadow->real (after execute). For input-only buffers (e.g., scale factors), the copy-back is wasted work; for output-only buffers, the copy-in is wasted. With many small slices this could accumulate.

Consider checking BufferAllocation::is_readonly() or access mode to skip unnecessary copies. This may not matter with the expected small number of tiny slices today, but worth noting for future optimization or at least a comment documenting the decision.

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 bidirectional copy is now documented as an intentional design choice (lines 480-486 in .cc), with a note that a future optimization could use BufferUse read/write to elide the unused direction.

@claude

claude Bot commented Jun 29, 2026

Copy link
Copy Markdown

Re-review summary

The four issues from the initial review have been addressed well in this revision — shadow buffer lifecycle, memory-space color propagation, strtoull validation, and GPU synchronization on shadow reallocation are all fixed.

Five new findings posted inline:

  • Bug: Internal() uses StrCat, not printf — %d placeholders appear literally in error messages (2 call sites)
  • Concern: Shadow reallocation under NEVER_UPDATE changes the baked-in command buffer address (silent corruption risk)
  • Concern: memory_space silently defaults to 0 when allocation index is out of range
  • Nit: Case-insensitive env var parsing doesn't cover mixed-case ("False", "Off")
  • Nit: Copy-in/copy-back runs unconditionally for all small-copy slices regardless of read/write access

The Internal() format-string bug is the most actionable — the error messages will be garbled if ever triggered.

@github-actions github-actions Bot removed the claude-review Request a Claude AI code review for this PR label Jun 29, 2026
- Fail loudly when a copy-into-shadow slice grows after first capture: under
  trace-once replay (NEVER_UPDATE) the shadow address is baked into the command
  buffer and cannot move, so reallocating would silently corrupt the replayed
  buffer. Return an error instead of reallocating (also removes the prior
  dealloc/realloc + GPU-race path). Static shapes never hit this.
- Return an error if a command-buffer slice index is out of range instead of
  silently defaulting the shadow's memory space to 0.
- Make XLA_VMM_SKIP_REMAP parsing case-insensitive for false/off/no via
  absl::EqualsIgnoreCase; extend the unit test accordingly.
- Document that the small-slice copy is intentionally bidirectional (direction-
  agnostic, tiny buffers); note a future BufferUse-based optimization.

Note: the reviewer's "Internal() uses StrCat" comment is a false positive --
xla::Internal is absl::StrFormat-based (xla/util.h), so %d is substituted.

ROCm-gated; CUDA path and default ROCm behavior unchanged.
@phambinhfin phambinhfin added the claude-review Request a Claude AI code review for this PR label Jun 29, 2026
@phambinhfin phambinhfin self-assigned this Jun 29, 2026
@claude

claude Bot commented Jun 29, 2026

Copy link
Copy Markdown

Re-review Summary (revision 2)

Good progress on this revision — 5 of my previous findings are now addressed:

  • Case-insensitive env var parsing — resolved (uses absl::EqualsIgnoreCase)
  • memory_space silent fallback to 0 — resolved (returns Internal error)
  • Shadow reallocation under NEVER_UPDATE — resolved (returns Internal error on grow)
  • Bidirectional copy for all small-copy slices — resolved (documented as intentional)
  • %d formatting in Internal() — retracted as false positive (xla::Internal uses StrFormat)

1 prior finding still open: Skip-remap is default-ON for ROCm while the PR description says "opt-in" — consider aligning the description.

No new issues found in this revision.

@github-actions github-actions Bot removed the claude-review Request a Claude AI code review for this PR label Jun 29, 2026
@phambinhfin phambinhfin added the claude-review Request a Claude AI code review for this PR label Jun 29, 2026
Comment on lines +647 to +649
ASSIGN_OR_RETURN(se::MemoryReservation::ScopedMapping remapped,
std::move(*va_ranges_->scoped_mapping)
.Remap(absl::MakeSpan(remaps)));

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: ASSIGN_OR_RETURN after std::move leaves scoped_mapping in a moved-from state on failure.

If Remap() fails, ASSIGN_OR_RETURN returns early. At that point, *va_ranges_->scoped_mapping has already been consumed by std::move() — the optional still has_value() but the contained ScopedMapping is moved-from (empty/null). The scoped_mapping.reset() on the legacy path (line 660) is never reached because the error return exits the function.

On the next call to ExecuteWithVaRemapping, can_skip_remap (line 617) checks scoped_mapping.has_value() which is still true, so the skip-remap path tries std::move(*va_ranges_->scoped_mapping).Remap(...) on a null/moved-from ScopedMapping — likely UB or a crash.

Fix: reset the optional before returning the error:

auto remap_result = std::move(*va_ranges_->scoped_mapping)
                        .Remap(absl::MakeSpan(remaps));
if (!remap_result.ok()) {
  va_ranges_->scoped_mapping.reset();
  return remap_result.status();
}
va_ranges_->scoped_mapping = std::move(*remap_result);

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 Remap() failure path now explicitly resets scoped_mapping before returning the error (lines 664-670 in .cc), preventing the moved-from optional from being misinterpreted as valid on the next step.

Comment on lines +228 to +233
~VaRanges() {
if (executor != nullptr) {
for (auto& [index, shadow] : small_shadow) {
executor->Deallocate(&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: Destructor deallocates shadow buffers without GPU synchronization.

The destructor calls executor->Deallocate(&shadow) for each shadow buffer, but does not first synchronize with the GPU. The last step's command buffer may still be reading from or writing to these shadow buffers on the device (the unmap_event records after execute() returns, and nothing here waits for it).

On ROCm, hipFree on memory still in use by a running kernel is undefined behavior. This is distinct from the previously-flagged leak issue (#1) — that was about missing deallocation entirely; this is about deallocating while the GPU is still using the memory.

Fix: synchronize before the deallocation loop:

~VaRanges() {
  if (executor != nullptr) {
    if (unmap_event) {
      unmap_event->Synchronize().IgnoreError();
    }
    for (auto& [index, shadow] : small_shadow) {
      executor->Deallocate(&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.

Resolved ✓ — addressed in this revision. The VaRanges destructor now synchronizes on unmap_event before deallocating shadow buffers (lines 240-252 in the header), preventing use-after-free on device memory still in use by a running kernel.

Comment on lines +76 to +78
if (env_value == nullptr) {
return true; // default ON for ROCm
}

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: Empty-string handling is inconsistent with ParseVmmCopyThresholdBytes.

ParseVmmCopyThresholdBytes (line 89) treats an empty env var ("") as equivalent to unset (returns 0 / disabled). But here, ParseVmmRemapSkipEnabled does not check for empty string — if a user does export XLA_VMM_SKIP_REMAP= (setting to empty), the value "" does not match any of the falsey values ("0", "false", "off", "no"), so it returns true (enabled).

This is probably fine in practice (empty → enabled is a reasonable default), but it is inconsistent between the two sibling functions and the test file has no coverage for the empty-string case.

Consider adding an explicit empty-string check for symmetry:

if (env_value == nullptr || *env_value == '\0') {
  return true;  // default ON for ROCm
}

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. ParseVmmRemapSkipEnabled now explicitly checks for empty string on line 78 (*env_value == '\0') and treats it the same as unset (returns true / default ON), consistent with ParseVmmCopyThresholdBytes' empty-string handling. The test file also covers the empty-string case (RocmEmptyStringDefaultsOn).

Comment on lines +616 to +619
const bool can_skip_remap =
skip_remap_enabled && va_ranges_->scoped_mapping.has_value() &&
!mapping_descriptors.empty() &&
va_ranges_->last_mapped_src_addrs.size() == new_src_addrs.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.

Minor: Skip-remap address comparison checks vector size but not allocation identity.

The can_skip_remap guard checks last_mapped_src_addrs.size() == new_src_addrs.size() to ensure the vectors are comparable element-by-element. However, it does not verify that the same allocation indices produced those vectors. If the set of VA-mapped allocation indices were to change between steps while keeping the same count (e.g., one allocation shrinks below the copy threshold while another grows above it), the per-slot comparison would compare addresses of different allocations, potentially concluding "no change" when the mapping is actually stale.

Under the NEVER_UPDATE / static-shapes contract this should not happen, but a cheap DCHECK that the allocation_va_offsets keys match the previous step would make the invariant explicit and catch violations early:

DCHECK(allocation_va_offsets_keys_match_previous_step);

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. A DCHECK now verifies per-slot reservation offsets match the previous step (lines 639-644 in .cc), catching any slot->allocation identity change that would invalidate the per-slot address comparison. The debug-only last_mapped_offsets vector tracks these offsets.

Comment on lines +200 to +212
// ROCm skip-remap booking. Source (BFC) device address mapped into each
// command-buffer slot during the previous step, in ascending
// reservation-offset order (same order the mapping descriptors are built).
// A slot whose source address is unchanged this step keeps its existing
// mapping instead of being unmapped+remapped, and when no slot changes the
// whole unmap/map/SetAccess (and the unmap-event sync) is skipped. Empty
// until the first mapping is established. ROCm-only; see
// VmmRemapSkipEnabled().
std::vector<const void*> last_mapped_src_addrs;

// ROCm copy-into-shadow (env XLA_VMM_TMP_COPY_THRESHOLD): small
// command-buffer slices (tiny scale/scalar/metric buffers that churn their
// address every step) are kept OUT of the VA reservation and instead given

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: VaRanges mutable fields lack ABSL_GUARDED_BY(mutex) annotations.

The new fields added here (last_mapped_src_addrs, small_shadow) and the existing ones above (va_reservation, unmap_event, scoped_mapping) are all accessed under mutex in the .cc file, but none carry ABSL_GUARDED_BY(mutex) annotations. Adding them would let the thread-safety analysis catch any future unguarded access at compile time.

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. All mutable VaRanges fields now carry ABSL_GUARDED_BY(mutex) annotations: va_reservation (line 189), unmap_event (line 194), scoped_mapping (line 199), last_mapped_src_addrs (line 209), last_mapped_offsets (line 217), and small_shadow (line 228).

@claude

claude Bot commented Jun 29, 2026

Copy link
Copy Markdown

Re-review Summary (Round 2)

Most issues from the initial review have been addressed — resolved 1 remaining open thread.

5 new findings posted inline:

  • 2 bugs: use-after-move on scoped_mapping after Remap() failure (cc:649); shadow buffer deallocation without GPU synchronization in destructor (h:233)
  • 1 minor: skip-remap address comparison lacks DCHECK on allocation index stability (cc:619)
  • 2 nits: inconsistent empty-string semantics between the two env-var parsers (cc:78); new VaRanges fields missing ABSL_GUARDED_BY annotations (h:212)

🤖 This review was generated by Claude.

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

Fixes from the second claude-review pass on PR #1001:

- Skip-remap: if ScopedMapping::Remap() fails, reset scoped_mapping before
  returning the error. Remap() consumes the mapping via std::move, so leaving
  the (now moved-from) optional with has_value()==true would make the next
  step's skip path call Remap() on an empty mapping (UB). Resetting forces the
  legacy reset()+MapTo() recovery path instead.
- VaRanges destructor: synchronize unmap_event before deallocating the shadow
  buffers. The last step's command buffer may still be using them on the
  device, and hipFree on in-use memory is UB on ROCm. Marked
  ABSL_NO_THREAD_SAFETY_ANALYSIS (runs at teardown, no other thread present).
- Add ABSL_GUARDED_BY(mutex) to the mutex-protected VaRanges fields
  (va_reservation, unmap_event, scoped_mapping, last_mapped_src_addrs,
  small_shadow) so the thread-safety analyzer enforces the locking.
- Debug-only DCHECK that the per-slot reservation offsets are stable across
  steps, making the skip-remap "same allocation per slot" invariant explicit
  (new last_mapped_offsets companion vector, populated only in non-NDEBUG).
- ParseVmmRemapSkipEnabled: treat empty XLA_VMM_SKIP_REMAP the same as unset
  (default ON), matching ParseVmmCopyThresholdBytes; add a unit test.

ROCm-gated; CUDA path and default ROCm behavior unchanged.
@phambinhfin phambinhfin added the claude-review Request a Claude AI code review for this PR label Jun 29, 2026
@claude

claude Bot commented Jun 29, 2026

Copy link
Copy Markdown

Re-review Summary

All 15 previously flagged issues have been addressed in this revision. Posted "Resolved" on the 5 remaining open threads:

  • ASSIGN_OR_RETURN after std::movescoped_mapping now explicitly reset on Remap() failure
  • Destructor shadow deallocationunmap_event->Synchronize() now called before deallocating shadow buffers
  • Empty-string env-var parsing — consistent handling between ParseVmmRemapSkipEnabled and ParseVmmCopyThresholdBytes
  • Allocation identity invariant — DCHECK added to verify per-slot reservation offsets match previous step
  • Thread-safety annotations — all mutable VaRanges fields now carry ABSL_GUARDED_BY(mutex)

No new issues found. The skip-remap and copy-into-shadow implementations look solid — error recovery paths are correct, shadow buffer lifecycle is properly managed, and the env-var parsing has good test coverage.

@github-actions github-actions Bot removed the claude-review Request a Claude AI code review for this PR label Jun 29, 2026
@phambinhfin phambinhfin added the claude-review Request a Claude AI code review for this PR label Jun 29, 2026
@claude

claude Bot commented Jun 29, 2026

Copy link
Copy Markdown

Re-review Summary

All 15 previously flagged issues have been addressed in this revision. No new findings.

The skip-remap and copy-into-shadow optimizations look correct: shadow buffers are properly freed with GPU synchronization in the destructor, memory_space is correctly propagated for shadow allocations, env-var parsing is robust with unit tests, and the ABSL_GUARDED_BY annotations are in place for mutable VaRanges fields.

@github-actions github-actions Bot removed the claude-review Request a Claude AI code review for this PR label Jun 29, 2026
Comment-only change: remove specific device timing figures and
cross-vendor comparisons from the source comments, keeping the
descriptions qualitative (what the path does and why) without quoting
hardware-specific numbers. No functional change.
… flags

Address upstream review: don't read environment variables directly. Replace
the XLA_VMM_SKIP_REMAP / XLA_VMM_TMP_COPY_THRESHOLD getenv reads with standard
XLA flags in xla.proto / DebugOptions.

- xla.proto: add xla_gpu_command_buffer_vmm_skip_remap (bool, default true) and
  xla_gpu_command_buffer_vmm_copy_threshold_bytes (int64, default 0).
- debug_options_flags.cc: set defaults and register both flags.
- gpu_executable_buffer_allocator: drop the getenv-based env parsers and the
  process-static caches; read the flag values from the module's DebugOptions
  (owner_->debug_options_, with a null-guard fallback to the defaults). The
  vmm_internal helpers now apply only the ROCm-only gating (and clamp a
  negative threshold to 0) so they stay unit-testable.
- Update the unit test to cover the flag-based helpers.

ROCm-gated; CUDA path and default ROCm behavior unchanged.
…hVaRemapping

Rename the two VMM command-buffer knobs with the xla_gpu_experimental_ prefix
(xla_gpu_experimental_command_buffer_vmm_skip_remap and
xla_gpu_experimental_command_buffer_vmm_copy_threshold_bytes); they are meant to
fold into the default behavior and be removed, so they are not a stable contract.
Field numbers are unchanged.

Break the ~400-line ExecuteWithVaRemapping into a thin orchestrator plus five
helpers on ExecutionScope: EnsureVaReservation, ComputeReservationOffsets,
GetOrCreateSmallShadow, BuildStepMappingPlan and EstablishMapping. Behavior is
unchanged; the per-executor mutex contract is preserved via
ABSL_EXCLUSIVE_LOCKS_REQUIRED and the allocator unit test still passes.
…BUG)

The per-slot reservation-offset bookkeeping used by the skip-remap DCHECK was
guarded by #ifndef NDEBUG to keep the release hot path free of the work. Measured
in isolation (-O2 -DNDEBUG) the maintenance is ~10-150 ns per step at typical
command-buffer slot counts (tens to hundreds), <=0.3 us at 512 slots, and N*8
bytes of RAM -- negligible next to the microsecond-to-millisecond
hipMemUnmap/hipMemMap it sits beside. Drop the preprocessor guard and always
populate it for readability; the DCHECK is still compiled out in release.
// ROCm-only by design: other platforms keep their existing full
// reset()+MapTo() behavior unchanged. Set XLA_VMM_SKIP_REMAP=0 to force the
// legacy path on ROCm for A/B measurement.
bool VmmRemapSkipEnabled(absl::string_view platform_name) {

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.

Should not all of this go to debug options flags not bespoke env parsing?

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