Skip to content

Mass-matrix update interval

59 · Solver and runtime

A six-link chain of light capsules (armature 0.001 kg·m²) released at 1.2 rad swings freely for 8 s. Building and factoring the joint-space mass matrix is a large part of each step. update_mass_matrix_interval reuses the factorization for several steps, which saves work but uses stale inertia while the chain changes shape. The chart tracks the chain's energy, armature included, as a share of its starting swing energy. The last run keeps interval 1 at a quarter of the step.

0.00 s

Cost

Time per step, CUDA graph replay, median of 7 interleaved rounds on one NVIDIA RTX PRO 6000 Blackwell Server Edition; ratios are against the first row. Solver allocation is the GPU memory the solver allocates at construction, before warm-up and graph capture. Scene: a driven 6-link chain in each world (target_ke 50, joint friction 0.01).

Option1 world64 worlds1024 worldssolver allocation, 1024 worldsresult in this tutorial
update_mass_matrix_interval=10.21 ms0.29 ms0.34 ms48 MBchain above: up to +46% swing energy
update_mass_matrix_interval=40.20 ms 0.95×0.28 ms 0.95×0.32 ms 0.95×48 MBchain above: -18% by 8 s
update_mass_matrix_interval=160.20 ms 0.94×0.28 ms 0.95×0.32 ms 0.93×48 MBchain above: -20% by 8 s

What to look at

  • The default gains up to 46% of the swing energy: at 240 Hz this light chain gains energy from the explicit velocity-product forces, whatever the interval.
  • Intervals 4 and 16 do not fix that. They gain less early on, then lose 18 to 20% by 8 s: the stale matrix trades the gain for a loss of the same order, and the tips drift apart.
  • A quarter of the step (960 Hz) cuts the gain to 11%; the error is first order in the step.

Key parameter

update_mass_matrix_interval

Rebuild every N steps (default 1). Under CUDA graph capture, N should divide the number of captured steps.

Try changing

  • Try it on a slow robot arm, where the matrix changes little.
  • Remove the armature and release the chain at 2.0 rad: interval 16 can go to NaN.

Concept

Mass-matrix update interval

What it is

Each step builds and factors the joint-space mass matrix, which depends on the pose. update_mass_matrix_interval reuses the factorization for several steps, accepting stale inertia.

In this scene

Over 8 s the default gains up to 46% of the swing energy and intervals 4 and 16 end 18 to 20% below the start, so a longer interval is not more accurate here; 960 Hz steps gain 11%.

Considerations

Cost
On this 6-DOF chain the saving was 4 to 7% per step; larger articulations save more.
Stability
A stale matrix does not stabilize a chain that gains energy. In separate 8 s CPU runs from 2.0 rad, intervals 4 and 16 lost 85 to 96% of the swing energy, and without armature interval 16 went to NaN. For light chains use a smaller step.
Interactions
dt changes, reset and notify_model_changed force a refresh.
Use it when
Large, slowly moving articulations; verify energy and tracking over a full episode first.

The script

cd tutorials && python 59_mass_matrix_interval.py prints each variant's final metric; --record out.json also writes the replay. Every tutorial imports replay.py from the same folder.

tutorials/59_mass_matrix_interval.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""update_mass_matrix_interval: reuse the factored mass matrix for several steps to save work."""
import newton
import numpy as np
import warp as wp
from newton.solvers import SolverSteel
from replay import Recorder
DT = 1.0 / 240.0
SUBSTEPS = 8
LINKS = 6
def tf(x, y, z):
return wp.transform((x, y, z), wp.quat_identity())
def build():
builder = newton.ModelBuilder()
parent, joints = -1, []
for i in range(LINKS):
link = builder.add_link(xform=tf(0.0, 0.0, 1.0 - 0.1 * i - 0.05))
builder.add_shape_capsule(link, radius=0.02, half_height=0.03)
anchor = tf(0.0, 0.0, 1.0) if parent < 0 else tf(0.0, 0.0, -0.05)
# the armature tutorial's 0.001: at 240 Hz this light chain still gains energy
joints.append(
builder.add_joint_revolute(
parent,
link,
axis=newton.Axis.Y,
parent_xform=anchor,
child_xform=tf(0.0, 0.0, 0.05),
armature=0.001,
)
)
builder.joint_q[-1] = 1.2 if i == 0 else 0.0
parent = link
builder.add_articulation(joints)
return builder.finalize()
def swing_energy(model, state):
"""Energy above the chain hanging straight down at rest [J]."""
rest = model.state()
newton.eval_fk(model, wp.zeros_like(model.joint_q), model.joint_qd, rest)
return energy(model, state) - energy(model, rest)
def energy(model, state):
"""Kinetic plus potential energy of the chain, including the joints' armature [J]."""
mass, inertia = model.body_mass.numpy(), model.body_inertia.numpy()
q, qd = state.body_q.numpy(), state.body_qd.numpy()
total = 0.0
for b in range(model.body_count):
rot = np.array(wp.quat_to_matrix(wp.quat(*q[b, 3:]))).reshape(3, 3)
omega = qd[b, 3:]
total += (
0.5 * mass[b] * qd[b, :3] @ qd[b, :3] + 0.5 * omega @ rot @ inertia[b] @ rot.T @ omega
)
total += mass[b] * 9.81 * q[b, 2]
return total + 0.5 * (model.joint_armature.numpy() * state.joint_qd.numpy() ** 2).sum()
def simulate(rec, interval, substeps=SUBSTEPS):
model = build()
solver = SolverSteel(model, update_mass_matrix_interval=interval)
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
state_0, state_1, control = model.state(), model.state(), model.control()
newton.eval_fk(model, model.joint_q, model.joint_qd, state_0)
start, swing = energy(model, state_0), swing_energy(model, state_0)
dt = DT * SUBSTEPS / substeps
label = f"update_mass_matrix_interval={interval}" + (" (default)" if interval == 1 else "")
if substeps != SUBSTEPS:
label += f", {round(1.0 / dt)} Hz steps"
run = rec.variant(label, model)
for _ in range(rec.frames(8.0)):
energy_change = 100.0 * (energy(model, state_0) - start) / swing
run.frame(state_0, tip_x=state_0.body_q.numpy()[-1, 0], energy_change_pct=energy_change)
for _ in range(substeps):
pipeline.collide(state_0, contacts)
solver.step(state_0, state_1, control, contacts, dt)
state_0, state_1 = state_1, state_0
run.check(solver)
if __name__ == "__main__":
rec = Recorder()
for interval in [1, 4, 16]:
simulate(rec, interval)
simulate(rec, 1, substeps=4 * SUBSTEPS) # a quarter of the step, for reference
rec.save()

Recorded with SolverSteel on Newton 1.9.0.dev0, Warp 1.19.0, NVIDIA RTX PRO 6000 Blackwell Server Edition. Replays read the state back every frame, so they are not a speed benchmark. Script · page text