x86でdoubleがfloatより速いかどうかを検証してみた

  • 48
    いいね
  • 2
    コメント
この記事は最終更新日から1年以上が経過しています。

昔話

それは昔々のこと。
x86には浮動小数点演算を行う手段がなく、外付けの浮動小数点演算ユニットを接続するという手法で、浮動小数点演算を実現していたのであった。
x87と呼ばれたそれはとてもエクセレントなシステムで…という話はwikipediaに譲ろう。
https://ja.wikipedia.org/wiki/Intel_8087
重要なのは、x87が内部表現として80bitの拡張倍精度を使っている、ということ。
これのおかげで、x87においては、確かにdoubleのほうが速かった
(floatだとdoubleへのキャストコストが発生するため)

嘘だろそれ。ASM見たら別にキャストとかしてなかったわ。
どっちかというと丸めの影響で精度が異なることのほうが重要だわ。
改めて調べてみると、doubleが速いとされている資料についてはあんまりないことに気付く。
(同等としている資料はherumiさんの http://homepage1.nifty.com/herumi/prog/prog90.html がある)
速いってのはそれがデマだったのでは…?

(2016/06/13追記: コメントにてご指摘いただきましたが、確かにfloatがdoubleより遅い条件があります。詳しくは後述)

しかしx87の登場からすでに30年(!)
x86も時代が移り変わり、x87は内部に統合され、MMX(こいつももはや死語),SSE,AVXと色々な命令が増えてきた。
この時代にあって、doubleがfloatより本当に速いのかどうか、調べてみよう。

ソースコード

gistに置いてあるので適当にとっていってほしい。
https://gist.github.com/telmin/846e1ee9ecddd4fcc063

仮説

速くないと一刀両断するのは簡単なので、doubleとfloatでどういう性能差が発生するかまず考えてみる。

キャッシュのはなし

floatとdoubleはご存知のとおり32bitと64bitで、使用するデータ量は倍になる。
とすると、当然キャッシュヒットしないような状態で計算するとあっさり性能は倍だろう。これはあんまり面白くないのと、いろいろと他でややこしくなるので、全データがキャッシュに乗るような状態を考えよう。

SSE命令のはなし

現在、浮動小数点SIMD命令として、x86(_64)には、SSE, AVXが用意されている。
話を簡単にするためにSSEのみにフォーカスする。
最近のAuto-Vectorizationは結構優秀で、簡単な演算は割と勝手にベクタライズしてくれる。ありがたいことだ。それでもまだ手で書きたい。一日一回感謝のIntrinsics!!!

でまぁ、floatとdoubleのmulでどのようにレイテンシとスループットが違うかというと
Intel Intrinsics guide
https://software.intel.com/sites/landingpage/IntrinsicsGuide/
より

  • __m128 _mm_mul_ps (__m128 a, __m128 b)
Architecture Latency Throughput
Haswell 5 0.5
Ivy Bridge 5 1
Sandy Bridge 5 1
Westmere 4 1
Nehalem 4 1
  • __m128d _mm_mul_pd (__m128d a, __m128d b)
Architecture Latency Throughput
Haswell 5 0.5
Ivy Bridge 5 1
Sandy Bridge 5 1
Westmere 4 1
Nehalem 4 1

となり、全く一緒になる。
なのであとは純粋にbit長に依存して、4Flop, 2Flopとなるわけだ。

あと、SSEにはxmmレジスタを入力としながらも先頭32bit, 64bitのみを演算として使用する命令もあり、普段x86_64で浮動小数点を使うとこちらが使用される。あとベクタライズしろって言ってんのにこいつを吐くこともある。
これが、それぞれ
- __m128 _mm_mul_ss (__m128 a, __m128 b)
- __m128 _mm_mul_sd (__m128 a, __m128 b)

レイテンシ、スループットはps, pdと同じ(当たり前)

x87命令のはなし

SSEのときと同様に、x87命令のレイテンシ、スループットを考えてみる。
x87の浮動小数点乗算命令はfmulである。
レイテンシ、スループットはhttp://www.agner.org/optimize/instruction_tables.pdf より

Architecture Latency Throughput
Haswell 5 1
Ivy Bridge 5 1
Sandy Bridge 5 1

となっている。
なんか全体的に同じ感じするわな。

つまり

ベクタライズしていればfloatとdoubleが約2倍の性能になるはず。
してない場合はx87、SSEのいずれにおいても、doubleとfloatは同等の性能になるはず。
x87の場合は、float -> doubleへのキャストコストが発生するからdoubleのほうが若干速いはず 嘘。同じ速度ぐらいになるはず。

実験環境 & 実験条件

環境

MacbookPro Retina 15(mid 2012)
CPU : Corei7-3720QM IvyBridge
RAM : 8GB
コンパイラ : Apple LLVM version 6.0 (clang-600.0.54) (based on LLVM 3.5svn)

条件

float, double 4096要素に対してaxpyを実施
中間変数含め、使うのは4096 * 4要素
axpyを100回実行してかかった時間を合計する。

x87命令動かすのに32bitバイナリじゃないとちゃんと動かなかったので以下はm32オプションを付与しているものとする。
つかなんで動かないのか誰か教えてプリーズ。
ABI絡みだった気がする…?

実験結果

とりあえずSSE

$ clang++ -O2 double.cpp -std=c++11 -m32
float time 185000 nsec
double time 390000 nsec

x87

$ clang++ -O2 double.cpp -std=c++11 -m32 -mno-sse
float time 561000 nsec
double time 549000 nsec

SSEでベクタライズ止めてみる

$ clang++ -O2 double.cpp -std=c++11 -m32 -fno-vectorize
float time 549000 nsec
double time 553000 nsec

表にまとめると以下のようになる。

float double
SSE 185000 390000
x87 561000 549000
SSE(no-vectorize) 549000 553000

x87でfloatが若干遅いのはなんだろうか。何度か実行してみると結果が若干揺れるからそのせいかな…

まとめ

あんまり優位な差は出なかったので結論が微妙になりそうな気がしないでもないが、少なくともSSEとかがあるこんにちでは、精度的に大丈夫な場合はfloatを使うと、SIMDの恩恵にあずかれるからそっちのほうがいいんじゃないかとか思う。
あと、x87は頑張らないと出ないので、もう拡張倍精度のことは忘れたほうがいい。マジで。

おまけ

ARMでの結果は以下
http://d.hatena.ne.jp/nakamura001/20090301/1235924387
(2009年なので、最新だとどうなってるかはわからないけどあんまり変わらんでしょ)

おまけその2

ちゃんとSSE命令とか出てるぅ?
SSE版

LBB0_17:                               ## %vector.body116
                                        ##   Parent Loop BB0_13 Depth=1
                                        ## =>  This Inner Loop Header: Depth=2
        movups  (%eax,%ebx,4), %xmm0
        movups  (%ecx,%ebx,4), %xmm1
        mulps   %xmm0, %xmm1
        movups  (%edx,%ebx,4), %xmm0
        addps   %xmm1, %xmm0
        movups  %xmm0, (%esi,%ebx,4)
        addl    $4, %ebx
        cmpl    $4096, %ebx             ## imm = 0x1000
        jne     LBB0_17

x87版

LBB0_14:                                ##   Parent Loop BB0_13 Depth=1
                                        ## =>  This Inner Loop Header: Depth=2
        flds    (%eax,%ebx,4)
        fmuls   (%ecx,%ebx,4)
        fadds   (%edx,%ebx,4)
        fstps   (%esi,%ebx,4)
        incl    %ebx
        cmpl    $4096, %ebx             ## imm = 0x1000
        jne     LBB0_14

出てるっぽい。

おまけその3

floatがdoubleより遅くなるシチュエーションは存在した…!
K&Rの頃のC言語は、floatはdoubleに昇格して演算を行うルールであった。
(fujitanozomu さんにコメントいただきました。ありがとうございました)

https://docs.oracle.com/cd/E19957-01/806-4836/compat.html
これでも記載されている通り、

K&R ANSI/ISO C
単精度計算と倍精度計算 浮動小数点式のオペランドを double に拡張する。float を返すように宣言された関数の戻り値は、常に double に拡張される。 float の演算を単精度計算で行うことができる。このような関数に float の戻り型を使用できる。

となっているので、ANSIの規格化を以って、ちゃんとfloatが(言語仕様的には)使えるようになった模様。

あと、現在でも下手するとこの型昇格は踏むらしい。それが以下のような条件。

hoge.c
extern float fa, fb, fc, fd;
extern double da, db, dc, dd;

extern float fcomp();
extern double dcomp();

hoge()
{
    fd = fcomp(fa, fb, fc);
}

piyo()
{
    dd = dcomp(da, db, dc);
}
$ clang -O2 -m32 -S hoge.c -o -
_hoge:                                  # @hoge
        subl    $24, %esp
        movss   _fa, %xmm0              # xmm0 = mem[0],zero,zero,zero
        cvtss2sd        %xmm0, %xmm0
        movss   _fb, %xmm1              # xmm1 = mem[0],zero,zero,zero
        cvtss2sd        %xmm1, %xmm1
        movss   _fc, %xmm2              # xmm2 = mem[0],zero,zero,zero
        cvtss2sd        %xmm2, %xmm2
        movsd   %xmm2, 16(%esp)
        movsd   %xmm1, 8(%esp)
        movsd   %xmm0, (%esp)
        calll   _fcomp
        fstps   _fd
        addl    $24, %esp
        retl

_piyo:                                  # @piyo
        subl    $24, %esp
        movsd   _da, %xmm0              # xmm0 = mem[0],zero
        movsd   _db, %xmm1              # xmm1 = mem[0],zero
        movsd   _dc, %xmm2              # xmm2 = mem[0],zero
        movsd   %xmm2, 16(%esp)
        movsd   %xmm1, 8(%esp)
        movsd   %xmm0, (%esp)
        calll   _dcomp
        fstpl   _dd
        addl    $24, %esp
        retl
piyo.c
float fcomp(a, b, c)
float a;
float b;
float c;
{
    return a + b * c;
}

double dcomp(a, b, c)
double a;
double b;
double c;
{
    return a + b * c;
}
$ clang -O2 -m32 -S piyo.c -o -
_fcomp:                                 # @fcomp
        pushl   %eax
        movsd   24(%esp), %xmm0         # xmm0 = mem[0],zero
        movsd   16(%esp), %xmm1         # xmm1 = mem[0],zero
        movsd   8(%esp), %xmm2          # xmm2 = mem[0],zero
        cvtsd2ss        %xmm2, %xmm2
        cvtsd2ss        %xmm1, %xmm1
        cvtsd2ss        %xmm0, %xmm0
        mulss   %xmm1, %xmm0
        addss   %xmm2, %xmm0
        movss   %xmm0, (%esp)
        flds    (%esp)
        popl    %eax
        retl

_dcomp:                                 # @dcomp
        pushl   %ebp
        movl    %esp, %ebp
        andl    $-8, %esp
        subl    $8, %esp
        movsd   16(%ebp), %xmm0         # xmm0 = mem[0],zero
        mulsd   24(%ebp), %xmm0
        addsd   8(%ebp), %xmm0
        movsd   %xmm0, (%esp)
        fldl    (%esp)
        movl    %ebp, %esp
        popl    %ebp
        retl

現代のコンパイラでも、外部関数についてプロトタイプ宣言を行わず、昔の K&R スタイルの仮引数のない外部宣言のみで float の値を引数として与えて関数呼び出しを行うと、float の値は一旦 double に昇格されてから引数として渡されるコードが生成される

とご指摘いただいたので、この辺は気を付けないといけなさそう。