为什么新的随机库比std::rand()更好
Why is the new random library better than std::rand()?
所以我看到了一个名为rand()的演讲,它被认为是有害的,它提倡使用随机数生成的引擎分布范式,而不是简单的std::rand()
加模范式。
然而,我想亲眼目睹std::rand()
的失败,所以我做了一个快速的实验:
- 基本上,我编写了两个函数
getRandNum_Old()
和getRandNum_New()
,分别使用std::rand()
和std::mt19937
+std::uniform_int_distribution
生成0到5之间的随机数 - 然后,我用"旧"的方法生成了96万个(可被6整除)随机数,并记录了0-5的频率。然后我计算了这些频率的标准偏差。我想要的是一个尽可能低的标准偏差,因为如果分布真的是均匀的,就会发生这种情况
- 我运行了1000次模拟,并记录了每个模拟的标准偏差。我还记录了它所花费的时间(以毫秒为单位)
- 之后,我又做了同样的事情,但这次是用"新"的方式生成随机数
- 最后,我计算了新旧方法的标准差列表的平均值和标准差,以及新旧方法所用时间列表的平均数和标准差
结果如下:
[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>
生成器是本地实例化的,并且具有可序列化(和可恢复)状态。
- C++:将控制台输出存储在宏中更好吗
- FFmpeg:制作一个应用程序比直接使用ffmepg更好吗
- 初始化具有非默认构造函数的std::数组项的更好方法
- 有没有比在库中添加一个并非由所有派生类实现的新虚拟函数更好的设计实践
- 为什么新的随机库比std::rand()更好
- 寻找一种更好的方法来表示无符号字符数组
- 哪种方法更好,性能明智
- 什么更好?返回对象指针列表?或返回指向对象列表的指针?
- 什么是更好的做法?通过指针或标识符传递类成员?
- 寻求更好地理解标准::访问
- 线程消息传递或更好:在"大师班"中访问其他班级的成员
- 有没有更好的方法来处理异常? try-catch块真的很丑
- 如何更好地检查两个 char 变量是否在一组值中?
- 有没有更好的方法对C++中的三个整数进行排序?
- 什么模板用法在阶乘中更好
- 平面缓冲区可以利用向量中的 0 吗?还是其他小波比哈尔变换更好?
- 我们应该如何使用枚举类进行索引(或者我们应该更好地避免这种情况)?
- Protobuf中重复字段的问题.使用重复字段进行序列化/反序列化的更好方法是什么?
- 比使用 s.str().c_str() 更好的表达?
- 测量时间以在 c++ 中生成更好的随机数