在C#中对小型代码样本进行基准测试,是否可以改善此实现?


104

经常在SO上我发现自己对一小段代码进行基准测试,以了解哪种实现速度最快。

我经常看到这样的评论,即基准测试代码未考虑到jitting或垃圾收集器。

我具有以下逐步发展的简单基准测试功能:

  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

Answers:


95

这是修改后的功能:根据社区的建议,随时对其进行修订,使其成为社区Wiki。

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也会通过附加的调试器来限制其优化。


您可能需要将循环展开一定次数,例如10次,以最大程度地减少循环开销。
Mike Dunlavey 2009年

2
我刚刚更新为使用Stopwatch.StartNew。不是功能上的更改,而是节省了一行代码。
LukeH

1
@Luke,很棒的更改(我希望我可以对其+1)。@Mike我不确定,我怀疑虚拟电话的开销会比比较和分配高得多,因此性能差异可以忽略不计
Sam Saffron

我建议您将迭代计数传递给Action,然后在其中创建循环(可能-甚至展开)。如果您要测量的操作时间相对较短,这是唯一的选择。而且我更希望看到逆度量-例如,通过次数/秒。
Alex Yakunin

2
您如何看待平均时间。像这样的东西:Console.WriteLine(“经过的平均时间{0}毫秒”,watch.Elapsed毫秒/迭代);
rudimenter 2012年

22

最终确定不一定要在GC.Collect返回之前完成。终结处理排队,然后在单独的线程上运行。测试期间该线程可能仍处于活动状态,从而影响结果。

如果要确保在开始测试之前完成完成工作,则可能要调用GC.WaitForPendingFinalizers,它将阻塞直到清除完成工作队列为止:

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

10
为什么再来GC.Collect()一次?
colinfang

7
@colinfang因为终结器没有对“确定”的对象进行GC处理。因此,第二个步骤Collect是确保还收集了“最终”对象。
MAV 2014年

15

如果您想排除方程式中的GC交互作用,则可能要在GC.Collect调用之后而不是之前运行“热身”调用。这样,您便知道.NET已经从OS中为功能的工作集分配了足够的内存。

请记住,您要为每个迭代进行非内联方法调用,因此请确保将要测试的内容与空主体进行比较。您还必须接受您只能可靠地计时比方法调用长几倍的时间。

此外,根据要分析的内容类型,您可能希望基于一定时间(而不是一定数量的迭代)运行计时,这可能会导致更容易比较的数字而没有对于最佳实施,必须要有很短的运行时间,对于最糟糕的情况,必须要有很长的运行时间。


1
好点,您会考虑基于时间的实现吗?
Sam Saffron

6

我完全避免通过委托:

  1. 委托调用是〜虚方法调用。不便宜:.NET中最小内存分配的25%。如果您对详细信息感兴趣,请参见此链接
  2. 匿名委托可能导致使用闭包,您甚至不会注意到。同样,访问闭包字段比访问堆栈上的变量要明显得多。

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

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

如果您不了解闭包,请查看.NET Reflector中的此方法。


有趣的一点,但是如果不传递委托,您将如何创建可重复使用的Profile()方法?还有其他方法可以将任意代码传递给方法吗?
灰烬

1
我们使用“使用(new Measurement(...)){...测量的代码...}”。因此,我们获得了实现IDisposable的Measurement对象,而不是传递委托。参见code.google.com/p/dataobjectsdotnet/source/browse/Xtensive.Core/…–
亚历克斯·亚库宁

这不会导致任何关闭问题。
亚历克斯·亚库宁

3
@AlexYakunin:您的链接似乎已损坏。您可以在答案中包含Measurement类的代码吗?我怀疑,无论如何实现,都无法使用IDisposable方法多次运行要分析的代码。但是,在您要测量复杂(交织)应用程序的不同部分的执行情况的情况下,这确实非常有用,只要您记住这些测量可能不准确,并且在不同时间运行时会不一致。我在大多数项目中都使用相同的方法。
ShdNx 2012年

1
多次运行性能测试的要求非常重要(热身+多次测量),因此我也改用了委托方法。此外,如果您不使用闭包,则使用时,委托调用要快于接口方法调用IDisposable
亚历克斯·亚库宁

6

我认为,使用这种基准测试方法要克服的最困难的问题是考虑到极端情况和意外情况。例如-“这两个代码片段如何在高CPU负载/网络使用率/磁盘抖动/等情况下工作”。它们非常适合用于基本逻辑检查,以查看特定算法是否有效快于另一个。但是要正确测试大多数代码性能,您必须创建一个测试,以衡量该特定代码的特定瓶颈。

我仍然要说,测试小的代码块通常没有什么投资回报,并且可以鼓励使用过于复杂的代码,而不是简单的可维护代码。与其他高度开发的代码相比,编写清晰的代码可让其他开发人员或我本人六个月就能快速理解,这将带来更多的性能优势。


1
重要的是真正被载入的那些术语之一。有时实施速度要快20%才有意义,有时实施速度必须快100倍才有意义。同意你清晰看到:stackoverflow.com/questions/1018407/...
萨姆藏红花

在这种情况下,重要的不是全部。您正在比较一个或多个并发实现,并且如果这两种实现的性能差异在统计上不显着,则不值得采用更复杂的方法。
Paul Alexander


4

改善建议

  1. 检测执行环境是否适合基准测试(例如检测是否连接了调试器或是否禁用了jit优化,这将导致错误的测量结果)。

  2. 独立地测量代码的各个部分(以准确了解瓶颈所在)。

  3. 比较不同版本的代码/组件/代码块(在第一句话中,您说“ ...对一小段代码进行基准测试以查看哪种实现最快。”)。

关于#1:

  • 要检测是否已连接调试器,请读取属性System.Diagnostics.Debugger.IsAttached(请记住,还要处理调试器最初未附加但在一段时间后附加的情况)。

  • 要检测是否禁用了jit优化,请阅读DebuggableAttribute.IsJITOptimizerDisabled相关程序集的属性:

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

关于#2:

这可以通过许多方式来完成。一种方法是允许提供多个代表,然后分别测量这些代表。

关于#3:

这也可以通过许多方式来完成,并且不同的用例将需要非常不同的解决方案。如果基准是手动调用的,那么写到控制台可能没问题。但是,如果基准是由构建系统自动执行的,则写入控制台可能不太好。

一种方法是将基准结果作为强类型对象返回,该对象可以在不同上下文中轻松使用。


Etimo基准

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

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

Etimo.Benchmarks-示例控制台输出

有一个NuGet包,一个示例NuGet包,其源代码位于 GitHub获得。还有一篇博客文章

如果您很着急,建议您获取示例包,并根据需要修改示例委托。如果您不着急,最好阅读博客文章以了解详细信息。



1

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


1

如果您要消除基准测试对垃圾收集的影响,是否值得设置GCSettings.LatencyMode

如果不是,并且您希望创建的垃圾影响func成为基准的一部分,那么您是否不应该在测试结束时(在计时器内部)也强制进行收集?


0

您的问题的基本问题是一种假设,即一次测量即可回答所有问题。您需要进行多次测量才能获得对情况的有效了解,尤其是在像C#这样的垃圾收集语言中。

另一个答案给出了一种衡量基本性能的好方法。

static void Profile(string description, int iterations, Action func) {
    // 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);
}

但是,此单一度量未考虑垃圾收集。适当的配置文件还可以解决在许多调用中分布的最坏情况下的垃圾回收性能(此数字是无用的,因为VM可以终止而无需收集剩余的垃圾,但对于比较的两种不同实现仍然有用func)。

static void ProfileGarbageMany(string description, int iterations, Action func) {
    // 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();
    }
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

并且可能还想为仅调用一次的方法测量垃圾回收的最坏情况性能。

static void ProfileGarbage(string description, int iterations, Action func) {
    // 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();

        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

但是,比建议对任何可能的其他度量进行概要描述更重要的是,一个想法应该度量多个不同的统计数据,而不仅仅是一种统计数据。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.