Skip to content

Performance options

60 · Solver and runtime

A fixed trunk with eight driven three-link branches, plus a separate two-link arm, so the model has two articulation sizes. Four construction options change how Steel schedules its work, not the physics: parallel_tree walks independent branches in parallel, use_parallel_streams runs each size group on its own CUDA stream, double_buffer clears scratch buffers on a side stream, and reuse_kinematics carries the step's kinematics into the next step instead of re-deriving them.

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 eight-branch tree and two-link arm in each world.

Option1 world64 worlds1024 worldssolver allocation, 1024 worldsresult in this tutorial
defaults0.40 ms0.47 ms0.65 ms160 MBreference
parallel_tree0.39 ms 0.97×0.44 ms 0.93×0.59 ms 0.91×161 MBidentical trajectory
use_parallel_streams0.39 ms 0.96×0.46 ms 0.98×0.64 ms 0.99×161 MBidentical
double_buffer0.40 ms 0.99×0.47 ms 1.00×0.65 ms 1.00×190 MBidentical
reuse_kinematics0.40 ms 0.99×0.46 ms 0.99×0.64 ms 0.99×160 MBwithin 0.03 µm

What to look at

  • All four runs match the default run: the largest body difference is 0 mm, and 0.03 µm with reuse_kinematics.
  • The Cost table shows which one pays off on this tree.

Key parameter

parallel_tree / use_parallel_streams / double_buffer / reuse_kinematics

Construction options; results are unchanged up to float summation order.

Try changing

  • Make the branches longer and count them again.
  • Add a FREE joint below the root and check solver.reuse_kinematics, which reports whether it runs.

Concept

Scheduling options

What it is

Steel walks each articulation tree for kinematics, dynamics and publication. parallel_tree gives a branched tree up to 32 threads instead of one; use_parallel_streams overlaps the factorization of different articulation sizes; double_buffer hides buffer clears; reuse_kinematics skips recomputing the kinematics the last step already formed.

In this scene

Trajectories are identical to the default. parallel_tree saved 9% per step at 1024 worlds (0.59 vs 0.65 ms); the others were within 1–3%.

Considerations

Cost
double_buffer kept 30 MB more at 1024 worlds for no gain here; the rest add no memory.
Interactions
reuse_kinematics stays off with the parallel tree, sleeping, joint velocity limits or a FREE joint that is not first; state edits outside step need notify_state_changed.
Failure modes
Narrow trees can get slower with parallel_tree; measure the full step.
Use it when
parallel_tree for broad branched robots such as hands and multi-legged bodies; test the others on your own model.

The script

cd tutorials && python 60_performance_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/60_performance_options.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""Performance options: parallel_tree, use_parallel_streams, double_buffer and reuse_kinematics."""
import math
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
OPTIONS = {
"defaults": {},
"parallel_tree": {"parallel_tree": True},
"use_parallel_streams + double_buffer": {"use_parallel_streams": True, "double_buffer": True},
"reuse_kinematics": {"reuse_kinematics": True},
}
def tf(x, y, z, q=None):
return wp.transform((x, y, z), q if q is not None else wp.quat_identity())
def build():
"""A trunk with eight driven three-link branches, plus a two-link arm as a second size group."""
builder = newton.ModelBuilder()
builder.add_ground_plane()
trunk = builder.add_link(xform=tf(0.0, 0.0, 0.8))
builder.add_shape_cylinder(trunk, radius=0.06, half_height=0.05)
joints = [builder.add_joint_fixed(-1, trunk, parent_xform=tf(0.0, 0.0, 0.8))]
for k in range(8):
yaw = wp.quat_from_axis_angle(wp.vec3(0.0, 0.0, 1.0), 2.0 * math.pi * k / 8)
parent, anchor = trunk, wp.transform(wp.quat_rotate(yaw, wp.vec3(0.07, 0.0, 0.0)), yaw)
for i in range(3):
link = builder.add_link(xform=tf(0.0, 0.0, 0.8))
builder.add_shape_capsule(
link,
xform=tf(
0.06, 0.0, 0.0, wp.quat_from_axis_angle(wp.vec3(0.0, 1.0, 0.0), math.pi / 2)
),
radius=0.015,
half_height=0.045,
cfg=newton.ModelBuilder.ShapeConfig(has_shape_collision=False),
)
axis = newton.Axis.Y if i else newton.Axis.Z
joints.append(
builder.add_joint_revolute(
parent,
link,
axis=axis,
parent_xform=anchor,
target_ke=40.0,
target_kd=1.0,
armature=0.001,
)
)
builder.joint_q[-1] = 0.3 * math.sin(k + i)
parent, anchor = link, tf(0.12, 0.0, 0.0)
builder.add_articulation(joints)
base = builder.add_link(xform=tf(0.6, 0.0, 0.3))
builder.add_shape_box(base, hx=0.02, hy=0.02, hz=0.1)
j0 = builder.add_joint_revolute(
-1, base, axis=newton.Axis.Y, parent_xform=tf(0.6, 0.0, 0.4), child_xform=tf(0.0, 0.0, 0.1)
)
tip = builder.add_link(xform=tf(0.6, 0.0, 0.1))
builder.add_shape_box(tip, hx=0.02, hy=0.02, hz=0.1)
j1 = builder.add_joint_revolute(
base,
tip,
axis=newton.Axis.Y,
parent_xform=tf(0.0, 0.0, -0.1),
child_xform=tf(0.0, 0.0, 0.1),
)
builder.joint_q[-2:] = [0.8, 0.4]
builder.add_articulation([j0, j1])
return builder.finalize()
def simulate(rec, label, reference=None):
model = build()
solver = SolverSteel(model, **OPTIONS[label])
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)
# sway the branch targets so the tree keeps moving
targets = control.joint_target_q.numpy()
run = rec.variant(label, model)
for frame in range(rec.frames(4.0)):
t = frame / rec.fps
control.joint_target_q.assign((targets + 0.4 * math.sin(2.0 * t)).astype(np.float32))
q = state_0.body_q.numpy()
diff = (
0.0
if reference is None
else 1000.0 * np.abs(q[:, :3] - reference.poses[frame][:, :3]).max()
)
run.frame(state_0, difference_mm=diff)
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)
print(f"{label}: reuse_kinematics runs = {solver.reuse_kinematics}")
return run
if __name__ == "__main__":
rec = Recorder()
reference = simulate(rec, "defaults")
for label in list(OPTIONS)[1:]:
simulate(rec, label, 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