Clojure系の言語を作っている jank が、自前の IR(Intermediate Representation / 中間表現) を持つようになった、というのがこの記事の主題です。
IRというのは、ざっくり言うと「ソースコードをそのまま機械語にする前に、一度コンパイラが扱いやすい形に変換したもの」です。
ちょっと難しく聞こえますが、要するに 翻訳の途中にある“整理された下書き” みたいなものだと思えばだいぶイメージしやすいです。
作者の Jeaye Wilkerson 氏は、これまで jank の最適化の多くを LLVM に任せていたけれど、LLVM IR は低レベルすぎて Clojure の本質を表しきれない、と説明しています。
ここ、かなり重要です。Clojure には var、transient、persistent data structures、lazy sequences など、独特の仕組みがたくさんあります。でも LLVM は CPU にかなり近い世界の表現なので、そういう言語固有の意味を見抜いて「ここは削れる」「ここはまとめられる」と判断するのが苦手なんですね。
個人的には、ここはすごく筋のいい判断だと思います。
「汎用の最適化器に全部おまかせ」ではなく、「言語の性格に合わせた形で途中表現を設計し直す」 方向は、速くしたい言語処理系ではかなり強いからです。
記事では IR のメリットを大きく3つ挙げています。
複数のCPU向けに変換しやすい
x86_64 や arm64 など、最後に落とす先を変えやすくなる。
最適化しやすい
SSA形式みたいに、変数への代入が1回だけになる表現だと、値の追跡がしやすくなる。
言語の意味に合わせて設計できる
汎用IRではなく、jank専用に作れば、jankの強みをそのまま最適化の材料にできる。
この「言語専用にする」というのが今回の肝です。
一般論としては汎用性が高い設計のほうが再利用しやすいですが、速さを狙うなら、わざわざ一般化しないほうが強い ことはよくあります。jank はまさにその道を選んだわけです。
記事では、次のようなシンプルな関数を例にしています。
(defn greet [name]
(if (= "jeaye" name)
(println "Are you me?!")
(println (str "Hello, " name "!"))))
これを jank の IR にすると、関数名、lift された var、定数、ブロック、命令列がはっきり分かれた形になります。
ここでのポイントは、IR が Clojureの「意味」に近い単位 で表されていることです。
たとえば:
var-deref は「var の参照を取り出す」dynamic-call は「動的に関数を呼ぶ」branch は条件分岐ret は returnみたいに、Clojureっぽい動作がちゃんと IR に出てきます。
これが LLVM IR だと、もっと機械寄りの、抽象度の低い表現になってしまいます。そこでは「これは println の呼び出しだ」といった文脈が見えにくい。最適化のしやすさが全然違う、というわけです。
ここは専門用語ですが、かなり大事なので簡単に説明します。
SSA(Single Static Assignment) は、1つの変数に1回しか代入しない 形式です。
同じ名前に何度も値を入れ直さないので、「この値はどこから来たのか」を追いやすくなります。
これはコンパイラにとってかなりありがたいです。
値の流れが明快になるので、不要な計算の削除や、同じ値の再利用などがやりやすくなります。
CFG(Control Flow Graph) は、プログラムの流れを ブロックごとにグラフとして表したもの です。
1つの基本ブロックの最後には、branch や jump、ret のような終端命令が1つだけある、という構造になっています。
要するに、コードを「上から順に読む文章」ではなく、分岐や合流が見える地図 として扱えるようにする仕組みです。
これも最適化にはめちゃくちゃ便利です。
記事では、IR から生成された C++ の例も載っています。
見てわかるのは、IR の変数名と C++ 側の変数名が対応していて、var-deref が ->deref() になり、dynamic_call が jank::runtime::dynamic_call になるなど、かなり素直に変換されていることです。
これ、地味だけどすごく大事です。
なぜなら、IR と生成コードの対応が素直だと、デバッグもしやすいし、あとから最適化パスを挟むのもやりやすい からです。コンパイラ開発では、こういう「見通しのよさ」が最終的に大きな差になります。
面白いのは、この記事の時点では まだ IR 上の最適化パスは走っていない ことです。
それでも作者は、まずは「IR パイプラインを main にマージする」ことを優先した、と書いています。
これはかなり現実的な判断だと思います。
コンパイラ開発って、理想を追いすぎるといつまでも完成しません。まず土台を入れて、そこからベンチマークを1つずつ潰していくほうが、結局は前に進みます。
作者は今後、ベンチマークを1つ選んで、それを速くするための最適化を積み上げる という方針を取るようです。
このやり方はかなりわかりやすいですし、何より成果が見えやすい。私は好きです。
最初のベンチマークは、あの定番の 再帰的 fibonacci です。
(defn fibonacci [n]
(if (<= n 1)
n
(+ (fibonacci (- n 1))
(fibonacci (- n 2)))))
fibonacci は「お約束すぎる」ベンチマークに見えるかもしれません。
でも作者は、これが単純な見せ物ではなく、いくつもの重要な要素を含んでいると説明しています。
数値演算と比較
多くのプログラムは数値を扱うので、ここが遅いと困る。
再帰
Lisp 系では再帰がよく出てくる。効率よく扱えるかは重要。
ガベージ生成とGC
ゴミをたくさん出すと回収コストが増える。できるだけ無駄を減らしたい。
ランタイムの邪魔を減らすこと
計算したいだけなのに、プロファイラに余計なものがたくさん映るのは困る。
この4点、かなり本質的だと思います。
ベンチマークというと「単なる数字遊び」に見えがちですが、実際には 言語実装の弱点を炙り出すレンズ なんですよね。
記事では、まず JVM 版 Clojure を基準にしています。
環境は、5年前の x86_64 デスクトップ、AMD Ryzen Threadripper 2950X、NixOS、OpenJDK 21。
fibonacci 35 はおよそ 200ms とのこと。
ここで面白いのが、作者が当初 lein repl で計測したら 2800ms 近く出てしまった、という話です。
これは lein repl が JVM の最適化に影響するらしく、ベンチマーク環境の違いがものすごく大きいことを示しています。
この手の話は本当に油断できません。
ベンチマークはコードだけでなく、起動方法や実行環境でも大きく変わる。だからこそ、比較条件を揃えるのが大事なんですね。
この記事の段階では、まだ「IRを作った」段階で、肝心の最適化パスはこれからです。
ただ、作者の狙いはかなり明確です。
この進め方は地味ですが、かなり強いです。
一気に全部を賢くしようとするより、測れるものを1個ずつ改善する ほうが、コンパイラ開発では成功しやすいと思います。
私はこの一手、かなり好きです。
「jank が速くなるぞ」という話以上に、**“言語の意味に近い層で最適化できるようにする”** という設計思想が面白い。
LLVM はすごいですが、万能ではありません。
特に動的言語や Lisp 系のように、実行時の意味が重要な言語では、低レベルに落としすぎると見えるはずのものが見えなくなります。
だからこそ、jank のように 自分たちの言語にぴったり合う中間表現を持つ のは、かなり本気の戦略だと感じます。
もちろん、IRを自前で持つと開発コストは上がります。
互換性も安定性も、汎用IRを使う場合より大変になるでしょう。けれど、その代わりに得られる最適化の自由度は大きい。この記事は、そのトレードオフをちゃんと受け入れて前に進んでいる感じがして、読んでいて気持ちよかったです。
jank は、Clojure風の言語として速さを本気で狙うために、独自の IR を導入しました。
LLVM に任せきりにせず、Clojure の意味に近い形でプログラムを表現することで、これまで見えなかった最適化のチャンスを掘り起こそうとしているわけです。
まだ最適化はこれからですが、土台はかなり整ってきた印象です。
recursive fibonacci を皮切りに、jank が JVM 版 Clojure にどこまで迫るのか、続きが楽しみな展開だと思います。