2025/04/06(日)非正規化数の計算は遅い?
はじめに
よく知られているように、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 |
おわりに
個人で所有している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 |
Intel 12世代以降はどうなってるの?
ところで、実験を見て気になるのはN100です。加算も遅いという散々な結果ですが、N100って、Intel 12世代以降のいわゆるEコアで出来ているはず。ということは、12世代以降のCPUで運悪くEコアに当たってしまうと加算ですら非正規化数のペナルティが発生する? 自分はIntelの12世代以降は所有していないので試せないのですが、気になります。追記 (2025年4月7日)
未開封の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 |
これを使って、
$ 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 |
なお、るふぁ先生が調査して下さった結果を合わせると、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 |
2025/03/18(火)数学ソフトウェアとフリードキュメント
- 講演に使ったスライド
2025/03/15(土)kv-0.4.58
今回も大した変更はありません。
- ODE Solverで、精度保証に失敗した場合ごく稀にstep sizeを小さくしてのretryをせずにエラーで止まってしまうことがあったのを修正。
- PSAのpow(x, y)で、yが整数でない定数だがpsa型でない場合の動作を改善。
- vleq.hppの精度を改善 (高安先生の要請による)
数学ソフトウェアとフリードキュメント 37でkvについてしゃべるので、いい機会なのでここまで溜まっていたアップデートを吐き出したかったという理由もありました。
2024/08/27(火)kv-0.4.57
今回は、
- 以前にるふぁさんにご指摘頂いた、defint_autostepで使用する級数の次数を1にしたときに正しく計算できていなかったバグの修正。
- ode_maffineに自動微分型を指定し、なおかつcallback関数を指定したとき、 callback関数が初期値に関する微分の情報をも渡してくれるように変更。
大したアップデートではないですが、一応2つ溜まったので。
2024/02/07(水)g++-13と_Float64xとkv-0.4.56
今回は問題発生への対処です。ある人から、g++ version 13でkvがまったくコンパイルできない、clang++では問題ないと連絡が来ました。実際、ubuntu 23.10を入れて試してみると、test-rounding.ccとかtest-interval.ccみたいな基本的なプログラムすらコンパイルエラーになってしまいました。
調べてみると、_Float64x (IntelのFPUが持っている80bit拡張浮動小数点数) の関係する部分がエラーになっています。kvどころか、
#include <iostream> int main() { _Float64x a; std::cin >> a; }みたいな単純なプログラムがコンパイルエラーになってしまいます。また、
#include <iostream> #include <limits> int main() { std::cout << std::numeric_limits<_Float64x>::epsilon() << std::endl; std::cout << std::numeric_limits<_Float64x>::max() << std::endl; std::cout << std::numeric_limits<_Float64x>::min() << std::endl; std::cout << std::numeric_limits<_Float64x>::infinity() << std::endl; std::cout << std::numeric_limits<__float80>::epsilon() << std::endl; std::cout << std::numeric_limits<__float80>::max() << std::endl; std::cout << std::numeric_limits<__float80>::min() << std::endl; std::cout << std::numeric_limits<__float80>::infinity() << std::endl; std::cout << std::numeric_limits<long double>::epsilon() << std::endl; std::cout << std::numeric_limits<long double>::max() << std::endl; std::cout << std::numeric_limits<long double>::min() << std::endl; std::cout << std::numeric_limits<long double>::infinity() << std::endl; }は、
0 0 0 0 1.0842e-19 1.18973e+4932 3.3621e-4932 inf 1.0842e-19 1.18973e+4932 3.3621e-4932 infのように、numeric_limits<_Float64x>はすべて0を返してきます。
調べてみると、「_Float64xをサポートしているのはgccのみで、もともとg++では_Float64xをまったくサポートしていなかった。しかしversion 12までは単なるlong doubleのtypedefだったので、たまたまg++でも動いてしまっていた。最新のg++ version 13では_Float64xを部分的にサポートするようになったがlibstdc++はサポートしていない状態になり、結果的にまったく使えなくなってしまった」ということのようです。やりとりはこの辺。
gccのドキュメントの6.12 Additional Floating Typesでは、「As an extension, GNU C and GNU C++ support additional floating types, (中略) _float80 is available on the i386, x86_64, and IA-64 targets, and supports the 80-bit (XFmode) floating type. It is an alias for the type name _Float64x on these targets. 」とはっきり書いているので、g++でも_Float64xはサポートされているように見えるのですが。
IntelのCPUではlong double、__float80も_Float64xと同じ80bit拡張浮動小数点数です。こっちの2つはg++-13でも生き残っています。どちらかで全部書き換えることも考えたのですが、long doubleは異なるアーキテクチャだと異なる浮動小数点数フォーマットに対応していることが多く、トラブルを招く可能性が高い、__float80はgccのみのオリジナル拡張でclangで使えない、とそれぞれ問題があります。
もっともメジャーで、4月にはみんながubuntu 24.04を使い始めることを考えると、g++-13で動かないなんてのは使い物にならないも同然なので、緊急にkv-0.4.56をリリースしました。とりあえず最低限の変更で、g++-13では_Float64x関連の機能が全部働かないようにしました。g++-12以下ではすべて問題なく、g++-13では_Float64xを使う機能を除いてすべてコンパイルできるようになりました。他の変更は、ついでにoptimize.hppにちょっとした追加機能が入ったくらいです。
g++-14以降でちゃんと戻ることを祈るのみですが、こんなIntel CPUにしかない盲腸のような機能を使おうとするほうが悪い、という話でもあるのかな。