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.99933842.199514.3503
Intel Core i5-6500T (Skylake)0.92403433.542811.956
Intel Core i9-7900X (Skylake)0.96088633.876411.9609
Intel Pentium CPU 4415Y (Kaby Lake)0.99709934.407912.1162
Intel Core i5-8250U (Kaby Lake R)0.99220934.503712.1195
11th Gen Intel Core i7-1195G70.9998429.120210.577
Intel Celeron N4120 (Gemini Lake Refresh)57.132643.77513.7602
Intel N100 (Alder Lake-N)95.714171.82423.5843
AMD Ryzen 7 7700 (Zen4)0.9816351.553461.39528
AMD Ryzen 7 8840U (Zen4)0.990941.329991.40047
Apple M10.9648730.9992541.00117
これを見ると、IntelのCPUは加算は遅くならないが乗除算が遅い、ATOM系は加算も遅い、AMDやApple Siliconはほとんど遅くならない、ことが読み取れます。

おわりに

個人で所有しているCPUの種類には限界があってなかなか網羅的な調査は難しいですが、いろいろ検索して情報を集めてみた結果、どうやら次の表のような感じっぽいです。(o=ペナルティなし、x=ペナルティあり)
CPU加算乗算除算
Intel SandyBridgeより古いxxx
AMD Bulldozerxxx
Intel SandyBridge以降11世代までoxx
Intel ATOM系xxx
Intel KNLoox
AMD Zenooo
Apple Siliconooo
SandyBridgeより古いのとかBulldozerとかKNLとか、実機で試すのは大変ですが、いつかやってみたいものです。あるいはどなたか動かしてくれないかしら。

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の論理コアごとに微妙に違っていて、
processorcore idcache size
0812288 KB
1812288 KB
21212288 KB
31212288 KB
4012288 KB
5112288 KB
6212288 KB
7312288 KB
8412288 KB
9512288 KB
10612288 KB
11712288 KB
12322048 KB
13332048 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.96551641.671411.1905
Core Ultra 7 155U (E core)94.278271.638723.4713
Core Ultra 7 155U (LP E core)95.088671.63223.4997
の通りでした。Eコアは全演算にペナルティがあって、Pコアは乗除算にペナルティがあります。

なお、るふぁ先生が調査して下さった結果を合わせると、Intelの12世代以降は、
CPU加算乗算除算
Intel 12-14世代 (Pコア)oxx
Intel 12-14世代 (Eコア)xxx
Intel Core Ultra 第1シリーズ (Pコア)oxx
Intel Core Ultra 第1シリーズ (Eコア)xxx
Intel Core Ultra 第2シリーズ (Pコア)oxx
Intel Core Ultra 第2シリーズ (Eコア)oxx
と、Core Ultra 第2シリーズでEコアが良くなったっぽいです。

2025/03/18(火)数学ソフトウェアとフリードキュメント

昨日は、数学ソフトウェアとフリードキュメント 37という集まりで、kvライブラリについて50分間、話をしてきました。数値計算の誤差がどのくらい怖いか、という「脅し」のパートが受けてたみたいです。kvライブラリのホームページに置いてあるスライドにちょっと追加しただけではありますが、スライドが欲しいという話があったのでここに置いておきます。

2025/03/15(土)kv-0.4.58

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

kvライブラリを0.4.57にアップデートしました。

今回は、
  • 以前にるふぁさんにご指摘頂いた、defint_autostepで使用する級数の次数を1にしたときに正しく計算できていなかったバグの修正。
  • ode_maffineに自動微分型を指定し、なおかつcallback関数を指定したとき、 callback関数が初期値に関する微分の情報をも渡してくれるように変更。
の2点です。後者はとある研究で必要になって機能追加しました。

大したアップデートではないですが、一応2つ溜まったので。

2024/02/07(水)g++-13と_Float64xとkv-0.4.56

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にしかない盲腸のような機能を使おうとするほうが悪い、という話でもあるのかな。
OK キャンセル 確認 その他