11

メモリから単精度浮動小数点数を読み取り、それらに対して倍精度で演算を実行することになっているコードを最適化しようとしています。データをメモリに単精度で格納するコードは、メモリにデータを倍精度で格納する同等のコードよりも大幅に遅くなるため、これは重大なパフォーマンスのボトルネックになりつつあります。以下は、私の問題の本質を捉えたおもちゃの C++ プログラムです。

#include <cstdio>

// noinline to force main() to actually read the value from memory.
__attributes__ ((noinline)) float* GetFloat() {
  float* f = new float;
  *f = 3.14;
  return f;
}

int main() {
  float* f = GetFloat();
  double d = *f;
  printf("%f\n", d);  // Use the value so it isn't optimized out of existence.
}

GCC と Clang はどちらも、命令がソース引数としてメモリをサポートしていても、倍精度のロード*fと倍精度への変換を 2 つの別個の命令として実行します。Agner Fogcvtss2sdによると、ほとんどのアーキテクチャと同じくらい高速に実行され、あとがきを実行する必要がありません。それにもかかわらず、Clang は に対して次のコードを生成します。cvtss2sd r, mmovss r, mcvtss2sd r, rmain()

main    PROC
        push    rbp                                     ; 
        mov     rbp, rsp                                ; 
        call    _Z8GetFloatv                            ;
        movss   xmm0, dword ptr [rax]                   ; 
        cvtss2sd xmm0, xmm0                             ; 
        mov     edi, offset ?_001                       ; 
        mov     al, 1                                   ; 
        call    printf                                  ; 
        xor     eax, eax                                ; 
        pop     rbp                                     ;
        ret                                             ;
main    ENDP

GCC も同様に効率の悪いコードを生成します。これらのコンパイラのどちらも、単に次のようなものを生成しないのはなぜcvtss2sd xmm0, dword ptr [rax]ですか?

編集: 素晴らしい答え、スティーブンキャノン!Clang のアセンブリ言語出力を実際のユース ケースに使用し、それをインライン ASM としてソース ファイルに貼り付けてベンチマークを行い、ここで説明した変更を加えて再度ベンチマークを行いました。cvtss2sd [memory]それが実際に遅いとは信じられませんでした。

4

1 に答える 1

18

これは実際には最適化です。メモリからの CVTSS2SD は、デスティネーション レジスタの上位 64 ビットを変更しません。これは、部分的なレジスタ更新が発生することを意味し、多くの状況で大幅なストールが発生し、ILP が大幅に削減される可能性があります。一方、MOVSS はレジスタの未使用ビットをゼロにするため、依存関係がなくなり、ストールのリスクが回避されます。

double への変換でボトルネックが発生する可能性がありますが、そうではありません。


部分的なレジスタ更新がパフォーマンス上の問題となる正確な理由について少し説明します。

実際にどのような計算が行われているのかわかりませんが、次の非常に単純な例のように見えるとしましょう:

double accumulator, x;
float y[n];
for (size_t i=0; i<n; ++i) {
    accumulator += x*(double)y[i];
}

ループの「明らかな」コード生成は次のようになります。

loop_begin:
  cvtss2sd xmm0, [y + 4*i]
  mulsd    xmm0,  x
  addsd    accumulator, xmm0
  // some loop arithmetic that I'll ignore; it isn't important.

素朴に、唯一のループ運搬依存関係はアキュムレータの更新にあるため、漸近的にループは 1/(addsdレイテンシ) の速度で実行される必要があります。これは、現在の「標準的な」x86 コアでのループ反復ごとに 3 サイクルです (Agner Fog の表または詳細については、インテルの最適化マニュアルを参照してください)。

しかし、これらの命令の動作を実際に見てみると、xmm0 の上位 64 ビットは、関心のある結果に影響を与えなくても、2 番目のループ運搬依存チェーンを形成することがわかります。各cvtss2sd命令は、前のループ反復の結果mulsdが利用可能になるまで開始できません。これにより、ループの実際の速度が 1/(cvtss2sdレイテンシ +mulsdレイテンシ) に制限されます。つまり、典型的な x86 コアでは、ループの反復ごとに 7 サイクルになります (良いニュースは、reg-reg 変換のレイテンシのみを支払うことです。変換操作はクラックされているためです)。 2 つの µop であり、負荷 µop はに依存しないxmm0ため、引き上げることができます)。

このループの操作を次のように書き出すと、もう少し明確になります (cvtss2sdこれらの µop はほとんど制約されておらず、多かれ少なかれいつでも発生する可能性があるため、 の半分の負荷は無視しています)。

cycle  iteration 1    iteration 2    iteration 3
------------------------------------------------
0      cvtss2sd
1      .
2      mulsd
3      .
4      .
5      .
6      . --- xmm0[64:127]-->
7      addsd          cvtss2sd(*)
8      .              .
9      .-- accum -+   mulsd
10                |   .
11                |   .
12                |   .
13                |   . --- xmm0[64:127]-->
14                +-> addsd          cvtss2sd
15                    .              .

(*) 実際には少し単純化しています。これを正確に行うには、遅延だけでなくポートの使用率も考慮する必要があります。ただし、問題のストールを説明するにはレイテンシのみを考慮するだけで十分なので、単純にしておきます。無限の ILP リソースを持つマシンで実行しているとします。

代わりに、次のようにループを記述したとします。

loop_begin:
   movss    xmm0, [y + 4*i]
   cvtss2sd xmm0,  xmm0
   mulsd    xmm0,  x
   addsd    accumulator, xmm0
   // some loop arithmetic that I'll ignore; it isn't important.

xmm0movssのメモリ ゼロ ビット [32:127] から、xmm0 へのループ搬送依存関係がなくなるため、予想どおり、累積レイテンシーに拘束されます。定常状態での実行は次のようになります。

cycle  iteration i    iteration i+1  iteration i+2
------------------------------------------------
0      cvtss2sd       .
1      .              .
2      mulsd          .              movss 
3      .              cvtss2sd       .
4      .              .              .
5      .              mulsd          .
6      .              .              cvtss2sd
7      addsd          .              .
8      .              .              mulsd
9      .              .              .
10     . -- accum --> addsd          .
11                    .              .
12                    .              .
13                    . -- accum --> addsd

私のおもちゃの例では、部分的なレジスタ更新の停止を解消した後、問題のコードを最適化するためにまだやるべきことがたくさんあることに注意してください。これはベクトル化でき、(発生する特定の丸めを変更することを犠牲にして) 複数のアキュムレータを使用して、ループで実行される累算から累算までのレイテンシーの影響を最小限に抑えることができます。

于 2013-05-16T21:20:13.070 に答える