-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 90a19b2..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index 9f965b1..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/CITATION.cff b/CITATION.cff
index e62f44f..de747cc 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -1,7 +1,11 @@
-@software{DiffMjStep2024,
- author = {Sharony, Elad},
- title = {{DiffMjStep: Custom Autograd Extension for Differentiable MuJoCo Dynamics}},
- year = {2024},
- version = {1.0},
- howpublished = {\url{https://github.com/EladSharony/DiffMjStep}},
-}
+cff-version: 1.2.0
+message: "If you use DiffMjStep, please cite this software."
+type: software
+title: "DiffMjStep: PyTorch Autograd for MuJoCo Rollouts"
+version: "0.2.0"
+date-released: "2026-06-28"
+authors:
+ - family-names: Sharony
+ given-names: Elad
+repository-code: "https://github.com/EladSharony/DiffMjStep"
+license: MIT
diff --git a/README.md b/README.md
index 0361d4a..53ebe14 100644
--- a/README.md
+++ b/README.md
@@ -1,61 +1,174 @@
+# DiffMjStep
-# DiffMjStep: Custom Autograd Function for Differentiable MuJoCo Dynamics
+**PyTorch autograd for MuJoCo rollouts on CPU.**
-[](https://opensource.org/licenses/MIT)
+
+
+
-## Description
+**51× faster forward and 13× faster backward** than a pure-Python reference of the same
+algorithm (batch 4096, CPU) — MuJoCo transition-FD gradients without leaving PyTorch.
-An efficient integration between PyTorch and MuJoCo.
-Enables automatic differentiation through MuJoCo simulation trajectories,
-allowing for gradient-based optimization of control policies directly within PyTorch.
+The forward pass executes MuJoCo. The custom backward applies MuJoCo transition
+Jacobians from `mjd_transitionFD` as a reverse-time VJP, so losses can differentiate with
+respect to the initial state and control sequence without moving the simulation to another
+framework.
-## Features
+```python
+x1 = mj_step(model, x0, u)
+loss = x1.square().sum()
+loss.backward() # x0.grad and u.grad
+```
+
+## Why it exists
-** Efficient Gradient Computations**: Significantly more efficient than naive Jacobian finite differencing calculations as it utilizes the built-in finite difference method in MuJoCo [mjd_transitionFD](https://mujoco.readthedocs.io/en/stable/APIreference/APIfunctions.html#mjd-transitionfd).
+- `loss.backward()` works through state, control, and optional sensor trajectories.
+- `backend="auto"` uses MuJoCo's C rollout and the native threaded backward when available,
+ with a clear Python fallback.
+- The Python fallback and native path implement the same MuJoCo transition-FD VJP.
+- The implementation stays small: Python orchestration plus one packaged C++ source file.
-** Multi-Step Calculations**: Provides the ability to estimate gradients over multiple simulation steps, by propagating gradients through the entire trajectory.
+This is a finite-difference method. In contact-rich systems the result is a
+**finite-difference estimate through nonsmooth contact dynamics**, so it can be noisy or
+step-size-sensitive at discontinuities.
-** Batch Simulation Support**: Enables batched simulations and gradient computations, significantly improving computational efficiency for large-scale experiments.
+## Install
+```bash
+pip install .
+```
-## Execution Benchmark
-
-
-
+The native extension targets Linux and macOS and needs a C++17 compiler plus `ninja`.
-## Usage
+## Quickstart
```python
+import mujoco
import torch
-import mujoco as mj
-from DiffMjStep import MjStep
-# Initialize MuJoCo model and data
-xml_path = 'path/to/your/model.xml'
-mj_model = mj.MjModel.from_xml_path(filename=xml_path)
-mj_data = mj.MjData(mj_model)
+from diffmjstep import mj_step
+
+xml = """
+
+
+
+
+
+
+
+
+
+
+"""
+
+model = mujoco.MjModel.from_xml_string(xml)
+x0 = torch.tensor([[0.1, 0.0]], dtype=torch.float64, requires_grad=True)
+u = torch.tensor([[0.2]], dtype=torch.float64, requires_grad=True)
+
+x1 = mj_step(model, x0, u)
+x1.square().sum().backward()
+print(x1, x0.grad, u.grad)
+```
-# Define initial state and control input tensors
-state = torch.rand(mj_model.nq + mj_model.nv + mj_model.na, requires_grad=True)
-ctrl = torch.rand(mj_model.nu, requires_grad=True)
+Run the complete example with:
-# Compute next state and gradients
-next_state, dydx, dydu = MjStep.apply(state, ctrl, n_steps=4, mj_model, mj_model, mj_data)
+```bash
+python examples/00_basic_step.py
```
-## Notes
-- As of [MuJoCo 3.1.2](https://mujoco.readthedocs.io/en/3.1.2/changelog.html#python-bindings) the initial state passed to `rollout()` must include a time-step, such that `nstate = mj_stateSize(model, mjtState.mjSTATE_FULLPHYSICS)`.
+The other examples cover trajectory optimization and sensor losses.
-## Citation
+## Rollouts and linearization
+
+`mj_rollout` accepts an initial state `x0` and controls `U` with shapes `[B, nx]` and
+`[B, T, nu]`. With `return_all=True` it returns `[B, T+1, nx]`:
+
+```python
+from diffmjstep import mj_rollout
+
+X = mj_rollout(model, x0, U)
+X[:, -1].square().sum().backward()
+```
-If you use this package in your research, a citation would be appreciated:
+For a single transition, including free-joint and quaternion models, use `mj_linearize`:
+```python
+from diffmjstep import mj_linearize
+
+A, B = mj_linearize(model, x, u)
+A, B, C, D = mj_linearize(model, x, u, sensors=True)
```
- @software{DiffMjStep2024,
+
+`mj_linearize` takes the full `[nq + nv + na]` state and returns `A`/`B` in MuJoCo's
+`[2*nv + na]` tangent space. Autograd rollouts currently require `nq == nv`.
+
+## Sensors
+
+```python
+X, Y = mj_rollout(model, x0, U, return_sensors=True)
+sensor_loss = (Y[:, -1] - target).square().sum()
+sensor_loss.backward()
+```
+
+Sensor timing follows MuJoCo's step contract: `Y[t] = g(x_t, u_t)`. Therefore
+`Y[:, -1]` is the **last pre-step sensor**, not a reading at the terminal state `X[:, -1]`.
+Losses may depend on `X`, `Y`, or both.
+
+## Backends
+
+- `auto` (default): C-threaded forward; native threaded backward when it loads, otherwise
+ the Python MuJoCo transition-FD VJP.
+- `mujoco_rollout`: C-threaded forward and Python backward.
+- `cpp_vjp`: C-threaded forward and required native threaded backward.
+
+The native code is packaged at
+[`diffmjstep/cpp/rollout_vjp.cpp`](diffmjstep/cpp/rollout_vjp.cpp). `nthread` can override
+the conservative automatic thread count.
+
+## Validation and benchmarks
+
+Speedup over a single-threaded pure-Python reference of the *same* algorithm, per dm_control
+model, out of the box (batch 256): forward scales with simplicity (11–23×), backward is a
+steady ~5–6×.
+
+
+
+
+
+Validation and benchmark tooling lives on the
+[`revive/validation` branch](https://github.com/EladSharony/DiffMjStep/tree/revive/validation).
+
+## Scope and limitations
+
+- Autograd state and control inputs must be CPU `float32` or `float64` tensors on the same
+ device.
+- Autograd rollouts require `nq == nv`; `mj_linearize` supports free-joint and quaternion
+ models in tangent space.
+- RK4 is unsupported by `mjd_transitionFD`; use Euler, implicit, or implicitfast.
+- Higher-order gradients are unsupported because the custom backward is
+ `once_differentiable`.
+- DiffMjStep disables MuJoCo solver warm-start on its private runtime model because the
+ warm-start buffer is hidden state outside `x`. This keeps no-grad and autograd forward
+ dynamics identical without mutating the caller's model.
+ Model owners may pre-disable `mjDSBL_WARMSTART` for the zero-copy inference path.
+- Pure callbacks whose outputs depend only on model, state, control, and simulation time are
+ supported. For derivative calls, registrations must not change or be mutated concurrently
+ from rollout forward through backward. An active Python
+ callback uses the Python transition-FD path; behavior that depends on external mutable state
+ is unsupported because finite-difference replay cannot reproduce it reliably.
+- Plugin/history state that extends MuJoCo `FULLPHYSICS` snapshots is unsupported.
+- Contact gradients remain a finite-difference estimate through nonsmooth contact dynamics.
+
+## Citation
+
+```bibtex
+@software{DiffMjStep2026,
author = {Sharony, Elad},
- title = {{DiffMjStep: Custom Autograd Function for Differentiable MuJoCo Dynamics}},
- year = {2024},
- version = {1.0},
+ title = {{DiffMjStep: PyTorch Autograd for MuJoCo Rollouts}},
+ year = {2026},
+ version = {0.2.0},
howpublished = {\url{https://github.com/EladSharony/DiffMjStep}},
}
```
diff --git a/assets/benchmark.svg b/assets/benchmark.svg
new file mode 100644
index 0000000..325ea2b
--- /dev/null
+++ b/assets/benchmark.svg
@@ -0,0 +1,58 @@
+
diff --git a/assets/half_cheetah.xml b/assets/half_cheetah.xml
deleted file mode 100644
index 64272d4..0000000
--- a/assets/half_cheetah.xml
+++ /dev/null
@@ -1,96 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/speedup_per_model.svg b/assets/speedup_per_model.svg
new file mode 100644
index 0000000..98ff48e
--- /dev/null
+++ b/assets/speedup_per_model.svg
@@ -0,0 +1,77 @@
+
diff --git a/autograd_mujoco.py b/autograd_mujoco.py
deleted file mode 100644
index e0902eb..0000000
--- a/autograd_mujoco.py
+++ /dev/null
@@ -1,178 +0,0 @@
-import numpy as np
-import torch
-from torch.autograd import Function
-from torch.autograd.function import once_differentiable
-
-import mujoco as mj
-from mujoco import rollout
-
-
-class MjStep(Function):
- """
- A custom autograd function for the MuJoCo step function.
- This is required because the MuJoCo step function is not differentiable.
- """
-
- @staticmethod
- def forward(*args, **kwargs) -> tuple[torch.Tensor, np.ndarray, np.ndarray]:
- """
- Forward pass of the MjStep function.
-
- Args:
- *args: Variable length argument list.
- **kwargs: Arbitrary keyword arguments.
-
- Returns:
- next_state: The next state after the step.
- dydx: The derivative of y with respect to x.
- dydu: The derivative of y with respect to u.
-
- Dimensions key:
- B: Batch size
- nq: Number of position variables
- nv: Number of velocity variables
- na: Number of actuator variables
- nu: Number of control variables
- n_steps: Number of steps in the rollout
-
- state.shape = [B, nq + nv + na]
- ctrl.shape = [B, 1, nu]
-
- """
- # Extracting the arguments
- state, ctrl, n_steps, mj_model, mj_data = args
- dydx, dydu = [], []
- device = state.device
- compute_grads = state.requires_grad or ctrl.requires_grad
-
- state, ctrl = state.numpy(force=True), ctrl.numpy(force=True)
-
- # Repeat the control input for each step in the rollout: [B, 1, nu] -> [B, n_steps, nu]
- ctrl = np.repeat(ctrl[:, None, :], n_steps, axis=1)
-
- if state.shape[-1] == mj.mj_stateSize(mj_model, mj.mjtState.mjSTATE_FULLPHYSICS.value) - 1:
- """
- As of MuJoCo 3.1.2 the initial state passed to rollout() must include a time step.
- Manually concatenating a time-step to the state vector.
- """
- state = np.concatenate([np.zeros_like(state[:, [0]]), state], axis=1)
-
- # Perform the rollout and get the states
- states, _ = mj.rollout.rollout(mj_model, mj_data, state, ctrl)
-
- # Pop the time-step from state and states (we don't need it for the gradients)
- state, states = state[:, 1:], states[:, :, 1:]
-
- # Reshape the states and control inputs: [B, n_steps, nq + nv + na], [B, n_steps, nu]
- states = states.reshape(-1, n_steps, mj_model.nq + mj_model.nv + mj_model.na)
- ctrl = ctrl.reshape(-1, n_steps, mj_model.nu)
-
- if compute_grads:
- # Concatenate the initial state with the rest of the states
- _states = np.concatenate([state[:, None, :], states[:, :-1, :]], axis=1)
- # Set the solver tolerances and disable the warmstart for the solver
- mj_model.opt.tolerance = 0 # Disable early termination to make the same number of steps for each FD call
- mj_model.opt.ls_tolerance = 1e-18 # Set the line search tolerance to a very low value, for stability
- mj_model.opt.disableflags = 2 ** 8 # Disable solver warmstart
- for (state_batch, ctrl_batch) in zip(_states, ctrl):
- # Initialize the A and B matrices for the approximated linear system
- A = np.eye(2 * mj_model.nv + mj_model.na)
- B = np.zeros((2 * mj_model.nv + mj_model.na, mj_model.nu))
-
- for (_state, _ctrl) in zip(state_batch, ctrl_batch):
- # Reset the MuJoCo data and set the state and control
- mj.mj_resetData(mj_model, mj_data)
- MjStep.set_state_and_ctrl(mj_model, mj_data, _state, _ctrl)
-
- # Initialize the _A and _B matrices for the approximated linear system
- _A = np.zeros((2 * mj_model.nv + mj_model.na, 2 * mj_model.nv + mj_model.na))
- _B = np.zeros((2 * mj_model.nv + mj_model.na, mj_model.nu))
-
- # Compute the forward dynamics using MuJoCo's built-in function
- mj.mjd_transitionFD(mj_model, mj_data, 1e-8, 1, _A, _B, None, None)
-
- # Update the A and B matrices
- A = np.matmul(_A, A)
- B = np.matmul(_A, B) + _B
-
- # Append the A and B matrices to the lists
- dydx.append(A.copy())
- dydu.append(B.copy())
-
- # Reset the solver tolerances and enable the warmstart for the solver
- mj_model.opt.tolerance = 1e-10
- mj_model.opt.ls_tolerance = 1e-10
- mj_model.opt.disableflags = 0
-
- # Get the final state from the rollout
- next_state = torch.as_tensor(states[:, -1, :], device=device, dtype=torch.float32)
-
- # Convert the lists of A and B matrices to numpy arrays
- dydx = np.array(dydx) if compute_grads else None
- dydu = np.array(dydu) if compute_grads else None
-
- return next_state, dydx, dydu
-
- @staticmethod
- def setup_context(ctx: Function, inputs: tuple, output: tuple) -> None:
- """
- Set up the context for the backward pass.
- """
- state, _, _, _, _ = inputs
- _, dydx, dydu = output
-
- dydx = torch.from_numpy(dydx.astype(np.float32)).to(state.device) if dydx is not None else None
- dydu = torch.from_numpy(dydu.astype(np.float32)).to(state.device) if dydu is not None else None
-
- ctx.save_for_backward(dydx, dydu)
-
- @staticmethod
- @once_differentiable
- def backward(ctx: Function, grad_output: torch.Tensor, *args) \
- -> tuple[torch.Tensor, torch.Tensor, None, None, None]:
- """
- Backward pass of the MjStep function.
-
- Args:
- ctx: The context object where results are saved for backward computation.
- grad_output: The output of the forward method.
- *args: Variable length argument list.
-
- Returns:
- grad_state: The gradient of the state.
- grad_ctrl: The gradient of the control.
- None, None, None, None: Placeholder for other gradients that are not computed.
- """
- dydx, dydu = ctx.saved_tensors
-
- if ctx.needs_input_grad[0]:
- # dL/dx = dL/dy * dy/dx ([B, 1, nq + nv] * [B, nq + nv, nq + nv] = [B, 1, nq + nv])
- grad_state = torch.bmm(grad_output[:, None, :], dydx).squeeze(1)
- else:
- grad_state = None
-
- if ctx.needs_input_grad[1]:
- # dL/du = dL/dy * dy/du ([B, 1, nq + nv] * [B, nq + nv, 1] = [B, 1, 1])
- grad_ctrl = torch.bmm(grad_output[:, None, :], dydu).squeeze(1)
-
- else:
- grad_ctrl = None
-
- return grad_state, grad_ctrl, None, None, None
-
- @staticmethod
- def set_state_and_ctrl(mj_model: mj.MjModel, mj_data: mj.MjData, state: np.ndarray, ctrl: np.ndarray) -> None:
- """
- Set the state and control for the MuJoCo model.
- """
- mj.mj_resetData(mj_model, mj_data)
- np.copyto(mj_data.qpos, state[:mj_model.nq].squeeze())
- np.copyto(mj_data.qvel, state[mj_model.nq:mj_model.nq + mj_model.nv].squeeze())
- np.copyto(mj_data.ctrl, ctrl.squeeze())
-
- @staticmethod
- def get_state(mj_data: mj.MjData) -> np.ndarray:
- """
- Get the state from the MuJoCo data.
- """
- return np.concatenate([mj_data.qpos, mj_data.qvel])
diff --git a/diffmjstep/__init__.py b/diffmjstep/__init__.py
new file mode 100644
index 0000000..10f1944
--- /dev/null
+++ b/diffmjstep/__init__.py
@@ -0,0 +1,4 @@
+from .core import mj_rollout, mj_step
+from .linearize import mj_linearize
+
+__all__ = ["mj_linearize", "mj_rollout", "mj_step"]
diff --git a/diffmjstep/core.py b/diffmjstep/core.py
new file mode 100644
index 0000000..5d088db
--- /dev/null
+++ b/diffmjstep/core.py
@@ -0,0 +1,541 @@
+from __future__ import annotations
+
+import operator
+import os
+import warnings
+from typing import Any
+
+import numpy as np
+import torch
+from torch.autograd.function import once_differentiable
+
+import mujoco
+from mujoco import rollout
+from . import native
+from .linearize import (
+ _transition_fd_at_snapshot,
+ check_transition_fd_integrator,
+)
+from .state import (
+ infer_state_dims,
+ snapshot_model,
+ validate_control,
+ validate_devices,
+ validate_eps,
+ validate_fullphysics,
+ validate_state,
+ validate_tensor,
+)
+
+
+_SUPPORTED_BACKENDS = {"auto", "mujoco_rollout", "cpp_vjp"}
+_AUTO_NATIVE_DISABLED = False
+_AUTO_NATIVE_LOADING_PID: int | None = None
+
+
+def _check_backend(backend: str) -> None:
+ if backend not in _SUPPORTED_BACKENDS:
+ supported = ", ".join(sorted(_SUPPORTED_BACKENDS))
+ raise NotImplementedError(f"backend {backend!r} is unknown; choose from {supported}")
+
+
+def _auto_native_ready() -> bool:
+ global _AUTO_NATIVE_DISABLED, _AUTO_NATIVE_LOADING_PID
+ pid = os.getpid()
+ if _AUTO_NATIVE_DISABLED:
+ return False
+ if _AUTO_NATIVE_LOADING_PID == pid:
+ return False
+ # No Python call between the checks and assignment: the GIL makes this the single
+ # loading claim without a lock that could be inherited held by fork.
+ _AUTO_NATIVE_LOADING_PID = pid
+ try:
+ native.load_extension()
+ except Exception as exc:
+ _AUTO_NATIVE_DISABLED = True
+ warnings.warn(
+ "DiffMjStep could not load the native cpp_vjp backend; "
+ "backend='auto' will keep MuJoCo's C rollout forward and use the "
+ "Python VJP. Install Ninja and a C++17 compiler. "
+ f"Native load failed with {type(exc).__name__}: {exc}",
+ RuntimeWarning,
+ stacklevel=2,
+ )
+ return False
+ else:
+ return True
+ finally:
+ if _AUTO_NATIVE_LOADING_PID == pid:
+ _AUTO_NATIVE_LOADING_PID = None
+
+def _python_callbacks() -> tuple[Any | None, ...]:
+ return (
+ mujoco.get_mjcb_act_bias(),
+ mujoco.get_mjcb_act_dyn(),
+ mujoco.get_mjcb_act_gain(),
+ mujoco.get_mjcb_contactfilter(),
+ mujoco.get_mjcb_control(),
+ mujoco.get_mjcb_passive(),
+ mujoco.get_mjcb_sensor(),
+ mujoco.get_mjcb_time(),
+ )
+
+
+def _require_same_python_callbacks(expected: tuple[Any | None, ...]) -> None:
+ if any(
+ current is not captured
+ for current, captured in zip(_python_callbacks(), expected, strict=True)
+ ):
+ raise RuntimeError(
+ "MuJoCo callback registration must remain unchanged from forward through "
+ "backward; register callbacks before mj_rollout"
+ )
+
+
+def _tensor_to_numpy(value: torch.Tensor) -> np.ndarray:
+ return value.detach().cpu().numpy()
+
+
+def _public_output_from_snapshots(
+ snapshots: np.ndarray,
+ nx: int,
+ return_all: bool,
+) -> np.ndarray:
+ public_states = (
+ snapshots[..., 1 : 1 + nx]
+ if return_all
+ else snapshots[:, -1, 1 : 1 + nx]
+ )
+ return np.array(
+ public_states,
+ dtype=np.float64,
+ copy=True,
+ order="C",
+ )
+
+
+_AUTO_NTHREAD_CAP = 8 # past this, per-call mjData allocation outweighs added parallelism
+
+
+def _auto_nthread(batch_size: int, nthread: int | None) -> int:
+ if batch_size == 0:
+ return 1
+ if nthread is not None:
+ return min(max(1, nthread), batch_size)
+ if batch_size < 8:
+ return 1
+ return min(_AUTO_NTHREAD_CAP, os.cpu_count() or 1, batch_size)
+
+
+def _rollout_mujoco(
+ model: Any,
+ x0: np.ndarray,
+ U: np.ndarray,
+ *,
+ return_all: bool,
+ sensors: bool,
+ nthread: int | None,
+) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]:
+ """Fast forward rollout via mujoco.rollout (C-threaded, GIL released)."""
+ dims = infer_state_dims(model)
+ nfull = validate_fullphysics(model)
+ batch_size, horizon, _ = U.shape
+ init = np.zeros((batch_size, nfull), dtype=np.float64)
+ init[:, 1:] = x0 # leading element is simulation time = 0
+
+ snapshots = np.empty((batch_size, horizon + 1, nfull), dtype=np.float64)
+ snapshots[:, 0, :] = init
+ if batch_size == 0 or horizon == 0:
+ X = _public_output_from_snapshots(
+ snapshots, dims.nx_qpos, return_all
+ )
+ Y = (
+ np.empty(
+ (batch_size, horizon, int(model.nsensordata)),
+ dtype=np.float64,
+ )
+ if sensors
+ else None
+ )
+ return X, Y, snapshots
+
+ nthread = _auto_nthread(batch_size, nthread)
+ if nthread > 1:
+ data_arg: mujoco.MjData | list[mujoco.MjData] = [
+ mujoco.MjData(model) for _ in range(nthread)
+ ]
+ else:
+ data_arg = mujoco.MjData(model)
+
+ states, sensordata = rollout.rollout(model, data_arg, init, U)
+ snapshots[:, 1:, :] = states
+ X = _public_output_from_snapshots(snapshots, dims.nx_qpos, return_all)
+ Y = sensordata if sensors else None
+ return X, Y, snapshots
+
+
+def _rollout_vjp_numpy(
+ model: Any,
+ U: np.ndarray,
+ full_snapshots: np.ndarray,
+ grad_X: np.ndarray | None,
+ grad_Y: np.ndarray | None,
+ *,
+ return_all: bool,
+ needs_control_grad: bool,
+ eps: float,
+ centered: bool,
+) -> tuple[np.ndarray, np.ndarray | None]:
+ batch_size, horizon, _ = U.shape
+ nx = infer_state_dims(model).nx_qpos
+ grad_x0 = np.zeros((batch_size, nx), dtype=np.float64)
+ grad_U = np.zeros_like(U) if needs_control_grad else None
+ if batch_size == 0 or (grad_X is None and grad_Y is None):
+ return grad_x0, grad_U
+ if horizon == 0:
+ if grad_X is not None:
+ grad_x0[:] = grad_X[:, 0] if return_all else grad_X
+ return grad_x0, grad_U
+
+ work_data = mujoco.MjData(model)
+ for batch in range(batch_size):
+ lambda_x = (
+ np.zeros(nx, dtype=np.float64)
+ if return_all or grad_X is None
+ else grad_X[batch]
+ )
+ for step in reversed(range(horizon)):
+ if return_all and grad_X is not None:
+ lambda_x += grad_X[batch, step + 1]
+ A, B, C, D = _transition_fd_at_snapshot(
+ model,
+ work_data,
+ full_snapshots[batch, step],
+ U[batch, step],
+ sensors=grad_Y is not None,
+ needs_control_grad=needs_control_grad,
+ eps=eps,
+ centered=centered,
+ )
+ gy = None if grad_Y is None else grad_Y[batch, step]
+ if needs_control_grad:
+ grad_U[batch, step] = lambda_x @ B
+ if gy is not None:
+ grad_U[batch, step] += gy @ D
+ lambda_x = lambda_x @ A
+ if gy is not None:
+ lambda_x += gy @ C
+ grad_x0[batch] = lambda_x
+ if return_all and grad_X is not None:
+ grad_x0[batch] += grad_X[batch, 0]
+ return grad_x0, grad_U
+
+
+class _MjRolloutFunction(torch.autograd.Function):
+ @staticmethod
+ def forward(
+ ctx,
+ x0: torch.Tensor,
+ U: torch.Tensor,
+ model: Any,
+ X_np: np.ndarray,
+ Y_np: np.ndarray | None,
+ full_snapshots: np.ndarray,
+ return_all: bool,
+ eps: float,
+ centered: bool,
+ nthread: int | None,
+ backward_impl: str,
+ callbacks: tuple[Any | None, ...] | None,
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ ctx.set_materialize_grads(False)
+ ctx.model = model
+ ctx.full_snapshots = full_snapshots
+ ctx.return_all = return_all
+ ctx.eps = eps
+ ctx.centered = centered
+ ctx.nthread = nthread
+ ctx.backward_impl = backward_impl
+ ctx.callbacks = callbacks
+ ctx.save_for_backward(U.detach().cpu())
+
+ X_t = torch.as_tensor(X_np, dtype=x0.dtype, device=x0.device)
+ Y_t = (
+ torch.as_tensor(Y_np, dtype=x0.dtype, device=x0.device)
+ if Y_np is not None
+ else x0.new_empty(0)
+ )
+ return X_t, Y_t
+
+ @staticmethod
+ @once_differentiable
+ def backward(ctx, grad_X: torch.Tensor | None, grad_Y: torch.Tensor | None):
+ (U_cpu,) = ctx.saved_tensors
+ model = ctx.model
+ return_all = ctx.return_all
+
+ U_np = U_cpu.numpy().astype(np.float64, copy=False)
+ full_snapshots = ctx.full_snapshots
+ callbacks = ctx.callbacks
+
+ device = U_cpu.device
+
+ if grad_Y is not None and not bool(torch.any(grad_Y)):
+ grad_Y = None
+
+ if grad_X is None and grad_Y is None:
+ grad_x0_t = U_cpu.new_zeros(
+ (full_snapshots.shape[0], infer_state_dims(model).nx_qpos)
+ )
+ grad_U_t = (
+ torch.zeros_like(U_cpu)
+ if ctx.needs_input_grad[1]
+ else None
+ )
+ return (
+ grad_x0_t,
+ grad_U_t,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ )
+
+ if callbacks is None:
+ raise RuntimeError("internal error: differentiable rollout lost callback state")
+ _require_same_python_callbacks(callbacks)
+
+ if any(callback is not None for callback in callbacks):
+ use_native = False
+ elif ctx.backward_impl == "cpp":
+ native.load_extension()
+ use_native = True
+ elif ctx.backward_impl == "auto":
+ use_native = _auto_native_ready()
+ else:
+ use_native = False
+
+ if use_native:
+ nthr = _auto_nthread(int(full_snapshots.shape[0]), ctx.nthread)
+ grad_x0_t, grad_U_t = native.rollout_backward_vjp(
+ model,
+ full_snapshots,
+ U_np,
+ grad_X,
+ grad_Y,
+ return_all=return_all,
+ needs_control_grad=ctx.needs_input_grad[1],
+ eps=ctx.eps,
+ centered=ctx.centered,
+ nthread=nthr,
+ )
+ grad_x0_t = grad_x0_t.to(dtype=U_cpu.dtype, device=device)
+ if grad_U_t is not None:
+ grad_U_t = grad_U_t.to(dtype=U_cpu.dtype, device=device)
+ else:
+ grad_X_np = (
+ grad_X.detach().cpu().numpy().astype(np.float64, copy=False)
+ if grad_X is not None
+ else None
+ )
+ grad_Y_np = (
+ grad_Y.detach().cpu().numpy().astype(np.float64, copy=False)
+ if grad_Y is not None
+ else None
+ )
+ grad_x0, grad_U = _rollout_vjp_numpy(
+ model,
+ U_np,
+ full_snapshots,
+ grad_X_np,
+ grad_Y_np,
+ return_all=return_all,
+ needs_control_grad=ctx.needs_input_grad[1],
+ eps=ctx.eps,
+ centered=ctx.centered,
+ )
+
+ grad_x0_t = torch.as_tensor(
+ grad_x0, dtype=U_cpu.dtype, device=device
+ )
+ grad_U_t = (
+ torch.as_tensor(grad_U, dtype=U_cpu.dtype, device=device)
+ if grad_U is not None
+ else None
+ )
+
+ _require_same_python_callbacks(callbacks)
+
+ return (
+ grad_x0_t,
+ grad_U_t,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ )
+
+
+def mj_rollout(
+ model: Any,
+ x0: torch.Tensor,
+ U: torch.Tensor,
+ *,
+ backend: str = "auto",
+ return_all: bool = True,
+ return_sensors: bool = False,
+ eps: float = 1e-8,
+ centered: bool = True,
+ nthread: int | None = None,
+):
+ """Roll out MuJoCo dynamics from PyTorch tensors.
+
+ backend="auto" uses the C-threaded mujoco.rollout forward and native threaded
+ backward when it can be loaded, with a Python VJP fallback. backend="mujoco_rollout"
+ uses the fast forward with the Python VJP; backend="cpp_vjp" requires native backward.
+
+ With return_sensors=True the call returns (X, Y), where Y is the [B, T, nsensordata]
+ sensor trajectory, and the loss may depend on X, Y, or both.
+ """
+ validate_tensor("state", x0)
+ validate_tensor("control", U)
+ validate_devices(x0, U)
+ if x0.dtype != U.dtype:
+ raise TypeError(
+ "state and control tensors must have the same dtype, got "
+ f"{x0.dtype} and {U.dtype}"
+ )
+ eps = validate_eps(eps)
+ _check_backend(backend)
+ validate_state(model, x0)
+ validate_control(model, U, allow_sequence=True)
+ if x0.ndim != 2:
+ raise ValueError("state must include a batch axis with shape [B, nx]")
+ if U.ndim != 3:
+ raise ValueError("control must include batch and time axes with shape [B, T, nu]")
+ if x0.shape[0] != U.shape[0]:
+ raise ValueError(
+ f"state/control batch sizes must match, got {x0.shape[0]} and {U.shape[0]}"
+ )
+
+ needs_graph = torch.is_grad_enabled() and (
+ x0.requires_grad or U.requires_grad
+ )
+ needs_derivatives = needs_graph
+ warmstart_disabled = int(mujoco.mjtDisableBit.mjDSBL_WARMSTART)
+ if needs_derivatives or not (int(model.opt.disableflags) & warmstart_disabled):
+ # Copy first, then validate the private model so a concurrent caller mutation
+ # cannot invalidate the model between validation and snapshotting.
+ runtime_model = snapshot_model(model)
+ else:
+ validate_fullphysics(model)
+ runtime_model = model
+
+ if needs_derivatives or backend == "cpp_vjp":
+ check_transition_fd_integrator(runtime_model)
+
+ backward_impl = (
+ "auto"
+ if backend == "auto"
+ else "cpp"
+ if backend == "cpp_vjp"
+ else "python"
+ )
+ U_contiguous = U.contiguous()
+ x0_np = np.asarray(_tensor_to_numpy(x0), dtype=np.float64)
+ U_np = np.asarray(_tensor_to_numpy(U_contiguous), dtype=np.float64)
+
+ callbacks = _python_callbacks() if needs_derivatives else None
+ X_np, Y_np, full_snapshots = _rollout_mujoco(
+ runtime_model,
+ x0_np,
+ U_np,
+ sensors=return_sensors,
+ return_all=return_all,
+ nthread=nthread,
+ )
+ if callbacks is not None:
+ _require_same_python_callbacks(callbacks)
+ X_out, Y_out = _MjRolloutFunction.apply(
+ x0,
+ U_contiguous,
+ runtime_model,
+ X_np,
+ Y_np,
+ full_snapshots,
+ return_all,
+ eps,
+ centered,
+ nthread,
+ backward_impl,
+ callbacks,
+ )
+ return (X_out, Y_out) if return_sensors else X_out
+
+
+def mj_step(
+ model: Any,
+ x0: torch.Tensor,
+ u: torch.Tensor,
+ *,
+ nstep: int = 1,
+ backend: str = "auto",
+ return_sensors: bool = False,
+ eps: float = 1e-8,
+ centered: bool = True,
+ nthread: int | None = None,
+):
+ """Apply one action for nstep MuJoCo steps and return the final state.
+
+ With return_sensors=True returns (xT, Y), where Y is the [B, nstep, nsensordata]
+ sensor trajectory over the repeated steps.
+ """
+ validate_tensor("state", x0)
+ validate_tensor("control", u)
+ eps = validate_eps(eps)
+
+ try:
+ nstep = operator.index(nstep)
+ except TypeError as exc:
+ raise TypeError(
+ f"nstep must be an integer, got {type(nstep).__name__}"
+ ) from exc
+ if nstep < 1:
+ raise ValueError(f"nstep must be >= 1, got {nstep}")
+ validate_state(model, x0)
+ validate_control(model, u, allow_sequence=False)
+ if x0.ndim != 2 or u.ndim != 2:
+ raise ValueError("state and control must include batch axes [B, nx] and [B, nu]")
+ if x0.shape[0] != u.shape[0]:
+ raise ValueError(
+ f"state/control batch sizes must match, got {x0.shape[0]} and {u.shape[0]}"
+ )
+
+ U = u[:, None, :].expand(-1, nstep, -1).contiguous()
+
+ return mj_rollout(
+ model,
+ x0,
+ U,
+ backend=backend,
+ return_all=False,
+ return_sensors=return_sensors,
+ eps=eps,
+ centered=centered,
+ nthread=nthread,
+ )
+
+
+__all__ = ["mj_rollout", "mj_step"]
diff --git a/diffmjstep/cpp/rollout_vjp.cpp b/diffmjstep/cpp/rollout_vjp.cpp
new file mode 100644
index 0000000..e327fe6
--- /dev/null
+++ b/diffmjstep/cpp/rollout_vjp.cpp
@@ -0,0 +1,269 @@
+// Native reverse-time VJP through a MuJoCo rollout, threaded over the batch.
+//
+// The forward (and therefore the per-step trajectory) is computed in Python via
+// mujoco.rollout; this kernel only does the expensive backward: at each (batch, step)
+// it recomputes the transition Jacobians A_t, B_t with mjd_transitionFD and accumulates
+// the adjoint. The FD sweep is GIL-bound from Python, so here we release the GIL and
+// run one mjData + one set of scratch buffers per worker thread.
+//
+// Layout (all float64, contiguous):
+// full_snapshots : [B, T+1, nfull] full physics state including x0 at t = 0
+// U : [B, T, nu]
+// grad_X : optional [B, T+1, nx] when return_all else [B, nx]
+// grad_Y : optional [B, T, nsensordata], or an empty sentinel
+// returns grad_x0 [B, nx], grad_U [B, T, nu] or an empty sentinel.
+//
+// Correctness mirrors the Python rollout VJPs; qpos state-mode with nq == nv, so the
+// nx = 2*nv+na transition Jacobians act directly on the packed state.
+
+#include
+#include
+
+#include
+#include
+#include
+
+namespace {
+void process_chunk(const mjModel* m, mjData* d, int b_begin, int b_end,
+ const double* full_snapshots, const double* U, const double* grad_X,
+ const double* grad_Y, double* grad_x0, double* grad_U,
+ int T, int nx, int nfull, int nu, int ns,
+ bool return_all, bool needs_control_grad, bool use_state,
+ bool use_sensors,
+ double eps, mjtByte centered) {
+ std::vector A(static_cast(nx) * nx);
+ std::vector B;
+ if (needs_control_grad) {
+ B.resize(static_cast(nx) * nu);
+ }
+ std::vector C;
+ if (use_sensors) {
+ C.resize(static_cast(ns) * nx);
+ }
+ std::vector D;
+ if (use_sensors && needs_control_grad) {
+ D.resize(static_cast(ns) * nu);
+ }
+ std::vector lam(nx), lam_new(nx);
+
+ for (int b = b_begin; b < b_end; ++b) {
+ if (return_all || !use_state) {
+ std::fill(lam.begin(), lam.end(), 0.0);
+ } else {
+ const double* g = grad_X + static_cast(b) * nx;
+ for (int i = 0; i < nx; ++i) lam[i] = g[i];
+ }
+
+ for (int t = T - 1; t >= 0; --t) {
+ if (return_all && use_state) {
+ const double* g = grad_X + (static_cast(b) * (T + 1) + (t + 1)) * nx;
+ for (int i = 0; i < nx; ++i) lam[i] += g[i];
+ }
+
+ const double* full_snapshot =
+ full_snapshots + (static_cast(b) * (T + 1) + t) * nfull;
+ mj_resetData(m, d);
+ mj_setState(m, d, full_snapshot, mjSTATE_FULLPHYSICS);
+ const double* u = U + (static_cast(b) * T + t) * nu;
+ for (int i = 0; i < nu; ++i) d->ctrl[i] = u[i];
+
+ mj_forward(m, d);
+ const bool forward_difference_D =
+ centered && use_sensors && needs_control_grad;
+ mjd_transitionFD(m, d, eps, centered, A.data(),
+ needs_control_grad ? B.data() : nullptr,
+ use_sensors ? C.data() : nullptr,
+ use_sensors && needs_control_grad && !forward_difference_D
+ ? D.data()
+ : nullptr);
+ if (forward_difference_D) {
+ // MuJoCo's centered-D ordering workaround: a D-only forward pass also
+ // preserves one-sided control-limit handling.
+ mjd_transitionFD(m, d, eps, 0, nullptr, nullptr, nullptr, D.data());
+ }
+
+ const double* gy = use_sensors
+ ? grad_Y + (static_cast(b) * T + t) * ns
+ : nullptr;
+
+ if (needs_control_grad) {
+ // grad_U[b, t, k] = sum_i lam[i] * B[i, k] + sum_s gy[s] * D[s, k]
+ double* gu = grad_U + (static_cast(b) * T + t) * nu;
+ for (int k = 0; k < nu; ++k) {
+ double s = 0.0;
+ for (int i = 0; i < nx; ++i) {
+ s += lam[i] * B[static_cast(i) * nu + k];
+ }
+ if (use_sensors) {
+ for (int sensor = 0; sensor < ns; ++sensor) {
+ s += gy[sensor] * D[static_cast(sensor) * nu + k];
+ }
+ }
+ gu[k] = s;
+ }
+ }
+ // lam <- lam @ A + gy @ C
+ for (int j = 0; j < nx; ++j) {
+ double s = 0.0;
+ for (int i = 0; i < nx; ++i) s += lam[i] * A[static_cast(i) * nx + j];
+ if (use_sensors) {
+ for (int sensor = 0; sensor < ns; ++sensor) {
+ s += gy[sensor] * C[static_cast(sensor) * nx + j];
+ }
+ }
+ lam_new[j] = s;
+ }
+ std::swap(lam, lam_new);
+ }
+
+ double* gx = grad_x0 + static_cast(b) * nx;
+ if (return_all && use_state) {
+ const double* g0 = grad_X + static_cast(b) * (T + 1) * nx;
+ for (int i = 0; i < nx; ++i) gx[i] = lam[i] + g0[i];
+ } else {
+ for (int i = 0; i < nx; ++i) gx[i] = lam[i];
+ }
+ }
+}
+
+} // namespace
+
+std::tuple rollout_backward_vjp(
+ torch::Tensor full_snapshots, torch::Tensor U, torch::Tensor grad_X,
+ torch::Tensor grad_Y,
+ torch::Tensor data_addresses, int64_t model_addr, int64_t nthread,
+ bool return_all, bool needs_control_grad, double eps, bool centered) {
+ TORCH_CHECK(full_snapshots.dtype() == torch::kFloat64,
+ "full_snapshots must be float64");
+ TORCH_CHECK(U.dtype() == torch::kFloat64, "U must be float64");
+ TORCH_CHECK(grad_X.dtype() == torch::kFloat64, "grad_X must be float64");
+ TORCH_CHECK(grad_Y.dtype() == torch::kFloat64, "grad_Y must be float64");
+ TORCH_CHECK(data_addresses.dtype() == torch::kInt64,
+ "data_addresses must be int64");
+ TORCH_CHECK(full_snapshots.device().is_cpu() && U.device().is_cpu() &&
+ grad_X.device().is_cpu() && grad_Y.device().is_cpu() &&
+ data_addresses.device().is_cpu(),
+ "full_snapshots, U, grad_X, grad_Y, and data_addresses "
+ "must be CPU tensors");
+ TORCH_CHECK(full_snapshots.dim() == 3,
+ "full_snapshots must have shape [B, T+1, nfull]");
+ TORCH_CHECK(U.dim() == 3, "U must have shape [B, T, model.nu]");
+ TORCH_CHECK(data_addresses.dim() == 1,
+ "data_addresses must have shape [nthread]");
+ const bool use_state = !(grad_X.dim() == 1 && grad_X.size(0) == 0);
+ const bool use_sensors = !(grad_Y.dim() == 1 && grad_Y.size(0) == 0);
+ TORCH_CHECK(!use_state || grad_X.dim() == (return_all ? 3 : 2),
+ return_all ? "grad_X must have shape [B, T+1, nx]"
+ : "grad_X must have shape [B, nx]");
+ TORCH_CHECK(!use_sensors || grad_Y.dim() == 3,
+ "grad_Y must have shape [B, T, nsensordata]");
+ full_snapshots = full_snapshots.contiguous();
+ U = U.contiguous();
+ grad_X = grad_X.contiguous();
+ grad_Y = grad_Y.contiguous();
+ data_addresses = data_addresses.contiguous();
+
+ const mjModel* m = reinterpret_cast(model_addr);
+ const int B = static_cast(full_snapshots.size(0));
+ const int Tp1 = static_cast(full_snapshots.size(1));
+ const int snapshot_width = static_cast(full_snapshots.size(2));
+ const int T = static_cast(U.size(1));
+ const int nq = m->nq, nv = m->nv, na = m->na, nu = m->nu;
+ const int nx = 2 * nv + na;
+ const int ns = m->nsensordata;
+ const int nfull = mj_stateSize(m, mjSTATE_FULLPHYSICS);
+
+ TORCH_CHECK(nq == nv, "cpp_vjp supports qpos state-mode with nq == nv only");
+ TORCH_CHECK(m->opt.integrator != mjINT_RK4,
+ "mjd_transitionFD does not support RK4 integrator; use Euler or implicit integration");
+ TORCH_CHECK(Tp1 == T + 1, "full_snapshots must have T+1 steps along dim 1");
+ TORCH_CHECK(snapshot_width == nfull,
+ "FULLPHYSICS snapshot width mismatch: expected ", nfull,
+ ", got ", snapshot_width);
+ TORCH_CHECK(static_cast(U.size(0)) == B &&
+ static_cast(U.size(2)) == nu,
+ "U must have shape [B, T, model.nu]");
+ if (use_state) {
+ if (return_all) {
+ TORCH_CHECK(static_cast(grad_X.size(0)) == B &&
+ static_cast(grad_X.size(1)) == Tp1 &&
+ static_cast(grad_X.size(2)) == nx,
+ "grad_X must have shape [B, T+1, nx]");
+ } else {
+ TORCH_CHECK(static_cast(grad_X.size(0)) == B &&
+ static_cast(grad_X.size(1)) == nx,
+ "grad_X must have shape [B, nx]");
+ }
+ }
+ if (use_sensors) {
+ TORCH_CHECK(static_cast(grad_Y.size(0)) == B &&
+ static_cast(grad_Y.size(1)) == T &&
+ static_cast(grad_Y.size(2)) == ns,
+ "grad_Y must have shape [B, T, nsensordata]");
+ }
+
+ auto opts = torch::TensorOptions().dtype(torch::kFloat64);
+ torch::Tensor grad_x0 = torch::zeros({B, nx}, opts);
+ torch::Tensor grad_U = needs_control_grad
+ ? torch::zeros({B, T, nu}, opts)
+ : torch::empty({0}, opts);
+ if (B == 0 || T == 0) {
+ TORCH_CHECK(data_addresses.size(0) == 0,
+ "data_addresses must be empty when B or T is zero");
+ if (T == 0 && use_state) {
+ grad_x0.copy_(return_all ? grad_X.select(1, 0) : grad_X);
+ }
+ return {grad_x0, grad_U};
+ }
+
+ int nthr = std::max(1, nthread);
+ nthr = std::min(nthr, B);
+ TORCH_CHECK(static_cast(data_addresses.size(0)) == nthr,
+ "data_addresses must have shape [nthread]");
+
+ const double* full_snapshots_p = full_snapshots.data_ptr();
+ const double* U_p = U.data_ptr();
+ const double* grad_X_p = use_state ? grad_X.data_ptr() : nullptr;
+ const double* grad_Y_p = use_sensors ? grad_Y.data_ptr() : nullptr;
+ double* gx0_p = grad_x0.data_ptr();
+ double* gU_p = needs_control_grad ? grad_U.data_ptr() : nullptr;
+
+ const mjtByte cflag = centered ? 1 : 0;
+
+ // Python owns and registers these mjData instances for callback compatibility.
+ // The synchronous extension call keeps their wrappers alive until all workers join.
+ const int64_t* data_addresses_p = data_addresses.data_ptr();
+ std::vector datas;
+ datas.reserve(nthr);
+ for (int i = 0; i < nthr; ++i) {
+ mjData* data = reinterpret_cast(data_addresses_p[i]);
+ TORCH_CHECK(data != nullptr, "data address must not be null");
+ datas.push_back(data);
+ }
+
+ {
+ pybind11::gil_scoped_release release;
+ if (nthr <= 1) {
+ process_chunk(m, datas[0], 0, B, full_snapshots_p, U_p, grad_X_p,
+ grad_Y_p, gx0_p, gU_p, T, nx, nfull, nu, ns, return_all,
+ needs_control_grad, use_state, use_sensors, eps, cflag);
+ } else {
+ std::vector threads;
+ for (int ti = 0; ti < nthr; ++ti) {
+ const int b0 = ti * B / nthr;
+ const int b1 = (ti + 1) * B / nthr;
+ threads.emplace_back(process_chunk, m, datas[ti], b0, b1,
+ full_snapshots_p, U_p, grad_X_p, grad_Y_p,
+ gx0_p, gU_p, T, nx, nfull, nu, ns, return_all,
+ needs_control_grad, use_state, use_sensors, eps, cflag);
+ }
+ for (auto& th : threads) th.join();
+ }
+ }
+ return {grad_x0, grad_U};
+}
+
+PYBIND11_MODULE(TORCH_EXTENSION_NAME, mod) {
+ mod.def("rollout_backward_vjp", &rollout_backward_vjp,
+ "Threaded reverse-time VJP through a MuJoCo rollout (CPU, float64).");
+}
diff --git a/diffmjstep/linearize.py b/diffmjstep/linearize.py
new file mode 100644
index 0000000..b955847
--- /dev/null
+++ b/diffmjstep/linearize.py
@@ -0,0 +1,191 @@
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+
+import mujoco
+from .state import (
+ FULLPHYSICS,
+ as_numpy,
+ infer_state_dims,
+ set_data_control,
+ set_data_state,
+ snapshot_model,
+ validate_control,
+ validate_eps,
+ validate_state,
+ validate_tensor,
+)
+
+
+def _enum_value(value: Any) -> int:
+ return int(value.value) if hasattr(value, "value") else int(value)
+
+
+def check_transition_fd_integrator(model: Any) -> None:
+ if _enum_value(model.opt.integrator) == _enum_value(mujoco.mjtIntegrator.mjINT_RK4):
+ raise ValueError("mjd_transitionFD does not support RK4 integrator; use Euler or implicit integration")
+
+
+def _as_real_float64(name: str, value: Any) -> np.ndarray:
+ if hasattr(value, "detach"):
+ validate_tensor(name, value)
+ array = as_numpy(value)
+ else:
+ array = np.asarray(value)
+ if isinstance(value, np.ndarray) and not np.issubdtype(array.dtype, np.floating):
+ raise TypeError(f"{name} must be a real floating array, got {array.dtype}")
+ if not isinstance(value, np.ndarray) and array.dtype.kind not in {"i", "u", "f"}:
+ raise TypeError(f"{name} must contain real numeric values, got {array.dtype}")
+ return np.asarray(array, dtype=np.float64)
+
+
+def _as_batched_state(model: Any, x: Any) -> np.ndarray:
+ x_np = _as_real_float64("state", x)
+ validate_state(model, x_np, require_square=False)
+ return x_np[None, :] if x_np.ndim == 1 else x_np
+
+
+def _as_batched_control(model: Any, u: Any) -> np.ndarray:
+ u_np = _as_real_float64("control", u)
+ validate_control(model, u_np, allow_sequence=False)
+ return u_np[None, :] if u_np.ndim == 1 else u_np
+
+
+def _return_like_input(reference: Any, value: np.ndarray):
+ if hasattr(reference, "detach"):
+ import torch
+
+ return torch.as_tensor(value, dtype=reference.dtype, device=reference.device)
+ return value
+
+
+def _transition_fd_at_snapshot(
+ model: Any,
+ data: Any,
+ full_snapshot: np.ndarray,
+ u: np.ndarray,
+ *,
+ sensors: bool = False,
+ needs_control_grad: bool = True,
+ eps: float,
+ centered: bool,
+):
+ nx = 2 * int(model.nv) + int(model.na)
+ nu = int(model.nu)
+ ns = int(model.nsensordata)
+ A = np.empty((nx, nx), dtype=np.float64)
+ B = np.empty((nx, nu), dtype=np.float64) if needs_control_grad else None
+ C = np.empty((ns, nx), dtype=np.float64) if sensors else None
+ D = (
+ np.empty((ns, nu), dtype=np.float64)
+ if sensors and needs_control_grad
+ else None
+ )
+
+ mujoco.mj_resetData(model, data)
+ mujoco.mj_setState(model, data, full_snapshot, FULLPHYSICS)
+ data.ctrl[:] = u
+ mujoco.mj_forward(model, data)
+ forward_difference_D = centered and D is not None
+ mujoco.mjd_transitionFD(
+ model,
+ data,
+ eps,
+ int(centered),
+ A,
+ B,
+ C,
+ None if forward_difference_D else D,
+ )
+ if forward_difference_D:
+ # MuJoCo's centered-D ordering reverses interior control derivatives; a
+ # D-only forward pass also preserves its one-sided control-limit handling.
+ mujoco.mjd_transitionFD(model, data, eps, 0, None, None, None, D)
+ return A, B, C, D
+
+
+def _linearize(
+ model: Any,
+ x: Any,
+ u: Any,
+ *,
+ sensors: bool = False,
+ eps: float = 1e-8,
+ centered: bool = True,
+):
+ dims = infer_state_dims(model)
+ x_np = _as_batched_state(model, x)
+ nx = dims.nx_tangent
+
+ u_np = _as_batched_control(model, u)
+ if x_np.shape[0] != u_np.shape[0]:
+ raise ValueError(f"state/control batch sizes must match, got {x_np.shape[0]} and {u_np.shape[0]}")
+ batch_size = x_np.shape[0]
+ A = np.empty((batch_size, nx, nx), dtype=np.float64)
+ B = np.empty((batch_size, nx, dims.nu), dtype=np.float64)
+ C = np.empty((batch_size, int(model.nsensordata), nx), dtype=np.float64) if sensors else None
+ D = np.empty((batch_size, int(model.nsensordata), dims.nu), dtype=np.float64) if sensors else None
+
+ data = mujoco.MjData(model)
+ centered_flag = 1 if centered else 0
+ for batch in range(batch_size):
+ mujoco.mj_resetData(model, data)
+ set_data_state(model, data, x_np[batch])
+ set_data_control(model, data, u_np[batch])
+ mujoco.mj_forward(model, data)
+ forward_difference_D = sensors and centered
+ mujoco.mjd_transitionFD(
+ model,
+ data,
+ eps,
+ centered_flag,
+ A[batch],
+ B[batch],
+ C[batch] if sensors else None,
+ None if forward_difference_D else D[batch] if sensors else None,
+ )
+ if forward_difference_D:
+ # MuJoCo's centered-D ordering workaround: request D alone with forward
+ # differences, which also keeps one-sided control-limit behavior intact.
+ mujoco.mjd_transitionFD(
+ model, data, eps, 0, None, None, None, D[batch]
+ )
+
+ A_out = _return_like_input(x, A)
+ B_out = _return_like_input(x, B)
+ if sensors:
+ return A_out, B_out, _return_like_input(x, C), _return_like_input(x, D)
+ return A_out, B_out
+
+
+def mj_linearize(
+ model: Any,
+ x: Any,
+ u: Any,
+ *,
+ sensors: bool = False,
+ eps: float = 1e-8,
+ centered: bool = True,
+):
+ """Compute MuJoCo transition Jacobians A/B, and optionally sensor Jacobians C/D.
+
+ Returned tensors/arrays are batched: A has shape [B, nx, nx], B has shape [B, nx, nu].
+
+ For free-joint / quaternion models (nq != nv), the input state is the full
+ [nq + nv + na] vector and A/B are returned in MuJoCo's tangent space (nx = 2*nv + na) --
+ the standard linearization for trajopt/iLQR. The qpos-square form is only defined when
+ nq == nv.
+ """
+ private_model = snapshot_model(model)
+ eps = validate_eps(eps)
+ check_transition_fd_integrator(private_model)
+ return _linearize(
+ private_model,
+ x,
+ u,
+ sensors=sensors,
+ eps=eps,
+ centered=centered,
+ )
diff --git a/diffmjstep/native.py b/diffmjstep/native.py
new file mode 100644
index 0000000..65e2ca2
--- /dev/null
+++ b/diffmjstep/native.py
@@ -0,0 +1,142 @@
+"""Lazy JIT build + thin wrapper for the native rollout VJP backend.
+
+The C++ extension is compiled on first use via torch.utils.cpp_extension.load and
+cached under TORCH_EXTENSIONS_DIR; subsequent imports are instant. It links the
+MuJoCo shared library that ships with the `mujoco` wheel, so no system MuJoCo install
+is required. Building needs a C++17 compiler.
+"""
+from __future__ import annotations
+
+import functools
+import os
+import shlex
+import sys
+from importlib.resources import as_file, files
+from pathlib import Path
+from typing import Any
+
+import torch
+
+
+def _ensure_ninja_on_path() -> None:
+ # torch's JIT build shells out to the `ninja` binary; pip installs it next to the
+ # interpreter, which is not on PATH unless the venv is activated. Add it so the
+ # extension builds whether or not the environment was sourced.
+ bin_dir = os.path.dirname(sys.executable)
+ parts = os.environ.get("PATH", "").split(os.pathsep)
+ if bin_dir and bin_dir not in parts:
+ os.environ["PATH"] = bin_dir + os.pathsep + os.environ.get("PATH", "")
+
+
+def _find_mujoco_library(mujoco_dir: Path, version: str) -> Path:
+ if sys.platform == "linux":
+ name = f"libmujoco.so.{version}"
+ elif sys.platform == "darwin":
+ name = f"libmujoco.{version}.dylib"
+ else:
+ raise RuntimeError(
+ f"native cpp_vjp does not support {sys.platform!r}; "
+ "supported targets are Linux and macOS"
+ )
+ library = mujoco_dir / name
+ if not library.is_file():
+ raise RuntimeError(f"could not find MuJoCo shared library {library}")
+ return library
+
+
+@functools.lru_cache(maxsize=1)
+def load_extension():
+ import mujoco
+ from torch.utils.cpp_extension import load
+
+ _ensure_ninja_on_path()
+ mujoco_dir = Path(mujoco.__file__).resolve().parent
+ source_resource = files("diffmjstep") / "cpp" / "rollout_vjp.cpp"
+ library = _find_mujoco_library(mujoco_dir, version=mujoco.__version__)
+ include = mujoco_dir / "include"
+ if not include.is_dir():
+ raise RuntimeError(f"MuJoCo headers are missing: {include}")
+
+ pthread = ["-pthread"] if sys.platform == "linux" else []
+ with as_file(source_resource) as source:
+ if not source.is_file():
+ raise RuntimeError(f"packaged native source is missing: {source}")
+ return load(
+ name="diffmjstep_cpp",
+ sources=[str(source)],
+ extra_include_paths=[str(include)],
+ extra_cflags=["-O3", "-std=c++17", *pthread],
+ extra_ldflags=[
+ shlex.quote(str(library)),
+ shlex.quote(f"-Wl,-rpath,{mujoco_dir}"),
+ *pthread,
+ ],
+ verbose=False,
+ )
+
+
+def rollout_backward_vjp(
+ model: Any,
+ full_snapshots: Any,
+ U: Any,
+ grad_X: Any | None,
+ grad_Y: Any | None = None,
+ *,
+ return_all: bool,
+ needs_control_grad: bool,
+ eps: float,
+ centered: bool,
+ nthread: int,
+) -> tuple[torch.Tensor, torch.Tensor | None]:
+ """Native reverse-time VJP.
+
+ full_snapshots: [B, T+1, nfull], U: [B, T, nu]. grad_X is optional
+ [B, T+1, nx] or [B, nx], and grad_Y is optional [B, T, nsensordata]. Returns
+ (grad_x0 [B, nx], grad_U [B, T, nu] or None).
+ """
+ import mujoco
+
+ ext = load_extension()
+ full_snapshots = (
+ torch.as_tensor(full_snapshots).detach().to(torch.float64).cpu().contiguous()
+ )
+ U = torch.as_tensor(U).detach().to(torch.float64).cpu().contiguous()
+ grad_X = (
+ torch.empty(0, dtype=torch.float64, device="cpu")
+ if grad_X is None
+ else torch.as_tensor(grad_X).detach().to(torch.float64).cpu().contiguous()
+ )
+ grad_Y = (
+ torch.empty(0, dtype=torch.float64, device="cpu")
+ if grad_Y is None
+ else torch.as_tensor(grad_Y).detach().to(torch.float64).cpu().contiguous()
+ )
+ has_work = (
+ full_snapshots.ndim == 3
+ and U.ndim == 3
+ and full_snapshots.shape[0] > 0
+ and U.shape[1] > 0
+ )
+ worker_count = (
+ min(max(1, int(nthread)), int(full_snapshots.shape[0]))
+ if has_work
+ else 0
+ )
+ scratch_data = [mujoco.MjData(model) for _ in range(worker_count)]
+ data_addresses = torch.tensor(
+ [data._address for data in scratch_data], dtype=torch.int64, device="cpu"
+ )
+ grad_x0, grad_U = ext.rollout_backward_vjp(
+ full_snapshots,
+ U,
+ grad_X,
+ grad_Y,
+ data_addresses,
+ int(model._address),
+ int(nthread),
+ bool(return_all),
+ bool(needs_control_grad),
+ float(eps),
+ bool(centered),
+ )
+ return grad_x0, grad_U if needs_control_grad else None
diff --git a/diffmjstep/state.py b/diffmjstep/state.py
new file mode 100644
index 0000000..e2e120c
--- /dev/null
+++ b/diffmjstep/state.py
@@ -0,0 +1,162 @@
+from __future__ import annotations
+
+import copy
+import math
+from dataclasses import dataclass
+from typing import Any
+
+import numpy as np
+import torch
+
+import mujoco
+
+
+FULLPHYSICS = mujoco.mjtState.mjSTATE_FULLPHYSICS
+
+
+def validate_tensor(name: str, value: object) -> None:
+ if not isinstance(value, torch.Tensor):
+ raise TypeError(
+ f"{name} must be a float32 or float64 torch.Tensor, "
+ f"got {type(value).__name__}"
+ )
+ if value.layout != torch.strided:
+ raise TypeError(f"{name} must have strided layout, got {value.layout}")
+ if value.dtype not in {torch.float32, torch.float64}:
+ raise TypeError(f"{name} must be float32 or float64, got {value.dtype}")
+
+
+def validate_devices(state: torch.Tensor, control: torch.Tensor) -> None:
+ if state.device != control.device:
+ raise ValueError(
+ "state and control tensors must be on the same device, got "
+ f"{state.device} and {control.device}"
+ )
+ if state.device.type != "cpu":
+ raise ValueError(f"state and control must be on CPU, got {state.device}")
+
+
+def validate_eps(eps: float) -> float:
+ eps = float(eps)
+ if not math.isfinite(eps) or eps <= 0:
+ raise ValueError(f"eps must be finite and positive, got {eps!r}")
+ return eps
+
+
+def validate_fullphysics(model: mujoco.MjModel) -> int:
+ actual = int(mujoco.mj_stateSize(model, FULLPHYSICS))
+ expected = 1 + int(model.nq) + int(model.nv) + int(model.na)
+ if actual != expected:
+ raise NotImplementedError(
+ "plugin/history FULLPHYSICS state is unsupported: "
+ f"expected {expected} values (time+qpos+qvel+act), got {actual}"
+ )
+ return actual
+
+
+def snapshot_model(model: mujoco.MjModel) -> mujoco.MjModel:
+ private = copy.copy(model)
+ validate_fullphysics(private)
+ private.opt.disableflags |= int(mujoco.mjtDisableBit.mjDSBL_WARMSTART)
+ return private
+
+
+@dataclass(frozen=True)
+class StateDims:
+ nq: int
+ nv: int
+ na: int
+ nu: int
+ nx_qpos: int
+ nx_tangent: int
+
+
+def infer_state_dims(model: Any) -> StateDims:
+ """Return the state dimensions DiffMjStep uses for the given MuJoCo model."""
+ nq = int(model.nq)
+ nv = int(model.nv)
+ na = int(model.na)
+ nu = int(model.nu)
+ return StateDims(
+ nq=nq,
+ nv=nv,
+ na=na,
+ nu=nu,
+ nx_qpos=nq + nv + na,
+ nx_tangent=2 * nv + na,
+ )
+
+
+def _shape(value: Any) -> tuple[int, ...]:
+ if not hasattr(value, "shape"):
+ raise TypeError("expected an array-like object with a shape")
+ return tuple(int(dim) for dim in value.shape)
+
+
+def check_rollout_state_supported(model: Any) -> None:
+ dims = infer_state_dims(model)
+ if dims.nq != dims.nv:
+ raise NotImplementedError(
+ "rollout state requires nq == nv; free-joint and quaternion models "
+ "are supported by mj_linearize only"
+ )
+
+
+def validate_state(model: Any, x: Any, *, require_square: bool = True) -> None:
+ """Validate a state tensor/array shape; raise ValueError if it is wrong."""
+ shape = _shape(x)
+ if len(shape) not in (1, 2):
+ raise ValueError(f"state must have shape [nx] or [B, nx], got {shape}")
+
+ if require_square:
+ check_rollout_state_supported(model)
+ nx = infer_state_dims(model).nx_qpos
+ if shape[-1] != nx:
+ raise ValueError(f"state last dimension must be {nx}, got {shape[-1]}")
+
+
+def validate_control(model: Any, u: Any, allow_sequence: bool = True) -> None:
+ """Validate a control tensor/array shape; raise ValueError if it is wrong."""
+ shape = _shape(u)
+ allowed = (1, 2, 3) if allow_sequence else (1, 2)
+ if len(shape) not in allowed:
+ expected = "[nu], [B, nu], or [B, T, nu]" if allow_sequence else "[nu] or [B, nu]"
+ raise ValueError(f"control must have shape {expected}, got {shape}")
+
+ nu = int(model.nu)
+ if shape[-1] != nu:
+ raise ValueError(f"control last dimension must be {nu}, got {shape[-1]}")
+
+
+def as_numpy(value: Any) -> np.ndarray:
+ """Convert a CPU tensor/array-like object to a detached NumPy array."""
+ if hasattr(value, "detach"):
+ if getattr(value, "device", None) is not None and value.device.type != "cpu":
+ raise ValueError("MuJoCo CPU backend received a non-CPU tensor")
+ return value.detach().cpu().numpy()
+ return np.asarray(value)
+
+
+def set_data_state(model: Any, data: Any, x: np.ndarray) -> None:
+ """Set qpos/qvel/act in mjData from a full [nq+nv+na] DiffMjStep state vector.
+
+ Slicing qpos/qvel/act is valid for any model, including free joints. Rollout's
+ nq == nv restriction is enforced by validate_state at the public boundary.
+ """
+ if x.ndim != 1:
+ raise ValueError(f"state must have shape [nx], got {_shape(x)}")
+ validate_state(model, x, require_square=False)
+ x = np.asarray(x, dtype=np.float64)
+ dims = infer_state_dims(model)
+
+ data.qpos[:] = x[: dims.nq]
+ data.qvel[:] = x[dims.nq : dims.nq + dims.nv]
+ if dims.na:
+ data.act[:] = x[dims.nq + dims.nv : dims.nq + dims.nv + dims.na]
+
+
+def set_data_control(model: Any, data: Any, u: np.ndarray) -> None:
+ u = np.asarray(u, dtype=np.float64)
+ if u.shape[-1] != int(model.nu):
+ raise ValueError(f"control last dimension must be {model.nu}, got {u.shape[-1]}")
+ data.ctrl[:] = u
diff --git a/examples/00_basic_step.py b/examples/00_basic_step.py
new file mode 100644
index 0000000..f63fa16
--- /dev/null
+++ b/examples/00_basic_step.py
@@ -0,0 +1,39 @@
+import torch
+import mujoco
+
+from diffmjstep import mj_step
+
+
+PENDULUM_XML = """
+
+