2年前にSdustをCrystalに移植した
2年ほど前にSdustというツールをCrystalに移植した。Sdustは、samtools, bwa, minimap2 などの開発で有名な Heng Li氏の作成したツールで、低複雑性のDNA配列を報告する。
Sdustのアルゴリズムは、3-mer の出現パターンを使って低複雑性領域を検出するものらしい。
ウィンドウ幅を指定し、DNA配列をスライディングしながら走査する。複雑度スコアの計算には 3-mer を利用し、ウィンドウが移動するたびに 3-mer に基づくスコアを更新する。同じ 3-mer 同士の組み合わせ数が一定の閾値を上回る領域を、低複雑性配列として報告する。
正直あまり仕組みを理解していないが、理解していなくてもコードを逐語訳をすることはできる。そのようにして2年前に移植を作成した。Sdustはビット演算を多用していたが、それらも全く同様にビット演算に逐語訳した。
Crystal移植は、Cより遅く、メモリを5倍使用する
がっかりしたのは、メモリの使用量が5倍大きかったことである。当時その理由がわからなかった。
私の主力のプログラミング言語はRubyだったので、Cの1.5倍の時間ですむなら、十分高速だと思った。
しかしメモリー使用量が多いのは残念で、Cとは異なりCrystalの標準ライブラリがいろいろアロケーションしているのか、GC言語だから仕方がないのだろうと思った。
それが正しくないことが今回わかった。
Claude にベンチマークと調査をさせた
しかし、2026年現在はコーディングエージェントが普及し、AIにコーディングやコンピュータの操作を依頼することが当たり前になった。そこで、AIなら原因を調査してくれるのではないかと考えて、プロジェクトにベンチマークディレクトリを作成して、AIに調査を依頼することにした。
実行ファイルとデータの準備
人間がやることは、AIが気持ちよくベンチマークを取れる環境を用意することだと思った。そこで入力に必要なデータをダウンロードするタスクと、比較するための実行ファイルをビルドするタスクを積んだMakefileを作成した。
というと聞こえはいいが、実際には、これもAIに指示して書いてもらった。
Claude が実際にやったこと
Codexも試したが、Codexはこちらの意図を深く理解して、ベンチマークのプランを立て、原因を調査することは必ずしも得意ではなかった。コーディングは大得意なのだが。そこで、お金はかかるが、今回はClaudeにやってもらった。
それにしても、AIは機関銃のようなスピードでシェルスクリプトを作成して実行する。
人間の専門家が、過去の経験をもとに、ちまちまと手打ちで作成するワンライナーなど不要と言わんばかりである。結果を可視化するPythonスクリプトなども一瞬で生成する。
これでは、生半可な知識の人間では、とても太刀打ちできないなと思った。しかし、やっていること自体は別に普通だった。
- 両方のファイルを実行して、出力に違いがないか、diff や md5 で確認する。
- hyperfine を使ってベンチマークを行う。
- ピークRSSを測定する。
- GC.stats を使って、ガベージコレクションの挙動を観察する。
- perf を使う。
私にはちょっと難しくても、経験豊富なプログラマであれば、このぐらいすぐにできるのかもしれない。それどころか、今回のような小さなコードなら経験豊富なプログラマはベンチマークしなくても原因を突き止めてしまうかもしれないと思う。
しかしClaudeはこういった解析を行うシェルスクリプトをサクッと1分で書き上げるので、人間の経験や勘がどれだけ優れていても、なかなか厳しいなあという気持ちにはなった。
ただし出力された結果をちゃんと理解するかどうかは人間のやる気と学習意欲によると思う。
またAIがベンチマークしやすいデータと環境を整えられるかも人間の責任だと思う。
原因の特定
ベンチマークの結果すぐに判明したのは、メモリ使用量が多いのは、単に読み込んだ配列を一度 IO::Memory にコピーしていたからだった。GC言語だから仕方がないとか、Crystalの標準ライブラリが悪いとか、全く関係がないことだった。Claudeは、アロケーションされたメモリの量と、配列のコンティグのサイズから推測される数値が同じことを、原因推定の根拠に挙げていた。賢すぎると思う。
そこで、なるべくストリーミング処理するようにお願いしたところ、元のC言語のSdust実装よりもメモリ使用量が少なくなってしまった。
ストリーミング処理
従来の実装は、FASTAの各コンティグをいったん全部 IO::Memory に読み込み、塩基を内部表現に変換した正規化済みの配列を作っていた。そこで、AIはこれを小さな状態機械のように変更した。
-
startで処理を開始する。 -
feedで配列の断片を流し込む。 -
finishで最後の結果を取り出す。
FASTA reader から得られる配列行を、そのまま feed する。これにより、コンティグ全体を保持しなくても処理できるようになった。
この変更により、Crystal版SdustのピークRSSは大きく下がった。以前は21番染色体ベンチマークでC版より3倍メモリを使っていたが、ストリーミング化後はC版より少ないメモリで動くようになった。
(アルゴリズムが違うんだから、C版より少ないメモリって当たり前じゃないか。タイトル詐欺だ、と思った人はSdustの作者の Heng Li が地球上で最高のプログラマの一人であることに留意してほしい。彼がsdustを本気でチューニングしてない可能性が高いとはいえ…)
速さについて
さらに、一部の関数にアノテーションで
@[AlwaysInline]
を指定することで顕著なスピードアップが見られた。
これまでLLVMアノテーションを重要だと考えていなかったけど、単純な数値計算プログラムでは測定可能な効果があることを知った。
塩基を内部表現に変換する処理は、各塩基に対して何千万回も呼ばれる。こういう場所では、読みやすい case 式より、256要素のテーブルを使った変換の方が速い。
最終的に、Crystal版のSdustはC版のSdustよりも、4%ほど遅いという状況になった。
この遅さの原因を調べたところ、イテレータや整数演算のオーバーフローチェックなど、Crystalらしい安全な処理のコストが示された。Crystalらしくないunsafeな記法を足しつつ、while ループにすると、さらに2%ほど改善するのだが、これはやらない方がいいと思った。
なお、並列実行は、コンティグ単位でworkerに仕事を渡す単純な実装になっている。そのため、各コンティグをいったん IO::Memory に読み込むためメモリー使用量は多い。(C版にはもともと並列実行は実装されていない)
Crystal言語の使い所
Crystalは、現実の世界では、主に高速な通信分野に採用されている。
なるべくIOのような構造を作って、ストリーミング処理に寄せるのが一つのパターンなのかもしれない。
おわりに
この記事は、まとまりがないけれども、割と忙しいため、ここで言語化しないと永久に失われる記憶になりそうな気がするので、理解が固まらないうちに書き上げた。
Crystal では Cの数倍のメモリーを使用するのは仕方がないと思っていたが、決してそんなことはなく単に自分のコードの書き方が悪いだけだった。また、libgc を勉強してログを記録して読めるようになれば、もうちょっと理解が進むと思った。
RubyとCrystalは同じように書くことができるけれども、実際にCrystalの性能を最大限活用すると、Rubyとは異なる書き方をする必要があることを最近感じている。けれども、そのような書き方をするとき、確実に失われるものが存在する。
そのため、Crystalを書いていると、かえってRubyのような動的な言語の可能性を深く感じることもある。
この記事は以上です。お読みいただきありがとうございました。