Repository navigation
Deterministic contact and constraint sorting using the new API - #1422
Conversation
355b588 to
5edce71
Compare
|
Test and benchmark results on this branch as pushed (RTX PRO 6000, CUDA 13.3). Full test suite: Determinism: the included determinism_test.py verifies qacc/qpos/qvel are bitwise identical across repeated runs over 1000 graph-captured steps. Forward-step throughput with
This is the goal of this PR: bitwise-deterministic stepping as an opt-in flag, with the default path unchanged within run-to-run noise (the (Edited: an earlier version of this comment claimed the deterministic gathers were faster than the atomic reductions; that comparison was against the naive deterministic kernels, not the atomics — see the linked comment for the corrected numbers.) |
|
awesome work! |
I already have differentiability in the other PR. I’d also like to add these two features, since I think they’re both important for the research community. Let me know if you have any comments or if you spot anything wrong. I tried to make the implementation as clean as possible. |
|
This is awesome stuff indeed, I really like it. Question about the performance numbers though - I though we might as well do the precomputation for JTDAJ always based on your claims, but I cannot reproduce that it is faster. I used three_humanoids to test that, what model did you measure this on? |
|
same question about your testspeed numbers - what are you actually comparing there? Is this just to show that deterministic = off did not get any slower? Would love to have a good set of numbers to characterize the overhead of deterministic = on. |
|
Good catch, and apologies, it was my own test environment with the previous PRs.... Let me explain: The perf claim in the original description was wrong, and you were right not to be able to reproduce it. The "-35% / -71%" figures came from the optimization history in mar-yan24#8: we compared the segmented-gather version of the deterministic reductions against the first naive O(nefc)-scan version of the same deterministic kernels, on a local 20-body contact pile scene. So we describe how much the det-mode kernels improved during development, not a win over the atomic baseline. The deterministic gathers are slower than the atomics everywhere, as you'd expect... there is no case where you'd want the precomputation with deterministic off. I've rewritten the PR description accordingly. Here are fresh numbers on three_humanoids so we're on the same model (mjwarp-testspeed, graph captured, nstep=1000, njmax=160, RTX PRO 6000 Blackwell sm_120, CUDA 13.3):
On your second question: yes, the testspeed numbers in the earlier comments were exactly that, checking that deterministic=off doesn't regress. The off path compiles to the original atomic kernels via wp.static dispatch, and the table bears that out: det=off matches upstream main within run-to-run noise at every world count (I re-ran the nworld=1 pair 3x to confirm the small deltas are noise). For det=on, the step-level overhead is larger than the solve-level overhead because the count-scan-emit row allocation and the contact sort also run outside the solver, and the per-world exclusive scans serialize at high world counts. If the high-nworld overhead matters for the RL use case, the clearest headroom is making those scans tile-parallel and giving the JTDAJ gather a precomputed (row, col) pair index.... happy to pursue that in this PR or a follow-up, whichever you prefer. |
|
Thanks for the update, makes a lot of sense. I'd say the JTDAJ follow-up is definitely material for a follow-up PR, and this PR should focus on correctness while not regressing the "off" path, which you already showed. |
thanks! Let me do that... Really appreciate it the feedback.. |
|
Follow-up PR with the first round of deterministic-mode overhead reductions is up as a draft: #1425 (JTDAJ merge-intersection gather + parallel contact world boundaries; det=on overhead at 8192 worlds goes from -81% to -66%, bitwise determinism unchanged). It's stacked on this branch and I'll rebase it once this lands. |
|
couple of questions:
thanks! |
@eric-heiden @adenzler-nvidia are the expert here… i think that in that thread @eric-heiden answered me that probably we get better performance if we do it in in subset of kernels. related to the other questions, let me check in detail, anyways i haver ready the other PR getting better performance than here. |
|
Following up on the first question properly — I went and actually ran NVIDIA/warp#1355 on mujoco_warp rather than leaving it at "not merged." I built mmacklin/warp@warp-deterministic locally (reports as 1.15.0.dev0), ran mjwarp The headline: NVIDIA/warp#1355 does make unmodified mjwarp bitwise-reproducible — so the approach genuinely works end to end. But applied wholesale to the solver it isn't practical on this model/hardware yet, for two concrete reasons that both surfaced as hard errors. Matched run at 256 worlds (bit_stable = sha1(qacc,qpos,qvel) identical across two in-process runs):
So at 256 worlds this PR is ~1.8x over baseline with graph capture intact and no extra memory, whereas NVIDIA/warp#1355 is ~37x with graph capture lost and +44 GB of scratch. Why NVIDIA/warp#1355 struggles specifically on the solver:
This PR sidesteps both by exploiting that J is fixed for the duration of a solve: it builds one compact per-dof column index (~87 MB at 8192 worlds) once per step and gathers in a fixed order, which stays capture-safe and costs ~1.8x at 256 worlds / ~3x at 8192 rather than 37x. My read is unchanged but now backed by numbers: the two are complementary — NVIDIA/warp#1355 is the zero-rewrite general tool, this PR is the domain-specific fast path for the solver. The genuinely useful next step is feeding these |
|
Rebased onto current main to clear the merge conflict, which pulled in mujoco v3.9.0 (#1428), the flex collision changes (#1421), the aloha cloth benchmark update (#1416) and per-world skybox (#1429). The only textual conflict was in constraint.py and resolved cleanly: main added the has_flex branch around contact initialization, so the deterministic count-scan-emit row allocation is threaded through both the flex and non-flex contact init kernels. Merging those changes surfaced a real crash in deterministic mode that I also fixed here. The fix guards the flex, elem and vert gathers behind a runtime shape check so the kernel never dereferences an empty array, and it also carries the new elem field through the sort so flex contact element ids stay consistent after reordering. Verified with compute-sanitizer memcheck (0 errors) and the full determinism, constraint and collision_driver suites on both GPU and CPU: determinism 48 passed, constraint plus collision_driver 119 passed. |
|
Rebased onto current main via merge (the conflicts were against the new flexstrain constraints, the compact sleep solve, and the stable-state fast path; the deterministic gathers cover the standard solve and the compact sleep path keeps upstream's kernels) and pushed e4d4a0a, which closes coverage gaps found while stress-testing the deterministic machinery:
Determinism tests grew from 48 to 55; the full suite passes (1250 passed, 18 skipped). |
Brings the deterministic-mode branch (google-deepmind#1422 plus the inertia-matrix race fix) up to current main, which it had not been synced with since google-deepmind#1530. Conflict resolutions: - constraint.py: keep google-deepmind#1422's `deterministic` factory argument alongside main's `grid_stride=False`. Main's passive adhesion (google-deepmind#1550) makes a contact active when it is penetrating or adhesive, so google-deepmind#1422's per-contact row counters (`_efc_contact_count`, `_efc_contact_count_flex`) now take `flg_adhesion` and `adhesion` and apply the same rule as `_efc_contact_init`; otherwise the precomputed row offsets would disagree with the rows actually written. - solver.py: main replaced `_JTDAJ_sparse` with `_JTDACJ_sparse`, which adds elliptic-cone curvature terms. The deterministic `_JTDAJ_sparse_det` gather covers only the quadratic J^T D J term, so it is used for non-elliptic cones and elliptic cones fall back to `_JTDACJ_sparse`. Dropped the flex diagonal preconditioner kernels that main reverted in google-deepmind#1576. - collision_flex.py: take main's rewritten flex pipeline (google-deepmind#1496, google-deepmind#1583, google-deepmind#1620); google-deepmind#1422's tie-break in the old `_filter_flex_candidates_sorted` has no counterpart there, and the flex cases in determinism_test.py pass without it. - io.py, types.py, pyproject.toml: union of both sides (`opt.deterministic` plus main's new options and the `warn_overflow` bitmask property; main's version and dependency floors).
…erministic arithmetic Adapt contact and constraint ordering from google-deepmind#1422 (a93c6d3); preserve current-main adhesion and discrete integration, canonicalize flex candidates before filtering, and order sparse Hessian block inputs.
|
@mar-yan24 @johnnynunez if you are interested in completing the determinism feature please rebase this pr on main containing #1687 and focus the updated pr on the contact sorting and constraint sorting. these sorting routines should follow the implemented determinism api outline. please let us know if you have any questions. thanks! |
There was a problem hiding this comment.
lets move this code to collision_driver_test.py
| mjm, mjd, m, _ = test_data.fixture( | ||
| xml=""" | ||
| <mujoco> | ||
| <option jacobian="sparse" cone="elliptic" solver="Newton" tolerance="1e-10"/> |
There was a problem hiding this comment.
we can drop solver=Newton since this is the default solver
| <option jacobian="sparse" cone="elliptic" solver="Newton" tolerance="1e-10"/> | ||
| <worldbody> | ||
| <geom type="plane" size="2 2 .1"/> | ||
| <body pos="0 0 .09"><freejoint/><geom type="box" size=".1 .1 .1"/></body> |
| from mujoco_warp._src import types | ||
|
|
||
|
|
||
| def _import_contacts(nworld): |
|
a few more recommendations focused on sort.py and sort_test.py
|
|
lets also test graph capture here
|
|
@johnnynunez what is the status of this pr? thanks! |
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.
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.
|
@thowell Updated in |
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.
|
Thanks @johnnynunez! A few items to resolve before merging: 1.
|
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.
|
Thanks @thowell, all six are resolved in 6e3c073:
The sorted output is bitwise identical to the previous head. |
thowell
left a comment
There was a problem hiding this comment.
lgtm, thanks @johnnynunez!
|
@johnnynunez we just need to resolve the merge conflict CausePR #1422 adds Conflict Hunkfrom mujoco_warp._src import render_util
from mujoco_warp._src import set_const
<<<<<<< feature/determinism
from mujoco_warp._src import sort
from mujoco_warp._src import util_pkg
=======
>>>>>>> main
from mujoco_warp._src import warp_utilResolutionKeep from mujoco_warp._src import render_util
from mujoco_warp._src import set_const
from mujoco_warp._src import sort
from mujoco_warp._src import warp_util(Whether resolved via GitHub's web conflict editor, |
Conflict in determinism_test.py: main removed the unused util_pkg import next to the sort import added here. The sort import is kept.
|
Done and thank your for your patient @thowell |
|
@johnnynunez thanks for getting this feature over the finish line 🚀 |
|
@mar-yan24 thanks for your contributions to this feature! 🚀 |
Implements the CONTACTS and CONSTRAINT sorting hooks from #1687, keeping the flags independent and upstream atomics unchanged. The review follow-up uses explicit fields and lazy comparisons, preserves
efc_addressin permutations, resets reused contact addresses and initializes imported collision IDs.At
831a476, 2,359 CPU and 2,366 GPU tests pass; pre-commit passes. Standalone assembly still sorts external contacts. This does not guarantee complete stepping determinism.Adhesion and sensor work is separate in #1860 and #1861 (drafts); FLEXSTRAIN already has #1793. Original joint work with @mar-yan24.