检查计算的 F# 性能影响?

F# Performance Impact of Checked Calcs?

使用 Checked 模块对性能有影响吗?我已经用 int 类型的序列对其进行了测试,没有发现明显的差异。有时选中的版本更快,有时未选中的版本更快,但通常不会快很多。

Seq.initInfinite (fun x-> x) |> Seq.item 1000000000;;
Real: 00:00:05.272, CPU: 00:00:05.272, GC gen0: 0, gen1: 0, gen2: 0
val it : int = 1000000000
open Checked

Seq.initInfinite (fun x-> x) |> Seq.item 1000000000;;
Real: 00:00:04.785, CPU: 00:00:04.773, GC gen0: 0, gen1: 0, gen2: 0
val it : int = 1000000000

基本上我想弄清楚总是打开已检查是否有任何缺点。 (我遇到了一个不是很明显的溢出,所以我现在扮演一个被抛弃的情人的角色,他不想再有一颗破碎的心。)我能想到的唯一非人为的原因是不总是使用 Checked是不是有一些性能下降,但我还没有看到。

当您衡量性能时,包含 Seq 通常不是一个好主意,因为 Seq 会增加很多开销(至少与 int 操作相比),因此您冒着大部分时间都花在在 Seq 中,而不是在您要测试的代码中。

我为(+)写了一个小测试程序:

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

let dbreak () = System.Diagnostics.Debugger.Break ()

let time a =
  let b = clock ()
  let r = a ()
  let n = clock ()
  let d = n - b
  d, r

module Unchecked =
  let run c () =
    let rec loop a i =
      if i < c then
        loop (a + 1) (i + 1)
      else
        a
    loop 0 0

module Checked =
  open Checked

  let run c () =
    let rec loop a i =
      if i < c then
        loop (a + 1) (i + 1)
      else
        a
    loop 0 0

[<EntryPoint>]
let main argv =
  let count     = 1000000000
  let testCases =
    [|
      "Unchecked" , Unchecked.run
      "Checked"   , Checked.run
    |]

  for nm, a in testCases do
    printfn "Running %s ..." nm
    let ms, r = time (a count)
    printfn "... it took %d ms, result is %A" ms r

  0

性能结果是这样的:

Running Unchecked ...
... it took 561 ms, result is 1000000000
Running Checked ...
... it took 1103 ms, result is 1000000000

所以使用 Checked 似乎增加了一些开销。 int add 的成本应小于循环开销,因此 Checked 的开销高于 2x 可能更接近 4x.

出于好奇,我们可以使用 ILSpy:

等工具检查 IL 代码

未选中:

    IL_0000: nop
    IL_0001: ldarg.2
    IL_0002: ldarg.0
    IL_0003: bge.s IL_0014

    IL_0005: ldarg.0
    IL_0006: ldarg.1
    IL_0007: ldc.i4.1
    IL_0008: add
    IL_0009: ldarg.2
    IL_000a: ldc.i4.1
    IL_000b: add
    IL_000c: starg.s i
    IL_000e: starg.s a
    IL_0010: starg.s c
    IL_0012: br.s IL_0000

已检查:

    IL_0000: nop
    IL_0001: ldarg.2
    IL_0002: ldarg.0
    IL_0003: bge.s IL_0014

    IL_0005: ldarg.0
    IL_0006: ldarg.1
    IL_0007: ldc.i4.1
    IL_0008: add.ovf
    IL_0009: ldarg.2
    IL_000a: ldc.i4.1
    IL_000b: add.ovf
    IL_000c: starg.s i
    IL_000e: starg.s a
    IL_0010: starg.s c
    IL_0012: br.s IL_0000

唯一的区别是 Unchecked 使用 add 而 Checked 使用 add.ovfadd.ovf 添加了溢出检查。

我们可以通过查看 jitted x86_64 代码来更深入地挖掘。

未选中:

; if i < c then
00007FF926A611B3  cmp         esi,ebx  
00007FF926A611B5  jge         00007FF926A611BD  
; i + 1
00007FF926A611B7  inc         esi  
; a + 1
00007FF926A611B9  inc         edi  
; loop (a + 1) (i + 1)
00007FF926A611BB  jmp         00007FF926A611B3

已检查:

; if i < c then
00007FF926A62613  cmp         esi,ebx  
00007FF926A62615  jge         00007FF926A62623  
; a + 1
00007FF926A62617  add         edi,1  
; Overflow?
00007FF926A6261A  jo          00007FF926A6262D  
; i + 1
00007FF926A6261C  add         esi,1  
; Overflow?
00007FF926A6261F  jo          00007FF926A6262D  
; loop (a + 1) (i + 1)
00007FF926A62621  jmp         00007FF926A62613

现在可以看到 Checked 开销的原因。每次操作后,抖动都会插入条件指令 jo,如果设置了溢出标志,该指令会跳转到引发 OverflowException 的代码。

chart 向我们展示了整数加法的成本小于 1 个时钟周期。它小于 1 个时钟周期的原因是现代 CPU 可以并行执行某些指令。

该图表还向我们展示了 CPU 正确预测的分支需要大约 1-2 个时钟周期。

因此假设吞吐量至少为 2,Unchecked 示例中两个整数相加的成本应该是 1 个时钟周期。

在 Checked 示例中,我们执行 add, jo, add, jo。在这种情况下,很可能 CPU 无法并行化,其成本应该在 4-6 个时钟周期左右。

另一个有趣的区别是添加的顺序发生了变化。选中添加后,操作顺序很重要,但未选中时,抖动(和 CPU)具有更大的灵活性,移动操作可能会提高性能。

长话短说;对于像 (+) 这样的廉价操作,与 Unchecked.

相比,Checked 的开销应该在 4x-6x 左右

这假定没有溢出异常。 .NET 异常的成本可能是整数加法的 100,000x 倍。