よく知られているように、IEEE754に従う浮動小数点数はフォーマットの一部に非正規化数(denormalized number, denormal number, subnormal number)という領域があります。倍精度では、絶対値が2-1074から2-1022までの0に近い領域で、非常に小さな数が表現できるのと引き換えに、この範囲では仮数部の長さが通常の53bitより短く精度が低下します。
ところで、我々の業界では、計算の途中に非正規化数が出てくると計算速度が極端に低下(50~100倍ほど)すると言われていて、いくつかの精度保証アルゴリズムは非正規化数がなるべく出現しないように設計されています。しかし、実際にそんなに遅くなるのかどうか、実測したことは無かったので、計測してみました。
使ったのは次のようなプログラムです。加算、乗算、除算 (減算は加算と同じだろうから省略) について、引数に非正規化数が含まれている場合と含まれていない場合の計算速度の比を計測しています。10億回繰り返しています。volatileに代入したり、計算結果を表示したりして、最適化で計算本体が消されないように小細工しています。実際にやっている計算は
- 正規化数 + 非正規化数 = 正規化数
- 非正規化数 * 正規化数 = 非正規化数
- 非正規化数 / 正規化数 = 非正規化数
です。もしかしたら計算結果が正規化数か否かでも変わるかもしれませんが、そこまでは調べていません。
#include <iostream>
#include <cmath>
#include <chrono>
double vc(double x)
{
volatile double tmp = x;
return tmp;
}
int main()
{
std::chrono::system_clock::time_point ts0, ts1, ts2, ts3;
int i, j;
double a, b;
double t0, t1, r0, r1, r2;
a = vc(1);
b = vc(std::pow(2., -1021));
std::cout << "add normal\n";
ts0 = std::chrono::system_clock::now();
for (i=0; i<10000; i++) {
for (j=0; j<100000; j++) {
a += b;
}
}
ts1 = std::chrono::system_clock::now();
t0 = std::chrono::duration_cast<std::chrono::nanoseconds>(ts1-ts0).count()/1e9;
std::cout << t0 << "\n";
std::cout << a << "\r \n"; // dummy
a = vc(1);
b = vc(std::pow(2., -1023));
std::cout << "add denormal\n";
ts2 = std::chrono::system_clock::now();
for (i=0; i<10000; i++) {
for (j=0; j<100000; j++) {
a += b;
}
}
ts3 = std::chrono::system_clock::now();
t1 = std::chrono::duration_cast<std::chrono::nanoseconds>(ts3-ts2).count()/1e9;
std::cout << t1 << "\n";
r0 = t1 / t0;
std::cout << "\nadd ratio\n";
std::cout << "denormal / normal = " << r0 << "\n";
std::cout << a << "\r \n"; // dummy
a = vc(std::pow(2., -1021));
b = vc(1);
std::cout << "mul normal\n";
ts0 = std::chrono::system_clock::now();
for (i=0; i<10000; i++) {
for (j=0; j<100000; j++) {
a *= b;
}
}
ts1 = std::chrono::system_clock::now();
t0 = std::chrono::duration_cast<std::chrono::nanoseconds>(ts1-ts0).count()/1e9;
std::cout << t0 << "\n";
std::cout << a << "\r \n"; // dummy
a = vc(std::pow(2., -1023));
b = vc(1);
std::cout << "mul denormal\n";
ts2 = std::chrono::system_clock::now();
for (i=0; i<10000; i++) {
for (j=0; j<100000; j++) {
a *= b;
}
}
ts3 = std::chrono::system_clock::now();
t1 = std::chrono::duration_cast<std::chrono::nanoseconds>(ts3-ts2).count()/1e9;
std::cout << t1 << "\n";
r1 = t1 / t0;
std::cout << "\nmul ratio\n";
std::cout << "denormal / normal = " << r1 << "\n";
std::cout << a << "\r \n"; // dummy
a = vc(std::pow(2., -1021));
b = vc(1);
std::cout << "div normal\n";
ts0 = std::chrono::system_clock::now();
for (i=0; i<10000; i++) {
for (j=0; j<100000; j++) {
a /= b;
}
}
ts1 = std::chrono::system_clock::now();
t0 = std::chrono::duration_cast<std::chrono::nanoseconds>(ts1-ts0).count()/1e9;
std::cout << t0 << "\n";
std::cout << a << "\r \n"; // dummy
a = vc(std::pow(2., -1023));
b = vc(1);
std::cout << "div denormal\n";
ts2 = std::chrono::system_clock::now();
for (i=0; i<10000; i++) {
for (j=0; j<100000; j++) {
a /= b;
}
}
ts3 = std::chrono::system_clock::now();
t1 = std::chrono::duration_cast<std::chrono::nanoseconds>(ts3-ts2).count()/1e9;
std::cout << t1 << "\n";
r2 = t1 / t0;
std::cout << "\ndiv ratio\n";
std::cout << "denormal / normal = " << r2 << "\n";
std::cout << a << "\r \n"; // dummy
std::cout << "ratio (add/mul/div): " << r0 << " " << r1 << " " << r2 << "\n";
}
これをg++で-O3をつけてコンパイル、実行して、正規化数の場合の計算時間を1としたときの非正規化数の計算時間を、手元に転がっていたPCで片っ端から調べてみました。次の表にまとめます。
CPU | 加算 | 乗算 | 除算 |
Intel Core M-5Y10c (Broadwell) | 0.999338 | 42.1995 | 14.3503 |
Intel Core i5-6500T (Skylake) | 0.924034 | 33.5428 | 11.956 |
Intel Core i9-7900X (Skylake) | 0.960886 | 33.8764 | 11.9609 |
Intel Pentium CPU 4415Y (Kaby Lake) | 0.997099 | 34.4079 | 12.1162 |
Intel Core i5-8250U (Kaby Lake R) | 0.992209 | 34.5037 | 12.1195 |
11th Gen Intel Core i7-1195G7 | 0.99984 | 29.1202 | 10.577 |
Intel Celeron N4120 (Gemini Lake Refresh) | 57.1326 | 43.775 | 13.7602 |
Intel N100 (Alder Lake-N) | 95.7141 | 71.824 | 23.5843 |
AMD Ryzen 7 7700 (Zen4) | 0.981635 | 1.55346 | 1.39528 |
AMD Ryzen 7 8840U (Zen4) | 0.99094 | 1.32999 | 1.40047 |
Apple M1 | 0.964873 | 0.999254 | 1.00117 |
これを見ると、IntelのCPUは加算は遅くならないが乗除算が遅い、ATOM系は加算も遅い、AMDやApple Siliconはほとんど遅くならない、ことが読み取れます。
個人で所有しているCPUの種類には限界があってなかなか網羅的な調査は難しいですが、いろいろ検索して情報を集めてみた結果、どうやら次の表のような感じっぽいです。(o=ペナルティなし、x=ペナルティあり)
CPU | 加算 | 乗算 | 除算 |
Intel SandyBridgeより古い | x | x | x |
AMD Bulldozer | x | x | x |
Intel SandyBridge以降11世代まで | o | x | x |
Intel ATOM系 | x | x | x |
Intel KNL | o | o | x |
AMD Zen | o | o | o |
Apple Silicon | o | o | o |
SandyBridgeより古いのとかBulldozerとかKNLとか、実機で試すのは大変ですが、いつかやってみたいものです。あるいはどなたか動かしてくれないかしら。
ところで、実験を見て気になるのはN100です。加算も遅いという散々な結果ですが、N100って、Intel 12世代以降のいわゆるEコアで出来ているはず。ということは、12世代以降のCPUで運悪くEコアに当たってしまうと加算ですら非正規化数のペナルティが発生する? 自分はIntelの12世代以降は所有していないので試せないのですが、気になります。
未開封のCore Ultra 7 155UのPCがあったのに気づいたのでセットアップ試してみました。こいつはCore Ultra シリーズ1というやつで、Pコア、Eコア、LP(低電力)Eコアがそれぞれ2,8,2コアあり、PコアはHyper Threadingで倍になるので全部で14threadというなかなかややこしい構成になっています。/proc/cpuinfoを読み出してじっと睨んでると14の論理コアごとに微妙に違っていて、
processor | core id | cache size |
0 | 8 | 12288 KB |
1 | 8 | 12288 KB |
2 | 12 | 12288 KB |
3 | 12 | 12288 KB |
4 | 0 | 12288 KB |
5 | 1 | 12288 KB |
6 | 2 | 12288 KB |
7 | 3 | 12288 KB |
8 | 4 | 12288 KB |
9 | 5 | 12288 KB |
10 | 6 | 12288 KB |
11 | 7 | 12288 KB |
12 | 32 | 2048 KB |
13 | 33 | 2048 KB |
0-3がPコア、4-11がEコア、12-13がLP Eコアと推定できます。
これを使って、
$ taskset 0x00000001 ./a.out
$ taskset 0x00000010 ./a.out
$ taskset 0x00001000 ./a.out
のようにbitmaskで使用していいprocessorを指定して実行しました。結果は、
CPU | 加算 | 乗算 | 除算 |
Core Ultra 7 155U (P core) | 0.965516 | 41.6714 | 11.1905 |
Core Ultra 7 155U (E core) | 94.2782 | 71.6387 | 23.4713 |
Core Ultra 7 155U (LP E core) | 95.0886 | 71.632 | 23.4997 |
の通りでした。Eコアは全演算にペナルティがあって、Pコアは乗除算にペナルティがあります。
なお、るふぁ先生が調査して下さった結果を合わせると、Intelの12世代以降は、
CPU | 加算 | 乗算 | 除算 |
Intel 12-14世代 (Pコア) | o | x | x |
Intel 12-14世代 (Eコア) | x | x | x |
Intel Core Ultra 第1シリーズ (Pコア) | o | x | x |
Intel Core Ultra 第1シリーズ (Eコア) | x | x | x |
Intel Core Ultra 第2シリーズ (Pコア) | o | x | x |
Intel Core Ultra 第2シリーズ (Eコア) | o | x | x |
と、Core Ultra 第2シリーズでEコアが良くなったっぽいです。