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

时间:2018-10-29 07:46:34

标签: c++ c++11 random

所以我看到了一个名为rand() Considered Harmful的演讲,它提倡在简单的std::rand()加模范范式上使用随机数生成的引擎分布范式。

但是,我想亲眼看看std::rand()的失败之处,所以我做了一个快速实验:

  1. 基本上,我编写了2个函数getRandNum_Old()getRandNum_New(),分别使用std::rand()std::mt19937 + std::uniform_int_distribution生成了一个介于0和5之间的随机数。
  2. 然后,我使用“旧”方式生成了960,000(可被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("Spread\n");
    printf("       mean:  %.6f\n", mean(stdDevList_Old, M));
    printf("    std dev:  %.6f\n", stdDev(stdDevList_Old, M));
    printf("Time Taken (ms)\n");
    printf("       mean:  %.6f\n", mean(timeTakenList_Old, M));
    printf("    std dev:  %.6f\n", stdDev(timeTakenList_Old, M));
    printf("\n");
    printf("[NEW WAY]\n");
    printf("Spread\n");
    printf("       mean:  %.6f\n", mean(stdDevList_New, M));
    printf("    std dev:  %.6f\n", stdDev(stdDevList_New, M));
    printf("Time Taken (ms)\n");
    printf("       mean:  %.6f\n", mean(timeTakenList_New, M));
    printf("    std dev:  %.6f\n", stdDev(timeTakenList_New, M));
}

4 个答案:

答案 0 :(得分:104)

几乎所有“旧” rand()的实现都使用LCG;尽管它们通常不是最好的发生器,但是通常您不会看到它们无法通过这样的基本测试-即使在最差的PRNG情况下,均值和标准差也通常是正确的。

常见的“错误”失败-但很常见-rand()实现是:

  • 低阶位的低随机性;
  • 短期;
  • RAND_MAX;
  • 连续提取之间的一些相关性(通常,LCG产生的数字在有限数量的超平面上,尽管可以通过某种方式缓解)。

不过,这些都不是rand()的API所特有的。特定的实现方式可以在srand / rand后面放置一个xorshift系列生成器,并且从算法上讲,它可以获取最先进的PRNG,而无需更改接口,因此不会像您所做的那样显示任何测试输出中的任何弱点。

编辑: @R。正确地注意到rand / srand界面受{{1 }}需要一个srand,因此实现可能会将其放在其后的任何生成器本质上仅限于unsigned int个可能的起始种子(以及由此生成的序列)。确实确实如此,尽管可以对API进行简单的扩展以使UINT_MAX采用srand,或添加单独的unsigned long long重载。


实际上,srand(unsigned char *, size_t)的实际问​​题在原理上并不是实现很多 ,而是:

  • 向后兼容性;许多当前的实现使用次优生成器,通常具有错误选择的参数;一个臭名昭著的例子是Visual C ++,它的rand()只有32767。但是,这不能轻易更改,因为它会破坏与过去的兼容性-人们使用带有固定种子的RAND_MAX进行可复制的仿真不会太高兴(实际上,IIRC的上述实现可追溯到80年代中期的Microsoft C早期版本-甚至是Lattice C);
  • 简单界面; srand为单个生成器提供整个程序的全局状态。尽管对于许多简单的用例来说,这是完全可以的(实际上非​​常方便),但它会带来一些问题:

    • 使用多线程代码:要修复它,您需要一个全局互斥体-它将无缘无故地降低所有速度 ,因为调用序列本身是随机的,所以会消除任何可重复性的机会-或线程局部状态最后一个已被多种实现方式采用(特别是Visual C ++);
    • 如果您希望在程序的特定模块中使用不影响全局状态的“私有”,可复制序列。

最后,rand()的状态:

  • 并未指定实际的实现(C标准仅提供了示例实现),因此任何旨在在不同编译器之间产生可重现输出(或期望具有某些已知质量的PRNG)的程序都必须使用自己的生成器;
  • 不提供任何跨平台方法来获得体面的种子(rand不够,因为它不够精细,而且经常-认为没有RTC的嵌入式设备-甚至不够随机)。

因此有了新的time(NULL)标头,该标头试图通过提供以下算法来解决此问题:

  • 充分指定(这样您就可以具有交叉编译器可再现的输出和有保证的特性-例如生成器的范围);
  • 通常具有最先进的质量(设计库时起;请参见下文);
  • 封装在类中(这样就不会强制您使用全局状态,从而避免了完全的线程和非局部性问题);

...以及默认的<random>来播种它们。

现在,如果您问我,我也希望也是在此基础上构建的一个简单API,用于“简单”,“猜数字”情况(类似于Python如何提供“复杂的” API,但也使用琐碎的random_device&Co.为我们提供了简单的全局预填充PRNG,这些简单的人不想在每次提取时都淹没在随机设备/引擎/适配器/任何物品中确实是一个宾果卡的编号),但确实可以轻松地在当前设施上自行构建它(而无法通过简单的构建构建完整的API)。


最后,回到性能比较:正如其他人所指出的,您正在将快速LCG与速度较慢(但通常认为质量更好)的Mersenne Twister进行比较。如果您对LCG的质量没问题,可以使用random.randint代替std::minstd_rand

实际上,在调整函数以使用std::mt19937并避免使用无用的静态变量进行初始化之后

std::minstd_rand

我得到9毫秒(旧)对21毫秒(新);最后,如果我摆脱了int getRandNum_New() { static std::minstd_rand eng{std::random_device{}()}; static std::uniform_int_distribution<int> dist{0, 5}; return dist(eng); } (与传统的模运算符相比,它处理了输出范围而不是输入范围倍数的分布偏斜),然后返回到您在dist中所做的工作

getRandNum_Old()

我将其降低到6毫秒(因此快了30%),可能是因为与调用int getRandNum_New() { static std::minstd_rand eng{std::random_device{}()}; return eng() % 6; } 相比,rand()更易于内联。


顺便说一句,我使用手动滚动(但非常符合标准库接口)std::minstd_rand做了相同的测试,它比XorShift64*快2.3倍(3.68 ms vs 8.61 ms) ;鉴于此,与Mersenne Twister和各种提供的LCG不同,它passes the current randomness test suites with flying colors 的运行速度非常快,这使您想知道为什么它尚未包含在标准库中。

答案 1 :(得分:6)

如果您以大于5的范围重复进行实验,则可能会看到不同的结果。当您的范围明显小于RAND_MAX时,对于大多数应用程序来说就没有问题了。

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

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

保证RAND_MAX大于32767,最小可能性和最大可能性之间的频率差异仅为1,对于较小的数字,在大多数使用情况下,分布几乎是随机的。

答案 2 :(得分:3)

首先,令人惊讶的是,答案随您使用随机数的目的而变化。如果要驱动,例如,使用rand()驱动一个随机的背景颜色更改器就很好了。如果您使用随机数来创建随机的扑克手或具有加密安全性的密钥,那是不正确的。

可预测性:序列012345012345012345012345 ...将提供样本中每个数字的均匀分布,但显然不是随机的。对于随机序列,无法轻易通过n的值(甚至是n,n-1,n-2,n-3等的值)轻松预测n + 1的值。相同的数字是简并的情况,但是可以对使用任何线性同余生成器生成的序列进行分析;如果您使用通用库中通用LCG的默认开箱即用设置,则恶意人员可以不费吹灰之力就“破坏序列”。过去,一些在线赌场(以及一些实体赌场)因使用不良随机数生成器的机器而遭受损失。即使是应该更了解的人也被赶上了。事实证明,由于密钥生成参数选择不当,一些制造商的TPM芯片比密钥的位长更容易破解。

分布:如视频中所提到的,取100的模(或任何不能在序列长度上均分的值)将确保某些结果比其他结果至少更有可能出现。在32767个可能的起始值模为100的范围中,数字0到66的出现频率将比值67到99的出现频率高328/327(0.3%);可能为攻击者提供优势的因素。

答案 3 :(得分:1)

正确的答案是:它取决于您所说的“更好”。

“新” <random>引擎是13年前引入C ++的,因此并不是真正的新引擎。 C库rand()于数十年前引入,在当时对于许多事物都非常有用。

C ++标准库提供了三类随机数生成器引擎:“线性同余”(例如rand()),“滞后斐波那契”和“梅森扭转者”。每个类都有权衡,每个类在某些方面是“最佳”的。例如,LCG的状态非常小,如果选择了正确的参数,则在现代台式机处理器上运行速度非常快。 LFG具有较大的状态,并且仅使用内存提取和加法运算,因此在缺少专用数学硬件的嵌入式系统和微控制器上的速度非常快。 MTG具有巨大的状态且运行缓慢,但是可以具有非常大的非重复序列,并具有出色的光谱特性。

如果提供的所有生成器都不足以满足您的特定用途,则C ++标准库还为硬件生成器或您自己的自定义引擎提供了接口。没有一种发生器可以独立使用:它们的使用目的是通过一个分配对象,该对象提供具有特定概率分布函数的随机序列。

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