アセンブリが C より速いのはいつですか?

以下は実際の例です:古いコンパイラでは固定小数点が乗算されます。

これらは、浮動小数点のないデバイスで便利になるだけでなく、予測可能なエラーで 32 ビットの精度を提供するため、精度に関しても優れています (浮動小数点数は 23 ビットしかなく、精度の低下を予測するのはより困難です)。つまり、一様 絶対 一様に近い relative ではなく、範囲全体にわたる精度 精度 (float ).

最新のコンパイラはこの固定小数点の例を適切に最適化するため、コンパイラ固有のコードが必要な最新の例については、

を参照してください。
  • 64 ビット整数乗算の上位部分の取得:uint64_t を使用したポータブル バージョン for 32x32 => 64 ビット乗算は 64 ビット CPU では最適化に失敗するため、組み込み関数または __int128 が必要です。 64 ビット システムで効率的なコードを作成する
  • Windows 32 ビット上の _umul128:64 にキャストされた 32 ビット整数を乗算する場合、MSVC は常に適切に機能するとは限らないため、組み込み関数が大いに役立ちました。

C には完全な乗算演算子 (N ビット入力からの 2N ビット結果) がありません。これを C で表現する通常の方法は、入力をより広い型にキャストし、コンパイラが入力の上位ビットが重要ではないことを認識することを期待することです:

// on a 32-bit machine, int can hold 32-bit fixed-point integers.
int inline FixedPointMul (int a, int b)
{
  long long a_long = a; // cast to 64 bit.

  long long product = a_long * b; // perform multiplication

  return (int) (product >> 16);  // shift by the fixed point bias
}

このコードの問題点は、C 言語では直接表現できないことを行っていることです。 2 つの 32 ビット数を乗算して 64 ビットの結果を取得し、その中間の 32 ビットを返します。ただし、C では、この乗算は存在しません。できることは、整数を 64 ビットに昇格させ、64*64 =64 の乗算を行うことだけです。

ただし、x86 (および ARM、MIPS など) は、単一の命令で乗算を実行できます。一部のコンパイラは、この事実を無視して、ランタイム ライブラリ関数を呼び出して乗算を行うコードを生成していました。 16 によるシフトも、ライブラリ ルーチンによって行われることがよくあります (x86 でもそのようなシフトを行うことができます)。

そのため、乗算のためだけに 1 つまたは 2 つのライブラリ呼び出しが残っています。これは深刻な結果をもたらします。シフトが遅くなるだけでなく、関数呼び出し間でレジスターを保持する必要があり、インライン化とコード展開にも役立ちません。

(インライン) アセンブラーで同じコードを書き直すと、速度が大幅に向上します。

これに加えて、ASM を使用することは、問題を解決する最良の方法ではありません。ほとんどのコンパイラでは、C で表現できない場合、一部のアセンブラ命令を組み込み形式で使用できます。たとえば、VS.NET2008 コンパイラは、32*32=64 ビット mul を __emul として公開し、64 ビット シフトを __ll_rshift として公開します。

組み込み関数を使用すると、何が起こっているのかを C コンパイラが理解できるように関数を書き直すことができます。これにより、コードのインライン化、レジスタの割り当て、共通部分式の削除、および定数の伝播も実行できます。 巨大な 手書きのアセンブラ コードよりもパフォーマンスが向上します。

参考までに:VS.NET コンパイラの固定小数点 mul の最終結果は次のとおりです:

int inline FixedPointMul (int a, int b)
{
    return (int) __ll_rshift(__emul(a,b),16);
}

固定小数点除算のパフォーマンスの違いはさらに大きくなります。数行の asm 行を書くことで、除算の多い固定小数点コードを 10 倍まで改善しました。

Visual C++ 2013 を使用すると、両方の方法で同じアセンブリ コードが得られます。

2007 年の gcc4.1 も純粋な C バージョンを適切に最適化します。 (Godbolt コンパイラ エクスプローラには以前のバージョンの gcc がインストールされていませんが、おそらく古い GCC バージョンでも組み込み関数なしでこれを実行できます。)

Godbolt コンパイラ エクスプローラーで、x86 (32 ビット) および ARM の source + asm を参照してください。 (残念ながら、単純な純粋な C バージョンから悪いコードを生成するのに十分古いコンパイラはありません。)

最新の CPU は、C に まったく 演算子がないことを実行できます 、 popcnt のように またはビットスキャンして最初または最後のセットビットを見つけます . (POSIX には ffs() があります 関数ですが、そのセマンティクスは x86 bsf と一致しません / bsr . https://en.wikipedia.org/wiki/Find_first_set を参照してください)。

一部のコンパイラは、整数に設定されたビット数をカウントするループを認識し、それを popcnt にコンパイルできる場合があります。 命令 (コンパイル時に有効になっている場合) ですが、 __builtin_popcnt を使用する方がはるかに信頼性が高くなります GNU C、または SSE4.2 を使用するハードウェアのみをターゲットにしている場合は x86:_mm_popcnt_u32 <immintrin.h> から .

または C++ では、std::bitset<32> に代入します。 .count() を使用します . (これは、常に正しいものにコンパイルされ、ターゲットがサポートするものすべてを利用できる方法で、最適化された popcount の実装を標準ライブラリを介して移植可能に公開する方法を言語が見つけた場合です。) https も参照してください。 ://en.wikipedia.org/wiki/Hamming_weight#Language_support.

同様に、ntohl bswap にコンパイルできます (エンディアン変換のための x86 32 ビット バイト スワップ) を持つ一部の C 実装。

組み込み関数または手書きの asm のもう 1 つの主要な領域は、SIMD 命令を使用した手動のベクトル化です。コンパイラは dst[i] += src[i] * 10.0; のような単純なループは悪くない 、しかし、物事がより複雑になると、うまくいかないか、自動ベクトル化がまったく行われないことがよくあります。たとえば、SIMD を使用して atoi を実装する方法は?スカラー コードからコンパイラによって自動的に生成されます。


何年も前に、私は誰かに C でプログラミングする方法を教えていました。演習は、グラフィックを 90 度回転させることでした。彼は、主に掛け算や割り算などを使用していたため、完了までに数分かかった解を返しました。

ビット シフトを使用して問題を再キャストする方法を彼に示したところ、彼が使用していた非最適化コンパイラでの処理時間は約 30 秒に短縮されました。

最適化コンパイラを入手したばかりで、同じコードが 5 秒未満でグラフィックを回転させました。私はコンパイラが生成しているアセンブリ コードを見て、そこで判断したことから、アセンブラを書いていた私の時代は終わったと判断しました。


コンパイラが浮動小数点コードを認識するときはいつでも、古い悪いコンパイラを使用している場合は、手書きのバージョンの方が高速です。 (2019 更新:これは一般に、最新のコンパイラには当てはまりません。 特に x87 以外でコンパイルする場合。コンパイラは、x87 のレジスタ スタックとは異なり、スカラー演算用の SSE2 または AVX、またはフラットな FP レジスタ セットを備えた非 x86 を使用する方が簡単です。)

主な理由は、コンパイラが堅牢な最適化を実行できないことです。この件に関する議論については、MSDN のこの記事を参照してください。以下は、アセンブリ バージョンが C バージョン (VS2K5 でコンパイル) の 2 倍の速度である例です:

#include "stdafx.h"
#include <windows.h>

float KahanSum(const float *data, int n)
{
   float sum = 0.0f, C = 0.0f, Y, T;

   for (int i = 0 ; i < n ; ++i) {
      Y = *data++ - C;
      T = sum + Y;
      C = T - sum - Y;
      sum = T;
   }

   return sum;
}

float AsmSum(const float *data, int n)
{
  float result = 0.0f;

  _asm
  {
    mov esi,data
    mov ecx,n
    fldz
    fldz
l1:
    fsubr [esi]
    add esi,4
    fld st(0)
    fadd st(0),st(2)
    fld st(0)
    fsub st(0),st(3)
    fsub st(0),st(2)
    fstp st(2)
    fstp st(2)
    loop l1
    fstp result
    fstp result
  }

  return result;
}

int main (int, char **)
{
  int count = 1000000;

  float *source = new float [count];

  for (int i = 0 ; i < count ; ++i) {
    source [i] = static_cast <float> (rand ()) / static_cast <float> (RAND_MAX);
  }

  LARGE_INTEGER start, mid, end;

  float sum1 = 0.0f, sum2 = 0.0f;

  QueryPerformanceCounter (&start);

  sum1 = KahanSum (source, count);

  QueryPerformanceCounter (&mid);

  sum2 = AsmSum (source, count);

  QueryPerformanceCounter (&end);

  cout << "  C code: " << sum1 << " in " << (mid.QuadPart - start.QuadPart) << endl;
  cout << "asm code: " << sum2 << " in " << (end.QuadPart - mid.QuadPart) << endl;

  return 0;
}

そして、デフォルトのリリース ビルドを実行している私の PC からのいくつかの数値 * :

  C code: 500137 in 103884668
asm code: 500137 in 52129147

興味深いことに、ループを dec/jnz に交換しましたが、タイミングに違いはありませんでした。時には速く、時には遅くなりました。メモリが限られているという側面は、他の最適化を圧倒していると思います。 (編集者注:FP レイテンシのボトルネックは、loop の追加コストを隠すのに十分である可能性が高いです。 .奇数/偶数要素に対して 2 つのカハン和を並行して実行し、それらを最後に加算すると、おそらく 2 倍高速化される可能性があります。)

おっと、少し異なるバージョンのコードを実行していたところ、数値が間違った方法で出力されました (つまり、C の方が高速でした!)。結果を修正して更新しました。