2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Crystalのコンパイルはなぜ遅いのか?

2
Last updated at Posted at 2025-12-08

本記事の最初のバージョンは2024年8月に書かれました。
2025年12月に内容が不正確であった部分を全面的に書き直して再投稿しました。

はじめに

Crystal言語はコンパイルがとても遅いことで有名です。

ところで、Crystalのコンパイルで最も時間がかかっている場所はどこだと思いますか?

図:Crystal言語はLLVMを使います

Crystalコンパイラの段取り

Crystalのコンパイラの行程は次のとおりです。

  1. new_program - プログラムオブジェクトの作成
  2. parse - 「字句解析」および「構文解析」
  3. semantic - セマンティック解析
  4. 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

これを棒グラフにしてみましょう。

image.png

このグラフは初版の記事のものを使用しており、最新のコンパイラとは少し違うかも知れません。

結果は予想通りだったでしょうか?

  • 字句解析・構文解析には全く時間を使っていません!
  • 型推定などのセマンティック解析にもほとんど時間を使っていません!

実は、ほとんどの部分が codegen それも

  • LibLLVM.run_passes
  • LibLLVM.target_machine_emit_to_file

といったCrystalの外で行われるLLVMの外部関数呼び出しなのです!!

今回 Crystal コンパイラ自身を --release でビルドしたケースでは、コンパイル時間の大部分は LLVM の最適化・コード生成に費やされていました。

これは少し意外な結果だったのではないでしょうか?

Crystalコンパイラを高速化する方法

Crystalのコンパイラでは、Crystalで実装されている部分、つまり「字句解析」「構文解析」「セマンティック解析」は十分に高速です。つまり今以上に高速化する場合には、

  1. リリースビルドにおいても並列化を導入する
  2. LLVM本体を(Crystal向けに)高速化・最適化する
  3. 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は他の言語と比較してライブラリの利用が盛んではありません。理由がはっきり把握しきれていないところもありますが、コードが再利用しやすい環境を整備していくうちに、コンパイル速度を改善する手法も発達していく可能性もあります。

この記事は以上です。最後までお読みいただきありがとうございました。

  1. ただし、この機構のせいで、複数のCrystalのコンパイルを同時に行うと競合して失敗する。

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?