Meet the new Numerics.NET Random API
Numerics.NET 10.3 introduces a new Random API for modern numerics workloads. Randomness is easy, until you need to debug a simulation, reproduce an experiment, or run Monte Carlo in parallel without subtle correlation bugs. Numerics.NET Random is built for those real workloads: deterministic by contract, portable across platforms, and fast in hot loops, while still interoperating cleanly with the .NET ecosystem.
This post introduces the new API and the mental model behind it, with a focus on practical day-one usage: how you get started, how you keep results reproducible, and how you scale to parallel workloads without surprises.
Why rebuild randomness for numerics?
General-purpose randomness is fine for many apps, but numerics workloads push on a few recurring problems:
- Reproducibility: you need the same sequence again to validate results, debug, or compare refactors.
- Parallel correctness: thread safety is only the first step. Independent streams matter to avoid accidental correlation.
- Performance: in tight loops, overhead and call shapes show up quickly.
Numerics.NET Random is designed around these constraints from the start.
At a glance
| Capability | System.Random | Numerics.NET Random |
|---|---|---|
| Reproducible seeded runs | Not a library contract | Guaranteed across platforms and architectures within a major version |
| Parallel-friendly independent streams | Manual and easy to get wrong | One-line deterministic stream creation |
| State save/restore (checkpoint/resume) | Not supported | Supported via state snapshot/restore |
| Modern generator choices | Implementation varies by runtime/version | Explicit, modern RNG families available |
| Interop | Native type | Full bidirectional interop |
Two ways to get random numbers
Most code falls into one of two buckets: convenient randomness, or reproducible randomness. The API makes that choice explicit.
Shared convenience randomness (thread-safe, not reproducible)
If you need incidental randomness such as jitter, randomized UI effects, or casual sampling, use RandomSources.Shared:
using Numerics.NET;
double u = RandomSources.Shared.NextDouble();
int k = RandomSources.Shared.Next(100);Shared
is safe to call concurrently from multiple threads, since it uses
thread-local generators internally. It is not reproducible across runs, by design.
Seeded reproducible randomness (deterministic by contract)
For experiments, simulations, tests, and anything you might want to rerun later, create an explicit RNG from a seed:
using Numerics.NET;
var rng = RandomSources.Create(42);
double u1 = rng.NextDouble();
double u2 = rng.NextDouble();Reproducibility contract: within a given major version (e.g., 10.x), the same seed and the same sequence of RNG calls produce identical results across machines, OSes, architectures, and .NET runtimes.
Seeding: the part you only notice when results stop making sense
A common way simulations get quietly compromised is seed management. Everything runs, numbers look plausible, and then a rerun shifts outcomes, or a parallel run picks up a subtle bias that is hard to detect.
Typical culprits include:
- Reseeding inside helper methods, which accidentally repeats the start of a sequence.
- Inventing ad-hoc seed schemes like
baseSeed + iand assuming that automatically creates independent streams.
Modern PRNGs have a large internal state, and small integer seeds must be expanded into that state. If you rely on simple seed arithmetic to create many streams, you can end up with correlated sequences across workers, which can invalidate the simulation and lead to outright misleading results.
Numerics.NET uses robust mixing when it expands seeds and derives streams. The practical rule is simple:
- Create one RNG per run, or one per worker, and reuse it.
- When you need many RNGs, do not invent a seeding scheme. Use the built-in stream APIs.
Reproducibility you can operationalize
In real projects, seeded RNG is only half the story. The other half is being able to checkpoint and resume.
Snapshot and restore RNG state
Random sources can persist and restore their internal state. State is tied to the specific RNG algorithm, so restoration is typically done through that RNG’s state constructor.
Below we use Pcg64 explicitly,
one of many generators available under Numerics.NET.Random:
using Numerics.NET.Random;
var rng = new Pcg64(42);
// Do some work...
var values = new double[1000];
rng.Fill(values);
// Save state for later
byte[] state = rng.GetState();
// ... later, restore and continue exactly
var resumed = new Pcg64(state);
double next1 = rng.NextDouble();
double next2 = resumed.NextDouble();
// next1 == next2State snapshots are the safest tool when you need to resume long-running jobs, reproduce a mid-stream bug report, or persist an experiment exactly.
Parallel randomness in one line
Parallel code needs independent streams. Sharing one RNG instance across threads leads to races and corrupted state:
using Numerics.NET;
// BAD: one RNG shared across threads
var rng = RandomSources.Create(42);
Parallel.For(0, 1000, _ => rng.NextDouble()); // unsafeA second, more subtle problem is “seed per worker” patterns that look reasonable but undermine confidence in results:
using Numerics.NET;
// AVOID: ad-hoc worker seeding
Parallel.For(0, 10, i =>
{
var myRng = RandomSources.Create(42 + i);
// ...
});This approach is fragile. It makes results depend on details like worker counts and seed math, and it relies on properties of the seeding path that you probably did not verify. In simulations, that is exactly how you end up with results that can be wrong in ways that are hard to detect.
For reproducible parallel work, the simplest correct approach is to create a stream per worker:
using Numerics.NET;
// One line: reproducible independent streams
var streams = RandomSources.CreateStreams(count: 10, seed: 42);
Parallel.For(0, 10, i =>
{
var myRng = streams[i];
double sample = myRng.NextDouble();
});CreateStreams gives each worker a stable stream identity derived from the base seed. The goal is that each worker’s sequence is statistically isolated from its neighbors, without you having to design a seeding scheme or worry about accidental reuse.
If you need more advanced stream management, such as nested parallelism, hierarchical derivation, or jump-based partitioning, the library supports it. You do not need to learn stream theory to get correct results on day one.
Choosing a specific generator (optional)
RandomSources.Create(seed) gives you a sensible default for most workloads.
When you have a reason to choose an algorithm explicitly, such as compatibility baselines,
very large stream counts, or specific statistical properties, you can construct one directly.
Classes that implement various algorithms live under Numerics.NET.Random:
using Numerics.NET.Random;
var pcg = new Pcg64(42);
var xoshiro = new Xoshiro256StarStar(42);
var philox = new Philox4x64(42);Most users never need to go further than this. The key is that you can stay on the easy path and still have a precise escape hatch when requirements demand it.
Compatibility when you need it
Sometimes reproducibility is not just “same program, same results.” Sometimes it is “match an external baseline.” Numerics.NET supports this via seed profiles, which control how seeds and stream addresses expand into generator state.
For example, specific generators can match NumPy’s bit generators when initialized
with SeedProfile.Numpy:
using Numerics.NET.Random;
var rng = new Pcg64(42, seedProfile: SeedProfile.Numpy);
// Primitive outputs match NumPy for supported RNGs.
ulong x = rng.NextUInt64();This is especially useful when validating against existing Python workflows or migrating code incrementally.
Interop with System.Random (without losing control)
You do not always control the APIs you have to call.
Numerics.NET can bridge to and from System.Random when needed.
Use a Numerics.NET RNG where System.Random is required
using Numerics.NET;
var rng = RandomSources.Create(42);
System.Random sys = rng.AsRandom();
LegacyApi(sys);Use System.Random with Numerics.NET APIs
using Numerics.NET;
System.Random sys = new System.Random(123);
IRandomSource src = sys.AsRandomSource();
var data = new double[256];
src.Fill(data);Interop is a thin wrapper that preserves RNG state. Convert once at the boundary, keep hot loops on native Numerics.NET types, and you get the best of both worlds. When possible, the interop layer can also avoid unnecessary double-wrapping.
Numerics.NET has also been updated to use random sources across the library.
APIs such as sampling from probability distributions and global optimization
now accept random sources directly, while still supporting System.Random
for compatibility with existing code.
Performance: designed for hot loops
Our focus in this initial release of the new API was on getting the fundamentals right. The design is built to enable high throughput in real numerics code, and there are clear opportunities for further optimizations as we iterate.
The performance story today is straightforward:
- Concrete generators inline well in tight loops.
- Bulk and span-first APIs let you generate efficiently without per-element overhead.
- Adapters exist for compatibility, but the native path is the intended hot path.
When you use a concrete generator type in performance-critical code, the JIT can specialize your loop for that type. That typically enables inlining of state transitions and avoids interface dispatch overhead in inner loops, which is where RNG cost tends to matter most in numerics workloads.
Where to start
- Want quick randomness? Use
RandomSources.Shared. - Want reproducible results? Use
RandomSources.Create(seed)and pass the RNG through your code. - Want parallel reproducibility? Use
RandomSources.CreateStreams(count, seed)and give each worker its own stream. - Need external baselines? Use
SeedProfilefor supported compatibility targets. - Integrating with legacy code? Use
AsRandom()andAsRandomSource()at the boundary.