今日は、おそらく .NET Core 3.0 で正式リリースとなるであろう最適化の話。 Hardware Intrinsics といって、特定 CPU の専用命令を利用するための機能の話になります。

元々は .NET Core 2.1 の頃に作業が始まっているんですが、2.1 リリースのタイミングには間に合いませんでした。 しかし、内部的な対応はすでに入っていて、daily ビルドなパッケージを参照すれば、今現在の .NET Core 2.1 でも利用可能です。 というか、ドキュメントはすでにあります

CPU 専用命令

いろいろなプログラミング言語で書かれたプログラムを比較したとき、 傾向として言うと、C 言語や C++ で書かれたものが最速です。

C# は、これら C や C++ と比較してどこがボトルネックでしょう。 印象としてはガベージ コレクションが遅そうに思われるかもしれませんが、案外、別のところにも原因があります。 (C# でもヒープ アロケーションを避けるコードは書けます。 それに、ヒープをどうしても避けれない場合だけに限定していうと、 ガベージ コレクションによるヒープ管理はものすごく高速です。)

高速化の行き着く先は、特定の CPU の専用命令をどれだけうまく使えるかになったりします。

例えば、32ビット整数の中から、特定のビットだけを抜き出すことを考えてみます。 普通に C# で書くと以下のような感じ。

struct SingleView
{
    public uint Value;
 
    /// <summary>
    /// Value のうち、23~31ビット目の値を抜き出す。
    /// </summary>
    public uint Exponent
    {
        get => (Value & 0x7F800000) >> 23;
        set => Value = (uint)((Value & ~0x7F800000) | ((value << 23) & 0x7F800000));
    }
}

AND とか OR とかシフト演算がいくつか必要です。

ところが、これ、たいていの CPU で1命令で実行できる命令があったりします。 x86 CPU だと BEXTR 命令ARM だと UBFX 命令というのがそれです。

理想をいうと、先ほどの AND とシフトな C# コードから、ちゃんと最適化でこれらの専用命令に翻訳されてほしいんですが、そんなにうまく行かないことが多いです。

そこで行き着く先は、「インライン アセンブラを書かせろ」となったりします。 実際、速いといわれている C/C++ コードは、CPU 専用命令を使ってガチガチに最適化していたりします。

実のところ、C/C++ と比べたときに C# (や、Java, Go, Swift 辺りの「そこそこ速い」言語)が遅い理由の結構な割合が、こういう専用命令利用に関する部分だったりします。

Intrinsics

ということで、C# 内にもインライン アセンブラを書きたいという要望はあるんですが。

しかし、「C# の中で別の言語を保守する」というのは、コンパイラーを作る側にとっても使う側にとってもかなりのハードル・足かせになります。 そこで最近よく取られる手法が、「intrinsic 関数の提供」です。

JIT Intrinsics」でも書きましたが、 intrinsic というのは固有の、内在的な、内因的な、本質的なという意味の単語で、 概ね、「内部的に特別扱いして最適化しているもの」という意味で使われています。

そして、intrinsic 関数(あるいは単に intrinsics)というのは、

  • 普通に関数(C# だと静的メソッド)としてライブラリ提供する
  • その関数を見たら特定の CPU 命令に置き換える

というようなもののことです。

例えば C++ でも、有名なものでは、Intel Intrinsics というものがあります。 名前通り Intel CPU 向けのものですが、Visual C++, GCC, clang など、Intel 製以外の C/C++ コンパイラーでも大体利用できます。 mmintrin.h とかで検索してもらうとサンプル コードがすぐに見つかると思います。 以下のような感じで、普通の C++ コードを書くと、それが特定の Intel CPU 命令に置き換わります。

#include <immintrin.h>
// 中略
__m128 c = _mm_mul_ps(a, b);

いわゆる SIMD 演算というやつで、 複数の積和演算を1命令で実行するので、うまく使えば数値計算が4~8倍速くなったりします。

ただし、注意点もあります。 特定 CPU の専用命令を使うための手法なので当然なんですが、 特定の CPU に依存します。 上記の Intel Intrinsics であれば当然 Intel CPU でしか動きません。 同じ Intel 系の CPU でも、世代を追うごとに命令がどんどん追加されているわけで、 古い CPU では対応していない命令が大量にあります。

その結果どうなるかというと、ガチガチに最適化するなら #ifdef だらけになります。 例え古い CPU のサポートを切ったとしても、Intel 系と ARM 系の2種類は保守が必要になったりします。

.NET でも Hardware Intrinsics

ということで、 .NET Core 2.1 くらいの頃から、.NET にも Hardware Intrinsics を入れたいという話が出ます。

実際、実は内部的にはもうその対応が入っていて、以下のパッケージを参照すれば .NET Core 2.1 で Hardware Intrinsics を使えます。

現状は、nuget.org からは落とせません。 MyGet (daily ビルド用の CI サーバー)からのみ取得できます。 また、正式リリースされた暁には Experimental が外れて、System.Runtime.Intrinsics パッケージになると思われます(もしかしたら、X86 と Arm で別パッケージになるかも)。

例えば、最初に出した「特定のビットだけを抜き出す」コードは以下のように書けます。

using System.Runtime.Intrinsics.X86;
 
struct SingleView
{
    public uint Value;
 
    public uint Exponent
    {
        get
        {
            if (Bmi1.IsSupported) return Bmi1.BitFieldExtract(Value, 23, 8);
            else return (Value & 0x7F800000) >> 23;
        }
        // set 割愛
    }
}

他にも、先ほど挙げた Intel Intrinsics 相当のメソッドもあります。

ちなみに、ここで出てくる IsSupported プロパティは JIT 時定数になります。 このコードは、JIT が掛かるタイミングで、 この CPU 命令セットを持っている環境なら if 側、 持っていない環境なら else 側だけが残ります。

なのでパフォーマンス的にはかなりいいものに仕上がるんですが、 見ての通り、同じ意味のコードを2回書く必要があります。 もちろん、ARM 系 CPU にも対応したければ3回に。

要するに、C/C++ でよくある「ガチガチに最適化するなら #ifdef だらけ」が、C# でも書けるようになります… 大変さと引き換えに、数倍高速なコードが手に入ります。