65.9K
CodeProject 正在变化。 阅读更多。
Home

基准测试探险 - 方法内联

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.50/5 (2投票s)

2016年9月11日

CPOL

6分钟阅读

viewsIcon

11812

基准测试探险 - 方法内联

上一篇文章中,我探讨了如何使用BenchmarkDotNet来诊断为什么一个基准测试比另一个运行得慢。这篇文章概述了ETW事件如何用于精确测量每个基准测试分配的字节数GC集合次数

内联

除了内存分配,BenchmarkDotNet还可以提供关于JIT编译器内联了哪些方法的信息。内联是将一个函数(被内联函数)的代码直接复制到另一个函数(内联函数)体内的过程。这样做的目的是节省方法调用的开销以及在控制权从一个方法传递到另一个方法时需要完成的相关工作。

为了展示这一点,我们将运行以下基准测试

[Benchmark]
public int Calc()
{
    return WithoutStarg(0x11) + WithStarg(0x12);
}

private static int WithoutStarg(int value)
{
    return value;
}

private static int WithStarg(int value)
{
    if (value < 0)
        value = -value;
    return value;
}

BenchmarkDotNet还允许您针对不同版本的.NET JIT编译器和各种CPU平台运行基准测试。因此,此测试将要求它针对以下配置运行:

  • 传统JIT - x86
  • 传统JIT - x64

设置好所有这些之后,我们可以运行基准测试,并得到以下结果:

Method Inlining - Benchmark Results

值得注意的是,Legacy JIT - x64的运行速度明显快于x86版本,尽管它们都在运行相同的C#代码(来自上面的Calc()函数)。

现在,我们将要求BenchmarkDotNet提供JIT内联诊断信息。这些诊断信息通过ETW事件提供,并在输出末尾进行收集、解析和显示,如下所示:

Method Inlining - Explanation

在这里,我们可以看到,当x64 JIT编译器运行时,WithStarg()函数成功内联到Calc()函数中,而在x86版本中则没有。因此,执行的代码相同,但由于WithStarg()函数相对简单,当它没有被内联时,方法调用的开销占主导地位,导致Calc()函数花费更多时间。作为对比,WithoutStarg()函数总是被内联,因为它不会对传入的value进行任何处理。

要全面了解为什么两个JIT版本之间存在行为差异,我建议阅读Andrey Akinshin 关于此主题的博客文章。但总而言之,x64版本更有效率,而x86版本没有相同的行为是一个错误/回归。

.NET JIT内联规则

在这种情况下,Legacy JIT - x86未能内联WithStarg()方法的具体原因是:

失败原因:被内联函数写入参数。

供参考,MSDN上有JIT ETW内联事件失败原因的综合列表,但有趣的是,它不包括这个原因!

然而,内联并非总是双赢的局面。由于您将相同的代码复制到两个位置,这可能会导致程序所需的内存膨胀。

更新:感谢Microsoft的Andy Ayers指出,现在有一个更近期的列表,说明了.NET JIT编译器不内联方法的原因

因此,.NET JIT编译器在决定是否内联方法时会遵循一些规则(请注意,此列表来自2004年,因此规则可能已发生变化)

以下是我们不会内联方法的某些原因:

  • 方法被标记为不可内联,带有CompilerServices.MethodImpl属性。

  • 被内联函数的大小限制为32字节的IL:这是一个启发式规则,其背后的原理是,通常情况下,当方法大于此大小时,调用的开销与方法执行的工作相比就不那么显著了。当然,作为一个启发式规则,它在某些情况下会失效。有人建议添加一个属性来控制这些阈值。对于Whidbey,这个属性还没有添加(它有一些非常糟糕的特性:它是x86 JIT特定的,而且随着编译器越来越智能,其长期价值是可疑的)。

  • 虚调用:我们不跨虚调用进行内联。不这样做的原因是我们不知道调用的最终目标。我们可能可以在这里做得更好(例如,如果99%的调用都指向同一个目标,您可以生成代码,通过检查对象的方法表来执行虚拟调用,如果不是99%的情况,您就进行调用,否则就执行内联代码),但与J语言不同,我们支持的主要语言中的大多数调用都不是虚调用,因此我们不必那么积极地优化这种情况。

  • 值类型:我们对值类型和内联有一些限制。我们为此负责,这是我们JIT的限制,我们可以做得更好,而且我们知道。不幸的是,当与其他Whidbey特性进行优先级排序时,获取有关方法因该原因而无法内联的频率统计数据,并考虑到让JIT的这一领域变得更好的成本,我们认为对于客户来说,将时间花在其他优化或CLR特性上更有意义。Whidbey比以前的版本在一个案例中有所改进:仅包含一个指针大小整数作为成员的值类型,这(相对而言)改进起来并不昂贵,并且对指针包装器(IntPtr等)等常见值类型有很大帮助。

  • MarshalByRef:目标是MarshalByRef类的方法不会被内联(调用必须被拦截和分派)。我们在Whidbey中对此场景进行了改进。

  • VM限制:这些主要是安全原因,JIT必须向VM申请内联方法的权限(请参阅Rotor源代码中的CEEInfo::canInline以了解VM检查的内容)。

  • 复杂的流程图:我们不内联循环、带有异常处理区域的方法等……

  • 如果调用所在的**基本块被认为不经常执行**(例如,具有throw语句的基本块,或静态类构造函数),那么内联的激进程度会降低(因为唯一真正的收益是代码大小)。

  • 其他:稀有的IL指令、需要方法帧的安全检查等。

摘要

因此,我们可以看到BenchmarkDotNet将显示多条信息,帮助您诊断基准测试花费时间的原因:

  1. 每个基准测试分配的字节数
  2. 触发的GC集合次数(Gen 0/1/2)
  3. 方法是否被内联

文章基准测试探险记 - 方法内联首次出现在我的博客性能是一项特性!上。

© . All rights reserved.