Skip to content

Run-to-run determinism

51 · Solver and runtime

Forty objects poured into a bin twice from identical inputs. Run A is solid and run B is a wireframe in the same place; each body of A turns green as soon as its pose differs from B, and the readout shows the largest difference. By default rows are reserved with atomics, so the Gauss-Seidel order can change between runs and the chaotic pile amplifies the difference. With deterministic=RUN_TO_RUN and a deterministic collision pipeline the recorded body poses of the two runs are bitwise identical.

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 8-box stack in each world, 8 steps of 1/240 s per frame.

Option1 world64 worlds1024 worldssolver allocation, 1024 worldsresult in this tutorial
default1.00 ms1.21 ms1.64 ms215 MBpours end 20 cm apart
RUN_TO_RUN1.06 ms 1.06×1.26 ms 1.04×1.70 ms 1.04×219 MBbitwise identical

What to look at

  • Default: the first bodies turn green at about 0.3 s and the runs end about 20 cm apart.
  • RUN_TO_RUN: every body stays neutral; the readout shows 0, bitwise identical, for the whole run.

Key parameter

deterministic

wp.DeterministicMode.RUN_TO_RUN, together with CollisionPipeline(model, deterministic=True).

Try changing

  • Make only the solver deterministic.
  • Time both modes on a large scene.

Concept

Run-to-run determinism

What it is

Rows are reserved with atomics by default, so their order, and with it the Gauss-Seidel result, can differ between runs. RUN_TO_RUN reserves rows in a fixed order; with a deterministic collision pipeline the same inputs give bitwise-identical results on the same GPU and build.

In this scene

Two default pours end 20 cm apart; RUN_TO_RUN gives byte-identical recorded poses.

Considerations

Cost
About 3.5% per step (1.64 → 1.70 ms in 1024 worlds) and about 5 MB more solver memory for 1024 worlds.
Accuracy
Neither order is more accurate; only reproducibility changes.
Interactions
body_parent_f sums with atomics and stays outside the guarantee.
Use it when
Debugging, regression tests and reproducible data.

The script

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

tutorials/51_determinism.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""Run-to-run determinism: the same pile poured twice, with and without deterministic row 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
def build():
builder = newton.ModelBuilder()
cfg = newton.ModelBuilder.ShapeConfig(mu=0.5)
builder.add_ground_plane(cfg=cfg)
for s in (-1, 1):
builder.add_shape_box(
-1,
xform=wp.transform((0.22 * s, 0.0, 0.1), wp.quat_identity()),
hx=0.01,
hy=0.22,
hz=0.1,
)
builder.add_shape_box(
-1,
xform=wp.transform((0.0, 0.22 * s, 0.1), wp.quat_identity()),
hx=0.22,
hy=0.01,
hz=0.1,
)
rng = np.random.default_rng(3)
for i in range(40):
axis = rng.normal(size=3)
q = wp.quat_from_axis_angle(wp.normalize(wp.vec3(*axis)), float(rng.uniform(0.0, np.pi)))
body = builder.add_body(
xform=wp.transform(
(rng.uniform(-0.12, 0.12), rng.uniform(-0.12, 0.12), 0.15 + 0.06 * i), q
)
)
if i % 2:
builder.add_shape_box(body, hx=0.03, hy=0.025, hz=0.02, cfg=cfg)
else:
builder.add_shape_sphere(body, radius=0.025, cfg=cfg)
return builder.finalize()
def simulate(rec, deterministic, slot):
"""Pour twice and record both runs, the second as a translucent ghost over the first."""
name = "deterministic=RUN_TO_RUN" if deterministic else "default"
runs = []
for k in range(2):
model = build()
mode = (
wp.DeterministicMode.RUN_TO_RUN
if deterministic
else wp.DeterministicMode.NOT_GUARANTEED
)
# a deep pile needs more free-body rows than the default 512
solver = SolverSteel(model, deterministic=mode, free_body_constraint_max=4096)
# the contact order must be fixed too
pipeline = newton.CollisionPipeline(model, deterministic=deterministic)
contacts = pipeline.contacts()
state_0, state_1, control = model.state(), model.state(), model.control()
run = rec.variant(f"{name}, run {k + 1}", model, ghost=k == 1, slot=slot)
for _ in range(rec.frames(4.0)):
run.frame(state_0)
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)
runs.append(run)
# metric: the largest position difference between the two runs at each frame
a, b = (np.stack(r.poses)[:, :, :3] for r in runs)
divergence = 1000.0 * np.linalg.norm(a - b, axis=2).max(axis=1)
for r in runs:
r.metrics = [{"divergence_mm": round(float(d), 4)} for d in divergence]
if __name__ == "__main__":
rec = Recorder()
simulate(rec, False, 0)
simulate(rec, True, 1)
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