はじめに
一様分布な浮動小数点数の乱数 (例えば、
の範囲の均等な乱数) が欲しいときがしばしばあります。
皆さんもおそらく random.NextDouble() * (max - min) + min みたいなコードを書いたことがあるかと思います。
しかし、このコードが実は複数の問題をはらんでいることをご存知でしょうか?
本稿では、そういった問題とその解決策、またパフォーマンスとの兼ね合いについて紹介したいと思います。
本稿の擬似乱数生成器は、このシグネチャで実装されていると仮定します。
(C# / .NET の Random とは違うことにご留意ください。)
public class Random
{
public ulong Next();
}
また、浮動小数点数の型は基本的に double (IEEE 754 の binary64) を前提とします。
float や Half でも同様の議論は適用可能ですので適宜読み替えてください。
本稿では、以降 double のビットパターンや内部表現を理解されている前提で話を進めていきます。
要所要所で説明は入れていますが、よくわからないという方は Wikipedia などをご参照ください。
本稿でよく取り扱う形式の早見表を以下に示します。
| 形式名 |
C# 型名 |
指数部bit |
仮数部bit |
指数部ゲタ |
ラウンドトリップ桁数 |
| binary16 |
Half |
5 |
10 |
15 |
5 |
| binary32 |
float |
8 |
23 |
127 |
9 |
| binary64 |
double |
11 |
52 |
1023 |
17 |
| 種類 |
指数部 |
仮数部 |
| 0 |
0 |
0 |
| 非正規化数 |
0 |
>0 |
| 正規化数 |
1~(emax-1) |
any |
| ∞ |
emax |
0 |
| NaN |
emax |
>0 |
の範囲において
一番標準となる
の範囲において一様乱数を生成する場合を考えます。
念のため、
は閉区間 (その値を含む) 、
は開区間 (その値を含まない) ことを表します。要するに 0.0 <= x && x < 1.0 です。
数学的には閉区間でも開区間でも確率は同じ (ある特定の値を取る確率は 0 なので) ですが、工学的にはある種の離散分布となるので確率が違ってきます。
で割る手法
const double POW_2_64 = 2.0 * (1ul << 63);
return random.Next() / POW_2_64;
return random.Next() * (1.0 / POW_2_64);
前述のスライドで「除算法」とされていた手法です。
除算版と乗算版は本質的に同一です。 C# では今のところ最適化されませんが、 C 言語 (clang) なら掛け算のほうに統一されます。
開区間にならない (1.0 が出る)
random.Next() が 0xffffffff_ffffffff を返した場合に 1.0 が返ります。
「誤差だよ誤差!」と思った方もいらっしゃるかもしれませんが、例えば 指数分布 に変換するコードを書いた場合問題になりえます。
double r = random.Next() * (1.0 / POW_2_64);
return -Math.Log(1.0 - r);
乱数が絡む関係上、これを見逃すと「ユーザ側でごくまれに計算エラーが発生するが、開発機では全く再現できない」ということになりがちです。
未然に防ぐためにも、必ず定義域内に収まるようにすべきです。
表現可能な値すべてが出ない
例えば、この手法で出現しうる 0 より大きい最小の数は 5.421010862427522e-20 (0x3bf0000000000000) です。
一方で、本来の 0 より大きい最小の数は、正規化数の範囲では 2.225073858507201e-308 (0x0010000000000000) 、非正規化数も含めれば 4.940656458412465e-324 (0x0000000000000001) です。桁の桁が違いますね。
これまた「誤差だよ誤差!」と思われたかもしれませんが、上と同じように指数分布に変換した場合に違いが顕著となります。
return -Math.Log(r);
ちょっと遅い
詳しいパフォーマンスについては後で触れますが、この手法はちょっと遅くなります。
というのも、 long → double に数値変換する intrinsics はあります (cvtsi2sd) が、 ulong → double では AVX-512 でない限り (_mm256_cvtepu64_pd) ありません。
ということで、前処理の命令が挟まって微妙に遅くなります。
で割る手法
return (random.Next() >> 11) * (1.0 / (1ul << 53));
前述の
で割る手法の欠点に対応したのが、この
で割る手法です。
なぜ
なのかというと、 double 型の仮数部の精度が 53 bit だからです。
実際の仮数部は 52 bit 幅ですが、暗黙的に存在する先頭の 1 (ケチ表現) の分で 53 bit 精度になります。
random.Next() が 0xffffffff_ffffffff を返した場合に 0.9999999999999999 (0x3fefffffffffffff) が返ります。
したがって、 1.0 以上になることはありません。
表現可能な値すべてが出ない
この問題は引き続き起こります。
この手法では、 0 より大きい最小の値は 1.110223024625157e-16 (0x3ca0000000000000) となります。
ちょっと速い
シフトがある分遅くなるのでは?と思われるかもしれませんが、
で割る手法に比べて速くなります。
具体的にアセンブリを見てみましょう。 BenchmarkDotNet の DisassemblyDiagnoser を使ってみます。
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26100.7171/24H2/2024Update/HudsonValley)
12th Gen Intel Core i7-12700F 2.10GHz, 1 CPU, 20 logical and 12 physical cores
.NET SDK 10.0.100
[Host] : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3
Job-OJRDUL : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3
まずは
のほう。
; FPTest.MultiplyFullWidth()
; return Rng.Next() * (1.0 / (2.0 * (1ul << 63)));
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
sub rsp,28
mov rcx,[rcx+8]
cmp [rcx],ecx
call qword ptr [7FFA9699FC48]; Seiran.Next()
vxorps xmm0,xmm0,xmm0
mov rdx,rax
shr rdx,1
mov ecx,eax
and ecx,1
or rcx,rdx
test rax,rax
cmovns rcx,rax
vcvtsi2sd xmm0,xmm0,rcx
jns short M00_L00
vaddsd xmm0,xmm0,xmm0
M00_L00:
vmulsd xmm0,xmm0,qword ptr [7FFA9666EC08]
add rsp,28
ret
; Total bytes of code 65
次に、
のほう。
; FPTest.MultiplyAppropriateWidth()
; return (Rng.Next() >> 11) * (1.0 / (1ul << 53));
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
sub rsp,28
mov rcx,[rcx+8]
cmp [rcx],ecx
call qword ptr [7FFA9698FC48]; Seiran.Next()
shr rax,0B
vxorps xmm0,xmm0,xmm0
mov rdx,rax
shr rdx,1
mov ecx,eax
and ecx,1
or rcx,rdx
test rax,rax
cmovns rcx,rax
vcvtsi2sd xmm0,xmm0,rcx
jns short M00_L00
vaddsd xmm0,xmm0,xmm0
M00_L00:
vmulsd xmm0,xmm0,qword ptr [7FFA966614F8]
add rsp,28
ret
; Total bytes of code 69
うーん、シフト命令が増えているので遅くなっているように見えますね。
しかし、実際のベンチマークでは、
| Method |
Mean |
Error |
StdDev |
Code Size |
| MultiplyFullWidth |
5.808 ns |
0.1336 ns |
0.1538 ns |
115 B |
| MultiplyAppropriateWidth |
1.928 ns |
0.0613 ns |
0.0776 ns |
119 B |
このように明らかに
のほうが速くなります。
同じようなコードを C で書いて、
double mul64(uint64_t num) {
return (double)(num) * (1.0 / (2.0 * ((uint64_t)1 << 63)));
}
double mul53(uint64_t num) {
return (double)(num >> 11) * (1.0 / (1.0 * ((uint64_t)1 << 53)));
}
Compiler Explorer にかけてみると (x86-64 clang 21.1.0; -O3)、
.LCPI0_0:
.long 1127219200
.long 1160773632
.long 0
.long 0
.LCPI0_1:
.quad 0x4330000000000000
.quad 0x4530000000000000
.LCPI0_2:
.quad 0x3bf0000000000000
mul64:
movq xmm1, rdi
punpckldq xmm1, xmmword ptr [rip + .LCPI0_0]
subpd xmm1, xmmword ptr [rip + .LCPI0_1]
movapd xmm0, xmm1
unpckhpd xmm0, xmm1
addsd xmm0, xmm1
mulsd xmm0, qword ptr [rip + .LCPI0_2]
ret
.LCPI1_0:
.quad 0x3ca0000000000000
mul53:
shr rdi, 11
cvtsi2sd xmm0, rdi
mulsd xmm0, qword ptr [rip + .LCPI1_0]
ret
命令数が結構減っていることが分かるかと思います。
余談:閉区間にするには
閉区間、つまり
の範囲にしたい場合、
の代わりに
で割る手法があります。
この手法は Unity の Random.value (これは float 型ですが) などで使われています。
ただし、表現可能な値すべてが出ない問題は引き続き発生しますので注意が必要です。
余談: .NET での実装
現時点での .NET の Random.Shared.NextDouble() はこの手法で 実装 されています。
なんでわざわざ Shared を付けたのかというと、 seed を指定した Random は 別の実装 になっており、そちらは Next() * (1.0 / int.MaxValue) (Next() は int.MaxValue より小さい正の乱数を返す) という感じになっているためです。
仮数部ビットパターン法
ulong bin = (0x3fful << 52) | (random.Next() >> 12);
return BitConverter.UInt64BitsToDouble(bin) - 1.0;
仮数部のビットパターンを直接埋める方式です。
BitConverter.UInt64BitsToDouble は、通常のキャスト (double)x ではなくビットパターンをそのまま移行するメソッドです。
random.Next() >> 12 が仮数部 (mantissa) です。したがって 52 bit の精度を持ちます。
0x3fful << 52 が符号部 (sign) と指数部 (exponent) です。ここでは、
になるように指定しています。
したがって、 bin は
つまり
になります。
そこから
を引くことで
の乱数を得ます。かしこい!
random.Next() が 0xffffffff_ffffffff を返した場合に 0.9999999999999998 (0x3feffffffffffffe) が返ります。
したがって、 1.0 以上になることはありません。
表現可能な値すべてが出ない
そもそも上で述べた最大値の時点で、表現可能な最大値ではないです。
0 より大きい最小の値は 2.220446049250313e-16 (0x3cb0000000000000) です。
これは、
で割る手法の 2 倍ぐらい大きい (精度が悪い) です。
また問題として、この手法によって得られる値は 仮数部の最下位ビットが常に 0 になります 。
したがって 1 bit 分精度が落ちているとも言えます。
最下位ビットが 0 になる理由を説明しましょう。
まず、仮数部をランダムな値で埋めた時点では
になりますね。
ここから
を引くと
になります。ただこれだと小数点の左隣が
でないので、正規化を行います。
そうなると、少なくとも 1 ビット以上シフトしないと小数点の左隣が
になりませんので、右端に 0 がシフト数と同じぶんだけ (1 つ以上) 挿入されることになります。
したがって、右端 (最下位) ビットが常に 0 になる、というわけです。
パフォーマンス
を掛ける手法との比較です。
| Method |
Mean |
Error |
StdDev |
Code Size |
| MultiplyAppropriateWidth |
1.928 ns |
0.0613 ns |
0.0776 ns |
119 B |
| BitOp |
1.661 ns |
0.0581 ns |
0.0622 ns |
101 B |
若干速いです。
余談: dSFMT について
double の出力に特化した擬似乱数生成器 dSFMT は、この手法を応用して
の乱数を生成しています。
内部状態を仮数部 52 bit に保持して、残り 12 bit (0x3ff) を定数としたメモリパターン (0x3ffxxxxx_xxxxxxxx) をそのまま保持することで、ダイレクトに
の乱数を吐き出す仕組みです。
ただ、そのせいで
乱数の仮数部の最下位ビットが常に 0 になる問題が避けられません。むずかしい。
余談: 別の区間への応用
例えば、
の区間の値を得るために、同様の手法が適用できます。
ulong bin = (0x400ul << 52) | (random.Next() >> 12);
return BitConverter.UInt64BitsToDouble(bin) - 3.0;
exponent+mantissa 法
ulong rng = Rng.Next();
int entropy = 64;
int exponent = 0x3fe;
do
{
if ((rng & 1) != 0)
{
break;
}
else
{
exponent--;
entropy--;
rng >>= 1;
if (entropy == 0)
{
rng = Rng.Next();
entropy = 64;
}
}
} while (exponent != 0);
ulong mantissa = (entropy >= 52 ? rng : Rng.Next()) & ((1ul << 52) - 1);
return BitConverter.UInt64BitsToDouble((ulong)exponent << 52 | mantissa);
前述した hole さんのスライドで紹介されていた方式です。
まず、指数部 (exponent) を決定します。初期値は 0x3fe つまり
(0.5) です。
そこから 1 bit 乱数を生成し、真なら確定 (ループ終了)、偽ならデクリメント (つまり 1/2) してループを続行します。
つまり、これは
の 幾何分布 に基づく (50% の確率で成功する試行が成功するまでの試行回数に基づく) といえます。
次に仮数部 (mantissa) を 52 bit 乱数で埋めます。
あとはそのビットパターンで double 型を組み立てれば完成です。
最大の値は 0.9999999999999999 (0x3fefffffffffffff) ですので、開区間になります。
表現可能なすべての値が出る
ここにきてようやく、表現可能なすべての値が出せるアルゴリズムが出ました。
ただし注記しておきたいのは、 厳密に正しい確率で出るわけではありません 。
そうするには後述する Downey の補正を入れる必要があります。
遅い
コードを読んでいただければわかると思うのですが、 1 ビットずつ処理する必要があり遅いです。
また、仕方のないことですが乱数を複数消費する可能性があります。
| Method |
Mean |
Error |
StdDev |
Code Size |
| MultiplyAppropriateWidth |
1.928 ns |
0.0613 ns |
0.0776 ns |
119 B |
| ExponentMantissa_Naive |
9.996 ns |
0.1565 ns |
0.1307 ns |
185 B |
改造してみる
ulong rng = Rng.Next();
int entropy = 64;
int exponent = 0x3fe;
do
{
if (rng != 0)
{
int clz = BitOperations.LeadingZeroCount(rng);
exponent -= clz;
entropy -= clz;
break;
}
else
{
exponent -= 64;
rng = Rng.Next();
}
} while (exponent >= 0);
ulong mantissa = (entropy >= 52 ? rng : Rng.Next()) & ((1ul << 52) - 1);
if (exponent < 0)
{
mantissa >>= -exponent;
exponent = 0;
}
return BitConverter.UInt64BitsToDouble((ulong)exponent << 52 | mantissa);
おそらく 1 bit ずつ処理しているのがネックなので、そこを改造してみましょう。
bit が 0 の間ループするということは、 64 bit 乱数の先頭からの 0 の数を数えられればよさそうですね。
BitOperations.LeadingZeroCount というぴったりのメソッドがあるのでそれを使いましょう。
「先頭」は 1ul << 63 のほうです。具体的には、 BitOperations.LeadingZeroCount(1ul) == 63 になります。
毎回 TrailingZeroCount とどっちがどっちなのか分からなくなります……
仮数部の処理はほぼ同じですが、一応指数部が負になった時のことも考えています。まぁほとんどあり得ませんが……
| Method |
Mean |
Error |
StdDev |
Code Size |
| ExponentMantissa_Naive |
10.055 ns |
0.1603 ns |
0.1339 ns |
185 B |
| ExponentMantissa_Clz |
1.944 ns |
0.0616 ns |
0.0546 ns |
196 B |
だいぶ高速化しました。乗算法とほぼ同じぐらいです。
Abseil 実装
Google が作った C++ 効率化ライブラリである Abseil による 実装 (GenerateRealFromBits) です。
ulong rng = Rng.Next();
if (rng == 0)
{
return 0;
}
int exp = 0x3fe;
int clz = BitOperations.LeadingZeroCount(rng);
rng <<= clz;
exp -= clz;
rng >>= 11;
ulong result = (ulong)exp << 52 | (rng & ((1ul << 52) - 1));
return BitConverter.UInt64BitsToDouble(result);
本質的には exponent+mantissa 法に近いです。
まず、 0 を引いた場合はそのまま 0 を返します。これは近似的な実装ですね (
の確率で発生)。
続いて、 BitOperations.LeadingZeroCount で先頭から続く 0 の数を数えます。
そして、指数部 exp から clz を引きます。要するに exponent+mantissa 法で 1 bit ずつやっていた処理をまとめてやっている感じですね。
rng をシフトしているのは、桁合わせのような感じです。
まず rng <<= clz によって、必ず rng の先頭ビット (1ul << 63 のところ) が 1 になります。
そこから rng >>= 11 することで、末尾から 53 bit 目が 1 になります。これが浮動小数点数におけるケチ表現の 1 に当たるわけですね。
あとは残りの rng を仮数部として、ビットパターンを組み立てて返します。
最大の値は 0.9999999999999999 (0x3fefffffffffffff) ですので、開区間になります。
表現可能なすべての値は出ない
0 より大きい最小の値は 5.421010862427522e-20 (0x3bf0000000000000) です。
とはいえ、
で割る手法に比べると大体
ぐらい分解能が高いです。
すべての値は出ないことと引き換えに、乱数消費は 64 bit 1 個で固定です。
while/for ループなどが使いにくい環境 (シェーダーとか?) で便利かもしれません。
パフォーマンス
| Method |
Mean |
Error |
StdDev |
Code Size |
| MultiplyAppropriateWidth |
1.927 ns |
0.0317 ns |
0.0265 ns |
119 B |
| Abseil |
1.668 ns |
0.0384 ns |
0.0340 ns |
139 B |
乗算法より高速です。
Downey による補正
Downey 氏が発表した手法です。 *1
ulong rng = Rng.Next();
int entropy = 64;
int exponent;
for (exponent = 0x3fe; exponent > 0; exponent--)
{
if ((rng & 1) != 0)
{
break;
}
entropy--;
rng >>= 1;
if (entropy == 0)
{
rng = Rng.Next();
entropy = 64;
}
}
ulong mantissa = Rng.Next() & ((1ul << 52) - 1);
if (mantissa == 0 && (rng & 1) != 0)
{
exponent++;
}
return BitConverter.UInt64BitsToDouble((ulong)exponent << 52 | mantissa);
前半の exponent を決めるあたりは exponent+mantissa 法と同じですね。
ポイントとなるのは mantissa == 0 だった時の処理で、 50% の確率で exponent を増やしています。
どうしてこんなことをしているのかというと、そのままだと境界部分の確率が正しくないからです。

これは Downey の補正を入れる前の、それぞれの値が得られる範囲を表した図です。
色のついたエリアの値は ▲ の値に丸められる……という図です。
一見正しそうに見えますが、本来はこのようになるべきです。

特定の値の区間は、その値から始まって次の値で終わる (切り捨てのイメージ) のではなく、特定の値を中心としてその周囲に半分ずつ ± した範囲にあります (四捨五入のイメージ)。
そして、
ちょうどの値については、前半 (
) と後半 (
) で範囲の大きさが異なります。一般的な区間 (オレンジ) の 3/4 のサイズですね。
この範囲の違いをどう処理するかというと、 50% の確率でそのまま (つまり後半部) 、もう 50% の確率で次の指数部の区間 (つまり
の前半部) に送る、とします。スマートですね!
開区間にならない
指数部で
の区間 (0x3fe) を引いたうえで補正がかかる条件を満たすと 1.0 を返します。
そうなる確率は
です。無視できるほど低いとは言えません。
そうなった場合、 2 つの手法が考えられます。
対策 1 : clamp する
要するに 0.9999999999999999 を返す手法です。
当然ながら確率が偏ることになるので、できればしたくありませんね。
ただ、固定時間で実行したい場合や、乱数消費の個数を固定したい場合には役立つかもしれません。
対策 2 : 再抽選する
結果を破棄して再度乱数を生成する手法です。
多少時間はかかりますが確率が偏らないので、個人的にはこちらをお勧めします。
(無限ループの可能性は擬似乱数生成器が壊れていない限り無視できるでしょう。)
表現可能なすべての値が「均等に」出現する
この手法最大の利点です。
加えて、補正をかける処理自体は別の手法と組み合わせて適用できます。
遅い
| Method |
Mean |
Error |
StdDev |
Code Size |
| MultiplyAppropriateWidth |
1.927 ns |
0.0317 ns |
0.0265 ns |
119 B |
| Downey |
9.336 ns |
0.0735 ns |
0.0652 ns |
196 B |
1 bit ずつの処理があるのでそれはそう。
このあたりは LeadingZeroCount などと組み合わせれば改善できます。この手法の本質は補正なので問題ではないです。
「さいきょうの」手法
こちらのブログ で紹介されていた、「ぼくのかんがえたさいきょうの手法」です。
int exp = 0x3fe;
ulong frac;
while (true)
{
ulong i = Rng.Next();
int l = BitOperations.TrailingZeroCount(i) + 1;
exp -= 64 - l;
if (exp <= 0)
{
frac = i;
exp = 0;
break;
}
if (l > 52)
{
frac = i >> (l - 52 - 1);
break;
}
else if (l > 0)
{
int s = 52 - l + 1;
frac = i << s;
i = Rng.Next();
frac |= i & ((1ul << s) - 1);
break;
}
}
return BitConverter.UInt64BitsToDouble((ulong)exp << 52 | (frac & ((1ul << 52) - 1)));
本質的には exponent+mantissa 法ですね。
TrailingZeroCount でまとめて判定しているため、安直な exponent+mantissa 法よりは速そうです。
最大の値は 0.9999999999999999 (0x3fefffffffffffff) ですので、開区間になります。
表現可能なすべての値が出る
表現可能なすべての値が出現します。
ただし Downey の補正がないため、厳密に正しい確率で、というわけではありません。
「さいきょう」はなかなか険しいですね。
パフォーマンス
| Method |
Mean |
Error |
StdDev |
Code Size |
| MultiplyAppropriateWidth |
1.927 ns |
0.0317 ns |
0.0265 ns |
119 B |
| Saikyou |
2.399 ns |
0.0281 ns |
0.0234 ns |
205 B |
微妙な速度です。精度が向上していることを考えると十分に速いとも言えそうです。
random_real
.NET の Random でも使われている xoshiro256** の発案者 Sebastiano Vigna 氏の サイト で 紹介されている手法 です。
執筆者さん自体は Taylor R Campbell 氏だとか。
int exponent = -64;
ulong significand;
int shift;
while ((significand = Rng.Next()) == 0)
{
exponent -= 64;
if (exponent < -1074)
{
return 0;
}
}
shift = BitOperations.LeadingZeroCount(significand);
if (shift != 0)
{
exponent -= shift;
significand <<= shift;
significand |= Rng.Next() >> (64 - shift);
}
significand |= 1;
return Math.ScaleB((double)significand, exponent);
手法の説明をしていきましょう。
無限のランダムなビット列からランダムに抽出し、それを
の実数の 2 進展開の小数部として解釈します。
要するに、乱数ビット列 01010011 が得られたら 2 進小数 0.01010011 にする、みたいな話ですね。
このあたりは exponent+mantissa 法と同じイメージです。
ビット列が最初の 1 から 53 bit めに達すると、 double の仮数部の精度の関係上これ以上ビットを追加できなくなりますね。
この時、丸めが行われます。
一般に、浮動小数点数の丸めは 最近接丸め(偶数) です。
- 最も近い値に丸める (それはそう)
- 完全に二値の中間だった場合は、仮数部の最下位ビットが 0 (→偶数) になるほうを採用する
という丸め方式です。
この 2 行目が曲者で、通常の数学においては偏りを消す方向に働くのですが、今回は偏りを生む原因になります。
実例を見ていきましょう。
for (int offset = 0; offset <= 20; offset++)
{
long x = 1L << 54;
x += offset;
Console.WriteLine($"{x:x16} {BitConverter.DoubleToUInt64Bits((double)x):x16} {(double)x:g17}");
}
// int64_t (hex) double (hex) double (value)
// ------------------------------------------------
0040000000000000 4350000000000000 18014398509481984
0040000000000001 4350000000000000 18014398509481984
0040000000000002 4350000000000000 18014398509481984
0040000000000003 4350000000000001 18014398509481988
0040000000000004 4350000000000001 18014398509481988
0040000000000005 4350000000000001 18014398509481988
0040000000000006 4350000000000002 18014398509481992
0040000000000007 4350000000000002 18014398509481992
0040000000000008 4350000000000002 18014398509481992
0040000000000009 4350000000000002 18014398509481992
004000000000000a 4350000000000002 18014398509481992
004000000000000b 4350000000000003 18014398509481996
004000000000000c 4350000000000003 18014398509481996
004000000000000d 4350000000000003 18014398509481996
004000000000000e 4350000000000004 18014398509482000
004000000000000f 4350000000000004 18014398509482000
0040000000000010 4350000000000004 18014398509482000
0040000000000011 4350000000000004 18014398509482000
0040000000000012 4350000000000004 18014398509482000
0040000000000013 4350000000000005 18014398509482004
0040000000000014 4350000000000005 18014398509482004
を double に変換してみました。
double が表現できるのは 53 bit までなので、下位 2 bit は丸めの影響を受けます。
とりあえず、 double(hex) の列について、最下位が 2 の行は 5 つあるのに対して 3 の行は 3 つしかない (つまり偏っている) ということを観察してみてください。
その理由を詳しく調べてみることにします。
ここで、 int64_t (hex) の列の 0040000000000002 の行に注目してみてください。
これを 2 進数表示すると、
| 53 bit |
0b 0000 0000 0100 0000 .... 0000 0010
といった感じで、ちょうど仮数部から 0b10 がはみ出す形になっています。
53 bit の仮数部を整数部分と考えると、 0b10 は小数部分にあたります。
小数部分として解釈するとこれは 0.5 になりますね。 0.5 は「完全に二値の中間である」値ですので、偶数丸めの対象となります。
このとき一番近い二値は 4350000000000000 と 4350000000000001 なのですが、このうち偶数 (最下位ビットが 0) のほうを選択するので、 最終的な結果は 4350000000000000 になります。
このとき、下位ビットと仮数部との関係は下表のようになります。
| 下位ビット |
処理 |
| 0b00 |
仮数部+0 |
| 0b01 |
仮数部+0 |
| 0b10 |
偶数丸め |
| 0b11 |
仮数部+1 |
問題となるのは、今観察したように偶数の行が増えて奇数の行が減る、つまり偏ってしまうことです。
したがって今回は偶数丸めを防ぎたいわけなのですが、 C# から (というか、 C/C++ 以外のほとんどの言語では) 丸めモードを変更するのは簡単ではありません。
じゃあどう対策するのかというと、最下位ビットを 1 にセットします。
そうすることによって「完全に二値の中間である」状態にならなくすることで、偶数丸めを防ぐことができます。
この手法に確率的な正当性があるのかというと、あります。
「完全に二値の中間となる」、つまり小数部分が 0b10000000... ちょうどになる確率は
です。
本来絶対に起こりえない事象であることを考えると、潰してしまっても問題ない、というわけです。
さて、それでは最下位ビットを 1 にセットしたと考えて、前の表をフィルタしたものを見てみましょう:
// int64_t (hex) double (hex) double (value)
// ------------------------------------------------
0040000000000001 4350000000000000 18014398509481984
0040000000000003 4350000000000001 18014398509481988
0040000000000005 4350000000000001 18014398509481988
0040000000000007 4350000000000002 18014398509481992
0040000000000009 4350000000000002 18014398509481992
004000000000000b 4350000000000003 18014398509481996
004000000000000d 4350000000000003 18014398509481996
004000000000000f 4350000000000004 18014398509482000
0040000000000011 4350000000000004 18014398509482000
0040000000000013 4350000000000005 18014398509482004
double(hex) の列で最下位が 2 の行は 2 つあるのに対して 3 の行も 2 つと、確率が揃ったことを観察してみてください。
浮動小数点数の丸めに詳しい方なら「要するに Sticky Bit みたいな感じ?」と思われたかもしれません。その通りです。
開区間にならない
(double)significand のところで 1.0 になる可能性があります。
もっとも、この現象についてはソースコードのコメントに記載されていて、開区間にしたければ再抽選せよ、とあります。
表現可能なすべての値が「ほぼ均等に」出現する
上述した工夫により、表現可能なすべての値が出現します。
なお、 0.0 と 1.0 の出現可能性が低い問題はあるそうです。
(ほかの値は両側から丸められるのに対し、端にあるこれらの値は片側からしか丸められないためです。)
もっとも、 0.0 は出現確率が
ですし、 1.0 に至っては棄却対象なので、ほぼ問題にならないでしょう。
パフォーマンス
| Method |
Mean |
Error |
StdDev |
Code Size |
| MultiplyAppropriateWidth |
1.927 ns |
0.0317 ns |
0.0265 ns |
119 B |
| RandomReal |
7.146 ns |
0.1659 ns |
0.1775 ns |
366 B |
ちょっと遅いですね。
調べてみた感じ、 (double)significand が遅いらしく ((double)Rng.Next() だけでも 5.6 ns ぐらいかかる) 、小手先で多少の短縮をしたとしても大幅な改善は難しそうです。
MarcDense
ulong r = Rng.Next();
int lzc = BitOperations.LeadingZeroCount(r);
if (lzc <= 64 - 52)
{
int exponent = 0x3fe - lzc;
ulong mantissa = r & ((1ul << 52) - 1);
return BitConverter.UInt64BitsToDouble((ulong)exponent << 52 | mantissa);
}
return (0.5 / (1ul << 63)) * r;
もともとは float 用だったコードを double に移植したものです。
正確さと速さのバランスを取ったような実装です。
実装の説明
LeadingZeroCount の結果が 12 以下なら仮数部ビットパターン法 (っぽいもの) 、それ以上なら
乗算法になります。
設計者さん曰く、そもそも理想を目指したとしても「多次元に均等分布する擬似乱数生成器」がない限り絶対に発生しえないパターンが生じてしまうので、そういった極限の状況はケアしないことで現実的な実用性を重視した、とのことです。
最大の値は 0.9999999999999999 (0x3fefffffffffffff) ですので、開区間になります。
表現可能なすべての値は出ない
0 より大きい最小の値は 5.4210108624275222e-20 (要するに
) です。
なお、
の区間では表現可能なすべての値が出現します。
の区間では、密ではない (すべての値は出ない) ものの、等間隔に出現します。
もともとの float 用のコードでは、
という広い区間で表現可能なすべての値が出現します。
double 用に引き延ばしたので微妙になっているところはあるかと思います。
すべての値は出ないかわりに、 Abseil の実装と同様に乱数消費が 1 個固定です。
パフォーマンス
| Method |
Mean |
Error |
StdDev |
Median |
Code Size |
| MultiplyAppropriateWidth |
1.873 ns |
0.0109 ns |
0.0091 ns |
1.871 ns |
119 B |
| MarcDense |
1.649 ns |
0.0530 ns |
0.0470 ns |
1.633 ns |
168 B |
それなりに速いです。
Perfect
ulong expRange = 52ul << 52;
ulong one = BitConverter.DoubleToUInt64Bits(1.0);
int tailBits = 0;
ulong mantissa = Rng.Next() >> 12;
while (mantissa == 0)
{
one -= expRange;
if (one < expRange)
{
mantissa = Rng.Next() >> 29 << 17;
tailBits = 17;
break;
}
mantissa = Rng.Next() >> 12;
}
double num = BitConverter.UInt64BitsToDouble(one | mantissa) - BitConverter.UInt64BitsToDouble(one);
ulong numAsInt = BitConverter.DoubleToUInt64Bits(num);
tailBits += (int)((one >> 52) - (numAsInt >> 52));
if (tailBits > 52)
{
tailBits = 52;
}
ulong tail = Rng.Next() >> -tailBits;
return BitConverter.UInt64BitsToDouble(numAsInt + tail);
GitHub のほうに論文があるので、興味のある方は確認してみてください。
実装の説明
まずは
の乱数から
を引いて
の乱数を得るのと同じ感じで num を求めます。
で、そのままだと「仮数部ビットパターン法」で前述したように最下位ビットが 0 になる問題が生じるので、乱数 tail を生成して穴埋めする、みたいな感じです。
最大の値は 0.99999999999999989 (0x3fefffffffffffff) ですので、開区間になります。
表現可能なすべての値が出る
穴埋めのおかげで表現可能なすべての値が出ます。
ただし、 Downey の補正がないため、厳密に均等な確率ではありません。
ところで、ブログと論文をよく読むと、丸めを Round To Nearest (RTN; 最近接丸め) にするモードがあります。
これを使うと Downey の補正と同じ計算ができそうです。
ブログのほうの実装は誤っているようです。論文の Algorithm 3 の実装を参考にしました。
ulong tail = ((Rng.Next() >> (-tailBits - 1)) + 1) >> 1;
これで名実ともに Perfect になりますね。
なお、この変更を入れると例によって開区間ではなくなる (
が出る) ので注意が必要です。
パフォーマンス
| Method |
Mean |
Error |
StdDev |
Code Size |
| MultiplyAppropriateWidth |
1.673 ns |
0.0440 ns |
0.0390 ns |
119 B |
| Perfect |
2.596 ns |
0.0426 ns |
0.0356 ns |
228 B |
| Perfect_Rtn |
2.579 ns |
0.0583 ns |
0.0487 ns |
240 B |
乗算法に比べると少し遅いですが、完璧な分布が得られることを考えると速いほうとも言えます。
また、切り捨て (Perfect) と最近接丸め (Perfect_Rtn) では速度差がほとんどありませんでした。
まとめ
私見も交えた各手法のまとめはこんな感じです。星の数は多いほど良いです。
| 手法 |
開区間 |
表現可能精度 |
パフォーマンス |
で割る |
✖️ |
🌟🌟🌟 |
🌟🌟🌟 |
を掛ける |
✖️ |
🌟🌟🌟 |
🌟🌟🌟 |
を掛ける |
✅ |
🌟🌟 |
🌟🌟🌟🌟🌟 |
| 仮数部ビットパターン法 |
✅ |
🌟 |
🌟🌟🌟🌟🌟 |
| ExponentMantissa_Naive |
✅ |
🌟🌟🌟🌟 |
🌟 |
| ExponentMantissa_Clz |
✅ |
🌟🌟🌟🌟 |
🌟🌟🌟🌟🌟 |
| Abseil |
✅ |
🌟🌟🌟 |
🌟🌟🌟🌟🌟 |
| Downey |
✖️ |
🌟🌟🌟🌟🌟 |
🌟 |
| 「さいきょう」 |
✅ |
🌟🌟🌟🌟 |
🌟🌟🌟🌟 |
| RandomReal |
✖️ |
🌟🌟🌟🌟🌟 |
🌟🌟 |
| MarcDense |
✅ |
🌟🌟🌟 |
🌟🌟🌟🌟🌟 |
| Perfect |
✅ |
🌟🌟🌟🌟 |
🌟🌟🌟🌟 |
| Perfect_Rtn |
✖️ |
🌟🌟🌟🌟🌟 |
🌟🌟🌟🌟 |
詳細なパフォーマンスのまとめはこんな感じです。
| Method |
Mean |
Error |
StdDev |
Code Size |
| DivideFullWidth |
4.994 ns |
0.0473 ns |
0.0442 ns |
115 B |
| MultiplyFullWidth |
4.915 ns |
0.0680 ns |
0.0636 ns |
115 B |
| MultiplyAppropriateWidth |
1.673 ns |
0.0440 ns |
0.0390 ns |
119 B |
| BitOp |
1.462 ns |
0.0433 ns |
0.0384 ns |
101 B |
| ExponentMantissa_Naive |
8.729 ns |
0.1143 ns |
0.1013 ns |
185 B |
| ExponentMantissa_Clz |
1.687 ns |
0.0398 ns |
0.0332 ns |
196 B |
| Abseil |
1.469 ns |
0.0243 ns |
0.0203 ns |
139 B |
| Downey |
8.262 ns |
0.0672 ns |
0.0562 ns |
196 B |
| Saikyou |
2.662 ns |
0.0734 ns |
0.0651 ns |
205 B |
| RandomReal |
6.294 ns |
0.1448 ns |
0.1284 ns |
366 B |
| MarcDense |
1.471 ns |
0.0315 ns |
0.0279 ns |
168 B |
| Perfect |
2.596 ns |
0.0426 ns |
0.0356 ns |
228 B |
| Perfect_Rtn |
2.579 ns |
0.0583 ns |
0.0487 ns |
240 B |
速い順に並べ替えるとこんな感じです。

速度的には仮数部ビットパターン法や Abseil ・ MarcDense、精度的には Downey や RandomReal などがよさそうです。
トータルの星の数 (バランス) 的には ExponentMantissa_Clz や Perfect_Rtn がよさげです。
の範囲において
さて、ここまでは基本となる
の範囲でしたが、それでも十分ややこしいことは理解いただけたかと思います。
以降は任意の範囲
を扱います。もっとひどいことになります。
なお、引数の例外処理は省略するものとします。
つまり、常に
でかつ
ともに有限の数を表す (±∞ や NaN ではない) ものとします。
普通のやつ
return min + Rng.NextDouble() * (max - min);
普通の人はこう書くのではないかと思います。
お察しかと思いますが、これは複数の問題をはらんでいます。これで解決するなら本記事は存在しません。
開区間にならない
丸めの問題で max と同じ値が出る可能性があります。
例えば、 min == 3.082039625533209e-09, max == 0.00024414275416177517, rng == 0.99999999999999989 のときに 0.00024414275416177517 が返ります。
NextDouble() が 1.0 を返すバグを含んでいた場合、 max を超える可能性すらあります。
実例として、 min == 5.84856512116677e-08, max == 0.00010836065264006332 のときに 0.00010836065264006333 が返ります。
望ましくない結果を返す可能性がある
ここでの「望ましくない結果」というのは、「正規化数を入力したにもかかわらず、 ±∞ や NaN などの値が返る状態」を指します。
例えば、 min == -1e+308, max == 1e+308 の場合は max - min の計算でオーバーフローするため ∞ を返します。
確率が均等にならない
出やすい値や出にくい値、最悪の場合全く出ない値が生じる可能性があります。
簡単な集計プログラムを書いてみましょう。
全集計を簡単にするために Half 型でやります。
乱数の代わりに
の範囲にあるすべての値をこの式に入れて、その結果の分布を集計するプログラムです。
まずは、
の範囲、つまり result = r * Half.Pi でやってみましょう。
Half min = Half.Zero;
Half max = Half.Pi;
Dictionary<Half, int> bucket = new();
for (Half i = min; i < max; i = Half.BitIncrement(i))
{
bucket[i] = 0;
}
for (Half i = Half.Zero; i < Half.One; i = Half.BitIncrement(i))
{
Half r = min + (max - min) * i;
if (bucket.TryGetValue(r, out var c))
{
bucket[r] = c + 1;
}
else
{
bucket[r] = 1;
}
}
foreach (var pair in bucket.OrderBy(pair => pair.Key))
{
Console.WriteLine($"{pair.Key:g}\t{pair.Value}");
}
結果をグラフにするとこんな感じです。

理想的にはすべての数が 1 回ずつ均等に出現するはずですが、ところどころ 0 回や 2 回出現している数値があります。
次に、
の範囲でやってみましょう。 result = Half.Pi + r * Half.Pi です。

縦軸は対数です。
もともと一様分布ではない (小さいほど多い) ので左側が天元突破しているのは置いておいて、特に右半分に注目してみてください。
やはり 1 ではない (理想よりも多い) 確率で出現している値がたくさんあります。
原因として考えられるのは、まずは 鳩の巣原理 です。
Half 型において
の範囲には 15360 個の数値がありますが、
の範囲には 1024 個の数値しかありません。
これをマッピングするとなると、どうしても同じ値になってしまう値が発生します。
加えて、浮動小数点数の丸めによる問題もあるかと思います。
の範囲には 16968 個の数値があるため、
からきちんとマッピングされていれば 0 ~ 1 個で済むはずが、 2 個になっている箇所が結構あります。
丸めによって同じ値にマッピングされてしまった値があるのだと思います。
以上は精度の低い Half 型での問題ということで強調されている面もあるかと思いますが、本質的に float や double でも同様の問題が発生します。
パフォーマンス
今後のことを考えて、複数の min, max の値についてパフォーマンスを測ることにします。
今回は、以下の五種類について測定を行います。
: 異符号で指数が同じ
: 異符号で、終点の仮数部が 1 (エッジケース)
: 同符号で 0 から
: 同符号で指数が 1 異なる
: 同符号で指数が同じ
| Method |
min |
max |
Mean |
Error |
StdDev |
Median |
Code Size |
| Normal |
-3.141592653589793 |
3.141592653589793 |
1.918 ns |
0.0169 ns |
0.0158 ns |
1.920 ns |
155 B |
| Normal |
0 |
3.141592653589793 |
1.743 ns |
0.0217 ns |
0.0203 ns |
1.740 ns |
155 B |
| Normal |
-0 |
4503599627370497 |
1.935 ns |
0.0624 ns |
0.0553 ns |
1.898 ns |
155 B |
| Normal |
1 |
3.141592653589793 |
1.902 ns |
0.0107 ns |
0.0095 ns |
1.903 ns |
155 B |
| Normal |
2.718281828459045 |
3.141592653589793 |
1.925 ns |
0.0209 ns |
0.0196 ns |
1.928 ns |
155 B |
どれも同じぐらいですね。それはそう。
標準ライブラリにおける実装について
こちらの文献 *2 によると、 C++, Fortran, Java, Julia, Matlab, Octave, Python, R, Rust, Scilab, Swift の標準ライブラリはこの式で実装されています。
なお、我らが C# にはそもそも NextDouble(min, max) が存在しません。かなしい。
ただ一応、 こちらの文献 にはこの式を使うとよい、との記述があります。
Lerp 式
double r = Rng.NextDouble();
return min * (1.0 - r) + max * r;
Lerp (線形補間) のような式です。数学的には「普通のやつ」と同一ですが、実際にはいくつか差異があります。
開区間にならない
例えば、 min == 0.32975114157467966, max == 0.34467819389987275, rng == 0.99999999999999989 のときに 0.34467819389987275 が返ります。
望ましくない結果を返さない
「普通のやつ」とは違い、オーバーフローによって望ましくない結果を返すことはありません。
(もちろん直接 +∞ とかを食わせた場合は別ですが)
確率が均等にならない
「普通のやつ」と同様に
の乱数を引き延ばしている以上、出やすい値や出にくい値、全く出ない値が生じる可能性があります。
単調増加にならないことがある
驚くべきことに、この式では r が増えたときに必ずしも結果が増加するとは限りません。
具体例を挙げると、 min == 0.31236300804549622, max == 0.66538509490050768, rA == 0.7628541362561102, rB == 0.76285413625611032 のとき lerpA == 0.58166736719260559 > lerpB == 0.58166736719260548 となります。
ただ、乱数生成においてこの性質が問題になるかどうかは微妙なところです。線形補間として利用する場合には注意しておきたいポイントかもですね。
パフォーマンス
| Method |
min |
max |
Mean |
Error |
StdDev |
Median |
Code Size |
| Normal |
-3.141592653589793 |
3.141592653589793 |
1.918 ns |
0.0169 ns |
0.0158 ns |
1.920 ns |
155 B |
| Lerp |
-3.141592653589793 |
3.141592653589793 |
1.710 ns |
0.0192 ns |
0.0179 ns |
1.705 ns |
163 B |
| Normal |
0 |
3.141592653589793 |
1.743 ns |
0.0217 ns |
0.0203 ns |
1.740 ns |
155 B |
| Lerp |
0 |
3.141592653589793 |
1.930 ns |
0.0216 ns |
0.0192 ns |
1.927 ns |
163 B |
| Normal |
-0 |
4503599627370497 |
1.935 ns |
0.0624 ns |
0.0553 ns |
1.898 ns |
155 B |
| Lerp |
-0 |
4503599627370497 |
1.756 ns |
0.0361 ns |
0.0337 ns |
1.748 ns |
163 B |
| Normal |
1 |
3.141592653589793 |
1.902 ns |
0.0107 ns |
0.0095 ns |
1.903 ns |
155 B |
| Lerp |
1 |
3.141592653589793 |
1.757 ns |
0.0459 ns |
0.0430 ns |
1.746 ns |
163 B |
| Normal |
2.718281828459045 |
3.141592653589793 |
1.925 ns |
0.0209 ns |
0.0196 ns |
1.928 ns |
155 B |
| Lerp |
2.718281828459045 |
3.141592653589793 |
1.716 ns |
0.0143 ns |
0.0127 ns |
1.717 ns |
163 B |
ほとんど変わりませんが、若干速い傾向にあるぐらいです。
double r = rng.NextDouble();
return Math.FusedMultiplyAdd(r, max - min, min);
return Math.FusedMultiplyAdd(r, max, (1.0 - r) * min);
return Math.FusedMultiplyAdd(r, max, Math.FusedMultiplyAdd(-r, min, min));
「普通のやつ」や Lerp 式に FMA を適用したバリエーションです。
計算結果の誤差が多少小さくなる可能性はありますが、本質的には同じなので、同様の問題が引き続き発生します。
これらの細かい違いについては、 こちらの記事 で調査しています。
パフォーマンス
| Method |
min |
max |
Mean |
Error |
StdDev |
Median |
Code Size |
| Normal |
-3.141592653589793 |
3.141592653589793 |
1.918 ns |
0.0169 ns |
0.0158 ns |
1.920 ns |
155 B |
| NormalFma |
-3.141592653589793 |
3.141592653589793 |
1.943 ns |
0.0263 ns |
0.0246 ns |
1.936 ns |
152 B |
| LerpFma1 |
-3.141592653589793 |
3.141592653589793 |
1.732 ns |
0.0227 ns |
0.0212 ns |
1.736 ns |
168 B |
| LerpFma2 |
-3.141592653589793 |
3.141592653589793 |
1.944 ns |
0.0211 ns |
0.0197 ns |
1.943 ns |
161 B |
| Normal |
0 |
3.141592653589793 |
1.743 ns |
0.0217 ns |
0.0203 ns |
1.740 ns |
155 B |
| NormalFma |
0 |
3.141592653589793 |
1.727 ns |
0.0271 ns |
0.0254 ns |
1.725 ns |
152 B |
| LerpFma1 |
0 |
3.141592653589793 |
1.926 ns |
0.0219 ns |
0.0194 ns |
1.927 ns |
168 B |
| LerpFma2 |
0 |
3.141592653589793 |
1.766 ns |
0.0613 ns |
0.0656 ns |
1.758 ns |
161 B |
| Normal |
-0 |
4503599627370497 |
1.935 ns |
0.0624 ns |
0.0553 ns |
1.898 ns |
155 B |
| NormalFma |
-0 |
4503599627370497 |
1.918 ns |
0.0078 ns |
0.0073 ns |
1.918 ns |
152 B |
| LerpFma1 |
-0 |
4503599627370497 |
1.736 ns |
0.0239 ns |
0.0224 ns |
1.741 ns |
168 B |
| LerpFma2 |
-0 |
4503599627370497 |
1.937 ns |
0.0112 ns |
0.0094 ns |
1.933 ns |
161 B |
| Normal |
1 |
3.141592653589793 |
1.902 ns |
0.0107 ns |
0.0095 ns |
1.903 ns |
155 B |
| NormalFma |
1 |
3.141592653589793 |
1.928 ns |
0.0138 ns |
0.0116 ns |
1.926 ns |
152 B |
| LerpFma1 |
1 |
3.141592653589793 |
1.697 ns |
0.0076 ns |
0.0071 ns |
1.698 ns |
168 B |
| LerpFma2 |
1 |
3.141592653589793 |
1.934 ns |
0.0195 ns |
0.0182 ns |
1.932 ns |
161 B |
| Normal |
2.718281828459045 |
3.141592653589793 |
1.925 ns |
0.0209 ns |
0.0196 ns |
1.928 ns |
155 B |
| NormalFma |
2.718281828459045 |
3.141592653589793 |
1.916 ns |
0.0059 ns |
0.0049 ns |
1.916 ns |
152 B |
| LerpFma1 |
2.718281828459045 |
3.141592653589793 |
1.710 ns |
0.0086 ns |
0.0076 ns |
1.711 ns |
168 B |
| LerpFma2 |
2.718281828459045 |
3.141592653589793 |
1.933 ns |
0.0103 ns |
0.0096 ns |
1.929 ns |
161 B |
多少のブレはあるものの、大きな違いはなさそうです。
HalfLerp
double r = Rng.NextDouble();
return 2 * (min / 2 + (max / 2 - min / 2) * r);
「普通のやつ」に似ているのですが、いったん半分にして計算してから 2 倍しています。
何が違うのかというと、半分になったおかげでオーバーフローしません。あと 2 冪倍は非正規化数でない限り精度を失わないので同じ精度で計算できます。
開区間にならない
例えば、 min == 0.57526014729317487, max == 0.89380543555156478, rng == 0.99999999999999989 のときに 0.89380543555156478 を返します。
望ましくない結果は返さない
前述したように、オーバーフローしないため ±∞ を返したりはしません。
確率が均等にならない
「普通のやつ」と同様に
の乱数を引き延ばしている以上、出やすい値や出にくい値、全く出ない値が生じる可能性があります。
なお、「非正規化数でない限り」と以前書いたように、非正規化数だった場合は精度を失って確率の偏りが顕著になる場合があります。
極端な例を挙げれば、 halfLerp(-double.Epsilon, double.Epsilon) は常に 0 を返します。
パフォーマンス
| Method |
min |
max |
Mean |
Error |
StdDev |
Median |
Code Size |
| Normal |
-3.141592653589793 |
3.141592653589793 |
1.918 ns |
0.0169 ns |
0.0158 ns |
1.920 ns |
155 B |
| HalfLerp |
-3.141592653589793 |
3.141592653589793 |
1.927 ns |
0.0206 ns |
0.0193 ns |
1.923 ns |
171 B |
| Normal |
0 |
3.141592653589793 |
1.743 ns |
0.0217 ns |
0.0203 ns |
1.740 ns |
155 B |
| HalfLerp |
0 |
3.141592653589793 |
1.729 ns |
0.0258 ns |
0.0215 ns |
1.725 ns |
171 B |
| Normal |
-0 |
4503599627370497 |
1.935 ns |
0.0624 ns |
0.0553 ns |
1.898 ns |
155 B |
| HalfLerp |
-0 |
4503599627370497 |
2.004 ns |
0.0669 ns |
0.0771 ns |
1.972 ns |
171 B |
| Normal |
1 |
3.141592653589793 |
1.902 ns |
0.0107 ns |
0.0095 ns |
1.903 ns |
155 B |
| HalfLerp |
1 |
3.141592653589793 |
1.906 ns |
0.0112 ns |
0.0100 ns |
1.907 ns |
171 B |
| Normal |
2.718281828459045 |
3.141592653589793 |
1.925 ns |
0.0209 ns |
0.0196 ns |
1.928 ns |
155 B |
| HalfLerp |
2.718281828459045 |
3.141592653589793 |
1.923 ns |
0.0113 ns |
0.0100 ns |
1.921 ns |
171 B |
これもほとんど変わらないですね。
int SampleToPowerOfTwoExponent(int exponent)
{
while (exponent > -0x3fe)
{
int lz = BitOperations.LeadingZeroCount(Rng.Next());
if (lz == 64)
{
exponent -= 64;
}
else
{
return Math.Max(-0x3ff, exponent - 1 - lz);
}
}
return -0x3ff;
}
int SampleExponent(int emin, int emax)
{
int c = 0;
while (true)
{
int lz = BitOperations.LeadingZeroCount(Rng.Next());
if (lz == 64)
{
c += 64;
}
else
{
return emax - 1 - ((c + lz) % (emax - emin));
}
}
}
double SampleRange(double a, double b)
{
ulong aa = BitConverter.DoubleToUInt64Bits(a);
ulong bb = BitConverter.DoubleToUInt64Bits(b);
int ea = (int)(aa >> 52) - 0x3ff;
int eb = (int)(bb >> 52) - 0x3ff;
ulong ma = aa & ((1ul << 52) - 1);
ulong mb = bb & ((1ul << 52) - 1);
if (mb != 0)
{
eb++;
}
while (true)
{
int e;
if (ea == -0x3ff)
{
e = SampleToPowerOfTwoExponent(eb);
}
else
{
e = SampleExponent(ea, eb);
}
double v = BitConverter.UInt64BitsToDouble((ulong)(e + 0x3ff) << 52 | (Rng.Next() & ((1ul << 52) - 1)));
if (a <= v && v < b)
{
return v;
}
}
}
if (min >= 0.0)
{
return SampleRange(min, max);
}
if (max < 0.0)
{
return -SampleRange(-max, -min); TODO
}
double absmax = Math.Max(Math.Abs(min), Math.Abs(max));
double result;
do
{
result = SampleRange(0, absmax);
if ((Rng.Next() & 1) != 0)
{
result = -result;
}
} while (result < min || max <= result);
return result;
以下のブログ記事のコードをベースに、 float 用だったのを double に改造した実装です。
実装の説明
メインとなるのは SampleRange() です。
まず、 b (絶対値が大きいほう) の仮数部が 0 でなければ、指数部を 1 増やします。
逆に言えば、仮数部が 0 のときは 1 小さくなるということです。
の
のほうに当たるわけですね。
次に、 a (絶対値が小さいほう) の指数部が 0 (非正規化数領域) かどうかで処理を分岐します。
SampleToPowerOfTwoExponent と SampleExponent では、似たようなことをやっています。
LeadingZeroCount による効率化を除けば、やっていることは「確率 50% で exponent を減算、 もう 50% で処理を終了」といった感じで、要するに幾何分布に従う乱数で埋めているわけなのですが、末端まで行った時の処理が異なります。
SampleToPowerOfTwoExponent では Math.Max でクランプしていますが、 SampleExponent では % (emax - emin) でループさせています。
こうしている理由は、非正規化数エリアの確率の扱いに由来します。
非正規化数 (指数部 0x000) がカバーするエリアの重み (出現確率) は、その一つ上 (指数部 0x001) のエリアの重みと等しいです。
対して、それ以外のエリアでは、一つ上のエリアの重みは一つ下のエリアの重みの 2 倍になります。

SampleToPowerOfTwoExponent のほうは非正規化数を含むため、重みのトータルが
になります。
一方、 SampleExponent のほうは重みのトータルが
になります。あふれた
に当たった場合は再抽選とみなしてもう一度最初からやる……のですが、これはつまり % でループさせてしまっても問題ないということです。
幾何分布には「無記憶性」という性質があります。
コイントスをしていて
回失敗している状態でさらに
回失敗する確率と、単に最初から
回失敗する確率は同じ (つまり、過去のことは未来の確率に関係しない) ということです。
なので、わざわざ再抽選しなくとも、過去のことはなかったことにして (emax - emin を引いて) よい、それを繰り返しても構わない (% (emax - emin) としてよい) ということになります。
以上から指数部が求められたら、仮数部を 52 bit 一様乱数で埋めます。
あとは範囲チェックして OK ならそれを返し、 NG なら再生成するという流れです。
以上で説明したのは a, b が同符号だった場合でしたが、異符号だった場合はちょっと処理が増えます。
として上記の生成処理をやった後、ランダムに符号をつけます。それが範囲内なら返して範囲外なら再生成、という流れです。
開閉区間の実装が不完全
オリジナルの実装では
の区間を想定していますが、
がともに正の場合のみ正しいです。
ともに負の場合は
、正負に分かれている場合は
になります。
望ましくない結果を返さない
∞ や NaN といった望ましくない結果を返しません。
確率が「ほぼ」均等になる
範囲内の値すべてがほぼ正しい確率で出現します。
「ほぼ」とつけたのは Downey の補正がないからです。
これについても改造するのは簡単でしょう。
あと、符号を跨いだ場合に ±0 それぞれのぶんで実質 0 が 2 倍出やすくなる問題もあります。
これについても Downey の補正をかければ確率がそれぞれ 1/2 になるので解決するはずです。
パフォーマンス
| Method |
min |
max |
Mean |
Error |
StdDev |
Median |
Code Size |
| Normal |
-3.141592653589793 |
3.141592653589793 |
1.918 ns |
0.0169 ns |
0.0158 ns |
1.920 ns |
155 B |
| Matt |
-3.141592653589793 |
3.141592653589793 |
12.517 ns |
0.2780 ns |
0.4644 ns |
12.495 ns |
882 B |
| Normal |
0 |
3.141592653589793 |
1.743 ns |
0.0217 ns |
0.0203 ns |
1.740 ns |
155 B |
| Matt |
0 |
3.141592653589793 |
6.399 ns |
0.0416 ns |
0.0389 ns |
6.377 ns |
861 B |
| Normal |
-0 |
4503599627370497 |
1.935 ns |
0.0624 ns |
0.0553 ns |
1.898 ns |
155 B |
| Matt |
-0 |
4503599627370497 |
18.473 ns |
0.1117 ns |
0.0990 ns |
18.482 ns |
786 B |
| Normal |
1 |
3.141592653589793 |
1.902 ns |
0.0107 ns |
0.0095 ns |
1.903 ns |
155 B |
| Matt |
1 |
3.141592653589793 |
9.061 ns |
0.0412 ns |
0.0386 ns |
9.061 ns |
786 B |
| Normal |
2.718281828459045 |
3.141592653589793 |
1.925 ns |
0.0209 ns |
0.0196 ns |
1.928 ns |
155 B |
| Matt |
2.718281828459045 |
3.141592653589793 |
50.068 ns |
0.1239 ns |
0.1159 ns |
50.067 ns |
770 B |
結構重いです。最悪の場合は「普通のやつ」の 25 倍程度の時間がかかっています。
また、入力に依存してかなり速度が異なることも分かります。最速は 6 ns ですが最遅は 50 ns です。
GammaCO
double gamma = Math.Max(Math.BitIncrement(min) - min, max - Math.BitDecrement(max));
static ulong CeilInt(double a, double b, double g)
{
double s = b / g - a / g;
double epsilon;
if (Math.Abs(a) <= Math.Abs(b))
{
epsilon = -a / g - (s - b / g);
}
else
{
epsilon = b / g - (s + a / g);
}
ulong si = (ulong)Math.Ceiling(s);
return s != si ? si : (si + (epsilon > 0 ? 1ul : 0ul));
}
ulong hi = CeilInt(min, max, gamma);
ulong k = 1 + Rng.NextULong(hi - 1);
ulong khi = k >> 2;
ulong klo = k & 0x3;
if (Math.Abs(min) <= Math.Abs(max))
{
return k == hi ? min : 4 * (max / 4 - khi * gamma) - klo * gamma;
}
else
{
return 4 * (min / 4 + khi * gamma) + (klo - 1) * gamma;
}
文献 "Drawing random floating-point numbers from an interval" *3 に載っていた手法です。
実装の説明
まず gamma が何かというと、大雑把に言えば ulp を取得する操作です。
ulp は連続する 2 つの浮動小数点数の間隔を表します。簡単に言えば、仮数部の最下位ビットが 0/1 に変わった時の数値の差分です。例えば、
になります。
次に、 CeilInt は、コメントにもあるように Ceiling(b / g - a / g) を正確に計算するためのメソッドです。
g (gamma) は ulp なので 2 冪です。ということは除算は基本的には正確に実行することができます。
問題は引き算のほうなのですが、うまいこと処理することで (Dekker's exact summation algorithm だそうです) 誤差なく計算できるようにしています。
次に、 k に
の一様分布整数乱数を入れます。
NextULong(max) の実装には、もちろん Rng.Next() % max などではなく Lemire 式を使うとよいでしょう。
public ulong NextULong(ulong max)
{
ulong hi = Math.BigMul(Next(), max, out var lo);
if (lo < max)
{
ulong mod = (0ul - max) % max;
while (lo < mod)
{
hi = Math.BigMul(Next(), max, out lo);
}
}
return hi;
}
Rng.Next() % max ではダメな理由は、確率が偏るためです。
max が 2 冪でない限り、必ず 0 が出る確率 > max-1 が出る確率になります。
それ以降の計算は、雑に言えば a + (k - 1) * g みたいな感じです。コメントアウトされているコードがあるかと思いますが、これをオーバーフローしないように注意深く実装するとこういう感じになります。
なので、一言で言えば
という感じです。伝わりましたでしょうか……?
この手法の利点としては、ビット操作を必要としないこと、ループを必要としないこと (ただし、 NextULong() 内でループは必要です) でしょうか。
なお、論文のオリジナル実装には
すべての実装が載っています。
望ましくない結果を返さない
論文によれば、 a + (b - a) * x のようにオーバーフロー起因の望ましくない結果は返さないとされています。
論文オリジナルの実装では、一部の計算でオーバーフローを起こして正しくない結果を返す場合があったそうです。
本項冒頭のコードは、その問題が修正された正誤表のほうのコードを使用しています。
確率は「ほぼ」均等になるが、すべての値は出ない
「ほぼ」とつけたのは、空間的に一様分布にならない可能性があるためです。
論文曰く、 max - min が gamma の倍数でない場合にひとつだけ距離間隔が縮まる場合があるとのことです。
また、すべての値は出ません。
gamma は min と max でより大きいほうの ulp に依存するので、小さい ulp の区間では飛び飛びにしか値が出現しません。

上の図で説明しましょう。
浮動小数点数は通常上側の目盛りのように左 (小さいほう) に行けば行くほど間隔が密になるわけなのですが、この手法だと下側の目盛りのように一番大きな間隔に依存していて左に行っても等間隔になります。これを論文では "Spatial equidistribution" と表現しています。
なので、一番大きな目盛りの区間 (4 ~ 8) ではすべての値が出現しますが、それより小さな区間 (1 ~ 4) では飛び飛びに出現しない値 (例えば、 2.5 や 1.25) が出てきます。
パフォーマンス
| Method |
min |
max |
Mean |
Error |
StdDev |
Median |
Code Size |
| Normal |
-3.141592653589793 |
3.141592653589793 |
1.918 ns |
0.0169 ns |
0.0158 ns |
1.920 ns |
155 B |
| GammaCO |
-3.141592653589793 |
3.141592653589793 |
9.146 ns |
0.0238 ns |
0.0211 ns |
9.151 ns |
1,127 B |
| Normal |
0 |
3.141592653589793 |
1.743 ns |
0.0217 ns |
0.0203 ns |
1.740 ns |
155 B |
| GammaCO |
0 |
3.141592653589793 |
9.272 ns |
0.1411 ns |
0.1319 ns |
9.243 ns |
1,127 B |
| Normal |
-0 |
4503599627370497 |
1.935 ns |
0.0624 ns |
0.0553 ns |
1.898 ns |
155 B |
| GammaCO |
-0 |
4503599627370497 |
9.091 ns |
0.2059 ns |
0.2022 ns |
8.980 ns |
1,127 B |
| Normal |
1 |
3.141592653589793 |
1.902 ns |
0.0107 ns |
0.0095 ns |
1.903 ns |
155 B |
| GammaCO |
1 |
3.141592653589793 |
9.119 ns |
0.0355 ns |
0.0332 ns |
9.125 ns |
1,127 B |
| Normal |
2.718281828459045 |
3.141592653589793 |
1.925 ns |
0.0209 ns |
0.0196 ns |
1.928 ns |
155 B |
| GammaCO |
2.718281828459045 |
3.141592653589793 |
9.125 ns |
0.0359 ns |
0.0319 ns |
9.124 ns |
1,127 B |
「普通のやつ」の 4.5 倍程度の時間がかかっていますが、 Matt 法のように入力値依存で速度が変わることはなさそうです。
余談: PHP での実装について
PHP の Random\Randomizer::getFloat は、このアルゴリズムで実装されているそうです。
Cauldron
int sign;
ulong minExponent, minMantissa, maxExponent, maxMantissa;
{
double a, b;
sign = (min < 0.0 ? 1 : 0) + (max < 0.0 ? 1 : 0);
if (sign == 0)
{
a = min;
b = max;
}
else if (sign == 1)
{
a = 0;
b = (max > -min) ? max : -min;
}
else
{
a = max;
b = min;
}
ulong aBits = BitConverter.DoubleToUInt64Bits(a);
ulong bBits = BitConverter.DoubleToUInt64Bits(b);
minExponent = (aBits >> 52) & 0x7ff;
minMantissa = aBits & ((1ul << 52) - 1);
maxExponent = (bBits >> 52) & 0x7ff;
maxMantissa = bBits & ((1ul << 52) - 1);
}
if (minExponent == maxExponent)
{
ulong result = (minExponent << 52) | (Rng.NextULong(maxMantissa - minMantissa + 1) + minMantissa);
if (sign == 1)
{
result |= Rng.Next() << 63;
}
else if (sign == 2)
{
result |= 1ul << 63;
}
return BitConverter.UInt64BitsToDouble(result);
}
if (minExponent + 1 == maxExponent && minExponent > 0)
{
ulong invMinMantissa = ((1ul << 52) - 1) - minMantissa;
ulong range = invMinMantissa + maxMantissa + 1;
ulong exponent, mantissa;
ulong x = 0;
int i = 0;
while (true)
{
if (i <= 3)
{
x = Rng.Next();
i = 64;
}
if ((x & 1) != 0)
{
i--;
x >>= 1;
exponent = maxExponent;
mantissa = Rng.NextULong(range);
if (mantissa <= maxMantissa)
{
break;
}
}
else if ((x & 2) != 0)
{
i -= 2;
x >>= 2;
exponent = minExponent;
mantissa = Rng.NextULong(range);
if (mantissa <= invMinMantissa)
{
mantissa = ((1ul << 52) - 1) - mantissa;
break;
}
}
else
{
i -= 2;
x >>= 2;
}
}
ulong result = exponent << 52 | mantissa;
if (sign == 1)
{
result |= x << 63;
}
else if (sign == 2)
{
result |= 1ul << 63;
}
return BitConverter.UInt64BitsToDouble(result);
}
while (true)
{
ulong exponent = maxExponent;
ulong x;
while ((x = Rng.Next()) == 0)
{
exponent -= 64;
}
exponent -= (ulong)BitOperations.TrailingZeroCount(x);
if (exponent < minExponent || exponent > maxExponent)
{
exponent = 0;
}
x = Rng.Next();
ulong result = exponent << 52 | x >> 12;
if (sign == 1)
{
result |= x << 63;
}
else if (sign == 2)
{
result |= 1ul << 63;
}
double doubleResult = BitConverter.UInt64BitsToDouble(result);
if (min <= doubleResult && doubleResult <= max)
{
return doubleResult;
}
}
実装の説明
ながい!けど長さには理由があります。
最初のブロックでは初期化を行っています。
sign は 0 なら min, max がどちらも正の場合、 1 なら正負を跨ぐ場合、 2 ならどちらも負の場合となります。
次のブロックでは、 min, max の指数部が同じだった場合の特殊処理を行っています。
この場合は仮数部を
の一様分布整数乱数で埋めればよいですね。
その次のブロックでは、 min, max の指数部が 1 異なる場合 (かつ、非正規化数でない場合) の特殊処理を行っています。
1/2 の確率で maxExponent 側の指数部の処理を、 1/4 の確率で minExponent 側の指数部の処理を、残りの 1/4 は再抽選としています。
このブロックがなぜ存在するのかというと、最後のブロックの処理にすべてやらせると、仮数部がランダム生成な都合上
みたいなケースで採択率が
のようにひどいことになるためです。
それ以外の場合は、最後のブロックに来ます。
最後のブロックでは、まず exponent を求めます。
exponent は
の 幾何分布 に基づく (50% の確率で成功する試行が成功するまでの試行回数に基づく) ため、連続する 0 のビット数を BitOperations.TrailingZeroCount を使って効率よく数えます。
そうしたら符号部と仮数部をランダムに生成して、範囲内ならそれを返し、範囲外なら再抽選するようになっています。
このアルゴリズムでは閉区間
になります。
望ましくない結果を返さない
全編通してビット操作で組み立てているので、オーバーフローなどの問題は発生しません。
確率は「ほぼ」均等・すべての値を返しうる
ほぼ、とつけたのは例によって Downey の補正がないためです。
これも補正の実装はすぐできるでしょう。
非正規化数まわりの確率が正しくないかも、といったコメントがオリジナルのソースコードにありましたが、私が調べた限りでは問題なさそうでした。
別件の問題はありましたが……これについては後述します。
半無限ループの可能性
例えば min == 2.2250738585072004e-308 (0x000ffffffffffffe), max == 2.2250738585072019e-308 (0x0010000000000001) を与えた場合に、半無限ループに陥ります。
というのも、片方が非正規化数なので最後のブロックに行くのですが、仮数部の関係でどちらの指数部でも採択率が
となり、ほぼ採択されず半無限にリトライし続ける状態になります。
「min, max の指数部が 1 異なる場合」の処理をうまく修正する必要がありそうですね。
パフォーマンス
| Method |
min |
max |
Mean |
Error |
StdDev |
Median |
Code Size |
| Normal |
-3.141592653589793 |
3.141592653589793 |
1.918 ns |
0.0169 ns |
0.0158 ns |
1.920 ns |
155 B |
| Cauldron |
-3.141592653589793 |
3.141592653589793 |
7.011 ns |
0.0212 ns |
0.0188 ns |
7.016 ns |
855 B |
| Normal |
0 |
3.141592653589793 |
1.743 ns |
0.0217 ns |
0.0203 ns |
1.740 ns |
155 B |
| Cauldron |
0 |
3.141592653589793 |
6.831 ns |
0.1054 ns |
0.0935 ns |
6.793 ns |
839 B |
| Normal |
-0 |
4503599627370497 |
1.935 ns |
0.0624 ns |
0.0553 ns |
1.898 ns |
155 B |
| Cauldron |
-0 |
4503599627370497 |
15.236 ns |
0.1784 ns |
0.1669 ns |
15.225 ns |
839 B |
| Normal |
1 |
3.141592653589793 |
1.902 ns |
0.0107 ns |
0.0095 ns |
1.903 ns |
155 B |
| Cauldron |
1 |
3.141592653589793 |
24.717 ns |
0.0667 ns |
0.0624 ns |
24.742 ns |
1,036 B |
| Normal |
2.718281828459045 |
3.141592653589793 |
1.925 ns |
0.0209 ns |
0.0196 ns |
1.928 ns |
155 B |
| Cauldron |
2.718281828459045 |
3.141592653589793 |
2.298 ns |
0.0111 ns |
0.0093 ns |
2.293 ns |
1,007 B |
入力値にもよりますが、速いときは速いです。ただ、入力値によっては「普通のやつ」の 12 倍になるなど、改善の余地はありそうです。
パフォーマンスを向上できそうな点としては、以下が考えられます。
- あふれた
exponent を棄却するのではなく Matt 式のように % でループさせる
- 非正規化数をうまく扱えるように頑張る
オレオレ手法
さて、ここで自分で実装するならどうするかを検討してみましょう。
まず、低精度なやつならいくらでも作れるので、高精度な (つまり、範囲内のすべての表現可能な数が正しい確率で出現する) 手法を考えるものとします。
そのうえで、できる限り速いと嬉しいですね。
浮動小数点演算は何が起こるか分からないので、ビットパターンを直接構築する手法で考えてみましょう。
その前に - 確率のおさらい
一般に、指数部は幾何分布乱数で、仮数部は一様分布乱数で埋めればよいです。
ただ、一部例外があるので、おさらいしておきましょう。
(仮数部が 0) のとき
Downey の補正の項で前述したように、一般に
(仮数部が 0) の場合の確率は通常 (仮数部が非 0 の場合) の
になります。
なお、「通常」と書いたように例外もあって、指数部が 0 (非正規化数) の場合は
(-0 を含める場合) または
(-0 を除外する場合) 、指数部が 1 (非正規化数のひとつ上) の場合は
となります。
-0 の取り扱い
そのまま実装すると「0」の確率が 2 倍になってしまうので、 -0 を棄却 (再抽選) するか、 +0 ・ -0 の確率をそれぞれ 1/2 にするか、を行う必要があります。
今回は後者の実装を採用します。
開閉区間について
閉区間の場合、その端点 (min か max そのもの) に当たる確率は、端点の仮数部が 0 でない場合通常の
になります。
なぜかというと、無限精度の乱数を浮動小数点数に丸めることを考えたとき、端点が占める面積はそれ以外の場所の半分だけになるからです。

この図は、仮数部が 2 bit の浮動小数点数で
の範囲の乱数を生成した場合の範囲の図です。
上段が実数
(無限精度) 、下段が浮動小数点数 FP を表します。
このとき、下段の 1.5 と 3 は 1/2 だけが範囲内になっているのがおわかりいただけますでしょうか。
なお、仮数部が 0 の場合は 1/2 になるとは限らず、もうちょっとややこしくなります。上図を参考に考えてみてください。
また、開区間の場合は、当然端点に当たる確率は
になります。
ところで、開閉区間の実装において、「閉区間
だけ実装すれば
で開区間
を表せるから、実装を使いまわせるのでは?」という考えが浮かぶかもしれません。
は
より前 (負方向) に隣接する浮動小数点数 (つまり Math.BitDecrement(x)) 、
は
の後 (正方向) に隣接する浮動小数点数 (つまり Math.BitIncrement(x)) を表します。
しかし、これは 誤り です。
理由を説明しましょう。

上図は正しい
です。
端点の隣 (1.75 と 2.5) の領域は 100% 塗られています。

一方、上図は誤った実装
です。
端点の隣 (1.75 と 2.5) の領域が 50% だけ塗られている、つまり半分の確率でしか出現しなくなっています。
対処としては、もちろん専用の実装をするのが最善ではありますが、次善の策として
で生成してから
のどちらかに等しければ再生成、という安直な処理もあります。
場合分け
直接コードを示す前に、どうやって設計するか考えてみましょう。
まずは場合分けです。
- 符号部が同じ場合
- 指数部が同じ場合
- 指数部が 1 異なる場合
min が非正規化数の場合
min が正規化数の場合
min が非正規化数の場合
- それ以外 (指数部が 2 以上異なり、
min が正規化数の場合)
- 符号部が異なる場合
- 指数部が同じ場合
min と max が非正規化数の場合
- それ以外 (
min と max が正規化数の場合)
- それ以外 (指数部が異なる場合)
符号部が同じ - 指数部が同じ
この場合は、仮数部を一様分布整数乱数を使って minMantissa + nextULong(maxMantissa - minMantissa) で埋めればよいです。
前述したように端点の確率は 1/2 にすべきなので、これが minMantissa に等しいときは 50% の確率で maxMantissa に振り替える処理が必要です。
符号部が同じ - それ以外
こっちを先に説明したほうが分かりやすいと思うので先に書きます。
仮数部は 52 bit 一様乱数で埋めます。
次に、指数部は幾何分布乱数をもとに決定します。
Downey の補正のため、仮数部が 0 の場合は 50% の確率で指数部を +1 します。
溢れた指数部は exponent % (maxExponent - minExponent + 1) でループさせます。理由は Matt 式で述べているのと同じです。
最後に、生成した乱数が min か max に等しい場合、 50% の確率で再抽選を行います。
この時点で得られるのは
の乱数ですので、
の範囲からはみ出す場合があります。
なので範囲チェックをして OK ならそれを返し、 NG なら再生成します。
符号部が同じ - min が非正規化数の場合
上記の処理とほぼ同じですが、溢れた指数部を Math.Max(exponent, 0) でクランプする点が異なります。
この理由も Matt 式で述べたとおりですね。
符号部が同じ - 指数部が 1 異なる - min が正規化数の場合
なんで「指数部が 1 異なる場合」を特別扱いする必要があるのかというと、そのままだと
みたいな入力を与えた場合に採択率が
になってしまい、処理に異常に時間がかかるからです。
説明を簡単にするため、各場所に名前を付けましょう:
- α: 指数部が小さいほうで、仮数部が 0
- β: 指数部が小さいほうで、仮数部が非 0
- γ: 指数部が大きいほうで、仮数部が 0
- δ: 指数部が大きいほうで、仮数部が非 0

この場合、各エリアの確率の倍率 (上図で β が出る確率を 1 としたときの倍率) は以下のようになります。
| 場所 |
α |
β |
γ |
δ |
| 始点 |
1/2 |
1/2 |
✖ |
✖ |
| 中間 |
✖ |
1 |
3/2 |
2 |
| 終点 |
✖ |
✖ |
1/2 |
1 |
基本的には、一様分布整数乱数を使って仮数部を生成します。
r = minMantissa + nextULong((1ul << 52) - minMantissa + ((maxMantissa + 1) * 2)) みたいな感じですね。
それで、 r が 1ul << 52 より小さければ minExponent 側で、以上なら maxExponent 側にするみたいな感じです。
あとは上記の確率に則って再抽選を行えばよいです。
符号部が同じ - 指数部が 1 異なる - min が非正規化数の場合
概ね min が正規化数の場合と同様ですが、確率の倍率が異なります。

| 場所 |
α |
β |
γ |
δ |
| 始点 |
1/2 |
1/2 |
✖ |
✖ |
| 中間 |
✖ |
1 |
1 |
1 |
| 終点 |
✖ |
✖ |
1/2 |
1/2 |
こっちは簡単ですね。始点か終点なら 1/2 の確率で再抽選すればよいです。
符号部が異なる - 指数部が同じ - min と max が非正規化数
この場合も、「指数部が 1 異なる場合」と同様に特別扱いする必要があります。
例えば、
を与えた場合を考えてみましょう。仮数部をランダムに発生させる手法だと採択率が
になり、非常に時間がかかってしまいます。
この場合の手法としても「指数部が 1 異なる場合」と概ね同様です。
r = nextULong(minMantissa + MaxMantissa + 2) で一様分布整数乱数を得て、 minMantissa 以下ならそれを、より大きければ r - minMantissa - 1 を仮数部として構成し、あとはそれぞれマイナス・プラスの符号をつければよいです。
なお、 r が 0 になった時か、 minMantissa または maxMantissa に等しくなった時は 1/2 の確率で再抽選しましょう。
符号部が異なる - 指数部が同じ - min と max が正規化数
指数部が同じで正規化数な場合は、符号部が + になる確率と - になる確率はほぼ均等だと思われるので、 1 bit の乱数を使ってランダムに決めます。
仮数部も 52 bit 乱数でランダムに決めます。
指数部は例によって幾何分布乱数で決めます。 Math.Max でクランプするのを忘れずに。
あとは前述の表を見ながら再抽選するわけですね。
過程を省くとこんな感じになります。
if (x == min) {
} else if (x == max) {
if (mantissa == 0 && exponent >= 2) {
} else {
}
} else if (mantissa == 0 && exponent >= 2) {
}
あとは範囲チェックして範囲内なら採択、範囲外なら再生成します。
符号部が異なる - 指数部が異なる
指数部が異なる場合、符号部が + になる確率と - になる確率は大幅に偏ります。そのため、単純にランダムに決定すると棄却率が 1/2 に近づいてしまいます。
そのため、以下のようなアルゴリズムである程度偏りを再現します。
int exponentDiff = Math.Min(Math.Abs((int)(minExponent >> 52) - (int)(maxExponent >> 52)), 63);
if (Rng.NextULong((1ul << exponentDiff) + 1) == 0)
{
sign = minExponent == Math.Min(minExponent, maxExponent) ? (1ul << 63) : 0ul;
exponentShift = exponentDiff;
}
else
{
sign = minExponent == Math.Max(minExponent, maxExponent) ? (1ul << 63) : 0ul;
exponentShift = 0;
}
exponentShift は、この後に続く指数部の処理で最初から指数部を exponentShift だけ減らしておくための変数です。
結果を
している感じです。
後の処理は指数部が同じ場合と同じです。
なんで「ある程度」偏らせる(=完全に同じ確率に偏らせるわけではない)ので良いのかというと、棄却採択法だからです。
本来の確率分布より多少大きくなっても結局棄却されるので問題ないのです。
コード
以上をコードに落とし込むとこんな感じになります。
ulong minBits = BitConverter.DoubleToUInt64Bits(min);
ulong maxBits = BitConverter.DoubleToUInt64Bits(max);
ulong minSign = minBits & (1ul << 63);
ulong maxSign = maxBits & (1ul << 63);
ulong minExponent = minBits & (0x7fful << 52);
ulong maxExponent = maxBits & (0x7fful << 52);
ulong minMantissa = minBits & ((1ul << 52) - 1);
ulong maxMantissa = maxBits & ((1ul << 52) - 1);
ulong r = 0;
int entropy = 0;
if (minSign == maxSign)
{
if (minExponent == maxExponent)
{
ulong mantissa = minMantissa + Rng.NextULong(maxMantissa - minMantissa);
if (mantissa == minMantissa)
{
if ((Rng.Next() & 1) != 0)
{
mantissa = maxMantissa;
}
}
return BitConverter.UInt64BitsToDouble(minSign | minExponent | mantissa);
}
else if (minExponent + (1ul << 52) == maxExponent)
{
if (minExponent == 0)
{
while (true)
{
ulong mantissa;
ulong exponent;
ulong mantissaRange = ((1ul << 52) - minMantissa) + ((maxMantissa + 1));
ulong rr = minMantissa + Rng.NextULong(mantissaRange);
if (rr < (1ul << 52))
{
mantissa = rr;
exponent = minExponent;
}
else
{
mantissa = rr - (1ul << 52);
exponent = maxExponent;
}
ulong x = minSign | exponent | mantissa;
if (x == minBits || x == maxBits)
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
continue;
}
}
return BitConverter.UInt64BitsToDouble(x);
}
}
else
{
while (true)
{
ulong mantissa;
ulong exponent;
ulong mantissaRange = ((1ul << 52) - minMantissa) + ((maxMantissa + 1) << 1);
ulong rr = minMantissa + Rng.NextULong(mantissaRange);
if (rr < (1ul << 52))
{
mantissa = rr;
exponent = minExponent;
if (mantissa == minMantissa)
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
continue;
}
}
}
else
{
mantissa = (rr - (1ul << 52)) >> 1;
exponent = maxExponent;
if (mantissa == 0)
{
if (mantissa == maxMantissa)
{
if (entropy < 2)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 3) != 0;
r >>= 2;
entropy -= 2;
if (flag)
{
continue;
}
}
else
{
if (entropy < 2)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 3) == 0;
r >>= 2;
entropy -= 2;
if (flag)
{
continue;
}
}
}
else if (mantissa == maxMantissa)
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
continue;
}
}
}
ulong x = minSign | exponent | mantissa;
return BitConverter.UInt64BitsToDouble(x);
}
}
}
else if (minExponent == 0)
{
while (true)
{
ulong mantissa;
{
if (entropy < 52)
{
r = Rng.Next();
entropy = 64;
}
mantissa = r & ((1ul << 52) - 1);
r >>= 52;
entropy -= 52;
}
int exponentShift = 0;
do
{
if (entropy <= 0)
{
r = Rng.Next();
entropy = 64;
}
int ctz = Math.Min(BitOperations.TrailingZeroCount(r), entropy);
r >>= ctz + 1;
entropy -= ctz + 1;
exponentShift += ctz;
} while (entropy == -1);
if (mantissa == 0)
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
exponentShift--;
if (exponentShift < 0)
{
continue;
}
}
}
ulong exponent = (ulong)Math.Max(((long)maxExponent >> 52) - exponentShift, 0) << 52;
ulong x = minSign | exponent | mantissa;
if (x == minBits || x == maxBits)
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
continue;
}
}
double result = BitConverter.UInt64BitsToDouble(x);
if (min <= result && result <= max)
{
return result;
}
}
}
else
{
while (true)
{
ulong mantissa;
{
if (entropy < 52)
{
r = Rng.Next();
entropy = 64;
}
mantissa = r & ((1ul << 52) - 1);
r >>= 52;
entropy -= 52;
}
int exponentShift = 0;
do
{
if (entropy <= 0)
{
r = Rng.Next();
entropy = 64;
}
int ctz = Math.Min(BitOperations.TrailingZeroCount(r), entropy);
r >>= ctz + 1;
entropy -= ctz + 1;
exponentShift += ctz;
} while (entropy == -1);
if (mantissa == 0)
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
exponentShift--;
if (exponentShift < 0)
{
continue;
}
}
}
ulong exponent;
{
ulong exponentRange = ((maxExponent - minExponent) >> 52) + 1;
ulong exponentSub = (ulong)exponentShift < exponentRange ? (ulong)exponentShift : ((ulong)exponentShift % exponentRange);
exponent = maxExponent - (exponentSub << 52);
}
ulong x = minSign | exponent | mantissa;
if (x == minBits || x == maxBits)
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
continue;
}
}
double result = BitConverter.UInt64BitsToDouble(x);
if (min <= result && result <= max)
{
return result;
}
}
}
}
else
{
if (minExponent == maxExponent)
{
if (minExponent == 0)
{
while (true)
{
ulong rr = Rng.NextULong(minMantissa + maxMantissa + 2);
if (rr <= minMantissa)
{
if ((rr == 0 || rr == minMantissa) && (Rng.Next() & 1) != 0)
{
continue;
}
return BitConverter.UInt64BitsToDouble(minSign | rr);
}
else
{
rr -= minMantissa + 1;
if ((rr == 0 || rr == maxMantissa) && (Rng.Next() & 1) != 0)
{
continue;
}
return BitConverter.UInt64BitsToDouble(rr);
}
}
}
else
{
while (true)
{
ulong sign;
{
if (entropy <= 0)
{
r = Rng.Next();
entropy = 64;
}
sign = r << 63;
r >>= 1;
entropy--;
}
ulong mantissa;
{
if (entropy < 52)
{
r = Rng.Next();
entropy = 64;
}
mantissa = r & ((1ul << 52) - 1);
r >>= 52;
entropy -= 52;
}
int exponentShift = 0;
do
{
if (entropy <= 0)
{
r = Rng.Next();
entropy = 64;
}
int clz = Math.Min(BitOperations.TrailingZeroCount(r), entropy);
r >>= clz + 1;
entropy -= clz + 1;
exponentShift += clz;
} while (entropy == -1);
ulong exponent = (ulong)Math.Max(((long)maxExponent >> 52) - exponentShift, 0) << 52;
ulong x = sign | exponent | mantissa;
if (x == minBits)
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
continue;
}
}
else if (x == maxBits)
{
if (mantissa == 0 && exponent >= 2)
{
if (entropy < 2)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 3) != 0;
r >>= 2;
entropy -= 2;
if (flag)
{
continue;
}
}
else
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
continue;
}
}
}
else if (mantissa == 0)
{
if (exponent >= 2)
{
if (entropy < 2)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 3) == 0;
r >>= 2;
entropy -= 2;
if (flag)
{
continue;
}
}
else if (exponent == 0)
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
continue;
}
}
}
double result = BitConverter.UInt64BitsToDouble(x);
if (min <= result && result <= max)
{
return result;
}
}
}
}
else
{
while (true)
{
ulong sign;
int exponentShift;
{
ulong lesserExponent = Math.Min(minExponent, maxExponent);
ulong greaterExponent = Math.Max(minExponent, maxExponent);
int exponentDiff = Math.Min((int)((greaterExponent - lesserExponent) >> 52), 63);
if (Rng.NextULong((1ul << exponentDiff) + 1) == 0)
{
sign = lesserExponent == minExponent ? (1ul << 63) : 0ul;
exponentShift = exponentDiff;
}
else
{
sign = greaterExponent == minExponent ? (1ul << 63) : 0ul;
exponentShift = 0;
}
}
ulong mantissa;
{
if (entropy < 52)
{
r = Rng.Next();
entropy = 64;
}
mantissa = r & ((1ul << 52) - 1);
r >>= 52;
entropy -= 52;
}
do
{
if (entropy <= 0)
{
r = Rng.Next();
entropy = 64;
}
int ctz = Math.Min(BitOperations.TrailingZeroCount(r), entropy);
r >>= ctz + 1;
entropy -= ctz + 1;
exponentShift += ctz;
} while (entropy == -1);
ulong exponent;
{
ulong greaterExponent = Math.Max(minExponent, maxExponent);
exponent = (ulong)Math.Max(((long)greaterExponent >> 52) - exponentShift, 0) << 52;
}
ulong x = sign | exponent | mantissa;
if (x == minBits)
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
continue;
}
}
else if (x == maxBits)
{
if (mantissa == 0 && exponent >= 2)
{
if (entropy < 2)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 3) != 0;
r >>= 2;
entropy -= 2;
if (flag)
{
continue;
}
}
else
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
continue;
}
}
}
else if (mantissa == 0)
{
if (exponent >= 2)
{
if (entropy < 2)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 3) == 0;
r >>= 2;
entropy -= 2;
if (flag)
{
continue;
}
}
else if (exponent == 0)
{
if (entropy < 1)
{
r = Rng.Next();
entropy = 64;
}
bool flag = (r & 1) != 0;
r >>= 1;
entropy--;
if (flag)
{
continue;
}
}
}
double result = BitConverter.UInt64BitsToDouble(x);
if (min <= result && result <= max)
{
return result;
}
}
}
}
大長編です。ただ、場合分けが結構多いので実行時に実際に通るパスはそれほど長くはないです。
上に述べたように、この手法は閉区間
になります。
開区間 (例えば
) にしたい場合は、一番安直な方法としてはこの手法で生成した後 max と同じなら再抽選するコードを入れることです。
本当にパフォーマンスを追求するなら、きちんとコードを全部修正する必要がありますが、まぁ面倒くさいですね……
望ましくない結果を返さない
ビットパターンから構築しているので、オーバーフロー起因の ∞ や NaN を返すことはありません。
パフォーマンス
| Method |
min |
max |
Mean |
Error |
StdDev |
Median |
Code Size |
| Normal |
-3.141592653589793 |
3.141592653589793 |
1.918 ns |
0.0169 ns |
0.0158 ns |
1.920 ns |
155 B |
| MyMethod |
-3.141592653589793 |
3.141592653589793 |
7.077 ns |
0.1655 ns |
0.1840 ns |
7.086 ns |
3,144 B |
| Normal |
0 |
3.141592653589793 |
1.743 ns |
0.0217 ns |
0.0203 ns |
1.740 ns |
155 B |
| MyMethod |
0 |
3.141592653589793 |
6.461 ns |
0.1199 ns |
0.1001 ns |
6.428 ns |
3,137 B |
| Normal |
-0 |
4503599627370497 |
1.935 ns |
0.0624 ns |
0.0553 ns |
1.898 ns |
155 B |
| MyMethod |
-0 |
4503599627370497 |
38.659 ns |
0.7987 ns |
1.2898 ns |
38.805 ns |
3,453 B |
| Normal |
1 |
3.141592653589793 |
1.902 ns |
0.0107 ns |
0.0095 ns |
1.903 ns |
155 B |
| MyMethod |
1 |
3.141592653589793 |
7.273 ns |
0.0195 ns |
0.0183 ns |
7.275 ns |
3,340 B |
| Normal |
2.718281828459045 |
3.141592653589793 |
1.925 ns |
0.0209 ns |
0.0196 ns |
1.928 ns |
155 B |
| MyMethod |
2.718281828459045 |
3.141592653589793 |
2.318 ns |
0.0295 ns |
0.0261 ns |
2.310 ns |
3,328 B |
最速の場合 (同符号・同指数の場合) は「普通のやつ」と遜色ないレベルです。
しかし、最悪の場合 (異符号・仮数部 1 の場合) は 17 倍程度遅くなってしまっています。
改善の余地
このアルゴリズムではケアできていませんが、「指数部の差が 2 以上あって、仮数部が 1」みたいなパターンのとき、指数部が maxExponent になる確率が
で仮数部が maxMantissa を超えない確率が
とかなので、再抽選率が約
ぐらいになって時間がかかってしまいます。
うまいことこういうパターンを処理できればより高速になりそうですね。棄却採択法のミソはどれだけ元の分布に近くて簡単な関数を作れるか、です。
まとめ
パフォーマンスを全部まとめた表を以下に示します。
| Method |
min |
max |
Mean |
Error |
StdDev |
Median |
Code Size |
| Normal |
-3.141592653589793 |
3.141592653589793 |
1.918 ns |
0.0169 ns |
0.0158 ns |
1.920 ns |
155 B |
| Lerp |
-3.141592653589793 |
3.141592653589793 |
1.710 ns |
0.0192 ns |
0.0179 ns |
1.705 ns |
163 B |
| NormalFma |
-3.141592653589793 |
3.141592653589793 |
1.943 ns |
0.0263 ns |
0.0246 ns |
1.936 ns |
152 B |
| LerpFma1 |
-3.141592653589793 |
3.141592653589793 |
1.732 ns |
0.0227 ns |
0.0212 ns |
1.736 ns |
168 B |
| LerpFma2 |
-3.141592653589793 |
3.141592653589793 |
1.944 ns |
0.0211 ns |
0.0197 ns |
1.943 ns |
161 B |
| HalfLerp |
-3.141592653589793 |
3.141592653589793 |
1.927 ns |
0.0206 ns |
0.0193 ns |
1.923 ns |
171 B |
| Matt |
-3.141592653589793 |
3.141592653589793 |
12.517 ns |
0.2780 ns |
0.4644 ns |
12.495 ns |
882 B |
| GammaCO |
-3.141592653589793 |
3.141592653589793 |
9.146 ns |
0.0238 ns |
0.0211 ns |
9.151 ns |
1,127 B |
| Cauldron |
-3.141592653589793 |
3.141592653589793 |
7.011 ns |
0.0212 ns |
0.0188 ns |
7.016 ns |
855 B |
| MyMethod |
-3.141592653589793 |
3.141592653589793 |
7.077 ns |
0.1655 ns |
0.1840 ns |
7.086 ns |
3,144 B |
| Normal |
0 |
3.141592653589793 |
1.743 ns |
0.0217 ns |
0.0203 ns |
1.740 ns |
155 B |
| Lerp |
0 |
3.141592653589793 |
1.930 ns |
0.0216 ns |
0.0192 ns |
1.927 ns |
163 B |
| NormalFma |
0 |
3.141592653589793 |
1.727 ns |
0.0271 ns |
0.0254 ns |
1.725 ns |
152 B |
| LerpFma1 |
0 |
3.141592653589793 |
1.926 ns |
0.0219 ns |
0.0194 ns |
1.927 ns |
168 B |
| LerpFma2 |
0 |
3.141592653589793 |
1.766 ns |
0.0613 ns |
0.0656 ns |
1.758 ns |
161 B |
| HalfLerp |
0 |
3.141592653589793 |
1.729 ns |
0.0258 ns |
0.0215 ns |
1.725 ns |
171 B |
| Matt |
0 |
3.141592653589793 |
6.399 ns |
0.0416 ns |
0.0389 ns |
6.377 ns |
861 B |
| GammaCO |
0 |
3.141592653589793 |
9.272 ns |
0.1411 ns |
0.1319 ns |
9.243 ns |
1,127 B |
| Cauldron |
0 |
3.141592653589793 |
6.831 ns |
0.1054 ns |
0.0935 ns |
6.793 ns |
839 B |
| MyMethod |
0 |
3.141592653589793 |
6.461 ns |
0.1199 ns |
0.1001 ns |
6.428 ns |
3,137 B |
| Normal |
-0 |
4503599627370497 |
1.935 ns |
0.0624 ns |
0.0553 ns |
1.898 ns |
155 B |
| Lerp |
-0 |
4503599627370497 |
1.756 ns |
0.0361 ns |
0.0337 ns |
1.748 ns |
163 B |
| NormalFma |
-0 |
4503599627370497 |
1.918 ns |
0.0078 ns |
0.0073 ns |
1.918 ns |
152 B |
| LerpFma1 |
-0 |
4503599627370497 |
1.736 ns |
0.0239 ns |
0.0224 ns |
1.741 ns |
168 B |
| LerpFma2 |
-0 |
4503599627370497 |
1.937 ns |
0.0112 ns |
0.0094 ns |
1.933 ns |
161 B |
| HalfLerp |
-0 |
4503599627370497 |
2.004 ns |
0.0669 ns |
0.0771 ns |
1.972 ns |
171 B |
| Matt |
-0 |
4503599627370497 |
18.473 ns |
0.1117 ns |
0.0990 ns |
18.482 ns |
786 B |
| GammaCO |
-0 |
4503599627370497 |
9.091 ns |
0.2059 ns |
0.2022 ns |
8.980 ns |
1,127 B |
| Cauldron |
-0 |
4503599627370497 |
15.236 ns |
0.1784 ns |
0.1669 ns |
15.225 ns |
839 B |
| MyMethod |
-0 |
4503599627370497 |
38.659 ns |
0.7987 ns |
1.2898 ns |
38.805 ns |
3,453 B |
| Normal |
1 |
3.141592653589793 |
1.902 ns |
0.0107 ns |
0.0095 ns |
1.903 ns |
155 B |
| Lerp |
1 |
3.141592653589793 |
1.757 ns |
0.0459 ns |
0.0430 ns |
1.746 ns |
163 B |
| NormalFma |
1 |
3.141592653589793 |
1.928 ns |
0.0138 ns |
0.0116 ns |
1.926 ns |
152 B |
| LerpFma1 |
1 |
3.141592653589793 |
1.697 ns |
0.0076 ns |
0.0071 ns |
1.698 ns |
168 B |
| LerpFma2 |
1 |
3.141592653589793 |
1.934 ns |
0.0195 ns |
0.0182 ns |
1.932 ns |
161 B |
| HalfLerp |
1 |
3.141592653589793 |
1.906 ns |
0.0112 ns |
0.0100 ns |
1.907 ns |
171 B |
| Matt |
1 |
3.141592653589793 |
9.061 ns |
0.0412 ns |
0.0386 ns |
9.061 ns |
786 B |
| GammaCO |
1 |
3.141592653589793 |
9.119 ns |
0.0355 ns |
0.0332 ns |
9.125 ns |
1,127 B |
| Cauldron |
1 |
3.141592653589793 |
24.717 ns |
0.0667 ns |
0.0624 ns |
24.742 ns |
1,036 B |
| MyMethod |
1 |
3.141592653589793 |
7.273 ns |
0.0195 ns |
0.0183 ns |
7.275 ns |
3,340 B |
| Normal |
2.718281828459045 |
3.141592653589793 |
1.925 ns |
0.0209 ns |
0.0196 ns |
1.928 ns |
155 B |
| Lerp |
2.718281828459045 |
3.141592653589793 |
1.716 ns |
0.0143 ns |
0.0127 ns |
1.717 ns |
163 B |
| NormalFma |
2.718281828459045 |
3.141592653589793 |
1.916 ns |
0.0059 ns |
0.0049 ns |
1.916 ns |
152 B |
| LerpFma1 |
2.718281828459045 |
3.141592653589793 |
1.710 ns |
0.0086 ns |
0.0076 ns |
1.711 ns |
168 B |
| LerpFma2 |
2.718281828459045 |
3.141592653589793 |
1.933 ns |
0.0103 ns |
0.0096 ns |
1.929 ns |
161 B |
| HalfLerp |
2.718281828459045 |
3.141592653589793 |
1.923 ns |
0.0113 ns |
0.0100 ns |
1.921 ns |
171 B |
| Matt |
2.718281828459045 |
3.141592653589793 |
50.068 ns |
0.1239 ns |
0.1159 ns |
50.067 ns |
770 B |
| GammaCO |
2.718281828459045 |
3.141592653589793 |
9.125 ns |
0.0359 ns |
0.0319 ns |
9.124 ns |
1,127 B |
| Cauldron |
2.718281828459045 |
3.141592653589793 |
2.298 ns |
0.0111 ns |
0.0093 ns |
2.293 ns |
1,007 B |
| MyMethod |
2.718281828459045 |
3.141592653589793 |
2.318 ns |
0.0295 ns |
0.0261 ns |
2.310 ns |
3,328 B |
グラフにするとこんな感じです。

Normal とか Lerp 系列はほとんど差がない一方で、正確な計算をしようとすると結構時間がかかることが分かります。
また、正確な計算をしようとすると GammaCO 以外は引数によってかなり速度にばらつきがあることが分かります。
例によって私見を交えて 🌟 にまとめるとこんな感じです。
| 手法 |
開区間 |
オーバーフローしないか |
表現可能精度 |
パフォーマンス |
| 普通のやつ |
✖️ |
✖️ |
🌟 |
🌟🌟🌟🌟🌟 |
| Lerp 式 |
✖️ |
✅ |
🌟 |
🌟🌟🌟🌟🌟 |
| FMA Lerp (1) |
✖️ |
✖️ |
🌟 |
🌟🌟🌟🌟🌟 |
| FMA Lerp (2) |
✖️ |
✅ |
🌟 |
🌟🌟🌟🌟🌟 |
| FMA Lerp (3) |
✖️ |
✅ |
🌟 |
🌟🌟🌟🌟🌟 |
| Half Lerp |
✖️ |
✅ |
🌟 |
🌟🌟🌟🌟🌟 |
| Matt 式 |
✖️ |
✅ |
🌟🌟🌟🌟 |
🌟 |
| GammaCO |
✅ |
✅ |
🌟🌟🌟 |
🌟🌟 |
| Cauldron |
✖️ |
✅ |
🌟🌟🌟🌟 |
🌟🌟🌟 |
| オレオレ手法 |
✖️ |
✅ |
🌟🌟🌟🌟🌟 |
🌟🌟 |
パフォーマンス第一の場合は Lerp 式 にしておくのがよさそうです。ただ、そのままだと意図せず開区間にならない問題があるので、範囲をはみ出したら再抽選する処理も入れておくとよいと思います。
精度がほしい場合はオレオレ手法も検討してみてください。
余談
擬似乱数生成器の選定について
本稿では擬似乱数生成器部分は理想的な実装であるものとして詳しくは触れませんでしたが、ここで挙げた「正しい」手法を実行するためには、かなりの「均等分布次元」が必要になります。
「均等分布次元」というのは、ここでは任意のビット列が出力される保証のあるビット長を表します。
例えば、メルセンヌツイスタ (32 bit 版) は 623 次元均等分布であることが有名ですが、これは 32 bit ワード単位での話なので、ビット長的には 623 x 32 = 19936 bit までなら任意のビット列を生成できます。
(おそらく、 bit 単位なら 19937 bit まで行けるとは思うのですが、自信がないです)
逆に言えば、均等分布次元を超えたビット列を出力した場合、絶対に出現しえないビット列が存在することになります。
実例を挙げると、線形合同法 (64 bit) は 64 bit ワード単位で 1 次元に均等分布するので、任意の 64 bit ワード、例えば 0ul は出力できますが、それ以上の長さを持つビット列は出現しない可能性があります。例えば、 [0ul, 0ul] (128 bit) は理論上絶対に出現しないと断言できます。
の範囲でさえ、最小値が
である、つまり 0.000~(1069個の0)~001 を生成できなければいけない以上、 1074 bit もの均等分布次元が要求されます。
そうなると xoshiro256++ (192 bit 均等分布) や xorshift1024* (1024 bit) などでは対応できず、メルセンヌツイスタ (mt19937_64) などの巨大な擬似乱数生成器を利用するか、事実上均等分布次元が ∞ である CSPRNG (暗号論的擬似乱数生成器) を使う必要が出てくるでしょう。
しかし、その場合当然生成自体が遅くなるという問題が出てきます。難しいですね。
逆に考えて、例えば xoshiro256++ を使うなら 192 bit ぶんの均等分布しか得られない前提に立って出力関数を設計する (つまり、
までしか出ない前提で高速化パスを作る。非正規化数にならない前提で組むなど)、というのもあります。 Abseil の実装はこのあたり考えられていそうですね。
要はバランス、です。
並列化の可能性
float 型のある範囲のそれぞれ独立した一様乱数が 2 つほしいシチュエーションが時々あります。
具体的には、単位円内・単位球面上に一様分布する座標の取得だったり、 ボックス=ミュラー法 による正規分布乱数への変換などがあります。
並列化と言えば SIMD ですが、ややこしいのでいったん置いておいて、もっと簡単な手法を考えます。
例えば、
の乱数が 2 個ほしいとき、
を掛ける手法なら 1 つあたり 24 bit の乱数があればよいのですから、 Next() が一度に 64 bit の乱数を生成できる以上、一度に 2 個分作れるわけですね。
ulong r = Rng.Next();
float f1 = (r & ((1ul << 24) - 1)) * (1f / (1 << 24));
float f2 = (r >> 40) * (1f / (1 << 24));
return (f1, f2);
次に、さっき置いておいた SIMD です。
C# なら System.Numerics.Vector256 などからアクセスできますね。
var vec = Vector256.Create(Rng.Next(), Rng.Next(), Rng.Next(), Rng.Next());
var zeroOne = Vector256.ConvertToDouble(vec >> 11) * (1.0 / (1ul << 53));
var result = Vector256.Create(min) + (max - min) * zeroOne;
ただ問題があるとすれば、高精度な手法は必然的に「再抽選が必要になる可能性」をはらんでおり、そうなると並列化が至難というところです。
なので、 Abseil の手法のように乱数固定消費で現実的な精度を出せる手法を使うことになるでしょう。
再現性について
シミュレーションやゲームにおいて、リプレイのために再現性が必要になる場合があります。
整数乱数の場合は完全に再現可能と言えますが、浮動小数点数乱数の場合はどうでしょうか?
IEEE 754 に準拠している場合 (現代における大半のプログラミング言語は該当します) 、以下の演算はどの環境でもビット単位で同じ結果を返す保証があります:
- 加減乗除 (
+, -, *, /)
- 剰余 (
Math.IEEERemainder) ※ % とは異なるので注意
- 平方根 (
Math.Sqrt)
- FMA (
Math.FusedMultiplyAdd)
- 整数との相互変換
CopySign ・単項マイナス演算子・絶対値など、数値演算を含まない関数
- 比較 (
==, < など)
さて、逆に言えばこれ以外の演算 (特に Math.Sin や Math.Log といった数学関数) を使用した場合は完全に同じ値が返る保証はないということになります。
なぜかというと、無限に正確に計算しようとすると計算コストが馬鹿にならないためです。多少の誤差と引き換えに高速に計算できるようにしてあります。
(大抵の場合、誤差は 1 ulp 以下です。したがって、仮数部の最下位ビットに ±1 を足した程度のわずかな誤差ではあります。)
C# の場合、例えば Math.Sin にはこのように書いてあります:
This method calls into the underlying C runtime, and the exact result or valid input range may differ between different operating systems or architectures.
(訳) このメソッドは基盤となる C ランタイムを呼び出すため、正確な結果や有効な入力範囲はオペレーティング システムやアーキテクチャによって異なる場合があります。
で、その C ランタイムのほうはどうなのかというと、 こちら に記述があります:
In most cases, the result produced is within +/-1 ULP (unit of least precision) of the correctly rounded result, though there may be cases where there's greater inaccuracy.
(訳) ほとんどの場合、生成される結果は、正しく丸められた結果の +/-1 ULP (最小精度単位) 以内ですが、不正確さが大きくなる場合もあります。
話を乱数に戻しましょう。
ビットパターンを利用して (整数演算のみで) 組み立てた乱数については、再現性が担保されています。
ただ、例えばこれを 指数分布 に加工するために -Math.Log(r) などとしてしまった場合、必ずしも同じ値が得られるとは限りません。
Java には異なる環境でも同じ値を返す保証がある StrictMath がありますが、 C# には今のところありません。なので、もし必要なら数学ライブラリを自力で実装する必要があります。険しい!
これは個人の考えにはなりますが、楽観的過ぎても悲観的過ぎても難しいので、「要はバランス」ということにしておくとよいかと思います。
例えば、乱数のシード値だけではなくて定期的に実際の値を送ったり保存したりして同期させる、など。
余談ですが、 Unity の BurstCompile 属性では、数学関数の精度を指定できます。
ここで低精度 Low を指定すると、なんと誤差が最大 350.0 ulp (!) になります。
またまた余談なのですが、 C# の仕様 に恐ろしげな文章を見つけました:
浮動小数点演算は、演算の結果の型よりも高い精度で実行される場合があります。
一部のハードウェア アーキテクチャでは、double 型よりも範囲と精度が広い「extended」または「long double」浮動小数点型をサポートしており、すべての浮動小数点演算をこのより高い精度の型を使用して暗黙的に実行します。
この文章が正しいとすると、 x87 FPUの呪い のような事象が発生しうる (異なる環境で浮動小数点数演算の再現性が担保されない) ということになります。
幸いにして x64 だと発生しないっぽい?ので、現実的な範囲で問題になることは少ない……かも……
ちなみに、 .NET Core 2.0 以降なら常に RyuJIT が使われて SSE(2) 命令を使うので呪われないらしい 、です。
Unity? IL2CPP? Burst? なんもわかりません。これだけで記事ができそう。
テスト - 精度の必要性を検証する
さて、ここまで高精度な一様分布浮動小数点数乱数の実装について説明してきましたが、この精度は本当に必要なのでしょうか?
別に「普通のやつ」でいいじゃん速いし、と言われれば、実例がないと言い返すのは難しそうです。
なので、実際に差が出るのかどうか、確かめてみましょう。
ここでは、円周率
の値を古典的なモンテカルロ法で求めてみることにしましょう。
手法をざっくり説明すると、
- 一辺
の長さの正方形の中にランダムに点を打つ
- それが半径
の円の中に入っていればカウントする
- 以上を繰り返すと、 (円の中の点数 / すべての点数) は
に近づく
というものです。
コードで説明するとこんな感じです。
Half l = Half.E;
long trial = 1ul << 32;
long count = 0;
for (long i = 0; i < trial; i++)
{
Half x = Rng.NextHalf(-l, l);
Half y = Rng.NextHalf(-l, l);
if ((double)x * (double)x + (double)y * (double)y <= (double)l * (double)l)
{
count++;
}
}
Console.WriteLine($"pi = {4.0 * count / trial:g17}");
まず、オレオレ手法 MyMethod では pi = 3.140543058514595 を得ました。小数点以下 2 桁まで合っています。
一方、普通のやつ Normal では pi = 3.1403892487287521 を得ました。小数点以下 2 桁まで合っています。
……全然変わりませんね……先行研究にはこれで差が出るとあったのですが……
先行研究での試行回数はたった 8000 回らしいので、乱数の上振れ下振れの可能性がまぁまぁあります。闇。
ここで示されたこととしては、精度を上げても結局そんなに変わりはないということです。かなしい。
もし差が出そうなセンシティブな実験をされている方は、ぜひオレオレ手法のほうを検討してみてください。
ただこれは私見ですが、モンテカルロ法で必要になるのは精度よりスピードなのでは?というのがあり、そうなると精度って実はいらないんじゃね?という悲しい結末を迎えそうです。はい。
おわりに
「 完璧な 一様分布の浮動小数点乱数が欲しい」というただそれだけのためにどれだけ頑張る必要があるか、伝わっていれば幸いです。
一様分布ですらこれなので、ほかの分布はもっと大変でしょうね……今は考えたくもないです。
「オレオレ手法」は使っても使わなくてもいいです。
ただ Rng.NextDouble() * (max - min) + min するときは範囲外にはみ出す可能性があることを忘れないように。それだけは心に置いておいてください。