Skip to content

perf(rbd): flat 1-D narrow-phase dispatch — pack warps across batches#21

Open
haixuanTao wants to merge 2 commits into
dimforge:mainfrom
haixuanTao:perf/flat-narrow-phase-upstream
Open

perf(rbd): flat 1-D narrow-phase dispatch — pack warps across batches#21
haixuanTao wants to merge 2 commits into
dimforge:mainfrom
haixuanTao:perf/flat-narrow-phase-upstream

Conversation

@haixuanTao

Copy link
Copy Markdown
Contributor

Problem

The narrow-phase kernels dispatch [max_len/64, num_batches, 1]: one 64-lane workgroup per batch, rounded up from that batch's live pair count. A batched robot environment has ~7 collision pairs, so at 2048+ envs the GPU runs thousands of workgroups at ~11% lane occupancy, and narrow-phase time scales almost linearly with env count (0.02 ms at 1 env → 18.6 ms at 4096 on the scene below) — it goes from negligible to the single largest pass in the frame.

Change

A new one-thread kernel (gpu_flatten_batches_dispatch, same style and cost as the existing max-scan init kernels) builds exclusive prefix offsets over the per-batch work-lists plus a flat [total/64, 1, 1] indirect grid. The classify, deferred, and PFM kernels walk 0..total and recover (batch, item) with a binary search over the offsets (≤12 steps at 4096 batches). Warps fill with real pairs from consecutive batches instead of one batch's handful plus idle lanes.

Buffer layout is unchanged — per-batch capacity-strided storage stays as is; only the dispatch shape and index math moved. The same flatten kernel is reused for both the collision-pair and the PFM work-lists, repurposing the two existing indirect buffers (nothing else consumes their batched form).

Results

RTX 5090, batched 12-DOF biped (one robot per env, mesh feet), dt = 5 ms, steady-state contacts, wgpu/Vulkan:

2048 envs 4096 envs
narrow-phase 10.31 → 5.09 ms (2.0×) 18.57 → 6.94 ms (2.7×)
whole step (wall) 22.45 → 16.50 ms
throughput 91k → 124k env-steps/s

Physics is bit-identical before/after (robot trajectory and cube-settle checks match to printed precision), as expected — the same items are processed, only warp packing changes. Single-env scenes see no change (the flat grid degenerates to the old one).

The remaining narrow-phase cost is the per-pair GJK/EPA divergence itself; the same per-batch dispatch pattern also exists in the contact-solver kernels (contacts_grid), which this PR deliberately leaves untouched — planned as a follow-up.

🤖 Generated with Claude Code

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>
…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

Copy link
Copy Markdown
Contributor Author

Pushed an additional commit with a correctness fix found while stress-testing heterogeneous environments: the classify and pfm_pfm kernels read collider_parent without the per-batch stride, resolving every batch's parents through batch 0's table. Invisible with identical envs (the tables coincide — which is why it survived all the bit-exactness checks), silently wrong with per-env MJCF heterogeneity: same-body pairs get skipped/mis-solved against the wrong topology. The buffer's own construction comment ('Env-local body slot; the kernels apply the per-batch stride') documents the intended contract. The bug predates this PR — the unsliced reads exist on main — but since this PR rewrites those exact lines, carrying the fix here avoids a conflicting follow-up. Repro: two envs with equal body/collider counts but different parent tables (glued-boxes vs separate-boxes); pre-fix the dropped box never stacks, post-fix it settles at the expected height. Same commit also cherry-picked onto #22.

haixuanTao added a commit to haixuanTao/nexus that referenced this pull request Jul 17, 2026
The flat narrow-phase kernels (post-dimforge#21 flatten) thread purely off
`global_invocation_id.x` with `num_threads = num_workgroups.x * WORKGROUP_SIZE`
— the grid y/z dims are never read. But the FIXED fallback grid was still the
stale 2-D `[x, num_batches, 1]` from before the flatten (the indirect path was
updated to 1-D by flatten_batches; the fixed path was not). Every one of the
`num_batches` y-slices re-ran the identical flat grid-stride loop, so the
atomic-append emitters — deferred (site 7) and pfm_pfm (site 8) — wrote each
contact `num_batches` times. That exploded the contact buffers and graph
coloring: ~19s/step at 2048 batches with the GPU pinned (the long-standing
"fixed-grid pathology" that forced sites 7+8 onto indirect dispatch; also the
historical >16-batch illegal address).

Fix: size the fixed grid 1-D over the whole flat capacity, matching the
indirect path. Verified: sites 7+8 on fixed dispatch now run at 2.0 s/iter
(was pathological), iter-0 falls within baseline noise. Flips both sites back
to fixed dispatch, removing their per-step host sync.

Also hoist the fused-color `num_colors_uniform` update out of a per-step
`Tensor::scalar` realloc (a cudaMalloc inside step(), illegal under CUDA-graph
capture) to an in-place stream-ordered `write_buffer`. Both are prerequisites
for restoring graph capture on the unified stack.

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