Skip to content

libgis: Add reentrant drand48 for independent parallel streams - #7884

Draft
petrasovaa wants to merge 16 commits into
OSGeo:mainfrom
petrasovaa:rsim-reentrant-prng
Draft

petrasovaa wants to merge 16 commits into
OSGeo:mainfrom
petrasovaa:rsim-reentrant-prng

Conversation

@petrasovaa

Copy link
Copy Markdown
Contributor

TL;DR

G_drand48() keeps one shared state, so parallel code either races on it or
serializes on it. This adds a caller-owned generator with independent streams
and uses it in r.sim.water / r.sim.sediment.

  • r.sim.water on 4 threads: 23.1 s → 7.5 s (1.25x → 3.1x)
  • Repeated runs with the same random_seed and nprocs are now identical;
    before they differed by up to ±0.024 m against a ~0.6 m max depth
  • Single-threaded results are unchanged — all r.sim reference tests pass
    with no fixture changes

Builds on #6480 and needs it to land first.

Why #6480 is not enough on its own

#6480 makes the shared generator thread-safe with an atomic compare-and-swap,
which is the right fix for callers that want one stream. But it is still one
stream: under contention only one CAS succeeds per cache-line round trip, so
r.sim.water (which draws 2-3 times per walker per timestep) gets slower with
every thread added — 31.4 s at 1 thread, 74.1 s at 8. It also cannot make
results reproducible, as the PR itself documents.

How the streams work

One generator step is the affine map x -> a*x + c, and composing two such maps
gives another, so the map for n steps can be built by repeated squaring. Stream
s starts s * 2^36 steps along the cycle: 4096 streams of ~6.9e10 draws each,
disjoint by construction rather than merely unlikely to collide. Stream 0 starts
exactly where G_srand48() does, which is what keeps single-threaded results
identical.

In r.sim, each walker chunk owns a stream seeded by chunk index rather than
thread number, and copies it into a thread-local so the 8-byte states do not
share a cache line. Walker setup draws from chunk 0's stream, which chunk 0 then
continues — with one chunk that is a single stream used in the order the tool
has always used it.

Measurements

nc_spm elevation at 10 m (1350x1500), 800k walkers, duration=10,
random_seed=42, 145 iterations, on a 4-core / 8-thread i5-10210U.

nprocs main #6480 alone this PR
1 28.9 s 31.4 s 23.5 s
2 19.5 s 38.1 s 12.0 s
4 23.1 s 61.4 s 7.5 s
8 22.2 s 74.1 s 8.6 s

Single-thread also improves because the 64-bit LCG is cheaper than the limb
arithmetic it replaces, which benefits every G_drand48() caller.

Tests: 39 pass in lib/gis/tests (8 new), and both r.sim gunittest suites pass
unchanged. The existing sequence-pinning tests are what verify that removing the
limb implementation changed no output.

Not addressed here

nprocs still changes results above one thread, because the number of walker
chunks is still the number of threads. Making the chunk count a parameter of its
own is a follow-up. Two other r.sim issues are also left alone: grids->dif is a
raster-sized array used as a per-walker scalar (a race and wasted memory), and
the hand-rolled thread chunking silently drops up to nthreads/2 walkers when
nwalk/nthreads rounds down.

🤖 Generated with Claude Code

marisn and others added 13 commits October 17, 2025 18:31
As random number generator tracks its internal state as a static
variable, it is necessary to guard its updates with mutex to
prevent misbehavour when multiple threads change values simultaneously
multiple threads

As random number generation is guarded by a mutex, it makes generation
a serial operation – always leading to the same sequence of numbers
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Replace the pthread mutex approach with a lock-free compare-and-swap on
the whole 48-bit generator state kept in a single C11 atomic integer.
One successful compare-and-swap is exactly one step of the standard
drand48 linear congruential generator, so the sequence of generated
values for a given seed is identical to a single-threaded run and to the
previous limb-based implementation.

Compared to the mutex: the feature is gated by the compiler
(__STDC_NO_ATOMICS__), not by --with-pthread, so it is active in every
supported build, including the OpenMP-without-pthread configurations
(macOS CI, Docker images) where the race reported in OSGeo#5742 actually
occurs; no build system changes or extra link dependencies are needed
(the Makefile and CMake changes are dropped, which also avoids defining
HAVE_PTHREAD from the Win32-threads CMake package on MSVC); and there is
no lock contention (in a microbenchmark the mutex made threaded runs
slower than single-threaded at every thread count, while the atomic
version is about twice as fast as the mutex when several threads
generate numbers at the same time). A single shared generator still
cannot scale with the number of threads; per-thread state remains
future work.

Where C11 atomics are unavailable, the code falls back to the previous
plain implementation, which is not thread-safe (unchanged behavior).

Output compatibility is guarded by the PRNG regression tests merged in
OSGeo#7633 and OSGeo#7634 (lib/gis/tests/lib_gis_lrand48_test.py and
raster/r.mapcalc/tests/r_mapcalc_rand_seed_test.py) and was additionally
verified against the previous implementation for multiple seeds.

The 64-bit reformulation of the generator and its verification were
AI-assisted (Claude).
…nd_thread-atomics

# Conflicts:
#	lib/gis/CMakeLists.txt
#	lib/gis/Makefile
G_drand48() advances a single shared state, so parallel callers either
race on it or serialize on it. Add G_srand48_r() and G_drand48_r(),
which advance a generator owned by the caller. Streams are spaced apart
by LCG jump-ahead so they are disjoint by construction, and stream 0
starts where G_srand48() does. The LCG step is now shared with the
global generator, replacing an equivalent 16-bit limb implementation.

r.sim.water and r.sim.sediment give each walker chunk its own stream.
Repeated runs with the same random_seed and nprocs are now identical
where they previously were not, and single-threaded results are
unchanged. Four threads go from 1.25x to 3.1x on an 8-thread machine.

Based on OSGeo#6480. Written with substantial help from Claude Code.
@github-actions github-actions Bot added raster Related to raster data processing Python Related code is in Python C Related code is in C libraries module tests Related to Test Suite labels Sep 4, 2026
@nilason nilason added the conflicts/needs rebase Rebase to or merge with the latest base branch is needed label Sep 14, 2026
@nilason

nilason commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

I took the liberty to update to main and resolve the conflicts. I hope I had it right in the end.

@nilason nilason removed the conflicts/needs rebase Rebase to or merge with the latest base branch is needed label Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C Related code is in C libraries module Python Related code is in Python raster Related to raster data processing tests Related to Test Suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants