Skip to content

r.mapcalc: Enable parallel rand() with reproducible results - #8114

Merged
wenzeslaus merged 21 commits into
OSGeo:mainfrom
wenzeslaus:mapcalc-rand-layout
Oct 29, 2026
Merged

wenzeslaus merged 21 commits into
OSGeo:mainfrom
wenzeslaus:mapcalc-rand-layout

Conversation

@wenzeslaus

@wenzeslaus wenzeslaus commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

rand() drew from the generator shared by the whole program, so r.mapcalc evaluated any expression with rand() on one thread, whatever nprocs was (to avoid the threads hitting the same library generator which was at that time not thread-safe).

Each row now gets a generator state of its own from an exact random number layout of the seed, so the result for a seed is the same as before and the same for any nprocs, and rand() no longer turns off parallel evaluation.

rand() in the calc library needs a modification since that's the place where the RNG library is called. The old global RNG is kept as a fallback, but a new function allows to set a single callback which can return a state to be used with the new RNG API. This is used to return the relevant state for each thread.

Wall time of r.mapcalc with rand() against the number of threads for 4 math functions + rand() and for 31 math functions, 2 graph() and 2 if() + rand(), for this PR, current main, current main without the one-thread rule, and main before OSGeo/grass#6480 without the rule

The picture shows wall time against the number of threads for two expressions on 52.5 and 420 million cells. This PR is blue and current main, where rand() runs on one thread, is orange. The dashed gray line is current main without the one-thread rule, where the threads share the atomic generator, which costs time when rand() is a large part of the work and many threads draw at the same time (an implementation with no race, but also no reproducibility). The dotted gray line is main from before the atomic generator (#6480) without the rule, where the threads share the generator with no synchronization, which is fast but gives values that change with the number of threads. Only the dotted line still has the shared floating point flag removed in #8031, so its stop at about 110 s on the costlier expression comes from the flag, not from the data race.

Builds on the random number layouts (#7979), the seed option parsing function (#8033), and the integer functions (#8079), all merged.

Details (AI generated)

Design

  • Every rand() call draws one value per cell of a row, and every row evaluates every call once, so a row draws columns times calls values. r.mapcalc builds an exact layout with a row as the unit (in r3.mapcalc the rows of all depths one after another) and columns times rand() calls as the draws per unit, before the outputs are opened.
  • Each row places a struct G_random_state local to its evaluation at the row's stream before evaluating the row. The streams of an exact layout are the positions of a serial run, which is why the values for a seed do not change. rand() reaches the state through a per-thread pointer. The states are not kept in one array of states, because the draws of up to eight threads would then write to the same cache line (see Performance of the state per row).
  • rand() stays in lib/calc. The library gains calc_set_random_state(), through which a tool gives rand() a function returning the calling thread's state; r.mapcalc sets it. When nothing is set, rand() draws from the shared generator as before, so other users of the library are unaffected.
  • A warning is given when the whole computation draws more than 2^46 values, after which the values repeat plus a constant. It is only a warning because earlier versions drew these values without complaint.

Alternative layouts

The layout here has a row as its unit, and all rand() calls of a row share its stream. So the values of a seed depend on the number of rand() calls, and adding a call changes the values of the others. The alternative is a layout in which each rand() call has a stream of its own, for example with the call as a batch of the layout and the row as the unit. Then a single rand() would give the same values as the first rand() of an expression with any number of calls. It was not chosen for three reasons.

  • The values for a seed would differ from earlier versions, while the layout here keeps them.
  • Each rand() call would need to know its index, which the calc library does not pass to the function, so the expression node or the tool would have to provide it.
  • Each rand() call would place its own state for every row, a jump of the generator per call and row instead of one per row.

Seed handling

The seed is read with G_random_seed_from_option(), so a seed outside -2147483648 to 4294967295 is an error instead of being silently reduced to its low 32 bits, and a seed with trailing characters is an error instead of being cut off. When rand() is used without a seed, the automatic seed comes from G_random_generate_seed(), so GRASS_RANDOM_SEED and SOURCE_DATE_EPOCH are honored, and it is still written to the history. nprocs is no longer forced to 1 when the expression contains rand().

Documentation

The .md files are the source and the .html files follow them in the random number generator section, which is the only part this PR changes in the .html files. Elsewhere the .html files were already out of sync with the .md files on main, and this PR leaves that as is. Most notably, r.mapcalc.html has no Performance section, so the removed note that rand() disables parallelization has no counterpart there.

Verification

  • 378 rasters compared as text between r.mapcalc and r3.mapcalc built from main (3c7208d) and from this PR (7d3339c) on the same seeds, for nprocs 1 and 4: 0 differences. The cases are two regions, seven seeds across the whole range, single and multiple rand() calls, nested calls, a variable of eval() and of an earlier expression, both branches of if(), and r3.mapcalc over five depths. As a control, seeds 1 and 2 differ.
  • All 26 testsuites which use rand() in their data pass with this PR on the nc_spm sample dataset: 11 imagery, 4 r3.to.rast, 3 r.mapcalc, 7 temporal and r.recode. The exception is one r.recode test which needs the landuse map, missing in the local copy of the dataset. The r.recode test using rand() passes.
  • The reference rasters of raster/r.mapcalc/tests/r_mapcalc_rand_seed_test.py were produced by earlier versions, which evaluated expressions with rand() on one thread, so they also check that the values stay the same. They now pass for nprocs 1 and 4. The test that rand() turned off parallel evaluation is removed.
  • The skipped test of rand() without a seed in raster/r.mapcalc/tests/r_mapcalc_nprocs_test.py is removed. The file tests error states, and rand() without a seed is not one since automatic seeding. rand() without a seed is covered by the automatic seed tests, which run with the default nprocs, so on all threads.
  • New pytest file raster/r.mapcalc/tests/r_mapcalc_rand_layout_test.py, 15 tests in about 3 s, comparing exact values with the values of the library's layout for nprocs 1 and 4 (1 for r3.mapcalc, which runs on one thread): expressions of the three types in order, several rand() calls in one expression, if() with rand() in both branches, a variable of eval(), and r3.mapcalc over several depths. It also checks that an automatic seed gives the result of the seed in the history and that invalid seeds are errors.

Benchmarks

Two benchmarks of rand() on 1 to 16 threads, run on 2026-10-21 and 2026-10-22 with this PR at 40a3b8a merged with main f557dea. The later commits of this PR change only comments, documentation and tests, so the timed code is the same as at 7d3339c. Both were first posted as comments with the figures below.

Protocol

Shared Intel Xeon W-2295 @ 3.00GHz (18 cores, 36 logical CPUs), Linux 6.8. All builds are Autotools with the same configure line, gcc, CFLAGS -g -O2, OpenMP on. One grass --tmp-mapset session on the SECREF North Carolina dataset drives the runs; each run uses the binary and libraries of its build, checked through /proc/<pid>/maps. Builds are interleaved within each number of threads; the figures show the median and the range of the runs. seed=1 for all runs.

Expressions, each with one rand(0.0, 1.0):

  • x = rand(0.0, 1.0) on 52.5 million cells (the SECREF extent at 0.1 m)
  • 4 math functions (sin, cos, sqrt, exp of row() and col()) + rand(), 52.5 million cells
  • 14 math functions (also log, atan, pow, tan) + rand(), 52.5 million cells
  • 31 math functions, 2 graph() with 29 points and 2 if() + rand(), 420 million cells

Runs:

  • Main and this PR, each with and without r.mapcalc: Fix modulo by zero hang and speed up threads #8031, at 1, 2, 4, 8 and 16 threads, and the two builds of this PR at the other thread counts, 3 repeats, 504 runs, 1-minute load average 1.2 to 16.4 (median 7.5).
  • The six builds of the first figure at 1, 2, 4, 8 and 16 threads, and the three which use threads at the other thread counts, reusing the runs above, 5 repeats for x = rand(0.0, 1.0), whose runs take about 1.5 s, and 3 for the others, 630 more runs, load 0.8 to 17.9 (median 8.1).
  • Points with runs slowed by other load on the machine got 3 more runs, and runs more than 15% slower than the median of their point were left out, 6 of 1152 runs, all in the builds before r.mapcalc: Fix modulo by zero hang and speed up threads #8031. Main without the one-thread rule is exempt, since the spread of its runs is its own: it is there also at a load of about 2.

This PR, main, earlier versions and alternatives

Wall time of r.mapcalc with rand() against the number of threads for four expressions, for this PR, current main, main before OSGeo/grass#6480, r.mapcalc before OpenMP, and two alternatives without the one-thread rule

This PR is the only version which gives the same values for any number of threads and also gets faster with more threads. The versions which force one thread for rand() stay flat. Current main without the one-thread rule is as fast as this PR on the heaviest expression, but on the cheaper ones it gets slower above 4 to 8 threads, as the threads compete for the shared generator (4 math functions + rand(): 1.55 s at 4 threads, 3.13 s at 16; this PR 1.70 s). The cost depends on how much of each row's work is rand(), not on the number of draws. With one rand() alone, the threads mostly wait for the rows to be written one after another, so few draw at the same time, and at 16 threads it uses as much CPU time as this PR (21 s), although single runs vary from 1.56 s to 2.65 s. With 4 math functions + rand(), many threads draw at once, and 79% of the CPU time is spent in G_drand48(), which advances the shared generator with a compare-and-swap (47 s of CPU time against 24 s for this PR). On the heaviest expression, rand() is too small a part of the work for the draws of the threads to collide often. The version with the data race keeps up with this PR on the cheap expressions, but it is built on main before #8031, so on the costlier ones it stops at 5.1 s and 108 s, like this PR on main before #8031 in the next figure. The values of both versions without the rule change with the number of threads.

label tree rand()
this PR this PR's head 40a3b8a with f557dea merged exact layout of the random numbers, independent of the number of threads
current main, rand() on 1 thread f557dea shared generator advanced with atomics (#6480); nprocs forced to 1 when rand() is used
before #6480, rand() on 1 thread 55de52a shared generator without atomics; nprocs forced to 1
before OpenMP in r.mapcalc (#5742), 1 thread 97d1178 r.mapcalc before parallel computing by OpenMP (#5742); no nprocs
current main without the 1-thread rule (atomic in RNG) f557dea with the rule removed threads share the atomic generator
before #6480 without the 1-thread rule (data race on RNG) 55de52a with the rule removed threads share the generator without synchronization

31 math functions, 2 graph(), 2 if() + rand(0.0, 1.0), 420 million cells; wall time in seconds, median (min-max) of 3 runs:

nprocs 1 nprocs 2 nprocs 4 nprocs 8 nprocs 16
this PR 283.4 (282.5-285.3) 160.7 (160.6-161.1) 92.6 (92.3-92.8) 67.6 (67.4-68.6) 65.7 (65.4-65.8)
current main, rand() on 1 thread 286.0 (284.7-292.7) 285.0 (284.9-286.8) 285.7 (285.1-286.5) 286.4 (285.9-287.0) 285.4 (284.3-285.9)
before #6480, rand() on 1 thread 291.6 (290.9-292.2) 291.0 (289.5-291.6) 291.9 (290.4-299.2) 291.4 (288.1-304.4) 290.9 (288.7-292.6)
before OpenMP in r.mapcalc (#5742), 1 thread 289.8 (289.3-302.7) 290.0 (287.4-290.6) 294.2 (290.1-294.7) 290.5 (289.5-309.4) 288.5 (288.3-291.7)
current main without the 1-thread rule (atomic in RNG) 287.7 (286.7-288.7) 163.4 (162.7-167.5) 93.9 (93.8-96.9) 67.9 (67.8-68.8) 65.6 (65.6-66.4)
before #6480 without the 1-thread rule (data race on RNG) 290.7 (287.4-291.0) 188.4 (188.3-197.5) 125.1 (125.1-128.2) 108.2 (107.8-110.1) 112.5 (112.3-116.5)

4 math functions + rand(0.0, 1.0), 52.5 million cells; wall time in seconds, median (min-max) of 3 runs (6 at 1 thread before OpenMP):

nprocs 1 nprocs 2 nprocs 4 nprocs 8 nprocs 12 nprocs 16
this PR 3.98 (3.95-4.04) 2.15 (2.14-2.21) 1.56 (1.55-1.58) 1.60 (1.59-1.60) 1.68 (1.66-1.69) 1.70 (1.69-1.72)
current main, rand() on 1 thread 4.29 (4.26-4.30) 4.26 (4.25-4.29) 4.25 (4.23-4.26) 4.27 (4.24-4.29) - 4.31 (4.25-4.32)
before #6480, rand() on 1 thread 4.16 (4.14-4.26) 4.16 (4.16-4.27) 4.27 (4.14-4.28) 4.17 (4.15-4.19) - 4.19 (4.15-4.24)
before OpenMP in r.mapcalc (#5742), 1 thread 4.19 (4.09-4.66) 4.16 (4.14-4.19) 4.12 (4.11-4.25) 4.17 (4.11-4.17) - 4.15 (4.13-4.19)
current main without the 1-thread rule (atomic in RNG) 4.30 (4.27-4.41) 2.35 (2.33-2.35) 1.55 (1.55-1.55) 2.45 (2.30-2.62) 3.07 (2.86-3.14) 3.13 (3.12-3.18)
before #6480 without the 1-thread rule (data race on RNG) 4.58 (4.57-4.65) 2.42 (2.40-2.42) 1.68 (1.67-1.69) 1.59 (1.56-1.60) 1.64 (1.64-1.65) 1.69 (1.67-1.71)

x = rand(0.0, 1.0), 52.5 million cells; wall time in seconds, median (min-max) of 5 runs:

nprocs 1 nprocs 2 nprocs 4 nprocs 8 nprocs 12 nprocs 16
this PR 1.48 (1.46-1.52) 1.34 (1.33-1.36) 1.39 (1.37-1.41) 1.45 (1.41-1.46) 1.51 (1.48-1.52) 1.54 (1.53-1.56)
current main, rand() on 1 thread 1.76 (1.73-1.81) 1.77 (1.74-1.81) 1.75 (1.72-1.76) 1.77 (1.74-1.80) - 1.78 (1.74-1.81)
before #6480, rand() on 1 thread 1.60 (1.54-1.61) 1.57 (1.55-1.62) 1.57 (1.55-1.63) 1.61 (1.57-1.65) - 1.58 (1.58-1.62)
before OpenMP in r.mapcalc (#5742), 1 thread 1.55 (1.51-1.60) 1.59 (1.57-1.60) 1.55 (1.53-1.58) 1.57 (1.55-1.66) - 1.56 (1.54-1.64)
current main without the 1-thread rule (atomic in RNG) 1.76 (1.73-1.85) 1.35 (1.32-1.40) 1.40 (1.39-1.53) 1.79 (1.47-1.91) 1.83 (1.53-2.00) 1.60 (1.56-2.65)
before #6480 without the 1-thread rule (data race on RNG) 1.60 (1.56-1.70) 1.34 (1.31-1.38) 1.36 (1.36-1.41) 1.41 (1.41-1.53) 1.49 (1.46-1.51) 1.54 (1.52-1.55)

With one thread, this PR is faster than current main (one rand(): 1.48 s against 1.76 s), most likely because it does not advance a shared generator with atomics.

This PR before and after #8031

Wall time of r.mapcalc with rand() against the number of threads for four expressions, for main and this PR, each on main before and after OSGeo/grass#8031

#8031 removed a shared floating point flag written by all threads in r.mapcalc. With main before #8031, this PR gets the costlier expressions down to 2 to 3 times faster than main, and then stops. With #8031, the same PR goes on: 6.2 times faster for 14 math functions + rand() and 4.4 times for the heaviest expression. The cheap expressions are the same on both. #8031 alone does not change main, since rand() still forces one thread there. The values are the same for any number of threads in all builds.

Main is f557dea; the builds before #8031 have its commit bf7d8b6 reverted, so each pair differs only by #8031.

label tree
main before #8031, rand() on 1 thread f557dea with #8031 reverted
main with #8031, rand() on 1 thread f557dea
this PR on main before #8031 this PR's head 40a3b8a with f557dea merged, #8031 reverted
this PR on main with #8031 this PR's head 40a3b8a with f557dea merged

14 math functions + rand(0.0, 1.0), 52.5 million cells; wall time in seconds, median (min-max) of 3 runs:

nprocs 1 nprocs 2 nprocs 4 nprocs 6 nprocs 8 nprocs 9 nprocs 12 nprocs 16
main before #8031, rand() on 1 thread 10.7 (10.7-10.7) 10.6 (10.6-10.7) 10.6 (10.6-10.7) - 10.7 (10.7-10.9) - - 10.8 (10.7-10.9)
main with #8031, rand() on 1 thread 10.5 (10.5-10.6) 10.5 (10.5-10.5) 10.6 (10.6-10.6) - 10.5 (10.5-10.7) - - 10.6 (10.6-10.7)
this PR on main before #8031 10.5 (10.4-10.5) 6.52 (6.41-6.63) 5.21 (5.15-5.27) 5.16 (5.06-5.21) 5.07 (5.05-5.09) 5.13 (5.11-5.15) 5.36 (5.28-5.38) 5.49 (5.46-5.51)
this PR on main with #8031 10.4 (10.3-10.5) 5.42 (5.34-5.46) 2.93 (2.93-2.94) 2.16 (2.15-2.16) 1.70 (1.69-1.70) 1.70 (1.69-1.71) 1.80 (1.79-1.80) 1.91 (1.89-1.91)

31 math functions, 2 graph(), 2 if() + rand(0.0, 1.0), 420 million cells; wall time in seconds, median (min-max) of 3 runs (5 at 5 and 12 threads for this PR on main before #8031):

nprocs 1 nprocs 2 nprocs 4 nprocs 6 nprocs 8 nprocs 12 nprocs 14 nprocs 16
main before #8031, rand() on 1 thread 289.7 (289.5-298.3) 289.8 (289.7-290.3) 290.3 (289.6-290.4) - 289.6 (288.4-291.0) - - 289.5 (288.8-290.5)
main with #8031, rand() on 1 thread 286.0 (284.7-292.7) 285.0 (284.9-286.8) 285.7 (285.1-286.5) - 286.4 (285.9-287.0) - - 285.4 (284.3-285.9)
this PR on main before #8031 287.5 (287.5-287.5) 185.5 (184.1-191.2) 126.9 (125.1-127.2) 112.2 (111.5-112.7) 110.7 (109.7-111.6) 108.1 (107.7-110.2) 110.1 (109.2-112.9) 112.4 (112.1-116.1)
this PR on main with #8031 283.4 (282.5-285.3) 160.7 (160.6-161.1) 92.6 (92.3-92.8) 73.3 (73.0-73.4) 67.6 (67.4-68.6) 65.7 (65.3-65.7) 65.1 (65.1-65.2) 65.7 (65.4-65.8)

For one rand() and for 4 math functions + rand(), this PR gives the same times on both mains (1.34 s at 2 threads for one rand(), about 1.6 to 1.7 s from 5 threads on for 4 math functions), so #8031 matters only when there is more work per cell.

The cheaper expressions get slower after their fastest thread count (e.g. 4 math functions + rand(): 1.56 s at 3 threads, 1.70 s at 16). The same expressions without rand() do the same on main with #8031 and on this PR (4 math functions: 1.54 s at 3 threads, 1.70 s at 16; x = 1.0: 0.62 s to 0.69 s), so this comes from r.mapcalc, not from the random numbers.

Values

Values of c = rand(0.0, 1.0) on 52.5 million cells, compared cell by cell: every build at nprocs 1 gives the same map as this PR at nprocs 1, including r.mapcalc before OpenMP and the builds before #8031. Between nprocs 1 and 16, the map differs in 0 cells for this PR and the builds which force one thread, in 128,062 cells for main without the rule and in all 52,500,000 cells for the version with the data race.

Performance of the state per row

An expression with 48 rand() calls per cell on a region of 2000 by 8000 cells, best of three runs on a 36-core machine, with the same results in all variants. Adjacent states is an earlier version of this PR with one array of per-thread states. States 64 bytes apart is the same array with the states a cache line apart, tried only to check the cause. State per row is the code of this PR.

nprocs Adjacent states States 64 bytes apart State per row
1 5.16 s 5.12 s 5.17 s
2 3.03 s 2.81 s 2.80 s
4 1.96 s 1.68 s 1.67 s
8 2.47 s 1.41 s 1.40 s
16 2.04 s 1.56 s 1.56 s

The states a cache line apart are as fast as the state per row, so the slowdown came from the threads writing to the same cache line. Above 8 threads the time no longer drops, most likely because the rows are written to the output one after another.

On one thread, the state per row costs nothing measurable compared with the array of states: +0.1% with -O2 and +0.2% with -O3 for 14 math functions + rand() on 52.5 million cells, medians of 12 runs per build with the build order reversed halfway, between two builds which differ only in this change. Separately linked builds can differ by about 5% on one thread without any change in work. Calling G_random_uint32() from the calc library adds one imported function, which moves all code of the library 32 bytes later; with the same 118 billion instructions, the same expression then took 4% to 5% more cycles, all of it in the calc library. This is code placement, and other changes to the library can move it either way.

Suggested squash commit message

r.mapcalc: Enable parallel rand() with reproducible results
rand() drew from the generator shared by the whole program, which hands
out values to whichever thread asks first, so r.mapcalc evaluated
expressions with rand() on one thread only, whatever nprocs was.

Every rand() call draws one value per cell of a row, and every row
evaluates every call once, so a row draws columns times calls values.
r.mapcalc now builds an exact random number layout from the seed with a
row as its unit (in r3.mapcalc, the rows of all depths one after
another), and each row places a generator state of its own at its stream
before it is evaluated. The state is local to the evaluation of the row,
so the draws of different threads do not write to the same cache line.
The streams of an exact layout are the parts of the sequence which the
rows drew when they were evaluated one after another from the shared
generator, so the result for a seed stays the same as in earlier
versions and is the same for any nprocs, and rand() no longer turns off
parallel evaluation. A warning is given when the whole computation draws
more than 2^46 values, after which the values repeat plus a constant.

The seed is read with G_random_seed_from_option(), so a seed outside
-2147483648 to 4294967295 is now an error rather than being silently
reduced to its low 32 bits, and a seed with trailing characters is an
error rather than being cut off. The automatic seed comes from
G_random_generate_seed(), so it can be set with GRASS_RANDOM_SEED or
SOURCE_DATE_EPOCH, and is still written to the history.

rand() stays in the calc library. The library gains
calc_set_random_state(), through which a tool gives rand() a function
returning the calling thread's generator state, and r.mapcalc sets it to
the state it places for the row. When no function is set, rand() draws
from the generator shared by the whole program as before, so other users
of the library get the same values as before.

This description and the code were written with Claude Code (Claude Opus 5.5 and earlier Claude models) and reviewed by the author of the PR.

rand() drew from the generator shared by the whole program, which hands
out values to whichever thread asks first, so r.mapcalc evaluated
expressions with rand() on one thread only, whatever nprocs was.

Every rand() call draws one value per cell of a row, and every row
evaluates every call once, so a row draws columns times calls values.
r.mapcalc now builds an exact random number layout from the seed with a
row as its unit (in r3.mapcalc, the rows of all depths one after
another), and each thread places a generator state of its own at the
row's stream before evaluating the row. The streams of an exact
layout are the parts of the sequence which the rows drew when they were
evaluated one after another from the shared generator, so the result
for a seed stays the same as in earlier versions and is the same for any
nprocs, and rand() no longer turns off parallel evaluation. A warning is
given when the whole computation draws more than 2^46 values, after
which the values repeat plus a constant.

The seed is parsed as a long long, since a long does not hold the range
of seeds on Windows, and checked right after parsing. A seed outside
-2147483648 to 4294967295 is now an error rather than being silently
reduced to its low 32 bits, and a seed with trailing characters is an
error rather than being cut off. The automatic seed comes from
G_random_generate_seed(), so it can be set with GRASS_RANDOM_SEED or
SOURCE_DATE_EPOCH, and is still written to the history.

rand() stays in the calc library. The library gains
calc_set_random_state(), through which a tool gives rand() a function
returning the calling thread's generator state, and r.mapcalc sets it to
the state it places for the row. When no function is set, rand() draws
from the generator shared by the whole program as before, so other users
of the library get the same values as before.

Written with the help of Claude Code.
Add the SPDX lines to the new test file and describe a row's random
numbers as its stream, the term the library documentation uses.

Written with the help of Claude Code.
Call G_random_state_from_seed() and G_random_layout_batches(), the new
names of G_random_seed() and G_random_layout_runs() in libgis. The
values of rand() do not change.

Written with the help of Claude Code.
The libgis functions take and return the seed as int64_t, so the tool
keeps it in that type, printed with PRId64, and forms the counts it
passes to the layout in it.

Written with the help of Claude Code.
The type comes with grass/gis.h, but code which uses it includes the
header itself rather than relying on that.

Written with the help of Claude Code.
The seed range, the environment variables behind a generated seed and
the comparison with earlier versions go: a bad seed is refused with a
message, the variables are documented on the environment variables
page, and the comparison belongs to the release notes. What stays is
that the result does not depend on nprocs and depends on all rand()
calls of the expression.

Written with the help of Claude Code.
Written with the help of Claude Code.
The function parses and checks the seed with the rules of the library,
in place of the tool's own strtoll() and the throwaway state it built
to have the range checked before the output maps are created.
The warning joined two complete clauses with a semicolon and had no
final period. The style guide asks for periods after complete
sentences, and most messages with two sentences use them.

Written with the help of Claude Code.
The per-thread states were adjacent elements of one array, 8 bytes
each, so up to eight threads wrote to the same cache line with every
draw. Each row now places a state local to its evaluation, and the
array only holds a pointer to it, written once per row.

With 48 rand() calls per cell on a region of 2000 by 8000 cells, the
best of three runs took:

nprocs  before   after
1       5.16 s   5.17 s
2       3.03 s   2.80 s
4       1.96 s   1.67 s
8       2.47 s   1.40 s
16      2.04 s   1.56 s

The results are the same as before for every nprocs.
calc_set_random_state() and rand() are public, so they get Doxygen
comments in the source file, and the header keeps only the prototype.
The static parts get short comments, and the comment no longer
describes what rand() did in earlier versions.
The comment on the layout now says what the layout is instead of what
rand() drew in earlier versions. The comments on the order of
evaluation are dropped. The arguments of a function and the expressions
of a row are evaluated one after another on the thread of the row,
since G_init_workers() is never called, so the worker threads of
G_begin_execute() do not exist. The tests of the order of the rand()
calls fail if that changes. The reason the size check is only a warning
is in the PR description instead.
The comment now says why following a variable would count the rand()
calls of its value again for every use, while they draw once per cell.
The function counting the calls of all expressions gets a short
comment too.
Saying that the values depend on the number of rand() calls is enough,
and it makes the explanation after it unnecessary.
A test that a feature no longer turns off parallel evaluation would
not be written for new code, and the message it checked is not about
rand(). The test file docstring no longer describes earlier versions;
that history is in the PR description.
One test of three expressions of the three types replaces the tests of
each type, of the order of expressions and of expressions from a file.
The tests that the result does not depend on nprocs or that a seed
gives the same result are dropped, since every test compares exact
values for nprocs 1 and 4. The tests of different seeds and of the
value range are dropped, since this change does not affect them, and
the two tests of invalid seeds are merged.
r3.mapcalc always evaluates on one thread, so the run with four
threads repeated the run with one.
The sentence on threads started with the negative. The values now come
first, with the number of threads in parentheses.
AGENTS.md asks for no decorative dividers.
@github-actions github-actions Bot added raster Related to raster data processing Python Related code is in Python C Related code is in C HTML Related code is in HTML libraries module docs markdown Related to markdown, markdown files tests Related to Test Suite labels Oct 27, 2026

@petrasovaa petrasovaa 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.

raster/r.mapcalc/tests/r_mapcalc_nprocs_test.py has a skipped test

@pytest.mark.skip(reason="Waiting for a fix to work on all platforms for any nprocs")

The file tests error states, and rand() without a seed is not one
since automatic seeding. The test was skipped because parallel rand()
failed differently per platform; rand() without a seed is covered by
the automatic seed tests, which run with the default nprocs.
@wenzeslaus

Copy link
Copy Markdown
Member Author

So, that skipped test can now be removed. It is not an error state anymore because rand() is fine without seed (#6476 which added the test was trying to test different error states). The test was disabled because nprocs at that time was apparently crashing with nprocs.

@wenzeslaus
wenzeslaus requested a review from petrasovaa October 27, 2026 12:40
@wenzeslaus wenzeslaus changed the title r.mapcalc: Make rand() independent of the number of threads r.mapcalc: Enable parallel rand() with reproducible results Oct 27, 2026
@wenzeslaus

Copy link
Copy Markdown
Member Author

This is actually enabling the parallelization itself, not just making it reproducible, so I updated the title. My original title was underselling the impact of this. So the parallelization is all new for rand(), although technically, we could have enabled parallelization with irreproducible results and worse performance after #6480 (that's the gray dashed line in the plots).

So, now, rand() no longer slows down r.mapcalc expressions, so expressions with rand() run 2-6 times faster with 8-16 threads on 52-420M cells. An expression with rand() alone runs 1.2 times faster on 52 cells.

@petrasovaa petrasovaa 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.

This is great!

@wenzeslaus
wenzeslaus merged commit 14b078e into OSGeo:main Oct 29, 2026
28 of 29 checks passed
@wenzeslaus
wenzeslaus deleted the mapcalc-rand-layout branch October 29, 2026 02:07
@github-actions github-actions Bot added this to the 8.6.0 milestone Oct 29, 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 docs HTML Related code is in HTML libraries markdown Related to markdown, markdown files 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.

2 participants