Skip to content

Make ColliderCouplingSet iteration deterministic (use DeterministicState, as HGrid already does) - #71

Open
uopoql wants to merge 1 commit into
dimforge:masterfrom
uopoql:deterministic-coupling-set
Open

Make ColliderCouplingSet iteration deterministic (use DeterministicState, as HGrid already does)#71
uopoql wants to merge 1 commit into
dimforge:masterfrom
uopoql:deterministic-coupling-set

Conversation

@uopoql

@uopoql uopoql commented Aug 27, 2026

Copy link
Copy Markdown

Problem

salva's rapier integration is not deterministic across processes when any collider is coupled
to a fluid. Two runs of the same scene, same build, same inputs, single-threaded, produce two
different fluid states — and they do it as a coin flip, not a drift: repeated runs land on one of
exactly two outcomes.

This is visible with RAYON_NUM_THREADS=1, so it is not the parallel float-reduction
non-determinism that a multi-threaded solver is expected to have. It is a plain ordering
difference, and it makes replay, lockstep, regression-hashing and cross-machine reproduction
impossible for any scene that uses ColliderCouplingSet.

Mechanism

src/integrations/rapier/fluids_pipeline.rs (around line 174):

pub struct ColliderCouplingSet {
    /// The hashmap containing mappings from ColliderHandle to ColliderCouplingEntry
    pub entries: HashMap<ColliderHandle, ColliderCouplingEntry>,
}

That is std::collections::HashMap with the default RandomState, whose keys are drawn from the
OS at first use — so the hasher differs per process. ColliderCouplingEntry values are then
iterated to build boundary particles and to apply the coupling forces back to the colliders, and
floating-point addition is not associative: a different visit order is a different sum, and the
different sum propagates through the pressure solve into a different fluid state.

Nothing here needs a random hasher. The map is keyed by ColliderHandle, populated by the user's
own register_coupling calls, and never exposed to adversarial input, so the DoS resistance
RandomState exists to provide buys nothing and costs reproducibility.

Fix

Use the crate's own deterministic hasher — the one HGrid is already built on.
DeterministicState is defined at src/geometry/hgrid.rs (a BuildHasher returning
FnvHasher::with_key(1820)) and is already what keeps the neighbour grid's iteration stable. The
patch re-exports it from src/geometry/mod.rs and hands it to ColliderCouplingSet::entries:

-    pub entries: HashMap<ColliderHandle, ColliderCouplingEntry>,
+    pub entries: HashMap<ColliderHandle, ColliderCouplingEntry, DeterministicState>,
...
-            entries: HashMap::new(),
+            entries: HashMap::with_hasher(DeterministicState),

No new dependency, no new type, no new concept: it applies an idiom the crate already established
to the one map that was missed. entries is a public field, so this is technically a
source-breaking change for anyone who names its type or assigns a HashMap<_, _> to it directly;
constructing through ColliderCouplingSet::new() and using register_coupling /
unregister_coupling is unaffected.

Evidence

Built from the 0.10.0 sources with and without the three lines, everything else — toolchain,
crate graph, features, LTO settings — held identical, and driven through the rapier integration
from a host application that hashes the whole fluid state at the end of a fixed-length run.

1. The discriminator: a splash scene where fluid meets a coupled collider, 10 runs,
RAYON_NUM_THREADS=1, final-state hash per run.

build distinct outcomes over 10 runs
stock 275860430f339c4ae x7, 46cc5bf09af5bcfd x3
patched 175860430f339c4ae x10

The patched build was then run as two independent trials of 10: 20/20 identical. The surviving
bit-pattern is the stock build's own majority branch, i.e. the patch does not invent a new
trajectory — it pins the existing one to the insertion order.

2. A control build isolates the three lines as the cure. A second build on the same
toolchain with only those three lines reverted to stock reproduces the stock split exactly —
75860430f339c4ae x7 / 46cc5bf09af5bcfd x3 over 10 runs. Toolchain and build flags are
therefore neutral on the outcome; the patch alone collapses the two futures into one.

3. The physics is unchanged. A drain scene with no coupled collider in contact runs 5/5 at
4bc3ca7c0d2273f1 on the patched build — the same hash the stock build produces. The patch
removes a branch; it does not move the fluid.

4. Cost. ms per simulated second, median of 3, splash at 1 thread: control (unpatched, same
toolchain) 265.1, patched 264.8. Within noise — an FNV hash of a ColliderHandle against
RandomState's SipHash is, if anything, cheaper.

Scope, stated plainly

This fixes one source of cross-process non-determinism. It does not make salva deterministic
under rayon fan-out: with multiple threads the same patched build still produces a different
hash on every run (5 distinct hashes in 5 runs), which is float-reduction ordering in the parallel
solver and a separate matter entirely. Single-threaded reproducibility, however, goes from
impossible to exact, and that is the configuration in which deterministic fluid is achievable at
all today.

Happy to add a regression test, split the re-export into its own commit, or adjust the field's
visibility story if the public entries type change is a concern.

…ate, as HGrid already does)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant