言語間の速度差について

全く同じアルゴリズムを似たようなスキルの人が書いても、使った言語によって速度差が出る。

たとえばガベージコレクタが一瞬実行を停止させてしまったり、配列の境界チェックが必ず行われたり、ネイティブコードにコンパイルされなかったり、そもそもの処理系が遅かったりなんてこともあったりなかったりする。

なので、例えばGCがあって(GCはかなり高速らしいが)境界チェックがあるGoでCの速度を追い抜こうとすると、かなり上手くハマった場合でしか達成できないだろう。ほんの少し遅いか、倍程度時間がかかるくらいの範囲になるのではないか。これはGoが悪いわけではなく、若干の速度を犠牲にしても安全なプログラムが書けるならその方がいいという判断であり、実際世の中のほとんどのアプリケーションの要求はGoの速度でもはや十分、むしろ速すぎるくらいだ。なのでこの選択が間違っているとは一切思えない。

それでも更なる速度向上が必要な分野というものは存在する。速ければ速いほど嬉しい分野、科学技術計算、機械学習、データベース、グラフィクス、ゲーム……。そういった場面では、異なる選択があり得る。手動でメモリを管理してガベージコレクタのオーバーヘッドをなくしたり、配列外参照をすることはないと信じて境界チェックをなくしたりする。柵を乗り越えて、自己責任で崖際を走っているわけだ。そのほうがインコースだからというだけの理由で。

さて、ゼロオーバーヘッドを謳っている言語として、C++やRustがある。これらの言語はプログラムを書くのをより便利にするような抽象化を提供するが、そのために不要な実行コストを持ち込まないように注意深く設計されている。これらの言語は、他の何で(例えばCで)同等の抽象化を行っても、それ以上無駄を削れないようなコードを生成することを目標としている。

What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better. -- Bjarne Stroustrup

もちろん、C++の継承に付随するvtableや、Rustのトレイトオブジェクトなんかは抽象化をしない場合と比べると若干のオーバーヘッドがあるが、それは抽象化そのものに付随するオーバーヘッドと解釈される。それ以上削るためには、そのレベルの抽象化(この場合、継承(のような機能、関数ポインタなどを含む)を使って実行時にコードを切り替える)を行うことを諦めざるを得ない。

CやFortranではあまりゼロコスト抽象化といった表現がされないが、おそらく「低級言語」と揶揄されたりする以上言及するべき抽象化が提供されていないと思われているのだろう(Cのポインタなんかは規格的にはアドレスのように振る舞うものならアドレスでなくてもいいので、見方によっては抽象化されているのだが、実際には誰も抽象化していない)。実際これらの言語は基本的にほぼオーバーヘッドがない。提供する抽象化機能もC++やRustに比べると少ないが。

私は、C、C++Fortran、Rustの全てを結構書いたことがあるし人のコードも読んだことがあるが、これらの言語間でどの言語が速いなどと速度を競うことに意味はないと思っている。ちょっとしか触ったことがないがZigやDも。これらの言語は概ね似たような速度が出て、書いた人のスキルに依存するところが非常に大きいからだ。同じ言語を使っていても書いた人によって倍以上の速度差が出ることは多いし、FortranやCで頑張って書かれたコードをC++で短く書き直したら倍速くなったみたいなことも何度もある。だが、C++を使う時に変なことをすると、例えばクラスのコンストラクタの裏に隠れている動的メモリ確保に気づかず適当に作っては消したりすると、バカみたいに遅くなることもある。C++やRustは最低限のコストに止めているとはいえ抽象化も提供するので、抽象化された便利な機能の裏側で実際にはオーバーヘッドがかかっていることに習熟しないと気づかない、ということがあり得る。だが一概にクラスを作ったから遅くなるということはない。そこを見分けるには経験と学習コストが必要だ。そういうことは言語自体の速度の問題だろうか? 人の理解度の問題だろうか? もしそれが言語の問題だというなら、最適化が難しくなる(というか、理解を要求する)代わりにコードを書くのが便利になる機能を追加することは高速な言語として欠陥か?

実際には半々だろう。学習コストは下げたほうが良い。速度は上げたほうが良い。コードは簡潔な方がいい。そのどれかが駄目な言語は、誰かしらにやり玉に上げられる。だがこれらの全てにはトレードオフがある。現実には全てを同時に達成するのは殆ど不可能なくらい難しい(そもそも、プログラミングというものそれ自体が多大な学習コストを要求しているんじゃないのか?)。そういうことを前提にすると、理想にまだ到達していない言語の設計の問題であり、目的に対し要求される最低限の学習コストを払えない人間の問題でもある(あるいは目標が高すぎる)。多くの言語はそれぞれ少しずつ異なる目標を持っている。簡潔に書けて学習コストが(想定内のことをしている限り)低い代わりに速度が遅い言語もあれば、そこそこ簡潔に書けて速度も悪くない、というバランスの取れた言語もある。好きなところを選べばいいだけだ。そして、何もかもを犠牲にして速度を出したいなら、残りのどちらを主に犠牲にするか、つまり全部自分で書くか(コードの簡潔さ)、抽象化の裏側を学んで少し楽をするか(学習コスト)、選べばいい。言語ごと選んでもいいし、言語の中のどの機能を使うかを選んでも良い。

ついでに、学習コストが高いことは常に悪かというとそれも難しい。参入してくるプログラマのその後のキャリアなどにも関わってくる(多くの問題解決方法を抱えた人のほうが潰しが効くが、実績を積む速度が遅くなりすぎると次の仕事に影響が出るかもしれない)。ここでも、たくさんの付随するパラメータがある。ゆっくりスキルアップできる環境なら、少し学習コストが重くても一度学べばその後楽ができるだろう。素早く完成させてしまわないといけないなら、多少コードが冗長でもとっとと書き始められる方がいいかもしれない。それでも、結局自分で書くなら裏側がわかるはずなので学習に少しコストを掛けたほうがいいだろうし、知らない問題解決方法を知った方がたいていは速く終わるのだが……。

もう不毛な話をするのはやめたらどうか。プログラマの存在を抜きにして一番速い言語なんてものは決められないし、学習コストや簡潔さのような他のパラメータ抜きにどのプロジェクトでも常に選ぶべき言語なんてものも存在しない。プロジェクトの規模や複雑さに関わりなく常に他の言語と比べて最高のパフォーマンスを発揮する言語は存在しないし、ライブラリの存在やコミュニティの広さに影響を受けない言語も存在しない。徐々にLLVMデファクトとしての地位を固めて来ている今、コンパイラのバックエンドの出来は少しずつ問題になりにくくなってきた。我々が考えるべきなのは、トレードオフの片方の端を無視してどれかの言語の方が常に良いと叫ぶことではない。

我々が考えるべきことは、自分の目的に沿ってそれぞれトレードオフのある複数の理想のうちどれを優先すべきかを考え、そしてその選択に最も適したコードを書き、可能なら言語にフィードバックを与えることだ。そして、経験を積んでどういうときにどういった目標が優先されるかを精度よく認識できるようにすることだろう。