インライン展開入門

こんにちは。ATL客員研究員の門脇です。普段はMITのJuliaLabという組織のResearch Programmerとしてプログラミング言語Juliaのコンパイラの開発に携わっています。

今回はJulia言語がサポートするインライン展開についてのご紹介したいと思います。「インライン展開」や「インライン最適化」といった言葉はよく聞きますが、その中身についてはあまり知らない方も多いかもしれません。この記事では、そもそもインライン展開とはなにかそしてなんのために行われるのかといったところから説明し、インライン展開によりパフォーマンスが改善される例や、Juliaプログラマが利用可能なインライン展開に関するアノテーション、そしてJuliaコンパイラのインラインコストモデルについて簡単に説明します。

Note: English version of this post is available at Inlining 101.

インライン展開とは

インライン展開とはプログラム最適化の一般的なテクニックの1つです。プログラム中に含まれる関数呼び出しについて、関数の中身を呼び出し元で展開することで、以下のようなパフォーマンス上の恩恵が得られる場合があります:

  1. 関数呼び出しにかかるオーバヘッドの削減
  2. インライン展開によりその他の最適化のチャンスが増える

一般的には最近の強力なCPU上では 1. の関数呼び出しにかかるコスト自体はかなり低く、多くの場合で他の計算コストと比較した場合に無視できるほど小さいことが多いそうです 1 。ただヘビーなループ内で呼び出されている関数呼び出しがインライン展開されていない場合、関数呼び出しのオーバーヘッドが累積し顕在化する場合もあります。また、呼び出される関数が非常にシンプルで小さくかつ高頻度で呼ばれる場合は、その関数の計算本体に比して関数呼び出しのコストの割合が大きくなるため、そうした関数をインライン展開することにより相対的なパフォーマンスゲインを得ることができるでしょう。

一方インライン展開の有無は多くの場合で後続のプログラムの最適化機会を大きく変えるため、 2. の効果はより広い文脈でプログラムのパフォーマンスに影響を与えます。一般に、プログラム解析は 関数呼び出しを跨ぐ解析 inter-procedural analysis よりも、ローカルなスコープに限った解析 intra-procedural analysis の方が容易です。インライン展開は、本来関数呼び出しを跨がなければ解析できないルーチン達をひとまとめにしてくれるため、プログラム最適化の視点からすれば、難しい最適化問題を易しい最適化問題に変換してくれているようなものなのです。また、こうした理由から、インライン展開は通常プログラム最適化のパイプラインの初めの方の段階で行われた方が効果的です。

具体例で見てみるインライン展開

現代的なコンパイラは様々な最適化を行うため、インライン展開によって具体的にどのような最適化が可能になるのか一概に語るのは難しいのですが、ここでは簡単なJuliaプログラムについて、インライン展開によってメモリアロケーションまで削減できてしまう例を見てみましょう。

こんなJuliaプログラムを考えます 2:

compute(n::Int) という呼び出しは、まず ab という2つの Point 構造体をアロケーションします。次にループの中で使用されている ::Point +ₚ ::Point 呼び出しは、 Point 型の引数を受け取り、新たな Point 構造体をアロケーションし返します。ループは引数 n の回数だけ繰り返されるので、このままでは n の値によってはかなり多くのアロケーションが発生してしまうことになります。

何とか Point のアロケーションを防ぐことはできないでしょうか?

+ₚ がインラインされない場合、 +ₚ の引数として渡されるために Point 型オブジェクトがメモリ上に存在する必要があるため、アロケーションを避けることはできません。

では、 +ₚcompute 内にインライン展開した場合はどうでしょう?コンパイラがインライン展開を行なった後の compute は以下のようなプログラムと同等になります:

よく目を凝らすと、上のプログラムは Point をスカラー値で置き換えた次のプログラムと同等であることがわかるかと思います:

なんとインライン展開を行うことで、元のプログラムのセマンティクスを保ったまま Point のアロケーションを完全に取り除くことができました。

ここでは説明のため元のプログラムに対してマニュアルでインライン展開しアロケーションを取り除きましたが、こうした処理をコンパイラは全て自動で行います。そのためプログラマは基本的にこうした最適化を自分で行う必要はありませんし、もしあるとしたらそうした必要はコンパイラの発展によって解決されていくべきです。

実際、Julia処理系はこうしたインライン展開とアロケーション最適化3を実装しており、関数呼び出し compute(100_000_000) に伴うアロケーションを完全に取り除くことができます。 @code_typed compute(100_000_000) の出力を確認することで以上で説明した最適化が確かに行われているのを確認できます:

どこにもPoint型のオブジェクトがなく、アロケーションが完全に取り除かれているのが確認できると思います。

インラインコストモデル

一方で、インライン展開は常にやればいいのかというと、そういうわけでもありません。過度なインライン展開は以下のような理由によって、逆にパフォーマンスを劣化させてしまう場合があるのです:

  • 実行時のコスト:
    • コンパイルされたネイティブコードのサイズが大きくなり、それ自体がメモリを逼迫してしまう
    • コードサイズが大きくなることで、メモリアクセスの局所参照性が悪くなりキャッシュミスが起こりやすくなってしまう
    • コードが複雑になることで、投機的実行による最適化が難しくなってしまう
  • コンパイル時のオーバヘッド: 中間プログラム表現(IR)のサイズが大きくなり複雑になってしまうことで後続の最適化パスにより多くの時間がかかってしまう

そのため、コンパイラはある関数をインライン展開するかしないかについて何らかのコストモデルを定義し、それに従ってインライン展開の是非を決定します。このコストモデルは多くの場合ヒューリスティック的に定義されます。ヒューリスティクスには次のような判断基準が用いられているようです:

  1. 関数がシンプルか否か
  2. 関数のコールサイトの数 (静的な関数呼び出し回数)
  3. 実行時に関数が呼ばれた回数 (動的な関数呼び出し回数)
  4. 引数がエスケープしないかどうか 4

いずれにせよ絶対的な正解がない問題であるので、機械学習によるコストモデルの使用など、他にも色々と可能性が考えられるテーマです。インライン展開のアイディア自体は非常にシンプルですが、実はかなり奥が深いのです。

Juliaプログラマが利用可能なインラインアノテーション

Juliaコンパイラも独自のインラインコストモデルを持っています。 Juliaのインラインコストモデルは上で説明した判断基準のうち 1. 関数がシンプルか否か に基づいてインライン展開の有無を決定します。

このコストモデルはとてもシンプルですが、多くの場合でリーズナブルな判断を下します。そのためJuliaプログラマは基本的にはインライン展開のことを考える必要はありません。

一方で、プログラムのパフォーマンスを追求するために、 code_typedCthulhu.jl を利用してプログラムを調べているときに、コンパイラが期待通りにインライン展開を行なってくれていない時があるかもしれません。

例えば、上記のプログラムを少しいじった以下のプログラムを考えてみましょう。インラインコストモデルを騙すため、わざと /ₚ の中で不自然なエラーチェックを行なっています(その理由は最後の章で説明しています):

比較のため、一旦この時点でのベンチマークを取ってみましょう:

1億回のループ計算が1秒以下で行われているのでパフォーマンスは既にすこぶる良いですが、まだ早くできるでしょうか。 Juliaプログラムを最適化する第一歩は @code_typed の出力を見てみることです:

この出力から以下の2点が確認できます:

  • %25 = invoke Main.:/ₚ(%24::Point, $(QuoteNode(Point(2.25, 4.75)))::Point)::Point/ₚ の関数呼び出しを表していて、つまり /ₚ がインライン展開されていないことを示しています。
  • また %24 = %new(Main.Point, %21, %23)::PointPoint 型オブジェクトの「アロケーション」5 が発生していることを意味しています。

この場合、以下のような理由からインライン展開を行なった方が有益だと考えられます:

  • ループの回数 n が大きい場合、 /ₚ の呼び出しにかかるコストが累積してしまう
  • /ₚ をインライン展開することによって、 %24 = %new(Main.Point, %21, %23)::Point/ₚ 内でのアロケーションを取り除けそう

こんなとき、どうすれば良いでしょうか? Juliaプログラマは以下の方法を用いてコンパイラにインライン展開を促すことができます:

  • 定義もとアノテーション definition-site annotation
  • 呼び出しもとアノテーション call-site annotation

定義もとアノテーション definition-site annotation

今回の /ₚ のように、インライン展開されていない関数を私たちが「所有」している場合、定義もとアノテーションが利用可能です。使い方は簡単で、 @inline あるいは @noinline マクロをインライン展開したい関数の定義にアノテーションするだけです。

今回は /ₚ をインライン展開したいので、 @inline マクロを利用します:

するとどうでしょう、 @inline アノテーションを施すことで、compute(100_000_000) がおよそ35%ほど高速化されました:

呼び出しもとアノテーション call-site annotation

次に、インライン展開したい関数を私たちが「所有」していない場合を考えてみましょう。例えば、今回の例でいうと Point+ₚ/ₚ が自分の管理していないライブラリから提供されており、私たちはそのライブラリのユーザであるような場合がそういった状況に該当します。アルゴリズムを全て一から組み上げるような場面はそうそうないので、むしろそういった状況の方が多いかもしれません。

そのような状況では、先ほどの定義もとアノテーションを利用するのは好ましくありません。 Julia では実行時に関数定義を上書きすることができる6ので不可能ではありませんが、あまり行儀が良くなくエラーの元になりやすい他、Julia においてはすでにコンパイル済みのキャッシュを無効化してしまう(俗に "method invalidation" と呼ばれる現象です)ため、余計なオーバヘッドが発生してしまいます。

そんな時は、次の次の安定版である Julia 1.8 で追加される予定の、呼び出しもとアノテーションが利用できます。呼び出しもとアノテーションには、定義もとアノテーションと同じ @inline/@noinline マクロを使いますが、マクロは関数定義に対してではなく関数呼び出しに対してアノテーションされます。呼び出しもとアノテーションを使うために関数定義を上書きする必要はないので、先程のモンキーパッチに付随する問題を気にする必要はありません。

この呼び出しもとアノテーションはJulia言語にもつい最近追加されたものですし、あまり他の言語では聞かない機能なので、まだ馴染みのない人が多いかもしれません。呼び出しもとアノテーションのドキュメントはここで読めますが、簡単に説明すると次のような設計で実装されています:

  • 呼び出しもとアノテーションは、適用されたブロック内の全ての関数呼び出しに対して影響する
  • 呼び出しもとアノテーションは、常に定義もとアノテーションよりも優先される
  • 呼び出しもとアノテーションがネストしている場合、最も内側のアノテーションが優先される

今回であれば、 compute 内で以下のように使ってみます:

呼び出しもとアノテーションを施すことで、定義もとアノテーションの時と同じ高速化が実現できました:

どんなときにアノテーションを考えるべきか

最後に、こうしたアノテーションがどのような場面で有効なのか、まとめてみたいと思います:

  • @inline を試してみる: ループの中で何度も呼び出されるシンプルな関数がインライン展開されていないとき
  • @noinline を試してみる: 滅多に呼び出されない複雑な関数がインライン展開されているとき
  • @inline アノテーションを無くしてみる: @inline だらけのコードベースのコンパイルがやけに遅いとき

とはいえ、大前提として、プログラマがこうしたアノテーションを与える必要がない方が理想的です。つまり、インライン展開されるべき関数がインライン展開されていない、あるいはインライン展開されるべきでない関数がインライン展開されている場合、それはつまりコンパイラのコストモデルに改善の余地があるということです。

そのため、もしそういった場面に遭遇したら積極的に JuliaLang/julia レポジトリのissue として挙げていただけるとJuliaコンパイラの改善に繋がるかもしれません。つい最近にも、僕の同僚の方がインライン展開の失敗を報告したことで、 Juliaコンパイラの改善に繋がった出来事がありました。

おまけ: Juliaコンパイラのインラインコストモデルを覗いてみる

Cthulhu.jl というパッケージを用いることで、 Juliaコンパイラのインラインコストモデルを覗くことができます。ここではなぜ /ₚ がインライン展開されなかったかを見てみましょう:

Juliaコンパイラが扱う中間表現としての /ₚ が表示されています。ここで、左から3列目の 0/2/40 といった数字の列が各SSAステートメントに対応するインラインコストを表しています。アノテーションが与えられていない場合、Juliaコンパイラはこれらのインラインコストの総和が 100 以下の関数呼び出しをインライン展開し、それ以外の場合はインライン展開を行いません。今回は総和が 124 になってしまっているので、 /ₚ はインライン展開されなかったわけですね。

もう少し細かくみてみましょう。よくみてみると、 goto #2 という2つの goto 文にそれぞれ 40 という高いインラインコストが割り当てられているのがわかると思います。この2つの goto #2 は元のプログラム中の @goto diverror に対応します。それらの goto はコントロールフローに対して逆方向に向かうジャンプ命令です。こうした逆方向ジャンプは通常ループの存在を意味しますが、ループは多くの場合で「複雑な」計算をするので高いインラインコストが割り当てられており、その結果として /ₚ はインライン展開されなかったというわけです。

Juliaコンパイラのインラインコストモデルのロジックは base/compiler/optimizer.jl に記述されており、各ビルトイン関数のコストは base/compiler/tfuncs.jl で定義されています。特に上で紹介した逆方向ジャンプ命令に関するヒューリスティックは具体的にはここに記述されているロジックに対応しています。

Cthulhu.jl を用いながら色々なコードに対してインラインコストモデルがどのように振る舞うかを調べてみると他にも面白い発見があるかもしれません。

まとめ

この記事では、インラインとは何かというところから、Juliaプログラマが利用可能なインラインアノテーション、そしてJuliaコンパイラのインラインコストモデルの簡単な中身をご紹介しました。

他にも、Juliaコンパイラの実装という観点ではインライン展開の可否は定数伝播と密接に関わっているなど、まだまだ興味深いトピックは尽きないのですが、今回は入門編ということでここで切り上げようと思います。以下に簡単なおさらいをまとめてこの記事を締めさせていただきます:

  • インライン展開は奥が深い: 多くのコンパイラはお手製のコストモデルでインライン展開の是非を決定している
  • インライン展開の挙動を変えたい時は、アノテーションが使える
    • 対象の関数を「所有」している場合は、定義もとアノテーションを使う
    • 対象の関数を「所有」していない場合、Julia v1.8以上であれば、呼び出しもとアノテーションを使うことができる
  • 期待通りにインライン展開がされていない場面に遭遇したら、積極的にissueを上げるとコンパイラの発展につながるかもしれないので是非

リンク


  1. 以下のページを参照:

    ↩︎

  2. このコードはこの LuaJIT コンパイラのドキュメントから着想を得て作成しました。↩︎
  3. こうして構造体をスカラー値で置き換えることでアロケーションを削減する最適化技術を SROA – Scalar Replacement of Aggregates と呼びます。↩︎
  4. JVMコンパイラの多くは優れたエスケープ解析を実装しています。その中にはより効果的なSROAを行うために、関数呼び出しの引数がエスケープするかしないかによってコストモデルを調整しているものもあるそうです: https://www.usenix.org/legacy/events/vee05/full_papers/p111-kotzmann.pdf↩︎
  5. @benchmark compute(100_000_000) がアロケーションを報告してないことに気がついたかもしれません (Memory estimate: 0 bytes, allocs estimate: 0.)。これは @benchmark@allocated が意図的にスタックアロケーションを報告していないためです。 Point はイミュータブルな形として定義されているため、Juliaコンパイラは Point 型のオブジェクトをヒープ領域ではなくスタック領域にアロケーションする最適化を行います。つまり、 Point 型オブジェクトに確保されたメモリ領域は関数呼び出しが終わってすぐに解放され、そのため GC が後からそのメモリ領域を解放する必要はありません。 /ₚ がインラインされない場合、 Point 型オブジェクトはメモリ上のどこかには存在しないといけないため、依然としてスタックメモリ領域にデータを読み書きする必要はありますが、そうした計算はヒープ領域へのアロケーションと比べた時に非常にチープです。以上が compute(100_000_000) が後で紹介するインライン展開による最適化がなくても、それなりに良好なパフォーマンスを示していた理由です。
    賢明な読者の方はここで、 Point をミュータブルなオブジェクトとして定義したらどうなるのか気になったかもしれません。現在のところJuliaはミュータブルなオブジェクトをスタック領域にアロケーションすることはできません。また、たとえ /ₚ がインラインされ、 SROA が Point オブジェクトを取り除ける可能性があったとしても、現在のところJuliaコンパイラのハイレベル最適化パス及びLLVMのいずれもうまくそうしたミュータブルなアロケーションを取り除くことはできません。この場合 Point オブジェクトのアロケーションはネストしているため、こうしたケースを最適化するためには、Juliaコンパイラのハイレベル最適化パイプラインのいずれかの段階で何らかのエイリアス解析が必要なのです。
    また現在我々はそうしたより発展的なメモリ最適化を目指すプロジェクトに取り組んでいます。もし興味のある方は以下の HackMD ドキュメントを見てみてください:

    またウィークリーでミーティングを行っていますので、もし参加してみたい方がいらっしゃいましたらお気軽に Julia Slack にてご連絡ください。↩︎

  6. いわゆるモンキーパッチと呼ばれるテクニックです。モンキーパッチの具体的な方法は言語やフレームワークに依存し様々です。 Juliaの場合、 Core.eval を利用することで、定義ずみのモジュールのコンテクストでコード実行を行うことができるため、それを用いてモンキーパッチすることができます。今回であれば、モジュール A/ₚ を定義しているとき、以下のようにすれば定義もとアノテーション @inline とともに新しい /ₚ の定義を上書きできます:

    ↩︎