在C#中对小代码样本进行基准测试,这个实现能否改进?

115

在 SO 上,我经常会对小代码块进行基准测试,以确定哪个实现方式最快。

经常会看到评论说基准测试代码没有考虑到即时编译器和垃圾回收器。

我有以下简单的基准测试函数,并且已经逐步完善:

  static void Profile(string description, int iterations, Action func) {
        // warm up 
        func();
        // clean up
        GC.Collect();

        var watch = new Stopwatch();
        watch.Start();
        for (int i = 0; i < iterations; i++) {
            func();
        }
        watch.Stop();
        Console.Write(description);
        Console.WriteLine(" Time Elapsed {0} ms", watch.ElapsedMilliseconds);
    }

用法:

Profile("a descriptions", how_many_iterations_to_run, () =>
{
   // ... code being profiled
});

这种实现方式有什么缺陷吗?它足够好以显示实现X在Z次迭代中比实现Y更快吗?您能想到任何改进的方法吗?

编辑 很明显,基于时间的方法(而不是迭代次数)更受欢迎,有人有任何实现,其中时间检查不会影响性能吗?


请参见BenchmarkDotNet - Ben Hutchison
11个回答

104

这是修改后的函数:按社区推荐,随意修改它,它是社区维护的。

static double Profile(string description, int iterations, Action func) {
    //Run at highest priority to minimize fluctuations caused by other processes/threads
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;
    Thread.CurrentThread.Priority = ThreadPriority.Highest;

    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
    return watch.Elapsed.TotalMilliseconds;
}

确保您在启用优化的发布模式下编译,并在 Visual Studio 外运行测试。这是非常重要的,因为即使在发布模式下,JIT 也会在调试器附加时限制其优化。


3
我刚刚更新了使用Stopwatch.StartNew。这不是功能上的改变,但可以节省一行代码。 - LukeH
1
@Luke,很棒的更改(我希望我可以+1)。@Mike我不确定,我怀疑虚函数调用的开销会比比较和赋值大得多,所以性能差异将是可以忽略的。 - Sam Saffron
2
你认为展示平均时间怎么样?就像这样:Console.WriteLine(" 平均耗时 {0} 毫秒", watch.ElapsedMilliseconds / iterations); - rudimenter
1
我写了一篇博客文章,解释了为什么这个示例在某些情况下无法工作,请参见http://mattwarren.org/2014/09/19/the-art-of-benchmarking/。 - Matt Warren
对于较短的测试,您还应该将延迟模式设置为低延迟,以防止由于垃圾回收而产生异常值。但是对于更大的测试,这实际上会误导那些创建大量垃圾的函数的速度。 - BlueRaja - Danny Pflughoeft
显示剩余3条评论

24

在调用GC.Collect之后,最终化不一定会在其返回之前完成。最终化会被排队并在单独的线程上运行。该线程可能仍然活动于您的测试期间,从而影响结果。

如果您希望确保在开始测试之前已完成最终化,则可以调用GC.WaitForPendingFinalizers,它将阻塞直到最终化队列被清除:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

11
为什么还需要再次调用 GC.Collect() 呢? - colinfang
8
因为被“finalize”的对象不会被垃圾回收器(GC)回收。因此第二个“Collect”存在是为了确保“finalize”对象也被回收。 - MAV

16

如果您想要消除GC交互的影响,可能需要在GC.Collect调用之后而不是之前运行您的“热身”调用。这样,您就知道.NET已经从操作系统分配了足够的内存来处理函数的工作集。

请记住,每次迭代都会进行非内联方法调用,因此请确保将您正在测试的内容与空体进行比较。您还必须接受仅可靠地计时长达几倍于方法调用的事实。

此外,根据您正在分析的内容类型,您可能希望基于特定时间段而不是特定迭代次数来运行计时 - 这可以导致更容易比较的数字,而无需为最佳实现运行非常短或为最差实现运行非常长的时间。


1
你有没有想过基于时间的实现方案? - Sam Saffron

7
我认为像这样的基准测试方法最难克服的问题是考虑到边缘情况和意外情况。例如,“在高CPU负载/网络使用率/磁盘抖动等情况下,这两个代码片段如何工作”。它们非常适合进行基本逻辑检查,以查看特定算法是否比另一个算法运行得更快。但要正确地测试大多数代码性能,您必须创建一个测试,以测量该特定代码的具体瓶颈。
我仍然认为经常测试小代码块往往回报很少,并且可能会鼓励使用过于复杂的代码,而不是简单易维护的代码。编写其他开发人员或我自己6个月后可以快速理解的清晰代码将比高度优化的代码具有更多的性能优势。

1
"significant"是一个非常有含义的术语。有时候,实现速度快20%就已经很显著了;而有时候,必须要快100倍才算显著。我同意你对于清晰度的看法,请参考:https://dev59.com/3XNA5IYBdhLWcg3wUL1Y#1025137 - Sam Saffron
在这种情况下,“significant”并不是那么重要。您正在比较一个或多个并发实现,如果这两个实现的性能差异没有统计学意义,那么就不值得采用更复杂的方法。 - Paul Alexander

6
我建议完全避免传递委托:
  1. 委托调用是虚方法调用,不便宜:在.NET中,最小内存分配的约25%。如果您对细节感兴趣,请参见此链接
  2. 匿名委托可能会导致使用闭包,这一点甚至您自己都没有注意到。同样,访问闭包字段比例如在堆栈上访问变量更加明显。

导致闭包使用的示例代码:

public void Test()
{
  int someNumber = 1;
  Profiler.Profile("Closure access", 1000000, 
    () => someNumber + someNumber);
}

如果您对闭包不熟悉,请查看.NET Reflector中的此方法。


有趣的观点,但如果不传递委托,你如何创建可重用的Profile()方法呢?还有其他将任意代码传递给方法的方式吗? - Ash
1
我们使用 "using (new Measurement(...)) { ... 测量的代码 ... }"。这样我们就可以得到实现IDisposable接口的Measurement对象,而不是传递委托。请参阅http://code.google.com/p/dataobjectsdotnet/source/browse/Xtensive.Core/Xtensive.Core/Diagnostics/Measurement.cs。 - Alex Yakunin
这不会导致任何闭包问题。 - Alex Yakunin
3
@AlexYakunin:你提供的链接似乎失效了。能否在你的回答中包含Measurement类的代码? 我怀疑无论你如何实现,都不能使用这种IDisposable方法多次运行要进行性能分析的代码。然而,在需要测量复杂(相互交织)应用程序的不同部分表现时,它确实非常有用,只要记住测量可能不准确,并且在不同时间运行时不一致。我在大多数项目中都使用相同的方法。 - ShdNx
1
运行性能测试多次的要求非常重要(预热+多次测量),因此我也采用了委托的方法。此外,如果您不使用闭包,在 IDisposable 的情况下,委托调用比接口方法调用更快。 - Alex Yakunin
之前我们一直在运行整个序列多次,即整个性能测试序列的调用者负责预热+多个周期。但将其作为Profile方法的一部分实现肯定更容易。 - Alex Yakunin

5

我建议在热身阶段多次调用func()而不是只调用一次。


1
意图是确保进行JIT编译,但在测量之前多次调用函数有什么好处? - Sam Saffron
3
为了给 JIT 改进其初步结果的机会。 - Alexey Romanov
1
.NET JIT并不像Java JIT那样随着时间的推移而改善其结果。它只在第一次调用时将方法从IL转换为Assembly。 - Matt Warren

4

改进建议

  1. 检测执行环境是否适合进行基准测试(例如检测调试器是否已附加或jit优化是否已禁用,这将导致不正确的测量结果)。

  2. 独立测量代码部分(以准确确定瓶颈所在)。

  3. 比较不同版本/组件/代码块(你的第一句话中说:“……基准测试小代码块,以查看哪个实现最快。”)。

关于 #1:

  • To detect if a debugger is attached, read the property System.Diagnostics.Debugger.IsAttached (Remember to also handle the case where the debugger is initially not attached, but is attached after some time).

  • To detect if jit optimization is disabled, read property DebuggableAttribute.IsJITOptimizerDisabled of the relevant assemblies:

    private bool IsJitOptimizerDisabled(Assembly assembly)
    {
        return assembly.GetCustomAttributes(typeof (DebuggableAttribute), false)
            .Select(customAttribute => (DebuggableAttribute) customAttribute)
            .Any(attribute => attribute.IsJITOptimizerDisabled);
    }
    

关于#2:

有很多方法可以做到这一点。其中之一是允许提供多个委托,然后逐个测量这些委托。

关于#3:

这也可以用多种方式实现,不同的用例需要非常不同的解决方案。如果手动调用基准测试,则写入控制台可能是可以接受的。但是,如果基准测试由构建系统自动执行,则写入控制台可能就不太合适了。

一种方法是将基准测试结果作为强类型对象返回,以便在不同的上下文中轻松消耗。


Etimo.Benchmarks

另一种方法是使用现有组件执行基准测试。实际上,在我的公司,我们决定将我们的基准测试工具发布到公共领域。它的核心管理垃圾收集器、抖动、预热等,就像其他答案中建议的一样。它还有我上面建议的三个特性。它处理了Eric Lippert博客中讨论的多个问题。

这是一个示例输出,其中比较了两个组件,并将结果写入控制台。在这种情况下,比较的两个组件称为'KeyedCollection'和'MultiplyIndexedKeyedCollection':

Etimo.Benchmarks - Sample Console Output

有一个NuGet包、一个示例NuGet包,并且源代码可在GitHub上获取。还有一篇博客文章

如果你很匆忙,建议你获取示例包并根据需要简单修改示例委托。如果你不急于求成,阅读博客文章以了解详细信息可能是一个好主意。


1
如果你想要消除基准测试中垃圾回收的影响,设置GCSettings.LatencyMode会不会有价值?
如果不是这样,并且你希望在基准测试中包含func创建的垃圾的影响,那么你不应该在测试结束时(定时器内部)强制进行垃圾回收吗?

1

在实际测量之前,您还必须运行一次“预热”过程,以排除JIT编译器花费在jitting代码上的时间。


它在测量之前执行。 - Sam Saffron

1

根据您正在基准测试的代码和运行平台,您可能需要考虑代码对齐如何影响性能。为此,可能需要一个外部包装器来多次运行测试(在单独的应用程序域或进程中?),其中一些时间首先调用“填充代码”以强制进行JIT编译,从而导致被基准测试的代码对齐方式不同。完整的测试结果将为各种代码对齐方式提供最佳和最差情况的计时。


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