Skip to content

Commit 31598d1

Browse files
authored
Backport Actuator.State.assign() to release-1.6 (#4154)
2 parents 3d0d7d6 + 0f5988a commit 31598d1

4 files changed

Lines changed: 409 additions & 1 deletion

File tree

changelog/4098.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `Actuator.State.assign()` for preserving actuator state across odd-length CUDA graph replays with a single captured graph.

docs/concepts/actuators.rst

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ reusable from a custom simulator or test harness:
169169
actuator.step(sim_state, sim_control, state_a, state_b, dt=0.01)
170170

171171

172+
.. _stateful-actuators:
173+
172174
Stateful Actuators
173175
------------------
174176

@@ -190,6 +192,51 @@ after each step:
190192
model.actuators[0].step(state, control, state_0, state_1, dt=0.01)
191193
state_0, state_1 = state_1, state_0
192194

195+
The swap rebinds Python names, while a CUDA graph records fixed buffer
196+
addresses. A captured region with an odd number of actuator steps therefore
197+
cannot carry its final state into the next replay through the ordinary swap. A
198+
single-step region updates its destination buffer, but does not advance state
199+
across replays. Even-length regions end with the original buffer orientation
200+
and need no special handling.
201+
202+
For an odd-length region, choose between assigning the state at the region
203+
boundary or alternating graphs for the two buffer orientations.
204+
205+
Boundary assignment
206+
^^^^^^^^^^^^^^^^^^^
207+
208+
Call :meth:`Actuator.State.assign` in place of the final swap. This uses one
209+
graph and copies the actuator state once at the boundary of each replay.
210+
211+
.. code-block:: python
212+
213+
with wp.ScopedCapture() as capture:
214+
for i in range(steps):
215+
control.joint_f.zero_()
216+
model.actuators[0].step(state, control, state_0, state_1, dt=0.01)
217+
if steps % 2 == 1 and i == steps - 1:
218+
state_0.assign(state_1)
219+
else:
220+
state_0, state_1 = state_1, state_0
221+
222+
Alternating graphs
223+
^^^^^^^^^^^^^^^^^^
224+
225+
Key captured graphs by the current state buffer and alternate between them.
226+
This avoids the boundary copy and keeps at most two graphs.
227+
228+
.. code-block:: python
229+
230+
graphs = {}
231+
for _ in range(replays):
232+
key = id(state_0) # one entry per buffer orientation
233+
if key not in graphs:
234+
with wp.ScopedCapture() as capture:
235+
after = run_region(state_0, state_1) # ordinary swapping inside
236+
graphs[key] = (capture.graph, after)
237+
graph, (state_0, state_1) = graphs[key]
238+
wp.capture_launch(graph)
239+
193240
Stateless actuators (e.g. a plain PD drive without delay) do not require
194241
state objects — simply omit them:
195242

@@ -348,6 +395,12 @@ backend: ONNX checkpoints are graphable, while Torch checkpoints are not due
348395
to framework interop overhead. :meth:`Actuator.is_graphable` returns ``True``
349396
when all components can be captured in a CUDA graph.
350397

398+
:meth:`Actuator.is_graphable` describes the components, not the captured region.
399+
A stateful actuator also needs the region's state exchange to be graph-safe. See
400+
:ref:`stateful-actuators` for the two patterns that keep an odd-length region
401+
correct. Torch-backed neural drives are not graphable and cannot be used in a
402+
captured region.
403+
351404
Available Components
352405
--------------------
353406

@@ -425,6 +478,12 @@ For example, a custom drive needs to implement
425478
:meth:`~newton.ModelBuilder.add_actuator` or USD schemas) to constructor
426479
parameters, filling in defaults where needed.
427480
481+
A stateful custom drive also defines a dataclass subclass of
482+
:class:`DriveBase.State` and implements :meth:`~DriveBase.State.reset`. The
483+
default :meth:`Actuator.State.assign` behavior copies direct Warp array and
484+
Torch tensor fields without replacing their storage. States with other field
485+
types or nested storage implement ``assign()`` to define that copy.
486+
428487
A custom drive works in the explicit mode with the methods above. To also
429488
support the implicit mode it provides three more things, because the solve
430489
evaluates the control law inside its own kernel rather than calling

newton/_src/actuators/actuator.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from __future__ import annotations
55

66
import warnings
7-
from dataclasses import dataclass
7+
from dataclasses import dataclass, fields
88
from typing import Any
99

1010
import numpy as np
@@ -42,6 +42,78 @@ def _scatter_add_kernel(
4242
computed_output[idx] = computed_output[idx] + computed_forces[i]
4343

4444

45+
def _assign_state_value(dst: Any, src: Any, name: str) -> None:
46+
"""Copy a supported state value without replacing its storage."""
47+
if dst is None and src is None:
48+
return
49+
if dst is None or src is None:
50+
raise ValueError(f"Cannot assign '{name}': present in one state and missing in the other.")
51+
52+
dst_is_warp = isinstance(dst, wp.array)
53+
src_is_warp = isinstance(src, wp.array)
54+
if dst_is_warp or src_is_warp:
55+
if not (dst_is_warp and src_is_warp):
56+
raise ValueError(f"Cannot assign '{name}': a Warp array in one state and not in the other.")
57+
dst.assign(src)
58+
return
59+
60+
dst_is_torch = type(dst).__module__.startswith("torch")
61+
src_is_torch = type(src).__module__.startswith("torch")
62+
if dst_is_torch or src_is_torch:
63+
if not (dst_is_torch and src_is_torch):
64+
raise ValueError(f"Cannot assign '{name}': a Torch tensor in one state and not in the other.")
65+
if dst.shape != src.shape:
66+
raise ValueError(f"Cannot assign '{name}': tensor shapes differ ({dst.shape} and {src.shape}).")
67+
import torch
68+
69+
with torch.inference_mode():
70+
dst.copy_(src)
71+
return
72+
73+
raise ValueError(f"Cannot assign '{name}': expected Warp arrays or Torch tensors.")
74+
75+
76+
def _assign_component_state(dst: Any, src: Any, name: str) -> None:
77+
"""Copy one actuator component state from *src* into *dst*.
78+
79+
Args:
80+
dst: Component state to copy into.
81+
src: Component state to copy from.
82+
name: Component name, used in error messages.
83+
84+
Raises:
85+
ValueError: The two actuator states have incompatible components or
86+
fields.
87+
NotImplementedError: A custom state is not a dataclass and does not
88+
implement ``assign()``.
89+
"""
90+
if dst is None and src is None:
91+
return
92+
if dst is None or src is None:
93+
raise ValueError(f"Cannot assign '{name}': one state has it allocated and the other does not.")
94+
if type(dst) is not type(src):
95+
raise ValueError(f"Cannot assign '{name}': state types differ ({type(dst).__name__} and {type(src).__name__}).")
96+
97+
custom_assign = getattr(dst, "assign", None)
98+
if custom_assign is not None:
99+
custom_assign(src)
100+
return
101+
102+
if "__dataclass_fields__" not in type(dst).__dict__:
103+
raise NotImplementedError(f"{type(dst).__qualname__} must be decorated with @dataclass or implement assign")
104+
105+
state_fields = fields(dst)
106+
field_names = {field.name for field in state_fields}
107+
attributes = set(getattr(dst, "__dict__", ())) | set(getattr(src, "__dict__", ()))
108+
undeclared = attributes - field_names
109+
if undeclared:
110+
names = ", ".join(sorted(undeclared))
111+
raise ValueError(f"Cannot assign '{name}': undeclared state attributes: {names}.")
112+
113+
for field in state_fields:
114+
_assign_state_value(getattr(dst, field.name), getattr(src, field.name), f"{name}.{field.name}")
115+
116+
45117
class Actuator:
46118
"""Composed actuator: delay → drive → clamping.
47119
@@ -130,6 +202,33 @@ def reset(self, mask: wp.array[wp.bool] | None = None) -> None:
130202
if self.drive_state is not None:
131203
self.drive_state.reset(mask)
132204

205+
def assign(self, other: Actuator.State) -> None:
206+
"""Copy the state held by *other* into this one.
207+
208+
A CUDA graph records buffer addresses rather than the caller's
209+
Python names. Assigning at the boundary of an odd-length captured
210+
region, in place of its final state swap, preserves the advanced
211+
state for the next replay::
212+
213+
for i in range(steps):
214+
control.joint_f.zero_()
215+
actuator.step(state, control, state_0, state_1, dt=0.01)
216+
if steps % 2 == 1 and i == steps - 1:
217+
state_0.assign(state_1)
218+
else:
219+
state_0, state_1 = state_1, state_0
220+
221+
Args:
222+
other: State to copy from.
223+
224+
Raises:
225+
ValueError: The two states do not hold the same components.
226+
NotImplementedError: A custom state does not implement
227+
assignment.
228+
"""
229+
_assign_component_state(self.delay_state, other.delay_state, "delay_state")
230+
_assign_component_state(self.drive_state, other.drive_state, "drive_state")
231+
133232
def __init__(
134233
self,
135234
indices: wp.array[wp.uint32],

0 commit comments

Comments
 (0)