かたちづくり

つれづれに、だらだらと、おきらくに

F# の Set と HashSet のパフォーマンス

ハマったのでメモしておきます。

経験的には、要素の追加/削除などの処理は F# の Set よりも HashSet の方が速いです。F# の Set は binary tree だと思うので(ですよね?)これらの計算量は O(log n)、対して HashSet は O(1) なので、まあそうだよね、と思います。

しかし、実務のコードで HashSet がエラく遅くて F# の Set に置き換えたら高速になったケースがあったので、テストコードを書いてみました。

open System
open System.Collections.Generic
open System.Diagnostics

[<EntryPoint>]
let main argv =
  let size = 100000 (* 10万要素 *)

  (* F# の Set と HashSet のパフォーマンス比較。
     集合が空になるまで先頭要素の削除を繰り返す処理。*)

  (* ---- F# Set ---- *)
  do
    let watch = Stopwatch()
    watch.Start()
    let mutable collection = seq { 1 .. size } |> Set.ofSeq
    while not collection.IsEmpty do
      collection <- collection |> Set.remove (collection |> Seq.head)
    printfn "F# Set : elapsed = %A" watch.Elapsed

  (* ---- HashSet ---- *)
  do
    let watch = Stopwatch()
    watch.Start()
    let collection = HashSet<int>( seq { 1 .. size } )
    while collection.Count > 0 do
      collection |> Seq.head |> collection.Remove |> ignore
    printfn "HashSet : elapsed = %A" watch.Elapsed

  0

実行結果:

F# Set : elapsed = 00:00:00.2876429
HashSet : elapsed = 00:00:10.7875533

HashSet 激遅ですね。

どうも先頭要素を取り出す処理(collection |> Set.head の部分。LINQで書くなら collection.First())が遅いみたいです。とはいっても先頭要素の取得が必ず遅いわけではなく、遅くなってしまう条件があるようです。上記のプログラムでは取り出した先頭要素は毎回 Remove されていますから、毎回取り出される先頭要素が異なります。このような場合に先頭要素の取得に時間がかかるようです。

HashSet の要素の列挙ってどういう実装になっているんでしょう。ヘタレなのであんまり分かりませんが、確かにハッシュテーブルは要素の検索には向いていても列挙には向かなそうです。先頭要素が削除されてしまうと、ハッシュテーブルから先頭要素を探索する処理が必要になるから遅いのかな?

(…ここまで書いて気づきましたが、この記事ってたまたま比較対象に F# の Set を使っただけで、F# は殆ど関係ないですね。まあいいか。)