Skip to content

determinism api - #1687

Merged
thowell merged 6 commits into
google-deepmind:mainfrom
thowell:determinism_api
Oct 22, 2026
Merged

thowell merged 6 commits into
google-deepmind:mainfrom
thowell:determinism_api

Conversation

@thowell

@thowell thowell commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

This PR introduces the API and foundation for deterministic simulation in MuJoCo Warp, along with deterministic kernel factories across all simulation subsystems and kernel analyzer enforcement for atomic operations. Non-deterministic execution in GPU physics arises from non-deterministic floating-point atomic reduction order, arbitrary contact generation order, and uncanonical constraint row ordering (island discovery and DOF/constraint island mappings are already deterministic following #1541). This change establishes the configuration bitmask and pipeline integration hooks to control these behaviors selectively while keeping the default non-deterministic path zero-overhead.

Determinism is configured via a new DeterminismType IntFlag enum (NONE=0, CONTACTS=1<<0, CONSTRAINT=1<<1, ATOMICS=1<<2, ALL=7) exposed on m.opt.deterministic and top-level mujoco_warp.DeterminismType. The option mirrors the warn_overflow property pattern, accepting boolean inputs (True maps to ALL, False to NONE), bitmask combinations, or string expressions through override_model (e.g. ["opt.deterministic=CONSTRAINT|ATOMICS"]).

Key changes included in this PR:

  • Added DeterminismType enum (NONE, CONTACTS, CONSTRAINT, ATOMICS, ALL) and Option.deterministic property/setter in types.py, with put_model initialization and string/bitmask parsing registered in io.py.
  • Outlined deterministic hook functions _sort_contacts in collision_driver.py and _sort_constraints in constraint.py, placed immediately above their callers and gated by DeterminismType.CONTACTS and DeterminismType.CONSTRAINT.
  • Converted all floating-point and order-dependent counter atomic operations across the physics pipeline into @cache_kernel factories that conditionally compile with wp.DeterministicMode.RUN_TO_RUN when DeterminismType.ATOMICS is active, maintaining enable_backward=False by default and passing 0 for deterministic_max_records dimension arguments on the default path to avoid redundant cache compilations. Subsystems covered include smooth dynamics, tree accumulations, momentum, tendon dynamics, transmissions, and sparse Cholesky backsubstitution (smooth.py), passive forces and flex body dynamics/bending (passive.py), sensors and contact matching (sensor.py), analytical derivatives (derivative.py), actuator forces (forward.py), solver linesearches, Hessians, and gradient reductions (solver.py), dense contact Jacobian velocity tile reductions and flex equality constraints (constraint.py), awake body/DOF compaction (sleep.py), flex BVH bounds and vertex normal accumulations (bvh.py, render_util.py), and model subtree mass accumulations (set_const.py).
  • Keyed @cache_kernel by (func.__module__, func.__name__) in warp_util.py so identically named kernel factories across modules do not collide.
  • Updated contrib/kernel_analyzer to version 0.9.0 with AST checks (MissingDeterministicFactory, MissingDeterministicMaxRecords, AtomicTargetConflict) enforcing deterministic factories, dynamic-loop deterministic_max_records bounds, and atomic target read/write separation on kernels and device functions containing atomic operations, enabled via --check_atomic=True in the CLI, .pre-commit-config.yaml, and VS Code extension (kernel-analyzer-0.9.0.vsix).
  • Added unit and pipeline tests in determinism_test.py and ast_analyzer_test.py covering all DeterminismType bitmask operations, hook preconditions, CUDA graph capture, static AST verification of every deterministic kernel factory, and bitwise reproducibility across all subsystems.

Future work:

  • Implementation of canonical contact sorting in _sort_contacts for DeterminismType.CONTACTS.
  • Implementation of canonical constraint row sorting in _sort_constraints for DeterminismType.CONSTRAINT.

@thowell thowell linked an issue Sep 22, 2026 that may be closed by this pull request
@thowell
thowell requested a review from eric-heiden September 23, 2026 19:15

@eric-heiden eric-heiden left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding the configurable determinism path. I reviewed b451d45 and found two correctness issues when ATOMICS is enabled, plus a smaller regression in analyzer suppression; the reproductions and suggested fixes are inline.

Validation on an NVIDIA L4 with Warp 1.15.0: the 50 API/analyzer tests and seven existing factor/solve tests passed, while focused reproductions exposed the issues below. The GPU failures also reproduce through the public solve_m and fwd_actuation APIs. I treated the contact, constraint, and island sorting hooks as the explicitly deferred work described in the PR.

"""Fused sparse backsubstitution: UP + diag + DOWN in one kernel."""
module_options = {"enable_backward": False}
if deterministic:
module_options["deterministic"] = wp.DeterministicMode.RUN_TO_RUN

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve the substitution dependencies in the deterministic path

Could we give this fused solver a separate deterministic implementation? Warp's RUN_TO_RUN mode records these atomic contributions and applies the reductions after the kernel finishes. This kernel needs the updated x_out values between substitution levels, before diagonal scaling, and during backward substitution, so _syncthreads() cannot preserve those dependencies under this mode.

On the L4 with Warp 1.15.0, a minimal two-DOF kernel reproduction with y=[2, 3], D=[1, 1], and an off-diagonal L coefficient of 0.5 returns [0.5, 2.0] instead of [0.5, 2.75]. I also reproduced incorrect results through solve_m on a 66-DOF model, which takes the sparse path.

Splitting the dependent stages/levels into separate launches, or using a fixed-order gather reduction that completes before the next stage, would preserve the solve. A numerical parity test with ATOMICS enabled would help cover this.

def _qfrc_actuator(deterministic: bool = False):
module_options = {"enable_backward": False}
if deterministic:
module_options["deterministic"] = wp.DeterministicMode.RUN_TO_RUN

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Size deterministic buffers for repeated atomics in dynamic loops

Could we pass a sufficient deterministic_max_records bound here and audit the other factories with dynamic loops? This kernel emits one contribution per moment nonzero, but the default code-generated record bound does not account for every iteration of range(rownnz).

I reproduced this through fwd_actuation with 1,024 worlds and one ball-joint motor (gear="1 1 1 0 0 0", ctrl=1). The normal path returns [1, 1, 1] in every world; enabling ATOMICS raises RuntimeError: Deterministic scatter buffer overflow. Capturing and replaying the same actuator kernel is more concerning: it returns without that exception, and only 1,024 of the 3,072 expected unit-valued entries are correct.

A model-derived upper bound on records per thread, or restructuring the launch to emit a bounded number of contributions per thread, should address this. A batched test that also exercises graph replay would catch the silent loss.

Comment on lines +631 to +636
start_line = iss.node.lineno
end_line = iss.node.end_lineno or start_line
matched_rules: Set[str] = set()
for l in range(start_line, end_line + 1):
if l in ignore_lines:
matched_rules.update(ignore_lines[l])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep inline ignores local for function-level diagnostics

Could we limit the expanded ignore range to the statement or diagnostic it belongs to? MissingModuleUnique is attached to a FunctionDef, whose end_lineno includes the entire kernel body. Collecting every ignore within that span means an unrelated inline ignore now suppresses the missing-module warning for the enclosing kernel.

For example, this reports MissingModuleUnique until the assignment's comment is added:

def factory():
    @wp.kernel
    def kernel(arr_out: wp.array[float]):
        arr_out[0] = 1.0  # kernel_analyzer: ignore

Keeping function-level suppression anchored to the function declaration, while allowing multiline atomic-call suppression across that call's own span, would retain the new functionality without weakening the existing check.

@thowell

thowell commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

@eric-heiden thank you for the review!

1.1 mujoco_warp/_src/smooth.py (discussion_r4190254448)

[P1] Preserve the substitution dependencies in the deterministic path

Could we give this fused solver a separate deterministic implementation? Warp's RUN_TO_RUN mode records these atomic contributions and applies the reductions after the kernel finishes. This kernel needs the updated x_out values between substitution levels, before diagonal scaling, and during backward substitution, so _syncthreads() cannot preserve those dependencies under this mode.

On the L4 with Warp 1.15.0, a minimal two-DOF kernel reproduction with y=[2, 3], D=[1, 1], and an off-diagonal L coefficient of 0.5 returns [0.5, 2.0] instead of [0.5, 2.75]. I also reproduced incorrect results through solve_m on a 66-DOF model, which takes the sparse path.

Splitting the dependent stages/levels into separate launches, or using a fixed-order gather reduction that completes before the next stage, would preserve the solve. A numerical parity test with ATOMICS enabled would help cover this.

Resolution:

  • Preserved _solve_LD_sparse_fused on the default non-deterministic path and added a separate deterministic path in _solve_LD_sparse when DeterminismType.ATOMICS is set.
  • The deterministic path launches _solve_LD_sparse_copy, _solve_LD_sparse_level per forward level, _solve_LD_sparse_diag, and _solve_LD_sparse_level per backward level (sharing the @wp.func helpers with _solve_LD_sparse_fused), so each level's RUN_TO_RUN reduction completes at kernel exit before the next stage reads x.
  • Added numerical parity and bitwise reproducibility coverage for the sparse block LDL path (M_BLOCK_SCALAR_MAX=0, M_BLOCK_DENSE_MAX=0) in determinism_test.py:test_tree.

1.2 mujoco_warp/_src/forward.py (discussion_r4190254453)

[P1] Size deterministic buffers for repeated atomics in dynamic loops

Could we pass a sufficient deterministic_max_records bound here and audit the other factories with dynamic loops? This kernel emits one contribution per moment nonzero, but the default code-generated record bound does not account for every iteration of range(rownnz).

I reproduced this through fwd_actuation with 1,024 worlds and one ball-joint motor (gear="1 1 1 0 0 0", ctrl=1). The normal path returns [1, 1, 1] in every world; enabling ATOMICS raises RuntimeError: Deterministic scatter buffer overflow. Capturing and replaying the same actuator kernel is more concerning: it returns without that exception, and only 1,024 of the 3,072 expected unit-valued entries are correct.

A model-derived upper bound on records per thread, or restructuring the launch to emit a bounded number of contributions per thread, should address this. A batched test that also exercises graph replay would catch the silent loss.

Resolution:

  • Updated _qfrc_actuator in forward.py to set module_options["deterministic_max_records"] = nv when deterministic=True, and audited all 85 deterministic kernel factories across the codebase so every factory executing atomics inside dynamic (non-unrolled) loops sets a model/data-derived deterministic_max_records bound.
  • Added the MissingDeterministicMaxRecords static analysis rule in ast_analyzer.py to enforce that any deterministic factory with atomics inside a dynamic loop sets "deterministic_max_records".
  • Added multi-world CUDA graph capture and replay verification in determinism_test.py:test_graph_capture.

1.3 contrib/kernel_analyzer/kernel_analyzer/ast_analyzer.py (discussion_r4190254457)

[P2] Keep inline ignores local for function-level diagnostics

Could we limit the expanded ignore range to the statement or diagnostic it belongs to? MissingModuleUnique is attached to a FunctionDef, whose end_lineno includes the entire kernel body. Collecting every ignore within that span means an unrelated inline ignore now suppresses the missing-module warning for the enclosing kernel.

For example, this reports MissingModuleUnique until the assignment's comment is added:

def factory():
    @wp.kernel
    def kernel(arr_out: wp.array[float]):
        arr_out[0] = 1.0  # kernel_analyzer: ignore

Keeping function-level suppression anchored to the function declaration, while allowing multiline atomic-call suppression across that call's own span, would retain the new functionality without weakening the existing check.

Resolution:

  • Scoped _is_ignored in ast_analyzer.py so ast.FunctionDef diagnostics (MissingModuleUnique, InvalidParamOrder, etc.) only inspect lines from the first decorator through the def header (iss.node.body[0].lineno - 1), while statement/call diagnostics inspect [iss.node.lineno, iss.node.end_lineno].
  • Added regression test coverage in ast_analyzer_test.py:test_ignore_and_scoping.

2. Additional Changes

2.1 Determinism API & Model Overrides (types.py, io.py)

  • Hardened Option.deterministic setter validation to accept bool, np.bool_, None, and integer/enum bitmasks via operator.index, mask negative bitwise complements (~DeterminismType.CONTACTS) against DeterminismType.ALL, and raise ValueError on out-of-range bitmasks.
  • Updated override_model so string overrides on enum flag fields support "true"/"false", numeric strings, and bitwise negation (~FLAG) starting from the field's current value rather than 0.

2.2 Complete RUN_TO_RUN Atomic & Counter Coverage

  • Cross-thread += reductions converted to wp.atomic_add factories:
    • accumulate_flex_vertex_normals in bvh.py
    • _tendon_actuator_force in sensor.py
    • _update_gradient_JTCJ_dense in solver.py
  • Two-pass RUN_TO_RUN counter atomics enabled for deterministic Data indices:
    • _transmission in smooth.py (d.moment_rowadr, d.moment_colind, d.actuator_moment)
    • _contact_match_builder in sensor.py (d.sensordata for contact sensors)
    • _update_constraint_efc in solver.py (ctx.quad_changed_ids)
    • _update_sleep_bodies and _update_sleep_dofs in sleep.py (d.body_awake_ind, d.dof_awake_ind), split from their count reset kernels to avoid read-after-atomic conflicts.
  • Deterministic tactile sensor contact collection (sensor.py):
    • Split tactile preprocessing into _mark_tactile_contacts (wp.atomic_or bitset marking) and _collect_tactile_contacts_kernel (ordered scan over marked geoms).
  • Eliminated RUN_TO_RUN read-after-atomic / AugAssign conflicts:
    • _linear_momentum and _angular_momentum in smooth.py compute updated bodyid values in registers before writing to bodyid and accumulating into pid.
    • _accumulate_subtreemass in set_const.py and deriv_rne_cfrcbody_backward in derivative.py separate _in and _out parameters across tree levels.
    • Split multi-stage atomic + post-processing kernels into separate launches: _qLD_acc + _qLD_finalize, _solve_LD_sparse_x_acc + _solve_LD_sparse_y, _subtree_vel_forward in smooth.py, and _flex_group_bound + _flex_group_bound_finalize in bvh.py.

2.3 deterministic_max_records Sizing & Kernel Cache Keying (warp_util.py, passive.py, derivative.py)

  • Added FLEX_INTERP_MAX_NODES = 27 in types.py and standardized small bounded loops in passive.py and derivative.py to use constant range(N) bounds so Warp unrolls them and computes scatter_records_per_thread automatically.
  • Passed 0 for deterministic_max_records-only factory arguments when deterministic=False (m.nv if det_atomics else 0) to prevent unnecessary kernel recompilations across models on the default path.
  • Namespaced cache_kernel keys by (func.__module__, func.__name__) so identically named factories across modules do not collide in _KERNEL_CACHE.
  • Guarded eff_prec_fold in derivative.py and init_context in solver.py with not m.efm0_active.

2.4 Comprehensive Determinism Test Suite (determinism_test.py, types_test.py)

  • Moved determinism tests out of types_test.py into determinism_test.py (80 tests), verifying bitwise reproducibility, numerical parity against DeterminismType.NONE, default-path cache key invariance, CUDA graph capture/replay, dynamic mode toggling, and 100% launch coverage across all 85 deterministic kernel factories.

@thowell
thowell marked this pull request as ready for review October 15, 2026 00:24

@eric-heiden eric-heiden left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates! I re-reviewed bd12638 and confirmed that all three findings from the previous review are addressed. The separate sparse-solve stages preserve the substitution dependencies, the dynamic atomic loops now have record bounds, and inline analyzer ignores no longer suppress unrelated function-level diagnostics. The earlier public-API reproductions also pass now.

@eric-heiden eric-heiden left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving this as the API and deterministic-atomics foundation, with contact, constraint-row, and island sorting remaining the explicitly described follow-up work.

I also exercised this PR through Newton's SolverMuJoCo at 18abe5585, using an NVIDIA L4 and MuJoCo 3.16.1.dev999309006:

  • On Warp 1.17.0, a 67-DOF branching articulation with contacts disabled was bitwise repeatable with this PR's flag enabled: 16 worlds, 64 steps, three rollouts from identical initial state, comparing body/joint poses, velocities, and accelerations.
  • With both Newton's deterministic=wp.DeterministicMode.RUN_TO_RUN and solver.mjw_model.opt.deterministic = mjw.DeterminismType.ALL, a six-box stack using deterministic Newton contacts and a driven quadruped also produced repeatable results. The stack/Newton-contact and quadruped/MuJoCo-contact CUDA graph trajectories matched ordinary stepping bitwise, with finite states and no overflow flags. These are checks of the combined configuration, rather than a general contact-determinism guarantee from this PR alone.

Non-blocking FYI for Warp 1.19 compatibility: Newton's lockfile at that commit uses 1.19.0.dev20261020. On that build, enabling this PR's flag causes both the articulation and quadruped to fail on the first step with:

WarpDeterminismError: Deterministic mode cannot run kernel '_subtree_com_acc__locals__kernel'.
Cause: argument 'subtree_com_out' receives deterministic atomic updates, but it overlaps
argument 'subtree_com_in', which is read directly by the kernel.

The launch in smooth.py passes d.subtree_com as both arguments, which Warp 1.19's runtime alias validation rejects. The normal-mode articulation control runs successfully on the same build. Flagging this for a compatibility follow-up; it does not block my approval of the scoped API work here.

…erministic in create_render_context, and remove DeterminismType.ISLANDS
@thowell

thowell commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

thanks @eric-heiden!

made a few additional changes and updated the pr description.

1. Remove DeterminismType.ISLANDS and island._sort_islands

Following #1541, island discovery (direct_dsu) and DOF/constraint island mappings (compute_island_mapping) are always deterministic on main (roots are hooked to minimum tree indices and numbered in ascending tree order, and mappings are computed without order-dependent atomics).

  • mujoco_warp/_src/types.py: Removed ISLANDS from DeterminismType, leaving NONE = 0, CONTACTS = 1 << 0, CONSTRAINT = 1 << 1, ATOMICS = 1 << 2, and ALL = CONTACTS | CONSTRAINT | ATOMICS (7).
  • mujoco_warp/_src/island.py: Removed the _sort_islands stub and DeterminismType.ISLANDS check from island().
  • mujoco_warp/_src/determinism_test.py: Updated test_flags, test_option, test_option_invalid, test_override_model, test_override_model_invalid, test_hooks, and test_hook_preconditions for the 3-bit DeterminismType mask.

2. Tighten Tendon & Actuator deterministic_max_records Bounds

Replaces loose nv-based deterministic_max_records bounds with tight per-thread upper bounds based on m.max_ten_J_rownnz (maximum nonzeros per tendon Jacobian row), reducing Warp RUN_TO_RUN scratch buffer allocations and avoiding int32 workspace capacity overflow on high-DOF models under CUDA graph capture:

  • mujoco_warp/_src/smooth.py:
    • _spatial_site_tendon: 2 * nv $\rightarrow$ 2 * max_ten_J_rownnz
    • _spatial_geom_tendon: 4 * nv $\rightarrow$ 4 * max_ten_J_rownnz
  • mujoco_warp/_src/derivative.py:
    • _eff_tendon_shift: nv $\rightarrow$ max_ten_J_rownnz
    • _eff_add_tendon_qH_diag: nv $\rightarrow$ max_ten_J_rownnz
    • _eff_mul_add_tendon: nv $\rightarrow$ max_ten_J_rownnz
    • _eff_fold_tendon: 3 * nv $\rightarrow$ 3 * max_ten_J_rownnz
  • mujoco_warp/_src/solver.py:
    • _add_tendon_metric_dense: nv * (nv + 1) // 2 $\rightarrow$ max_ten_J_rownnz * (max_ten_J_rownnz - 1) // 2 (since diagonal entries have no atomic update and the loop runs over nnz nonzeros)
    • _add_actuator_metric_dense: nv * (nv + 1) // 2 $\rightarrow$ nv * (nv - 1) // 2 (since only strict lower-triangle pairs j < i emit atomic updates)

3. Forward deterministic in render_util.create_render_context

  • mujoco_warp/_src/render_util.py: Added deterministic: bool = False parameter to create_render_context and forwarded deterministic=bool(deterministic) to bvh.build_flex_bvh so callers creating render contexts for deformable models can enable deterministic flex vertex normal accumulation during BVH construction.
  • mujoco_warp/_src/determinism_test.py: Added create_render_context(..., deterministic=True) verification in test_subsystems.

@thowell

thowell commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for catching this on Warp 1.19! 90bb78ea updates the affected DeterminismType.ATOMICS code paths so they pass Warp 1.19's overlap and direct-write validation while leaving the default (deterministic=False) path unchanged.


Changes in 90bb78ea

  1. Non-overlapping input buffers for level-by-level kernels:
    • In Warp 1.19, wp.launch checks pointer extents at launch time and raises WarpDeterminismError if an argument receiving deterministic atomic updates overlaps an input argument read directly by the kernel.
    • When DeterminismType.ATOMICS is active, the level-by-level launches in smooth.com_pos (_subtree_com_acc), smooth.crb (_crb_accumulate), smooth._factor_i_sparse (_qLD_acc), smooth._rne_cfrc_backward (_cfrc_backward), smooth._solve_LD_sparse (_solve_LD_sparse_level), smooth.subtree_vel (_linear_momentum, _angular_momentum), derivative.deriv_rne_vel (deriv_rne_cfrcbody_backward), and set_const.set_const_fixed (_accumulate_subtreemass) copy the current state into a separate scratch input buffer (wp.copy) before each level launch so the read input and atomic output arguments never alias:
det_atomics = bool(m.opt.deterministic & DeterminismType.ATOMICS)
subtree_com_in = wp.empty_like(d.subtree_com) if det_atomics else d.subtree_com
for i in reversed(range(len(m.body_tree))):
  body_tree = m.body_tree[i]
  if det_atomics:
    wp.copy(subtree_com_in, d.subtree_com)
  wp.launch(
    _subtree_com_acc(det_atomics),
    dim=(d.nworld, body_tree.size),
    inputs=[m.body_parentid, subtree_com_in, body_tree],
    outputs=[d.subtree_com],
  )
  1. Separated direct writes on deterministic atomic targets:
    • Warp 1.19 also rejects kernels that perform direct indexed stores (arr[...] = ...) to an array that receives deterministic atomic updates in the same kernel.
    • In _qLD_acc, _linear_momentum, and _angular_momentum, direct element writes to the atomic target are statically guarded onto the non-deterministic branch (if wp.static(not deterministic):) and executed via separate kernels (_qLD_scale, _linear_momentum_div, _angular_momentum_body) when DeterminismType.ATOMICS is enabled:
@cache_kernel
def _linear_momentum(deterministic: bool = False):
  module_options = {"enable_backward": False}
  if deterministic:
    module_options["deterministic"] = wp.DeterministicMode.RUN_TO_RUN

  @wp.kernel(module="unique", module_options=module_options)
  def kernel(
    # Model:
    body_parentid: wp.array[int],
    body_subtreemass: wp.array2d[float],
    # Data in:
    subtree_linvel_in: wp.array2d[wp.vec3],
    # In:
    body_tree_: wp.array[int],
    # Data out:
    subtree_linvel_out: wp.array2d[wp.vec3],
  ):
    worldid, nodeid = wp.tid()
    bodyid = body_tree_[nodeid]
    linvel = subtree_linvel_in[worldid, bodyid]
    if bodyid:
      pid = body_parentid[bodyid]
      wp.atomic_add(subtree_linvel_out, worldid, pid, linvel)
    if wp.static(not deterministic):
      subtree_linvel_out[worldid, bodyid] = linvel / wp.max(
        MJ_MINVAL, body_subtreemass[worldid % body_subtreemass.shape[0], bodyid]
      )

  return kernel
  1. Preallocated scratch for CUDA graph capture (wp.capture_while):
    • Because smooth.solve_LD and smooth.solve_m are called inside _update_gradient (during SolverType.CG iterations) and derivative.eff_prec (during eff_solve PCG iterations), which may execute inside wp.capture_while, solve_ld_scratch is preallocated on SolverContext (when alloc_mgrad, d.qLD.shape[1] > m.qLD_block_total, and DeterminismType.ATOMICS are active) and before _pcg_iteration in eff_solve, avoiding allocations inside captured loops.

@eric-heiden eric-heiden left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix, Taylor! Retested 9c9ec5c with Newton on Warp 1.17 and 1.19 dev: the reported _subtree_com_acc aliasing error is resolved, and the articulation and Newton-contact quadruped are bitwise repeatable with both eager stepping and CUDA graphs (16 worlds, 64 steps, 3 repeats). Looks good to me!

@thowell
thowell merged commit 86e2fae into google-deepmind:main Oct 22, 2026
24 of 31 checks passed
Codnay added a commit to Codnay/mujoco_warp that referenced this pull request Oct 23, 2026
Resolve the conflicts with main's determinism API (google-deepmind#1687), fast-math line
search (google-deepmind#1803) and the main/deepmind sync (google-deepmind#1834).

main turned the Hessian metric kernels (_add_tendon_metric_dense,
_add_actuator_metric_dense, _add_efmK_metric_dense,
_add_flexcon_metric_dense) and _update_gradient_JTCJ_dense into
@cache_kernel factories with deterministic_max_records sizing. The
float64 scalar type now goes into those factories as an htype argument
(diagonal too, for the tendon and actuator metrics), placed before the
trailing deterministic=False that determinism_test requires; the PR's
*_typed variants of these kernels are gone. _JTDACJ_sparse and
_update_gradient_h_incremental_sparse take htype between max_records
and deterministic, and their call sites (including main's deterministic
re-creation) pass it positionally, since cache_kernel takes no keywords.
JTCJ keeps main's wp.atomic_add, in htype.

Kernels main did not touch keep the PR design (factories over htype,
module-level names are the float32 instance).
johnnynunez added a commit to johnnynunez/mujoco_warp that referenced this pull request Oct 26, 2026
Fill the CONTACTS and CONSTRAINT hooks introduced by google-deepmind#1687 without changing atomic reductions or the NONE path. Canonicalize contact features and geometry, permute constraint rows with their sparse Jacobian metadata, and repair contact and JTDAJ references independently of incoming contact order.

Add permutation, CPU parity, interleaved-world, mixed-dimension, overflow, flex, SDF, and CUDA capture regressions. Credit Mark Yang (@mar-yan24) for the original joint determinism work that motivated this follow-up.
@johnnynunez johnnynunez mentioned this pull request Nov 10, 2026
thowell pushed a commit that referenced this pull request Nov 11, 2026
* Implement canonical contact and constraint sorting

Fill the CONTACTS and CONSTRAINT hooks introduced by #1687 without changing atomic reductions or the NONE path. Canonicalize contact features and geometry, permute constraint rows with their sparse Jacobian metadata, and repair contact and JTDAJ references independently of incoming contact order.

Add permutation, CPU parity, interleaved-world, mixed-dimension, overflow, flex, SDF, and CUDA capture regressions. Credit Mark Yang (@mar-yan24) for the original joint determinism work that motivated this follow-up.

* Preserve equality force semantics during constraint sorting

Order equality rows by subtype and ID while preserving each connect/weld group's semantic component offsets. Keep physical Jacobian tie-breaks for repeated flex IDs and leave force reconstruction unchanged.

Add CPU-parity regressions for solved-state sorting and full forward with asymmetric loads, interleaved equality types, dense/sparse Jacobians, heterogeneous worlds, reversed groups, and JTDAJ block support.

* Make sorting regression snapshots portable to CPU

Copy retained NumPy snapshots before mutating Warp storage so CPU assertions compare independent data rather than aliased views. Use moderate binary-exact equality loads and velocities without relaxing the standard parity tolerance or changing production sorting.

* Refine sorting kernels and regression coverage after review

* Initialize contact addresses and imported collision IDs

* Address the second review round of the sorting kernels and tests

Compare contacts lazily through a `_ContactKeys` struct instead of materializing a 43-column key buffer, evaluate the comparison once per merge step, test raw bits before computing float keys, gather rows as `uint32` words, and name kernel parameters after their Model and Data fields. `_remap_blocks` reads the head row of each block directly and `constraints` always ranks contacts.

Call `sort.constraints` from `make_constraint` instead of going through `_sort_constraints`. Clear only the contact address components that assembly does not write, so the addresses of active contacts are no longer written twice.

Move the contact-address and imported-contact regressions into `sort_test.py`, replace attribute reflection and random permutations with explicit fields and fixed permutations, and replay captured contact and constraint sorts across input orders and active counts. JTDAJ blocks are asserted to tile every row except the DOF friction rows, which have no block on current main.

`test_contact_permutation` still fails at this commit because main now stores `contact.surfacevel`; the next commit adapts sorting to the merged main.

* Follow upstream contact and constraint references through sorting

Merging main brought three things that sorting has to carry. `contact.surfacevel` is computed before the contact sort, so it is permuted with the other contact fields and used as a tie breaker. `dof_frictionadr` holds the row of each DOF friction constraint for the sparse Newton Hessian diagonal, so it is remapped through the row permutation. Flex strain rows are now allocated as one JTDAJ block per cell, so they keep their mode order within the cell like connect and weld components, which keeps the head row of each block first.

Add regressions for the friction addresses and the flex strain blocks, and a surface velocity tie-break case.

* Sort contacts and constraint rows with fewer launches

The deterministic sort launched one kernel per merge pass and one clone
and one gather per contact or constraint field: 80 sorting launches per
step on the humanoid benchmark with 8192 worlds.

Contacts are now counted per world, grouped and placed by rank, moving
every field in the same launch. The second contact sort of the constraint
rows is gone: one launch checks whether the contact buffer is already in
canonical order, and only ranks the contacts if it is not. The row sort
starts from a counting launch over blocks of rows, and the row fields and
the Jacobian move through one packed copy that skips the rows that are
already in place. Buffers larger than the static limits fall back to the
merge passes.

Sorting launches per step go from 80 to 15 (humanoid) and from 99 to 23
(three humanoids). With 8192 worlds, CONTACTS|CONSTRAINT costs 18.4% of
the steps per second of the humanoid benchmark instead of 33.6%, and
14.9% of three humanoids instead of 29.1%. The sorted output is bitwise
identical to the previous implementation.

* Address review: sort tests, typed sort structs, stale Jqvel

sort_test.py: test_contact_rank_paths and test_sparse_offsets_paths build
the unsorted state once, snapshot Data and restore it before each path, so
both paths sort the same input instead of the output of two forward calls,
whose efc.Ma differs by one ULP.

sort.py: the fields of _ContactRows and _ConstraintRows are assigned
directly, the contact copy is allocated as a _ContactRows instance by
_empty_contact_rows, and _rank and _merge take the type of their keys from
compare.input_types["keys"]. This removes getattr, setattr,
SimpleNamespace, Any, _CONTACT_FIELDS and _CONSTRAINT_FIELDS.
test_contact_bucket_ties clones the contact into a types.Contact.

sort_test.py: test_permutation_blocks, test_contact_paths and
test_sparse_move use modular index arithmetic instead of
np.random.default_rng.

collision_driver.py: collision calls sort.contacts(d) and _sort_contacts
is removed. sort_test.py and the spies of determinism_test.py follow.

collision_flex.py: the duplicate reset of contact_efc_address_out is
removed, so the file matches main.

sort.py: _constraint_primary zeroes efc.Jqvel of the rows that are not
contact rows. Assembly never writes it for them, so sorted rows carried
the values of earlier slots. test_noncontact_jqvel covers it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Determinism

2 participants