[AMDGPU] Skip redundant per-launch RuntimeContext HtoD - #876
Conversation
The AMDGPU launcher re-uploads the whole RuntimeContext struct to the per-handle persistent device buffer on every launch. Across repeated launches of the same kernel handle the struct is almost always identical, so most of these ~4us async copies are pure overhead on the launch-bound path. Cache the last-uploaded bytes (plus the device address they were written to) in the per-handle Context and skip the HtoD when both still match. The compare runs after prepare_streaming_checkpoint_state, so any checkpoint_*_ptr mutation forces a re-upload; a moved arg-buffer or reallocated context buffer also forces one. The ephemeral (explicit-stream) path always uploads. To make the struct actually byte-stable, also pin RuntimeContext.result_buffer to the persistent device buffer unconditionally. Previously it was only set for result_buffer_size > 0 kernels, leaving a per-launch-varying host pointer in the field for result-less kernels - which defeated the cache and was a latent hazard on AMDGPU (no UVA fallback for host pointers). Launcher-local, AMDGPU-only; no codegen or ABI change. Measured on CDNA3 (gfx942), 12-kernel x 5000-launch loop: per-launch 17.7us -> 14.4us (~19%), with byte-identical results on both result-less and reduction kernels. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 65ffc8cdf4
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…, add coverage Codex + Opus review fixes for the skip-redundant-RuntimeContext-HtoD optimization: - Tighten the skip gate (Opus #1): only skip the RuntimeContext HtoD on the default-stream fast path (active_stream == nullptr && all_sgid_zero), matching the null-stream-ordering + host-serialization justification. The active_stream == nullptr && !all_sgid_zero (parallel-group-stream) case still uses persistent scratch but now always re-uploads (and refreshes the cache so it stays consistent), rather than skipping under a weaker visibility invariant. - Normalize the zero-size argument-buffer pointer (Codex P2): for argument-less kernels, pin RuntimeContext::arg_buffer to nullptr instead of the per-launch host allocation LaunchContextBuilder leaves there. Keeps the struct byte-stable so the cache hits for this common launch-bound case, and removes a host pointer from a device-visible field. Symmetric with the existing result_buffer pinning. Safe: arg-less kernels never dereference arg_buffer. - Make the compare key robust (Opus #2): value-initialize cpu_thread_id (the one RuntimeContext scalar lacking an in-class initializer) and document that the raw-byte memcmp is correctness-safe (a spurious hit is impossible) and byte-stable because RuntimeContext is value-initialized at construction. - Document the host-serialization invariant of the non-atomic cache (Opus #4). - Add tests/python/test_amdgpu_context_cache.py covering repeated same-handle launches (arg-less/result-less cache-hit path, result-producing reduction, and ndarray-arg changes that must force a re-upload). Validated on MI308X (gfx942, ROCm 7.2.4): the new tests pass, test_checkpoint.py (checkpoint_*_ptr-forces-reupload parity) passes 39/39, and test_function.py is green. clang-format / line-wrapping clean. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thanks - both Opus and Codex caught real things. Pushed 9e926e0 addressing them, validated on MI308X (gfx942, ROCm 7.2.4). Concern 1 - gate broader than its justification. Fixed. The skip is now gated on Concern 2 - memcmp over padding + uninitialized field. Hardened + documented. Concern 3 - test-plan gaps. Addressed:
Concern 4 - thread-safety invariant. Documented: the non-atomic cache ( Codex P2 (arg_buffer) is fixed as its own reply on that thread. CI: the red Happy to run the official |
Official
|
| case (official id) | base fps | opt fps | paired Δ | verdict |
|---|---|---|---|---|
| franka_free @30000 | 4,333,624 | 4,331,117 | −0.04% | no sig diff |
| franka @30000 | 3,194,920 | 3,160,685 | −1.06% | no sig diff |
| anymal_zero @30000 | 2,078,257 | 2,085,679 | +0.36% | no sig diff |
| anymal_random @30000 | 1,340,015 | 1,343,121 | +0.23% | no sig diff |
| go2 @4096 | 485,706 | 486,297 | +0.12% | no sig diff |
| box_pyramid_3 @4096 | 301,579 | 303,716 | +0.71% | no sig diff |
Overall paired mean +0.05% (−1.06% … +0.71%). At the suite's default env counts the change is performance-neutral within noise (30000-env steps are compute-bound, so the small RuntimeContext HtoD is negligible there).
2) Launch-bound sweep at n_envs=4096 (n=3)
The official parametrization hard-codes n_envs=30000 for franka/anymal, so to probe the launch-bound regime the PR's custom-driver numbers came from, these reuse test_rigid.py's unmodified make_franka/make_anymal factories and run_benchmark() loop, invoked directly at 4096 (i.e. same scene construction + timing, not the pytest test_speed wrapper).
| case @4096 | base fps | opt fps | paired Δ | per-trial | verdict |
|---|---|---|---|---|---|
| franka | 864,784 | 886,083 | +2.46% | +4.8%, +1.5%, +1.1% | no sig diff (n=3) |
| franka_free | 1,000,508 | 999,189 | −0.10% | −0.2%, +3.6%, −3.8% | no sig diff |
| anymal_zero | 917,387 | 900,473 | −1.84% | −2.5%, −1.2%, −1.8% | SLOWER |
3) anymal_zero @4096 confirmation (n=5)
The n=3 slowdown reproduced. base mean 917,501 → opt mean 899,838, paired Δ = −1.92% (std 0.90, SE 0.40); per-trial −1.47/−2.68/−2.69/−2.17/−0.59% (all negative); t = −4.79 vs t_crit(0.05,4df)=2.78 → statistically significant (p<0.01).
Bottom line
On the official harness the optimization is net neutral-to-mixed: franka@4096 shows the expected modest win (+2.46%, direction consistent with the PR description's +3.1%), but franka_free/anymal do not reproduce the larger custom-driver gains, and anymal_zero@4096 shows a small but reproducible, significant ~2% regression. That regression appears attributable to the per-launch memcmp of RuntimeContext on the gate path costing more than the saved HtoD in that scene. Net: the skip's benefit is narrow/launch-bound and scene-dependent, and the compare gate has a measurable cost in at least one scene — flagging for consideration.
Note: the toggle used for A/B is local/uncommitted and not part of this PR; the pushed branch is unchanged.
|
Followed up on the anymal_zero@4096 regression flag with instrumentation (a local hit/miss counter on the RuntimeContext skip-cache) to settle whether it's real. Re-ran the A/B on a clock-locked MI308X (gfx942, perf_determinism), anymal_zero@4096, full 45s/15s protocol — but with the arm order randomized per pair rather than fixed base-then-opt.
So the regression isn't real; it was measurement bias from fixed arm ordering. At ~7% run-to-run sd I'd call this scene net-neutral rather than a win, consistent with the official-harness +0.05%. Correctness was already covered (checkpoint parity 39/39 + the new cache tests). (Instrumentation was a local diagnostic, not part of the PR.) |
…tics The ndarray/scalar-arg tests don't force a RuntimeContext re-upload: those values ride the separately-uploaded arg_buffer, while RuntimeContext holds only the stable device arg_buffer address and keeps hitting the cache. Rename test_repeated_launch_changing_ndarray_forces_reupload -> _arg_buffer_split and correct the docstrings so the tests are accurately described as cache-hit correctness coverage of the cached-context / always-uploaded-arg_buffer split. Note that the genuine forced-re-upload path (checkpoint_*_ptr mutation, inside RuntimeContext) is covered by test_checkpoint.py. Test-only; no behavior change. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Addressed the two remaining minor review points: 1. Test naming/docs (commit 79171fb, test-only). The new tests don't force a 2. Scope line. Updated to note the PR also touches the backend-shared |
Tighten the skip-H2D and test comments to document only the surprising invariants (default-stream/host-serialization skip gate, arg_buffer vs cached RuntimeContext split, value-init requirement) instead of restating the code. Co-authored-by: Cursor <cursoragent@cursor.com>
Per review, cut the repeated byte-stability rationale on the result_buffer / arg_buffer pins and the cached_runtime_context field to one terse note each (the pins now cross-reference rather than re-explain). Co-authored-by: Cursor <cursoragent@cursor.com>
Per review, rewrite the skip-cache comment as short self-contained sentences: state the cache first, then why the skip is default-stream-only, then the value-init requirement. Drop parentheticals and the derivable details. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Looks like this touches common (non-amd) files => I will run genesis benchmarks, to check for any regression. |
|
running genesi benchmarks on bench1 |
|
rjunning bench_interleaved.py |
|
bench_interleaved.py results look ok-ish: |
|
Ok, so what we need:
|
|
no docs, so docs ok |
|
looks like we already addressed comments/code ratio ✅ |
|
running genesis unit tests in tests0 |
|
probably need agent ci, so I'll need to create a parallel pr |





[amdgpu] Skip redundant per-launch RuntimeContext HtoD
Summary
On the AMDGPU launcher's default-stream path,
launch_llvm_kernelre-uploads thewhole
RuntimeContextstruct to the per-handle persistent device buffer onevery launch:
memcpy_host_to_device_async(context_pointer, &ctx.get_context(), sizeof(RuntimeContext), active_stream);Across repeated launches of the same kernel handle that struct is almost always
byte-identical — the device arg-buffer / runtime / result-buffer pointers are
stable, and only the rare checkpoint kernel mutates the
checkpoint_*_ptrfields. Each of these copies costs ~4 µs of host-side API time (measured), which
is pure overhead on the launch-bound path (Genesis dispatches ~100 kernels per
step).
This PR caches the last-uploaded bytes (plus the device address they were
written to) in the per-handle
Contextand skips the HtoD when both stillmatch. Correctness is preserved unconditionally:
prepare_streaming_checkpoint_state, so anycheckpoint_*_ptrchange forces a re-upload.arg_bufferpointer changes) or a reallocated contextbuffer (
context_pointerchanges) also forces a re-upload.buffer per launch and may run concurrently on pool streams.
Making the struct actually byte-stable
The skip-cache is useless unless
RuntimeContextis stable. Profiling showed onefield flipping nearly every launch:
result_buffer(offset 24). The launcheronly set it to the device buffer for
result_buffer_size > 0kernels; forresult-less kernels it left the per-launch-varying host pointer that
LaunchContextBuilderputs there. This PR pinsresult_bufferto the persistentdevice buffer unconditionally:
device address is safe — and is arguably more correct, since a host pointer
sitting in a device-visible field is a latent hazard on AMDGPU (no UVA
fallback).
Scope
quadrants/runtime/amdgpu/kernel_launcher.{cpp,h}(the optimization), a one-line layout-neutralint32_t cpu_thread_id{0}in-class initializer in the backend-sharedquadrants/program/context.h, and a newtests/python/test_amdgpu_context_cache.py.context.hedit only adds a default initializerto an existing field (which value-initialization already zeroed at construction) — it does not change
RuntimeContext's size or layout, so it is ABI-neutral even though the header is shared across backends.Benchmarks (CDNA3, gfx942)
12-kernel × 5000-launch loop (launch-bound):
Correctness also verified on a workload including a reduction kernel
(
result_buffer_size > 0): identical checksum and output sum vs baseline.End-to-end (Genesis rigid-body scenes, 4096 envs)
The microbench figure is a launch-bound ceiling. On real scenes the async
context copy overlaps with solver compute, so the end-to-end gain scales
inversely with per-step compute.
The numbers below are not from the repository's
tests/benchmarks/test_rigid.pyharness. They come from a small custom driver that reuses that file's scene shapes
(anymal / franka / go2 / box-pyramid, same assets and control patterns) but with
its own measurement: a fixed
n_envs = 4096for every scene and a per-steptiming loop (warm 80 steps, best-of-3 over 400 steps) rather than the harness's
warmup/record
runtime_fpsat the suite's official env counts. It is a paired A/B(same binary, the optimization gated behind an env flag and toggled back-to-back),
with physics byte-identical between arms:
So: a consistent ~2–4% throughput gain on launch-bound articulated-body
scenes, tapering to ~0% (never a regression) as scenes become compute/collision
bound. Official
test_rigid.pynumbers at the suite's env counts are still worthcollecting as a follow-up.
Test plan
mainwith the AMDGPU backend.test_rigid-derived scenes at4096 envs): positive on launch-bound scenes, neutral on compute-bound, no
regressions.
tests/benchmarks/test_rigid.pyA/B at the suite's env counts.(MI300X, gfx942, ROCm 7.2.4; paired A/B — see results comment. Net-neutral
+0.05% overall at default env counts; surfaced a reproducible,
significant −1.92% regression on anymal_zero@4096 on the launch-bound
sweep, flagged for consideration.)
qd.checkpoint) kernel — thecompare should force re-upload when
checkpoint_*_ptrchanges.(
tests/python/test_checkpoint.py39/39 pass on MI308X, gfx942; commit9e926e0.)upload). (Verified by construction: the skip-cache exists only on the
null-stream default path; the ephemeral/explicit-stream path is unchanged
and always uploads, and HIP graph capture cannot capture the null stream.)
Note on scope
This captures the host-side per-launch win without the higher-risk
kernarg-by-value codegen change (passing the struct by value would also remove
the per-instruction context pointer-loads in the kernel body, but touches shared
codegen and arch guards). It is deliberately launcher-local so it can land
independently and be reverted trivially.