Differentiability
Everything between actions and rewards is differentiable.
The dynamics, the soft collisions and the walls run under Warp’s adjoint tape; observations and rewards are plain torch ops over the world state. The two meet in a torch.autograd.Function, so loss.backward() reaches all the way to the actions.
How the bridge works
swarp/interop/autograd.py holds three pieces:
TorchStatea
NamedTupleview of the world state —pos,theta,vel,speed,ang_vel, plus the four 6-DOF drone fields, which default toNoneand are auto-filled with zeros and an identity attitude.warp_step(stepper, state, actions)one differentiable step. The forward wraps the input tensors as Warp arrays zero-copy, records every substep launch on a
wp.Tape, and returns zero-copy views of freshly allocated outputs. The backward seeds the output adjoints and replays the tape in reverse.rollout(stepper, state, actions_seq)chains
Tsteps for backprop-through-time and returns(final_state, trajectory).
Under torch.no_grad() the tape is skipped entirely and intermediate buffers are recycled — same kernels, no steady-state allocations.
Optimizing through the physics
Gradient-descend a plan directly, with no policy and no learning algorithm in the way (examples/optimize_actions.py is the runnable version):
import torch, warp as wp
from swarp import AgentConfig, ControlMode, DynamicsModel, Stepper, TorchState, WorldConfig, rollout
T, n_agents, device = 30, 4, "cuda:0"
cfgs = [AgentConfig(model=DynamicsModel.DIFF_DRIVE, ctrl_mode=ControlMode.VELOCITY,
max_speed=1.0, max_ang_vel=3.0) for _ in range(n_agents)]
stepper = Stepper(cfgs, dt=0.1, device=device, world=WorldConfig(collision_k=50.0))
actions = torch.zeros(T, 1, n_agents, 2, device=device, requires_grad=True)
opt = torch.optim.Adam([actions], lr=0.05)
for _ in range(200):
opt.zero_grad()
final, traj = rollout(stepper, state0, actions) # BPTT through the Warp adjoints
loss = (final.pos - goals).square().sum() + 1e-3 * actions.square().sum()
loss.backward()
opt.step()
With crossing start/goal pairs the collision term is what makes this interesting: the agents have to learn to yield to each other, and that signal arrives entirely through the contact gradients.
For an RL-style loop the same machinery is behind Environment.step — just call it with grads enabled.
Reusing the taped scratch
A T-step rollout needs T distinct sets of taped buffers, since they must not alias while the tapes are live.
GradRing pre-allocates them once and hands them out across optimizer iterations:
from swarp.interop.autograd import GradRing
ring = GradRing(stepper, n_envs=1, act_dim=2, capacity=T)
for _ in range(iters):
final, traj = rollout(stepper, state0, actions, ring=ring)
Passing no ring keeps the fresh-allocation path, which is the one torch.autograd.gradcheck relies on.
Correctness
Gradients are checked two ways in the test suite:
Strict
torch.autograd.gradcheckin float64 on the CPU. Kernels are generic over dtype and explicitly instantiated for float32 and float64 viawp.overload, and float64-on-CPU is exactly what makes a strict check possible.Analytic gradient tests for cases with a closed form, so a systematically wrong adjoint cannot pass by matching a finite-difference approximation of itself.
What is not differentiable
Knowing where the gradient stops matters more than the headline.
Saturated actions. Limits are clamped inside the kernel, so an action past its bound gets zero gradient. That is correct — and verified finite, not NaN — but a policy initialized far outside the action range will see no signal until it comes back in.
Neighbor membership. The neighbor set is a discrete structure and is built with record_tape=False. Gradients flow through contact geometry (how deep the overlap is, how fast the closing velocity), not through which pairs are in contact.
Movable rigid bodies. swarp/core/bodies.py advances body state with record_tape=False, so no gradient flows through a body’s motion. A scenario that needs one keeps its own torch-side copy and integrates it there:
TransportScenarioalways does, staggered by one step.PushTScenariodoes it on the grad path only; the no-grad path uses the engine body.
Both give BPTT body→agent→action across a rollout, but not through the intra-step agent-avoids-body force. Putting movable bodies on the tape is on the roadmap.
The functional-step invariant
Every array written during a taped step — intermediate states, force buffers, neighbor lists — must be allocated fresh per step.
Overwriting an array that is recorded on a wp.Tape silently corrupts its adjoint: no error, just wrong gradients.
Stepper is the single place that knows this. Grad-mode steps are strictly functional; the no-grad hot path recycles one cached StepBuffers per batch size.
If you extend the step, read Architecture first.