Skip to content

perf(rocprofiler-sdk): GPU-backed snapshots and faster divergence-check hashing for range replay - #14808

Draft
minseobshin11 wants to merge 11 commits into
users/vkale/range-replay-v1from
users/mishin/range-replay-perf
Draft

minseobshin11 wants to merge 11 commits into
users/vkale/range-replay-v1from
users/mishin/range-replay-perf

Conversation

@minseobshin11

Copy link
Copy Markdown
Contributor

Motivation

Range replay re-executes a recorded range of kernel dispatches so counters can be re-collected without re-running the application. Two cost problems in the current implementation:

  1. Range replay maintains its own snapshot/restore path instead of using kernel replay's, and misses the GPU-backed snapshot machinery (device-side capture, batched blit restore) that perf(rocprofiler-sdk): kernel replay phase 2: performance optimizations #10916 adds to kernel replay.
  2. The opt-in divergence check (ROCPROF_RANGE_REPLAY_VERIFY=1) re-hashes two full snapshots per replay window. At a 256 MB footprint that check costs 755 ms of a 939 ms window (80%), making verify unusable at realistic footprints.

Technical Details

Where the window goes

Stage breakdown of one replay window on this branch (verify off), MI300X (gfx942), medians of 12 windows. This table describes the resulting path; each optimization below has its own before/after measurement.

stage what it is b4 / K512 / W1024 / P8 b256 / K512 / W256 / P4
lock + drain (entry) replay-window lock, drain of prior work 0.001 ms 0.001 ms
exit snapshot capture device state at range entry 0.44 ms 0.55 ms
kernarg staging allocate/reuse the kernarg block 0.001 ms 0.001 ms
passes (the replay loop) the whole replay, per pass: 30.88 ms 17.93 ms
- blit submit host submits the restore blits (async) 0.012 ms 0.008 ms
- fill host memcpy of recorded kernargs into staging 1.12 ms 0.26 ms
- packet submit transform + write N packets via the interceptor 5.25 ms 2.34 ms
- drain GPU executes the restore blits and the replayed kernels 24.40 ms 15.28 ms
- retire retire blit packets 0.002 ms 0.002 ms
exit restore hand memory back to the application 0.058 ms 0.21 ms
window 31.35 ms 18.71 ms

Reading it: the snapshot/restore stages are cheap because of optimization 1 (the device-resident snapshot path below). The two largest rows are not tool-optimizable: drain is the application's own kernels - the work the replay exists to re-measure - and packet submit (~1.5 us per replayed dispatch) is the SDK's instrumented dispatch path, shared with every application dispatch; optimizing it is an SDK-wide tracing project, deliberately out of scope. The one stage where the tool itself dominated was verify - 755 of 939 ms at 256 MB before this PR - which is what optimization 2 fixes.

1. GPU-backed snapshots (first four commits)

What changed. The old path captured the snapshot into host memory (a device-to-host readback per range) and restored it with host-to-device copies per pass - both scale with the snapshot footprint. The new path keeps the snapshot in device memory: captured on-device, restored by batched blit kernels on the GPU (submitted asynchronously), with automatic gpu/cpu placement and a host fallback when device staging spills. The range-replay-specific replay_barrier_and_wait in hsa/replay_window.cpp is removed; range_replay/executor.cpp now captures and restores through kernel_replay/memory_snapshot. Kernarg staging is unchanged on both sides of this comparison (the agent's kernarg pool, retained across ranges); the device-staged variant with an explicit store fence is tracked separately in #13770.

Before/after (same client and shapes on both builds, SDK phase lines, per-range medians of 3 runs x 9 ranges; both builds stage kernargs in host memory, so the snapshot path is the only difference):

footprint base: capture / restore / window this PR: capture / restore / window
4 MB 0.4 / 1.4 / 7.6 ms 0.2 / 4.1 / 4.4 ms
64 MB 34.2 / 10.2 / 51.7 ms 0.2 / 4.2 / 4.4 ms
256 MB 134.6 / 38.2 / 184.2 ms 0.3 / 4.7 / 5.2 ms

The base path's capture and restore grow with the footprint (~2 GB/s and ~7 GB/s effective); this PR's capture stays under 0.5 ms and its restore is flat (~4-5 ms) for the same captured byte count. Window ratios: 1.7x / 11.7x / 35x at 4 / 64 / 256 MB. Caveat, stated rather than implied: this is a build-vs-build comparison, so the ratios measure the branch as a whole - but both sides stage kernargs identically (host memory), so the snapshot path is the dominant difference, and the restore's fixed ~4 ms blit cost is visible even at 4 MB where the base path was cheaper.

2. Divergence check (ROCPROF_RANGE_REPLAY_VERIFY=1)

The verify path digests the range-exit snapshot and the post-replay snapshot and counts divergent regions. The cost of one 256 MB digest decomposes as (standalone microbenchmark):

component ms share
byte-at-a-time FNV-1a hash 292 66%
zero-fill of fresh digest buffers 136 31%
device-to-host readback (~18 GB/s DMA) ~14 3%

Two independent changes in range_replay/digest.cpp / executor.cpp:

  • T1, persistent digest scratch: the digest buffers become a static thread_local grow-only scratch, removing the per-call zero-fill and page faults. Safe without clearing because every region is covered by exactly one extent, so every byte is overwritten by the readback before it is hashed.
  • T2, 4-lane CRC32 hash: the byte-at-a-time FNV-1a is replaced by four interleaved SSE4.2 crc32 lanes (runtime-probed, FNV-1a fallback kept). Safe because digests are compared only within one process by one binary and never persisted; the hash function may change freely between releases.

Verify-path cost, medians over 12 windows:

footprint base this PR (T1+T2) speedup
4 MB 11.7 ms 0.80 ms 15x
64 MB 188 ms 7.4 ms 25x
256 MB 755 ms 28.7 ms 26x

Window with verify on at 256 MB: 939 to 47 ms (20x). The result is readback-bound (18.5 GB/s, the DMA floor) for a host-side hash. The verify-off path is unchanged within noise.

Future work: hashing on the GPU itself - the snapshot is already device-resident, so a blit-launched reduction could avoid the 18 GB/s readback entirely, an estimated ~10x beyond T1+T2. It requires new device code and a new correctness surface for an opt-in path that already costs ~29 ms per range, so it is deliberately not attempted here.

Docs: the soundness page's divergence-check section is updated for the new hash and the never-persisted rationale.

Issue Tracking

Related: #10864 (base branch), #10916 (kernel-replay snapshot machinery).

Test Plan

  • ctest -L range-replay (unit, integration, and the two nightly perf tests)
  • Stale-read positive control: skip one kernarg refill on a reused block and assert the detector fires
  • Witness ring: every staged byte checked against its expected value across the stale sweep
  • Divergence check exercised with verify enabled across the sweep
  • Any failing test re-run against the unmodified feat(rocprofiler-sdk): range replay service (callback tracing) #10864 head to classify pre-existing vs regression

Test Result

  • Range-replay suite: 11 of 11 pass (the pc-sampling variant self-skips: pc sampling is unavailable on this agent, same as the kernel-replay pc-sampling test).
  • Kernel-replay suite: 23 of 23 pass (the shared memory-snapshot machinery this PR moves to range replay is covered by its own tests).
  • Unit tests: 92 of 92 pass.
  • Positive control fires at exactly 20,480 stale reads (40 ranges x 512 dispatches), matching the prediction.
  • Witness ring: 62,977/62,977 entries, zero mismatches.
  • divergence=0 on all verify-enabled runs.

Submission Checklist

MythreyaK and others added 10 commits November 1, 2026 23:26
Hold the allocation inventory lock until the restore blit and dependent
replay dispatch complete, preventing concurrent frees without adding
host-side synchronization.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Flatten discontiguous snapshot regions into one compact 16-byte work stream so restore avoids per-tile descriptor searches and skewed grids. Add deterministic coverage for cross-region assignments and partial items.

Co-authored-by: Cursor <cursoragent@cursor.com>
- compose replay snapshot storage tiers

Plan whole regions against bounded GPU and pinned-host budgets before
capture, using a variadic fallback chain ending in a null allocator.
Materialize one arena per tier and verify mixed restore ordering,
allocation rollback, and memory-pressure behavior.

Improvements to come in subsequent commits. This is MVP for host-fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
…king

The host-fallback MVP described each storage tier twice: a planning-only
allocator chain chose placement, then separate GPU and pinned-host
allocation functions re-applied their own admission checks, and
materialization was hard-coded to two tiers.

Make each tier a single storage::arena<Policy, BackingAllocator> that
owns its capacity policy and its physical backing:

- planning::bounded_policy handles alignment, capacity, and offsets.
- One hsa_pool_allocator template replaces the separate GPU and pinned
  allocation paths. Arena constructor arguments are forwarded to the
  backing allocator unchanged.
- storage::fallback_allocator chains arenas and must end in the
  terminal null_allocator. Placements record the tier's storage index
  instead of a hard-coded storage kind.
- Commit allocates only tiers that received reservations, and on
  failure rolls back already committed tiers in reverse order.

GPU headroom and host-memory limits are now applied once, when planning
budgets are computed. Allocation success remains authoritative, and a
failed GPU commit still replans everything onto pinned host. Placement
is unchanged: whole regions go to GPU first, then pinned host.

Remove the unused global logical-offset mapper, the planning-only
allocator aliases, and snapshot agent fields that nothing read. Rename
the capture context's pinned_agent to cpu_agent.

Move planner tests out of blit_copy.cpp into a GPU-independent
kernel-replay-snapshot-storage-test covering fallback, alignment,
overflow, rollback, three-tier chains, and move-only backing arguments.

Co-authored-by: Cursor <cursoragent@cursor.com>
bounded_policy both sized requests and read the arena's cursor, so a
new layout meant rewriting every policy. Give each tier three parts:

- Admission: how much of a request the tier takes, given only what is
  available (whole_region; prefix_chunking splits on a granule).
- Suballocator: where it goes. bump_suballocator commits one backing
  of exactly the used size.
- Backing allocator: where memory comes from. hsa_pool_allocator now
  takes its storage kind and retain mode at runtime.

arena<Admission, Suballocator> clamps admission to what is left, and
fallback re-asks a tier until it declines, so a region can span tiers.
Requests carry region_offset and priority; production still sends one
request per whole region, so placement is unchanged. plan_and_commit
disables a tier whose commit fails and replans, replacing the
hard-coded GPU-to-pinned retry.

Replace the cacheable flag with retain_mode and keep both GPU and
pinned arenas across replays: one high-water arena per pool, reused
when big enough, otherwise freed before the larger allocation. Retained
pinned memory stays pinned until replaced or the process exits.
Budgets count the retained arena as available:

- GPU: min(alloc max, half of VRAM, retained + 95% of free)
- Pinned: min(alloc max, retained + half of MemAvailable)

BabelStream, 3 x 6 GiB behind a 221 GiB VRAM ballast (6 GiB spills to
pinned): only the first replay pays the ~2 s pinned allocation, so
later replays drop from ~3.3 s to ~0.63 s. All-GPU runs are unchanged.

Rewrite the 11 CPU-only kernel-replay-snapshot-storage-test cases:
chunking, priority, fragmented tiers, property-selected admission,
replan and rollback, move-only backings, near-SIZE_MAX capacities.

Co-authored-by: Cursor <cursoragent@cursor.com>
Every wave of the restore blit reads its arguments and copy descriptors.
They lived in the host kernarg pool, so each restore depended on PCIe
read latency: a 384 MiB restore took 240-510 us depending on how long
the host had been idle. Allocate them from the GPU's pool when the CPU
can map it, fence and read back the writes before the packet is
published, and fall back to the host kernarg pool otherwise. The same
restore now takes ~180 us, cold or warm.

This impacts just the blit copy kernel, not the replayed kernel's own
kernarg.

Co-authored-by: Minseob Shin <minseob.shin11@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation project: rocprofiler-sdk labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Report

Code Coverage Report

Tests Only

code coverage tests.png

Samples Only

code coverage samples.png

Tests + Samples

code coverage all.png

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation organization: ROCm project: rocprofiler-sdk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants