pic_rng.f90 Source File

Reproducible pseudo-random number generation with explicit state.

The Fortran intrinsic random_number is deliberately unspecified: every compiler is free to ship a different engine, a different seeding rule and a different number of seed words. A code whose results depend on it therefore cannot be regression tested across GNU, Intel, NVIDIA HPC, LFortran, Cray or Flang. pic_rng provides explicit-state counter/LCG generators whose output is a pure function of the state, so the same seed yields the same bits on every compiler and every platform.


This file depends on

sourcefile~~pic_rng.f90~~EfferentGraph sourcefile~pic_rng.f90 pic_rng.f90 sourcefile~pic_error.f90 pic_error.F90 sourcefile~pic_rng.f90->sourcefile~pic_error.f90 sourcefile~pic_types.f90 pic_types.F90 sourcefile~pic_rng.f90->sourcefile~pic_types.f90 sourcefile~pic_uint64.f90 pic_uint64.f90 sourcefile~pic_rng.f90->sourcefile~pic_uint64.f90 sourcefile~pic_error.f90->sourcefile~pic_types.f90 sourcefile~pic_global_definitions.f90 pic_global_definitions.f90 sourcefile~pic_error.f90->sourcefile~pic_global_definitions.f90 sourcefile~pic_uint64.f90->sourcefile~pic_types.f90 sourcefile~pic_global_definitions.f90->sourcefile~pic_types.f90

Files dependent on this one

sourcefile~~pic_rng.f90~~AfferentGraph sourcefile~pic_rng.f90 pic_rng.f90 sourcefile~pic_random_dist.f90 pic_random_dist.f90 sourcefile~pic_random_dist.f90->sourcefile~pic_rng.f90 sourcefile~pic_random_dist_real.f90 pic_random_dist_real.f90 sourcefile~pic_random_dist_real.f90->sourcefile~pic_rng.f90

Source Code

! SPDX-License-Identifier: MIT
! Copyright (c) 2025 Jorge Luis Galvez Vallejo
!! Reproducible pseudo-random number generation with explicit state.
!!
!! The Fortran intrinsic `random_number` is deliberately unspecified: every
!! compiler is free to ship a different engine, a different seeding rule and a
!! different number of seed words. A code whose results depend on it therefore
!! cannot be regression tested across GNU, Intel, NVIDIA HPC, LFortran, Cray or
!! Flang. `pic_rng` provides explicit-state counter/LCG generators whose output
!! is a pure function of the state, so the same seed yields the same bits on
!! every compiler and every platform.
module pic_rng
   !! Portable, reproducible pseudo-random number generators.
   !!
   !! Two engines are provided:
   !!
   !! * `splitmix64_t` - SplitMix64 (Steele, Lea and Flood, 2014; reference
   !!   implementation by Sebastiano Vigna, public domain). A 64-bit counter
   !!   plus a strong finalising mix. Tiny, fast, and ideal for seeding other
   !!   generators.
   !! * `pcg32_t` - PCG-XSH-RR 64/32 (O'Neill, 2014). A 64-bit LCG whose output
   !!   is a permuted 32-bit word. Supports 2**63 distinct streams through its
   !!   increment.
   !!
   !! Both types expose `seed`, `next` and `next_u64`, and both work with the
   !! module-level generics `next_real_dp`, `next_below` and `stream_for`.
   !!
   !! ### Portability notes (the whole reason this module exists)
   !!
   !! 1. **No BOZ literals anywhere.** The 64-bit constants of SplitMix64 have
   !!    their top bit set, so `z'9E3779B97F4A7C15'` is not representable as a
   !!    positive `integer(int64)`. Compilers disagree on BOZ literals appearing
   !!    in initialisation expressions (gfortran, ifx and nvfortran all differ in
   !!    what they accept and what they warn about). Every constant below is
   !!    therefore written as an ordinary signed decimal literal equal to the
   !!    two's-complement interpretation of the intended bit pattern, with the
   !!    hexadecimal value given in a comment.
   !! 2. **No reliance on signed overflow.** Fortran has no unsigned integer
   !!    type, and signed overflow is not defined by the standard: a build with
   !!    `-ftrapv` or `-fsanitize=signed-integer-overflow` may abort on it. All
   !!    modular arithmetic goes through `u64_add` and `u64_mul` in `pic_uint64`,
   !!    which work on limbs so that no intermediate ever leaves the signed
   !!    64-bit range, and reassemble with `ior`/`ishft`, which are bit-model
   !!    intrinsics and exactly defined for every bit pattern, sign bit
   !!    included. See that module for the limb widths and the bounds; they are
   !!    stated once, where they are implemented.
   !! 3. **No `transfer` between reals and integers.** nvfortran and LFortran do
   !!    not agree on the result of bit-casting a double to an integer. Reals are
   !!    built arithmetically instead (see `next_real_dp`).
   !! 4. **No right shift by a negative count and no F2008 shift intrinsics.**
   !!    `ishft(x, -n)` is standard but its interaction with the sign bit is a
   !!    recurring source of confusion, and `shiftr`/`shifta` are Fortran 2008
   !!    and not uniformly available on the older compilers PIC targets. Every
   !!    logical right shift goes through `u64_shr` in `pic_uint64`, which is
   !!    built from `ibits`: Fortran 90, zero-filling by definition, and correct
   !!    for negative `x`. `ishft` is used only with non-negative (left-shift)
   !!    counts.
   use pic_types, only: default_int, dp, int32, int64
   use pic_error, only: error_t, ERROR_VALIDATION
   use pic_uint64, only: u64_add, u64_mul, u64_shr
   implicit none
   private

   public :: splitmix64_t
   public :: pcg32_t
   public :: next_real_dp
   public :: next_below
   public :: stream_for

   integer(int64), parameter :: MASK32 = 4294967295_int64
      !! Low 32-bit mask (2**32 - 1).
   integer(int64), parameter :: TWO_TO_31 = 2147483648_int64
      !! 2**31, the first value that does not fit in a signed 32-bit integer.
   integer(int64), parameter :: TWO_TO_32 = 4294967296_int64
      !! 2**32, used to fold an unsigned 32-bit value into `int32`.

   integer(int64), parameter :: GOLDEN_GAMMA = -7046029254386353131_int64
      !! SplitMix64 additive constant, bit pattern 0x9E3779B97F4A7C15.
   integer(int64), parameter :: MIX_C1 = -4658895280553007687_int64
      !! SplitMix64 first multiplier, bit pattern 0xBF58476D1CE4E5B9.
   integer(int64), parameter :: MIX_C2 = -7723592293110705685_int64
      !! SplitMix64 second multiplier, bit pattern 0x94D049BB133111EB.

   integer(int64), parameter :: PCG_MULTIPLIER = 6364136223846793005_int64
      !! PCG 64-bit LCG multiplier, bit pattern 0x5851F42D4C957F2D.
   integer(int64), parameter :: PCG_DEFAULT_INCREMENT = 1442695040888963407_int64
      !! Default PCG stream increment, bit pattern 0x14057B7EF767814F.

   real(dp), parameter :: TWO_TO_MINUS_53 = 1.0_dp/9007199254740992.0_dp
      !! 2**-53, exactly representable in IEEE binary64.

   type :: splitmix64_t
      !! SplitMix64 generator: a 64-bit counter run through a strong finaliser.
      integer(int64) :: state = 0_int64
         !! Explicit 64-bit state. Fixed width on purpose: the algorithm is
         !! defined on 64-bit words and must not follow `default_int`.
   contains
      procedure :: seed => splitmix64_seed
      procedure :: next => splitmix64_next
      procedure :: next_u64 => splitmix64_next_u64
   end type splitmix64_t

   type :: pcg32_t
      !! PCG-XSH-RR 64/32 generator: 64-bit LCG state with a 32-bit output
      !! permutation, plus a per-stream odd increment.
      integer(int64) :: state = 0_int64
         !! Explicit 64-bit LCG state (fixed width, not `default_int`).
      integer(int64) :: increment = PCG_DEFAULT_INCREMENT
         !! Stream increment; always odd, which is what makes streams distinct.
   contains
      procedure :: seed => pcg32_seed
      procedure :: next => pcg32_next
      procedure :: next_u64 => pcg32_next_u64
   end type pcg32_t

   interface next_real_dp
      !! Draw a uniform `real(dp)` in [0, 1).
      !!
      !! Construction, identical for both engines and on every compiler: take a
      !! fresh 64-bit word `w` from the generator, keep its top 53 bits
      !! `u = ibits(w, 11, 53)` (an integer in [0, 2**53)), and return
      !! `real(u, dp) * 2**-53`. Every integer below 2**53 is exact in IEEE
      !! binary64 and 2**-53 is a power of two, so the product is exact: no
      !! rounding, no compiler-dependent fused multiply-add, and the result can
      !! never reach 1.0. Values are therefore multiples of 2**-53 in
      !! [0, 1 - 2**-53].
      module procedure splitmix64_next_real_dp
      module procedure pcg32_next_real_dp
   end interface next_real_dp

   interface next_below
      !! Draw a uniform integer in [0, n) by rejection sampling.
      !!
      !! The naive `mod(next(), n)` is biased whenever `n` does not divide the
      !! generator's period: the first `2**64 mod n` residues each get one extra
      !! preimage, so for large `n` some outcomes are measurably more likely.
      !! Instead a mask of the smallest `2**k - 1` at least `n - 1` is applied to
      !! a fresh 64-bit word and values at or above `n` are rejected and redrawn.
      !! Every accepted value has exactly one preimage per draw, so the result is
      !! exactly uniform. The acceptance probability is above 1/2, so the
      !! expected number of draws is below 2 and the loop terminates with
      !! probability one.
      !!
      !! `n == 1` returns 0 without consuming a draw. `n <= 0` is a usage error:
      !! it returns 0 and, if `error` is present, sets `ERROR_VALIDATION`.
      module procedure splitmix64_next_below
      module procedure pcg32_next_below
   end interface next_below

   interface stream_for
      !! Derive an independent, reproducible generator for a stream id.
      !!
      !! Parallel codes must not let the seed of a task depend on scheduling
      !! order. `stream_for` maps `(master_seed, stream_id)` to a generator
      !! through two SplitMix64 finalisers, so stream 7 is the same sequence
      !! whether it runs first, last or on another rank, and different ids give
      !! uncorrelated sequences.
      module procedure stream_for_splitmix64
      module procedure stream_for_pcg32
   end interface stream_for

contains

   pure function mix64(x) result(r)
      !! The SplitMix64 finalising mix (the "MurmurHash3-style" avalanche used
      !! by `splitmix64.c`). Used both as the generator output stage and as the
      !! seed derivation function for `stream_for`.
      integer(int64), intent(in) :: x
      integer(int64) :: r

      r = u64_mul(ieor(x, u64_shr(x, 30_default_int)), MIX_C1)
      r = u64_mul(ieor(r, u64_shr(r, 27_default_int)), MIX_C2)
      r = ieor(r, u64_shr(r, 31_default_int))
   end function mix64

   pure function rotate_right32(x, rot) result(r)
      !! Rotate the low 32 bits of `x` right by `rot` bits, `0 <= rot <= 31`.
      !! Branch free: when `rot` is zero the left-shifted term is shifted clean
      !! out of the low 32 bits and the mask removes it.
      integer(int64), intent(in) :: x
      integer(int64), intent(in) :: rot
      integer(int64) :: r

      r = ior(ibits(x, int(rot, default_int), int(32 - rot, default_int)), &
              iand(ishft(iand(x, MASK32), int(32 - rot, default_int)), MASK32))
   end function rotate_right32

   pure function u32_to_int32(u) result(r)
      !! Reinterpret an unsigned 32-bit value held in [0, 2**32) as the signed
      !! `int32` with the same bit pattern. Done arithmetically rather than with
      !! `transfer`, and without ever passing an out-of-range value to `int`.
      integer(int64), intent(in) :: u
      integer(int32) :: r

      if (u >= TWO_TO_31) then
         r = int(u - TWO_TO_32, int32)
      else
         r = int(u, int32)
      end if
   end function u32_to_int32

   pure function rejection_mask(n) result(mask)
      !! Smallest `2**k - 1` that is at least `n - 1`, for `n >= 1`.
      integer(int64), intent(in) :: n
      integer(int64) :: mask

      mask = 0_int64
      do while (mask < n - 1_int64)
         mask = ior(ishft(mask, 1), 1_int64)
      end do
   end function rejection_mask

   pure function real_from_word(word) result(r)
      !! Turn a 64-bit random word into a `real(dp)` in [0, 1) using its top 53
      !! bits. See the `next_real_dp` interface documentation for the exactness
      !! argument.
      integer(int64), intent(in) :: word
      real(dp) :: r

      r = real(ibits(word, 11, 53), dp)*TWO_TO_MINUS_53
   end function real_from_word

   pure function stream_seed(master_seed, stream_id) result(s)
      !! Deterministic seed for `stream_id` under `master_seed`.
      !!
      !! `mix64` is applied to the master seed, the stream id is spread over the
      !! whole word by multiplying `stream_id + 1` by the golden-ratio constant,
      !! the two are combined with `ieor`, and the result is mixed once more.
      !! Adding one to the id keeps stream 0 from collapsing the multiplication
      !! to zero. Only bit-model and modular arithmetic is used, so the mapping
      !! is identical on every compiler.
      integer(int64), intent(in) :: master_seed
      integer(default_int), intent(in) :: stream_id
      integer(int64) :: s

      s = mix64(ieor(mix64(master_seed), &
                     u64_mul(u64_add(int(stream_id, int64), 1_int64), GOLDEN_GAMMA)))
   end function stream_seed

   subroutine splitmix64_seed(self, s)
      !! Set the generator state. Any 64-bit value is a valid seed, including
      !! zero: SplitMix64 has no degenerate state.
      class(splitmix64_t), intent(inout) :: self
      integer(int64), intent(in) :: s

      self%state = s
   end subroutine splitmix64_seed

   function splitmix64_next(self) result(r)
      !! Advance the state by the golden-ratio increment and return the mixed
      !! 64-bit output.
      class(splitmix64_t), intent(inout) :: self
      integer(int64) :: r

      self%state = u64_add(self%state, GOLDEN_GAMMA)
      r = mix64(self%state)
   end function splitmix64_next

   function splitmix64_next_u64(self) result(r)
      !! Full 64-bit random word. For SplitMix64 this is simply `next`; it
      !! exists so that both engines present the same interface to the shared
      !! real and bounded-integer conversions.
      class(splitmix64_t), intent(inout) :: self
      integer(int64) :: r

      r = self%next()
   end function splitmix64_next_u64

   subroutine pcg32_seed(self, state, seq)
      !! Seed the generator, following the reference `pcg32_srandom_r`: the
      !! stream selector is shifted left and forced odd to become the LCG
      !! increment, then the state is warmed up, offset by the requested state
      !! and warmed up again.
      class(pcg32_t), intent(inout) :: self
      integer(int64), intent(in) :: state
      integer(int64), intent(in) :: seq

      integer(int64) :: discard

      self%increment = ior(ishft(seq, 1), 1_int64)
      self%state = 0_int64
      discard = pcg32_step(self)
      self%state = u64_add(self%state, state)
      discard = pcg32_step(self)
   end subroutine pcg32_seed

   function pcg32_step(self) result(r)
      !! Advance the LCG one step and return the XSH-RR permutation of the
      !! *previous* state as an unsigned value in [0, 2**32).
      class(pcg32_t), intent(inout) :: self
      integer(int64) :: r

      integer(int64) :: old

      old = self%state
      self%state = u64_add(u64_mul(old, PCG_MULTIPLIER), self%increment)
      r = rotate_right32(ibits(ieor(u64_shr(old, 18_default_int), old), 27, 32), &
                         ibits(old, 59, 5))
   end function pcg32_step

   function pcg32_next(self) result(r)
      !! Next 32-bit output, returned as the `int32` with the same bit pattern
      !! as the reference implementation's `uint32_t` result. Roughly half of
      !! the returned values are therefore negative; use `next_real_dp` or
      !! `next_below` if a magnitude is wanted.
      class(pcg32_t), intent(inout) :: self
      integer(int32) :: r

      r = u32_to_int32(pcg32_step(self))
   end function pcg32_next

   function pcg32_next_u64(self) result(r)
      !! Full 64-bit random word built from two consecutive 32-bit outputs; the
      !! first output supplies the high half. Fixing the order here is what makes
      !! `next_real_dp` and `next_below` reproducible for this engine.
      class(pcg32_t), intent(inout) :: self
      integer(int64) :: r

      integer(int64) :: high, low

      high = pcg32_step(self)
      low = pcg32_step(self)
      r = ior(ishft(high, 32), low)
   end function pcg32_next_u64

   function splitmix64_next_real_dp(gen) result(r)
      !! Uniform `real(dp)` in [0, 1) from a SplitMix64 generator.
      type(splitmix64_t), intent(inout) :: gen
      real(dp) :: r

      r = real_from_word(gen%next_u64())
   end function splitmix64_next_real_dp

   function pcg32_next_real_dp(gen) result(r)
      !! Uniform `real(dp)` in [0, 1) from a PCG32 generator. Consumes two
      !! 32-bit outputs.
      type(pcg32_t), intent(inout) :: gen
      real(dp) :: r

      r = real_from_word(gen%next_u64())
   end function pcg32_next_real_dp

   function splitmix64_next_below(gen, n, error) result(r)
      !! Uniform integer in [0, n) from a SplitMix64 generator.
      type(splitmix64_t), intent(inout) :: gen
      integer(default_int), intent(in) :: n
      type(error_t), intent(inout), optional :: error
      integer(default_int) :: r

      integer(int64) :: bound, mask, candidate

      r = 0_default_int
      if (n <= 0_default_int) then
         if (present(error)) then
            call error%set(ERROR_VALIDATION, "pic_rng: next_below requires n > 0")
         end if
         return
      end if
      if (n == 1_default_int) return

      bound = int(n, int64)
      mask = rejection_mask(bound)
      do
         candidate = iand(gen%next_u64(), mask)
         if (candidate < bound) exit
      end do
      r = int(candidate, default_int)
   end function splitmix64_next_below

   function pcg32_next_below(gen, n, error) result(r)
      !! Uniform integer in [0, n) from a PCG32 generator. Each attempt consumes
      !! two 32-bit outputs, so the stream stays word aligned no matter how many
      !! rejections occur.
      type(pcg32_t), intent(inout) :: gen
      integer(default_int), intent(in) :: n
      type(error_t), intent(inout), optional :: error
      integer(default_int) :: r

      integer(int64) :: bound, mask, candidate

      r = 0_default_int
      if (n <= 0_default_int) then
         if (present(error)) then
            call error%set(ERROR_VALIDATION, "pic_rng: next_below requires n > 0")
         end if
         return
      end if
      if (n == 1_default_int) return

      bound = int(n, int64)
      mask = rejection_mask(bound)
      do
         candidate = iand(gen%next_u64(), mask)
         if (candidate < bound) exit
      end do
      r = int(candidate, default_int)
   end function pcg32_next_below

   subroutine stream_for_splitmix64(master_seed, stream_id, gen)
      !! Build the SplitMix64 generator belonging to `stream_id`.
      integer(int64), intent(in) :: master_seed
      integer(default_int), intent(in) :: stream_id
      type(splitmix64_t), intent(out) :: gen

      call gen%seed(stream_seed(master_seed, stream_id))
   end subroutine stream_for_splitmix64

   subroutine stream_for_pcg32(master_seed, stream_id, gen)
      !! Build the PCG32 generator belonging to `stream_id`. The derived seed
      !! drives a local SplitMix64 which supplies both the LCG state and the
      !! stream selector.
      integer(int64), intent(in) :: master_seed
      integer(default_int), intent(in) :: stream_id
      type(pcg32_t), intent(out) :: gen

      type(splitmix64_t) :: seeder
      integer(int64) :: initial_state, initial_seq

      call seeder%seed(stream_seed(master_seed, stream_id))
      ! Two separate statements on purpose: Fortran does not specify the order in
      ! which actual arguments are evaluated, so two `seeder%next()` calls inside
      ! one call statement would not be reproducible across compilers.
      initial_state = seeder%next()
      initial_seq = seeder%next()
      call gen%seed(initial_state, initial_seq)
   end subroutine stream_for_pcg32

end module pic_rng