scala.Optionやboxingのコストと例外のコストの比較

関連tweet多過ぎるから全部貼らないんですが、まぁそれそうだな、と思って実験してみた結果。

例外の生成が遅くて結構なコストがかかるのは、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
  • x1が例外投げる
  • x2がscala.Option
  • x3がboxingやunboxing避けるために独自のInt専用Option

「1秒間あたり何回実行できたか?」という単位なので、多いほどパフォーマンス良い、ということです。

例外投げるパターンが、Option使うけど例外投げないパターンと比較して、パフォーマンス1番よくなってしまいましたね。boxingの有無は思ったよりあまり差が出ませんでした。 (とはいえ多少差は出てる)(あとboxing以外にもgetOrElseの定義の違いもある)

つまり、あくまでこの例のコードでいうと、2000回に1回程度しか例外が発生しないならば、わざわざ値で丁寧に返すより、1回だけ例外投げてしまっても、その分の遅さを挽回するほど、Optionで包んでゴニョゴニョする処理などがチリも積もって山となり負けてしまう、ということです。

もちろん、最初にも書きましたが、この結果をもとに「じぁあ多分滅多に起きないし例外でいいや〜〜〜」という判断は危険というか99%無意味というか間違った判断になりかねないのでやめましょう。

これはまた、あくまで平均というか全体としては負けた、というだけで、例外が発生する時の処理単体はもちろん遅いので、たとえば例外が発生するのが、普通のwebサービス作っていてユーザーの入力のvalidationだとしたら、ユーザーが変な入力しただけで、原理上はその処理単体で見れば何倍も何十倍も(もっと?)負荷が増えることになります。 ユーザー側に悪気があってもなくても、そういうのが積み重なると意図せず負荷が増大する原因になりかねないので、そういう部分でもなんでも雑に例外投げるのではなく、丁寧なプログラミングを心がけたいですね(?)

本当に例外的な処理で、本当に一定頻度以上では発生しない、ということに対する判断はすごく難しいし、大事なことなので何度でも言いますが、そもそもそれを基準に例外かOptionか?などは決めるべきではないので。

追記:

'"a"' の例外が出る値をSeqから外して全部成功する値に変えて、単に

   values.map(n => 
      Main.f1(n)
    ).sum

    values.map(n => 
      try {
        Main.f1(n)
      } catch {
        case e: NumberFormatException =>
          0
      }
    ).sum

を比較したら、0.1%も差が出なかった、の意味です。