本記事の最初のバージョンは2024年8月に書かれました。
2025年12月に内容が不正確であった部分を全面的に書き直して再投稿しました。
はじめに
Crystal言語はコンパイルがとても遅いことで有名です。
ところで、Crystalのコンパイルで最も時間がかかっている場所はどこだと思いますか?
図:Crystal言語はLLVMを使います
Crystalコンパイラの段取り
Crystalのコンパイラの行程は次のとおりです。
- new_program - プログラムオブジェクトの作成
- parse - 「字句解析」および「構文解析」
- semantic - セマンティック解析
- codegen - オブジェクトファイルの生成まで
module Crystal
class Compiler
def compile(source : Source | Array(Source), output_filename : String) : Result
source = [source] unless source.is_a?(Array)
# 1 new_program
program = new_program(source)
# 2 parse
node = parse program, source
# 3 semantic
node = program.semantic node, cleanup: !no_cleanup?
# 4 codegen
units = codegen program, node, source, output_filename unless @no_codegen
# 5 cleanup
# ... omission ...
Result.new program, node
end
end
end
リンクはそのあとで標準のリンカによって行われます
コンパイル時間の統計を出すオプション
Crystalでは、コンパイル時間の統計をだしてくれるコマンドラインオプションがついています。
crystal build -s hoge.cr
しかし、この方法ではLLVMのネイティブ関数の所要時間は表示されないので今回の記事には不十分でした。
問題の本質を探るためにあえてPrintデバッグを利用して、コンパイル時間を調べてみましょう。
Codegenで呼ばれるネイティブLLVM関数
codegenの中では以下のLLVMのネイティブ関数を呼び出します。
-
LibLLVM.run_passes- LLVM-IR の最適化を行うパスを適用します
-
LibLLVM.target_machine_emit_to_file- オブジェクトファイル等の生成を行います
が呼び出されます。これらついてもPrintデバッグで実行時間を計測しました。
結果
Crystalのコンパイラをコンパイルした結果は次のようになります。
| Stage | Time (seconds) |
|---|---|
| new_program | 0.000388207 |
| parse | 0.000065000 |
| semantic | 12.552620028 |
| codegen | 355.245409133 |
| - LibLLVM.run_passes | 252.340241198 |
| - LibLLVM.target_machine_emit_to_file | 93.280652845 |
| cleanup | 0.000013180 |
| total | 367.798495548 |
これを棒グラフにしてみましょう。
このグラフは初版の記事のものを使用しており、最新のコンパイラとは少し違うかも知れません。
結果は予想通りだったでしょうか?
- 字句解析・構文解析には全く時間を使っていません!
- 型推定などのセマンティック解析にもほとんど時間を使っていません!
実は、ほとんどの部分が codegen それも
LibLLVM.run_passesLibLLVM.target_machine_emit_to_file
といったCrystalの外で行われるLLVMの外部関数呼び出しなのです!!
今回 Crystal コンパイラ自身を --release でビルドしたケースでは、コンパイル時間の大部分は LLVM の最適化・コード生成に費やされていました。
これは少し意外な結果だったのではないでしょうか?
Crystalコンパイラを高速化する方法
Crystalのコンパイラでは、Crystalで実装されている部分、つまり「字句解析」「構文解析」「セマンティック解析」は十分に高速です。つまり今以上に高速化する場合には、
- リリースビルドにおいても並列化を導入する
- LLVM本体を(Crystal向けに)高速化・最適化する
- CrystalがLLVMが実行しやすいLLVM-IRを生成するように改良する
といったハードコアな方法が必要だということがわかります。
しかし、これだと日常の役に立たないので次のような方法を紹介しておきます。
--release のかわりに -O3 を使う
Crystalコンパイラでは --release を指定すると -O3 及び --single-module を指定した状態になります。最適化をすこし犠牲にしてもいい場合は、-O3 オプションのみ指定することで、並列化が効くようになり、コンパイルが高速化するケースがあります。
ここから先は、少しポエム要素があります。
なぜCrystal言語で、インクリメンタルコンパイルや、共有ライブラリの作成が実現していないのか
Crystalの --release モードは --single-module を含む
Crystalはコードを分割してコンパイルし、その結果を再利用することが苦手です。特に --release ビルドでは --single-module が有効になり、1つの巨大なLLVMモジュールにまとめて最適化を行います。
例えばRustでは --release を指定してもクレートごとに分割コンパイルを行います。Rustでは -C lto=fat とするとCrystalと同じようなLLVM-IR全体を最適化する挙動になります。
弱いCrystalのキャッシュ利用機構
Crystalでも通常のビルドにおいては、型単位でLLVMビットコードファイル(.bc)とオブジェクトファイルをキャッシュしており、ビットコードに全く変更がない場合に限ってオブジェクトファイルを再利用できる機構が存在します。 1
これによって、コストが高いオブジェクトファイルの生成を省略することができる場合があります。
しかし、その場合であっても、字句解析や構文解析、セマンティック解析が省略できるわけではありません。あくまで .bc ファイルを生成してからの比較になります。そして、後述の理由により、ビットコードに全く変更がないケースは実はあまり多くないのです。
Crystalは、呼び出し元が型を決定する静的言語
なぜCrystalでは、パッケージを複数のLLVM-IRモジュールに分割してあらかじめコンパイルしておき、その結果を再利用できないのでしょうか。
その大きな理由は、Crystalでは、強い型推論やユニオン型を持ち、呼び出し元の文脈に応じてメソッドの具体的な型が変わるからです。
Crystalは、呼び出し元が型を決定 する珍しい静的言語であり、ダックタイピングが可能です。しかしその代償として、コンパイルのたびに型のシグネチャーを推定する必要が出てきます。
コンパイルごとに型IDが変更される
Crystalコンパイラは型を解決するために、すべてのクラスに番号を割り当てます。コンパイルのたびに登場する全ての型に、「番号」が割り当てられます。あるコンパイルではAというクラスに「10」という数字が割り当てられたとしましょう。コードにわずかな変更を施してコンパイルし直した場合「10」は別のクラスに割り当てられてしまいます。このようにして作られたオブジェクトファイルをリンクすると型の不整合が起きて失敗します。型に応じて正しい条件分岐が行われないからです。
他にも、複数のCrystal共有ライブラリを同時にロードする場合は、ランタイム関数が多重定義されるという問題があります。
このように今のCrystalでは、コードの一部を分割して事前にコンパイルしておき、後から再利用するということが難しい状況にあります。
しかし、これはCrystal言語の特性なのでしょうか?もう少し社会的な文脈から考えてみましょう。
Crystal言語コミュニティとリソース制約
CrystalはRubyのような簡潔な文法で、匹敵する素晴らしいパフォーマンスを発揮する言語として知られています。
しかし、Crystalの開発チームはとても小さい。そのため開発リソースに制約があります。
たとえば、AppleがCrystalを開発することを想像してみましょう。
Apple社のエンジニアが、clang/LLVMそのものに変更を加えて、コンパイルが高速化するような大きな改善を加えるかも知れません。
またSwiftのようにしっかりとABIを定義して、Crystal言語によく適合した中間言語やバイナリを作成するかもしれません。そうすることによって、Crystal独自の中間ファイルを作成し、その段階でモジュール同士を突き合わせることで、型の解決をはかり、そこからオブジェクトファイルを生成するような方式があるかもしれません。(LLVMの枠組みでそういうことが可能かよくわかりませんが)
しかし、私たちの手元にあるCrystalコンパイラはそうではありません。一枚岩の長大なLLVM-IRを吐き出し、全ての最適化をLLVMに委ねます。パッケージもGitHubから直接ソースコードをダウンロードする方法が主流です。
まだ少し改善の余地があるように感じられます。
コンパイルが遅く、実行速度が早いという特性は、Crystalの言語的な特性ではなく、Crystalの開発チームのリソース制約による側面もあると思います。つまり、将来的に大きなリソースが開発に投入されると改善される可能性も秘めていると思います。
最後に
CrystalのためのABI仕様や中間言語を設計するのはとても難しいことです。しかし、もし誰かが実現した場合、それは Crystal 2.0 や Crystal 3.0 となることでしょう。
そこまでいかずとも、出力されたLLVM-IRを複数のモジュールに分割する方法を考えたり、関数名やグローバル変数をマングリングするなどでも大きな進歩となることでしょう。
Crystalは他の言語と比較してライブラリの利用が盛んではありません。理由がはっきり把握しきれていないところもありますが、コードが再利用しやすい環境を整備していくうちに、コンパイル速度を改善する手法も発達していく可能性もあります。
この記事は以上です。最後までお読みいただきありがとうございました。
-
ただし、この機構のせいで、複数のCrystalのコンパイルを同時に行うと競合して失敗する。 ↩
