不同 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