不同 F 資料流水線的比較

F# 中,有許多用於建立資料管道的選項,例如:ListSeqArray

從記憶體使用和效能角度來看,哪種資料管道更受歡迎?

為了回答這個問題,我們將使用不同的管道來比較效能和記憶體使用情況。

資料管道

為了衡量開銷,我們將使用每個處理專案的 cpu 成本低的資料管道:

let seqTest n =
  Seq.init (n + 1) id
  |> Seq.map    int64
  |> Seq.filter (fun v -> v % 2L = 0L)
  |> Seq.map    ((+) 1L)
  |> Seq.sum

我們將為所有替代方案建立等效的管道並進行比較。

我們將改變 n 的大小,但讓工作總數相同。

資料管道替代方案

我們將比較以下替代方案:

  1. 勢在必行的程式碼
  2. 陣列(非懶惰)
  3. 清單(非懶惰)
  4. LINQ(懶人拉流)
  5. Seq(懶人拉流)
  6. Nessos(懶惰拉/推流)
  7. PullStream(簡單的拉流)
  8. PushStream(簡單推送流)

雖然不是資料管道,但我們將與 Imperative 程式碼進行比較,因為它最接近於 CPU 執行程式碼的方式。這應該是計算結果的最快可能方式,允許我們衡量資料管道的效能開銷。

ArrayList 在每一步計算一個完整的 Array / List,所以我們期望記憶體開銷。

LINQSeq 都基於 IEnumerable<'T>,這是懶惰的拉流(pull 意味著消費者流正在從生產者流中提取資料)。因此,我們期望效能和記憶體使用情況相同。

Nessos 是一個支援推拉的高效能流庫(如 Java Stream)。

PullStream 和 PushStream 是 PullPush 流的簡單實現。

執行的效能結果:F#4.0 - .NET 4.6.1 - x64

StackOverflow 文件

條形顯示經過的時間,越低越好。所有測試的有用工作總量相同,因此結果具有可比性。這也意味著少數執行意味著更大的資料集。

像往常一樣測量一個看到有趣的結果。

  1. List 效能差與大資料集的其他替代方案進行比較。這可能是因為 GC 或快取區域性性差。
  2. Array 的表現好於預期。
  3. LINQSeq 表現更好,這是出乎意料的,因為兩者都是以 IEnumerable<'T> 為基礎的。但是,Seq 內部基於所有演算法的通用實現,而 LINQ 使用專門的演算法。
  4. PushPull 表現更好。這是預期的,因為推送資料管道具有較少的檢查
  5. 簡單的 Push 資料管道的效能與 Nessos 相當。但是,Nessos 支援拉動和平行。
  6. 對於小型資料流水線,Nessos 的效能可能會降低,因為流水線會設定開銷。
  7. 正如所料,Imperative 程式碼表現最佳

執行時的 GC 集合計數:F#4.0 - .NET 4.6.1 - x64

StackOverflow 文件

條形圖顯示測試期間的 GC 集合計數總數,越低越好。這是對資料管道建立的物件數量的度量。

像往常一樣測量一個看到有趣的結果。

  1. List 預計會產生比 Array 更多的物件,因為 List 本質上是一個單獨的節點連結串列。陣列是連續的記憶體區域。
  2. 看看潛在的數字,ListArray 強制推動 2 代收藏。這種收藏很貴。
  3. Seq 正在引發驚人數量的收藏品。在這方面,令人驚訝地甚至比 List 更糟糕。
  4. LINQNessosPushPull 在幾次執行中都沒有觸發任何集合。但是,物件被分配,因此 GC 最終將必須執行。
  5. 正如預期的那樣,由於 Imperative 程式碼沒有分配任何物件,因此沒有觸發 GC 集合。

結論

所有資料管道在所有測試用例中都執行相同數量的有用工作,但我們發現不同管道之間在效能和記憶體使用方面存在顯著差異。

此外,我們注意到資料管道的開銷因處理的資料大小而異。例如,對於小尺寸,Array 表現相當不錯。

應該記住,管道中每個步驟中執行的工作量非常小,以便測量開銷。在真實情況下,Seq 的開銷可能並不重要,因為實際工作更耗時。

更值得關注的是記憶體使用差異。GC 不是免費的,對於長時間執行的應用來說,保持 GC 壓力是有益的。

對於關注效能和記憶體使用情況的 F# 開發人員,建議檢視 Nessos Streams

如果你需要具有策略性位置的頂級效能 Imperative 程式碼值得考慮。

最後,談到效能時不要做出假設。測量和驗證。

完整原始碼:

module PushStream =
  type Receiver<'T> = 'T -> bool
  type Stream<'T>   = Receiver<'T> -> unit

  let inline filter (f : 'T -> bool) (s : Stream<'T>) : Stream<'T> =
    fun r -> s (fun v -> if f v then r v else true)

  let inline map (m : 'T -> 'U) (s : Stream<'T>) : Stream<'U> =
    fun r -> s (fun v -> r (m v))

  let inline range b e : Stream<int> =
    fun r ->
      let rec loop i = if i <= e && r i then loop (i + 1)
      loop b

  let inline sum (s : Stream<'T>) : 'T =
    let mutable state = LanguagePrimitives.GenericZero<'T>
    s (fun v -> state <- state + v; true)
    state

module PullStream =

  [<Struct>]
  [<NoComparison>]
  [<NoEqualityAttribute>]
  type Maybe<'T>(v : 'T, hasValue : bool) =
    member    x.Value        = v
    member    x.HasValue     = hasValue
    override  x.ToString ()  =
      if hasValue then
        sprintf "Just %A" v
      else
        "Nothing"

  let Nothing<'T>     = Maybe<'T> (Unchecked.defaultof<'T>, false)
  let inline Just v   = Maybe<'T> (v, true)

  type Iterator<'T> = unit -> Maybe<'T>
  type Stream<'T>   = unit -> Iterator<'T>

  let filter (f : 'T -> bool) (s : Stream<'T>) : Stream<'T> =
    fun () ->
      let i = s ()
      let rec pop () =
        let mv = i ()
        if mv.HasValue then
          let v = mv.Value
          if f v then Just v else pop ()
        else
          Nothing
      pop

  let map (m : 'T -> 'U) (s : Stream<'T>) : Stream<'U> =
    fun () ->
      let i = s ()
      let pop () =
        let mv = i ()
        if mv.HasValue then
          Just (m mv.Value)
        else
          Nothing
      pop

  let range b e : Stream<int> =
    fun () ->
      let mutable i = b
      fun () ->
        if i <= e then
          let p = i
          i <- i + 1
          Just p
        else
          Nothing

  let inline sum (s : Stream<'T>) : 'T =
    let i = s ()
    let rec loop state =
      let mv = i ()
      if mv.HasValue then
        loop (state + mv.Value)
      else
        state
    loop LanguagePrimitives.GenericZero<'T>

module PerfTest =

  open System.Linq
#if USE_NESSOS
  open Nessos.Streams
#endif

  let now =
    let sw = System.Diagnostics.Stopwatch ()
    sw.Start ()
    fun () -> sw.ElapsedMilliseconds

  let time n a =
    let inline cc i       = System.GC.CollectionCount i

    let v                 = a ()

    System.GC.Collect (2, System.GCCollectionMode.Forced, true)

    let bcc0, bcc1, bcc2  = cc 0, cc 1, cc 2
    let b                 = now ()

    for i in 1..n do
      a () |> ignore

    let e = now ()
    let ecc0, ecc1, ecc2  = cc 0, cc 1, cc 2

    v, (e - b), ecc0 - bcc0, ecc1 - bcc1, ecc2 - bcc2

  let arrayTest n =
    Array.init (n + 1) id
    |> Array.map    int64
    |> Array.filter (fun v -> v % 2L = 0L)
    |> Array.map    ((+) 1L)
    |> Array.sum

  let imperativeTest n =
    let rec loop s i =
      if i >= 0L then
        if i % 2L = 0L then
          loop (s + i + 1L) (i - 1L)
        else
          loop s (i - 1L)
      else
        s
    loop 0L (int64 n)

  let linqTest n =
    (((Enumerable.Range(0, n + 1)).Select int64).Where(fun v -> v % 2L = 0L)).Select((+) 1L).Sum()

  let listTest n =
    List.init (n + 1) id
    |> List.map     int64
    |> List.filter  (fun v -> v % 2L = 0L)
    |> List.map     ((+) 1L)
    |> List.sum

#if USE_NESSOS
  let nessosTest n =
    Stream.initInfinite id
    |> Stream.take    (n + 1)
    |> Stream.map     int64
    |> Stream.filter  (fun v -> v % 2L = 0L)
    |> Stream.map     ((+) 1L)
    |> Stream.sum
#endif

  let pullTest n =
    PullStream.range 0 n
    |> PullStream.map     int64
    |> PullStream.filter  (fun v -> v % 2L = 0L)
    |> PullStream.map     ((+) 1L)
    |> PullStream.sum

  let pushTest n =
    PushStream.range 0 n
    |> PushStream.map     int64
    |> PushStream.filter  (fun v -> v % 2L = 0L)
    |> PushStream.map     ((+) 1L)
    |> PushStream.sum

  let seqTest n =
    Seq.init (n + 1) id
    |> Seq.map    int64
    |> Seq.filter (fun v -> v % 2L = 0L)
    |> Seq.map    ((+) 1L)
    |> Seq.sum

  let perfTest (path : string) =
    let testCases =
      [|
        "array"       , arrayTest       
        "imperative"  , imperativeTest  
        "linq"        , linqTest        
        "list"        , listTest        
        "seq"         , seqTest         
#if USE_NESSOS
        "nessos"      , nessosTest      
#endif
        "pull"        , pullTest        
        "push"        , pushTest        
      |]
    use out                   = new System.IO.StreamWriter (path)
    let write (msg : string)  = out.WriteLine msg
    let writef fmt            = FSharp.Core.Printf.kprintf write fmt

    write "Name\tTotal\tOuter\tInner\tElapsed\tCC0\tCC1\tCC2\tResult"

    let total   = 10000000
    let outers = [| 10; 1000; 1000000 |]
    for outer in outers do
      let inner = total / outer
      for name, a in testCases do
        printfn "Running %s with total=%d, outer=%d, inner=%d ..." name total outer inner
        let v, ms, cc0, cc1, cc2 = time outer (fun () -> a inner)
        printfn "  ... %d ms, cc0=%d, cc1=%d, cc2=%d, result=%A" ms cc0 cc1 cc2 v
        writef "%s\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d" name total outer inner ms cc0 cc1 cc2 v

[<EntryPoint>]
let main argv =
  System.Environment.CurrentDirectory <- System.AppDomain.CurrentDomain.BaseDirectory
  PerfTest.perfTest "perf.tsv"
  0