Skip to content

CUDA graph capture

55 · Solver and runtime

Sixteen replicated worlds. Eight collide-and-step calls are captured once into a CUDA graph and replayed every frame, which removes Python and kernel-launch overhead. The physics is the same as eager stepping.

0.00 s

Cost

Time per step, eager stepping for the eager row and CUDA graph replay for the others, median of 7 interleaved rounds on one NVIDIA RTX PRO 6000 Blackwell Server Edition; ratios are against the first row. Solver allocation is the GPU memory the solver allocates at construction, before warm-up and graph capture. Scene: the 8-box stack in each world, 8 steps of 1/240 s per frame.

Option1 world64 worlds1024 worldssolver allocation, 1024 worldsresult in this tutorial
eager1.58 ms1.62 ms1.74 ms215 MBsame physics
CUDA graph1.01 ms 0.64×1.21 ms 0.75×1.64 ms 0.94×215 MBsame physics

What to look at

  • The eager and graph runs land the same way; small differences come from the default row order, which is not deterministic.
  • The script prints the time per frame of both.

Key parameter

wp.ScopedCapture / wp.capture_launch

Warm up once eagerly so kernels compile outside the capture. Use an even number of steps so the state buffers end where they started.

Try changing

  • Capture 16 steps.
  • Edit a mimic coefficient in place between replays.

Concept

CUDA graph capture

What it is

Capturing a frame of collide and step calls records every kernel launch once; replaying the graph skips Python and launch overhead. Arrays are read in place, so in-place edits between replays take effect, but replaced arrays need a new capture.

In this scene

Eager and graph runs land the same way; the 16-world replay ran faster per frame.

Considerations

Cost
On the 8-box stack: 1.0 vs 1.6 ms per step in one world, 1.64 vs 1.74 ms in 1024 worlds, so the gain is largest for small batches.
Interactions
check_constraint_capacity and watermarks must run outside capture; update_mass_matrix_interval should divide the captured step count.
Use it when
Always for training loops; compile once eagerly before capturing.

The script

cd tutorials && python 55_cuda_graph.py prints each variant's final metric; --record out.json also writes the replay. Every tutorial imports replay.py from the same folder.

tutorials/55_cuda_graph.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""CUDA graph capture: record one frame of collide + step calls once, then replay it."""
import time
import newton
import warp as wp
from newton.solvers import SolverSteel
from replay import Recorder
DT = 1.0 / 240.0
SUBSTEPS = 8 # even, so the state buffers end each frame where they started
def build():
scene = newton.ModelBuilder()
for i in range(6):
body = scene.add_body(
xform=wp.transform(
(0.02 * (i % 2), 0.0, 0.1 + 0.15 * i), wp.quat_rpy(0.2 * i, 0.1, 0.0)
)
)
scene.add_shape_box(body, hx=0.05, hy=0.04, hz=0.04)
builder = newton.ModelBuilder()
builder.add_ground_plane()
builder.replicate(scene, 16, spacing=(0.35, 0.35, 0.0))
return builder.finalize()
def simulate(rec, label, use_graph):
model = build()
solver = SolverSteel(model)
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
state_0, state_1, control = model.state(), model.state(), model.control()
def frame_steps():
nonlocal state_0, state_1
for _ in range(SUBSTEPS):
pipeline.collide(state_0, contacts)
solver.step(state_0, state_1, control, contacts, DT)
state_0, state_1 = state_1, state_0
graph = None
if use_graph:
frame_steps() # warm up: compile kernels outside the capture
state_0.assign(model.state()) # restart from the initial state
solver.reset(state_0)
with wp.ScopedCapture() as capture:
frame_steps()
graph = capture.graph
run = rec.variant(label, model)
elapsed, frames = 0.0, rec.frames(3.0)
for frame in range(frames):
run.frame(state_0, top_z=state_0.body_q.numpy()[:, 2].max())
wp.synchronize()
start = time.perf_counter()
if graph is not None:
wp.capture_launch(graph)
else:
frame_steps()
wp.synchronize()
elapsed += time.perf_counter() - start if frame > 0 else 0.0
run.check(solver)
print(f"{label}: {1000.0 * elapsed / (frames - 1):.3f} ms per frame, {SUBSTEPS} steps")
if __name__ == "__main__":
rec = Recorder()
simulate(rec, "eager steps", False)
simulate(rec, "captured graph", True)
rec.save()

Recorded with SolverSteel on Newton 1.9.0.dev0, Warp 1.19.0, NVIDIA RTX PRO 6000 Blackwell Server Edition. Replays read the state back every frame, so they are not a speed benchmark. Script · page text