---
title: Benchmarks
description: Criterion bench numbers — FGN CPU vs CUDA, distribution sampling speedups, and the all-backends matrix (CPU / cubecl / Metal / Accelerate).
category: reference
since: 2.0.0
status: stable
---

# Benchmarks

The library uses [`criterion`](https://github.com/bheisler/criterion.rs)
for performance tracking, with named baselines per release for
regression detection.

## Bench suites

The workspace ships **28 bench files** under `benches/`. The
default-feature suites cover stochastic processes, distribution
sampling, pricers, risk, microstructure, and lattice methods. The
remaining suites are feature-gated to backends or optional dependencies.

### Default-feature suites

```
distributions       fgn_fbm        process_generation   gn_batching
dist_multicore      option         risk                 instruments
microstructure      credit         cashflows            calendar
lattice             market         filtering            realized
poisson_process     mle            econometrics         slv
rl_rough
```

### Feature-gated suites

| Bench                | Required features                  |
|----------------------|------------------------------------|
| `fgn_gpu`            | `gpu`                              |
| `fgn_cuda_native`    | `cuda-native`                      |
| `fgn_metal`          | `metal`                            |
| `fgn_accelerate`     | `accelerate`                       |
| `fgn_all_backends`   | `gpu-wgpu`, `metal`, `accelerate`  |
| `factors`            | `openblas`                         |
| `hotpath_profile`    | `hotpath`                          |

## FGN — CPU vs CUDA native

`cuda-native` backend: cudarc + cuFFT + fused Philox RNG kernel
(no `.cu` files, no `nvcc`). Environment: Intel i9-285K + NVIDIA RTX 4070
SUPER, CUDA 12.x, Rust nightly, `--release` with LTO.

```bash
cargo bench --features cuda-native --bench fgn_cuda_native
```

### Single path (`f32`, H = 0.7)

| n      | CPU `sample` | CUDA `sample_cuda_native(1)` | Speedup    |
|-------:|-------------:|------------------------------:|-----------:|
|  1,024 |       8.1 µs |                          46 µs|       0.18× |
|  4,096 |        35 µs |                          84 µs|       0.42× |
| 16,384 |       147 µs |                         110 µs| **1.3×**    |
| 65,536 |       850 µs |                         227 µs| **3.7×**    |

### Batch (`sample_par(m)` vs `sample_cuda_native(m)`, `f32`, H = 0.7)

| n, m         | CPU `sample_par` | CUDA `sample_cuda_native` | Speedup  |
|--------------|------------------:|---------------------------:|---------:|
|   4,096, 32  |          147 µs   |                     117 µs |  **1.3×** |
|   4,096, 512 |         1.78 ms   |                    2.37 ms |   0.75×   |
|  65,536, 128 |         12.6 ms   |                    10.5 ms |  **1.2×** |
|  65,536, 1 k |          102 ms   |                      93 ms |  **1.1×** |

CUDA wins for large `n` (≥ 16 k) and stays competitive at `n = 65 k`
batches. CPU rayon parallelism dominates for medium `n` due to zero
transfer overhead.

## All backends head-to-head

`fgn_all_backends` (requires `gpu-wgpu`, `metal`, and `accelerate`)
compares **CPU**, **cubecl-wgpu**, **Metal**, and **Accelerate** on the
same FGN sampler:

```rust
g.bench_with_input(BenchmarkId::new("cpu", n),         …);
g.bench_with_input(BenchmarkId::new("gpu_cubecl", n),  …);
g.bench_with_input(BenchmarkId::new("metal", n),       …);
g.bench_with_input(BenchmarkId::new("accelerate", n),  …);
```

Test grid: `n ∈ {1024, 4096, 16384, 65536}` for single paths and
`(n, m) ∈ {(4 096, 32), (4 096, 128), (4 096, 512), …}` for batches.
Use this bench to discover the cross-over point on your machine
between scalar / SIMD CPU, CPU-FFT (Accelerate), and GPU.

### Single path (`f32`, H = 0.7)

Single-path latency is the only dimension where the two test machines share a
grid, so the backends are directly comparable. The **i9-285K / RTX 4070 SUPER**
figures are converted from the `Melem/s` values in [`CUDA_BENCHMARK.md`](https://github.com/dancixx/stochastic-rs/blob/main/CUDA_BENCHMARK.md)
(`fgn_cuda_compare`).

**Apple M4 Max** (10 P + 4 E cores, 36 GB unified):

| n      | CPU       | Accelerate | Metal      | CubeCL-wgpu |
|-------:|----------:|-----------:|-----------:|------------:|
|  1,024 | **11 µs** |    11.6 µs |     148 µs |      230 µs |
|  4,096 |    49 µs  |  **45 µs** |     175 µs |      295 µs |
| 16,384 |   202 µs  | **165 µs** |     220 µs |      543 µs |
| 65,536 |   838 µs  |    677 µs  | **456 µs** |     1.66 ms |

**Intel i9-285K + RTX 4070 SUPER** (`cudarc` + cuFFT, hand-written cubecl):

| n      | CPU (i9-285K) | cuFFT (`cuda_native`) | cubecl (`gpu_cuda`) |
|-------:|--------------:|----------------------:|--------------------:|
|  1,024 |    **7.9 µs** |                 80 µs |              148 µs |
|  4,096 |     **34 µs** |                 84 µs |              212 µs |
| 16,384 |        144 µs |            **110 µs** |              514 µs |
| 65,536 |        847 µs |            **189 µs** |             1.62 ms |

Both SIMD CPUs dominate short paths (the i9-285K is the fastest of all up to
`n = 4 k`); **cuFFT overtakes at `n ≈ 16 k`** and reaches **4.4× over the CPU at
`n = 65 k`** (189 µs vs ~840 µs). On the Apple side the vDSP (Accelerate) FFT
edges the `ndrustfft` CPU for mid-size single paths (`n = 4 k`–`16 k`), and Metal
passes both only for the largest. The hand-written cubecl FFT
(`gpu_cuda` / `CubeCL-wgpu`) is never competitive.

### Batch throughput (`sample_par`, `f32`, H = 0.7) — Melem/s, higher is better

**Apple M4 Max** — vDSP/Accelerate and the rayon CPU run neck-and-neck (both far
ahead of the GPU backends):

| n × m        | CPU | Accelerate | Metal | CubeCL-wgpu |
|--------------|----:|-----------:|------:|------------:|
| 4,096 × 32   | 532 |    **548** |   332 |          58 |
| 4,096 × 512  | 744 |    **790** |   565 |          76 |
| 16,384 × 128 | 670 |    **749** |   488 |          62 |
| 16,384 × 512 | 676 |    **787** |   433 |          62 |

**Intel i9-285K + RTX 4070 SUPER** — small `n` stays cache-resident on the
24-core i9; batched cuFFT wins once `n ≥ 4 k`:

| n × m           | CPU (i9-285K) | cuFFT   | cubecl |
|-----------------|--------------:|--------:|-------:|
| 1,024 × 16,384  |      **1751** |     557 |     49 |
| 4,096 × 16,384  |           269 | **581** |     48 |
| 4,096 × 65,536  |           118 | **383** |     42 |
| 16,384 × 16,384 |            59 | **183** |     32 |
| 65,536 × 4,096  |       **504** |     236 |     28 |

The two machines' batch grids do not overlap (the Mac bench tops out at
`m = 512`; the CUDA batch grid starts at `m = 1024`), so they are shown
separately. **Takeaway:** for FGN the discrete-GPU win is real but narrow — it
needs *both* a long path (`n ≥ 16 k` single, or `n ≥ 4 k × m ≥ 16 k` batch) *and*
an NVIDIA-class FFT. On Apple Silicon the CPU-side FFTs win: `ndrustfft` + rayon
and vDSP/Accelerate are interchangeable (both ~700–800 Melem/s in batch), while
Metal only helps for the largest single paths.

## Normal sampling vs `rand_distr` (single-thread, `fill_slice`)

Measured with `cargo bench --bench dist_multicore`. Single-thread `fill_slice`,
median of 7 runs. Compares three implementations of the **same** workload
(write `n` `N(0, 1)` samples into a pre-allocated `Vec<f64>`):

- **`SimdNormal::fill_slice`** — our SIMD Ziggurat with the `wide`-based RNG.
- **`rand_distr + SimdRng`** — `rand_distr::Normal::sample` consuming our
  `SimdRng`. Isolates the Normal algorithm — both paths share the same
  uniform stream.
- **`rand_distr + rand::rng()`** — out-of-box upstream: `rand_distr::Normal`
  on `rand::rng()` (`ThreadRng`-backed `ChaCha`).

|      n  | `SimdNormal` (µs) | `rand_distr + SimdRng` (µs) | speedup | `rand_distr + rand::rng()` (µs) | speedup |
|--------:|------------------:|-----------------------------:|--------:|---------------------------------:|--------:|
|       4 |             0.008 |                        0.013 |  1.73×  |                            0.032 |  4.22×  |
|       8 |             0.014 |                        0.026 |  1.78×  |                            0.065 |  4.52×  |
|      16 |             0.029 |                        0.051 |  1.79×  |                            0.128 |  4.47×  |
|      64 |             0.109 |                        0.208 |  1.90×  |                            0.508 |  4.64×  |
|     256 |             0.432 |                        0.840 |  1.94×  |                            2.029 |  4.70×  |
|   4 096 |             6.975 |                       13.176 |  1.89×  |                           32.382 |  4.64×  |
|  65 536 |           113.458 |                      212.406 |  1.87×  |                          520.219 |  4.59×  |

The **`rand_distr + SimdRng`** column shows the pure algorithmic win at the
distribution level (our SIMD Ziggurat fast-path beats the scalar reference
Ziggurat ~1.7–1.9×). The **`rand_distr + rand::rng()`** column shows the
full pipeline win including the SIMD RNG itself (~4.2–4.7×).

## v2.1 SIMD RNG / Ziggurat speedups

Criterion deltas vs the `wide 1.3.0` baseline (single-sample `dist.sample(rng)`
loop, `cargo bench --bench distributions -- --baseline before`):

| Distribution     | f32 / large       | f64 / large       | f64 / small       |
|------------------|------------------:|------------------:|------------------:|
| `Uniform/simd`   | **−57%** (≈ 2.3×) | **−77%** (≈ 4.4×) | **−58%** (≈ 2.4×) |
| `Normal/simd`    | **−51%** (≈ 2.0×) | **−75%** (≈ 4.0×) | **−63%** (≈ 2.7×) |
| `Exp/simd N=64`  | −3% (n.s.)        | **−73%** (≈ 3.7×) | —                 |
| `LogNormal/simd` | **−71%** (≈ 3.4×) | **−70%** (≈ 3.4×) | **−66%** (≈ 2.9×) |

Drivers (sub-crate paths):

- `stochastic-rs-core::simd_rng::next_f64_array` / `next_f32_array` —
  8 scalar shift+cast+mul loops replaced by SIMD shift + magic-number
  bit-cast + subtract (52-bit / 23-bit precision; sub-ULP impact on
  Monte Carlo). The new
  [`fill_uniform_f64`](https://docs.rs/stochastic-rs-core/latest/stochastic_rs_core/simd_rng/struct.SimdRng.html#method.fill_uniform_f64)
  / `fill_uniform_f32` direct-write APIs skip the `[f64; 8]` /
  `[f32; 8]` return-by-value round-trip — `vmovupd` directly into the
  caller's slice. `next_f64` / `next_f32` refill their internal
  buffers via the same inlined SIMD store.
- `stochastic-rs-distributions::normal` / `exp` — `fill_ziggurat` main
  loop runs 8 lanes per iteration so `copy_from_slice(&scaled_arr)`
  inlines to `stp` stores instead of compiling to a `memcpy` call; the
  final 0–7-element tail is fed through scalar `sample_one`.
- `stochastic-rs-distributions::exp` — fused `1/λ` scaling into the
  SIMD store inside `fill_exp_scaled`, removing the second-pass
  `scale_in_place`.
- `stochastic-rs-distributions::uniform` — `fill_slice_fast`
  short-circuits to `rng.fill_uniform_f64(out)` for the `[0, 1)` case
  (the dominant pattern) and falls back to a single SIMD affine pass
  otherwise.
- `stochastic-rs-distributions::lib` — `<f64 as SimdFloatExt>::simd_from_i32x8`
  splits `i32x8` into two `i32x4` halves and calls `f64x4::from_i32x4`
  per half, routing through `vcvtdq2pd` on AVX / `sshll + scvtf` on
  NEON instead of `wide`'s 8-scalar fallback.
- `simd_rng::fill_bytes` batches 32-byte writes per engine call and
  the Xoshiro256++ / Xoshiro128++ seed paths use `u64::from_le_bytes`
  for big-endian portability.

### Opt-in: dual-stream RNG (`dual-stream-rng` feature)

```toml
[dependencies]
stochastic-rs = { version = "2.2", features = ["dual-stream-rng"] }
```

Unlocks `stochastic_rs_core::simd_rng_dual::SimdRngDual` (two parallel
xoshiro engines) and `stochastic_rs::distributions::normal_dual::SimdNormalDual`
(Ziggurat unrolled 2× over the dual streams). Measured against the
single-stream `SimdNormal::fill_slice` on Apple Silicon
(`cargo bench --bench dual_stream_compare --features dual-stream-rng`):

|         n | single (`SimdNormal`) | dual (`SimdNormalDual`) |    Δ   |
|----------:|----------------------:|------------------------:|-------:|
|        64 |              111.6 ns |                105.5 ns |  −5.5% |
|       256 |              444.8 ns |                418.3 ns |  −6.0% |
|     4 096 |               7.43 µs |                 6.60 µs | −11.2% |
|    65 536 |              113.9 µs |                106.6 µs |  −6.4% |
| 1 048 576 |               1.83 ms |                 1.70 ms |  −6.8% |

The win is that a modern out-of-order core hides the 16 scalar
`kn` / `wn` table-lookup latencies behind the second engine's `xoshiro`
state update. Uniform-only fills are not engine-bound so they show no
speedup. Trade-off: `SimdRngDual::from_seed` does **not** reproduce
`SimdRng::from_seed`'s bit-exact sequence (statistical properties are
identical and KS-validated).

## Establishing a baseline

```bash
# Save the current build as the "v2" baseline
cargo bench --bench distributions      -- --save-baseline v2
cargo bench --bench fgn_fbm            -- --save-baseline v2
cargo bench --bench process_generation -- --save-baseline v2
cargo bench --bench option             -- --save-baseline v2
# … and so on for the rest of the suites
```

OpenBLAS-gated benches go to a separate baseline name to keep the
linear-algebra-heavy suite out of the default comparison:

```bash
cargo bench --bench factors --features openblas -- --save-baseline v2-openblas
```

## Comparing against a baseline

Before merging any PR with non-trivial perf impact:

```bash
cargo bench --bench <bench> -- --baseline v2
```

`criterion` prints `Performance has improved` / `regressed` per
benchmark with effect size and 95% confidence intervals. Block the
merge on any regression of more than 5% (the project's de-facto
threshold).

## Reproducing the numbers

To reproduce the FGN-vs-CUDA table you need:

- NVIDIA GPU with CUDA 12.x toolkit
- Rust nightly (for inline-asm in some `cudarc` paths)
- `RUSTFLAGS="-C target-cpu=native"` for the CPU side (otherwise the
  CPU column above will look slower because `wide`'s `f32x8` falls
  back to scalar — see [Native CPU optimization](/docs/getting-started/installation-rust#native-cpu-optimization))

```bash
RUSTFLAGS="-C target-cpu=native" cargo bench \
  --features cuda-native \
  --bench fgn_cuda_native -- --save-baseline local
```

## Adding a benchmark

See the
[`bench-writing`](https://github.com/dancixx/stochastic-rs/blob/main/.claude/skills/bench-writing/SKILL.md)
SKILL — group naming, parameter sweep, `[[bench]]` required-features
gating, and the "no-println / no-dead-helper" rules.
