Skip to content

Thread-reproducible simulation: counter-based RNG + race fixes - #427

Open
heberlr wants to merge 9 commits into
MathCancer:developmentfrom
heberlr:counter_based_rng
Open

Thread-reproducible simulation: counter-based RNG + race fixes#427
heberlr wants to merge 9 commits into
MathCancer:developmentfrom
heberlr:counter_based_rng

Conversation

@heberlr

@heberlr heberlr commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an opt-in, deterministic RNG mode (Philox4x32-10, keyed by (cell_id, time_step, purpose, sub_index)) so a simulation produces bit-identical output regardless of OpenMP thread count — and fixes four core-engine race conditions that RNG determinism alone doesn't address, since they involve one cell's thread mutating another cell's live state, not random draws.

Off by default (<options><rng_mode>counter_based</rng_mode></options> to enable); existing projects are unaffected unless they opt in.

Why

Thread-count-dependent (and in some cases run-to-run-dependent, at a fixed thread count) results meant there was never a reliable way to tell a genuine model bug from scheduling noise, or to reproduce a specific run for debugging. Every race fixed here was a real, silent correctness bug — not just a reproducibility gap — that had gone undetected because nothing before could reliably say "these two runs should match and don't." See protocols/counter_based_rng.md for the full design writeup, including why each fix needs ordered vs. just critical, and why fixes are gated behind use_counter_based_rng rather than applied unconditionally.

What's included

RNG core (modules/philox.h, core/PhysiCell_utilities.{h,cpp}): a keyed Random(cell_id, time_step, purpose, sub_index) entry point on top of D. E. Shaw Research's Philox4x32-10 (vendored from Random123; see Credit section in the doc), plus a thread-local deterministic context so existing UniformRandom()/NormalRandom()/etc. call sites — core and user code alike — pick it up automatically without being rewritten.

Simulation-loop wiring (modules/PhysiCell_settings.{h,cpp}, core/PhysiCell_cell_container.{h,cpp}): the <rng_mode> config option, and a deterministic context installed around each per-cell update phase, keyed by phase-specific purpose. Division/death are applied serially in ID-sorted order (when enabled) so new cell IDs and cell ordering stay thread-count independent — the existing pattern the four fixes below extend.

Four cross-cell mutation races found and fixed, discovered via repeated 1-vs-4-thread comparisons on real, multi-behavior projects, not by inspection:

  1. standard_cell_cell_interactions() (attack/ingest/fuse) — mutates a target cell with no commit ordering; two attackers can race on the same target.
  2. dynamic_spring_attachments() — checks a neighbor's attachment capacity before attach_cells_as_spring() re-validates it; two cells can both attach to an already-full neighbor.
  3. max_cell_interactive_distance_in_voxel — unprotected read-compare-write on a shared per-voxel bound, in convert_to_cell_definition() and (adopting upstream PR #409) update_voxel_in_container().
  4. Secretion::advance()simulate_secretion_and_uptake() — accumulates directly into a shared voxel density vector with no lock; a genuine lost-update on substrate mass, independent of RNG mode, that predates this branch.

Fixes 1, 2, and 4 use #pragma omp ordered (the operation isn't commutative — order matters); fix 3 only needs critical (max is order-independent). Fixes 1/2/4 are gated behind use_counter_based_rng: ordered isn't free (DOACROSS-style serialization of the marked region, see literature discussion in the doc), so the legacy path keeps its original, untouched, fully-parallel loop. That means these three races are still present with counter_based_rng off — a deliberate, documented trade-off, not an oversight.

Testing tool (beta/test_thread_repro.py): runs a built project at two thread counts with the same seed and diffs the final output — the tool that surfaced all four races above.

Docs (protocols/counter_based_rng.md): full design rationale, why each fix needs the synchronization primitive it uses, why gating was chosen over unconditional fixes, how to enable the feature, and how to test a new/modified project for thread reproducibility.

heberlr added 9 commits August 6, 2026 12:53
Add a deterministic, keyed random generator, Random(cell_id, time_step,
purpose, sub_index), built on Philox4x32-10, plus a thread-local
deterministic context that UniformRandom(), UniformInt(), NormalRandom(),
and LogNormalRandom() consult when active. Existing call sites keep
working unchanged, but draw from a reproducible sequence instead of a
thread-order-dependent one whenever the context is installed.
Add PhysiCell_settings.use_counter_based_rng (default off) and the
<options><rng_mode> config element (counter_based/philox/counter to
enable; legacy/thread_local/mt19937 to disable explicitly). Install a
per-cell deterministic RNG context, keyed by cell ID/step/phase, around
each parallel per-cell update phase, and sort cells_ready_to_divide /
cells_ready_to_die by cell ID before applying them serially, so new cell
IDs and (*all_cells) ordering are thread-count independent.

RNG determinism alone doesn't stop one cell's thread from mutating
another cell's live state mid-update in the same parallel loop. Three
such races are fixed here with #pragma omp ordered around the per-cell
call, forcing the same relative commit order as a single-thread run:

- standard_cell_cell_interactions() (attack/ingest/fuse) mutates a
  target cell with no ordering; two attackers can race on one target.
- dynamic_spring_attachments() checks a neighbor's attachment capacity
  before attach_cells_as_spring() re-validates it, so two cells can
  both attach to an already-full neighbor.
- Secretion::advance() -> simulate_secretion_and_uptake() accumulates
  into a shared per-voxel density vector with no lock; two cells
  sharing a voxel on different threads can lose an update.
Runs a built project twice at two thread counts with the same config and
seed, then diffs the final snapshot output to catch thread-count-dependent
divergence.
Explain the Philox-based deterministic RNG scheme, the existing
division/death ordering precedent, the four cross-cell mutation races
found and fixed, how to enable counter-based RNG, and how to test a
new/modified project for thread reproducibility with
beta/test_thread_repro.py.
…current on move

Update the bound in update_voxel_in_container() whenever a cell crosses
into a new voxel, not just on the rare type-conversion path. Without this
the bound goes stale as cells migrate, and a voxel's neighbor search can
wrongly skip a cell that has since moved in.

Adapted from MathCancer#409, with
#pragma omp critical added around the read-compare-write: in this codebase
update_voxel_in_container() is also reachable from fuse_cell()'s parallel
context, so the unprotected version from the upstream PR would still race
here even though it may not have in the original context.
convert_to_cell_definition

Unrelated to PR MathCancer#409: this read-compare-write already existed on the
type-conversion path and was never protected. convert_to_cell_definition()
is reachable from rule-triggered transformations inside the (unordered)
phenotype loop, so two cells transforming in the same voxel in the same
step could race on this shared bound. Protected with #pragma omp critical;
sufficient since max doesn't depend on evaluation order.
Move the use_counter_based_rng branch out of run_secretion_phase(),
run_spring_attachment_phase(), and run_cell_cell_interactions_phase() and
into their call sites in update_all_cells(). Each is now
if(use_counter_based_rng){ call a small *_ordered() function } else {
original inline loop }, so the legacy path stays untouched inline code
instead of living inside a shared function, and use_counter_based_rng is
still checked once per phase per step, not once per cell.

Also drop the activate_random_context()/clear_random_context() calls from
the three else branches: inside a branch that only runs when
use_counter_based_rng is already false, that guarded call is provably
dead code, so removing it makes the legacy path true original behavior
rather than calls that happen to no-op.
… code.

Also bring the content in line with the current architecture: add
"Why this matters" (reproducibility, debugging, potential bitwise
restart) and "Credit" (D. E. Shaw Research / Random123, philox.h
provenance) sections, correct Fixes 1/2/4 to name the *_ordered()
functions and describe gating at the call site instead of inside a
shared function, and document why the else branches no longer call
activate_random_context()/clear_random_context().
Comment on lines +280 to +299
if (rng_mode_node)
{
std::string rng_mode = xml_get_my_string_value(rng_mode_node);
if( rng_mode == "counter_based" || rng_mode == "counter" || rng_mode == "philox" )
{
PhysiCell_settings.use_counter_based_rng = true;
std::cout << "Using counter-based RNG mode" << std::endl;
}
else if( rng_mode == "legacy" || rng_mode == "thread_local" || rng_mode == "mt19937" )
{
PhysiCell_settings.use_counter_based_rng = false;
std::cout << "Using legacy RNG mode" << std::endl;
}
else if( rng_mode != "" )
{
std::cout << "ERROR: unsupported rng_mode '" << rng_mode << "'. Use 'legacy' or 'counter_based'." << std::endl;
exit(-1);
}
}

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.

Could we document here what happens if the config file does not have an rng_mode node? I assume it is use_counter_based_rng = false

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.

and I did just see the answer in the header file 😆 . I still think that showing up here would be helpful as this becomes the one place to look for what it gets set to

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.

2 participants