#rng #rand #heap-allocator

无std tinyrand

轻量级随机数生成器规范和Rust中几个超快实现

5个版本 (破坏性更新)

0.5.0 2022年9月12日
0.4.0 2022年9月8日
0.3.0 2022年9月7日
0.2.0 2022年9月5日
0.1.0 2022年9月5日

嵌入式开发中排名173

Download history 107/week @ 2024-03-13 200/week @ 2024-03-20 175/week @ 2024-03-27 151/week @ 2024-04-03 301/week @ 2024-04-10 221/week @ 2024-04-17 205/week @ 2024-04-24 160/week @ 2024-05-01 171/week @ 2024-05-08 115/week @ 2024-05-15 137/week @ 2024-05-22 142/week @ 2024-05-29 126/week @ 2024-06-05 109/week @ 2024-06-12 144/week @ 2024-06-19 134/week @ 2024-06-26

每月下载量527
用于22个包(直接使用12个)

MIT许可证

66KB
1K SLoC

tinyrand

轻量级随机数生成器规范和Rust中几个超快实现。 tinyrandno_std 并不使用堆分配器。

Crates.io docs.rs Build Status codecov no_std

为什么选择 tinyrand

  • 它非常小巧,不需要 std,这意味着它可以嵌入——它可以在微控制器和裸机(没有操作系统)环境中运行。
  • 它非常快。它包含了 XorshiftSplitMixWyrand
  • 随机数生成器行为被简洁地指定为一组特质,独立于底层实现。这使得交换实现变得容易。
  • 它还包括 Mock 用于测试依赖于随机数的代码。也就是说,如果您关心代码覆盖率。

性能

以下是一些著名PRNG的比较。

随机数生成器 算法 带宽(GB/s)
rand ChaCha12 2.4
tinyrand SplitMix 6.5
tinyrand Xorshift 6.7
fastrand Wyrand 7.5
tinyrand Wyrand 14.6

TL;DR: tinyrandfastrand 快2倍,比 rand 快6倍。

统计属性

无法确定某个PRNG是否良好;答案是概率性的。所有三种算法都能很好地通过 Dieharder 测试,但Wyrand和SplitMix比Xorshift略好。(在30.8亿个样本上进行了测试。)这意味着 tinyrand 产生的数字看起来足够随机,并可能适用于大多数应用程序。

tinyrand 算法不是密码学安全的,这意味着可以通过观察一系列数字来猜测下一个随机数。(或者前一个数字。)如果您需要一个健壮的CSPRNG,强烈建议您选择 rand。CSPRNG通常要慢得多,大多数人并不需要。

入门

添加依赖项

cargo add tinyrand

基础

生成数字需要使用一个 Rand 实例。在这里,我们使用了 StdRand,它是默认/推荐 RNG 的别名。(目前设置为 Wyrand,但未来可能会有所改变。)

use tinyrand::{Rand, StdRand};

let mut rand = StdRand::default();
for _ in 0..10 {
    let num = rand.next_u64();
    println!("generated {num}");
}

类似地,我们还可以生成其他类型的数字

use tinyrand::{Rand, StdRand};

let mut rand = StdRand::default();
let num = rand.next_u128();
println!("generated wider {num}");

next_uXX 方法可以生成指定类型无符号整数范围内的数字。通常,我们希望得到一个特定范围内的数字

use tinyrand::{Rand, StdRand, RandRange};

let mut rand = StdRand::default();
let tasks = vec!["went to market", "stayed home", "had roast beef", "had none"];
let random_index = rand.next_range(0..tasks.len());
let random_task = tasks[random_index];
println!("This little piggy {random_task}");

另一个常见用例是生成 bool。我们也可能想要给二进制结果分配权重

use tinyrand::{Rand, StdRand, Probability};

let mut rand = StdRand::default();
let p = Probability::new(0.55); // a slightly weighted coin
for _ in 0..10 {
    if rand.next_bool(p) {
        // expect to see more heads in the (sufficiently) long run
        println!("heads"); 
    } else {
        println!("tails");
    }
}

有时我们需要让线程休眠一段时间,等待某个条件。当许多线程都在休眠时,通常建议它们随机退避,以避免拥挤。

use tinyrand::{Rand, StdRand, RandRange};
use core::time::Duration;
use std::thread;
use tinyrand_examples::SomeSpecialCondition;

let mut rand = StdRand::default();
let condition = SomeSpecialCondition::default();
let base_sleep_micros = 10;
let mut waits = 0;
while !condition.has_happened() {
    let min_wait = Duration::ZERO;
    let max_wait = Duration::from_micros(base_sleep_micros * 2u64.pow(waits));
    let random_duration = rand.next_range(min_wait..max_wait);
    println!("backing off for {random_duration:?}");
    thread::sleep(random_duration);
    waits += 1;
}

初始化种子

Rand 上调用 Default::default() 将其初始化为常数种子。这对于可重复性来说很棒,但会导致“随机”数字的运行结果相同,这不是大多数人需要的。

tinyrand 是一个 no_std 包,遗憾的是,当无法对底层平台做出假设时,没有好的、可移植的方法来生成熵。在大多数应用程序中,人们可能会想到时钟,但像 SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) 这样微不足道的东西可能并不总是可用。

如果您有可用的熵源,可以像这样初始化一个 Rrnd

use tinyrand::{Rand, StdRand, Seeded};
let seed = tinyrand_examples::get_seed_from_somewhere(); // some source of entropy

let mut rand = StdRand::seed(seed);
let num = rand.next_u64();
println!("generated {num}");

您还可以考虑使用 getrandom,这是一个跨平台的方法来检索熵数据。

如果不在乎 no_std,就不应该受其限制。要从系统时钟中获取种子,可以选择加入 std

cargo add tinyrand-std

现在,我们有了一个 ClockSeed,它也实现了 Rand 特性。通过将 SystemTime 的纳秒时间戳的高64位与低64位进行异或操作,ClockSeed 派生出一个 u64。它不适合加密使用,但足以满足大多数通用应用程序的需求。

use tinyrand::{Rand, StdRand, Seeded};
use tinyrand_std::clock_seed::ClockSeed;

let seed = ClockSeed::default().next_u64();
println!("seeding with {seed}");
let mut rand = StdRand::seed(seed);
let num = rand.next_u64();
println!("generated {num}");

tinyrand-std 包还包含一个初始化的、线程局部的 Rand 实现

use tinyrand::Rand;
use tinyrand_std::thread_rand;

let mut rand = thread_rand();
let num = rand.next_u64();
println!("generated {num}");

模拟

有时很难实现良好的测试覆盖率;当应用程序依赖于随机性或其他非确定性来源时,情况更是如此。 tinyrand 随带一个模拟 RNG,它提供了对您代码执行的精细控制。

模拟使用 alloc 包,因为它需要闭包的堆分配。因此,模拟作为一个可选择的包进行分发

cargo add tinyrand-alloc

在底层,Mock 是一个配置了少量 代理 的结构。代理是一个闭包,当测试系统调用特定特质的某个方法时,模拟会调用它。模拟还维护一个内部调用状态,跟踪特定代理被调用的次数。因此,不仅可以通过模拟 Rand 特质的操作行为,还可以验证特定组相关特质方法被调用的次数。

代表由测试用例指定,而模拟实例作为 Rand 实现传递给待测系统。目前支持三种代表类型

  1. FnMut(&State) -> u128 —— 当在模拟上调用 next_uXX() 方法之一时被调用。(uXXu16u32u64u128usize 之一。)代表返回下一个“随机”数,宽度可能高达128位。宽度被设计为容纳 u128 —— Rand 支持的最宽类型。如果请求较窄的类型,模拟实例将简单地返回低位。 (例如,对于 u32,模拟的值在内部使用 as u32 截断。)
  2. FnMut(Surrogate, Probability) -> bool —— 当调用 next_bool(Probability) 方法时被调用。
  3. FnMut(Surrogate, u128) -> u128 —— 当调用 next_limnext_range 时。

从绝对基本开始,让我们模拟 next_uXX() 返回一个常数。然后我们将检查我们的模拟被调用了多少次。

use tinyrand::Rand;
use tinyrand_alloc::Mock;

let mut rand = Mock::default().with_next_u128(|_| 42);
for _ in 0..10 {
    assert_eq!(42, rand.next_usize()); // always 42
}
assert_eq!(10, rand.state().next_u128_invocations());

尽管这个场景很简单,但实际上很常见。这可以通过 fixed(uXX) 函数实现。

use tinyrand::Rand;
use tinyrand_alloc::{Mock, fixed};

let mut rand = Mock::default().with_next_u128(fixed(42));
assert_eq!(42, rand.next_usize()); // always 42

因为代表是常规闭包,我们可以将其绑定到包围作用域中的变量。这给了我们对模拟行为几乎无限的控制。

use tinyrand::Rand;
use tinyrand_alloc::Mock;
use core::cell::RefCell;

let val = RefCell::new(3);
let mut rand = Mock::default().with_next_u128(|_| *val.borrow());

assert_eq!(3, rand.next_usize());

// ... later ...
*val.borrow_mut() = 17;
assert_eq!(17, rand.next_usize());

代表可以在任何时间重新分配,即使在模拟创建和执行之后。

use tinyrand::Rand;
use tinyrand_alloc::{Mock, fixed};

let mut rand = Mock::default().with_next_u128(fixed(42));
assert_eq!(42, rand.next_usize());

rand = rand.with_next_u128(fixed(88)); // the mock's behaviour is now altered
assert_eq!(88, rand.next_usize());

next_u128 代表的签名接受一个 State 引用,该引用捕获了模拟被调用的次数。(计数仅在调用完成后才增加。)让我们编写一个返回从调用状态派生出的“随机”数的模拟。

use tinyrand::Rand;
use tinyrand_alloc::Mock;

let mut rand = Mock::default().with_next_u128(|state| {
    // return number of completed invocations
    state.next_u128_invocations() as u128
});
assert_eq!(0, rand.next_usize());
assert_eq!(1, rand.next_usize());
assert_eq!(2, rand.next_usize());

这在预期模拟将被多次调用且每次调用应返回不同结果时很有用。可以使用类似的方法通过 counter(Range) 函数实现,该函数遍历指定的数字范围,并在边界处方便地循环。

use tinyrand::Rand;
use tinyrand_alloc::{Mock, counter};

let mut rand = Mock::default().with_next_u128(counter(5..8));
assert_eq!(5, rand.next_usize());
assert_eq!(6, rand.next_usize());
assert_eq!(7, rand.next_usize());
assert_eq!(5, rand.next_usize()); // start again

通过仅提供 next_u128 代表,我们可以影响 Rand 特性中其他每个方法的结果,因为它们都来自相同的随机数源,并最终在内部调用我们的代表...从理论上讲!在实践中,事情要复杂得多。

衍生 Rand 方法,例如 next_bool(Probability)next_lim(uXX)next_range(Range),由不同的概率分布支持。例如,next_bool 从伯努利分布中抽取样本,而 next_limnext_range 使用带有去偏层的缩放均匀分布。此外,各种分布之间的映射是内部实现细节,可能会发生变化。仅去偏层就有几种实现,针对不同宽度的类型进行了优化。换句话说,从 next_u128next_boolnext_limnext_range 的映射是非平凡的;这不是一个你需要没有计算器和一些模运算知识就能随意模拟的东西。

幸运的是,Rand 允许我们“绕过”这些映射函数。这就是另外两个委托的作用所在。以下示例模拟了 next_bool 的结果。

use tinyrand::{Rand, Probability};
use tinyrand_alloc::Mock;

let mut rand = Mock::default().with_next_bool(|_, _| false);
if rand.next_bool(Probability::new(0.999999)) {
    println!("very likely");
} else {
    // we can cover this branch thanks to the magic of mocking
    println!("very unlikely");
}

next_bool 委托接受一个 Surrogate 结构体,该结构体既是 Rand 实现,也是调用状态的守护者。代理允许我们生成 bool 类型,如下所示

use tinyrand::{Rand, Probability};
use tinyrand_alloc::Mock;

let mut rand = Mock::default().with_next_bool(|surrogate, _| {
    surrogate.state().next_bool_invocations() % 2 == 0
});
assert_eq!(true, rand.next_bool(Probability::new(0.5)));
assert_eq!(false, rand.next_bool(Probability::new(0.5)));
assert_eq!(true, rand.next_bool(Probability::new(0.5)));
assert_eq!(false, rand.next_bool(Probability::new(0.5)));

代理还允许委托在模拟内部调用模拟的方法。

最后一个委托用于模拟 next_limnext_range 方法,因为它们是同构的。在底层,next_range 将委托给 next_lim,这样对于任何一对极限边界(MN),M < Nnext_range(M..N) = M + next_lim(N - M)。这就是实际模拟的全部过程

use tinyrand::{Rand, RandRange};
use tinyrand_alloc::Mock;

enum Day {
    Mon, Tue, Wed, Thu, Fri, Sat, Sun
}
const DAYS: [Day; 7] = [Day::Mon, Day::Tue, Day::Wed, Day::Thu, Day::Fri, Day::Sat, Day::Sun];

let mut rand = Mock::default().with_next_lim_u128(|_, _| 6);
let day = &DAYS[rand.next_range(0..DAYS.len())];
assert!(matches!(day, Day::Sun)); // always a Sunday
assert!(matches!(day, Day::Sun)); // yes!!!

如何测试 tinyrand

本节简要描述了 tinyrand 的测试方法。它针对那些——

  • 想知道他们是否得到了“真正的产品”;
  • 希望了解如何实际测试伪随机数生成器;
  • 想知道“适用于大多数应用”是什么意思。

tinyrand 的测试过程分为四个层次

  1. 单元测试用于确保 100% 的代码覆盖率,并断言 tinyrand 的基本健全性。换句话说,每行代码至少执行一次,基本期望得到维持,并且很可能没有微不足道的缺陷。
  2. 合成基准测试。
  3. 然后使用统计测试来验证构成 tinyrand 的伪随机数生成器的特定属性。这些是假设源是随机的(零假设)的正式假设检验,寻找证据来反驳这一假设(备择假设)。
  4. Dieharder 统计测试套件。

单元测试

单元测试的目的不是断言数值质量;它们纯粹是功能性的。目标包括——

  • 覆盖率测试。 tinyrand 建立在这样一个哲学上:如果一个代码行无法被证明被执行,那么它应该被移除。这个规则没有例外。
  • 种子和状态管理。 每个伪随机数生成器(PRNG)都必须能够在调用之间保持状态,并且某些可以从用户提供的种子初始化。存在测试来验证这一点。
  • 域变换。 一个PRNG至少在某个 先验 范围内生成均匀分布的值。在实践中,我们需要某些对应用程序有用的特定范围的随机数。此外,我们可以规定某些值比其他值出现的频率更高;例如,在生成 bool 时,true 的权重与 false 的权重。从均匀分布映射到自定义分布的函数是非平凡的,并且需要一个去偏层。 tinyrand 根据字宽使用不同的去偏方法。域变换测试的目的是验证此功能是否按预期工作,并且是否正在进行拒绝采样。但是,它不验证去偏的数值属性。

合成基准测试

合成基准测试用于测试 tinyrand PRNG 的热点路径,并将结果与同行库进行比较。基准测试测试了不同字长度的数字生成、变换/去偏以及加权 bool 的生成。这些基准测试的子集也包括在持续集成(CI)测试中,这使得比较 tinyrand 在提交版本间的性能变得稍微容易一些。

统计假设检验

tinyrand 集成了统计测试套件,灵感来自 DiehardDieharderNIST SP 800-22。不可否认,tinyrand 套件比这些测试中的任何一个都要小;目的不是复制这个领域已经相当庞大并且易于获取的工作,而是创建一个既非常有效地检测常见异常又足够快,以至于可以在每次提交时运行的保障网。

以下测试包括。

  • 位翻转:通过屏蔽单个位的值对 Rand 实例进行一系列伯努利试验,验证位被设置为1的次数是否在预期的范围内。对于每个后续试验,掩码向左移动一位,并重新测试假设。测试在多个周期中进行;每个周期包含64次伯努利试验(每个 u64 的每个位一次)。
  • 硬币翻转:与 位翻转 在随机字的单个位级别上工作并且是无权重的(或等权重)不同,硬币翻转 使用伯努利分布从64位无符号字中获取一个具有选定概率的 bool。测试包含一系列伯努利试验,每个试验都有不同的(随机选择的)权重,模拟了一系列的硬币翻转。在每次试验中,H0断言来源是随机的。(即,“正面”的数量落在统计上可接受的区间内。)
  • 碰撞:一系列试验,每次试验中都有一个不同的(随机选择的)整数生成范围。在每个试验范围内,选择一个随机数作为控制值。然后生成一系列随机数(从同一范围中采样),并计算与控制值的碰撞次数。根据零假设H0,碰撞应该遵循具有λ为期望碰撞率的泊松过程。
  • 单比特:计算32位字中的比特数,通过交替生成u64的MSB(最高有效位)和LSB(最低有效位)段来分别进行试验。在每次试验中,我们假设各个比特的值是独立同分布的,概率为0.5,验证比特被设置为1的次数在预期范围内。对于随机源,1的次数(以及0的次数)遵循伯努利过程。
  • 求和收敛:一系列试验,每次试验中都有一个不同的(随机选择的)整数生成范围。在每个试验中,H0断言源是随机的。(即,采样值的总和落在统计学上可接受的范围内。)高斯分布被用作Irwin-Hall分布的近似,未缩放的均值和方差参数分别设置为n/2和n/12。
  • 滞后求和收敛:类似于标准的求和收敛,但在计算总和时跳过一定数量的样本。这个测试寻找PRNG中的滞后自相关,这些自相关通常难以检测。滞后设置为2的小幂。一个求和收敛测试是滞后求和收敛测试的极限情况,滞后设置为零。

tinyrand的每个测试不仅针对其自身的PRNG进行,还针对故意错误的实现进行,这些实现用于验证测试的有效性。测试必须始终未能拒绝正确的PRNG的H0,并接受错误的PRNG的H1。

统计测试本身是从随机值中生成的。随机性用于对测试中的PRNG进行播种(每个试验独立播种),为伯努利实验分配权重,为测试变换函数和去偏选择整数范围,为测试碰撞控制值等。我们使用rand包作为控制PRNG,以确保tinyrand中的缺陷不会意外地破坏测试,从而掩盖自身。测试是通过播种的,这样它们看起来似乎是在参数空间中进行随机的巡游,但它们选择参数的方式完全是确定性的,因此是可重复的。这是由于可能存在I型错误(错误地拒绝零假设)的可能性,这种错误不应间歇性地发生,特别是在CI环境中。换句话说,随机性测试不能留给偶然性

测试随机性假设的一种方法是从参数集中选择一组参数(例如,整数生成范围M..N或从伯努利分布中获得true的概率),并进行长时间运行,寻找大量随机样本中的异常。其原理是样本越大,包含可检测异常的可能性就越大。这通常对于在特定条件下可能只影响PRNG的某些类型的异常不太有效。例如,一个编写得不好的去偏函数可能对于大多数小整数范围以及一些大整数范围(那些接近2的幂的)仍然表现良好。如果测试选择不利的参数,那么无论它如何彻底测试这些参数,都可能找不到异常。

测试伪随机数生成器(PRNG)的一种更好的方法是引入测试体系中的多样性——进行大量的小型试验,而不是一次非常大的试验。这正是 tinyrand 统计测试所做的事情——使用随机(但确定性地)选择的参数进行多次试验。这立即暴露了 多重比较问题。考虑一个 先验 理想的 PRNG。它经常会生成按照某些一致的标准看起来“随机”的数字。但偶尔,它会产生按照同一标准看起来非随机的输出。即使是理想的来源,也会产生非常长的连续一或零的序列,例如。实际上,如果不能做到这一点,也会使其变得非随机。不幸的是,这会产生一个 p 值,即使是最宽松的测试也会失败……在某个时候。这是单假设测试的问题,但在多重假设测试中,这个问题成比例地加剧。

tinyrand 内置测试使用 Holm-Bonferroni 顺序校正方法解决这个问题。Holm-Bonferroni 校正抑制了第一类错误,同时保持良好的统计功效——抑制第二类错误。它似乎能满足 tinyrand 的需求,特别是考虑到试验次数通常保持在 100—1000 范围内。(tinyrand 测试被设计得非常快,这为试验次数设定了实际界限——理想情况下,所有统计测试应在几秒钟内完成,以便将其作为常规开发流程的一部分强制执行。)

Dieharder 测试

Dieharder 测试套件扩展了 Marsaglia 的原始 Diehard 测试套件。它包含大量测试,需要很长时间(约 1 小时)才能完成。 tinyrand 有一个将随机输出泵送到 Dieharder 的实用工具,通常根据需要运行。当 PRNG 发生重大变化时,应该运行 Dieharder 测试套件,这种情况很少见——一旦实现了 PRNG 算法,它通常保持不变,除非对其进行重构或发现缺陷。Dieharder 可用于使用 tinyrand 构建和测试实验性 PRNG,这可以说是更有用的。其他三个测试级别足以维护 tinyrand 包。

运行 tinyrand 与 Dieharder 对抗

cargo run --release --bin random -- wyrand 42 binary 1T | dieharder -g 200 -a

上述命令使用 Wyrand PRNG,使用数字 42 作为种子,生成超过 1 万亿个 64 位字的二进制输出。它的 stdout 被泵送到 dieharder。(实际上,Dieharder 将消耗不到 310 亿个数字。)

注意事项:Dieharder 没有处理多重假设测试中第一类错误的机制——部分原因在于测试的类型不同,而不仅仅是参数不同。Dieharder 将假设测试限制在单个测试的范围内;没有涵盖整个假设,该假设根据通过测试的数量将 PRNG 归类为适合或不适合,或者根据其他方式调整置信水平以考虑到第一类错误。

致谢

无运行时依赖