例外の効率: OCaml の例外は早い、は本当か

例外による再帰関数からの大域脱出は OCaml ではランタイムのペナルティはほとんどない、 という事になっている。 try with を書いてそれでもコードが読みやすければ使って構わない。 が、実際のところ、どうか。 -g を付けてコンパイルした場合、遅くなる。 さらに、 OCAMLRUNPARAM 環境変数に "b" が入っていると更に遅くなる。


しっかりした OCaml プログラムを開発したい場合はバックトレースは是非欲しい ところなので、 -g を付けて OCAMLRUNPARAM 環境変数に "b" を入れてプログラム を実行することは普通にある。だから、安易に例外を使うとパフォーマンスに影響する:

let gen_timed get minus f v =
  let t1 = get () in
  let res = f v  in
  let t2 = get () in
  res, minus t2 t1

let timed f v = gen_timed Unix.gettimeofday (-.) f v

let f1 x =
  match x with
  | 1 -> 1
  | 2 -> 2
  | 3 -> 3
  | _ -> 0

let f2 x =
  try
    match x with
    | 1 -> 1
    | 2 -> 2
    | 3 -> 3
    | _ -> raise Exit
  with
  | Exit -> 0

let loop f () =
  for i = 0 to 1073741823 do
    ignore (f i)
  done

let () =
  let _, sec = timed (loop f1) () in
  Format.eprintf "%f@." sec;
  let _, sec = timed (loop f2) () in
  Format.eprintf "%f@." sec


例えば上記のプログラムでは、 ocamlopt で -g 無しでコンパイルした場合:

2.507164
5.330632


と 2倍くらいなのだが、 -g 付きでコンパイルした場合は:

2.471575
21.626229


さらに OCAMLRUNPARAM=b した場合:

2.478992
30.855514


ということになり、 12倍近く遅くなる。


実際これをどう受け取るかはコンテキストによるところだ。 このベンチは例外を発生させて受け取る、この処理以外パターンマッチ一回やるだけ なので、この10倍近い比も最悪の場合の数字であって、実際のコードではこの比は どんどん小さくなるはずだ。 また、raise して try で受けるとバックトレースの処理 10億回に 28秒しか掛かっていない、結構早いじゃんと考えることもできる。


例えば、 一連の操作をやっていって、全ての操作が成功したら Some x を 返し、どこかで何かしらエラーが出たら None を返す、というコードの場合、 option モナドをチェーンして書く方法と、エラーが出たら例外で脱出する という場合の2つの方法があるが、次のコードのように 10回チェーンさせる場合 だと -g と OCAMLRUNPARAM=g を付けても両者はほとんど変わらない。 (ちなみに -g も OCAMLRUNPARAM=g もない場合は例外版が 2倍早くなる):

let (>>=) v f = match v with
  | None -> None
  | Some v -> f v

let g i = match i mod 2 with
  | 0 -> Some i
  | _ -> None

let f1 i = g i >>= g >>= g >>= g >>= g >>= g >>= g >>= g >>= g >>= g

let f2 i =
  try
    match i mod 2 with
    | 0 ->
        begin match i mod 2 with
        | 0 ->
            begin match i mod 2 with
            | 0 ->
                begin match i mod 2 with
                | 0 ->
                    begin match i mod 2 with
                    | 0 ->
                        begin match i mod 2 with
                        | 0 ->
                            begin match i mod 2 with
                            | 0 ->
                                begin match i mod 2 with
                                | 0 ->
                                    begin match i mod 2 with
                                    | 0 ->
                                        begin match i mod 2 with
                                        | 0 -> Some i
                                        | _ -> raise Exit
                                        end
                                    | _ -> raise Exit
                                    end
                                | _ -> raise Exit
                                end
                            | _ -> raise Exit
                            end
                        | _ -> raise Exit
                        end
                    | _ -> raise Exit
                    end
                | _ -> raise Exit
                end
            | _ -> raise Exit
            end
        | _ -> raise Exit
        end
    | _ -> raise Exit
  with
  | Exit -> None

let loop f () =
  for i = 1 to 1073741823 do
    ignore (f i)
  done

let () =
  let _, sec = timed (loop f1) () in
  Format.eprintf "%f@." sec;
  let _, sec = timed (loop f2) () in
  Format.eprintf "%f@." sec


え? match を 10回もネストさせないと?いやいや、これはただの例だ。 それぞれが例外を投げるような手続き型命令を10回実行する場合に、 try with で包む代わりに一つ一つを Either を返すようにして bind で チェーンすると流石に遅くなりますよという事である。 そういう事であれば普通に起こりうるだろう。


さてさて、ではどうすればいいのか。私はこうしたいと思っている:

  • ライブラリ関数のような誰かがどこかで再帰やループで何度も呼び出すかもしれない
    関数については安易に大域脱出しない。
  • アプリケーションコードで、呼び出される回数が読め、かつ十分少ない場合、
    例外で書くと読みやすくなる場合は例外で書く。
  • 一関数内部でのローカルに完結した脱出は気にしない


ちなみに、この、例外が遅いので大域脱出に気軽に使えない、 という問題を解決するため、バックトレースを生成しない速度の早い例外 を実験している人もいる: http://caml.inria.fr/mantis/view.php?id=5879 確かに、「安全な goto」として例外を使いたい場合はそのバックトレースは 別に興味がない。本当に例外的な事が起こった時だけトレースが欲しいわけだから フローコントロールの道具としての例外と、 非常事態のための例外は分けるべきなのかもしれない。