Skip to content

lib: Make random number generation thread safe - #6480

Merged
wenzeslaus merged 14 commits into
OSGeo:mainfrom
marisn:rand_thread
Sep 10, 2026
Merged

wenzeslaus merged 14 commits into
OSGeo:mainfrom
marisn:rand_thread

Conversation

@marisn

@marisn marisn commented Oct 10, 2025

Copy link
Copy Markdown
Contributor

As random file generation keeps internal state in a static variable, it is prone to failures as multiple threads would attempt to update sate simultaneously. This PR adds a mutex lock around internal state update calls thus preventing clashes when called from multiple threads.

Contains code to be merged as PR #6479
Also contains necessary changes in Make/CMake to compile gis lib with pthread support.

Should fix issues observed in r.mapcalc after thread support was merged #5742

@marisn
marisn requested review from echoix and nilason October 10, 2025 09:19
@github-actions github-actions Bot added C Related code is in C libraries CMake labels Oct 10, 2025
@wenzeslaus

Copy link
Copy Markdown
Member

The code changes look straightforward. Do you have an idea about performance? I agree we need the change in any case, but at least knowing whether we need a better solution in the future would be good.

@wenzeslaus wenzeslaus changed the title threads: make random number generation thread safe lib: Make random number generation thread safe Oct 10, 2025
@wenzeslaus

Copy link
Copy Markdown
Member

Good news. This makes the failing rand function tests in #6476 pass at least on Linux.

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
@marisn

marisn commented Oct 17, 2025

Copy link
Copy Markdown
Contributor Author

The code changes look straightforward. Do you have an idea about performance? I agree we need the change in any case, but at least knowing whether we need a better solution in the future would be good.

I haven't tested but locked part is small and should be fast (just update, no IO), thus I would expect impact on speed to be negligible.

@wenzeslaus wenzeslaus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think the locking needs to expand to reading of the shared state, it is both write and read which need to be isolated.

More generally, I'm afraid we didn't finalize the r.mapcalc's rand behavior with multiple threads (and seeds). (Or did we?) If r.mapcalc is using these functions directly as before parallelization, the random numbers placed in the raster will depend on the order of threads, i.e., what rows will come first, but I did not review the relevant code now. (An ultimate fix there would be different "context" objects for the random numbers, per row (for maximum reproducibility) or per thread (for at least seed+nprocs reproducibility).

Comment thread lib/gis/lrand48.c Outdated
Comment thread lib/gis/lrand48.c
Comment thread lib/gis/lrand48.c
Comment thread lib/gis/lrand48.c Outdated
@wenzeslaus

Copy link
Copy Markdown
Member

I haven't tested but locked part is small and should be fast (just update, no IO), thus I would expect impact on speed to be negligible.

No IO, but it is called for every cell in r.mapcalc, no? Still better slow with overall faster code than a segfault, and rand() is already much slower than let's say a constant (I did not test recently). If it is slow, we could use a solution with an explicit state (context) which may allow us to avoid locking, while having a separate locking version of the API.

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
@github-actions github-actions Bot added Python Related code is in Python tests Related to Test Suite labels Oct 20, 2025
@marisn

marisn commented Oct 20, 2025

Copy link
Copy Markdown
Contributor Author

This is the worst case scenario – code calls random number generation and discards produced numbers (thread creation + locking overhead):

Total random calls per run: 100,000,000
Runs per thread count: 5

Threads Avg. Time (s) Overhead vs. 1 Thread
1 0.8202 +0.0000s (+0.00%)
2 3.3390 +2.5187s (+307.07%)
4 3.2877 +2.4674s (+300.81%)
6 3.5505 +2.7303s (+332.86%)
8 4.3752 +3.5549s (+433.40%)
10 4.9465 +4.1263s (+503.05%)
12 5.7690 +4.9488s (+603.32%)
16 6.2315 +5.4113s (+659.71%)

@marisn

marisn commented Oct 20, 2025

Copy link
Copy Markdown
Contributor Author

No IO, but it is called for every cell in r.mapcalc, no? Still better slow with overall faster code than a segfault, and rand() is already much slower than let's say a constant (I did not test recently). If it is slow, we could use a solution with an explicit state (context) which may allow us to avoid locking, while having a separate locking version of the API.

RN generation now is always serial – outcome order is guaranteed. Consumption is another topic. I lean towards just stating that for fully reproducible outcome one should set parameters for single thread execution.
Generally it is out of scope of this PR and should be discussed separately with more people involved.

@wenzeslaus

Copy link
Copy Markdown
Member

A random sequence can contain duplicates, so your test is failing, but it is because you don't have to (can't) have that requirement.

AssertionError: 10000 != 9996 : Duplicate values found in multi-threaded run, indicating a race condition.

I think only the comparison of sorted lists gives you a true idea of what happened.

@wenzeslaus

Copy link
Copy Markdown
Member

...outcome order is guaranteed.

From library perspective, but never in the actual usage.

Consumption is another topic.

So I would say just don't overstate the outcome order guarantees in the doc.

I lean towards just stating that for fully reproducible outcome one should set parameters for single thread execution.

That would be my choice at this point as well.

Generally it is out of scope of this PR and should be discussed separately with more people involved.

I agree.

Comment thread lib/gis/testsuite/test_lrand48.py Outdated
Comment thread lib/gis/testsuite/test_lrand48.py Outdated
Comment thread lib/gis/testsuite/test_lrand48.py Outdated
Comment thread lib/gis/CMakeLists.txt Outdated
Comment thread lib/gis/testsuite/test_lrand48.py Outdated
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
@echoix
echoix removed their request for review December 3, 2025 15:07
@echoix echoix removed the conflicts/needs rebase Rebase to or merge with the latest base branch is needed label Jul 17, 2026
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
@wenzeslaus

Copy link
Copy Markdown
Member

Commit 38ed9dc updates the PR to use C11 atomics instead of a pthread mutex to fix #5742 (thread-safe G_lrand48/G_mrand48/G_drand48). The generated sequence for a given seed is unchanged and now pinned by the regression tests from #7633 and #7634, which pass on my fork and should run also here.

Details: why atomics, how it works, and what was verified (AI-generated)

Three problems the mutex approach ran into:

  1. The default builds never got the fix. HAVE_PTHREAD is only defined with --with-pthread, which is independent of --with-openmp and off by default. Our own macOS CI and both Docker images build with --with-openmp but without --with-pthread, so exactly the builds with the r.mapcalc: Support parallel computing by OpenMP #5742 race would have compiled the mutex out. The atomics gate (__STDC_VERSION__/__STDC_NO_ATOMICS__) is answered by the compiler, with no configure involvement.

  2. Build system changes are no longer needed. The Makefile and CMake changes are dropped entirely (also resolving the CMake review comment). As a side effect this avoids defining HAVE_PTHREAD on MSVC, where find_package(Threads) succeeds with Win32 threads but <pthread.h> does not exist.

  3. Performance. The mutex serializes every call and adds contention: in a microbenchmark, threaded runs were slower than single-threaded at every thread count. The atomic version keeps the exact same serialized sequence at about half the cost when several threads generate numbers at the same time, with near zero single-threaded overhead. (A single shared generator still cannot scale with threads; per-thread streams remain future work.)

How it works: the generator is the standard drand48 linear congruential generator (LCG): the next state is computed from the current one as X' = (A*X + B) mod 2^48. The previous code kept those 48 bits split across three 16-bit variables and advanced them with hand-written multi-word arithmetic, a leftover from times before 64-bit integers were portable; that split is what made concurrent updates able to interleave and corrupt the state. Now the whole state is a single 48-bit value in one atomic_uint_least64_t, and advancing it is one atomic compare-and-swap (CAS): a thread reads the state, computes the successor with the formula above, and atomically replaces the state only if it still holds the value that was read; otherwise it retries with the fresh value. In other words, the update optimistically assumes the state was not touched by another thread in the meantime and just recomputes when it was — unlike a lock, which pessimistically excludes all other threads ahead of time and pays that cost on every call whether a conflict occurs or not. Each successful swap is exactly one generator step, and because the caller computes its return value from the successor it installed rather than by re-reading shared variables, concurrent callers consume distinct, consecutive steps of the same sequence. Without C11 atomics the code falls back to the previous plain implementation (not thread-safe, i.e. unchanged behavior on such platforms; RFC 7 allows optional C11 features with a fallback).

The same C11-atomics-with-fallback approach is used in #7350; if both land, the capability test can be factored into a shared macro.

Reproducibility: the sequence of values is serialized and seed-reproducible, but which thread (and so which raster cell) receives which value depends on scheduling. Fully reproducible results still require single-threaded execution; this is now stated in the file documentation. The concern about G_fatal_error() under a held lock in the mutex-based implementation no longer applies (there is no lock).

Test cleanup: the duplicate-values assertion is removed (a correct random sequence can contain duplicates, as the observed 10000 != 9996 failure showed; the sorted-lists comparison is the valid check), the AI authorship note moved to the PR, and the banner comments are gone.

The 64-bit reformulation and its verification were AI-assisted (Claude). Verification included a standalone build of this file compared against the previous implementation for 100k steps and the reference sequences for five seeds, an 8-thread multiset check (1M draws), and the regression tests from #7633 and #7634 run against a GRASS build using this file.

@wenzeslaus
wenzeslaus requested a review from nilason August 19, 2026 15:33
@wenzeslaus

Copy link
Copy Markdown
Member

@nilason This is now using your approach from #7350 while not touching any build files (unlike the pthreads locking version). Please review.

@wenzeslaus
wenzeslaus dismissed their stale review August 19, 2026 15:37

I changed the code to address my previous review.

@wenzeslaus wenzeslaus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is passing tests from #7633 and #7634, i.e. generating same numbers as before. I approve, but I'm also responsible for the new code, so someone else should give the formal approval, too.

nilason
nilason previously approved these changes Sep 9, 2026

@nilason nilason left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Look good to me, the atomic ops are reasonable.

@marisn

marisn commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Lets merge this, but remember that this is the first step, as we still have other issues to tackle:

  • reproducibility in threaded environments;
  • MSVC;
  • fallback if compiled with older compiler (do we block older compilers?)

Comment thread lib/gis/testsuite/test_lrand48.py Outdated
@nilason

nilason commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Lets merge this, but remember that this is the first step, as we still have other issues to tackle:

  • reproducibility in threaded environments;

#7884 is an enhancement built upon this PR. I'm not sure if that answers your question.

  • MSVC;

MSVC do have support for atomic operations, but with a bit different API compared to C11. I plan to suggest to add a GRASS wrapper API for atomic operations similar to https://github.com/python/cpython/pull/108701/changes. That will hopefully make implementation of atomic operations with broader support easier in GRASS.

  • fallback if compiled with older compiler (do we block older compilers?)

cpython do have a wrapper for GCC's internal functions which match C11. This will enable support for GCC 4.8+ according to https://github.com/python/cpython/blob/main/Include/cpython/pyatomic_gcc.h.
As this PR currently stands, atomic operations (and thus thread safe random number generation) is enabled only if the compiler supports it.

nilason
nilason previously approved these changes Sep 10, 2026
Comment thread lib/gis/testsuite/test_lrand48.py Outdated
@wenzeslaus
wenzeslaus merged commit c83afef into OSGeo:main Sep 10, 2026
26 checks passed
@wenzeslaus

Copy link
Copy Markdown
Member

With available C11 atomics, main is now without race although potentially slower (price for correctness). Follow-up in #7884 for a more complete fix.

@github-actions github-actions Bot added this to the 8.6.0 milestone Sep 10, 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 CMake libraries Python Related code is in Python tests Related to Test Suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants