衡量并验证你的绩效假设

这个例子是用 F# 编写的,但这些想法适用于所有环境

优化性能时的第一条规则是不依赖于假设; 始终测量并验证你的假设。

由于我们不直接编写机器代码,因此很难预测编译器和 JIT 如何将程序转换为机器代码。这就是为什么我们需要测量执行时间以确保我们获得预期的性能改进并验证实际程序不包含任何隐藏的开销。

验证是一个非常简单的过程,涉及使用 ILSpy 等工具对可执行文件进行逆向工程。JIT:er 使验证变得复杂,因为看到实际的机器代码很棘手但可行。但是,通常检查 IL-code 会带来很大的收益。

更难的问题是衡量; 更难,因为设置可以衡量代码改进的现实情况很棘手。静止测量是非常宝贵的。

分析简单的 F#函数

让我们来看一些简单的 F# 函数,它们以各种不同的方式累积 1..n 中的所有整数。由于范围是一个简单的算术系列,因此可以直接计算结果,但为了本例的目的,我们迭代范围。

首先,我们定义一些有用的函数来测量函数的时间:

// now () returns current time in milliseconds since start
let now : unit -> int64 =
  let sw = System.Diagnostics.Stopwatch ()
  sw.Start ()
  fun () -> sw.ElapsedMilliseconds

// time estimates the time 'action' repeated a number of times
let time repeat action : int64*'T =
  let v = action ()  // Warm-up and compute value

  let b = now ()
  for i = 1 to repeat do
    action () |> ignore
  let e = now ()

  e - b, v

time 反复运行一个动作,我们需要运行几百毫秒的测试来减少变化。

然后我们定义了一些函数,它们以不同的方式累积 1..n 中的所有整数。

// Accumulates all integers 1..n using 'List'
let accumulateUsingList n =
  List.init (n + 1) id
  |> List.sum

// Accumulates all integers 1..n using 'Seq'
let accumulateUsingSeq n =
  Seq.init (n + 1) id
  |> Seq.sum

// Accumulates all integers 1..n using 'for-expression'
let accumulateUsingFor n =
  let mutable sum = 0
  for i = 1 to n do
    sum <- sum + i
  sum

// Accumulates all integers 1..n using 'foreach-expression' over range
let accumulateUsingForEach n =
  let mutable sum = 0
  for i in 1..n do
    sum <- sum + i
  sum

// Accumulates all integers 1..n using 'foreach-expression' over list range
let accumulateUsingForEachOverList n =
  let mutable sum = 0
  for i in [1..n] do
    sum <- sum + i
  sum

// Accumulates every second integer 1..n using 'foreach-expression' over range
let accumulateUsingForEachStep2 n =
  let mutable sum = 0
  for i in 1..2..n do
    sum <- sum + i
  sum

// Accumulates all 64 bit integers 1..n using 'foreach-expression' over range
let accumulateUsingForEach64 n =
  let mutable sum = 0L
  for i in 1L..int64 n do
    sum <- sum + i
  sum |> int

// Accumulates all integers n..1 using 'for-expression' in reverse order
let accumulateUsingReverseFor n =
  let mutable sum = 0
  for i = n downto 1 do
    sum <- sum + i
  sum

// Accumulates all 64 integers n..1 using 'tail-recursion' in reverse order
let accumulateUsingReverseTailRecursion n =
  let rec loop sum i =
    if i > 0 then
      loop (sum + i) (i - 1)
    else
      sum
  loop 0 n

我们假设结果是相同的(除了使用 2 的增量的函数之一)但是性能有差异。为了测量这个,定义了以下功能:

let testRun (path : string) =
  use testResult = new System.IO.StreamWriter (path)
  let write   (l : string)  = testResult.WriteLine l
  let writef  fmt           = FSharp.Core.Printf.kprintf write fmt

  write "Name\tTotal\tOuter\tInner\tCC0\tCC1\tCC2\tTime\tResult"

  // total is the total number of iterations being executed
  let total   = 10000000
  // outers let us variate the relation between the inner and outer loop
  //  this is often useful when the algorithm allocates different amount of memory
  //  depending on the input size. This can affect cache locality
  let outers  = [| 1000; 10000; 100000 |]
  for outer in outers do
    let inner = total / outer

    // multiplier is used to increase resolution of certain tests that are significantly
    //  faster than the slower ones

    let testCases =
      [|
    //   Name of test                         multiplier    action
        "List"                              , 1           , accumulateUsingList
        "Seq"                               , 1           , accumulateUsingSeq
        "for-expression"                    , 100         , accumulateUsingFor
        "foreach-expression"                , 100         , accumulateUsingForEach
        "foreach-expression over List"      , 1           , accumulateUsingForEachOverList
        "foreach-expression increment of 2" , 1           , accumulateUsingForEachStep2
        "foreach-expression over 64 bit"    , 1           , accumulateUsingForEach64
        "reverse for-expression"            , 100         , accumulateUsingReverseFor
        "reverse tail-recursion"            , 100         , accumulateUsingReverseTailRecursion
      |]
    for name, multiplier, a in testCases do
      System.GC.Collect (2, System.GCCollectionMode.Forced, true)
      let cc g = System.GC.CollectionCount g

      printfn "Accumulate using %s with outer=%d and inner=%d ..." name outer inner

      // Collect collection counters before test run
      let pcc0, pcc1, pcc2 = cc 0, cc 1, cc 2

      let ms, result       = time (outer*multiplier) (fun () -> a inner)
      let ms               = (float ms / float multiplier)

      // Collect collection counters after test run
      let acc0, acc1, acc2 = cc 0, cc 1, cc 2
      let cc0, cc1, cc2    = acc0 - pcc0, acc1 - pcc1, acc1 - pcc1
      printfn "  ... took: %f ms, GC collection count %d,%d,%d and produced %A" ms cc0 cc1 cc2 result

      writef "%s\t%d\t%d\t%d\t%d\t%d\t%d\t%f\t%d" name total outer inner cc0 cc1 cc2 ms result

在 .NET 4.5.2 x64 上运行时的测试结果:

StackOverflow 文档

我们看到了巨大的差异,一些结果出乎意料地糟糕。

让我们来看看坏情况:

名单

// Accumulates all integers 1..n using 'List'
let accumulateUsingList n =
  List.init (n + 1) id
  |> List.sum

这里发生的是一个包含所有整数的完整列表 1..n 是使用总和创建和减少的。这应该比在范围内迭代和累积更昂贵,它似乎比 for 循环慢约 42 倍。

此外,我们可以看到 GC 在测试运行期间运行了大约 100 倍,因为代码分配了大量对象。这也花费了 CPU。

SEQ

// Accumulates all integers 1..n using 'Seq'
let accumulateUsingSeq n =
  Seq.init (n + 1) id
  |> Seq.sum

Seq 版本没有分配一个完整的 List 所以有点令人惊讶的是这比 for 循环慢〜270 倍。此外,我们看到 GC 执行了 661x。

当每个项目的工作量非常小时(在这种情况下聚合两个整数),Seq 效率很低。

关键是不要永远不要使用 Seq。重点是测量。

manofstick 编辑: Seq.init 是这个严重性能问题的罪魁祸首。使用表达 { 0 .. n } 而不是 Seq.init (n+1) id 更有效。当这个 PR 合并和释放时,这将变得更有效。即使在释放之后,原始 Seq.init ... |> Seq.sum 仍然会很慢,但有点反直觉,Seq.init ... |> Seq.map id |> Seq.sum 会很快。这是为了保持与 Seq.inits 实现的向后兼容性,这不是最初计算 Current,而是将它们包含在 Lazy 对象中 - 尽管这也应该是由于这个 PR, 表现要好一点 。编辑注意:对不起这是一种漫无边际的笔记,但我不希望人们在改进即将来临时被推迟 Seq …… 当那个时代到来时,更新此页面上的图表会更好。

foreach-expression over List

// Accumulates all integers 1..n using 'foreach-expression' over range
let accumulateUsingForEach n =
  let mutable sum = 0
  for i in 1..n do
    sum <- sum + i
  sum

// Accumulates all integers 1..n using 'foreach-expression' over list range
let accumulateUsingForEachOverList n =
  let mutable sum = 0
  for i in [1..n] do
    sum <- sum + i
  sum

这两个功能之间的差异非常微妙,但性能差异不大,约为 76 倍。为什么?让我们对坏代码进行逆向工程:

public static int accumulateUsingForEach(int n)
{
  int sum = 0;
  int i = 1;
  if (n >= i)
  {
    do
    {
      sum += i;
      i++;
    }
    while (i != n + 1);
  }
  return sum;
}

public static int accumulateUsingForEachOverList(int n)
{
  int sum = 0;
  FSharpList<int> fSharpList = SeqModule.ToList<int>(Operators.CreateSequence<int>(Operators.OperatorIntrinsics.RangeInt32(1, 1, n)));
  for (FSharpList<int> tailOrNull = fSharpList.TailOrNull; tailOrNull != null; tailOrNull = fSharpList.TailOrNull)
  {
    int i = fSharpList.HeadOrDefault;
    sum += i;
    fSharpList = tailOrNull;
  }
  return sum;
}

accumulateUsingForEach 作为一个有效的 while 循环实现,但 for i in [1..n] 被转换为:

FSharpList<int> fSharpList =
  SeqModule.ToList<int>(
    Operators.CreateSequence<int>(
      Operators.OperatorIntrinsics.RangeInt32(1, 1, n)));

这意味着首先我们在 1..n 上创建一个 Seq,然后最终调用 ToList

昂贵。

foreach-expression 增量为 2

// Accumulates all integers 1..n using 'foreach-expression' over range
let accumulateUsingForEach n =
  let mutable sum = 0
  for i in 1..n do
    sum <- sum + i
  sum

// Accumulates every second integer 1..n using 'foreach-expression' over range
let accumulateUsingForEachStep2 n =
  let mutable sum = 0
  for i in 1..2..n do
    sum <- sum + i
  sum

这两个功能之间的差异再次微妙,但性能差异是残酷的:~25 倍

让我们再次运行 ILSpy

public static int accumulateUsingForEachStep2(int n)
{
  int sum = 0;
  IEnumerable<int> enumerable = Operators.OperatorIntrinsics.RangeInt32(1, 2, n);
  foreach (int i in enumerable)
  {
    sum += i;
  }
  return sum;
}

1..2..n 上创建了 Seq,然后我们使用枚举器迭代 Seq

我们期待 F# 创建这样的东西:

public static int accumulateUsingForEachStep2(int n)
{
  int sum = 0;
  for (int i = 1; i < n; i += 2)
  {
    sum += i;
  }
  return sum;
}

但是,F# 编译器仅支持增加 1 的 int32 范围内的高效 for 循环。对于所有其他情况,它会回落到 Operators.OperatorIntrinsics.RangeInt32。这将解释下一个令人惊讶的结果

foreach-expression 超过 64 位

// Accumulates all 64 bit integers 1..n using 'foreach-expression' over range
let accumulateUsingForEach64 n =
  let mutable sum = 0L
  for i in 1L..int64 n do
    sum <- sum + i
  sum |> int

这比 for 循环慢约 47 倍,唯一的区别是我们迭代 64 位整数。ILSpy 向我们展示了原因:

public static int accumulateUsingForEach64(int n)
{
  long sum = 0L;
  IEnumerable<long> enumerable = Operators.OperatorIntrinsics.RangeInt64(1L, 1L, (long)n);
  foreach (long i in enumerable)
  {
    sum += i;
  }
  return (int)sum;
}

F# 只支持 int32 号码的有效 for 循环,它必须使用 fallback Operators.OperatorIntrinsics.RangeInt64

其他情况大致类似:

StackOverflow 文档

对于更大的测试运行,性能下降的原因是调用 action 的开销正在增长,因为我们在 tihuan 39 中的工作越来越少。

0 循环有时可以提供性能优势,因为它可能会节省 CPU 寄存器,但在这种情况下,CPU 有备用寄存器,所以它似乎没有什么区别。

结论

测量很重要,因为否则我们可能认为所有这些替代方案都是等效的,但有些替代方案比其他方案慢〜270 倍。

验证步骤涉及逆向工程可执行文件帮助我们解释为什么我们做了或没有得到我们期望的性能。此外,验证可以帮助我们预测在执行适当测量时太难的情况下的性能。

很难预测性能总是测量,总是验证你的性能假设。