与直接“IEnumerable<T>”实现相比,while 与 for-do 理解的序列性能

Performance of sequences with while vs. for-do comprehensions, compared to direct `IEnumerable<T>` implementation

(抱歉长post,直接跳到问题,见底部)
(更新:如果您要重新访问,请参阅标记为 "update" 的部分;)

我让自己更好地理解 F# 序列背后发生的事情。我需要优化的一项任务涉及将字符串转换为一系列 Unicode 代码点,我想知道是否可以在不牺牲太多性能的情况下将我们使用的可变循环替换为不可变循环。

其中一个挑战是 returned 序列的长度与输入序列的长度不同,因为代理对一起 return 一个整数。这是原始代码,如下所示:

let stocp0(value: string) : seq<int> =
    let pos = ref 0
    seq {
        while !position < value.Length do
            let c = System.Char.ConvertToUtf32(value, !pos)
            pos := !position + if c >= 0x10000 then 2 else 1
            yield c
    }

尝试 1:for-do

我认为最简单的做法是将它变成一个 for-do 循环(不是 for-in-do 循环,它们有太多的额外开销):

let inline stocp1(value: string) =
    seq {
        for i = 0 to value.Length - 1 do
            if(not <| Char.IsLowSurrogate(value.[i])) 
            then yield Char.ConvertToUtf32(value, i)
    }

这比上面的可变对象慢 3.2 倍。一个比我想象的更高的因素。

尝试 2:Seq.mapi

因为一个字符串已经是一个序列(好的,有一个 IEnumerable<char> 包装器)我想利用它与 Seq 模块中现有的序列函数,希望这可能会带来更好的性能:

let inline stocp2(value: string) =
    value
        |> Seq.mapi (fun i c -> 
            if(Char.IsLowSurrogate(c)) then 0
            else Char.ConvertToUtf32(value, i))
        |> Seq.filter ((<>) 0)

它的执行速度慢了 3.5 倍。

奇怪的是,如果我用 value.AsEnumerable() 替换 value,它的执行速度明显快于 stocp1:因子 3.0。

经过多次测试,我清楚地看到每个 |> 都会创建一个新的 IEnumerable<T> 层,其中涉及所有链接操作(这也可以在 [的 FSharp 源代码中观察到) =25=]).但是开销的大小让我感到惊讶。由于上面的 none 甚至与原来的性能相当,我决定尝试防止额外的序列开销开始并创建一个 Seq.mapiAndFilter 函数来同时执行这两个操作。

尝试 3:Seq.mapiAndFilter

由于这是一个非常微妙的循环,我只需要根据当前位置过滤当前字符和 return,也许我可以删除涉及 Seq.mapi 的额外步骤,这好像很贵。

为此,我需要模仿现有 Seq.xxx 函数的行为,我的第一次尝试是使用 while-yield 循环来实现。这将最接近原始的可变方法,但增加了一层 IEnumerable<T> 开销。

我编写了以下函数,它接受一个 return 布尔值的函数,如果为真,它将在当前元素的位置应用第二个函数。

let inline mapiAndFilter f g (e: seq<_>) : seq<_> =
    let position = ref -1
    let e = e.GetEnumerator()
    seq {
        while e.MoveNext() do
            position := !position + 1
            if f e.Current then yield g !position
    }


// and the application of it:
let inline stocp3(value: string) =
    value.AsEnumerable()
        |> mapiAndFilter (not << Char.IsLowSurrogate) (fun i -> Char.ConvertToUtf32 (value, i))

结果比之前的尝试,它的性能是可变解决方案性能的 1.5 倍。不过,速度仍然令人失望,这似乎意味着在紧密循环中枚举器增加的开销约为 50%。

尝试 4:改进 Seq.mapiAndFilter

为了找出幕后发生的事情,我决定显式编写可枚举类型,这应该让我有机会找出 FSharp 库中添加的任何样板检查是否与低性能特征。

没有 FSharp Seq 函数在内部使用的安全防护(在非法使用 Current 等时引发错误),我想到了这个:

let mapiAndFilter f g (e : seq<'a>) : seq<'b> =
    let i = ref -1
    let e = e.GetEnumerator()
    let inline getEnum() = {
            new IEnumerator<'b> with 
                member x.Current = g !i
            interface System.Collections.IEnumerator with 
                member x.Current = box <| g !i
                member x.MoveNext() = 
                    let rec next() = 
                        i := !i + 1
                        e.MoveNext() && (f e.Current || next())
                    next()
                member x.Reset() = noReset()
            interface System.IDisposable with 
                member x.Dispose() = e.Dispose()  
        }
    {
    new System.Collections.Generic.IEnumerable<'b> with
        member x.GetEnumerator() = getEnum()
    interface System.Collections.IEnumerable with
        member x.GetEnumerator() = getEnum() :> System.Collections.IEnumerator
    }

// apply the same way as before:
let inline stocp4(value: string) =
    value.AsEnumerable()
        |> mapiAndFilter (not << Char.IsLowSurrogate) (fun i -> Char.ConvertToUtf32 (value, i))

这成了我们目前的赢家!它似乎只比原来的可变函数慢 1.1 倍。当然,它使用可变状态,但所有 Seq.xxx 函数在内部也是如此。

性能比较概览

关于上述所有尝试的一般说明:我还使用 ToCharArray() 进行了测试,它提高了中小型输入的性能,但对大型输入字符串尤其不利。当不需要枚举所有项目时。我遗漏了许多其他方法,因为它们的性能要差得多(Seq.chooseSeq.filter 很多 Seq.collect,非常慢等) .

我使用下面的方法进行性能比较(显然,Seq.length 是最快的强制迭代方式,Seq.lastSeq.iter 慢得多):

let input = "ab\U0001ABCDcde\U0001ABCEfghi\U0001ABCF"
let run f = for i in 1 .. 1000000 do f input |> Seq.length |> ignore;;
run stocp1
// etc

结果:

Function  CPU     Factor
stocp0    0.296   1.0
stocp1    0.951   3.2
stocp2    1.029   3.5
stocp2'   0.873   3.0
stocp3    0.436   1.5
stocp4    0.327   1.1
stocp5    0.405   1.3 (latkin's answer, adj. with Array.toSeq)

stocp' 是在将字符串传递给 Seq.xxx 函数之前在字符串上使用 AsEnumerable() 的版本。所有其他功能都已使用此功能。

我还测试了更长和非常大 (50MB) 的字符串,这是我们的典型用例,虽然后续 运行s 的时间不太稳定,但有效因子大致相同如上

更新: 我将 latkin 的答案添加为 stocp5,但必须通过添加 Array.toSeq 进行调整。没有它,它会以 0.234 计时,这比原来的 while 循环更快。不幸的是,我需要一个序列(我们必须使用延迟加载并且不能在内存中保存整个字符串)。

(更新)性能比较,包括元素访问

以上比较只测试了迭代,这有助于找到堆叠迭代器引起的问题。但是,如果您将元素访问添加到方程式,时间会略有不同。我通过添加 Seq.map id:

来强制执行它
let runmap f = for i in 1 .. 1000000 do f input |> Seq.map id |> Seq.length |> ignore;;

结果:

Function  CPU     Factor
stocp0    0.795   1.0
stocp1    1.528   1.9
stocp2    1.731   2.2
stocp2'   1.812   2.3
stocp3    0.936   1.2
stocp4    0.842   1.1
stocp5    0.873   1.1  (credit: latkin, see his answer and notes above)

(更新)性能比较,包括 有限 元素访问

由于我们的典型用例不需要完整迭代,因此我添加了一个测试,它只迭代到位置 6 的第二个代理对,输入更大(3932160 个字符)。

let runmapnth f = for i in 1 .. 1000000 do f input |> Seq.map id |> Seq.nth 6 |> ignore;;

结果:

Function  CPU     Factor
stocp0    0.624   1.0
stocp1    1.029   1.6
stocp2    1.263   2.0
stocp2'   1.107   1.8
stocp3    0.717   1.1
stocp4    0.624   1.0
stocp5    ---     --- OOM

OutOfMemoryExceptionwith latkin的回答让我有点吃惊,这意味着创建的数组在像上面这样的紧密循环中使用时没有被清理。我的机器在几秒钟内分配了几次 8GB,并在其间丢弃(GC'ed?),但最终仍然失败。奇怪:

其他性能特征与之前的观察结果相同。

结论,问题

通过上面的最后一个练习,我发现了一些我没有预料到的事情:F# 编译器只调用非泛型 IEnumerator.Current 而从不调用 IEnumerator<T>.Current。这可以部分解释为什么当您执行它的对象是值类型时,链式序列过滤器的性能下降如此明显:装箱将它放在堆上并再次放回,这很糟糕。

我还有很多问题,但是 SO 格式通常希望您只问一个简单的单一问题,而我显然没有。很抱歉这么详细,我希望我不会让太多人望而却步,无法进行一些有见地的观察。

更新: 正如评论中所指出的,拳击似乎只在 运行 来自 FSharp Interactive (FSI) 时出现。如果您使用 stocp4 并通过添加冗余 Seq.filter ((<>) 0)(或类似的东西)来更改调用代码,它将改为调用未装箱的访问器。为什么?不知道。

好的,我试试看。可以找到所有代码和基准测试结果 here.

Lazy v Eager 序列很慢​​。理解速度很慢。它们是一种方便的抽象,涉及大量编译器生成的 goop 和分配,如果 perf 很重要,通常应该完全避免。以下简单的非惰性解决方案可以轻松击败所有相关问题。

// ~50% faster for given test case
// still ~20% faster even for length 1.5M string
let eager1 (value: string) =
    let result = ResizeArray(value.Length)
    for i in 0 .. value.Length - 1 do
        if not (Char.IsLowSurrogate(value.[i]))
        then result.Add(Char.ConvertToUtf32(value, i))
    result.ToArray()

通用 v 非您的通用代码正在基准函数中被调用。

向两个 .Current impls 添加一个日志记录语句,并将您的输出序列通过管道传输到 |> Seq.iter (printfn "%d"),您将看到它是被调用的通用语句。

你在 FSI 测试过吗?无论出于何种原因,FSI 的 "print a few elements of this sequence to the console" 代码确实在非通用路径中结束,但这并不影响执行代码。也许这就是您所看到的?

seq{}中的循环seq { }和其他计算表达式中的循环不是常规循环。 (事实上​​ ,计算表达式内部几乎没有任何东西 "normal looking" 实际上是正常的,这就是要点:))如计算表达式 docs 所示,for 循环结束了编码作为对另一个可枚举的迭代。 while 循环有点简单。

这或多或少解释了为什么您的 "attempt 1" 如此慢 - for 循环导致在您的序列中分配和迭代另一个序列。

通过 Seq API 进行管道传输 是的,这将在每一步创建新的序列。如果涉及的 "real work" 像这个例子一样非常小,那么开销就开始占主导地位。

变得更快您的后续优化都删除了抽象层,因此虽然我没有准确的解释,但它们变得更快似乎是合理的。

.AsEnumerable() 这很古怪,我可以重现您看到的显着加速。非常奇怪,因为 AsEnumerable 扩展方法除了 return 直接输入它什么都不做!

这些情况下生成的代码结构非常不同。也许这是优化器中的病态案例。有趣的发现。

变体 我发现当您 enable/disable 优化时,以及当您针对 x64 与 x86 时,结果差异很大。物有所值。


更新 在更改了来自 OP

的基准和要求之后

Array.toSeq 没有必要在这里使用 Array.toSeq,并且可以预见会降低我建议的解决方案的性能。 Array.toSeqSeq.ofArray 比类型转换更安全(确保生成的 seq 不能被消费者转换回数组并发生变异)。

更好的选择:

  • 在return
  • 时简单地将数组转换为seq<_>
  • 更新您的其他 API 以接受 flexible type #seq<'t>,那么即使是普通数组也可以

更新要求 给定新公布的约束条件:

  • 处理大到1、2份也会导致OOM的字符串
  • 处理时经常提早退出

那么显然懒惰的方法会更合适,至少在某些情况下是这样。

然而,即使有这些要求,在我使用您的新基准进行的测试中,非惰性解决方案在所有情况下仍然表现良好,除了 OOM 或大量输入以及早期救助。

查看上面链接的我的要点以获得结果。它包括替代的非惰性实现:

let eager2 (value: string) =
    let result = ResizeArray(value.Length)
    for i in 0 .. value.Length - 1 do
        if not (Char.IsLowSurrogate(value.[i]))
        then result.Add(Char.ConvertToUtf32(value, i))
    // cast result so that return type isn't array
    (result.ToArray()) :> seq<_>

let eager3 (value: string) =
    let result = ResizeArray(value.Length)
    for i in 0 .. value.Length - 1 do
        if not (Char.IsLowSurrogate(value.[i]))
        then result.Add(Char.ConvertToUtf32(value, i))
    // ToArray() causes another copy to be generated.
    // Avoiding that is a win in large-input scenarios, but at a cost
    // of otherwise slower processing
    (result) :> seq<_>

改进惰性解决方案

这里是对惰性方法的进一步优化,直接整合所有逻辑,避免使用字符串枚举器,避免递归。

在大多数情况下,这家伙实际上似乎击败了非惰性解决方案!

let lazy5 (value : string) =         
    let inline getEnum() = 
        let i = ref -1
        { new IEnumerator<int> with
              member __.Current = Char.ConvertToUtf32(value, !i)
          interface System.Collections.IEnumerator with
              member __.Current =  box (Char.ConvertToUtf32(value, !i))
              member __.MoveNext() = 
                      incr i
                      if !i >= value.Length then false else
                      if not (Char.IsLowSurrogate(value.[!i])) then true else
                      incr i
                      !i < value.Length                  
              member __.Reset() = failwith "reset"
          interface IDisposable with
              member __.Dispose() = () }
    { new IEnumerable<int> with
          member __.GetEnumerator() = getEnum()
      interface IEnumerable with
          member __.GetEnumerator() = getEnum() :> IEnumerator }

总结

第一个基于 while 的 seq 解决方案看起来很棒并且在给定的约束条件下表现良好。我试图提供一些背景信息,说明为什么建议的替代方案可能会更慢,希望这会有所帮助。我设法通过将所有内容直接集成到显式 IEnumerable 中来获得更多性能。

根据约束和输入,非惰性解决方案可能是一个不错的选择。我在这里提出了一些选择。与往常一样,您需要在真实环境中进行测试。