为什么新的随机库比std::rand()更好

Why is the new random library better than std::rand()?

本文关键字:rand 更好 std 随机 为什么      更新时间:2023-10-16

所以我看到了一个名为rand()的演讲,它被认为是有害的,它提倡使用随机数生成的引擎分布范式,而不是简单的std::rand()加模范式。

然而,我想亲眼目睹std::rand()的失败,所以我做了一个快速的实验:

  1. 基本上,我编写了两个函数getRandNum_Old()getRandNum_New(),分别使用std::rand()std::mt19937+std::uniform_int_distribution生成0到5之间的随机数
  2. 然后,我用"旧"的方法生成了96万个(可被6整除)随机数,并记录了0-5的频率。然后我计算了这些频率的标准偏差。我想要的是一个尽可能低的标准偏差,因为如果分布真的是均匀的,就会发生这种情况
  3. 我运行了1000次模拟,并记录了每个模拟的标准偏差。我还记录了它所花费的时间(以毫秒为单位)
  4. 之后,我又做了同样的事情,但这次是用"新"的方式生成随机数
  5. 最后,我计算了新旧方法的标准差列表的平均值和标准差,以及新旧方法所用时间列表的平均数和标准差

结果如下:

[OLD WAY]
Spread
mean:  346.554406
std dev:  110.318361
Time Taken (ms)
mean:  6.662910
std dev:  0.366301
[NEW WAY]
Spread
mean:  350.346792
std dev:  110.449190
Time Taken (ms)
mean:  28.053907
std dev:  0.654964

令人惊讶的是,两种方法的卷材的骨料分布是相同的。即std::mt19937+std::uniform_int_distribution并不比单纯的std::rand()+%"更均匀"。我的另一个观察结果是,新方法比旧方法慢了大约4倍。总的来说,我似乎在速度上付出了巨大的代价,而在质量上几乎没有任何收获。

我的实验在某种程度上有缺陷吗?还是std::rand()真的没有那么糟糕,甚至可能更好?

作为参考,这里是我使用的全部代码:

#include <cstdio>
#include <random>
#include <algorithm>
#include <chrono>
int getRandNum_Old() {
static bool init = false;
if (!init) {
std::srand(time(nullptr)); // Seed std::rand
init = true;
}
return std::rand() % 6;
}
int getRandNum_New() {
static bool init = false;
static std::random_device rd;
static std::mt19937 eng;
static std::uniform_int_distribution<int> dist(0,5);
if (!init) {
eng.seed(rd()); // Seed random engine
init = true;
}
return dist(eng);
}
template <typename T>
double mean(T* data, int n) {
double m = 0;
std::for_each(data, data+n, [&](T x){ m += x; });
m /= n;
return m;
}
template <typename T>
double stdDev(T* data, int n) {
double m = mean(data, n);
double sd = 0.0;
std::for_each(data, data+n, [&](T x){ sd += ((x-m) * (x-m)); });
sd /= n;
sd = sqrt(sd);
return sd;
}
int main() {
const int N = 960000; // Number of trials
const int M = 1000;   // Number of simulations
const int D = 6;      // Num sides on die
/* Do the things the "old" way (blech) */
int freqList_Old[D];
double stdDevList_Old[M];
double timeTakenList_Old[M];
for (int j = 0; j < M; j++) {
auto start = std::chrono::high_resolution_clock::now();
std::fill_n(freqList_Old, D, 0);
for (int i = 0; i < N; i++) {
int roll = getRandNum_Old();
freqList_Old[roll] += 1;
}
stdDevList_Old[j] = stdDev(freqList_Old, D);
auto end = std::chrono::high_resolution_clock::now();
auto dur = std::chrono::duration_cast<std::chrono::microseconds>(end-start);
double timeTaken = dur.count() / 1000.0;
timeTakenList_Old[j] = timeTaken;
}
/* Do the things the cool new way! */
int freqList_New[D];
double stdDevList_New[M];
double timeTakenList_New[M];
for (int j = 0; j < M; j++) {
auto start = std::chrono::high_resolution_clock::now();
std::fill_n(freqList_New, D, 0);
for (int i = 0; i < N; i++) {
int roll = getRandNum_New();
freqList_New[roll] += 1;
}
stdDevList_New[j] = stdDev(freqList_New, D);
auto end = std::chrono::high_resolution_clock::now();
auto dur = std::chrono::duration_cast<std::chrono::microseconds>(end-start);
double timeTaken = dur.count() / 1000.0;
timeTakenList_New[j] = timeTaken;
}
/* Display Results */
printf("[OLD WAY]n");
printf("Spreadn");
printf("       mean:  %.6fn", mean(stdDevList_Old, M));
printf("    std dev:  %.6fn", stdDev(stdDevList_Old, M));
printf("Time Taken (ms)n");
printf("       mean:  %.6fn", mean(timeTakenList_Old, M));
printf("    std dev:  %.6fn", stdDev(timeTakenList_Old, M));
printf("n");
printf("[NEW WAY]n");
printf("Spreadn");
printf("       mean:  %.6fn", mean(stdDevList_New, M));
printf("    std dev:  %.6fn", stdDev(stdDevList_New, M));
printf("Time Taken (ms)n");
printf("       mean:  %.6fn", mean(timeTakenList_New, M));
printf("    std dev:  %.6fn", stdDev(timeTakenList_New, M));
}

几乎所有"旧"rand()的实现都使用LCG;虽然它们通常不是最好的生成器,但通常你不会看到它们在这样一个基本的测试中失败——即使是最差的PRNG,平均值和标准偏差也通常是正确的。

"坏"但足够常见的rand()实现的常见故障有:

  • 低阶比特的低随机性
  • 短期
  • 低CCD_ 15
  • 连续提取之间的一些相关性(通常,LCG产生的数字在有限数量的超平面上,尽管这可以以某种方式减轻)

尽管如此,这些都不是rand()的API所特有的。一个特定的实现可以将xorshift族生成器放置在srand/rand之后,从算法上讲,可以在不更改接口的情况下获得最先进的PRNG,因此没有像您所做的测试那样的测试会在输出中显示出任何弱点。

编辑: @R正确地指出,rand/srand接口受到srand采用unsigned int这一事实的限制,因此实现可能放在它们后面的任何生成器本质上都局限于UINT_MAX可能的起始种子(以及由此生成的序列)。这确实是真的,尽管API可以简单地扩展为使srand采用unsigned long long,或者添加单独的srand(unsigned char *, size_t)过载


事实上,rand()的实际问题原则上不是实现,而是:

  • 向后兼容性;许多当前的实现使用次优生成器,通常具有糟糕选择的参数;一个臭名昭著的例子是Visual C++,它的RAND_MAX只有32767。然而,这一点无法轻易改变,因为它会破坏与过去的兼容性——使用带有固定种子的srand进行可重复模拟的人不会太高兴(事实上,IIRC的上述实现可以追溯到80年代中期的Microsoft C早期版本,甚至Lattice C)
  • 界面过于简单;rand()为单个生成器提供了整个程序的全局状态。虽然这对于许多简单的用例来说非常好(而且实际上非常方便),但它也带来了问题:

    使用多线程代码的
    • :要修复它,你要么需要一个全局互斥体-这会无故减慢一切,因为调用序列本身变得随机-要么需要线程本地状态;最后一个已经被几个实现所采用(特别是Visual C++)
    • 如果你想要一个"私有"的、可复制的序列到你的程序的特定模块中,而不会影响全局状态

最后,rand状态:

  • 没有指定实际的实现(C标准只提供了一个示例实现),因此任何旨在在不同编译器之间产生可复制输出(或期望某种已知质量的PRNG)的程序都必须推出自己的生成器
  • 没有提供任何跨平台的方法来获得合适的种子(time(NULL)不是,因为它不够细粒度,而且通常——想想没有RTC的嵌入式设备——甚至不够随机)

因此产生了新的<random>标头,它试图修复这种混乱,提供了以下算法:

  • 完全指定(这样您就可以拥有跨编译器的可复制输出和有保证的特性,比如生成器的范围)
  • 通常具有最先进的质量(从图书馆设计时开始;见下文)
  • 封装在类中(这样就不会强制使用全局状态,从而避免了完全线程化和非局部性问题)

。。。以及默认的CCD_ 34来对它们进行种子化。

现在,如果你问我,我会希望一个简单的API构建在此基础上"容易","猜一个数字"的情况(类似于Python如何提供"复杂的"API,但也类似于琐碎的random.randint&Co.,为我们这些不复杂的人使用全局预反馈PRNG,他们不想淹没在随机的设备/引擎/适配器中/每当我们想为宾果卡提取一个数字时),但事实上,您可以在当前设施上轻松地自己构建它(而在简单的设施上构建"完整的"API是不可能的)。


最后,回到您的性能比较:正如其他人所指定的,您将快速LCG与较慢(但通常认为质量更好)的Mersenne Twister进行比较;如果您对LCG的质量满意,可以使用std::minstd_rand而不是std::mt19937

事实上,在调整函数以使用std::minstd_rand并避免初始化时使用无用的静态变量之后

int getRandNum_New() {
static std::minstd_rand eng{std::random_device{}()};
static std::uniform_int_distribution<int> dist{0, 5};
return dist(eng);
}

我得到9毫秒(旧)vs 21毫秒(新);最后,如果我去掉dist(与经典的模运算符相比,它处理的是输出范围的分布偏斜,而不是输入范围的倍数),然后回到getRandNum_Old()

int getRandNum_New() {
static std::minstd_rand eng{std::random_device{}()};
return eng() % 6;
}

我将其降低到6毫秒(因此,速度快30%),可能是因为与对rand()的调用不同,std::minstd_rand更容易内联。


顺便说一句,我使用手动滚动的(但几乎符合标准库接口)XorShift64*进行了同样的测试,它比rand()快2.3倍(3.68ms vs 8.61ms);考虑到这一点,与Mersenne Twister和提供的各种LCG不同,它以优异的成绩通过了当前的随机性测试套件——它非常快,这让你想知道为什么它还没有包含在标准库中。

如果你用大于5的范围重复你的实验,那么你可能会看到不同的结果。当您的范围明显小于RAND_MAX时,大多数应用程序都不会出现问题。

例如,如果我们的RAND_MAX为25,那么rand() % 5将产生具有以下频率的数字:

0: 6
1: 5
2: 5
3: 5
4: 5

由于RAND_MAX保证大于32767,并且最不可能和最可能之间的频率差仅为1,因此对于小数字,对于大多数用例来说,分布几乎是随机的。

首先,令人惊讶的是,答案会根据您使用随机数的目的而变化。如果要驱动,比如说,一个随机的背景颜色转换器,使用rand()是完全可以的。如果你使用随机数创建一个随机扑克手或加密安全密钥,那么这是不好的。

可预测性:序列012345012345012345…将提供样本中每个数字的均匀分布,但显然不是随机的。对于一个随机序列,n+1的值不能很容易地通过n的值(或者甚至通过n、n-1、n-2、n-3等的值)来预测。显然,相同数字的重复序列是退化的情况,但用任何线性同余生成器生成的序列都可以进行分析;如果使用公共库中公共LCG的默认开箱即用设置,恶意人员可能会毫不费力地"破坏序列"。过去,一些在线赌场(以及一些实体赌场)因使用糟糕的随机数生成器的机器而遭受损失。即使是本该更了解的人也被赶上了;来自几个制造商的TPM芯片已经被证明比密钥的比特长度更容易被破坏,否则密钥的比特长将被预测,因为在密钥生成参数方面做出了糟糕的选择。

分布:正如视频中所暗示的,取100的模(或任何不能被序列长度整除的值)将保证某些结果的可能性至少比其他结果略高。在32767个可能的起始值模100的宇宙中,数字0到66将比值67到99更频繁地出现328/327(0.3%);可能为攻击者提供优势的因素。

正确答案是:这取决于你所说的"更好"是什么意思

"新"的<random>引擎是在13年前引入C++的,所以它们并不是什么新鲜事。C库rand()是几十年前引入的,在当时对许多事情都非常有用。

C++标准库提供了三类随机数生成器引擎:线性同余(其中rand()是一个例子)、滞后斐波那契和Mersenne Twister。每个类都有权衡,每个类在某些方面都是"最好的"。例如,LCG的状态非常小,如果选择了正确的参数,在现代桌面处理器上速度相当快。LFG具有更大的状态,并且只使用内存获取和加法操作,因此在缺乏专门数学硬件的嵌入式系统和微控制器上速度非常快。MTG具有巨大的状态,速度较慢,但可以具有非常大的非重复序列,具有优异的光谱特性。

如果提供的生成器都不适合您的特定用途,那么C++标准库还为硬件生成器或您自己的自定义引擎提供了一个接口。没有一个生成器打算单独使用:它们的预期用途是通过一个分布对象,该对象提供具有特定概率分布函数的随机序列。

rand()相比,<random>的另一个优点是rand()使用全局状态,不可重入或线程安全,并且允许每个进程有一个实例。如果您需要细粒度的控制或可预测性(即,能够在给定RNG种子状态的情况下重现错误),那么rand()是无用的。<random>生成器是本地实例化的,并且具有可序列化(和可恢复)状态。