为什么F#中的函数组合比管道慢60%?

8

诚然,我���于比较苹果和苹果还是苹果和梨子不确定。但是我特别惊讶于这个差异的大小,本应该期望只有轻微甚至没有差异。

管道经常可以表示为函数组合,反之亦然,我认为编译器也知道这一点,因此我做了一个小实验:

// simplified example of some SB helpers:
let inline bcreate() = new StringBuilder(64)
let inline bget (sb: StringBuilder) = sb.ToString()
let inline appendf fmt (sb: StringBuilder) = Printf.kbprintf (fun () -> sb) sb fmt
let inline appends (s: string) (sb: StringBuilder) = sb.Append s
let inline appendi (i: int) (sb: StringBuilder) = sb.Append i
let inline appendb (b: bool) (sb: StringBuilder) = sb.Append b

// test function for composition, putting some garbage data in SB
let compose a =            
    (appends "START" 
    >> appendb  true
    >> appendi 10
    >> appendi a
    >> appends "0x"
    >> appendi 65535
    >> appendi 10
    >> appends "test"
    >> appends "END") (bcreate())

// test function for piping, putting the same garbage data in SB
let pipe a =
    bcreate()
    |> appends "START" 
    |> appendb  true
    |> appendi 10
    |> appendi a
    |> appends "0x"
    |> appendi 65535
    |> appendi 10
    |> appends "test"
    |> appends "END"

在启用了64位,开启了--optimize标志的FSI中测试这个命令,会得到以下结果:

> for i in 1 .. 500000 do compose 123 |> ignore;;
Real: 00:00:00.390, CPU: 00:00:00.390, GC gen0: 62, gen1: 1, gen2: 0
val it : unit = ()
> for i in 1 .. 500000 do pipe 123 |> ignore;;
Real: 00:00:00.249, CPU: 00:00:00.249, GC gen0: 27, gen1: 0, gen2: 0
val it : unit = ()

小的差别可以理解,但这是60%的性能下降因素1.6。

我实际上希望大部分工作在StringBuilder中完成,但显然组合的开销有相当大的影响。

我意识到在大多数实际情况下,这种差异将是微不足道的,但如果你正在编写大型格式化文本文件(如日志文件),就像在这种情况下一样,它会产生影响。

我正在使用最新版本的F#。

2个回答

10

我使用FSI尝试了您的示例,发现没有明显的差异:

> #time
for i in 1 .. 500000 do compose 123 |> ignore

--> Timing now on

Real: 00:00:00.229, CPU: 00:00:00.234, GC gen0: 32, gen1: 32, gen2: 0
val it : unit = ()
> #time;;

--> Timing now off

> #time
for i in 1 .. 500000 do pipe 123 |> ignore;;;;

--> Timing now on

Real: 00:00:00.214, CPU: 00:00:00.218, GC gen0: 30, gen1: 30, gen2: 0
val it : unit = ()

BenchmarkDotNet中进行测量(第一个表格只是一个单一的compose/pipe运行,第二个表格是进行500000次运行),我发现了类似的情况:

  Method | Platform |       Jit |      Median |     StdDev |    Gen 0 | Gen 1 | Gen 2 | Bytes Allocated/Op |
-------- |--------- |---------- |------------ |----------- |--------- |------ |------ |------------------- |
 compose |      X64 |    RyuJit | 319.7963 ns |  5.0299 ns | 2,848.50 |     - |     - |             182.54 |
    pipe |      X64 |    RyuJit | 308.5887 ns | 11.3793 ns | 2,453.82 |     - |     - |             155.88 |
 compose |      X86 | LegacyJit | 428.0141 ns |  3.6112 ns | 1,970.00 |     - |     - |             126.85 |
    pipe |      X86 | LegacyJit | 416.3469 ns |  8.0869 ns | 1,886.00 |     - |     - |             121.86 |

  Method | Platform |       Jit |      Median |    StdDev |    Gen 0 | Gen 1 | Gen 2 | Bytes Allocated/Op |
-------- |--------- |---------- |------------ |---------- |--------- |------ |------ |------------------- |
 compose |      X64 |    RyuJit | 160.8059 ms | 4.6699 ms | 3,514.75 |     - |     - |      56,224,980.75 |
    pipe |      X64 |    RyuJit | 163.1026 ms | 4.9829 ms | 3,120.00 |     - |     - |      50,025,686.21 |
 compose |      X86 | LegacyJit | 215.8562 ms | 4.2769 ms | 2,292.00 |     - |     - |      36,820,936.68 |
    pipe |      X86 | LegacyJit | 209.9219 ms | 2.5605 ms | 2,220.00 |     - |     - |      35,554,575.32 |

可能你正在测量的差异与GC有关。在时间测试之前/之后,尝试强制进行GC收集。

话虽如此,查看管道运算符的源代码

let inline (|>) x f = f x

并使用组合运算符进行比较:

let inline (>>) f g x = g(f x)

看起来,组合运算符将创建lambda函数,这应该会导致更多的分配。这也可以在BenchmarkDotNet运行中看到。这可能也是你看到的性能差异的原因。


谢谢,非常有趣的比较。也许你使用了服务器GC,而我使用的是普通的单线程GC?我不知道如何为FSI配置它。我应该比较编译后的版本。我很高兴看到,在你的系统上,差异微不足道,这正是应该的。 - Abel
我在使用FSI时没有使用任何特殊标志,除了你提到的--optimize。如果有影响的话,我也会运行fsianycpu.exe。 - Ringil
1
@Ringil 我不同意关于lambda的观点。是的,它们在未经优化的代码中创建。但是启用优化后,我只看到两个lambda,而不是九个。其他所有内容都会被内联。我想最重要的是编译器在组合的情况下比管道的情况更难确定内联。 - Fyodor Soikin
1
我找到了差异的来源,我今天早些时候不小心打开了脚本调试。@FyodorSoikin是正确的,lambda表达式实际上已经被优化掉了。仍然存在性能差异,但现在它要小得多,不那么明显,这是可以预料的。 - Abel

7

如果没有深入了解F#内部的知识,从生成的IL中可以看出,compose将产生lambda表达式(如果关闭优化,则会产生大量lambda表达式),而在pipe中所有对append*的调用都将被内联。

pipe函数的生成IL:

Main.pipe:
IL_0000:  nop         
IL_0001:  ldc.i4.s    40 
IL_0003:  newobj      System.Text.StringBuilder..ctor
IL_0008:  ldstr       "START"
IL_000D:  callvirt    System.Text.StringBuilder.Append
IL_0012:  ldc.i4.1    
IL_0013:  callvirt    System.Text.StringBuilder.Append
IL_0018:  ldc.i4.s    0A 
IL_001A:  callvirt    System.Text.StringBuilder.Append
IL_001F:  ldarg.0     
IL_0020:  callvirt    System.Text.StringBuilder.Append
IL_0025:  ldstr       "0x"
IL_002A:  callvirt    System.Text.StringBuilder.Append
IL_002F:  ldc.i4      FF FF 00 00 
IL_0034:  callvirt    System.Text.StringBuilder.Append
IL_0039:  ldc.i4.s    0A 
IL_003B:  callvirt    System.Text.StringBuilder.Append
IL_0040:  ldstr       "test"
IL_0045:  callvirt    System.Text.StringBuilder.Append
IL_004A:  ldstr       "END"
IL_004F:  callvirt    System.Text.StringBuilder.Append
IL_0054:  ret

compose函数生成的IL代码:

Main.compose:
IL_0000:  nop         
IL_0001:  ldarg.0     
IL_0002:  newobj      Main+compose@10..ctor
IL_0007:  stloc.1     
IL_0008:  ldloc.1     
IL_0009:  newobj      Main+compose@10-1..ctor
IL_000E:  stloc.0     
IL_000F:  ldc.i4.s    40 
IL_0011:  newobj      System.Text.StringBuilder..ctor
IL_0016:  stloc.2     
IL_0017:  ldloc.0     
IL_0018:  ldloc.2     
IL_0019:  callvirt    Microsoft.FSharp.Core.FSharpFunc<System.Text.StringBuilder,System.Text.StringBuilder>.Invoke
IL_001E:  ldstr       "END"
IL_0023:  callvirt    System.Text.StringBuilder.Append
IL_0028:  ret

compose@10.Invoke:
IL_0000:  nop         
IL_0001:  ldarg.0     
IL_0002:  ldfld       Main+compose@10.a
IL_0007:  ldarg.1     
IL_0008:  call        Main.f@1
IL_000D:  ldc.i4.s    0A 
IL_000F:  callvirt    System.Text.StringBuilder.Append
IL_0014:  ret         

compose@10..ctor:
IL_0000:  ldarg.0     
IL_0001:  call        Microsoft.FSharp.Core.FSharpFunc<System.Text.StringBuilder,System.Text.StringBuilder>..ctor
IL_0006:  ldarg.0     
IL_0007:  ldarg.1     
IL_0008:  stfld       Main+compose@10.a
IL_000D:  ret         

compose@10-1.Invoke:
IL_0000:  nop         
IL_0001:  ldarg.0     
IL_0002:  ldfld       Main+compose@10-1.f
IL_0007:  ldarg.1     
IL_0008:  callvirt    Microsoft.FSharp.Core.FSharpFunc<System.Text.StringBuilder,System.Text.StringBuilder>.Invoke
IL_000D:  ldstr       "test"
IL_0012:  callvirt    System.Text.StringBuilder.Append
IL_0017:  ret         

compose@10-1..ctor:
IL_0000:  ldarg.0     
IL_0001:  call        Microsoft.FSharp.Core.FSharpFunc<System.Text.StringBuilder,System.Text.StringBuilder>..ctor
IL_0006:  ldarg.0     
IL_0007:  ldarg.1     
IL_0008:  stfld       Main+compose@10-1.f
IL_000D:  ret

这很有趣。在使用组合时,我以前见过“许多lambda”的生成。但是这种差异比我预期的要大得多。更多的IL并不一定意味着性能更低。尽管如此,我仍然很好奇为什么它会表现出这样的性能。我猜测JIT编译器无法有效地优化组合场景中的闭包。 - Abel
JIT编译器的时间、内存和知识都是有限的。根据我的经验,我们不能指望它进行全面的优化。它可以消除未使用的变量、内联方法(除非是虚拟的)和展开循环,但在我看来似乎就只有这些了。F#编译器可用的信息要多得多,原则上应该能够编写更高效的IL代码。 - Just another metaprogrammer
“许多lambda表达式”只会在没有优化的情况下发生。请参见我对Ringil答案的评论。 - Fyodor Soikin
1
@FyodorSoikin,是的。这正是答案所说的。 - Good Night Nerd Pride

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接