web-dev-qa-db-ja.com

新しいランダムライブラリがstd :: Rand()より優れているのはなぜですか?

そこで私は Rand()Considered Harmful と呼ばれる講演を見ました。そしてそれは単純なstd::Rand()と法のパラダイムの上に乱数生成のエンジン分配パラダイムを使うことを提唱しました。

しかし、私はstd::Rand()の失敗を直接目にしたかったので、簡単な実験をしました。

  1. 基本的に、getRandNum_Old()std::mt19937 + std::uniform_int_distributionを使って0から5までの乱数を生成する2つの関数getRandNum_New()std::Rand()を書きました。
  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::mt19937some_code __ + 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));
}
79
rcplusplus

"古い" Rand()のほとんどすべての実装は _ lcg _ ;を使います。それらは一般的には最良のジェネレータではありませんが、通常、あなたはそれらがそのような基本的なテストで失敗するのを見ることはないでしょう - 平均と標準偏差は一般に最悪のPRNGでも正しく得られます。

"悪い"という一般的な失敗 - しかし一般的に十分な - Rand()実装は次のとおりです。

  • 下位ビットのランダム性が低い。
  • 短期間;
  • 低いRand_MAX;
  • 逐次抽出間の相関(一般に、LCGは限られた数の超平面上にある数を生成しますが、これはいくらか軽減される可能性があります)。

それでも、これらのどれもRand()のAPIに特定ではありません。特定の実装はxorshift-familyジェネレータをsrand/Randの後ろに置き、アルゴリズム的に言えば最先端のPRNGを得ることができるので、あなたがしたようなテストは弱点を示すことはないでしょう。出力。

編集: @R。Rand/srandインターフェースはsrandunsigned intを取るという事実によって制限されることを正しく注意しているので、実装がそれらの後ろに置くことができるジェネレータは本質的にUINT_MAXに制限されます可能性のある開始シード(そしてこうして生成されたシーケンス)。 APIはsrandunsigned long longを取るように、または別のsrand(unsigned char *, size_t)オーバーロードを追加するように自明に拡張することができますが、これは確かに本当です。


実際のところ、Rand()の実際の問題は実装の大部分ではありません原則的にですが、

  • 後方互換性現在の実装の多くは、最適とは言えないジェネレータを使用しています。通常は、不適切に選択されたパラメータを使用します。悪名高い例はVisual C++で、これはたった32767のRand_MAXを使用します。ただし、これは過去との互換性を壊すので簡単には変更できません。再現性のあるシミュレーションに固定シードを付けてsrandを使用する人々はうんざりしません。 IIRC、前述の実装は、80年代半ばからMicrosoft Cの初期バージョン、さらにはLattice Cまでさかのぼります。
  • 単純なインタフェース。 Rand()はプログラム全体のグローバルな状態を持つ単一のジェネレータを提供します。これは多くの単純なユースケースでは完全に問題ありませんが(そして実際にはかなり便利ですが)、問題を引き起こします。

    • マルチスレッドコードでは、それを修正するためにはグローバルミューテックスが必要です - これは理由もなくすべてを遅くしますand呼び出しのシーケンスがランダムになるので、繰り返しの可能性があります。 ;この最後のものはいくつかの実装(特にVisual C++)によって採用されました。
    • グローバル状態に影響を与えない、プログラムの特定のモジュールへの「プライベートな」再現可能なシーケンスが必要な場合。

最後に、Randの業務状況は次のとおりです。

  • 実際の実装を指定しない(C標準は単なる実装例を提供する)ので、異なるコンパイラにわたって再現可能な出力を生成する(または既知の品質のPRNGを期待する)ことを意図したプログラムはそれ自身のジェネレータをロールバックしなければならない;
  • まともなシードを取得するためのクロスプラットフォームの方法は提供していません(time(NULL)は十分ではないため、十分ではありません。多くの場合、RTCがない組み込みデバイスを考える - 十分にランダムではありません)。

それゆえ、新しい<random>ヘッダは、この混乱を修正しようとしており、以下のアルゴリズムを提供します。

  • 完全に指定されているので(クロスコンパイラで再現可能な出力と保証された特性を持つことができます - ジェネレータの範囲など)。
  • 一般的には最先端の品質です(ライブラリが設計されたときから;下記参照)。
  • クラスにカプセル化されています(つまり、グローバルな状態が強制されることはありません。これにより、完全なスレッド化および非ローカリティの問題が回避されます)。

...そしてそれらをシードするためのデフォルトのrandom_device

さて、あなたが私に尋ねるなら、私はを "簡単な"、 "多くのことを推測する"ケースのために構築されたシンプルなAPIが好きだったでしょう。ランダムなデバイス/エンジン/アダプタ/に没頭したくない場合は何でも、グローバルな事前シードPRNGを使用している些細なrandom.randint&Co.も、ビンゴカード)、しかし、それはあなたが現在の機能の上に自分でそれを簡単に構築できることは事実です(単純なものの上に「完全な」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 ms(古い)と21 ms(新しい)が表示されます。最後に、(従来のモジュロ演算子と比較して、入力範囲の倍数ではない出力範囲の分布の偏りを処理する)distを取り除き、getRandNum_Old()で行っていることに戻ります。

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

Rand()の呼び出しとは異なり、std::minstd_Randのインライン化が簡単だからでしょう。


ちなみに、私はハンドロール(しかし標準ライブラリインターフェースにほぼ準拠しています)XorShift64*を使って同じテストをしました、そしてそれはRand()より2.3倍速いです(3.68 ms対8.61 ms)。 Mersenne Twisterや提供されているさまざまなLCGとは異なり、 現在のランダム性テストスイートを飛んでいる色で通過させますandこれは驚くほど高速です。まだ標準ライブラリ。

104
Matteo Italia

5より大きい範囲で実験を繰り返すと、おそらく異なる結果が表示されます。あなたの範囲がRand_MAXよりかなり小さい場合、ほとんどのアプリケーションにとって問題はありません。

たとえば、Rand_MAXが25の場合、Rand() % 5は次の頻度で数値を生成します。

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

Rand_MAXは32767を超えることが保証されており、頻度の最も低い可能性が高いと最も高い可能性が高いのは1にすぎません。少数の場合、分布はほとんどのユースケースで十分にランダムです。

6
Alan Birtles

まず、驚くべきことに、答えは乱数を使っているものによって変わります。例えば、ランダムな背景色チェンジャーを動かすのであれば、Rand()を使用しても問題ありません。乱数を使ってランダムなポーカーハンドや暗号化された安全な鍵を作成しているのであれば、それは問題ありません。

予測可能性:順序012345012345012345012345 ...は、サンプル内の各数値の均等分布を示しますが、明らかにランダムではありません。シーケンスがランダムであるためには、nの値(またはn、n-1、n-2、n-3などの値でさえ)でn + 1の値を簡単に予測することはできません。同じ数字の縮退は縮退した場合ですが、任意の線形合同生成元を使用して生成されたシーケンスを分析することができます。一般的なライブラリからの一般的なLCGのデフォルトのデフォルト設定を使用する場合、悪意のある人はまったく努力せずに「シーケンスを壊す」ことができます。過去には、いくつかのオンラインカジノ(およびいくつかの実店舗用のカジノ)が、貧弱な乱数発生器を使用している機械で損失を被っていました。もっとよく知っておくべき人でさえ巻き込まれています。いくつかの製造業者からのTPMチップは、鍵生成パラメータによる選択が不十分であるために、鍵のビット長が予測したものよりも壊れやすいことが実証されています。

分布:ビデオで触れたように、100のモジュロ(またはシーケンスの長さに均等に分割できない任意の値)を使用すると、一部の結果が他の結果よりもわずかに高くなる可能性があります。 100を法とする32767の可能な開始値の範囲では、0から66までの数値は、67から99までの値よりも328/327(0.3%)多く出現します。攻撃者に利点をもたらす可能性がある要因。

3
JackLThornton

正しい答えは次のとおりです。それは、「より良い」という意味によって異なります。

"新しい" <random>エンジンは13年以上前にC++に導入されたので、本当に新しいものではありません。 CライブラリRand()は数十年前に導入され、その当時非常に有用でした。

C++標準ライブラリは、3つのクラスの乱数ジェネレータエンジンを提供します。線形合同(そのうちのRand()がその例です)、Lagged Fibonacci、およびMersenne Twisterです。各クラスにはトレードオフがあり、各クラスはある意味で「最善」です。たとえば、LCGの状態は非常に小さく、正しいパラメータが選択されていれば、最近のデスクトッププロセッサではかなり高速です。 LFGは状態が大きく、メモリのフェッチと加算操作のみを使用するため、組み込みシステムや特殊な演算ハードウェアがないマイクロコントローラでは非常に高速です。 MTGは巨大な状態を持ち、遅いですが、優れたスペクトル特性を持つ非常に大きな非繰り返しシーケンスを持つことができます。

提供されているジェネレータがどれもあなたの特定の用途に十分に適していない場合、C++標準ライブラリはハードウェアジェネレータまたはあなた自身のカスタムエンジンのいずれかのためのインターフェースも提供します。ジェネレータのどれもスタンドアロンで使用されることを意図していません:それらの意図された使用は特定の確率分布関数でランダムなシーケンスを提供する分布オブジェクトを通してです。

Rand()に対する<random>のもう1つの利点は、Rand()がグローバル状態を使用し、再入可能またはスレッドセーフではなく、プロセスごとに単一のインスタンスを許可することです。きめ細かい制御や予測可能性(つまり、RNGシード状態でのバグを再現できること)が必要な場合は、Rand()は役に立ちません。 <random>ジェネレータはローカルにインスタンス化され、シリアライズ可能な(そして復元可能な)状態を持ちます。

1
Stephen M. Webb