Skip to content

Propagation options

61 · Solver and runtime

A twelve-link chain hung from one end swings down, lands and folds onto itself, so it has ground contacts and contacts between its own links. With the colored sweep and the propagation response, contacts between two links of one articulation stay dense rows by default; propagation_same_articulation_rows moves them to propagation rows. propagation_cached_response applies cached body responses instead of walking the tree each iteration, and propagation_color_order picks the order of the colored batches.

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: the folding twelve-link chain in each world.

Option1 world64 worlds1024 worldssolver allocation, 1024 worldsresult in this tutorial
serial + immediate0.98 ms1.14 ms1.65 ms211 MBreference
colored + propagation (defaults)1.02 ms 1.04×1.17 ms 1.03×1.98 ms 1.21×374 MB96 dense rows
propagation_same_articulation_rows=True2.09 ms 2.13×5.90 ms 5.19×12.87 ms 7.82×374 MB0 dense rows
propagation_cached_response=False1.12 ms 1.14×1.25 ms 1.11×1.96 ms 1.19×362 MBsame result
propagation_color_order="rotating"1.02 ms 1.04×1.17 ms 1.03×1.99 ms 1.21×374 MBsame result
propagation_constraint_max=64, free_body_constraint_max=81.02 ms 1.03×1.17 ms 1.03×1.94 ms 1.18×254 MBfits, less memory

What to look at

  • The chain lands the same way in every run; same_articulation_rows turns the 96 dense self-contact rows into propagation rows.
  • The 96 dense rows do not fit the default articulation_constraint_max of 32. As propagation rows they do: the last run uses the default capacity and lands like the others. Separate runs that kept them dense at 32 dropped rows, and some went non-finite.
  • The divergence comes from which contacts keep their rows: by default that set changes every step, and contacts that penetrated while dropped are pushed out when they return, which adds energy. In 6 runs each it diverged in 1 with this colored propagation solve and in 4 with the default immediate response; with deterministic solver and pipeline order it repeated exactly and stayed finite.

Key parameter

propagation_same_articulation_rows / propagation_cached_response / propagation_color_order

Options of articulated_contact_response="propagation"; propagation_constraint_max sizes its rows.

Try changing

  • Set propagation_constraint_max=64 and shrink free_body_constraint_max.
  • Try propagation_color_order="longest_first".

Concept

Propagation rows

What it is

The propagation response solves each contact as a small row on the touched bodies and pushes its impulse through the tree each iteration. Self-contacts of one articulation normally stay dense rows with their own capacity. Cached responses replace the tree walk with small precomputed matrices, and the color order changes which contacts Gauss-Seidel favours.

In this scene

Moving self-contacts to propagation rows takes 0 dense rows instead of 96, so the default dense capacity of 32 is enough.

Considerations

Cost
same_articulation_rows was 2× slower in one world and 6× at 1024 worlds (12.9 vs 2.0 ms); cached_response=False was 10% slower in one world; setting propagation_constraint_max=64 together with free_body_constraint_max=8 reduced solver-owned memory from 374 to 254 MB at 1024 worlds.
Interactions
rotating color order changed nothing here; it targets symmetric grasps on sorted contact streams.
Use it when
same_articulation_rows when dense capacity overflows; size propagation_constraint_max from row_watermark to save memory.

The script

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

tutorials/61_propagation_options.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""Propagation options: same-articulation rows, cached responses and the colored contact order."""
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 = 12
BASE = {"solve_mode": "colored", "articulated_contact_response": "propagation"}
OPTIONS = {
"colored + propagation (defaults)": {},
"propagation_same_articulation_rows=True": {"propagation_same_articulation_rows": True},
"propagation_cached_response=False": {"propagation_cached_response": False},
'propagation_color_order="rotating"': {"propagation_color_order": "rotating"},
# the 96 dense self-contact rows would not fit the default 32; as propagation rows they do
"same_articulation_rows, articulation_constraint_max=32": {
"articulation_constraint_max": 32,
"propagation_same_articulation_rows": True,
},
}
def tf(x, y, z):
return wp.transform((x, y, z), wp.quat_identity())
def build():
"""A twelve-link chain hung from one end; it swings down and folds onto itself."""
builder = newton.ModelBuilder()
builder.add_ground_plane()
parent, joints = -1, []
for i in range(LINKS):
link = builder.add_link(xform=tf(0.08 * i + 0.04, 0.0, 0.5))
rot = wp.quat_from_axis_angle(wp.vec3(0.0, 1.0, 0.0), np.pi / 2)
builder.add_shape_capsule(
link, xform=wp.transform((0.0, 0.0, 0.0), rot), radius=0.025, half_height=0.02
)
anchor = tf(0.0, 0.0, 0.5) if parent < 0 else tf(0.04, 0.0, 0.0)
axis = (0.0, 1.0, 0.0) if i % 3 else (0.0, 0.3, 1.0)
joints.append(
builder.add_joint_revolute(
parent,
link,
axis=axis,
parent_xform=anchor,
child_xform=tf(-0.04, 0.0, 0.0),
armature=0.002,
damping=0.002,
)
)
parent = link
builder.add_articulation(joints)
return builder.finalize()
def simulate(rec, label):
model = build()
options = dict(
BASE, articulation_constraint_max=256, free_body_constraint_max=256, row_watermark=True
)
solver = SolverSteel(model, **dict(options, **OPTIONS[label]))
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
state_0, state_1, control = model.state(), model.state(), model.control()
run = rec.variant(label, model)
for _ in range(rec.frames(4.0)):
marks = solver.constraint_row_watermarks()
run.frame(
state_0,
dense_rows=marks["articulation_raw_high_water"],
propagation_rows=marks["propagation_raw_high_water"],
tip_z=state_0.body_q.numpy()[-1, 2],
)
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 label in OPTIONS:
simulate(rec, label)
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