この記事はプログラミング言語に備わっている例外機構を使うべきでないという趣旨の話ではないです。処理時間を測って他の言語はどうだろう?と言うのを調べてみたかっただけです。
要約
プログラミング言語における例外機構について、今回調べた言語においては…
- try-catchを書く分にはオーバーヘッドはさほどなさそう
- 例外が起きるとコストがかかるのは大半の言語で事実といえそう
ただし、計測はサンプル数が少ないため信頼性は低いともいえます。今回の計測においての話であるため、鵜呑みにしないように。
なお、JITの影響や、大域脱出については未検証です。
背景
Javaにおいて、例外のコストはかかります。そして例外のコストがかかるのは例外をthrowした時にかかるようです。ということをまだ若いときに次のブログから学びました
最近例外の話題をよく耳にします。その中に定期的に「例外はコストがかかる」という人がいます。これ私はあまりピンときませんでした。この人はどういう立場でどのあたりで「コストがかかる」と言っているのか?
そもそもどの言語の話なんでしょうか?これは自分でも調べることができそうなので調べてみました。
計測
いくつかの言語で実装
例外の発生頻度で実行時間の差を測るベンチマークをGPTに書かせました。それを他の言語に書き換えてもらって実行できるように手直しして通してみました。
Rustのpanicを使った場合やElixierとかも書きたかったんですが、GPTを導けず心が折れました。他の言語がない理由はこれです。
そもそも例外機構がない言語もありますので、それに近い振る舞いをするもので代用しています。Cのsetjmp/longjmpとか、RustのResult型とか全く違いますからね。あとC++,D,Go,Rustあたりは詳しくないので、ちょっと至らない部分もあるかもしれません。
どうでもいいですが乱数っぽいものはXorShiftを独自実装させています。そこそこ再現性がほしかったので。(結局言語間で一致してないですけど、酷い偏りはないみたいだしいいんじゃないですかね)
ミリ秒までしか取れてない結果もあるので、それは手抜きです。GPTが悪いんです。言い分けばっかだな。
wandboxで試しました。いつもお世話になってます。
- C (setjmp) (GCC 12)
- C (setjmp) (GCC 12 -O2)
- C (setjmp) (Clang 15) ※ 16では動かなかったので。あと-O2をつけると期待した結果にならなかった
- C++ (throw) (GCC 12)
- C++ (throw) (GCC 12 -O2)
- C++ (throw) (Clang 15)
- C++ (throw) (Clang 15 -O2)
- C# (.NET 5)
- D (dmd 2)
- Go (return) (1.16)
- Go (panic) (1.16) ※panicはこういう用途で使うべきではない
- JavaScript(Node 16)
- Java (OpenJDK 15)
- PHP (8)
- Perl (eval block) (5.38)
- Perl (try-catch) (5.38)
- Python (CPython 3.10)
- Python (pypy 7.3) ※早すぎたのでPythonの100倍回してる
- Ruby (3.3)
- mruby (3.0)
- Rust (return)
計測結果
表の列の補足
- no-try-catch: try-catch無し(例外が起きない前提) try-catchなしでの処理時間。try-catchの有無のオーバーヘッド確認のための比較用
- 0%: 例外が0%の確率で起きる場合の処理時間(ms)と例外発生件数
- 1%: 例外が1%の確率で起きる場合の処理時間(ms)と例外発生件数
- 99%: 例外が99%の確率で起きる場合の処理時間(ms)と例外発生件数
- no-0% ratio: try-catchの有無での性能劣化がどれぐらいあったか。
no-try-catchの処理時間 / 0%の処理時間
で算出。 - 0%-99% ratio: 例外の発生頻度で性能劣化がどれぐらいあったか。
99%の処理時間 / 0 %の処理時間
で算出。
lang | no-try-catch | 0% | count | 1% | count | 99% | count | no-0% ratio | 0%-99% ratio |
---|---|---|---|---|---|---|---|---|---|
C (setjmp) (GCC 12) | 770.93 | 759.99 | 0 | 780.00 | 1000361 | 1171.99 | 98999886 | 0.99 | 1.54 |
C (setjmp) (GCC 12 -O2) | 354.38 | 408.00 | 0 | 424.00 | 1000361 | 704.00 | 98999886 | 1.15 | 1.73 |
C (setjmp) (Clang 15) | 791.80 | 764.00 | 0 | 776.00 | 1000361 | 1131.99 | 98999886 | 0.96 | 1.48 |
C++ (throw) (GCC 12) | 5.84 | 5.87 | 0 | 17.51 | 9990 | 1058.56 | 990056 | 1.01 | 180.25 |
C++ (throw) (GCC 12 -O2) | 1.66 | 1.70 | 0 | 9.66 | 9990 | 769.13 | 990056 | 1.02 | 453.16 |
C++ (throw) (Clang 15) | 5.63 | 5.58 | 0 | 18.47 | 9990 | 1213.75 | 990056 | 0.99 | 217.34 |
C++ (throw) (Clang 15 -O2) | 1.59 | 1.65 | 0 | 11.38 | 9990 | 959.46 | 990056 | 1.04 | 582.75 |
C# (.NET 5) | 7.00 | 7.00 | 0 | 99.00 | 9990 | 8931.00 | 990056 | 1.00 | 1275.86 |
D (dmd 2) | 6.00 | 4.00 | 0 | 47.00 | 9990 | 4163.00 | 990056 | 0.67 | 1040.75 |
Go (return) (1.16) | 235.62 | 196.62 | 0 | 207.22 | 1000361 | 210.34 | 98999886 | 0.83 | 1.07 |
Go (panic) (1.16) | 67.37 | 50.20 | 0 | 66.15 | 100077 | 1529.88 | 9899875 | 0.75 | 30.47 |
JavaScript(Node 16) | 10.32 | 8.21 | 0 | 41.75 | 10001 | 3361.13 | 989739 | 0.80 | 409.59 |
Java (OpenJDK 15) | 40.00 | 38.00 | 0 | 77.00 | 100077 | 3937.00 | 9899875 | 0.95 | 103.61 |
PHP (8) | 621.81 | 595.05 | 0 | 640.38 | 100198 | 3609.00 | 9899591 | 0.96 | 6.07 |
Perl (eval block) (5.38) | 415.51 | 420.17 | 0 | 425.75 | 10126 | 667.70 | 990030 | 1.01 | 1.59 |
Perl (try-catch) (5.38) | 393.29 | 474.62 | 0 | 482.05 | 10126 | 792.91 | 990030 | 1.21 | 1.67 |
Python (CPython 3.10) | 401.72 | 420.10 | 0 | 422.63 | 9990 | 595.90 | 990056 | 1.05 | 1.42 |
Python (pypy 7.3) | 373.38 | 371.59 | 0 | 387.83 | 1000361 | 909.33 | 98999886 | 1.00 | 2.45 |
Ruby (3.3) | 194.15 | 193.22 | 0 | 199.66 | 10042 | 687.16 | 989911 | 1.00 | 3.56 |
mruby (3.0) | 372.95 | 359.21 | 0 | 362.53 | 10042 | 757.66 | 989911 | 0.96 | 2.11 |
Rust (return) | 1168.00 | 1267.00 | 0 | 1262.00 | 1000361 | 1264.00 | 98999886 | 1.08 | 1.00 |
ベンチマークという割にはよくわからない環境を使うとか計測時のループ回数もバラバラだし計測自体も1回しかしてなくて、ベンチマークといえるのか?という感想はあるでしょう。本来は順番を変えながら数回以上実行し、結果の偏差が小さいことも確認すべきでしょう。
でも手元に環境作るの面倒なんで…やる気が出たらdockerで流すやつ書いてちゃんと試します…
考察
Go(return), Rust(return)についてはそもそも例外機構を使わない書き方ができて、その場合は頻度関係なく早そうです。
no-0% ratioの比率を見る限り、tryで囲むだけでは性能に悪影響はそこまで与えないようです。(D言語はちょっとサンプル数が小さすぎた)
0%-99% ratioは比率が高いものは、例外が起きるほど遅い=例外をthrowするとthrowしなかった場合と比べて時間がかかる、ということが分かります。顕著なのはC++,Java,C#,Dといった例外機構を備えておりコンパイルを要する言語たちです。例外が起きるとスタックフレームを拾ったり色々するから時間がかかるんでしょうか。
JavaScriptは例外機構を持っているLL言語の1つですが、例外が起きなければかなり早く、例外が起きると極端に遅くなることがratioの高さから伺えます。
他のLL系は例外の頻度が上がっても処理時間の差は1.5~6倍程度で収まっています。LL系言語はまぁ元々遅いから…(PHPはその中でも比較的早かったのでループを10倍多めに回しています)
Cのsetjmp/longjmpは例外とは違うんですが、longjmpすると時間がかかってしまうようです。これはちょっと意外でした。
感想
例外機構を持っている今回調べた言語においては、例外をthrowするとコストがかかりそうだ、ということがわかりました。
今回の計測ではC++では1回起きると約1マイクロ秒ぐらいのロスがある・・・という感じなんですが、そこまでシビアな環境に身を置いたことをないのでよくわからないです。とはいえ、今どきのマシンはナノ秒単位で処理できるので1マイクロ秒を例外に費やすのは大きいとも取れますが…
例外は制御フローにも使われます。例えばWebフレームワークでは例外が投げられると必ずキャッチして雑に500エラーを返して処理を打ち切るとかします。しかしそれでも頻発するような操作ではありません(集中するときはあるかもしれませんけど)。特にWebアプリケーションの文脈で言えば、ネットワークのオーバヘッドのほうがまだまだ大きく、処理時間が1ミリ秒程度増えるぐらい可愛いものともいえるはずです。
なお全く触れてないですが、例外ハンドリングがCPUの投機実行とかJITで最適化されたコードから外れるといった話や、そもそも例外のハンドリングがJITの最適化の邪魔をするといった話はあります。こういった別のパフォーマンス劣化の話はあると思いますが、今回はそこを考えないようにしました(時間が足りない)。
そもそも例外がある言語では標準ライブラリとかで平気で例外が混ざってきますから、無理に「例外はコストがかかる」と排斥しようとせんでもいいのでは…という感じです。C++のようにゼロオーバーヘッド原則というその機能を使わなければオーバヘッドは発生しないという強い思想があれば別ですが。
あと、大域脱出の手段としての例外のコストはまだ検証できていません。エラー値のバケツリレーとどっちがいいんでしょうか?これは気が向いたら調べます(多分数年後とか)