Python Wrappers (grid-rbd)#
The grid-rbd package wraps GRiD’s per-robot CUDA codegen behind a
two-tier Python API: a slow one-time register_robot() step that
generates and compiles a per-robot .so, and fast subsequent
algorithm calls on the returned handle.
register_robot accepts a backend= argument — "numpy" (the
default, returning a RobotHandle), "jax" (a JaxRobotHandle),
or "torch" (a TorchRobotHandle) — and a urdf_string= argument
to register from inline URDF text instead of a file on disk. All three
backends share the same content-addressed .so cache.
Source: bindings/ in the GRiD repo.
See also
In a hurry? Fast Robot Setup is the quick
canonical path — one-call load_robot, cache anatomy, and warm-up
recipes.
Install (editable, from a GRiD checkout)#
cd path/to/GRiD
pip install -e .
This builds a small pybind11 extension (grid_rbd._core) at install
time. nvcc is not required for the install — only for
grid_rbd.register_robot(), which compiles a per-robot
.so on first call.
Register-then-run UX#
import grid_rbd
# One-time per (robot, options, GRiD version, CUDA arch).
# cold: about ten minutes for iiwa14 on an RTX 5090 (see Fast Robot Setup); warm: seconds.
# Cached under ~/.cache/grid-rbd/ by content key.
handle = grid_rbd.register_robot(
name="iiwa14",
urdf_path="path/to/iiwa.urdf",
floating_base=False,
max_batch_size=256,
)
# Fast inference. All methods are 2D-batched on axis 0.
import numpy as np
q = np.random.randn(64, handle.num_joints).astype(np.float32)
qd = np.random.randn(64, handle.num_joints).astype(np.float32)
u = np.random.randn(64, handle.num_joints).astype(np.float32)
qdd = handle.forward_dynamics(q, qd, u) # shape (64, NJ)
M = handle.crba(q) # shape (64, NV, NV) (fixed base: NV == NJ)
A complete walkthrough exercising every bound method is at
bindings/examples/quickstart_iiwa14.py.
Method surface#
Inputs are 2D arrays of the handle dtype (float32 unless the artifact was
built with dtype="float64") with the batch on axis 0 and num_joints
(NJ, the configuration width nq) on axis 1 — on a floating base that
is NV + 1 (the quaternion pad; a velocity or torque is passed at the same
NJ width with the last slot zero). Outputs are batched on axis 0 with the
shapes below: vectors are NJ-wide, matrices, Jacobians and gradients are
NV-wide (num_vel; on a fixed base NV == NJ). gravity is the
signed gravitational acceleration, default
-9.81 (standard downward gravity) — the same convention as
RBDReference.inverse_dynamics(..., GRAVITY=-9.81) and pinocchio, so
pass the same value to both for cross-validation.
Method |
Returns |
Notes |
|---|---|---|
|
|
Inverse dynamics bias (RNEA); alias |
|
|
Direct mass-matrix inverse. |
|
|
|
|
|
Articulated body algorithm FD. |
|
|
Composite-rigid-body mass matrix. |
|
|
|
|
|
EE pose Jacobian |
|
|
EE pose Hessian (∂²ee/∂q²). |
|
|
|
|
|
|
|
tuple of 4 |
Second-order ID (dispatched). |
|
tuple of 4 |
Second-order FD. |
|
varies |
Centroidal kinematics (CoM + CoM Jacobian; CMM |
|
|
Coriolis matrix |
|
|
Inertial-parameter energy regressors ( |
|
|
∂A/∂q tensor (centroidal-momentum-matrix derivative). |
|
|
Ȧ = |
|
|
General-frame J / J̇ (runtime |
|
|
Runtime arbitrary multi-EE pose (runtime target joints + per-target
offset); |
|
|
Joint-torque inertial-parameter regressor |
|
|
jax/torch only: ID / FD as differentiable-in-π ops — autograd flows
the analytic |
|
|
jax/torch only: the FD inertial-parameter gradient |
|
varies / — / |
Runtime welded tool/payload ( |
|
|
Multi-contact: maps per-contact-frame world-aligned wrenches to
joint-local |
|
— |
Runtime-mutable model tables (flag-gated builds; no recompile). |
|
|
Large-batch forward kinematics for the leaf EE frame
( |
The numeric methods also accept allow_fp64=True at register_robot for
an fp64-in/fp64-out convenience cast (compute stays fp32).
Validation against RBDReference lives at
test/python_wrappers/test_iiwa14_smoke.py (28 tests, all numerical
methods pass at float32 precision).
Build cost on large floating-base robots#
On a floating-base, non-mimic robot GRiD emits two variants of each
kernel: the Pinocchio-convention (“pin”) kernel and a MuJoCo-convention
(“mjx”) twin applying the G = blockdiag(R, I) output basis change.
That convention change is cheap in principle but was expensive in generated
code; the second-order mjx epilogues have since been block-parallelized, which
cut them substantially. Current mjx SASS relative to pin (measured
go2-floating, nv=18):
Kernel |
mjx / pin |
|---|---|
|
2.42x (was 5.5x rolled / 28x raw) |
|
1.41x (was 3.0x) |
|
~1.0x |
The second-order mjx twins are still the largest kernels in a humanoid build, so if you do not need the MuJoCo convention, pin-only is lighter to compile.
If you do not need the MuJoCo output convention, build pin-only:
handle = grid_rbd.register_robot(
name="g1", urdf_path="g1.urdf", floating_base=True,
enable_mujoco_kernels=False,
)
On g1-floating that is the difference between a 53-minute / 8.3 GB build
(pin-only) and a 1 h 16 min / 11 GB one (with the mjx twins) — the dated table
is on Fast Robot Setup. Fixed-base and mimic robots (e.g. h1_2)
never get mjx twins, so the flag is a no-op there. It is mutually
exclusive with output_convention="mujoco", and participates in the
.so cache key only when False, so existing caches stay valid.
The generator emits a warning naming this flag when it detects a large
floating-base non-mimic robot.
For test suites and codegen sessions, GRID_ENABLE_MUJOCO_KERNELS=0
makes pin-only the default for every gen_all_code call that does not
pass the argument explicitly (an explicit argument always wins). It does
not affect register_robot/precompile, whose .so is cached
under the option dict — an env var that changed the build without changing
the cache key would return a stale .so.
Cache layout#
~/.cache/grid-rbd/
├── manifest.json # name -> content key (writers take manifest.lock)
├── bykey/<input_key> # stage-1 pointer -> content key
└── store/<content_key>/
├── grid.cuh
├── wrapper.cu
├── robot.so
├── meta.json
├── build_inputs.json # the build identity this entry was built under
└── robot.build.log
Two keys. The stage-1 input key = SHA-256 of urdf_bytes +
canonical_json(options) + grid_rbd_version plus the codegen-source
hash (every grid_codegen//URDFParser file) and the build identity:
CUDA arch, nvcc path + version, host C++ compiler, the content of the
vendored GLASS headers, _compile.py, the wrapper template, the torch/jax
ABI tags and the generation-time env knobs — the one list in
grid_codegen/env_knobs.py (GRID_CUDA_TARGET_SHARED_MEM_BYTES,
GRID_CUDA_TARGET_LITE_SHARED_MEM_BYTES, GRID_CUDA_SHARED_MEM_TYPE_SIZE_BYTES,
GRID_NO_LICM_BARRIER, GRID_FDSVA_SO_MINV_TILE, GRID_GLASS_REVISION; the
benchmark and equivalence header caches key the same list, and a test keeps it equal
to the reads in the tree). The stage-2 content key = SHA-256 of exactly
what nvcc compiled (generated bytes + flag drivers + toolchain); the
.so lives under it. A stage-1 pointer is honoured only when the entry’s
build_inputs.json equals the current identity, so a toolkit upgrade or a
dirty GLASS checkout can never return a stale .so; a codegen edit whose
emitted bytes are unchanged re-runs generation only (no nvcc), and
force_rebuild is never needed for correctness. CUDA arch and toolchain in
the key mean a roaming home directory (e.g. NFS-mounted between a laptop and
a desktop) safely keeps separate .so files per machine.
Override the cache root with $GRID_RBD_CACHE_DIR or
cache_dir=... on register_robot.
Dimensions, layouts and differentiability#
Dimension names. Every handle exposes three read-only widths (the legacy names remain as aliases):
Name |
Legacy alias |
Meaning |
|---|---|---|
|
|
Configuration width: |
|
|
Tangent width: matrix and gradient outputs are |
|
|
Body count (base included on a floating base): |
Velocity-space inputs and dynamics vector outputs are nv-wide. qd,
qdd and u are passed at the tangent width nv on every surface
(NumPy, JAX, PyTorch and the C ABI), and inverse_dynamics,
forward_dynamics, aba, nonlinear_effects and
generalized_gravity return (B, nv). This is the Pinocchio and MuJoCo
convention; on a scalar-joint fixed base nq == nv and nothing changes. An
nq-wide padded array on a floating-base robot raises a clear
ValueError naming the tangent width rather than being sliced silently. The
kernels’ padded staging layout (Input / Output ABI (h_q_qd_u)) is
internal to the compiled wrapper.
What is differentiable. jax and torch handles attach an analytic
backward (a batched VJP through the *_gradient kernels) to exactly these
methods; everything else is forward-only on every backend.
Method |
Differentiable inputs |
Notes |
|---|---|---|
|
|
|
|
|
|
|
|
the derivative of the returned coordinates |
|
|
fixed-base only (the SE(3)-chart VJP is not implemented); through
the baked |
|
|
local parameter sensitivity ( |
|
|
local parameter sensitivity ( |
Each of these is a reverse-mode VJP only: there is no forward-mode
(jax.jvp / jax.jacfwd through them fails), no higher-order autograd
(differentiating the backward again is not supported — use the explicit
second-order kernels idsva_so / fdsva_so / end_effector_pose_hessian,
which are forward-only outputs), and jax.vmap composes only along the
batch axis. The q cotangent on a floating base is the exact ambient
pullback through the kernel’s quaternion normalization (pinocchio handles)
or the on-manifold pullback (mjx handles); see Gradient semantics under
the JAX section.
``*_wrt_params`` are local sensitivities, not parameterized functions.
inverse_dynamics_wrt_params(q, qd, params) returns the SAME torque the
baked (or current runtime) inertial table gives, whatever params you pass;
the operand exists so that the backward can supply ∂τ/∂π through the
regressor VJP. Use them as the linearization point for sysID / calibration
(the gradient with respect to π at the current model), not as a function
you can evaluate at a different π — to actually change the model, call
set_inertia_params (runtime_inertia=True builds). A functional
parameterized forward is a registered design decision (audit W11, option B),
not something the current API pretends to be.
Runtime tables reach dynamics, not baked kinematics. runtime_inertia,
runtime_transform and runtime_joint_dynamics rebuild the dynamics
tables from a mutable device table once per launch, so inverse_dynamics,
forward_dynamics, their gradients and the second-order kernels follow
set_*_params with no recompile. The homogeneous-transform path used by
end_effector_pose / end_effector_pose_gradient / _hessian /
frame_jacobian / fk_batched reads the BAKED joint origins: after
set_transform_params the kinematics still report the URDF geometry. That
is a documented partial capability, not a bug you can work around from
Python; consistent kinematic updates are a separate feature (register item).
End-effector target selection#
By default register_robot uses GRiD’s default EE choice (all leaf
nodes). Pass ee_joint_names=["iiwa_joint_7"] to bake a specific
fixed-joint target into the codegen:
handle = grid_rbd.register_robot(
name="iiwa14_wrist",
urdf_path="iiwa.urdf",
ee_joint_names=["iiwa_joint_7"],
)
A different ee_joint_names value lands in a separate cache entry —
both targets can coexist in the cache.
Contact frames and welded tools#
register_robot(contact_frames=[...]) takes a list of fixed-joint
names and bakes the contact family into the build: the handle gains the
contact_fext method (per-contact-frame world-aligned wrenches →
joint-local f_ext) and a contact_frames property. The kwarg
re-keys the .so cache, so contact and non-contact builds coexist. It
is accepted by every backend’s register_robot (NumPy, JAX, PyTorch)
and composes with a subset algorithm_list — e.g.
algorithm_list=["inverse_dynamics", "forward_dynamics"] plus
contact_frames=[...] builds only the dynamics cores and the contact
family (the bindings/examples/multi_contact_fext.py recipe).
The baked contact set also exposes its ORIGINS to CUDA/plant consumers
(GATO ask 2026-09-20): grid::contact_frame_positions_device (the
3*NUM_CONTACT_FRAMES world positions of the points f_ext_body takes
the wrench about, registration order) and
grid::contact_frame_positions_gradient_device (3 x NUM_VEL per
frame, layout [3*NUM_VEL*f + 3*vi + row], floating tangent
[v_lin; omega; joints]), with the caller-scratch wrappers
grid_plant::contact_frame_positions[_gradient](…, s_scratch, …) sized by
CONTACT_FRAME_POSITIONS[_GRADIENT]_DYNAMIC_SHARED_MEM_COUNT. They ride
the multi-target emitters, so a dynamics-only algorithm_list still gets
them. (No Python method yet — the device/plant layer is the consumer.)
The same caller-scratch pair exists for the DEFAULT multi-target batch (the
multi_target_batch option / collision spheres):
grid_plant::multi_target_position[_gradient](…, s_scratch, …) with
s_scratch sized by the constexpr
MULTI_TARGET_POSITION[_GRADIENT]_DYNAMIC_SHARED_MEM_BYTES<T>() — the raw
evaluator a consumer’s own FK carve would otherwise have to compose.
register_robot(enable_tool=True) enables the runtime welded-tool
surface — attach_tool / detach_tool / tool_fext — by turning
on the runtime inertia table + runtime contact surface, all with no
recompile at attach time. See
bindings/examples/AGENT_INTEGRATION_GUIDE.md and
bindings/examples/tool_use.py for the full recipes.
Host round trips (numpy): allocate once, reuse#
The numpy methods return a fresh array per call. For the large outputs
(idsva_so / fdsva_so: 4·NV³ floats per batch item, 702 MB on g1
at batch 1024) that costs a device→host copy into the context’s page-locked
mirror plus a host memcpy into the new array, and ~120 ms per call. The
robotics pattern is to allocate once and reuse: handle.pinned_empty(shape)
returns a page-locked array in the compute dtype, and out= makes the
generated host wrapper copy device→host straight into it — no host memcpy, at
the PCIe rate. The returned tensors are views of out. out may also be
an ordinary C-contiguous array (then the copy is driver-staged, still without
the extra memcpy); wrong shape/dtype/contiguity is refused with a clear error.
h = grid_rbd.get_robot("g1")
out = h.pinned_empty((B, 4 * h.num_vel ** 3)) # once
for q, qd, qdd in trajectory: # many
d2tau_dq, d2tau_dqd, d2tau_cross, dM_dq = h.idsva_so(q, qd, qdd, out=out)
Measured 2026-10-01 (g1 idsva_so @1024): ~40 ms with a pinned out
versus 120 ms through a fresh array; see the torch/JAX sections below for the
same pattern on those surfaces. Page-locked memory is a limited resource — do
not allocate it per call.
The first-order gradients take out= too. Their buffer is the flat
(B, 2·NV²) block the device writes — one column-major NV × 2NV matrix
per item — and the returned (B, NV, 2NV) array is a view of it, so no
array is allocated and nothing is re-laid-out on the host:
out = h.pinned_empty((B, 2 * h.num_vel ** 2)) # once
for q, qd, qdd in trajectory: # many
dtau = h.inverse_dynamics_gradient(q, qd, qdd, out=out) # view of out
dtau_dq, dtau_dqd = dtau[..., :h.num_vel], dtau[..., h.num_vel:]
The view is column-major per item (not C-contiguous); wrap it in
np.ascontiguousarray if a consumer needs C order. Reusing out on the
next call overwrites the values the earlier view shows.
JAX FFI (grid_rbd[jax])#
Install with pip install -e ".[jax]" to get the JAX bridge.
The same per-robot .so is shared with the plain wrapper — no
recompile on first grid_rbd.jax.register_robot:
import grid_rbd.jax as grid_jax
import jax
handle = grid_jax.register_robot(name="iiwa14", urdf_path="iiwa.urdf")
@jax.jit
def step(q, qd, u):
return handle.forward_dynamics(q, qd, u)
Methods slot into the JAX FFI machinery as ffi_call targets
running on JAX-supplied CUDA streams. Inputs may be numpy or
jax.Array — JAX moves data to device transparently before the
handler runs, and outputs stay device-resident.
Host round trips. Keep outputs resident when the next consumer is on the
GPU. When you do need numpy, grid_rbd.jax.to_host(outputs) moves an array
or any pytree of arrays through XLA’s pinned_host memory kind and returns
zero-copy numpy views: on a 702 MB idsva_so output (g1, batch 1024) it
takes 41 ms against 141 ms for jax.device_get (measured 2026-10-01). The
pinned route costs about 20 µs per array, so by default (pinned="auto")
only arrays of at least 256 KiB take it and smaller ones go through
jax.device_get; pinned=True / pinned=False force either route. It
falls back to jax.device_get on devices without that memory kind. For device-side
allocate-once reuse, jax.jit(..., donate_argnums=...) lets XLA write an
output into a donated input buffer.
JAX surface: the core dynamics / kinematics / SO methods are bound via
FFI and JIT-compatible (the SO methods idsva_so / fdsva_so follow
the plain wrapper’s tuple-of-four convention), with autograd-aware
inverse_dynamics / forward_dynamics (qdd-aware), end_effector_pose,
f_ext parity, and the inertial-parameter (π) regressor VJP path. The
centroidal / kinematics value ops (generalized_gravity,
nonlinear_effects, energy, com, ccrba, dccrba,
cmm_time_variation, coriolis_matrix, frame_jacobian /
frame_jacobian_dot, osc_inertia, and the KE/PE regressors) are now
exposed on the jax and torch surfaces too (forward-only, no autograd).
PyTorch backend (backend="torch")#
register_robot(..., backend="torch") returns a TorchRobotHandle
whose methods return torch.Tensor. The differentiable
algorithms (inverse_dynamics / forward_dynamics / aba /
end_effector_pose / integrator) are autograd-aware — their backward
passes are analytic, reusing the existing *_gradient kernels — while the
remaining methods are forward-only ops. The .so is shared with the numpy/JAX surfaces; the
torch op block is compiled in under -DGRID_RBD_WITH_TORCH when torch
is present at register time.
import grid_rbd, torch
h = grid_rbd.register_robot("iiwa14", urdf_path="iiwa.urdf", backend="torch")
q = torch.randn(64, h.num_joints, device="cuda", requires_grad=True)
qd = torch.randn(64, h.num_joints, device="cuda", requires_grad=True)
u = torch.randn(64, h.num_joints, device="cuda", requires_grad=True)
qdd = h.forward_dynamics(q, qd, u) # autograd-aware torch.Tensor
qdd.sum().backward() # gradients flow to q, qd, u
What the gradients mean (both backends, audit 2026-09-19).
Floating base, w.r.t. ``q``. The analytic kernels differentiate in the Pinocchio free-flyer tangent chart (
[v_lin local, ω local, joints], nv-wide; that is whatinverse_dynamics_gradientetc. return). The publicqis[pos(3), quat_xyzw(4), joints](nq = nv+1), andjax.grad/q.gradreturn the exact pullback to THAT layout: the kernels evaluateR(p/|p|), so the value isf ∘ normalizeon the ambient quaternion and the gradient is its true ambient derivative (central differences over anyqcomponent agree, including for a non-unit quaternion; the radial direction is a null direction). Underoutput_convention="mujoco"the twins use the mjx free-joint chart (linear world, angular local,quat_wxyz) and expect a UNIT quaternion (they do not renormalize): the returnedqcotangent is the on-manifold pullback (zero radial component). Need the tangent-space gradient instead? Contract your cotangent with the*_gradientJacobian yourself. Spherical joints have their own four-position/three-tangent quaternion blocks. Their pullbacks use the model’s per-joint coordinate offsets, not a floating-root assumption. Old spherical artifacts without this layout metadata must be re-registered before using framework autodiff.External forces.
f_extis a non-differentiated input, but theq/qdgradients are taken at the given force (a fixed body-local wrench has q-dependent joint torques).f_extmust be(B, 6*num_bodies)in the body-local frame; on the jax surface(6*num_bodies,)and(1, 6*num_bodies)broadcast forms are materialized to the batch, and any other leading shape is rejected before the FFI call (the native handlers reject a batch mismatch too).
Shared runtime lifetime. Backend handles referring to the same compiled
artifact share its model and arena. Closing or collecting one handle does not
reset the others. Framework-allocated arena buffers are retained by the shared
native owner until the last Runner closes, and outstanding device work is
completed before that buffer is released. A fresh runtime allocates lazily on
its first operation or parameter update, so CUDA allocation errors can surface
there rather than during registration. A framework view opened after a NumPy
arena is already live reuses it; it does not replace the allocator or erase
inertia, transform, or tool updates. Isolation is opt-in: handle.context()
(numpy, jax and torch handles alike) opens a runtime context of its own on
the same artifact — its own arena, tables, streams and launch overrides — see
the Runtime contexts concepts page. Concurrent operations on ONE context’s
scratch are still not independently safe (per-call leases are a later
increment). Runtime-parameter mutations (set_inertia_params,
attach_tool …) are serialized against every in-flight call and bump
handle.model_version; a torch or JAX backward whose forward ran under an
older version raises (model mutated between forward and backward) instead
of differentiating the new model — recompute the forward after mutating.
For fixed-batch, low-launch-overhead replay (MPC / training),
handle.capture(method, *example_inputs, **kwargs) returns a
GraphCallable backed by a CUDA graph. A mandatory off-graph warmup
runs the one-time >48 KB dynamic-smem opt-in (illegal during capture)
before the graph is recorded. A graph bakes device addresses and the model
epoch it was captured at: every replay takes a replay admission on its
context and is refused (RuntimeError) after a runtime-parameter mutation
(recapture) or after the context was closed; static_out is overwritten by
the next replay, so clone it to keep a value (see the Runtime contexts
concepts page):
g = h.capture("forward_dynamics", q, qd, u) # warmup + capture
qdd = g(q_new, qd_new, u_new) # copy_ + replay
Host round trips: allocate once, reuse. .cpu() on a large device
output goes through a pageable staged copy (~3 GB/s here). Allocate
page-locked mirrors ONCE with grid_rbd.torch.pinned_host_like(out) (a
tensor or a tuple, e.g. g.static_out) and fill them with
grid_rbd.torch.copy_to_host(host, out) or, for a captured graph,
g.replay_into(host) — a non_blocking copy at the PCIe rate followed
by a stream sync. Measured 2026-10-01 on g1 idsva_so at batch 1024
(702 MB): 39.5 ms vs 238 ms for .cpu(). Inputs can take the same route:
torch.from_numpy(a).pin_memory().to("cuda", non_blocking=True).
g = h.capture("idsva_so", q, qd, qdd)
host = grid_rbd.torch.pinned_host_like(g.static_out) # once
for q_new in trajectory: # many
g.static_in[0].copy_(q_new)
d2tau_dq, d2tau_dqd, d2tau_cross, dM_dq = g.replay_into(host)
Note
The backward VJP contractions run torch’s own CUDA kernels, so the
installed torch build must support the GPU’s compute capability. On an
RTX 5090 (sm_120) you need a torch cu128 (or newer) build — a
cu124 wheel (max sm_90) cannot launch on sm_120. The grid /
grid_plant kernels themselves are always nvcc-built for the
detected arch and are unaffected.
grid_plant cost / barrier / plant-step methods#
The handle also exposes the generated grid_plant trajectory-
optimization surface (validated against RBDReference._PlantMixin).
All take/return 2D arrays with axis 0 = batch; cost methods return
(value, grad, hess) and barriers return (value, grad, hess_diag).
Method |
Returns |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
as above over |
|
as above over |
|
|
|
|
|
|
plant_step_hessian is available on the NumPy handle, not the JAX or
PyTorch handles. See Integrators and the plant layer for
input layouts, diagonal weights and derivative scope.
External forces (f_ext)#
Per-body external forces are an opt-in feature of the underlying CUDA
codegen and the RBDReference oracle (body-local frame, subtracted
from the per-body force; an empty/None value reproduces the no-force
path). The generated host wrappers carry the d_f_ext argument, and an
f_ext= kwarg is exposed on the RobotHandle algorithm methods that
support it (inverse_dynamics / forward_dynamics / aba and
their gradients); the default None reproduces the no-force path.
See also#
Benchmarks — bench harness and what the methods cost.
Algorithms — algorithm-level docs.
docs/open-tasks/archive/python_wrappers_plan.md— design rationale (archived).