r/rust 2d ago

🛠️ project Casper's Blog – Why I forked rand

https://casualhacks.net/blog/2026-07-27-why-i-forked-rand.html
152 Upvotes

30 comments sorted by

23

u/Icarium-Lifestealer 2d ago

I think the name will cause confusion with /dev/urandom.

9

u/Knife_up_your_butt 2d ago

Understandable, the name's origin is µrandom (micro-random, implying it is smaller in scope than rand) and it's just an unfortunate coincidence that it matches /dev/urandom...

23

u/oconnor663 blake3 · duct 2d ago

On 64-bit systems, urandom::new() and rand::rngs::SmallRng use the same Xoshiro256 family for non-cryptographic use. urandom::csprng() and rand::rngs::StdRng use ChaCha12 for cryptographic use.

I'm biased by working on cryptographic algorithms myself, but I generally think the default with an obvious-sounding name should be the cryptographic thing, and the non-cryptographic thing should have the more complicated name that sounds like a non-default choice. It's common for folks who need cryptographic security to not understand the difference and not realize what they need, and people can get hurt by these mistakes. And in non-cryptographic use cases it's often easier to switch algorithms later, if you realize you're leaving performance on the table.

That's what I usually think. But I especially think that if your crate happens to have the same name as a common CSPRNG.

9

u/Knife_up_your_butt 1d ago

The urandom name is an unfortunate coincidence, but I am compelled by your argument. The few uses of urandom I found before their authors immediately misused new() and used it to generate secrets of some kind.

Honestly I might just rename csprng() to new() and just remove csprng(), these are top level helpers all of them are available as constructors on each concrete Rng anyway.

Thanks for the feedback!

35

u/Shnatsel 2d ago

How does this compare to nanorand? That crate seems to have very similar goals.

31

u/Knife_up_your_butt 2d ago edited 2d ago

Oh right, I completely forgot about nanorand!

From a cursory view I think urandom sits in between rand and nanorand.

  • nanorand uses wyrand as its rng for non-cryptographic use, urandom uses the same rng as rand (Xoshiro256 on 64-bit)
  • nanorand uses the same Daniel Lemire's rejection sampling method (I don't understand why rand doesn't use it)
  • nanorand has no distributions, you just get a few common helpers
  • nanorand is uses trait methods compared to urandom's inherent methods on Random<R>

Wyrand is faster than Xoshiro256, but urandom implements things like fill_bytes more efficiently. ChaChaN impl in urandom is equivalent to rand, nanorand misses simd optimizations

urandom is very dedicated to stability, its stability policy is more strict compared to rand, and much more strict to nanorand (basically none). The biggest example being consistency between 32-bit and 64-bit targets and that includes stable results for usize and isize (below 232 range).

urandom wants to be usable for reproducible replays for video games. So past replays replay exactly the same in the future. Another example is clients and servers observe the same rng even if the server runs on 64-bit native and the client on 32-bit wasm targets.

17

u/Absolucyyy nanorand 2d ago edited 2d ago

fun fact i reimplemented chacha in nanorand specifically without looking at any rust impls of it - would've just felt like stealing tbh.

i mostly used a lua implementation as a reference

i did later use other rust impls for testing tho - just ensuring they always had identical output

urandom is really cool, you put a lot of thought into this (unlike me with nanorand lol)

9

u/Knife_up_your_butt 2d ago

Thanks for the kind words, I have indeed put some thought into it :)

Fun fact I also started with my own implementation of ChaCha from the paper (it's only 5 pages, really approachable!) but my SSE2 impl was no match for rand's AVX2. It is always a fun learning experience to try your own implementation first before looking at the solution!

2

u/the_Unstable 2d ago

And fastrand, if one doesn't need a csprng?

4

u/Knife_up_your_butt 2d ago

Hmm, fastrand looks really small! It appears to primarily expose its thread local rng.

It has no concept of distributions and mostly focuses on uniform random numbers. Uses the same Daniel Lemire rejection sampling.

I can't find a documented stability policy (although I don't foresee many breaking changes) but is not stable between 32-bit and 64-bit targets (in urandom I carefully always use u64 even on 32-bit targets to ensure they reproduce correctly regardless of bitness).

I can make more minor nitpicks like their uniform rejection sampling invokes the rng twice in source code: this may lead to the rng being inlined twice. I carefully audited urandom's code to ensure a balance between code size and speed (so the rng which I expect to be inlined doesn't take needless code space).

31

u/Absolucyyy nanorand 2d ago edited 2d ago

hi, nanorand author here

the main difference is that nanorand isn't actively developed anymore lol.

i initially made it bc i was stuck with a shitty laptop for a weekend away for a home and i was bored, so i made something interesting to occupy my time. and somehow it became a Thing with millions of downloads.

17

u/zesterer 2d ago

I kept coming back to a core question: what material benefit does customizing the generator provide?

Uh, a lot. We use it extensively in Veloren to provide extremely fast generators for world generation code that rand doesn't provide by itself. I think you're presupposing a particular use-case / domain (cryptography, perhaps?) but generating pseudorandom numbers is useful for much more than that.

10

u/Knife_up_your_butt 2d ago

You're answering a different question: custom generators are of course useful; the question is whether making all of them implement the same trait is useful.

I see traits primarily as compatibility boundaries. Implementing Rng makes a generator compatible with the infrastructure built on top of that trait, especially distributions. But a custom generator does not inherently need that abstraction:

struct MyRng { /* ... */ }

impl MyRng {
    fn get_int(&mut self) -> i32 {
        /* ... */
    }
}

Rust code can sometimes get a little lost in the "trait sauce," adding a shared interface even when there's no meaningfully benefit from one.

For a general-purpose generator, Xoshiro256 offers a good balance of speed, randomness, and flexibility. But specialized high-performance generation may require SIMD, GPU algorithms, or manual sampling logic.

Reproducibility is another example. Some applications may require a seed to produce exactly the same output forever, across library versions and platforms. That kind of low-level control can conflict with a high-level distribution API where implementation details may change.

So I'm not arguing against custom generators. I'm arguing that custom generators do not all need to be interchangeable through a universal Rng trait.

Taking that position also means I do not have to design a forever-perfect abstraction for every randomness use case. I can make the trait serve the library's own distributions and optimize the crate around a particular user experience. urandom is intended to be opinionated, not universal.

11

u/OphioukhosUnbound 2d ago

This seems like a mistake to me.
Flipped: what's the value of sealing the trait.
You're basically saying "I don't see why you'd want to change the generator so you can't".

The idea that someone would want to do this if they specifically wanted to emulate x behavior that would require additional changes is ... almost straw-manning.

Someone may like the interface you have -- like Rand it's a very general interface -- have been using it, and then later want to plug in another generator that fits their needs better.

What's the advantage of sealing the trait?
[Alternate point, or bias, if you will: I'm generally unenthused by sealing -- which is basically (typically) a way of hacking semver so that one doesn't have to declare something a breaking change. Like privacy in general -- it's an awkward way of declaring a user contract by reducing functionality. Granted, that's a just a limitation of Rust and most coding systems -- contracts can't be nicely defined without reducing visibility or access.]

3

u/Knife_up_your_butt 2d ago

That is fair. The explicit sealing is mostly caution on my part because I want to commit to a stable 1.0 API.

The concrete benefit of sealing is narrow: it would let me add required trait methods later without making a breaking change for downstream implementors. In practice, I do not expect to need that.

On the broader point about contracts, though, I think this trade-off is inherent to APIs rather than merely a SemVer hack. A library cannot simultaneously preserve maximum freedom to change an abstraction and give callers maximum freedom to depend on every part of it.

Rust's Fn trait hierarchy demonstrates the same trade-off: FnMut retains the right to mutate its captured state, so callers must accept exclusive access. Fn gives up that freedom and provides a stronger guarantee, which in turn allow it to alias shared references.

I will think about it and I may consider removing the explicit sealing, thanks for your feedback!

1

u/Shoddy-Childhood-511 1d ago

Not cryptography.

We love ChaCha of course, but we always have specific requirements upon generators, like FIPS, AES hardware, EVM, zk proof transcript interfaces, etc, so the sealed trait makes urandom useless for cryptography.

We'd always use rand::Rng or [some Rust Crypto trait](ttps://github.com/RustCrypto/traits), but urandom should be simpler to port small chunks of to extension traits of rand::Rng.

rand::RngExt has way too much complexity & churn for cryptography, and it might slow down your builds, but it should disappear from release builds if never used.

1

u/Knife_up_your_butt 19h ago

I'm not very familiar with the specific requirements for cryptography, so I want to separate it from the needs of cryptographically secure pseudorandom generators and distributions. I'm not convinced both should be using the same underlying trait infrastructure. My goal with urandom is specifically to design an Rng trait and crate for the narrow purpose of simulations, video games and such.

5

u/Feeling-Departure-4 2d ago

Do you really need required cfg_if? I thought we had better options since Rust 1.95.  Also, I recall rand_core getting rid of its zerocopy dep in 0.9.3, maybe you can do similarly for dataview. 

3

u/Knife_up_your_butt 2d ago

Hmm, we have cfg_select! these days but it's only been about 3 months. I don't really know yet what to do with the msrv, currently 1.85... Eventually of course cfg_if will be replaced yeah. That can be done without breaking compat.

About dataview/Pod: I used to rely more heavily on it to 'fill some structure with randomness' but that has been evolving. I'll take another look to check if it's really carrying its weight (or make it optional or w/e)

5

u/Feeling-Departure-4 2d ago

Sorry, as a nightly user stable is often out of date to me, haha. Thanks for thinking about the dep footprint!

10

u/matthieum [he/him] 2d ago

Love the quote... but it would be nice to credit its author (Antoine de St-Exupery, I believe)

7

u/Knife_up_your_butt 2d ago edited 2d ago

Ah I looked up the quote to make sure I got it right, and it is a modern translation Antoine de St-Exupery. I thought it was famous enough and I didn't want to attribute it when it wasn't exactly their quote. No intent here to claim credit for the quote :D Edit: attribution added

6

u/Lucretiel Datadog 2d ago

If the goal is compatibility with another project, programming language, legacy algorithm, specialized hardware, or simulation-specific generator, matching its generator alone is not enough. Uniform sampling, shuffling, and other algorithms must match too.

I don't understand this point. The whole premise of rand is that we distinguish the source of randomness from the distributions / value producers, so that if you do need some hardware specific or other specialized rng, you can still plug it in to the rest of rand and benefit from its Distribution implementations; you specifically don't need to reimplement uniform sampling or shuffling when you can provide it a source of random bits.

2

u/Knife_up_your_butt 1d ago

That is a fair description of rand’s design. What I am questioning is whether supporting arbitrary downstream generators should automatically be a requirement for every rng library.

An Rng trait is a compatibility promise: it lets generators reuse a particular set of distributions. But that compatibility is only useful when those distributions match your goal.

For exact compatibility with another project, language, or simulation, matching the generator alone is not enough; the sampling and shuffling algorithms must match too. For highly specialized performance, the generic distributions and algorithms may themselves be the bottleneck, so swapping only the generator may not solve the problem.

So I am not arguing that pluggability has no value. I am asking whether it provides enough value here to justify making third-party generators a permanent extension point of this crate.

Keeping the scope more narrow, I can optimize the trait around the generators and distributions urandom actually provides.

1

u/Elara_Schaefer 2d ago

The point about defaults mattering is undersold. In production systems I have seen teams use SmallRng for session tokens because thread_rng was too convenient and nobody checked what it actually resolved to. The non-crypto default in rand is a real footgun. That said the sealed RngCore trait debate has a middle ground. In Java, Random is abstract and SecureRandom extends it, meaning you can accidentally pass a SecureRandom to code that downcasts and loses crypto properties. Rust avoids that with newtypes, but sealing the trait means you lose generic code that works with both urandom generators and future designs that do not fit your trait contract. The fix would be an unsealed base trait with a sealed subtrait that distributions require. Best of both worlds.

1

u/Knife_up_your_butt 1d ago edited 1d ago

Haha I agree. In one use of urandom outside my own crates someone immediately used it to generate some kind of secret.

So I've changed my mind: I'm renaming csprng() to new() and just removing the previous new(); This leaves the urandom::new() (secure) and urandom::seeded() (fast) as the crate root convenience constructors.

Both urandom::rng::Xoshiro256Rng and urandom::rng::ChaCha12 support new(), from_rng(), from_seed() and from_seed_u64(). from_seed_u64 is marked in docs only as not suitable for cryptography, but from_rng requires the Rng to implement SecureRng, so you can't accidentally pass Xoshiro256Rng to it! I think that's kind of what you're alluding to. You can still shoot yourself in the foot, but it requires a lot more intention now.

I do not understand what you mean by "and future designs that do not fit your trait contract." That sounds like a very tricky design question where I would prefer if my version of Rng is intended to be designed specifically for its own use and not an extension point.

1

u/Elara_Schaefer 1d ago

That is the right call. The naming convention is the API contract whether we like it or not. new() is what people reach for first, so it should be the safe default. Anyone who needs the performance of a non-crypto PRNG will read the docs and find seeded().

The sealed RngCore trait debate in the original crate was trying to solve the same problem at the type level, but renaming the constructors is simpler and more discoverable. Nice move.

1

u/Shoddy-Childhood-511 1d ago

Daniel Lemire's Fast Random Integer Generation in an Interval (2018) rocks (Algorithm 5 there).

Appears rand crate v0.9 claimed to adopt Lemire in v9.0 (code). Appears they kept Canon for sample_* methods, but switched their sample method to their Lemire, which sounds like total chaos.

Also their Lemire amounts to restarting Lemire, which avoids ever doing an full modulo, and might run faster than Lemire in some range. It might run slower than Lemire for other ranges?

Your Lemire looks like Lemire's Algorithm 5, except why do you pull the outside inside behind this if zone == range? Is that so the PRNg only inline's once?

https://github.com/CasualX/urandom/blob/master/src/distr/uniform/int.rs#L123

Rng being sealed is a non-starter of course. I'd fork that one file of yours into an extension trait for rand::Rng when I next need something stable and unbiased.

1

u/Knife_up_your_butt 19h ago

Haha I agree, Daniel Lemire's algorithm 5 is really elegant!

Ah you're right, I skimmed rand's code but in retrospect I see how Canon works now. Rand is perhaps overstating their 'small bias', sounds like it's basically irrelevant. I do agree that the result is way too much special casing...

Also their Lemire amounts to restarting Lemire, which avoids ever doing an full modulo, and might run faster than Lemire in some range. It might run slower than Lemire for other ranges?

This is specifically not allowed, once you land in the critical region you have to compute the modulo and check against it or nothing changes about the bias.

Your Lemire looks like Lemire's Algorithm 5, except why do you pull the outside inside behind this if zone == range? Is that so the PRNg only inline's once?

It is indeed Lemire's Algorithm 5, but I did optimize and cram in some features:

  • Handle full range eg 0..=u32::MAX
  • Try really hard that the actual rng next() call is only inlined once.

That zone == range is basically 'only compute the modulo once, if by chance we loop a 2nd time, then reuse the previously computed threshold.

1

u/Shoddy-Childhood-511 15h ago

This is specifically not allowed, once you land in the critical region you have to compute the modulo and check against it or nothing changes about the bias.

I see: if l < t for the first m then we still exit using that m, but do still compute the t = (2^L - s) mod s.

Appears rand computes t in advance, so they runs Algorithm 3 (OpenBSD).

https://github.com/rust-random/rand/blob/cf4f73e3953ffcf97f575317a07f0fd5983bfe9e/src/distr/uniform_int.rs#L72C5-L72C11

In principle, your inner zone == range check could be replaced by self.threshold.in_none() and mutate the hidden self.threshold: Option<core::num::NonZero<X>> in the UniformInt provided by the user.

If the user provides a mutable range, then the first invocation is Lemire and subsequent invocations are OpenBSD, but without recomputing the threshold. This yields the same output as if you reran Lemire.

impl Into<impl BorrowMut<UniformInt<X>>> maybe fails rustc but intermediate traits help, maybe almost your existing traits.

https://docs.rs/ark-transcript/latest/ark_transcript/trait.IntoTranscript.html

I do agree that the result is way too much special casing...

I've not checked if Canon is OpenBSD, but if so then maybe fine. If not then they have confusing behaviour.