Skip to content

Commit 402d8ea

Browse files
c0d1f1edclaude
andauthored
Extend CPU graph capture coverage (#3501)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9db21eb commit 402d8ea

14 files changed

Lines changed: 164 additions & 66 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
- Preserve muscles and rigid-body color groups when copying or replicating a `ModelBuilder`.
112112
- Fix `ModelBuilder.add_usd()` to honor `PhysicsScene.gravityDirection`, including stage-to-builder rotation and per-world imports.
113113
- Fix stale overlay layers remaining visible after switching examples in the OpenGL viewer.
114+
- Fix `SolverKamino` CG/CR solves silently under-iterating on CPU graph capture; the capture-safe loop path now runs on any capturing device, not only CUDA, so CPU captures no longer record a stale host-readback convergence decision at record time.
114115
- Reject incompatible custom attribute and frequency definitions before composing `ModelBuilder` instances.
115116
- Fix `cloth_franka` example rendering particles at simulation scale (cm) instead of viewer scale (m)
116117
- Fix `ModelBuilder` merges to accept array-valued transform fields and plain-list particle color groups.

docs/concepts/simulation_tuning_solvers.rst

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -157,15 +157,21 @@ repository examples spend tuning effort, not a shared solver API.
157157
``particle_topological_contact_filter_threshold``,
158158
``particle_rest_shape_contact_exclusion_radius``.
159159
- Contact history requires matched contacts, for example
160-
``CollisionPipeline(contact_matching="latest")``. When recording VBD
161-
steps in a CUDA graph, construct :class:`~newton.CollisionPipeline`
162-
before :class:`~newton.solvers.SolverVBD` so contact history is
163-
pre-allocated, or run one uncaptured solver step before capture. Buffer
164-
sizes that are too small can drop contacts; sizes that are too large cost
165-
memory and performance. Examples commonly tune ``iterations``, particle
166-
self-contact radius and margin, particle contact buffers and filters,
167-
``particle_collision_detection_interval``, ``particle_enable_tile_solve``,
168-
``rigid_body_contact_buffer_size``,
160+
``CollisionPipeline(contact_matching="latest")``. Contact history is
161+
cross-replay-persistent state, so it must always be pre-allocated
162+
before graph capture on any device: allocating it inside a graph
163+
records a ``wp.zeros`` fill that wipes the warm-start buffers on every
164+
replay. Construct :class:`~newton.CollisionPipeline` before
165+
:class:`~newton.solvers.SolverVBD` so contact history is pre-allocated,
166+
or run one uncaptured solver step before capture. Ordinary contact
167+
buffers can still grow on demand during graph capture on CPU and on
168+
CUDA with the memory pool enabled; only CUDA capture without a memory
169+
pool requires that they also be pre-allocated. Buffer sizes that are
170+
too small can drop contacts; sizes that are too large cost memory and
171+
performance. Examples commonly tune
172+
``iterations``, particle self-contact radius and margin, particle
173+
contact buffers and filters, ``particle_collision_detection_interval``,
174+
``particle_enable_tile_solve``, ``rigid_body_contact_buffer_size``,
169175
``rigid_body_particle_contact_buffer_size``, ``rigid_contact_hard``,
170176
``rigid_contact_history``, and ``rigid_avbd_contact_alpha``.
171177
* - :class:`~newton.solvers.SolverFeatherstone`

newton/_src/solvers/kamino/_src/linalg/conjugate.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ def _run_capturable_loop(
338338
maxiter: wp.array[int],
339339
atol_sq: wp.array[Any],
340340
callback: Callable | None,
341-
use_cuda_graph: bool,
341+
use_graph: bool,
342342
use_graph_conditionals: bool = True,
343343
maxiter_host: int | None = None,
344344
loop_granularity: int = 1,
@@ -384,7 +384,7 @@ def do_cycle_with_condition():
384384
if callback_launch is not None:
385385
callback_launch.launch()
386386

387-
if use_cuda_graph and device.is_cuda and device.is_capturing:
387+
if use_graph and device.is_capturing:
388388
if use_graph_conditionals:
389389
wp.capture_while(global_condition, do_cycle_with_condition)
390390
else:
@@ -541,7 +541,7 @@ class ConjugateSolver(Generic[ScalarType, IndexType]):
541541
maxiter: Maximum iterations per world. If None, defaults to 1.5 * maxdims.
542542
Mi: Operator applying the inverse preconditioner M^-1, such that Mi @ A has a smaller condition number than A.
543543
callback: Optional callback kernel invoked each iteration.
544-
use_cuda_graph: Whether to use CUDA graph capture for the solve loop.
544+
use_graph: Whether to use graph capture for the solve loop.
545545
loop_granularity: Number of iterations before termination criteria are checked.
546546
"""
547547

@@ -555,7 +555,7 @@ def __init__(
555555
maxiter: wp.array[wp.int32] | None = None,
556556
Mi: BatchedLinearOperator[ScalarType, IndexType] | None = None,
557557
callback: Callable | None = None,
558-
use_cuda_graph: bool = True,
558+
use_graph: bool = True,
559559
use_graph_conditionals: bool = True,
560560
loop_granularity: int = 1,
561561
):
@@ -593,7 +593,7 @@ def __init__(
593593
self.loop_granularity = loop_granularity
594594

595595
self.callback = callback
596-
self.use_cuda_graph = use_cuda_graph
596+
self.use_graph = use_graph
597597

598598
self.dot_tile_size = min(2048, 2 ** math.ceil(math.log(self.maxdims, 2)))
599599
self.tiled_dot_kernel = make_dot_kernel(self.dot_tile_size, self.maxdims)
@@ -756,7 +756,7 @@ def solve(
756756
self.maxiter,
757757
self.atol_sq,
758758
self.callback,
759-
self.use_cuda_graph,
759+
self.use_graph,
760760
use_graph_conditionals=self.use_graph_conditionals,
761761
maxiter_host=self.maxiter_host,
762762
loop_granularity=min(self.loop_granularity, self.maxiter_host),
@@ -890,7 +890,7 @@ def solve(
890890
self.maxiter,
891891
self.atol_sq,
892892
self.callback,
893-
self.use_cuda_graph,
893+
self.use_graph,
894894
use_graph_conditionals=self.use_graph_conditionals,
895895
maxiter_host=self.maxiter_host,
896896
loop_granularity=min(self.loop_granularity, self.maxiter_host),

newton/_src/solvers/kamino/_src/linalg/linear.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,7 @@ def _allocate_impl(self, operator, **kwargs: dict[str, Any]) -> None:
776776
maxiter=self._maxiter,
777777
Mi=self._Mi,
778778
callback=None,
779-
use_cuda_graph=True,
779+
use_graph=True,
780780
use_graph_conditionals=self._use_graph_conditionals,
781781
loop_granularity=self.loop_granularity,
782782
)
@@ -790,7 +790,7 @@ def _allocate_impl(self, operator, **kwargs: dict[str, Any]) -> None:
790790
maxiter=self._maxiter,
791791
Mi=self._Mi,
792792
callback=None,
793-
use_cuda_graph=True,
793+
use_graph=True,
794794
use_graph_conditionals=self._use_graph_conditionals,
795795
loop_granularity=self.loop_granularity,
796796
)
@@ -888,7 +888,7 @@ def _allocate_impl(self, operator, **kwargs: dict[str, Any]) -> None:
888888
maxiter=self._maxiter,
889889
Mi=self._Mi,
890890
callback=None,
891-
use_cuda_graph=True,
891+
use_graph=True,
892892
use_graph_conditionals=self._use_graph_conditionals,
893893
loop_granularity=self.loop_granularity,
894894
)
@@ -902,7 +902,7 @@ def _allocate_impl(self, operator, **kwargs: dict[str, Any]) -> None:
902902
maxiter=self._maxiter,
903903
Mi=self._Mi,
904904
callback=None,
905-
use_cuda_graph=True,
905+
use_graph=True,
906906
use_graph_conditionals=self._use_graph_conditionals,
907907
loop_granularity=self.loop_granularity,
908908
)

newton/_src/solvers/kamino/tests/test_linalg_solve_cg.py

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def _test_solve(self, solver_cls, problem_params, device):
7575
maxiter=maxiter,
7676
Mi=None,
7777
callback=None,
78-
use_cuda_graph=False,
78+
use_graph=False,
7979
)
8080
cur_iter, r_norm_sq, atol_sq = solver.solve(b_wp, x_wp)
8181

@@ -139,6 +139,98 @@ def test_solve_cr_cuda(self):
139139
with self.subTest(problem=problem_name, solver=solver_cls.__name__):
140140
self._test_solve(solver_cls, problem_params, device)
141141

142+
def _test_capture_replay_matches_eager(self, solver_cls, device):
143+
"""Regression: capture-and-replay must match an eager solve.
144+
145+
Guards against the class of bug where the capture-safe path in
146+
``_run_capturable_loop`` silently under-iterates. Concretely, if the
147+
break decision is frozen at record time via a host readback of stale
148+
pre-capture memory, the recorded graph bakes in a fixed cycle count
149+
and ``cur_iter`` under capture will be far below the eager count.
150+
"""
151+
device = wp.get_device(device)
152+
problem = RandomProblemLLT(
153+
maxdims=8,
154+
dims=[5, 8],
155+
seed=self.seed,
156+
np_dtype=np.float32,
157+
wp_dtype=wp.float32,
158+
device=device,
159+
)
160+
n_worlds = problem.num_blocks
161+
162+
info = DenseSquareMultiLinearInfo()
163+
info.finalize(dimensions=problem.maxdims, dtype=wp.float32, device=device)
164+
info.dim = problem.dim_wp
165+
operator = DenseLinearOperatorData(info=info, mat=problem.A_wp)
166+
A = BatchedLinearOperator.from_dense(operator)
167+
168+
world_active = wp.full(n_worlds, True, dtype=wp.bool, device=device)
169+
maxdim = max(problem.maxdims)
170+
atol = wp.full(n_worlds, 1.0e-4, dtype=problem.wp_dtype, device=device)
171+
rtol = wp.full(n_worlds, 1.0e-5, dtype=problem.wp_dtype, device=device)
172+
maxiter = wp.full(n_worlds, max(3 * maxdim, 50), dtype=int, device=device)
173+
174+
def new_solver(*, use_graph):
175+
return solver_cls(
176+
A=A,
177+
world_active=world_active,
178+
atol=atol,
179+
rtol=rtol,
180+
maxiter=maxiter,
181+
Mi=None,
182+
callback=None,
183+
use_graph=use_graph,
184+
)
185+
186+
# Eager reference.
187+
x_eager = wp.zeros(info.total_vec_size, dtype=wp.float32, device=device)
188+
eager_cur, _, _ = new_solver(use_graph=False).solve(problem.b_wp, x_eager)
189+
190+
# Captured replay. Warm up outside capture so kernels compile before
191+
# recording; zero the output between the warmup and the capture body
192+
# so both solves see the same initial state.
193+
solver = new_solver(use_graph=True)
194+
x_capture = wp.zeros(info.total_vec_size, dtype=wp.float32, device=device)
195+
solver.solve(problem.b_wp, x_capture)
196+
x_capture.zero_()
197+
# Warp CPU graph capture currently rejects wp.copy() on non-contiguous
198+
# arrays. Both the buggy pre-fix eager path (via strided
199+
# ``dot_partial_sums[:, :, 0]`` in ``dot_product``) and the fixed
200+
# capture path (via ``rz_old.assign(rz_new)`` in ``do_iteration``)
201+
# hit that limitation on CPU, so the parity assertions below only
202+
# activate once Warp lifts it. The CUDA counterparts exercise the
203+
# same gate on a path where the dot buffer is contiguous and no
204+
# skip is needed.
205+
try:
206+
with wp.ScopedCapture(device) as cap:
207+
cap_cur, _, _ = solver.solve(problem.b_wp, x_capture)
208+
except NotImplementedError as e:
209+
if "non-contiguous" not in str(e):
210+
raise
211+
self.skipTest(f"Warp graph capture limitation on {device}: {e}")
212+
assert cap.graph is not None
213+
wp.capture_launch(cap.graph)
214+
215+
np.testing.assert_array_equal(cap_cur.numpy(), eager_cur.numpy())
216+
np.testing.assert_array_equal(x_capture.numpy(), x_eager.numpy())
217+
218+
def test_capture_replay_cg_cpu(self):
219+
self._test_capture_replay_matches_eager(CGSolver, "cpu")
220+
221+
def test_capture_replay_cr_cpu(self):
222+
self._test_capture_replay_matches_eager(CRSolver, "cpu")
223+
224+
def test_capture_replay_cg_cuda(self):
225+
if not wp.get_cuda_devices():
226+
self.skipTest("No CUDA devices found")
227+
self._test_capture_replay_matches_eager(CGSolver, wp.get_cuda_device())
228+
229+
def test_capture_replay_cr_cuda(self):
230+
if not wp.get_cuda_devices():
231+
self.skipTest("No CUDA devices found")
232+
self._test_capture_replay_matches_eager(CRSolver, wp.get_cuda_device())
233+
142234
def _test_sparse_solve(self, solver_cls, dims, block_size, device):
143235
"""Test CG/CR with sparse matrices built from random SPD matrices.
144236
@@ -223,7 +315,7 @@ def _test_sparse_solve(self, solver_cls, dims, block_size, device):
223315
maxiter=None,
224316
Mi=None,
225317
callback=None,
226-
use_cuda_graph=False,
318+
use_graph=False,
227319
)
228320
solver_dense.solve(b_wp, x_dense)
229321

@@ -237,7 +329,7 @@ def _test_sparse_solve(self, solver_cls, dims, block_size, device):
237329
maxiter=None,
238330
Mi=None,
239331
callback=None,
240-
use_cuda_graph=False,
332+
use_graph=False,
241333
)
242334
solver_sparse.solve(b_wp, x_sparse)
243335

@@ -335,7 +427,7 @@ def test_sparse_cg_solve_simple(self):
335427
rtol=rtol,
336428
maxiter=None,
337429
Mi=None,
338-
use_cuda_graph=False,
430+
use_graph=False,
339431
)
340432
solver.solve(b_wp, x_wp)
341433

@@ -477,7 +569,7 @@ def _test_solve_heterogeneous(self, solver_cls, problem_params, device):
477569
maxiter=maxiter,
478570
Mi=None,
479571
callback=None,
480-
use_cuda_graph=False,
572+
use_graph=False,
481573
)
482574
solver.solve(b, x_wp)
483575

@@ -570,7 +662,7 @@ def _test_solve_heterogeneous_jacobi(self, solver_cls, problem_params, device):
570662
maxiter=maxiter,
571663
Mi=Mi,
572664
callback=None,
573-
use_cuda_graph=False,
665+
use_graph=False,
574666
)
575667
solver.solve(b, x_wp)
576668

newton/_src/solvers/vbd/solver_vbd.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
State,
2323
StateFlags,
2424
)
25+
from ...utils import is_graph_capture_allocation_enabled
2526
from ...utils.deprecation import deprecate_nonkeyword_arguments
2627
from ..coupled.interface import CouplingInterface
2728
from ..solver import SolverBase
@@ -134,13 +135,16 @@ class SolverVBD(SolverBase, CouplingInterface):
134135
Buffer sizing:
135136
SolverVBD pre-allocates contact state from capacities populated by
136137
:class:`~newton.CollisionPipeline` when available; otherwise, the first
137-
:meth:`step` lazily sizes buffers from ``Contacts``. During CUDA graph
138-
recording, ordinary lazy resizing is supported only when Warp's memory pool
139-
is enabled; otherwise, the solver raises with guidance to pre-size before
140-
capture. Rigid contact history must be allocated before capture regardless
141-
of memory-pool support. With ``rigid_contact_history=True``, construct
142-
:class:`~newton.CollisionPipeline` before ``SolverVBD``, or run one
143-
uncaptured solver step before capture.
138+
:meth:`step` lazily sizes buffers from ``Contacts``. During graph capture,
139+
ordinary lazy resizing is supported on CPU and on CUDA with Warp's
140+
stream-ordered memory pool enabled; otherwise the solver raises with
141+
guidance to pre-size before capture. Rigid contact history is
142+
cross-replay-persistent state, so it must always be allocated before
143+
capture regardless of the device's allocation-during-capture support --
144+
allocating it inside a graph records a `wp.zeros` fill that wipes the
145+
warm-start buffers on every replay. With ``rigid_contact_history=True``,
146+
construct :class:`~newton.CollisionPipeline` before ``SolverVBD``, or run
147+
one uncaptured solver step before capture.
144148
145149
References:
146150
- Anka He Chen, Ziheng Liu, Yin Yang, and Cem Yuksel. 2024. Vertex Block Descent. ACM Trans. Graph. 43, 4, Article 116 (July 2024), 16 pages.
@@ -341,7 +345,7 @@ def __init__(
341345
Requires contacts with ``rigid_contact_match_index`` populated; use
342346
``CollisionPipeline(contact_matching="latest")`` for VBD warm-starting. Ignored
343347
when ``integrate_with_external_rigid_solver=True`` or ``model.body_count == 0``.
344-
For CUDA graph capture, construct :class:`~newton.CollisionPipeline` before
348+
During graph capture, construct :class:`~newton.CollisionPipeline` before
345349
``SolverVBD`` so history is pre-allocated, or run one uncaptured solver step
346350
before capture.
347351
rigid_contact_stick_motion_eps: Tangential contact residual threshold for marking hard
@@ -1180,8 +1184,6 @@ def _init_rigid_contact_warmstart(self, rigid_contact_max: int) -> None:
11801184
self._prev_contact_normal = wp.zeros(cap, dtype=wp.vec3, device=self.device)
11811185

11821186
def _raise_if_capturing_resize(self, name: str, current: int, required: int) -> None:
1183-
from ...utils import is_graph_capture_allocation_enabled # noqa: PLC0415
1184-
11851187
if self.device.is_capturing and not is_graph_capture_allocation_enabled(self.device):
11861188
raise RuntimeError(
11871189
f"SolverVBD {name} buffer needs to grow from {current} to {required} "
@@ -1809,7 +1811,7 @@ def step(
18091811
18101812
Raises:
18111813
RuntimeError: If required rigid contact-matching data is unavailable, or contact-history storage would
1812-
need to be allocated or grown during CUDA graph capture.
1814+
need to be allocated or grown during graph capture.
18131815
"""
18141816
self._apply_module_options()
18151817
update_rigid = self._update_rigid_history
@@ -2141,11 +2143,17 @@ def _initialize_rigid_bodies(
21412143
internal_rigid = model.body_count > 0 and not self.integrate_with_external_rigid_solver
21422144
rigid_capacity = contacts.rigid_contact_max if contacts is not None else 0
21432145

2146+
# Rigid contact history is cross-replay-persistent state: allocating it
2147+
# during capture records a `wp.zeros` fill into the graph, which then
2148+
# re-zeros the warm-start buffers on every replay -- silently
2149+
# equivalent to `rigid_contact_history=False`. So this guard fires
2150+
# unconditionally when capturing, regardless of the device's
2151+
# allocation-during-capture support.
21442152
if self.device.is_capturing and internal_rigid and self.rigid_contact_history:
21452153
history_capacity = 0 if self._prev_contact_lambda is None else self._prev_contact_lambda.shape[0]
21462154
if history_capacity < rigid_capacity:
21472155
raise RuntimeError(
2148-
"SolverVBD contact history must be allocated before CUDA graph capture. "
2156+
"SolverVBD contact history must be allocated before graph capture. "
21492157
"Construct CollisionPipeline before SolverVBD, or run one uncaptured solver step before capture."
21502158
)
21512159

newton/examples/cable/example_cable_cross_slide_table.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -775,7 +775,7 @@ def __init__(self, viewer, args):
775775
self.control = self.model.control()
776776
self.contacts = self.collision_pipeline.contacts()
777777

778-
# Device arrays used by kernels during simulation and CUDA graph replay.
778+
# Device arrays used by kernels during simulation and captured replay.
779779
self.kinematic_body_indices = wp.array(
780780
kinematic_body_indices,
781781
dtype=wp.int32,
@@ -834,13 +834,10 @@ def __init__(self, viewer, args):
834834
self.capture()
835835

836836
def capture(self):
837-
"""Capture the simulation update when running on CUDA."""
838-
if self.solver.device.is_cuda:
839-
with wp.ScopedCapture() as capture:
840-
self.simulate()
841-
self.graph = capture.graph
842-
else:
843-
self.graph = None
837+
"""Capture the simulation update into a graph for replay."""
838+
with wp.ScopedCapture() as capture:
839+
self.simulate()
840+
self.graph = capture.graph
844841

845842
def simulate(self):
846843
"""Advance the XY table simulation by one rendered frame."""

0 commit comments

Comments
 (0)