3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SSA ベースの .NET IL 最適化コンパイラーを作ってみた

3
Last updated at Posted at 2026-11-07

はじめに

C# のコードがソースから実行に至るまでには、Roslyn と JIT の 2 段階のコンパイルを経由します。コンパイラフロントエンドである Roslyn は主に構文展開と基礎的な解析を担当しており、出力される IL は元のソースコードの構造をほぼそのまま保っています。本格的な最適化の大部分は実行時の RyuJIT に委ねられていますが、JIT は実行時に動く性質上コンパイル時間の制約が厳しく、インライン展開の深さや脱仮想化、エスケープ解析、ループ最適化などはかなり控えめに抑えられています。

cover.jpg

そこでビルド段階に 1 ステップ挟み、コンパイル済みの .NET アセンブリを読み込んで SSA(静的単一代入)形式の中間表現(IR)へ持ち上げ、コンパイラのミドルエンドに相当する最適化を一通り走らせてから標準的な IL として書き戻せば、ランタイムや JIT に一切手を加えることなく、より洗練されたコードを JIT に渡すことができます。

こうした発想で作ったのが Cilica です。

なぜ一旦 SSA に変換するのか

CIL はスタックベースの命令セットです。計算は暗黙の評価スタックを介して行われ、ローカル変数も任意の場所で上書きできます。フロントエンドがコードを生成する分には扱いやすい形式ですが、静的解析やプログラム変換には向いていません。スタックトップの値がどの命令で計算されたのかを調べるだけでも、評価スタックのシミュレーションが必要になります。

そのため Cilica では IL の命令列を直接書き換えるのではなく、一般的なコンパイラのミドルエンドと同じ手順を踏みます。

  1. インポート(Import):メソッド本体の IL をパースして基本ブロックと制御フローグラフ(CFG)を構築し、アドレス取得されていないローカル変数を SSA(静的単一代入)形式に昇格させます。SSA ではすべての値の定義箇所が一箇所に定まり、制御フローの合流点は明示的な phi ノードで表されます。
  2. 最適化(Optimize):SSA グラフ上で解析パスと変換パスを反復実行します。
  3. コード生成(Codegen):SSA 形式を解除してコンパクトな式木を再構築し、グラフ彩色ベースのレジスタ割り当てアルゴリズムでローカル変数のスロットを再利用した上で、標準の CIL を出力します。

Cilica の IR は LLVM の設計を参考にしつつ、CIL に合わせた高水準なセマンティクスと型システムを残しています。簡単なメソッドをインポートすると次のようになります。

public static Program::Run() -> int {
BB_01:
  r2 = call Program::Create() -> IReader
  r3 = callvirt IReader::Read(this: r2) -> int
  ret r3
}

この形式では、r2 や r3 の定義箇所が一意に定まります。SSA の Def-Use チェーンを辿るだけで、「呼び出し対象の具体的な型が静的に確定するか」「新しく確保したオブジェクトが外部へエスケープしているか」といった問いにそのまま答えられるため、後続の各最適化パスの土台になります。

最適化パイプライン

デフォルト設定では、以下のパスを組み合わせて実行します(一部のパスは異なる段階で複数回実行されます)。

  • SimplifyCFG / DeadCodeElim:制御フローグラフの簡約化、到達不能ブランチやデッドコードの除去
  • SsaPromotion:ローカル変数の SSA 値への昇格(LLVM の mem2reg に相当)
  • InlineMethods:コードサイズの見積もりに基づくメソッドのインライン展開。メソッド境界を越えた最適化の文脈を露出させる
  • Devirtualize:レシーバの具体的な型を推論し、仮想メソッド・インターフェース・デリゲート呼び出しを直接呼び出しに書き換える
  • ScalarReplacement:エスケープしないオブジェクトに対するスカラー置換(SROA)。フィールドを通常のローカル変数に分解し、ヒープ割り当てを消去する
  • ValueNumbering:支配木ベースの大域的値番号付け(GVN / 共通部分式除去)。エイリアス解析を伴う冗長なメモリ読み取りの除去も含む
  • AssertionProp:分岐条件や参照外しから得られる制約を伝播し、後続の冗長な null チェックや境界チェックを除去する
  • SimplifyInsts:代数・ビット演算の簡約化(例:x * a + x * b を x * (a + b) に変換)
  • PresizeLists:コレクションの要素数を静的に推論し、List<T> などの初期容量を事前確保する
  • LoopStrengthReduction:ループ強度低減。境界内であることが証明できる場合に配列のインデックス計算を ref ポインタの歩進に書き換える
  • LoopVectorizer:自動ベクトル化。Vector256 → Vector128 → スカラーの端数処理という 3 段構成で SIMD ループを生成する
  • ExpandLinq:代表的な LINQ の呼び出しチェーンを認識し、単一パスの通常のループへ融合・展開する

個々のパス単体でできることは限られていますが、複数のパスが連鎖することで大きな効果が出ます。まずインライン展開によってファクトリメソッドやラッパーを剥がして具象型を露出させ、次に脱仮想化でインターフェース呼び出しや仮想呼び出しを直接呼び出しに変え、さらに 2 回目のインライン展開で呼び出し先の本体を取り込み、最後に不要になった一時オブジェクトの割り当てをスカラー置換で丸ごと消す、といった具合です。

具体例

1. 抽象化レイヤーとヒープ割り当ての消去

普段のコードでよく見かける、インターフェースと実装クラスにファクトリメソッドを挟んだ構成を見てみます。

interface IReader
{
    int Read();
}

sealed class ConstantReader : IReader
{
    public int Read() => 42;
}

static IReader Create() => new ConstantReader();

static int Run() => Create().Read();

Cilica で最適化した後、ILSpy でデコンパイルすると次の 1 行だけになります。

static int Run()
{
    return 42;
}

途中の経過を追うと、まず InlineMethods が Create() を Run() にインライン展開した時点で、Read() のレシーバがインターフェース型の IReader から具象型の new ConstantReader() として見えるようになります。続いて脱仮想化と 2 回目のインライン展開によって ConstantReader.Read() が定数 42 に展開され、IR は次の形になります。

r5 = newobj ConstantReader::.ctor() -> ConstantReader
ret 42

さらに ScalarReplacement の段階で、r5 のコンストラクタに副作用がなく、生成されたインスタンスのフィールド読み取りもエスケープも存在しないことが確認されるため、この newobj 自体が削除されます。

ret 42

元のコードにあったインターフェースディスパッチ、仮想呼び出し、ヒープ割り当てはすべてビルド時に消去されます。

2. ループの自動ベクトル化

単純な配列の集計だけでなく、ループ変数を使ったビット演算や浮動小数点演算、条件分岐が混ざったループもベクトル化できます。たとえば次のコードです。

static void Generate(float[] dest)
{
    for (int i = 0; i < dest.Length; i++)
    {
        float x = dest[i] + ((i & 7) / 3.5f) - 1f;
        dest[i] = x < 0f ? 0f : x;
    }
}

最適化後に ILSpy でデコンパイルした結果です(読みやすさのためローカル変数名のみ整理しています)。

static void Generate(float[] dest)
{
    int n = dest.Length;
    ref float p = ref MemoryMarshal.GetArrayDataReference(dest);
    var lanes = Vector256.Create(0, 1, 2, 3, 4, 5, 6, 7);
    var seven = Vector256.Create(7);
    var one = Vector256<float>.One;
    var zero = Vector256<float>.Zero;
    int i = 0;

    while (Vector256.IsHardwareAccelerated && n - i >= 8)
    {
        var x = Vector256.LoadUnsafe(ref p, (nuint)i)
              + Vector256.ConvertToSingle((Vector256.Create(i) + lanes) & seven) / 3.5f
              - one;
        Vector256.ConditionalSelect(Vector256.LessThan(x, zero), zero, x)
            .StoreUnsafe(ref p, (nuint)i);
        i += 8;
    }

    while (Vector128.IsHardwareAccelerated && n - i >= 4)
    {
        // 上記と同様(1 回につき 4 要素ずつ処理)
    }

    while (i < n)
    {
        float x = Unsafe.Add(ref p, i) + (i & 7) / 3.5f - 1f;
        Unsafe.Add(ref p, i) = x < 0f ? 0f : x;
        i++;
    }
}

生成されたコードは 3 段構成になっています。まず 1 イテレーションあたり 8 個の float を処理する Vector256 のメインループが走り、残りの要素(あるいは 128 ビット SIMD のみ対応の環境)を Vector128 ループが処理し、最後にベクトル幅に満たない端数を元のスカラーループで処理します。

いくつか細かいポイントがあります。

  • ループインデックスのベクトル化:ループ内の i & 7 は現在のインデックスに依存しています。ベクトル化の際、レーンごとのオフセット <0, 1, …, 7> をループ不変量としてループ外へくくり出し(LICM)、ループ内では (Create(i) + lanes) & 7 によって 8 レーン分のインデックス値を並列に計算しています。
  • 条件分岐のマスク選択への変換:三項演算子 x < 0f ? 0f : x は if-conversion によって分岐のない ConditionalSelect(LessThan(x, zero), zero, x) に変換されます。
  • 元のセマンティクスの厳密な維持:配列アクセスが境界内に収まることを静的に証明しきれないケースでは、ベクトルループに入る前に null チェックと境界チェックを一括で挿入します。条件を満たさない場合はそのまま元のスカラーループへフォールバックするため、例外がスローされるタイミングやそれまでに書き込まれた要素の状態も最適化前と完全に一致します。

手元の BenchmarkDotNet による測定では、このループの実行時間は 2,791 ns から 597 ns に短縮され、約 4.7 倍高速化されました。

3. LINQ の融合とループ展開

ホットパスで LINQ を使う際、特に効いてくるのが列挙子(Enumerator)の割り当てと各要素ごとのデリゲート呼び出しのオーバーヘッドです。次のクエリを見てみます。

public static float Aggregate(Item[] items)
    => items
        .Select(x => x.Weight)
        .Where(x => x > 0.0f && x < 1.0f)
        .Aggregate(0.0f, (result, x) => result + (x < 0.5f ? -x : x));

Cilica の ExpandLinq パスはクエリチェーン全体を認識し、データソース・Select・Where・末尾の Aggregate を配列に対する 1 回の走査へと融合した上で、インライン展開と組み合わせてラムダ式の中身をループ本体に直接展開します。

float acc = 0f;
foreach (Item item in items)   // 実際には ref ベースのポインタ走査が出力される
{
    float x = item.Weight;
    if (x > 0f && x < 1f)
        acc += x < 0.5f ? -x : x;
}
return acc;

要素数 4,096 の場合、実行時間は 30.7 μs から 12.2 μs へと約 2.5 倍高速化されます。さらに、展開された通常のループはそのまま後続の最適化パスを通るため、ループ本体が条件を満たせば自動ベクトル化まで一気に適用されます(たとえば後述のベンチマークにある CountLetters では、LINQ 展開とベクトル化の組み合わせで約 16 倍高速化されています)。

ベンチマーク

.NET 10 上で通常のビルドと Cilica 適用後を比較した BenchmarkDotNet のベンチマーク結果です(コードはtests/Benchmarksへ参照)。

benchmarks.png

ブラウザ上で動作確認:Cilica Lab

コンパイラを書いていると、各パスが実際にどう IR を書き換えたのかを常に追いたくなります。その確認を手軽にするため、Blazor WebAssembly を使ってブラウザ上で完結するオンラインツール Cilica Lab を作りました:https://hez2010.github.io/CilicaLab/

ブラウザ内で Roslyn、Cilica、ILSpy がそのまま動いています。C# のコードを貼り付けると、各パスの実行前後の SSA IR の diff や制御フローグラフを 1 ステップずつ確認できるほか、最適化前後の IL や ILSpy でデコンパイルした C# コードを並べて比較できます。

cilica-lab.png

プロジェクトへ導入

Cilica は MSBuild タスクのパッケージとして提供しており、Release ビルド時に自動で適用できます(.NET 10+ SDK が必要です)。

dotnet add package Cilica.OptimizerTask --prerelease

デフォルトでは、[Optimize] 属性を付与した型またはメソッドのみが最適化対象になります。

using Cilica.Attributes;

[Optimize]
public static class HotPaths
{
    // ...
}

アセンブリ全体を最適化対象にしたい場合は、.csproj に以下のプロパティを追加します。

<PropertyGroup>
  <CilicaAllMethods>true</CilicaAllMethods>
</PropertyGroup>

※ MSBuild タスクがデフォルトで処理するのは現在のプロジェクトが出力するメインアセンブリのみです。参照しているサードパーティ製ライブラリも含めて最適化したい場合は、パッケージに同梱されている Cilica.Cli コマンドラインツールを直接呼び出してください。

リポジトリ:https://github.com/hez2010/Cilica

.NET のコンパイル最適化に興味があれば、ぜひリポジトリに Star を付けたり、手元のプロジェクトのホットパスを Cilica Lab に貼り付けて試してみてください。コンパイルエラーや最適化漏れ、面白いケースなどがあれば Issue で共有してもらえると嬉しいです。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?