Skip to content

Commit c868ad8

Browse files
Replace the per-step publish with State.assign()
Per review, drop _publish_state() and the step-6 call. Actuator.step() and the current/next double-buffer contract go back to untouched, so a caller can again retain the pre-step state and no actuator pays a state copy on every step. Add Actuator.State.assign() instead, mirroring newton.State.assign(): callers that capture an odd-length region assign at its boundary in place of the final swap. The helper walks __dict__ rather than dataclass fields, so a controller state that allocates arrays in __init__ is copied too, and it raises on a component or array mismatch instead of silently skipping. An undecorated Controller.State subclass is a dataclass with no fields, which the previous helper missed. Document both routes: the boundary assign, and alternating one graph per buffer orientation, which needs no Newton-side support and so works on releases without assign(). Test both, and cover the assign contract. Closes #4098
1 parent ce31ce6 commit c868ad8

5 files changed

Lines changed: 178 additions & 70 deletions

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()` to copy actuator state between the caller's two state objects, so a CUDA graph region holding an odd number of actuator steps can advance stateful `Delay`, `ControllerPID` and `ControllerNeuralLSTM` state by assigning at the region boundary instead of relying on the host-side swap, which a graph does not record.

changelog/4098.fixed.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

docs/concepts/actuators.rst

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -159,14 +159,16 @@ reusable from a custom simulator or test harness:
159159
actuator.step(sim_state, sim_control, state_a, state_b, dt=0.01)
160160

161161

162+
.. _stateful-actuators:
163+
162164
Stateful Actuators
163165
------------------
164166

165167
Controllers that maintain internal state (e.g. :class:`ControllerPID` with an
166168
integral accumulator, or :class:`ControllerNeuralLSTM` with hidden/cell state) and
167169
actuators with a :class:`Delay` require explicit double-buffered state
168-
management. Create two state objects with :meth:`Actuator.state` and pass them
169-
to every step:
170+
management. Create two state objects with :meth:`Actuator.state` and swap them
171+
after each step:
170172

171173
.. testcode:: actuator-usage
172174

@@ -178,18 +180,52 @@ to every step:
178180
for step in range(3):
179181
control.joint_f.zero_() # zero output before stepping actuators
180182
model.actuators[0].step(state, control, state_0, state_1, dt=0.01)
183+
state_0, state_1 = state_1, state_0
184+
185+
That swap is a host-side rebinding of two Python names. A CUDA graph records
186+
buffer addresses instead, so a captured region holding an **odd** number of
187+
actuator steps replays with its two state objects the wrong way round: the last
188+
step's update is discarded on every replay, and a region holding a single step
189+
never advances state at all. An even-length region is unaffected, because its
190+
swaps cancel inside the graph.
191+
192+
Either pattern below keeps an odd-length region correct. Assign at the region
193+
boundary, in place of its final swap, to keep one graph at the cost of one state
194+
copy per replay:
195+
196+
.. code-block:: python
197+
198+
steps = 3 # odd, so the region needs a boundary assign
199+
with wp.ScopedCapture() as capture:
200+
for i in range(steps):
201+
control.joint_f.zero_()
202+
model.actuators[0].step(state, control, state_0, state_1, dt=0.01)
203+
if steps % 2 == 1 and i == steps - 1:
204+
state_0.assign(state_1)
205+
else:
206+
state_0, state_1 = state_1, state_0
207+
208+
:meth:`Actuator.State.assign` mirrors :meth:`newton.State.assign`, which solves
209+
the same problem for an odd number of solver substeps.
210+
211+
Or capture one graph per buffer orientation and alternate them. This copies no
212+
state and needs at most two graphs, since the orientation returns to its start
213+
after two odd-length regions:
214+
215+
.. code-block:: python
181216
182-
:meth:`Actuator.step` writes the step's update into *state_1* and then publishes
183-
it back over *state_0* with a device copy, so the two objects hold the same
184-
advanced state when the step returns. Both the exchange and the update are
185-
device operations, which is what makes a stateful actuator behave the same under
186-
CUDA graph capture as it does eagerly, whatever number of steps the captured
187-
region holds.
217+
graphs = {}
218+
for _ in range(replays):
219+
key = id(state_0) # one entry per buffer orientation
220+
if key not in graphs:
221+
with wp.ScopedCapture() as capture:
222+
after = run_region(state_0, state_1) # ordinary swapping inside
223+
graphs[key] = (capture.graph, after)
224+
graph, (state_0, state_1) = graphs[key]
225+
wp.capture_launch(graph)
188226
189-
Earlier releases advanced state only through a host-side
190-
``state_0, state_1 = state_1, state_0`` swap after each step. That swap is no
191-
longer needed; it remains correct if kept, since both objects now hold the same
192-
state.
227+
The second pattern needs no Newton-side support, so it also works on releases
228+
without :meth:`Actuator.State.assign`.
193229

194230
Stateless actuators (e.g. a plain PD controller without delay) do not require
195231
state objects — simply omit them:
@@ -348,10 +384,12 @@ backend: ONNX checkpoints are graphable, while Torch checkpoints are not due
348384
to framework interop overhead. :meth:`Actuator.is_graphable` returns ``True``
349385
when all components can be captured in a CUDA graph.
350386

351-
A graphable actuator is graphable for any number of steps in the captured
352-
region, including one. Internal state — a :class:`Delay` buffer, a PID integral,
353-
LSTM hidden and cell state — is both advanced and exchanged on the device, so
354-
each replay carries it forward exactly as an eager loop of the same length does.
387+
:meth:`Actuator.is_graphable` describes the components, not the captured region.
388+
A stateful actuator also needs the region's state exchange to be graph-safe: see
389+
:ref:`stateful-actuators` for the two patterns that keep an odd-length region
390+
correct. ``ControllerNeuralLSTM`` on a Torch checkpoint is a further exception —
391+
it keeps hidden and cell state in Torch tensors that the controller rebinds on
392+
the host, so that state never advances inside a graph at all.
355393

356394
Available Components
357395
--------------------

newton/_src/actuators/actuator.py

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from __future__ import annotations
55

6-
from dataclasses import dataclass, fields, is_dataclass
6+
from dataclasses import dataclass
77
from typing import Any
88

99
import numpy as np
@@ -32,30 +32,43 @@ def _scatter_add_kernel(
3232
computed_output[idx] = computed_output[idx] + computed_forces[i]
3333

3434

35-
def _publish_state(current: Any, advanced: Any) -> None:
36-
"""Copy an advanced state object back over the state the next step reads.
35+
def _assign_component_state(dst: Any, src: Any, name: str) -> None:
36+
"""Copy one component state (delay or controller) from *src* into *dst*.
3737
38-
Warp arrays are copied with :func:`warp.copy`, so the exchange is a device
39-
operation that a CUDA graph records and replays. Fields that are not Warp
40-
arrays — the Torch tensors :class:`ControllerNeuralLSTM.State` holds for a
41-
Torch checkpoint — are rebound instead, which is how the controller itself
42-
publishes them; that path is host-side and not graphable either way.
38+
Walks both objects' ``__dict__`` rather than their dataclass fields, so a
39+
state that allocates its arrays in ``__init__`` is covered too. Warp
40+
arrays are copied in place, which is what a captured region records.
41+
Values that are not Warp arrays — the Torch tensors
42+
:class:`ControllerNeuralLSTM.State` holds for a Torch checkpoint — are
43+
rebound, matching how that controller publishes them; a Torch checkpoint is
44+
not graphable either way.
4345
4446
Args:
45-
current: State the next step reads from.
46-
advanced: State the step just wrote.
47+
dst: Component state to copy into.
48+
src: Component state to copy from.
49+
name: Component name, used in error messages.
50+
51+
Raises:
52+
ValueError: The two states do not hold the same values.
4753
"""
48-
for field in fields(advanced):
49-
src = getattr(advanced, field.name)
50-
if src is None:
54+
if dst is None and src is None:
55+
return
56+
if dst is None or src is None:
57+
raise ValueError(f"Cannot assign '{name}': one state has it allocated and the other does not.")
58+
for attr in set(dst.__dict__) | set(src.__dict__):
59+
val_dst = getattr(dst, attr, None)
60+
val_src = getattr(src, attr, None)
61+
if val_dst is None and val_src is None:
5162
continue
52-
dst = getattr(current, field.name)
53-
if isinstance(src, wp.array):
54-
wp.copy(dst, src)
55-
elif is_dataclass(src):
56-
_publish_state(dst, src)
63+
if val_dst is None or val_src is None:
64+
raise ValueError(f"Cannot assign '{name}.{attr}': present in one state and missing in the other.")
65+
array_dst = isinstance(val_dst, wp.array)
66+
if array_dst != isinstance(val_src, wp.array):
67+
raise ValueError(f"Cannot assign '{name}.{attr}': a Warp array in one state and not in the other.")
68+
if array_dst:
69+
val_dst.assign(val_src)
5770
else:
58-
setattr(current, field.name, src)
71+
setattr(dst, attr, val_src)
5972

6073

6174
class Actuator:
@@ -108,6 +121,33 @@ def reset(self, mask: wp.array[wp.bool] | None = None) -> None:
108121
if self.controller_state is not None:
109122
self.controller_state.reset(mask)
110123

124+
def assign(self, other: Actuator.State) -> None:
125+
"""Copy the state held by *other* into this one.
126+
127+
Mirrors :meth:`newton.State.assign`. A CUDA graph records buffer
128+
addresses, not the caller's Python names, so a captured region
129+
holding an odd number of actuator steps leaves its two state
130+
objects the wrong way round for the next replay. Assigning at the
131+
boundary, in place of that region's final swap, costs one copy per
132+
replay and keeps a single graph correct::
133+
134+
for i in range(steps):
135+
control.joint_f.zero_()
136+
actuator.step(state, control, state_0, state_1, dt=0.01)
137+
if steps % 2 == 1 and i == steps - 1:
138+
state_0.assign(state_1)
139+
else:
140+
state_0, state_1 = state_1, state_0
141+
142+
Args:
143+
other: State to copy from.
144+
145+
Raises:
146+
ValueError: The two states do not hold the same components.
147+
"""
148+
_assign_component_state(self.delay_state, other.delay_state, "delay_state")
149+
_assign_component_state(self.controller_state, other.controller_state, "controller_state")
150+
111151
def __init__(
112152
self,
113153
indices: wp.array[wp.uint32],
@@ -300,14 +340,6 @@ def step(
300340
(e.g. ``control.joint_f.zero_()``) before looping over actuators.
301341
5. **State updates** — controller state update, then delay
302342
buffer write (push current targets into ``next_state``).
303-
6. **State publish** — copy the advanced state back over
304-
``current_act_state``, so both state objects hold it.
305-
306-
Step 6 makes the state exchange a device operation instead of a
307-
host-side rebinding of the two state objects. A captured region
308-
therefore advances state on every replay, whatever number of steps it
309-
holds; the ``state_0, state_1 = state_1, state_0`` swap of earlier
310-
releases is no longer required and stays correct if kept.
311343
312344
Args:
313345
sim_state: Simulation state with position/velocity arrays.
@@ -400,7 +432,3 @@ def step(
400432
current_act_state.delay_state,
401433
next_act_state.delay_state,
402434
)
403-
404-
# --- 6. Publish the advanced state on-device ---
405-
if self.is_stateful() and next_act_state is not current_act_state:
406-
_publish_state(current_act_state, next_act_state)

newton/tests/test_actuators.py

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4090,6 +4090,32 @@ def _loop(
40904090
)
40914091

40924092

4093+
class TestActuatorStateAssign(unittest.TestCase):
4094+
"""Cover the :meth:`Actuator.State.assign` copy contract."""
4095+
4096+
def test_assign_copies_arrays_allocated_outside_dataclass_fields(self):
4097+
"""Copy state a controller allocates in ``__init__`` rather than as a field."""
4098+
4099+
class _CustomState(Controller.State):
4100+
"""Undecorated subclass: ``dataclasses.fields()`` is empty for it."""
4101+
4102+
def __init__(self, num_actuators: int, device: wp.Device):
4103+
self.extra = wp.zeros(num_actuators, dtype=wp.float32, device=device)
4104+
4105+
device = wp.get_device()
4106+
current, advanced = _CustomState(1, device), _CustomState(1, device)
4107+
advanced.extra.fill_(1.0)
4108+
4109+
Actuator.State(controller_state=current).assign(Actuator.State(controller_state=advanced))
4110+
self.assertEqual(current.extra.numpy()[0], 1.0)
4111+
4112+
def test_assign_rejects_mismatched_components(self):
4113+
"""Raise rather than silently skip when only one state holds a component."""
4114+
state = ControllerPID.State(integral=wp.zeros(1, dtype=wp.float32, device=wp.get_device()))
4115+
with self.assertRaisesRegex(ValueError, "controller_state"):
4116+
Actuator.State(controller_state=state).assign(Actuator.State())
4117+
4118+
40934119
@unittest.skipUnless(
40944120
wp.get_device().is_cuda and wp.is_mempool_enabled(wp.get_device()),
40954121
"CUDA graph capture requires CUDA device with memory pools",
@@ -4099,23 +4125,25 @@ class TestControllerStateGraphCapture(unittest.TestCase):
40994125
41004126
A PID with only ``ki`` set, held at a constant position error by a model no
41014127
solver moves, accumulates exactly ``ki * error * dt`` per actuator step
4102-
however the loop is chunked. While only the caller's host-side swap
4103-
advanced state, an odd captured count above one discarded the last step's
4104-
update and a single captured step never advanced at all.
4128+
however the loop is chunked. A graph cannot re-point its state buffers
4129+
between replays, so an odd-length captured region needs either a boundary
4130+
:meth:`Actuator.State.assign` or a second graph of the opposite parity.
41054131
"""
41064132

41074133
DT = 0.01
41084134
KI = 1.0
41094135
TARGET = 1.0
41104136
STEPS = 12
41114137

4112-
def _integral_after_all_steps(self, implicit: bool, steps_per_graph: int | None) -> float:
4113-
"""Run :attr:`STEPS` actuator steps, eagerly or as replays of a captured region.
4138+
def _integral_after_all_steps(self, implicit: bool, steps_per_graph: int | None, parity_graphs: bool) -> float:
4139+
"""Run :attr:`STEPS` actuator steps and return the resulting PID integral.
41144140
41154141
Args:
41164142
implicit: Solve the control law implicitly instead of explicitly.
41174143
Both effort modes advance the integral through the same state.
41184144
steps_per_graph: Steps per captured region, or ``None`` to run eagerly.
4145+
parity_graphs: Capture one graph per state-buffer orientation and
4146+
alternate them, instead of assigning at the region boundary.
41194147
"""
41204148
device = wp.get_device()
41214149
builder = newton.ModelBuilder(gravity=(0.0, 0.0, 0.0))
@@ -4134,14 +4162,17 @@ def _integral_after_all_steps(self, implicit: bool, steps_per_graph: int | None)
41344162
control.joint_target_q.fill_(self.TARGET) # joint_q stays 0, so the error is constant
41354163
s0, s1 = actuator.state(), actuator.state()
41364164

4137-
def run(s0, s1, steps):
4138-
"""The documented stateful loop, host-side swap included."""
4139-
for _ in range(steps):
4165+
def run(s0, s1, steps, boundary_assign=False):
4166+
"""Step the actuator, swapping state as the documented loop does."""
4167+
for i in range(steps):
41404168
control.joint_f.zero_()
41414169
if oracle is not None:
41424170
oracle.refresh(state)
41434171
actuator.step(state, control, s0, s1, dt=self.DT)
4144-
s0, s1 = s1, s0
4172+
if boundary_assign and steps % 2 == 1 and i == steps - 1:
4173+
s0.assign(s1) # keeps a single odd-length graph correct
4174+
else:
4175+
s0, s1 = s1, s0
41454176
return s0, s1
41464177

41474178
if steps_per_graph is None:
@@ -4151,24 +4182,35 @@ def run(s0, s1, steps):
41514182
s0, s1 = run(s0, s1, 1)
41524183
s0.controller_state.integral.zero_()
41534184
s1.controller_state.integral.zero_()
4154-
with wp.ScopedCapture(device) as capture:
4155-
s0, s1 = run(s0, s1, steps_per_graph)
4185+
graphs = {}
41564186
for _ in range(self.STEPS // steps_per_graph):
4157-
wp.capture_launch(capture.graph)
4158-
wp.synchronize_device(device)
4187+
# Keying on the current buffer builds one graph per orientation.
4188+
key = id(s0) if parity_graphs else None
4189+
if key not in graphs:
4190+
with wp.ScopedCapture(device) as capture:
4191+
after = run(s0, s1, steps_per_graph, boundary_assign=not parity_graphs)
4192+
graphs[key] = (capture.graph, after)
4193+
graph, (s0, s1) = graphs[key]
4194+
wp.capture_launch(graph)
4195+
self.assertLessEqual(len(graphs), 2, msg="alternating parity needs at most two graphs")
41594196
return float(s0.controller_state.integral.numpy()[0])
41604197

4161-
def _assert_integral_matches(self, implicit: bool) -> None:
4198+
def test_pid_integral_advances_per_replay_boundary_assign(self):
4199+
"""Assign at an odd-length region's boundary and match the eager integral."""
41624200
expected = self.KI * self.TARGET * self.DT * self.STEPS
4163-
for steps_per_graph in (None, 1, 2, 3):
4201+
for implicit in (False, True):
4202+
for steps_per_graph in (None, 1, 2, 3):
4203+
with self.subTest(implicit=implicit, steps_per_graph=steps_per_graph):
4204+
got = self._integral_after_all_steps(implicit, steps_per_graph, parity_graphs=False)
4205+
self.assertAlmostEqual(got, expected, places=6)
4206+
4207+
def test_pid_integral_advances_per_replay_parity_graphs(self):
4208+
"""Alternate one graph per buffer orientation and match the eager integral."""
4209+
expected = self.KI * self.TARGET * self.DT * self.STEPS
4210+
for steps_per_graph in (1, 2, 3):
41644211
with self.subTest(steps_per_graph=steps_per_graph):
4165-
self.assertAlmostEqual(self._integral_after_all_steps(implicit, steps_per_graph), expected, places=6)
4166-
4167-
def test_pid_integral_advances_per_replay_explicit(self):
4168-
self._assert_integral_matches(implicit=False)
4169-
4170-
def test_pid_integral_advances_per_replay_implicit(self):
4171-
self._assert_integral_matches(implicit=True)
4212+
got = self._integral_after_all_steps(False, steps_per_graph, parity_graphs=True)
4213+
self.assertAlmostEqual(got, expected, places=6)
41724214

41734215

41744216
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)