Skip to content

perf(rbd): fuse the solver color loops — one workgroup per env (stacked on #21)#22

Open
haixuanTao wants to merge 4 commits into
dimforge:mainfrom
haixuanTao:perf/fused-color-loops-upstream
Open

perf(rbd): fuse the solver color loops — one workgroup per env (stacked on #21)#22
haixuanTao wants to merge 4 commits into
dimforge:mainfrom
haixuanTao:perf/fused-color-loops-upstream

Conversation

@haixuanTao

Copy link
Copy Markdown
Contributor

Stacked on #21 (its commit is included as the base of this branch; only the top two commits are new).

Problem

After #21 packs the narrow-phase warps, the solver becomes the dominant pass — and its cost is not lane math. Each substep runs reset_color + num_colors × (solve + inc_color) three times as sequential device-wide dispatches: ~230 dependent launches per step, each touching ~1–2 contacts per env for ~a microsecond of work. Flattening these kernels' lanes (same treatment as #21) measures neutral — they are latency-bound, not occupancy-bound.

Change

The ordering the colors enforce only ever applies within an env — bodies are never shared across batches — so the barrier between colors needs workgroup scope, not a dispatch boundary.

Commit 1: gpu_warmstart_fused / gpu_step_gauss_seidel_fused run one 64-lane workgroup per env: stage that env's solver_vels in shared memory (which the workgroup barrier fences), walk colors 1..=num_colors with a barrier between, write back once. Each replaces an entire reset+N×(solve+inc) chain with a single dispatch. num_colors arrives via a one-word uniform refreshed only when the Grow policy raises it. Batches with more than FUSED_SOLVE_MAX_BODIES (64) bodies fall back to the per-color chain automatically.

Commit 2: the fused kernels absorb their per-substep neighbours — apply_solver_vels_inc folds into the staging load, update_constraints into a prologue (a constraint is only read by the lane that updated it, so lane-locality replaces the barrier), remove_cfm_and_bias into a _no_bias variant — and gpu_lbvh_refit_leaves goes flat (uniform per-batch collider count → batch by division; its per-leaf mesh-AABB work ran at 14-of-64 lanes).

Results

RTX 5090, batched 12-DOF biped (mesh feet), dt = 5 ms, steady-state, wgpu — cumulative over #21:

2048 envs 4096 envs
#21 alone 16.50 ms/step (124k env-steps/s) 27.27 ms/step (150k)
+ commit 1 8.68 ms/step (236k) 13.72 ms/step (298k)
+ commit 2 6.52 ms/step (314k) 10.04 ms/step (408k)

Physics is bit-identical at every stage (robot trajectory and cube-settle checks to printed precision): same-color contacts touch disjoint bodies so intra-color order cannot matter, and inter-color order is preserved by the barriers.

🤖 Generated with Claude Code

pkooij and others added 4 commits July 17, 2026 14:31
The narrow-phase kernels dispatched [max_len/64, num_batches, 1]: one 64-lane
workgroup per batch rounded up from that batch's live pair count. A batched
robot env has ~7 pairs, so at 2048+ envs the GPU ran thousands of workgroups
at ~11% lane occupancy and narrow-phase scaled ~linearly with env count
(0.02 ms at 1 env -> 18.6 ms at 4096).

gpu_flatten_batches_dispatch (one thread, same style as the existing
max-scan init kernels) now builds exclusive prefix offsets over the
per-batch work-lists plus a flat [total/64, 1, 1] grid; the classify,
deferred and PFM kernels walk 0..total and recover (batch, item) with a
binary search over the offsets. Warps fill with real pairs from consecutive
batches. Buffer layout is unchanged — only the dispatch shape and index
math moved.

RTX 5090, 12-DOF biped batch, dt=5ms, steady-state contacts:
  narrow-phase   2048 envs: 10.31 -> 5.09 ms   4096 envs: 18.57 -> 6.94 ms
  whole step     2048 envs: 22.45 -> 16.50 ms/step (91k -> 124k env-steps/s)
Physics bit-identical (robot trajectory + cube settle unchanged to printed
precision).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s via workgroup barriers

The contact solver ran reset_color + num_colors x (solve + inc_color) as
sequential device-wide dispatches, three times per substep — ~230 dependent
launches per step whose cost was launch/barrier latency, not lane math (the
color kernels touch ~1-2 contacts per env; flattening their lanes was
measured neutral). But the ordering the colors enforce is only ever
*within* an env — bodies are never shared across batches — so the barrier
only needs workgroup scope.

gpu_warmstart_fused / gpu_step_gauss_seidel_fused run one 64-lane workgroup
per batch: stage the batch's solver_vels in shared memory (which the
workgroup barrier fences), walk colors 1..=num_colors with a barrier
between, write back once. Each replaces a whole reset+N x (solve+inc) chain
with a single dispatch; the per-color path remains as fallback for batches
with more than FUSED_SOLVE_MAX_BODIES (64) bodies. num_colors reaches the
kernels via a tiny uniform refreshed only when the Grow policy raises it.

RTX 5090, 12-DOF biped batch, dt=5ms, steady-state, wgpu — on top of the
flat narrow-phase dispatch:
  2048 envs: 16.50 -> 8.68 ms/step (124k -> 236k env-steps/s)
  4096 envs: 27.27 -> 13.72 ms/step (150k -> 298k env-steps/s)
Cumulative vs pre-flatten baseline: 91k -> 298k env-steps/s at 4096 (3.3x).
Physics bit-identical (robot trajectory + cube settle unchanged to printed
precision) — same-color contacts touch disjoint bodies, so intra-color
order cannot matter, and inter-color order is preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ls + flat refit_leaves

Round 2 of the one-workgroup-per-env fusion:

- gpu_warmstart_fused absorbs gpu_apply_solver_vels_inc (increment added
  while staging velocities into shared memory, same bodies_len bound) and
  gpu_solver_update_constraints (each lane refreshes its own contacts'
  constraints before the color walk — a constraint is only read by the lane
  that updated it, so no extra barrier). Exactly fills the 8-storage-buffer
  budget.
- gpu_step_gauss_seidel_fused_no_bias replaces the final color loop, with
  gpu_remove_cfm_and_bias_kernel absorbed as a prologue (same lane-locality
  argument).
- gpu_lbvh_refit_leaves goes flat: the per-batch collider count is uniform,
  so batch/index recover by division. A 14-collider env used 14 of 64 lanes
  per workgroup, and per-leaf work is mesh-AABB computation — this pass was
  17% of the profiled frame.

Removes another ~12 dispatches per step in the fused path; the standalone
kernels remain for the >FUSED_SOLVE_MAX_BODIES fallback.

RTX 5090, 12-DOF biped batch, dt=5ms, wgpu, on top of the round-1 fusion:
  2048 envs: 8.68 -> 6.52 ms/step (236k -> 314k env-steps/s)
Physics bit-identical (robot trajectory endpoint + cube settle to printed
precision).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…narrow phase

Collision pairs carry env-local collider ids, and collider_parent is
batch-strided like every other per-collider buffer (its construction comment
says so: 'Env-local body slot; the kernels apply the per-batch stride') —
but the classify and pfm_pfm kernels read it unsliced, resolving every
batch's parents through batch 0's table.

With identical environments the tables coincide and nothing observable goes
wrong — which is why every bit-exactness check passed. With heterogeneous
environments (the point of per-env MJCF insertion) contacts are silently
mis-parented: a pair that is same-body in batch 0 gets skipped in batches
where it isn't, and solved impulses can target the wrong bodies.

Repro (now a stacking test): env0 = body with two glued boxes + a single
box; env1 = single box dropped onto a two-glued-box body (equal body and
collider counts, different parent tables). Pre-fix the falling box never
rests on the stack; post-fix it settles on top (z=0.75). Identical-env
scenes are bit-identical before/after, as the aliasing argument predicts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
haixuanTao added a commit to haixuanTao/nexus that referenced this pull request Jul 17, 2026
…capture

CUDA-graph capture of the physics step was failing with
STREAM_CAPTURE_INVALIDATED on the unified stack (production captured fine;
these are unified-added regressions from the dimforge#20 small-sort and dimforge#22
fused-color merges). Any allocation (cuMemAlloc) or pageable host→device
copy inside the captured region invalidates the capture. Two offenders,
localized with a cuStreamGetCaptureInfo probe after each op:

1. Small-sort fast path (radix_sort): allocated a fresh SortUniforms tensor
   every dispatch (`pass_uniforms.clear(); push(Tensor::scalar(..))`),
   bypassing the `uniforms_key` cache the radix path already uses for exactly
   this reason. Now cached/keyed by per_batch_max — allocate once, reuse.

2. num_colors_uniform sync (rbd_step): updated the fused-solver color-count
   uniform inside step() — first as a realloc, then (mistakenly) as an
   in-place write_buffer, both illegal during capture. Moved the sync to
   auto_resize_buffers, which runs OUTSIDE the captured region; max_colors
   only changes there (the ratchet) and in set_max_colors (setup), so the
   value is stable across a capture.

With these + the 1-D fixed-grid fix (daf5a43), BIPED_GRAPH=1 now captures and
replays on the unified stack (verified: 12 iters @2048 g1_29dof_agile terrain
full-parity, falls within baseline noise). Unblocks the env-per-lane LᵀDL
wall-clock win, whose per-kernel gain was hidden by eager launch overhead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2n9RqmxTJb8UG5d1Sjw4W
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