与直接“IEnumerable <t>”实现

时间:2015-05-13 02:01:26

标签: performance f# ienumerable seq

(对不起,很长的帖子,直接跳到问题,见底部)
(更新:如果您正在重新访问,请参阅标记为&#34的部分;更新&#34;;)

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

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

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-for-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替换为value.AsEnumerable(),则其执行速度明显快于stocp1:因素3.0。

经过几次测试后,我清楚地知道每个|>创建一个新的IEnumerable<T>层,包含所有链接操作(这也可以在{{1的FSharp源代码中观察到) }})。但开销的规模让我感到惊讶。由于上述任何一个都没有与原始版本具有相同的性能,我决定尝试防止额外的序列开销,并创建一个Seq函数来同时执行这两个操作。

尝试3:Seq.mapiAndFilter

由于它是如此精致的循环,我只需要根据当前位置过滤当前字符并返回,也许我可以删除Seq.mapiAndFilter所涉及的额外步骤,这看起来很昂贵。

要做到这一点,我需要模仿现有Seq.mapi函数的行为,我的第一次尝试是使用while-yield循环来实现。这将与最初的可变方法最接近,但会增加一层Seq.xxx开销。

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

IEnumerable<T>

结果很多比以前的尝试更好,它的性能是可变解决方案的1.5倍。然而,仍然令人失望地缓慢,这似乎意味着在紧密循环中,调查员增加的开销约为50%。

尝试4:改进了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))

为了找出底层发生了什么,我决定明确地编写可枚举类型,这应该让我有机会找出FSharp库中添加的任何样板检查是否与低性能有关特性

如果没有安全保护FSharp Seq.mapiAndFilter函数在内部使用(在非法使用Current等时引发错误),我想出了这个:

Seq

这成了我们目前的赢家!它似乎比原始的可变功能慢了1.1倍。当然,它使用可变状态,但内部所有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)) 函数也是如此。

绩效比较概述

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

我使用以下内容进行性能比较(显然,Seq.collect是强制迭代的最快方法,Seq.lengthSeq.last要慢得多):

Seq.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'函数之前对字符串使用AsEnumerable()的版本。所有其他功能都已使用此功能。

我还测试了较长和非常大(50MB)的琴弦,这是我们的典型用例,虽然时序在后续运行中不太稳定,但有效因素与上述大致相同。

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

(更新)包含元素访问的性能比较

上述比较仅测试迭代,这有助于找到堆叠迭代器引起的问题。但是,如果添加元素访问方程式,时间会略有不同。我通过添加0.234

强制执行此操作
Seq.map id

结果:

let runmap f = for i in 1 .. 1000000 do f input |> Seq.map id |> Seq.length |> ignore;;

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

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

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)

结果:

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

使用latkin的答案的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 让我感到有些惊讶,这意味着如上所述在紧密循环中使用时,创建的数组不会被清除。我的机器在几秒钟内分配了几次8GB,然后在它之间掉线(GC&#39; ed?),但最终仍然失败。奇怪:

8GB allocation spikes

其他性能特征可以根据之前的观察结果预期。

结论,问题

通过上面的练习,我发现了一些我没想到的东西:F#编译器只调用非泛型OutOfMemoryException而从不调用IEnumerator.Current。这可能部分解释了为什么当你执行它的对象是一个值类型时,链接序列过滤器的性能下降是如此明显:拳击将它放在堆上并再次返回,这很糟糕。

  • 为什么编译器不使用通用接口?

  • 为什么for循环如此缓慢,内部会发生什么?它不应该变成尾调用,然后在内部编译成快速循环吗?

  • 是否有更自然或其他方式编写过滤器,就像我所做的那样(mapi,然后过滤器),它没有我所描述的有害性能的缺点?

  • 为什么直接管道(慢)和IEnumerator<T>.Current(快)之间有如此大的区别?

我还有很多问题,但SO格式一般只要求你提一个简单的问题,我显然没有。很抱歉这么精致,我希望我没有让太多人接受一些有见地的观察。

UPDATE:正如评论中指出的那样,拳击似乎只在从FSharp Interactive(FSI)运行时出现。如果您使用string.AsEnumerable()并通过添加冗余stocp4(或类似内容)来更改调用代码,则会调用未装箱的访问者。为什么?不知道。

1 个答案:

答案 0 :(得分:9)

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

Lazy v Eager Seqs很慢。理解很慢。它们是一种方便的抽象,涉及大量编译器生成的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 impl添加一个日志记录语句,并将输出序列传递给|> Seq.iter (printfn "%d"),您将看到它是被调用的通用序列。

您在FSI进行测试吗?无论出于何种原因,FSI&#34;将这个序列的一些元素打印到控制台&#34;代码确实在非泛型路径中结束,但这不会影响执行代码。也许这就是你所看到的?

seq中的循环{} seq { }内的循环和其他计算表达式不是常规循环。 (事实上​​几乎没什么&#34;看起来正常&#34;在计算表达式内部实际上是正常的,这是一点:))如计算表达式docs所示,for循环最终以另一个可枚举的迭代方式进行修改。 while循环有点简单。

这或多或少解释了为什么你的&#34;尝试1&#34;速度慢得多 - for循环会导致你的seq中的另一个seq的分配和迭代。

通过Seq API管道是的,这将在每一步创建新的seq。如果&#34;真正的工作&#34;这个例子非常小,然后开销占主导地位。

加快速度您的后续优化每个都会删除抽象层,因此虽然我没有精确的解释,但它们的速度更快似乎是合理的。

.AsEnumerable()这很古怪,我可以重复你看到的显着加速。非常奇怪,因为AsEnumerable扩展方法什么都不做,只是直接返回它的输入!

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

变体我发现当您启用/禁用优化时,以及当您将x64与x86对齐时,结果会有很大差异。把它当作值得的东西。

改变OP的基准和要求后

更新

Array.toSeq 此处不必使用Array.toSeq,并且可以预测地降低我建议的解决方案的性能。 Array.toSeqSeq.ofArray有更多的安全性(确保生成的seq不能被消费者和变异转换回数组),而不是类型转换。

更好的选择:

  • 在返回时将数组转换为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 }

<强>摘要

第一个基于时间的seq解决方案看起来很棒并且在给定约束条件下表现良好。我试图给出一些关于为什么提出的替代方案可能会变慢的背景,希望这会有所帮助。通过将所有内容直接集成到一个明确的IEnumerable中,我设法挤出了更多的性能。

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

相关问题