関連tweet多過ぎるから全部貼らないんですが、まぁそれそうだな、と思って実験してみた結果。
tryは例外発生しなかったらコスト消える可能性があって、OptionやEitherやその他はtryと比較したらheapに乗る余計なオブジェクト出来るからほんの少しコストかかる可能性増える。から例外発生しない正常系パターンだけを考えたらむしろ値で異常系を表現して返す方が原理上遅く可能性がある、
— Kenji Yoshida (@xuwei_k) November 29, 2024
例外の生成が遅くて結構なコストがかかるのは、JVM詳しいおじさん達にとっては割と常識ですが(そうか?)、それはそれとして、実際にどの程度の頻度なら、例外生成と他のコストで釣り合うのか?みたいな実験です。
あくまで実験をしただけであって、強く言いたいこととしては、例外やその他いろいろなコストは知っておくに越したことはないですが、あくまでそのコスト(のみ)を1番に考えてというか、判断材料にしてプログラム全体の設計するのは99%以上の場合でナンセンスなのでやめましょう。
(Scalaコードわかる人は説明読み飛ばしてもらってコード見てもらった方が早いですが) さて、つまり上記のtweetで言いたかったことを、もう少し具体的に言い換えて、実験してみたパターンを説明すると
- とあるメソッドは、ごく稀に特定の引数で例外を投げる。呼び出し側でtry catchする。代わりに、正常系の場合はscala.Optionに包まれない
- もう1つのメソッドは、失敗した場合をちゃんと値として表現する。そのためにscala.Optionに必ず包まれる。例外は投げない
- さらに、scala.OptionはIntなどはboxingされるので、boxingされない独自IntOptionでも計測する
という感じです。 例外そのものの生成が遅いのは当たり前で、しかし 「例外はごく稀にしか発生しない。そのごく稀に発生するのを補うために毎回Optionで包むなどの細かいコストが積み重なったらどうなるか?」 という実験です。
とりあえず例外はStringからIntの変換として、他の条件をある程度同じにするため、標準のtoIntOptionなどは使わずに、例外が投げられる値は雑に決めうちでifで処理書きました。
合計2001個要素が入っているSeqがあり、2000個がStringからIntへの変換に成功する、1つだけ失敗する。という感じです。
ここまで書いていて気がついたかもしれませんが「JVMでtryそのものが本当にゼロコストなのか?」ということ自体の実験ではありません。
追記: その実験もした結果最後に書いた
Main.scala
package example import org.openjdk.jmh.annotations.Benchmark sealed abstract class IntOption { final def getOrElse(other: Int): Int = this match { case IntOption.Some(x) => x case _ => other } } object IntOption { final case class Some(value: Int) extends IntOption case object None extends IntOption } object Main { val values: Seq[String] = ((1 to 1000).map(_.toString) :+ "a") ++ (2001 to 3000).map(_.toString) def f1(s: String): Int = s.toInt def f2(s: String): Option[Int] = { if (s == "a") { None } else { Some(s.toInt) } } def f3(s: String): IntOption = { if (s == "a") { IntOption.None } else { IntOption.Some(s.toInt) } } } class Main { @Benchmark def x1(): Unit = { Main.values.map(n => try { Main.f1(n) } catch { case e: NumberFormatException => 0 } ).sum } @Benchmark def x2(): Unit = { Main.values.map(n => Main.f2(n).getOrElse(0) ).sum } @Benchmark def x3(): Unit = { Main.values.map(n => Main.f3(n).getOrElse(0) ).sum } }
build.sbt
scalaVersion := "3.6.2-RC2"
enablePlugins(JmhPlugin)
project/plugins.sbt
addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.7")
JDKは21
Jmh/run -i 5 -wi -f1 -t1
での実験結果が以下。
[info] Benchmark Mode Cnt Score Error Units [info] Main.x1 thrpt 5 41850.790 ± 1188.245 ops/s [info] Main.x2 thrpt 5 37851.568 ± 676.597 ops/s [info] Main.x3 thrpt 5 39729.147 ± 312.620 ops/s
「1秒間あたり何回実行できたか?」という単位なので、多いほどパフォーマンス良い、ということです。
例外投げるパターンが、Option使うけど例外投げないパターンと比較して、パフォーマンス1番よくなってしまいましたね。boxingの有無は思ったよりあまり差が出ませんでした。 (とはいえ多少差は出てる)(あとboxing以外にもgetOrElseの定義の違いもある)
つまり、あくまでこの例のコードでいうと、2000回に1回程度しか例外が発生しないならば、わざわざ値で丁寧に返すより、1回だけ例外投げてしまっても、その分の遅さを挽回するほど、Optionで包んでゴニョゴニョする処理などがチリも積もって山となり負けてしまう、ということです。
もちろん、最初にも書きましたが、この結果をもとに「じぁあ多分滅多に起きないし例外でいいや〜〜〜」という判断は危険というか99%無意味というか間違った判断になりかねないのでやめましょう。
これはまた、あくまで平均というか全体としては負けた、というだけで、例外が発生する時の処理単体はもちろん遅いので、たとえば例外が発生するのが、普通のwebサービス作っていてユーザーの入力のvalidationだとしたら、ユーザーが変な入力しただけで、原理上はその処理単体で見れば何倍も何十倍も(もっと?)負荷が増えることになります。 ユーザー側に悪気があってもなくても、そういうのが積み重なると意図せず負荷が増大する原因になりかねないので、そういう部分でもなんでも雑に例外投げるのではなく、丁寧なプログラミングを心がけたいですね(?)
本当に例外的な処理で、本当に一定頻度以上では発生しない、ということに対する判断はすごく難しいし、大事なことなので何度でも言いますが、そもそもそれを基準に例外かOptionか?などは決めるべきではないので。
追記:
同じような感じで、例外が1回も発生しないパターンでtryの有無の違いのみを試したら(つまりtry書くのがゼロコストなのか実験したら)、
— Kenji Yoshida (@xuwei_k) November 29, 2024
ちゃんと多めにやれば、たぶん0.1%も差が出ないっぽいので、ゼロコストになってそう・・・?
(測り方本当に合ってるのか謎だけど)
'"a"' の例外が出る値をSeqから外して全部成功する値に変えて、単に
values.map(n => Main.f1(n) ).sum
と
values.map(n => try { Main.f1(n) } catch { case e: NumberFormatException => 0 } ).sum
を比較したら、0.1%も差が出なかった、の意味です。