エフェクト解析による動的言語最適化 Part.1

こんにちは。ATL客員研究員の門脇です。普段は Julia Computing というボストンにある会社のソフトウェアエンジニアとして プログラミング言語Julia のコンパイラの開発に携わっています。

今回のブログ記事では、次のJuliaの最新版であるv1.8から追加される新しいコンパイラ機能 エフェクト解析 についてご紹介したいと思います。

エフェクト解析とは、メモリへの書き込みなどプログラムが持つ エフェクト(computational effects) を解析する技術のことで、 一般的にはエフェクトを解析することでリソースの安全な管理などを行うための発展的なコンパイルタイムチェックを行います。 そうしたエフェクトシステムおよび静的解析はいくつかの静的型付けの言語では既に実用化されていて、 例えば Haskell ではエフェクトシステムがライブラリレベルで実装されていたり、 Koka という実験的な言語ではエフェクトシステムが言語のコア機能として提供されています。1

一方でJuliaのような動的言語においてはエフェクト解析の利用はまだ一般的ではありません。 今回我々Juliaコンパイラ開発チームは、エフェクト解析をコンパイルタイムチェックのためではなく、 もっぱら定数畳み込み最適化のために利用することで、 Juliaのコンパイル時間およびランタイムパフォーマンスを同時に大幅に改善することに成功しました。2

おそらく実用言語の範囲では初の例だと思われるこのエフェクト解析を用いた動的言語の最適化手法について、 今回は以下の流れで2つの記事に分けて解説を行いたいと思います:

  • Part.1
    • まずJuliaの設計思想とコンパイラの基本的な原理を説明し、 Juliaコンパイラが直面しているトレードオフについて解説します。
    • 次にエフェクト解析を利用してそのトレードオフを改善するアイディアを簡単に説明します。
  • Part.2
    • 次回の記事では、まず具体的にどのようなエフェクトシステムが必要なのかを考察し、 その後今回実装したエフェクト解析のアルゴリズムを説明します。
    • 次にエフェクト解析に関してJuliaプログラマが利用可能なアノテーションを紹介します。
    • 最後にこのエフェクト解析のさらなる応用可能性について考察したいと思います。

今回の記事はPart.1で、Part.2は来月公開する予定です。

!!! warning 以下のご紹介するコードは、2022年06月25日時点でのnightlyバージョン Version 1.9.0-DEV.848 (2022-06-24) で実行しています。 今回取り上げているエフェクト解析およびコンパイラ最適化は現在も細かな修正が加えられており、 他のバージョンでは同様の実験結果が得られない場合があります。

Juliaコンパイラにおけるトレードオフ

JuliaはPythonやRubyなどと同じ動的言語に類されるプログラミング言語ですが、 同時にC言語やRustなどの静的言語と同じく強力なコンパイラを備えてもいます。

Juliaの実行プロセスにおいては、各関数の型付けは必ずしも定義時に決定される必要はなく、 その関数が実行時に呼ばれたタイミングでコンパイルが開始され最適化されます。 このJuliaの実行方式は一般に ジャストインタイムコンパイル(JIT – Just-In-Time compilation) と呼ばれますが、実はJuliaにおける"JIT"は、コンパイラ技術における一般的な"JIT"とはかなり異なった概念です。 つまりJulia処理系はプログラムの実行過程をプロファイリングした結果に基づいてプログラムを最適化しているの ではなく実行時直前に 行われる静的な解析によりプログラムを最適化しています。 その意味でJuliaプログラムは、コンパイルの開始が実行時に遅延されているという点を除けば、C言語やRustなどの静的型付け言語が行っている アヘッドオブタイムコンパイル(AoT – Ahead-of-Time compilation) と基本的に同じ仕組みでコンパイルされている言えます。3

Julia処理系がわざわざコンパイルの開始を実行時直前にまで遅らせている理由は、究極的にはプログラムの ジェネリクス(Polymorphism) を高めるためです。 つまり、Juliaプログラムの型はプログラムが書かれたときに決定されている必要がなく、 そのことによってプログラムが持ち得る限りのジェネリクスを獲得しているのです。

Juliaはこのようにして ダック・タイピング(duck-typing) されたプログラムを実行時直前にコンパイルすることで、 動的言語の持つ簡潔な書き味と、静的言語で得られるようなハイパフォーマンス、 そして他の言語では得難いほど高度なジェネリックプログラミングを達成しようとしているわけです。

抽象解釈

Juliaコンパイラが行う静的解析・最適化の中でも、とりわけ 抽象解釈 と呼ばれるステップは、 Juliaプログラムの型を解決し4、 その後のさまざまな最適化ステップのベースとなる情報を導出するため非常に重要です。

抽象解釈とは、プログラミング言語の近似的なセマンティクスを用いてプログラムを解析することにより、 そのプログラムが実行時に取りうる振る舞いを導出するための静的解析フレームワークです。 …と言っても分かりにくいので、ここでは 「プログラムを型レベルで仮想的に実行することで、そのプログラムが実行時に取りうる型を解析する技術」 くらいに理解してください。 「型レベル」5という実際のプログラム実行よりも抽象的なドメインでプログラム実行を考えることにより、 どのようなプログラムに対しても ——たとえ実際の実行では終了しない、あるいは異常終了してしまうプログラムでも—— 安全かつ解析の終了が保証された形でプログラムを解析することができるのです。

たとえば、以下のようなプログラムの実行を考えてみます。


このとき、kernel1(n, x) という関数呼び出しはJulia言語処理系によりコンパイルされ最適化されています。 @code_typed マクロを使って、Juliaコンパイラがどのような抽象解釈を行ったのかを確認してみましょう:


コンパイラが kernel1(n, x) という関数呼び出しにおける引数型情報 n::Int, x::Float64 を使って kernel1 の実行を「型レベル」で解析することで、kernel1 に含まれる変数や関数呼び出しの型を推論しています。 例えば、変数 outn == 1 のときは out = 0::Int という型を取りますが、 それ以外のときは (out = %5 + %7)::Float64 という別の型を取ります。 %5 = out::Union{Float64, Int64} という表示は、 Juliaコンパイラの抽象解釈が、 kernel1 の実行時に out が取りうるこれら2つの可能性を Union{Float64, Int64} という型で近似していることを示しています。

Juliaコンパイラは、このように抽象解釈により推論されたプログラムの型情報を用いて

  • 実行時に呼び出されうるメソッドを解決したり(dispatch devirtualization)
  • そのメソッドの中身を展開したり(インライン展開)
  • オブジェクトのアロケーションをスカラー値で置き換えたり(SROA – Scalar-Replacement-Of-Aggregates)

などなど各種の最適化を行っているのです。

さて、この抽象解釈という技術それ自体は別に真新しい技術ではなく、 1970年代後半に Patrick Cousot らによって基礎となる理論が提唱されて以降、 実にさまざまな応用が試みられてきた古典的な静的解析技術です。 その一方で、すでに完成され確立された技術かと言えるのかというとそんなことはなく、 その他多くのコンパイラ技術と同様に、抽象解釈も現実のプログラミング言語に対して実装するのはやはり難しく、 加えて解析の 精度とレイテンシのトレードオフ と向き合わなくてはなりません。 抽象解釈というジェネラルなフレームワークを現実のプログラミング言語に対してどのように上手く適用し実装するのか、 プログラミング言語の研究・開発の現場で今まさに試行錯誤され、発展し続けている技術と言えるでしょう。

言語機能のコアとして抽象解釈を利用しているJuliaにおいては、特に優れた抽象解釈の実装が求められます。 最先端の科学計算の要求水準を満たすランタイムパフォーマンスを達成しつつ、 インタラクティブな研究開発を支える動的言語としてのコンパイルタイムパフォーマンスを実現するべく、 Julia言語の開発者はこのトレードオフをさらに改善するための試行錯誤を日々試みています。

以降の章では、我々が日々格闘しているこのトレードオフについてもう少し詳しく掘り下げてみます。

関数境界を跨いだ抽象解釈

Juliaコンパイラにおける抽象解釈を理解する上でおそらく最も重要なのは、 それが 関数境界を跨いで(inter-proceduralに) 行われているということです。 例えば先ほどの @code_typed optimize=false kernel1(n, x) の出力を見ると、 sincos(x)::Tuple{Float64,Float64}(n@_5 = n@_5 - 1)::Int などの kernel1 内で呼び出されている他の関数の返り値の型も推論されているのが確認できます。 このことは、Juliaコンパイラは kernel1(n::Int, x::Float64) という関数呼び出しをコンパイルする上で、 その関数呼び出しに必要な他の関数まで同時に解析し、 関数境界を跨いでコンパイルを行っているということを意味しています。 Juliaにおいては sincos- といった基本的な算術関数もJulia自身で実装されており、 型付けが実行時直前に解決されるという意味で、 コンパイラからは sincos-kernel1 のようなユーザが定義した関数と本質的に同様な見え方をしています。 そのため kernel1(n::Int, x::Float64) という呼び出しを最適化する上で、 kernel1 を解析するだけではなく sincos-sincos- の内部で呼ばれている他の関数… というように再帰的に抽象解釈を走らせて一気にコンパイルしているのです。 そのため、例えば sincos の代わりに自前の mysincos 関数を定義しても同様の最適化を得ることができます:


こうした関数境界を跨いだ最適化を、コンパイラの文脈では IPO – Inter-Procedural Optimization と呼びます。 一般に、1つの関数に対するローカルな最適化に比較して、IPOを適切に実装することは容易ではありません。 実行され得るコールグラフ全体を最適化しようとするIPOでは、 ランタイムのパフォーマンス(最適化の精度)とコンパイルタイムのパフォーマンス(最適化のレイテンシ) のトレードオフに直面しやすいからです。6

Julia設計思想においては特に優れたIPOの実装が重要です。 なぜならば、コンパイルのエントリポイントとなる関数呼び出しが受け取った引数の情報を、 inter-proceduralな抽象解釈によりサブルーチンの関数へと次々と伝播させ、 実行され得るコールグラフ(call graph)全体を最適化することで初めて、 型が明示的に制限されていないジェネリックなコードが組み合わさって構成されたプログラムを、 静的型付け言語と同等のレベルで最適化することができるようになるからです。

定数伝播による抽象解釈の精度向上

さて、ここまで抽象解釈をざっくりと「型レベルでプログラムを解析しプログラムの型付けを推論するもの」として説明してきましたが、 実は抽象解釈を行うときに何も「型レベル」でプログラムの実行を近似する必然性はありません。 抽象解釈が使用する「近似的なセマンティクス」のドメインとしては、 対象のプログラミング言語の実行に与えられているセマンティクスを健全に抽象化している限り、 どのようなドメインでも使うことができるのです。 一般に、ドメインを実際のセマンティクスに近づけてより具体的にすればするほど(抽象化の度合いを下げて抽象解釈の精度を高めるほど)、 解析の複雑性が高まり解析時間が肥大化するため、 抽象解釈にどの程度具体的なドメインを使うかという選択は抽象解釈の精度とレイテンシのトレードオフを大きく左右します。

実際のところ、Juliaコンパイラは「型レベル」よりも少し具体的なドメインを使って抽象解釈を行っています。 プログラム中の変数や関数呼び出しを、それらが実行時に取りうる型に加えて、さまざまな「付随情報」を与えて抽象化しているのです。 この「付随情報」にも色々な種類があるのですが、その中でも特に重要なのが定数情報です。 つまり、変数や関数呼び出しが実行時にある1つの定数値を取ると分かっている場合を、 Const と呼ばれる特殊な抽象状態で表現しています。 例えば先ほどの @code_typed optimize=false kernel1(n, x) の出力にも Const は現れており、 (out = 0)::Core.Const(0) という表示は while ループの前において変数 out は必ず定数値 0 を取るため、 「型レベル」の情報 Int で抽象化せず、その定数値そのもので表現していることを意味しています。 一方で while ループの内部においては out は必ずしも定数値 0 を取るとは限らないため、 %5 = out::Union{Float64, Int64} という表示においては「型レベル」の情報 Union{Int, Float64} に抽象化されているのです。

このように静的解析においてプログラムの状態を定数値を用いて表現することを、 定数畳み込み と呼びます。 プログラムを定数値を用いて単純化することにより、 本来はランタイムに行われる計算をコンパイル時に行ってしまったり、 到達されないコード領域の削除(デッドコード削除) を行ったりできるのです。 先ほど確認したように、Juliaコンパイラは抽象解釈時に定数値を伝播させることで、定数畳み込みを行っています。 この方式の面白いところは、Juliaコンパイラの抽象解釈が関数を跨いで行われるために、 その定数畳み込みも関数境界を跨いで行われるということです。 つまりJuliaコンパイラの抽象解釈は、 型レベルの情報だけでなく定数情報をもサブルーチンの関数へ伝播させることで非常に強力なIPOを実現しているのです。 この抽象解釈におけるinter-proceduralな定数情報の伝播を 定数伝播(constant-propagation) と呼びます。 ここでは kernel1′ を第2引数 x を定数値 1.0 で呼び出す kernel2 を定義して定数伝播の様子を確認してみましょう。 Cthulhu.jl というサードパーティパッケージを利用します:


kernel2 内における kernel1′ の呼び出しにおいて第2引数 xConst(1.0) で表現されており、 その情報がそのまま kernel1′ の抽象解釈に伝播されてる様子が確認できるかと思います。 ここで注目したいのが、 %6 = Main.mysincos(x)::Core.Const((0.8414709848078965, 0.5403023058681398))%7 = Main.sum(%6)::Core.Const(1.3817732906760363) において、 mysincos / sum 呼び出しの返り値も定数値で表現されているということです。 このことはJuliaコンパイラが x::Core.Const(1.0) という情報を mysincossum から構成されるコールグラフ全体に行き渡らせ、 本来ならば実行時に行われる sum(mysincos(x)) の計算をまるっと定数値で単純化できたことを意味しています。 定数伝播を行わない場合、sum(mysincos(x)) の計算が実行時に n 回だけ繰り返されてしまうことを考慮すると、 こうした定数伝播によるIPOがいかに強力か理解できるのではないかと思います。7

定数伝播とレイテンシ

一方で、定数情報は抽象解釈の精度を高める代わりに解析にかかる時間を肥大化させてしまいます。 Juliaコンパイラが行う抽象解釈のようなinter-proceduralな解析においては、 ある関数呼び出しに対して解析が終わった時にその情報をキャッシュし、 その後の解析で同じ呼び出しがあったときにそのキャッシュ情報を再利用することで解析時間を大幅に短縮することができますが、 定数情報を用いた解析結果は多くの場合で再利用することができないのです。 例えば kernel1 の呼び出しを kernel1(::Int, ::Float64) というふうに「型レベル」まで抽象化して解析した結果は、 kernel1(42, 2.0), kernel1(0, 0.0), kernel1(rand(Int)::Int, rand(Float64)::Float64) のどの呼び出しをコンパイルする時にも使うことができますが、 kernel1(::Int, ::Core.Const(1.0)) というふうに定数情報まで含めて解析した結果は、 上のどの呼び出しをコンパイルする時にも使えません。

以上の理由から、Juliaの抽象解釈においては、 闇雲に定数情報を伝播させるのはレイテンシ上の懸念から望ましくありません。 そのため、Juliaの抽象解釈の実装では、 まず関数呼び出しを「型レベル」情報のみで解析し結果をキャッシュした後に、 その解析結果から定数伝播による最適化の恩恵を得られそうかをヒューリスティック的に判断し、 制限的に定数伝播を行なっています。 そのヒューリスティックには、「『型レベル』での解析結果がインライン展開可能な程度にシンプルかどうか」 という判断基準が使われています8が、 多くの場合で期待通りに動作する一方、 定数伝播によるレイテンシへの影響を直接考慮している訳ではないため、 定数情報が多くのシンプルな関数呼び出しにおいて利用可能な場合に過剰に定数伝播が起こりレイテンシに重大な影響を与えてしまう潜在的な可能性があります。

ここでは先ほどの kernel2(n::Int) のコンパイル時に「型レベル」情報のみの解析と定数情報ありの解析それぞれにおいて、 どの程度のプログラムが解析されたかを確認してみましょう:


期待通り「型レベル」情報のみでの解析(non-constant absint)においては、 kernel1′(n, x) の抽象解釈が604個の関数呼び出しを解析しているのに対して、 kernel1′ の呼び出しを含む kernel2(n) の抽象解釈では kernel1′ の分だけ1つ多い605個の関数呼び出しを解析を行っています。 その一方で、定数情報ありでの解析(constant absint)では大きく違い生じているのが確認できると思います。 kernel2(n) の呼び出しで利用可能な x::Core.Const(1.0) という定数情報を kernel1′ のコールグラフ全体に行き渡らせるためには、 719-383 == 336 個もの関数呼び出しを定数情報ありで再解析しなくてはならないのです。

利用可能な定数情報が増えれば当然、定数伝播される呼び出しは増大し、その分だけ解析時間がかかってしまいます:

エフェクト解析のモチベーション

以上の説明で、定数がたくさん使われている関数をコンパイルする際には、 Juliaコンパイラの抽象解釈が潜在的に持っているレイテンシの問題が顕在化しやすいことが想像いただけたのではないかと思います。 普通にJuliaでプログラミングをしている時にはそのような状況はあまり発生しませんが、 特にシミュレーションなどの場面で特定のパラメータから自動生成されたプログラムをコンパイルする時などに実際に起こり得ます。 次のコード例はかなり人工的ですがそういったシチュエーションで発生するレイテンシの問題をよく理解することができます:


今回我々が開発したエフェクト解析のアイディアは、 Juliaコンパイラの抽象解釈が行うような関数境界を跨いだ定数伝播における精度とレイテンシのトレードオフを一気に改善するための秘策です —— エフェクト解析を用いて関数呼び出しが特定の条件を満たすと証明できる場合に、 定数伝播のプロセスを丸ごと実際のプログラム実行で置き換えてしまうことで、 定数伝播にかかるコストを削減しコンパイルタイムレイテンシを大幅に改善し、 さらには定数畳み込みの範囲を拡大しランタイムパフォーマンスまで向上させることができるのです。 このアイディアで重要なのは、Julia言語処理系が実行時直前に行うコンパイルタイムにおいては、 実際にJuliaプログラムを実行可能なランタイムシステムが利用可能である ということです。 つまり、エフェクト解析を用いて、静的な抽象解釈のプロセスに、 動的で具体的なプログラム実行を部分的に入れ込むことで、 抽象解釈の精度とレイテンシを改善しようというわけです。 我々はこのエフェクト解析に基づく実際のプログラム実行による抽象解釈の部分的な具体化を、 "abstract interpretation" と対比させ、 (partial) concrete evaluation と呼んでいます。

実際にこの concrete evaluation を有効化して、 simkernel() 呼び出しに対する抽象解釈のパフォーマンスを確認してみましょう:


以下のような劇的な改善が確認できますね:

  • 定数伝播された呼び出しの数が大幅に削減された(45503 calls217 calls)
  • 抽象解釈のパフォーマンスが劇的に改善された(12.409912 seconds37.783 ms)

また、より一般的なケースである kernel1′(n, x), kernel2(n), kernel3(n) においてもパフォーマンスが向上していることも確認できます:

Part.1 まとめ

この記事では、Juliaコンパイラの設計思想や基本的な動作原理を説明し、 特にそのコンパイラの核となっている抽象解釈における精度とレイテンシのトレードオフについて詳しく解説しました。 最後に紹介したように、エフェクト解析を利用した concrete evaluation により、 このトレードオフを劇的に改善することができるのですが、 いよいよ次のPart.2では、そのエフェクト解析の中身について詳しく解説したいと思います。 また、Juliaプログラムをさらに最適化する上で利用可能なエフェクトアノテーションについても紹介する予定なので、 ぜひ楽しみにお待ちください。

リンク


  1. ちなみに"Koka"という名前は、日本語の「効果」にちなんで名付けられているようです。↩︎
  2. この記事で紹介する、エフェクト解析によってJulia言語の最適化を行うというアイディアの基本的な部分は、 Juliaコンパイラの開発者の1人である Keno Fisher さんによるものであることを断っておきます。↩︎
  3. JavaScriptのV8やJVMのHotSpotのようなプロファイリングに基づくJIT (PGO – Profile-Guided-Optimization)は、 Julia言語処理系には現在(2022年6月)のところ実装されていません。 Julia言語処理系は2つのランタイムシステムを有しており、 プログラムをコンパイルして実行することも、インタープリターで実行することもどちらもできるため、 PGOを行うためのインフラストラクチャを既に備えています。 その一方で我々はJuliaのような言語処理系においては、 PGOによるプログラムの効率化の効果は限定的であると考えています。 PGOよりもJuliaコンパイラが現在行っているようなAoT的なアプローチの方が、 より強力でコストパフォーマンスに優れた最適化を実現できるという前提の下、 PGOの実装を急ぐよりも、現行のコンパイラの精度やパフォーマンスを高める方にプライオリティを置いています。↩︎
  4. このJuliaのプログラムの型を解決するプロセスを指して「型推論」と呼ぶこともありますが、 このJuliaコンパイラが行っている抽象解釈ベースの静的解析は、 Haskellなどの静的型付け言語で行われている所謂 「型推論」 とは全く違う技術であることに注意してください。 この記事においてはJuliaコンパイラが行う型解析を単に「抽象解釈」と呼びます。↩︎
  5. この記事においては、Juliaプログラムのオブジェクトが実行時に持ちうる IntString などのオブジェクト型を指して、「型(レベル)」という単語を使います。↩︎
  6. 例えば、Julia処理系も利用しているコンパイラバックエンドLLVM"Attributor" と呼ばれる、関数呼び出しに対するフラグに基づいたIPOのインフラストラクチャ を提供していますが、 残念ながら現在のところレイテンシがすこぶる悪く、 このIPOをJuliaコンパイラで有効化するとコンパイルが終わらなくなってしまいます… 逆にその意味では、Juliaが比較的新しい言語であるのにも関わらず、 既に実用言語としての実績を達成できている最大の理由の1つは、 そのコンパイラが実用的なIPO能力を保持しているからと言うこともできるかもしれません。↩︎
  7. 定数畳み込みを行わない場合、 LICM – Loop-Invariant-Code-Motion と呼ばれる最適化により、 sum(mysincos(x)) の計算をループの外に追いやることで計算量を削減することができます。 一方LICMを行ったとしても、最低1度は sum(mysincos(x)) の計算が実行時に発生するため、 この場合厳密には定数畳み込みの方が強力な最適化です。 また、LICMを行う場合でも、今回紹介するエフェクト解析に類する解析が必要となります。↩︎
  8. なぜいきなり「インライン展開可能かどうか("inlineability")」という、一見すると定数伝播と直接関係なさそうな条件が出てきたのでしょうか。 ここでは例として、関数呼び出し f(...) をコンパイル中に、f 内の関数呼び出し g(...) における定数伝播を考えているとします。 この時 g(...) を定数情報ありでコンパイルした結果は、上述の理由からキャッシュされません。 Julia言語処理系は実行中に関数呼び出しがあった場合、キャッシュされてるコンパイル結果があるかをまず確認し、 ある場合はそのままそのコンパイル結果を利用してその呼び出しを実行し、 ない場合は その呼び出しの引数の「型レベル」の情報を用いて 新たにコンパイルを開始します。 そのため、 g(...)f(...) 内にインライン展開されない場合、 g(...) を定数情報ありで解析しコンパイルした結果は無駄になってしまうのです。 一方、 g(...)f(...) 内にインライン展開される場合は、 g に伝播させた定数情報は f 内に埋め込まれるため無駄にはなりません。 またg に定数情報を伝播させることで g 内の無駄な条件分岐を削減しすることで、 g のインライン展開をさらに効率的にできる可能性も見込めます。 以上のロジックが、現在Julia言語処理系が定数伝播のヒューリスティックに "inlineability" を利用している理由です。↩︎