2

I have written a simulation in Rust which was previously written in Julia. I need to make sure that the results confirm; the simulation relies heavily on random numbers. I don't want to use any brute-force methods (such as averaging over a big number of runs); I'd rather make sure that the sequence of the random numbers is the same in both languages when using the same seed and the same RNG method.

To start with, I wrote a small piece of code to test generating the random numbers (Julia version 1.11.6 and rustc version 1.89.0):

Julia:

using Random, Printf

const SEED = 0xDEADBEEF
rng = Xoshiro(SEED)                  
xs = rand(rng, Float64, 100)         

for x in xs
    @printf("%.17f\n", x)
end

and Rust:

use rand::{Rng, SeedableRng};
use rand_xoshiro::Xoshiro256PlusPlus;

const STATIC_SEED :u64 =0xDEADBEEF; 

fn main() {
    let mut rng = Xoshiro256PlusPlus::seed_from_u64(STATIC_SEED); 

    for _ in 0..100 {
        let x: f64 = rng.random::<f64>(); 
        println!("{:.17}", x);
    }
}

My Rust Cargo.toml looks like this:

[package]
name = "xoshiro_match"
version = "0.1.0"
edition = "2024"

[dependencies]
rand = "0.9.2"
rand_xoshiro = "0.7.0"

However, these produce a completely different set of numbers! Did I miss something, or is there a fundamental problem with this approach?

6
  • 3
    I think your idea will never work. From the Julia package page: > WARNING! Because the precise way in which random numbers are generated is considered an implementation detail, bug fixes and speed improvements may change the stream of numbers that are generated after a version change. docs.julialang.org/en/v1/stdlib/Random Commented Aug 25 at 12:02
  • 1
    can't you save the numbers generated in julia and use them? Commented Aug 25 at 13:22
  • 7
    I would not assume that, just because the methods are both named "Xoshiro", they are identical. I'd expect the name to indicate the general concept used by the algorithm, but not the bit-by-bit low-level details. Commented Aug 25 at 14:24
  • 7
    The 64bit integer seed is not used directly, but is run through a function to generate the initial state 256 bits. Currently the Julia version seems to use SHA2_256, while the Rust version seems to use SplitMix64. Maybe try specifying 256 bits. (The Julia version looks like it can take four 64bit Integers. The Rust version looks like it can take a 32 byte array. Bytes of an Integer might need to be reversed, little-endian.) Commented Aug 25 at 14:28
  • 2
    In addition to the seeding difficulties, most rngs produce random bits and the way the language extracts a float from random bits may vary. Commented Aug 26 at 11:54

1 Answer 1

3

The most obvious, if perhaps a tad painful, way is to only use one of these languages to generate your floats. We'll use Rust to generate a dylib and link to it with Julia’s ccall.

Cargo.toml:

[package]
    edition = "2024"
    name = "rs-jl-rand"
    version = "0.1.0"

[dependencies]
    rand = "0.9.2"

[lib]
    crate-type = ["rlib", "cdylib"]

lib.rs:

use std::sync::{LazyLock, Mutex};
use rand::{Rng, SeedableRng, rngs::StdRng};

static RNG: LazyLock<Mutex<StdRng>> = LazyLock::new(|| Mutex::new(StdRng::seed_from_u64(42)));

pub fn rand_float() -> f64 {
    RNG.lock().unwrap().random()
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn c_rand_float() -> f64 {
    rand_float()
}

main.rs

use rs_jl_rand::rand_float;

fn main() {
    let rand_nums = [(); 5].map(|_| rand_float());
    println!("{:#?}", rand_nums)
}

At this point you should build with cargo build --release, and then run with cargo run --release (I don't know if RNG produces the same output in debug and release builds and didn't bother checking). Rust’s output:

[
    0.5265574090027738,
    0.5427252099031439,
    0.6364650991438949,
    0.4059017582307767,
    0.034342817954956195,
]

Now, in Julia:

using Libdl

dlopen("./target/release/librs_jl_rand") do handle
    rand_float = dlsym(handle, "c_rand_float")
    [ccall(rand_float, Float64, ()) for _ in 1:5]
end
5-element Vector{Float64}:
 0.5265574090027738
 0.5427252099031439
 0.6364650991438949
 0.4059017582307767
 0.034342817954956195

As expected, they match.

This is an MVP with plenty of room for extension. For instance, to reduce time spent locking and unlocking the mutex (which in any case should be almost nothing since it's uncontested), rand_float could return a whole batch of floats instead of just one. You could also imagine adding reseedability.

Sign up to request clarification or add additional context in comments.

2 Comments

pub fn random_matrix<R: Rng + ?Sized>(rows: usize, cols: usize, rng: &mut R) -> Array2<f64> { Array2::from_shape_simple_fn((rows, cols), || rng.r#gen::<f64>()) } I used this to create the matrices needed, then used ndarray_npy to save them as .npy . Now they could easily be both loaded to my julia variant of the software and re-used as deterministic 'random' matrices in my Rust-variant. So I guess my solution is the same in its essentials.
If you're fine pre-generating random numbers then sure, that works.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.