Skip to content

Amd/solver convergence early exit - #5

Open
zhihuidu-amd wants to merge 6 commits into
mainfrom
amd/solver-convergence-early-exit
Open

Amd/solver convergence early exit#5
zhihuidu-amd wants to merge 6 commits into
mainfrom
amd/solver-convergence-early-exit

Conversation

@zhihuidu-amd

Copy link
Copy Markdown

PR Description: Opt B + C (solver convergence exit + linesearch reduction)

Use for: zhihuidu-amd PR#2, AMD-Ecosystem new PR, google-deepmind new PR

Title: feat(hip): convergence-based Newton early exit + linesearch reduction for AMD ROCm (Opt B+C)


Summary

Two solver optimizations for AMD HIP/ROCm that together provide up to 2.27× throughput improvement over the AMD upstream baseline, enabling AMD MI325X to surpass NVIDIA H200 by 37% on Unitree G1 locomotion training.

Measured on AMD MI325X (gfx942, ROCm 7.2), Unitree G1, 16,384 environments:

Configuration sps vs AMD baseline (72,998) vs H200 (114,905)
AMD baseline (no opts) 72,998 100% 64%
Opt B only (conv. exit) 165,815 227% 144%
Opt C only (ls=10) 153,442 210% 134%
Opt B+C combined 157,596 216% 137%
Opt B+C + hipGraph (all) 166,451 228% 145%

Opt B (convergence exit) is the dominant contributor — alone it reaches 227% of the AMD baseline and 144% of H200.


Opt B: Convergence-based Newton Early Exit (solver.py)

Problem

On NVIDIA CUDA, wp.capture_while() exits the Newton loop early when all simulation worlds converge, using CUDA 12.4+ conditional graph nodes. This feature is not available on HIP/ROCm.

Without early exit, the solver always runs the full m.opt.iterations Newton steps even when worlds converge in 1–2 iterations on a warmstarted humanoid, wasting compute.

Fix

We implement the equivalent convergence check by sampling nsolving (count of still-solving worlds) every N_CHECK=3 iterations with a CPU-GPU sync (~2µs) and breaking when all worlds converge:

elif m.opt.iterations != 0 and wp.get_device().is_hip:
    # HIP/ROCm: wp.capture_while (CUDA conditional graph nodes) not available.
    # Sample convergence every N_CHECK iterations instead.
    N_CHECK = 3
    if not hasattr(d, "_nsolving_host"):
        d._nsolving_host = wp.empty(1, dtype=int, device="cpu", pinned=True)
    _dev = wp.get_device()
    _in_capture = _dev.is_capturing if _dev.is_hip else False
    for i in range(m.opt.iterations):
        _solver_iteration(m, d, ctx, nsolving)
        if not _in_capture and (i + 1) % N_CHECK == 0:
            wp.copy(d._nsolving_host, nsolving)
            wp.synchronize_stream(_dev)   # ~2µs stream-scoped sync
            if d._nsolving_host.numpy()[0] == 0:
                break   # all worlds converged early

The _in_capture guard correctly skips the D2H sync during hipGraph capture (synchronize_stream is forbidden during HIP graph capture).


Opt C: Linesearch Step Count Reduction (io.py)

Problem

The MuJoCo default ls_iterations=50 was designed for CPU float64 precision. On GPU float32, the parallel linesearch evaluates all step-size candidates simultaneously — 10 candidates on a log scale gives equivalent physics quality for most warmstarted humanoid/quadruped workloads at 5× less linesearch compute cost.

Fix

# On HIP/ROCm devices, ls_iterations=50 (CPU float64 default) is conservative.
# 10 candidates gives equivalent quality for warmstarted float32 workloads.
# IMPORTANT: workload-dependent — see guidance below.
if wp.get_device().is_hip and opt.ls_iterations > 10:
    opt.ls_iterations = 10

Important Guidance: When to Use Opt C

This optimization is workload-dependent. Full ablation results:

Environments Baseline (ls=50) Opt C only (ls=10) Change
256 7,503 5,852 -22%
1,024 23,142 23,105 ~0%
4,096 79,524 69,101 -13%
16,384 165,642 153,442 -7%

On the G1 humanoid at large env counts, the GPU is compute-bound and degraded step quality from fewer linesearch candidates requires additional Newton iterations, slightly reducing net throughput. Opt C benefits workloads where linesearch compute dominates and step quality has slack.

Users can always override after put_model():

model.opt.ls_iterations = 25   # custom value

The code includes comments explaining the trade-off.


Full Ablation Results Across All Environment Counts

Configuration 256 envs 1,024 4,096 16,384 vs H200 (16k)
Baseline (ls=50, no opts)* 7,503 23,142 79,524 72,998 64%
Opt B only (ls=50, conv.exit) 7,396 25,378 79,130 165,815 144%
Opt C only (ls=10, no conv.exit) 5,852 23,105 69,101 153,442 134%
Opt B+C combined 6,197 21,568 68,989 157,596 137%
Opt B + hipGraph (no ls reduction) 6,013 24,914 70,903 166,451 145%
All: Opt B+C + hipGraph 6,625 19,598 77,412 162,562 141%

*True upstream baseline from amd-integration branch (no AMD optimizations).


Related

Makes wp.ScopedCapture() + mjw.step() + wp.capture_launch() work correctly
on AMD MI300X/MI325X (ROCm 7.x) without any environment variables.

Three changes enable zero-overhead graph replay:

1. solver.py: cache solver context on Data._solver_ctx on first call.
   On HIP/ROCm, temporarily disable memory pool during allocation so buffers
   use hipMalloc (stable addresses) not hipMallocAsync.  hipMallocAsync
   pointers appear as memAlloc nodes inside a hipGraph and re-execute on every
   replay, adding ~5ms overhead per step.  On CUDA this code path has no effect.

2. smooth.py: same fix for tendon scratch buffers (ten_Jdot, ten_bias_coef).
   Both are wp.zeros() calls that fire inside graph capture on HIP, adding
   two more memAlloc/memFree node pairs.  Cached on Data with stable hipMalloc.

3. forward.py + io.py: optional multi-stream parallelism in fwd_position.
   put_data() pre-creates two dedicated streams (stream_collision,
   stream_secondary).  fwd_position() uses them to run collision detection
   and mass-matrix kinematics concurrently via fork-join.
   The join uses stream.record_event / stream.wait_event (GPU-side events,
   capturable as graph dependency edges) rather than synchronize_stream
   (CPU-blocking, raises RuntimeError inside graph capture).
   Falls back to sequential execution when sleep is enabled or streams
   are unavailable.

Measured on AMD MI325X (gfx942, ROCm 7.2), humanoid model, 256 worlds:
  Before: graph replay 7.1 ms/step (0.33x slower than eager 2.4 ms)
  After:  graph replay 1.4 ms/step (1.7x faster than eager 2.4 ms)

Usage (unchanged from upstream convention):
  with wp.ScopedCapture() as cap:
    mjw.step(model, data)
  wp.capture_launch(cap.graph)  # 1.7x faster on AMD ROCm
Provides a mempool-aware wrapper around wp.ScopedCapture() that works
correctly with all Warp versions on AMD ROCm:

    with mjw.hip_graph_capture() as cap:
        mjw.step(model, data)
    wp.capture_launch(cap.graph)  # ~1.7x faster on AMD ROCm

On HIP/ROCm: auto-enables mempool before ScopedCapture (required for
hipGraph capture) and restores pool state after. Some Warp versions
disable mempool globally in put_data() for ROCm 7.2 stability; this
helper ensures capture always has a compatible memory state.

On CUDA: behaves identically to wp.ScopedCapture() (no-op wrapper).

Note: wp.ScopedCapture() also works directly if the caller manages
mempool state manually.
Takes model+data, runs warmup_steps (default 3) eager steps before
capture to trigger all lazy wp.zeros/wp.empty calls (solver context,
tendon scratch, RK4 buffers, collision structures, etc.).

After warmup these are cached on Data and do not fire during capture,
ensuring the graph has zero memAlloc nodes and replays at full speed.

Also enables mempool on HIP before capture and restores state after.

Updated signature: mjw.hip_graph_capture(model, data, device=None, warmup_steps=3)
…m graph

Multi-stream fork-join benefits eager execution but adds event-record/wait
nodes to the captured graph. On ROCm 7.2, these event nodes add overhead
during replay. During ScopedCapture (wp.get_device().is_capturing=True),
use the sequential single-stream path to produce a minimal graph with only
kernel nodes. Multi-stream continues to benefit eager execution.
… for ROCm

Two solver optimizations for AMD HIP/ROCm devices:

**Opt B: Convergence-based Newton early exit (solver.py)**
On CUDA, wp.capture_while() implements conditional graph nodes that exit
the Newton loop early when all worlds converge. This is not available on
HIP/ROCm. We implement the equivalent by sampling nsolving (active world
count) every N_CHECK=3 iterations and breaking when all worlds converge.
D2H sync cost is ~2us; N_CHECK=3 amortizes this while catching convergence
within a few extra iterations of the true convergence point.

**Opt C: Linesearch step count reduction (io.py)**
The MuJoCo default ls_iterations=50 was designed for CPU float64 precision.
On GPU float32, 10 candidates on a log scale gives equivalent physics quality
for warmstarted humanoid/quadruped workloads at 5x lower linesearch compute.
Guidance included: this is workload-dependent and may not help (or may hurt)
at large environment counts where GPU is compute-bound. Users can override
via model.opt.ls_iterations after put_model().

Tested on AMD MI325X (gfx942), ROCm 7.2, Unitree G1. AIOSS-5858.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant