はじめに
C++やCでは,浮動小数点数が整数に変換されるとき,基本的に小数点以下が切り捨てられます.この挙動に対する思い込みが原因で,バグに遭遇したことがあります.実際のソースコードではありませんが,それを理解しやすくするために改変したプログラムを使って説明します.
バグのあるプログラム
0からN-1までの整数乱数を生成し,その頻度をヒストグラムにしています,ここではN = 10
,繰り返し回数はM = 100000000
としています.メルセンヌ・ツイスタを使って,0から$2^{32}-1$までの乱数を発生させ,それを$2^{32}$で割ることにより,0.0以上1.0未満の浮動小数点数に変換しています.整数での除算は整数値になってしまうため,分母と分子を一旦浮動小数点数に変換してから除算を行っています.この0.0以上1.0未満の浮動小数点数にN = 10
を掛けると,0.0以上10未満の値が得られます.そして,それを整数に変換すると切り捨てが行われ,0以上9以下の値になるはずです.
#include <iostream>
#include <random>
#include <vector>
class MyRandom {
std::mt19937 mt;
public:
MyRandom() : mt(std::random_device{}()) {}
// 0.0以上1.0未満の乱数を返すことを意図している
float operator()() {
return static_cast<float>(mt()) /
(static_cast<float>(std::mt19937::max()) + 1);
}
};
int main() {
const int N = 10;
const int M = 100000000;
MyRandom rnd;
std::vector<int> histogram(N, 0);
// 0からN-1までの乱数を生成し、その値のヒストグラムを作成することを意図している
for (int i = 0; i < M; i++) {
// rnd() * Nは0.0以上N未満の乱数を返すことを意図している
histogram[static_cast<int>(rnd() * N)]++;
}
int sum = 0;
for (int i = 0; i < N; i++) {
sum += histogram[i];
std::cout << i << ": " << histogram[i] << std::endl;
}
std::cout << "sum: " << sum << std::endl;
for (int i = 0; i < M; i++) {
if (rnd() == 1) {
std::cout << "rnd() == 1" << std::endl;
}
}
}
実行結果
実行すると,次のような結果が得られます.ヒストグラムの合計が99999998となり,M = 100000000
より2少なくなっています.
0: 10000972
1: 9994335
2: 10001085
3: 9996150
4: 10002344
5: 9997849
6: 10000672
7: 9996816
8: 10006780
9: 10002995
sum: 99999998
バグの原因
これは,割り算の結果がちょうど1になることがあり、rnd() * N
が10になってしまうためです。浮動小数点演算では四捨五入による丸めが行われます.32ビット整数を32ビット浮動小数点数(float)に変換して割り算を行っていますが,これらの計算処理は切り捨てとは限らず,結果として切り上げとなることもあります.floatの仮数部は23ビットなので,32ビット整数をfloatに変換すると9ビット分足りず,表現できる値に丸められます.そのため,割り算の分母と分子のfloatの値が一致し,ちょうど1となることがあります.これにより,配列の範囲外をアクセスすることになり,Segmentation faultが発生する可能性があります.
おわりに
メルセンヌ・ツイスタを使って0からN-1の整数乱数を発生させるのであれば,mt() % N
とするだけでよく,こんなおかしなプログラムを書く必要はありません.では,なぜこのようなプログラムを書いたのかというと,それはGPU用のプログラムだったからです.一般に,GPUではfloatの演算の方が整数演算より効率が良いため,なるべく整数演算を避け,floatの演算を使うようにしていた結果,このようなプログラムを書いてしまいました。