原文:https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-6
作者:Stephen
日期:2021/8/17
版权:TBD

这篇文章很有意思,虽然是 .NET 的,但是涉及到 JIT 的那部分却是跟 C/C++ 等二进制语言有点关系;另外我觉得性能优化这种东西无论是哪种语言、哪种架构,都是带有一定共通性的,这篇文章我觉得能够指引我们一些优化的方向、思路等,都是非常有用的。另外文章整体来说通俗易懂,我这种不写 C#(只会一点 Java/Kotlin)的人都能看的懂七八成左右(翻译中一些实在不懂得地方我可能会保留原文,希望能够见谅)。总的来说值得推荐,有条件还是建议看原文(逃。

P.S. 作为初次翻译,以及我个人对 .NET 并不是很熟悉,因此可能有许多(翻译腔式的)不到位、不通顺、甚至意思与原文完全相悖的地方,还请海涵,如您在发现这些错误的同时能通过 GitHub Issue 告知我的话,我将不胜感激。再次感谢您的阅读!

四年前,大约是在 .NET Core 2.0 刚发布的时候,我写了一篇 Performance Improvements in .NET Core 来标出数个 .NET 性能提升的例子以及它们的效果,反响非常不错。所以一年后我也同样写了 Performance Improvements in .NET Core 2.1,逐渐地这就成为了一个年度传统。之后是 Performance Improvements in .NET Core 3.0,接着是 Performance Improvements in .NET 5,直到今天。

dotnet/runtime 仓库包含了 .NET 运行时(runtimes)、runtime hosts 以及核心的库。大概一年前仓库的主分支被 fork 出来作为 .NET 6 的开发分支,到发布时分支已经有超过 6500 个已经合并了的 PR(Pull Request),这还没有算上那些例如用于升级依赖版本之类的 bots 自动提交的 PR(这并没有在贬低 bots 的贡献,毕竟它们还真的收到过一些来自求职者的招聘邮件,只是它们可能并不能从中挑选出合适的人1)。就算不需要我审核(review),我也至少仔细看过其中绝大多数的 PR,并且每当我看到一些可能涉及到性能优化的项时,我都会把它们记录在一个列表里,这样我要写文章的时候我就能拿出来重新审视一番了。但当我真正坐下来开始写文章时,发现列表中有将近 550 个 PR,八月也因此过的无比艰巨。但是请放心,我不会把它们全部都列出来,不过还是请准备好一杯你最欢喜的热饮,因为我们即将踏入 .NET 6 的性能优化之旅,在大约 400 个 PR 中穿梭,感受它们共同对 .NET 带来的巨大的性能提升。

好好享受这趟旅程吧!

索引

FIXME: To be done.

设置基准测试(Benchmark)

跟以前的文章一样,绝大多数例子我都是使用的 BenchmarkDotNet 运行的。开始前,我们需要创建一个新的控制台程序(console application):

dotnet new console -o net6perf
cd net6perf

并且引用 BenchmarkDotNet 这个 nuget 包

dotnet add package benchmarkdotnet

这样我们就有了一个 net6perf.csproj,并且我将这个文件修改为了下面的内容;最重要的就是这个项目需要引用多个不同的 frameworks,这样我才能轻松地用 BenchmarkDotNet 来进行性能对比:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>net48;netcoreapp2.1;netcoreapp3.1;net5.0;net6.0</TargetFrameworks>
    <Nullable>annotations</Nullable>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <LangVersion>10</LangVersion>
    <ServerGarbageCollection>true</ServerGarbageCollection>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="benchmarkdotnet" Version="0.13.1" />
  </ItemGroup>

  <ItemGroup Condition=" '$(TargetFramework)' == 'net48' ">
    <Reference Include="System.Net.Http" />
  </ItemGroup>

</Project>

之后我将生成出来的 Program.cs 改写成了如下的模板:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Reports;
using BenchmarkDotNet.Order;
using Perfolizer.Horology;
using System;
using System.Buffers;
using System.Buffers.Binary;
using System.Buffers.Text;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics;
using System.Diagnostics.Tracing;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Numerics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.IO.Compression;
#if NETCOREAPP3_0_OR_GREATER
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
#endif

[DisassemblyDiagnoser(maxDepth: 1)] // change to 0 for just the [Benchmark] method
[MemoryDiagnoser(displayGenColumns: false)]
public class Program
{
    public static void Main(string[] args) =>
        BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, DefaultConfig.Instance
            //.WithSummaryStyle(new SummaryStyle(CultureInfo.InvariantCulture, printUnitsInHeader: false, SizeUnit.B, TimeUnit.Microsecond))
            );

    // BENCHMARKS GO HERE
}

只需要一些小小的修改,你就应该能复制这篇文章中任意的测试代码(benchmark),并粘贴到 // BENCHMARKS GO HERE,这样代码就能编译运行了。你可以用如下面的命令来运行测试程序:

dotnet run -c Release -f net48 -filter "**" --runtimes net48 net5.0 net6.0

上面的命令表示:

  • 以 release 模式编译所有代码
  • 使用 .NET Framework 4.8 进行编译2
  • 不排除任何 benchmark
  • 并且分别在 .NET Framework 4.8、.NET 5 和 .NET 6 上运行所有的 benchmark

在某些情况下,我会额外添加一些运行的 runtime(例如 netcoreapp3.1),以展示某些“一代更比一代强”的地方。另一方面,我可能也会只选用 .NET 6.0 作为 runtime,例如比较新老 API 之间的差异等。文章中多数的运行结果都是我在 Windows 下跑出来的,主要也是为了能跟 .NET Framework 4.8 进行一个对比。当然,除非有其他更特殊的情况,所有的这些 benchmark 代码都应该能在 Linux 或者 macOS 上展示出性能的提升,你只需要装上想要测试的 runtime 即可。版本方面我正在使用的是 .NET 6 RC1 nightly build 版,以及能下载到的最新的 .NET 5 和 .NET Core 3.1。

最后,以及“老套”的免责声明:同一个基准测试(microbenchmarking)在不同的机器上的结果可能都会非常的不同,这或许是因为某台机器上同时在跑着其他的一些程序,也或许只是因为刚好吹了个风。做好心理预期。

总算,我们开始吧……

JIT

代码生成(code generation)作为构建任何东西都需要的基石,对于它的一些改进显然会带来多种多样的效果,例如所有运行在该平台上的代码的性能都会有所提升。在 .NET 6 里就存在着大量的针对 JIT(just-in-time 编译器)的性能优化;JIT 被用于在运行时动态地将 IL(intermediate language,中间语言)转译成汇编代码,同样也作为 Crossgen2R2R format (ReadyToRun) 这类 AOT(ahead-of-time 编译)的一部分。

既然 JIT 对 .NET 代码的性能产生的影响如此之大,我们就先来探究一下内联(inlining)以及去虚化(devirtualization)3。“内联”是指编译器会直接将被调用(callee)的代码在调用者(caller)中生成,这样就避免了调用方法带来的额外开销,不过这只是比较微不足道的提升。内联化的最大优点在于它将调用者的上下文(context)直接暴露给了被调用者,这就让(原来不可能的)后续额外的优化(“敲门砖”)成为可能。举个简单的例子:

[MethodImpl(MethodImplOptions.NoInlining)]
public static int Compute() => ComputeValue(123) * 11;

[MethodImpl(MethodImplOptions.NoInlining)]
private static int ComputeValue(int length) => length * 7;

上面的 ComputeValue 接受一个 int 参数,然后乘以 7,并且就这样返回结果。因为这个函数实在是过于简单,(编译器)总是会把它内联优化掉,因此为了展示原始的效果,我加入了 MethodImplOptions.NoInlining 来告诉 JIT 不要去内联这个函数。之后我们能够观察 JIT 针对 ComputeComputeValue 所生成的汇编代码4

; Program.Compute()
       sub       rsp,28
       mov       ecx,7B
       call      Program.ComputeValue(Int32)
       imul      eax,0B
       add       rsp,28
       ret

; Program.ComputeValue(Int32)
       imul      eax,ecx,7
       ret

Compute128(十六进制为 0x7b),作为 ComputeValue 的参数载入到 ecx 寄存器中,然后调用 ComputeValue;最后 ComputeComputeValue 返回的值(存放在 eax 寄存器中)乘以 11(十六进制为 0xb),得到最终的结果并返回。对于 ComputeValue 而言,自然就是从 ecx 寄存器中取出参数,然后乘以 7,将结果放在 eax 寄存器中,等待着 Compute 方法去取。现在,如果我们去掉 NoInlining 会发生什么呢:

; Program.Compute()
       mov       eax,24FF
       ret

之前的乘法和方法调用都消失不见了,现在 Compute 就只是一个返回 0x24ff 的非常简单的方法了,自然,这个值是由 JIT 在运行时计算出来的:(123 * 7) * 11 = 9471,即十六进制的 0x24ff。换言之,我们不仅仅省下了方法调用带来的开销,更是直接将整个函数都变成了一个常量(constant)。内联真的是一种非常强大的优化手段。

当然,内联也是一把双刃剑。如果你到处都去内联函数,那么你的方法最终生成出来的代码可能就会变得非常、非常的膨大。内联可能会让你的 benchmark 变得很好看,但是它也能带来一些很糟糕的影响。举个栗子,我们假设 Int32.Parse 以及它所有调用到的方法编译成二进制5后的大小为 1,000 字节(这个数字只是我为了举例随便写的),并且假设我们强制所有方法都进行内联——那么每一次对 Int32.Parse 的调用都会产生的(也可能会内联后被当作“敲门砖”优化的)它所有代码的一份拷贝;如果你在不同的地方调用了它 100 次,那么最终你产生的二进制大小就会变为 100,000 字节;但是如果我们复用这些代码,就只会有仅仅 1,000 字节而已。这就意味着,更多的内联会占用更多的内存(或者更多的磁盘空间,如果是在 AOT 的场景下)来存放更大的二进制。还有,过度内联可能也会带来了一些潜在的危害:计算机有一个非常快速的指令缓存区(instruction caches),但对应的空间也非常紧张;如果你有 100 个不同的地方调用这 1,000 字节的代码,那么任意一次的调用都有可能直接复用上一次调用所载入到缓存中的代码。但是如果给这 100 个地方都塞入 1,000 字节的(很可能被修改过的)代码拷贝,那么在硬件看来它们就是不同的代码,也就意味着硬件可能会强制清理掉(eviction)缓存中的这些旧代码,并且从内存中载入新的代码到指令缓存中;最终你可能会发现开启内联后的代码甚至运行的更慢了。并且 JIT 编译器本身也会受到过度内联的影响,因为 JIT 对需要编译的方法(method)存在着诸如空间大小等限制,超过限制后 JIT 可能就不会再进行进一步的优化了;过度内联可能会导致你的代码超过这些限制,从而失去了更大的优化6

总而言之7,内联非常强大,但同时也需要被慎重地使用,因此 JIT 也需要采取一些方法性的(methodically)启发式策略,来去(尽可能快速地)决策衡量是否值得被内联8

这么说来,dotnet/runtime#50675dotnet/runtime#51124dotnet/runtime#52708dotnet/runtime#53670 以及 dotnet/runtime#55478 都在尝试帮助 JIT 去(并且更高效地去)理解被调用者会去调用(invoke)哪些方法9——通过让内联器(inliner)考虑更多的方面,例如如果调用者向被调用者传递的参数是常量的话,被调用者是否能够执行一些裁剪(fold);又或者是通过让内联器去优化掉一些不可能的分支,例如条件判断(switches)。让我们看一下其中一个 PR 中给出的例子:

private int _value = 12345;
private byte[] _buffer = new byte[100];

[Benchmark]
public bool Format() => Utf8Formatter.TryFormat(_value, _buffer, out _, new StandardFormat('D', 2));

通过对比在 .NET 5 和 .NET 6 下的执行结果,我们可以发现一些变化:

方法 Runtime 均值 比例 代码大小
Format .NET 5.0 13.21 ns 1.00 1,659 B
Format .NET 6.0 10.37 ns 0.78 590 B

首先 .NET 6 运行的更快了,要知道 .NET 6 几乎没有对 Utf8Formatter 本身进行过额外的性能优化;其次是代码大小(这个值是通过在 Program.cs 中加入的 [DisassemblyDiagnoser] 特性(attribute)得到的),相对于 .NET 5 而言缩减了近 35%。这是怎么实现的呢?两个 runtime 所实现的 TryFormat 都是只有一行的方法,委托调用了 私有的 TryFormatInt64 方法,写出这个方法的开发者还决定加上 MethodImplOptions.AggressiveInlining 特性让 JIT 尽可能地内联这个函数,而不是去依赖一些启发式算法来决定要不要内联。TryFormatInt64 方法是一个对于参数 format.Symbol 取值的 switch-case,通过判断格式化符号(例如‘D’、‘G’、‘N’等)来决定该去调用哪一个方法。不过实际上我们已经错过了最有趣的部分,就在调用这个函数的时候。对于 .NET 5 而言,JIT 认为没有必要去内联 StandardFormat 的构造方法(constructor),因此生成了这样的代码:

       mov       edx,44
       mov       r8d,2
       call      System.Buffers.StandardFormat..ctor(Char, Byte)

因此,在 .NET 5 中就算 TryFormat 方法被内联了,但是 JIT 依然无法看到 TryFormatInt64StandardFormat 的参数情况,也就无法对这个函数做出分支裁剪的优化——因为通过 'D' 构造的 StandardFormat 并没有被内联,JIT 无法将其与 TryFormatInt64 关联起来10。而在 .NET 6 中,JIT 会选择去内联 StandardFormat 的构造方法,这样一来效果就是 JIT 能有效地将原来的 TryFormatInt64 方法:

if (format.IsDefault)
    return TryFormatInt64Default(value, destination, out bytesWritten);

switch (format.Symbol)
{
    case 'G':
    case 'g':
        if (format.HasPrecision)
            throw new NotSupportedException(SR.Argument_GWithPrecisionNotSupported);
        return TryFormatInt64D(value, format.Precision, destination, out bytesWritten);

    case 'd':
    case 'D':
        return TryFormatInt64D(value, format.Precision, destination, out bytesWritten);

    case 'n':
    case 'N':
        return TryFormatInt64N(value, format.Precision, destination, out bytesWritten);

    case 'x':
        return TryFormatUInt64X((ulong)value & mask, format.Precision, true, destination, out bytesWritten);

    case 'X':
        return TryFormatUInt64X((ulong)value & mask, format.Precision, false, destination, out bytesWritten);

    default:
        return FormattingHelpers.TryFormatThrowFormatException(out bytesWritten);
}

裁剪为等价的:

TryFormatInt64D(value, 2, destination, out bytesWritten);

以此避免了多余的分支判断,并且也不需要内联多个 TryFormatInt64D(case 'G' 时)或者 TryFormatInt64N,两者均为 AggressiveInlining11

内联与去虚化(devirtualization)经常是相辅相成的,当 JIT 在尝试调用虚方法或者接口方法时,去虚化就可以静态地(statically)分析最终会调用到的目标方法,并让 JIT 直接产生该方法的调用(call),最终就能省略掉原来虚拟派发(virtual dispatch)12所带来的开销。一旦被去虚化后,目标方法同样也可能被内联(同样遵循前文所述的优化规则和手段等),这样带来的提升就不仅仅是消去了虚拟派发的额外开销,也会更加受益于内联所能带来的进一步的优化。例如,考虑下面的函数,你可能在一些处理集合的实现中找到类似的代码:

private int[] _values = Enumerable.Range(0, 100_000).ToArray();

[Benchmark]
public int Find() => Find(_values, 99_999);

private static int Find<T>(T[] array, T item)
{
    for (int i = 0; i < array.Length; i++)
        if (EqualityComparer<T>.Default.Equals(array[i], item))
            return i;

    return -1;
}

上一版本的 .NET Core 已经教会了 JIT 在某些情况下如何将 EqualityComparer<T>.Default 进行去虚化,并且(在这个例子里)带来比 .NET Framework 4.8 快约两倍的速度提升。

方法 Runtime 均值 比例 代码大小
Find .NET Framework 4.8 115.4 us 1.00 127 B
Find .NET Core 3.1 69.7 us 0.60 71 B
Find .NET 5.0 69.8 us 0.60 63 B
Find .NET 6.0 53.4 us 0.46 57 B

不过之前 JIT 虽然能对 EqualityComparer<T>.Default(对于值类型)去虚化,但是面对它的兄弟 Comparer<T>.Default.Compare 却无法做到。dotnet/runtime#48160 解决了这个问题,下面的对 ValueTuple 实例的比较(compare)测试就能体现出优化的效果(方法 ValueTuple<>.CompareTo 会使用 Comparer<T>.Default 来比较元组(tuple)中的每一个元素):

private (int, long, int, long) _value1 = (5, 10, 15, 20);
private (int, long, int, long) _value2 = (5, 10, 15, 20);

[Benchmark]
public int Compare() => _value1.CompareTo(_value2);
方法 Runtime 均值 比例 代码大小
Compare .NET Framework 4.8 17.467 ns 1.00 240 B
Compare .NET 5.0 9.193 ns 0.53 209 B
Compare .NET 6.0 2.533 ns 0.15 186 B

不过去虚化带来的性能提升远不止这些方法的内在(intrinsic)部分。考虑下面的基准测试:

[Benchmark]
public int GetLength() => ((ITuple)(5, 6, 7)).Length;

这里我选择了使用 ValueTuple'3ITuple 接口,不过这些都是无关紧要的,我只是随便选了一个实现了某个接口(interface)的值类型(value type)而已。之前的 .NET Core JIT 在这里会去避免装箱(boxing,从一个值类型强制转换(cast)为它实现过的其中一个接口),并且就直接生成一个受限的(constrained)方法调用;新版则加入了去虚化和内联优化:

方法 Runtime 均值 比例 代码大小 分配的内存
GetLength .NET Framework 4.8 6.3495 ns 1.000 106 B 32 B
GetLength .NET Core 3.1 4.0185 ns 0.628 66 B -
GetLength .NET 5.0 0.1223 ns 0.019 27 B -
GetLength .NET 6.0 0.0204 ns 0.003 27 B -

看起来不错,但是让我们稍微地改一改测试代码:

[Benchmark]
public int GetLength()
{
    ITuple t = (5, 6, 7);
    Ignore(t);
    return t.Length;
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static void Ignore(object o) { }

在这里我借助了 Ignore 方法必须要传入装箱了的 object 参数才能被调用的特点,来强制进行装箱;在以前的版本中,这足以让 JIT 失去对 t.Length 的去虚化能力,但是现在 .NET 6 却依然能做到。我们可以观察一下生成的汇编代码,首先是 .NET 5 的:

; Program.GetLength()
       push      rsi
       sub       rsp,30
       vzeroupper
       vxorps    xmm0,xmm0,xmm0
       vmovdqu   xmmword ptr [rsp+20],xmm0
       mov       dword ptr [rsp+20],5
       mov       dword ptr [rsp+24],6
       mov       dword ptr [rsp+28],7
       mov       rcx,offset MT_System.ValueTuple~3[[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib]]
       call      CORINFO_HELP_NEWSFAST
       mov       rsi,rax
       vmovdqu   xmm0,xmmword ptr [rsp+20]
       vmovdqu   xmmword ptr [rsi+8],xmm0
       mov       rcx,rsi
       call      Program.Ignore(System.Object)
       mov       rcx,rsi
       add       rsp,30
       pop       rsi
       jmp       near ptr System.ValueTuple~3[[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib]].System.Runtime.CompilerServices.ITuple.get_Length()
; Total bytes of code 92

以及 .NET 6 的:

; Program.GetLength()
       push      rsi
       sub       rsp,30
       vzeroupper
       vxorps    xmm0,xmm0,xmm0
       vmovupd   [rsp+20],xmm0
       mov       dword ptr [rsp+20],5
       mov       dword ptr [rsp+24],6
       mov       dword ptr [rsp+28],7
       mov       rcx,offset MT_System.ValueTuple~3[[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib]]
       call      CORINFO_HELP_NEWSFAST
       mov       rcx,rax
       lea       rsi,[rcx+8]
       vmovupd   xmm0,[rsp+20]
       vmovupd   [rsi],xmm0
       call      Program.Ignore(System.Object)
       cmp       [rsi],esi
       mov       eax,3
       add       rsp,30
       pop       rsi
       ret
; Total bytes of code 92

注意到在 .NET 5 中,最后会调用到接口的实现中(是直接跳转(jumping)到目的地址中,而没有产生一个 call 指令来指示后续可能会回到当前这个函数来):

       jmp       near ptr System.ValueTuple~3[[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib]].System.Runtime.CompilerServices.ITuple.get_Length()

与此同时 .NET 6 不仅仅进行了去虚化13,还将 ITuple.Length 直接进行了内联,最终 JIT 直接将计算出的结果(3)放置到返回寄存器中:

       mov       eax,3

Nice.

还有其他众多的改变都对去虚化产生了一定影响。例如,dotnet/runtime#53567 提升了 AOT ReadyToRun 镜像中去虚化的能力、dotnet/runtime#45526 则让去虚化能够处理泛型(generics)使得内联器使用具体获取到的类(class)类型来进行优化14

当然,依然存在着许多 JIT 不能静态地分析出一个方法最终所调用到的情况,它们当然也就无法受益于去虚化和内联……吗?

.NET 6 中我最喜的一个特性就是 PGO(profile-guided optimization)15。PGO 早就不是什么新鲜概念,许许多多的开发技术栈都已经实现过它了,甚至在 .NET 中都以不同的形式存在了许多年。不过 .NET 6 带来的实现对比以前来说更加特殊:就我而言,它更像是“动态 PGO”。PGO 背后的想法(idea)是开发者可以先去编译他们的应用程序(app),并且往程序里加入一些能收集各种各样有趣数据的特殊工具。之后开发者将这个特别的程序放到一般(typical)场景下运行,之后从工具(intrumentation)中收集到的数据会反馈给编译器,那么编译器在下一次编译同样代码的时候就会根据这些数据来进行优化。这里关键的点是“下一次”。你需要构建你的应用、执行收集数据的过程、然后根据收集到的数据重新构建应用,这些过程很可能会被视作是自动化构建流水线的一部分;这样的传统方式一般就叫做“静态 PGO”。然鹅,自从有了分层编译(tiered compilation)之后,一个新的世界向我们敞开。

“分层编译”在 .NET Core 3.0 时就被默认启用了。对于需要被 JIT 编译的代码而言,分层编译是一种在“让 JIT 编译得更快”还是“让 JIT 尽可能地优化代码”的一种妥协方式。代码都是从“0 层(tier 0)”开始的,这个时候 JIT 只会应用非常少量的优化,当然也就意味着 JIT 能很快地就编译完这代码(优化其实经常是用更长的编译时间换来的)。在这个阶段下生成的代码中,还含有对方法调用次数的统计,一旦次数16超过了某个特定的阈值,JIT 就会把它们放进“1 层(tier 1)”队列中重新进行编译,这次(tier 1)JIT 就能从之前的编译中“收集”出所有的优化项,并且加以学习。例如曾经访问过的 static readonly int 可以直接当作常量使用,因为它的值在进行 tier 1 优化的时候就会被计算出来(dotnet/runtime#45901 使用了一个专门的线程替代线程池,来优化上述提到的队列)。你等会就可以看到将会发生些什么。有了“动态 PGO”之后,JIT 就能在 tier 0 时做更进一步的记录(instrument)了——除了统计调用次数外,还会收集许多“有趣”的数据,用来在 tier 1 时进行 PGO 优化。

在 .NET 6 中,动态 PGO 默认是关闭的。如果需要启用,就要在环境变量中设置 DOTNET_TieredPGO

# with bash
export DOTNET_TieredPGO=1

# in cmd
set DOTNET_TieredPGO=1

# with PowerShell
$env:DONET_TieredPGO="1"

这会让 JIT 在 tier 0 时收集所有“有意思”的数据。除此之外,还有其他的一些环境变量你可能也会想要设置。不过要注意一点是 .NET 的核心库(core libraries)是通过 ReadyToRun 镜像安装的,这就意味着这部分代码已经被编译成为了汇编代码。ReadyToRun 镜像可以参与 JIT 分层编译,但是它不会经过 tier 0,而是直接将 ReadyToRun 的代码传递到 tier 1 中;这就意味着动态 PGO 没办法去记录这些“二进制”产生的数据了。如果还是需要记录核心库,那么你可以选择禁用 ReadyToRun 功能:

$env:DOTNET_ReadyToRun="0"

这样核心库也能参与(tier 0 的)优化了。最后,你可以考虑设置一下 DOTNET_TC_QuickJitForLoops

$env:DOTNET_TC_QuickJitForLoops="1"

这会让一些带有循环(loop)的方法也启用分层编译:否则所有出现了向后跳跃(backward jump)17的代码都会直接进入 tier 1 编译,就好像没有分层编译一样直接进行最后的优化,但这样也就会丢失一些进入 tier 0 才能带来的“好处”。你可能会从一些从事 .NET 工作的人那里听到“full PGO”这个词:那指的是将上面三个环境变量全部都设置好了的情况,然后应用中的所有代码都会利用到“动态 PGO”了。(注意有些框架集(framework assemblies)提供的 ReadyToRun 代码确实也包含了一些基于 PGO 的优化,但只是“静态 PGO”——框架集先使用 PGO 进行一次编译,然后执行一些具有代表性的应用和服务,通过 PGO 生成的结果再去编译一次,最终就成为了提供给我们的“库”的一部分。)

真设置够了18……这到底能有啥用呢?让我们来看一个例子吧:

private IEnumerator<int> _source = Enumerable.Range(0, int.MaxValue).GetEnumerator();

[Benchmark]
public void MoveNext() => _source.MoveNext();

这个 benchmark 非常的简单:我们有一个 IEnumerator<int> 存放在一个字段中(field),然后 benchmark 的作用就是简单地将迭代器(iterator)往前移动。当我们正常编译时(不开 PGO),.NET 6 会产生下面的结果:

; Program.MoveNext()
       sub       rsp,28
       mov       rcx,[rcx+8]
       mov       r11,7FFF8BB40378
       call      qword ptr [7FFF8BEB0378]
       nop
       add       rsp,28
       ret

上面的汇编代码就是直接调用 IEnumerator<int> 接口的真正实现(inetrface dispatch)。现在让我们设置:

$env:DOTNET_TieredPGO=1

然后再重新编译一次。这次,代码变得非常不一样了:

; Program.MoveNext()
       sub       rsp,28
       mov       rcx,[rcx+8]
       mov       r11,offset MT_System.Linq.Enumerable+RangeIterator
       cmp       [rcx],r11
       jne       short M00_L03
       mov       r11d,[rcx+0C]
       cmp       r11d,1
       je        short M00_L00
       cmp       r11d,2
       jne       short M00_L01
       mov       r11d,[rcx+10]
       inc       r11d
       mov       [rcx+10],r11d
       cmp       r11d,[rcx+18]
       je        short M00_L01
       jmp       short M00_L02
M00_L00:
       mov       r11d,[rcx+14]
       mov       [rcx+10],r11d
       mov       dword ptr [rcx+0C],2
       jmp       short M00_L02
M00_L01:
       mov       dword ptr [rcx+0C],0FFFFFFFF
M00_L02:
       add       rsp,28
       ret
M00_L03:
       mov       r11,7FFF8BB50378
       call      qword ptr [7FFF8BEB0378]
       jmp       short M00_L02

除了代码长度外,我们来讨论其他的一些值得注意的点。首先是跟在 call qword ptr [7FFF8BEB0378] 后的 mov r11,7FFF8BB40378 指令,这部分调用接口实现的逻辑依然存在,只是被放置在了方法的最后面。PGO 的其中一个主流实现就是“热区/冷区分离(hot/cold splitting)”优化,方法中被频繁执行的所有区域(“热区”)都会被集中地放置在方法开头处;而没那么频繁的区域(“冷区”)就会被放到方法的末尾。这能充分地利用好指令缓存(intruction caches),并且尽可能少地引入那些(大概率)没怎么被使用的代码。所以这里调用接口实现的代码生成的 PGO 数据,让 JIT 认为它是冷区且很少会被执行到,因此将它放在了方法最后面。既然最初生成的代码被视作是“冷区”了,那么“热区”是啥呢?现在让我们看一下方法开头,能发现:

       mov       rcx,[rcx+8]
       mov       r11,offset MT_System.Linq.Enumerable+RangeIterator
       cmp       [rcx],r11
       jne       short M00_L03

这就是“魔法”所在。当 JIT 在 tier 0 编译(instrument)该方法时,同样也包含了对接口派发的记录,来跟踪每一次调用时 _source 的具体类型。随后 JIT 发现每一次调用都是类型 Enumerable+RangeIterator,这是个用来实现 Enumerable.Range 的一个内部私有类19。所以在 tier 1 时 JIT 就生成了检查 _source 的类型是否为 Enumerable+RangeIterator 的代码:如果它不是,就跳转到我们之前看到的“冷区”中,去执行接口派发找到真正的实现;但如果它是——基于我们的采样(profiling)结果,这应该是大多数情况下的选择——就能直接“去虚化”地调用 Enumerable+RangeIterator.MoveNext 方法,不仅如此,JIT 还发现将 MoveNext 函数进行内联似乎带来的收益更高。因此 MoveNext 的实现就直接被内联在了随后的汇编代码中。最后效果就是我们(生成出)的代码稍微大了一丢丢,但是对最可能常见的场景进行了优化:

方法 均值 代码大小
禁用 PGO 1.905 ns 30 B
启用 PGO 0.7071 ns 105 B

JIT 会针对 PGO 产生的数据进行各种各样的优化。有了这些数据 JIT 就能够知道代码是怎样“运行”(behave)的,区分出那些值得或是不值得优化的部分,它就因此能更积极地去内联。它可以执行“带检查的去虚化”(guarded devirtualization)优化,能够针对大多数接口和虚表查询生成一条或多条去虚化的、可能还内联了的“快路径”(fast paths);以及一条执行原始接口派发操作的路径,适用于那些真实类型与期望类型不符的情况。它其实也能针对那些被视为“冷区”的代码,当优化可能导致代码变得更大时 JIT 可以选择不对这些冷区部分进行优化,以此来减少代码体积。它还能针对类型转换(type cast)进行优化,生成一个直接与目标类型进行比较的检查代码,而非始终都依赖于更复杂且更耗时的 cast helpers20(例如,可能需要搜寻到更“高”的层级上(ancestor hierarchies)、或是需要搜寻接口列表、或是要处理泛型的 co-variance 和 contra-variance 等等)。这里的优化项随着 JIT 以后逐渐地,嗯,“学会去学习”,还会持续地增加。

有许许多多的 PR 都贡献给了 PGO,这里只列出了一部分:

  • dotnet/runtime#44427 使得内联器能利用调用频率(call site frequency)来加快对优化收益的度量(即,内联一个方法能带来多大的效益)。
  • dotnet/runtime#45133 增加了(最初的)在虚函数调用与接口派发的位置计算出具体类型的分布,用于带条件的去虚化(guarded devirtualization)。dotnet/runtime#51157 更进一步地考虑了小的结构体类型(struct types)。dotnet/runtime#51890 通过把所有带条件的去虚化“串联”(chain)起来来增进代码生成能力,会将所有经常被调用到的代码路径聚合到一起(如果可以的话)。
  • dotnet/runtime#52827 增加了对特殊 switch 分支的 PGO 数据处理。如果存在一个经常被“选中”的 switch 分支(JIT 发现这个分支有 30% 的时间都会被选中),JIT 就可以在生成一个对这个分支单独检测的 if 判断,并把它放在代码前面,而不需要让它再继续往下经过 switch 的其他分支。(注意这会应用在 IL 层次上的分支语句;并非所有 C# 的 switch 语句都能产生对应的 switch IL 指令,事实上需要语句都不会生成,因为 C# 编译器就经常会将比较小的或是比较复杂的分支语句,优化成等价的 if/else if 嵌套检测21)。

关于内联我们似乎已经讲的够多了。我们还有其他对 C# 和 .NET 的高性能代码的重要优化项,例如边界检查。C# 和 .NET 的一个很好的优点是,像缓冲区溢出这些典型的安全问题几乎是不可能存在的,除非你想尽一切办法去“强行”规避掉这些保护措施(例如使用 unsafe 关键字、Unsafe 类、Marshal 类或是 MemoryMarshal 类等等)。这是因为 JIT 对所有对数组、字符串以及 spans 的访问都会自动地加上一些“边界检查”(bounds checked),意味着 JIT 确保了你在使用索引(index)访问这些数据结构前,索引值都是在有效范围内的。你可以通过一个简单的例子来认识它:

public int M(int[] arr, int index) => arr[index];

JIT 对上述代码会生成如下近似的汇编:

; Program.M(Int32[], Int32)
       sub       rsp,28
       cmp       r8d,[rdx+8]
       jae       short M01_L00
       movsxd    rax,r8d
       mov       eax,[rdx+rax*4+10]
       add       rsp,28
       ret
M01_L00:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 28

在这里 rdx 寄存器存放着数组 arr 的地址,arr 的长度则是该地址后的 8 个字节(在 64 位进程中),换言之 [rdx+8] 就是 arr.Length,那么 cmp r8d, [rdx+8] 这条指令就是在将索引值 index(存放在 r8d 寄存器中)与 arr.Length 进行比较。如果索引值要大于或等于数组的长度,它就会跳到方法的最末尾,那里有一个会抛出异常的 helper。上述的比较就是“边界检查”。

当然,这种边界检查肯定会引入额外开销。多数情况下这种开销都是微不足道的,不过既然你都在读这篇文章了,那还是有很大可能你写过那种因为边界检查导致开销很大的代码。而且你也会经常使用到这样的代码:很多 .NET 核心库都会尽量地去避免这样的开销。因此,当 JIT 能够证明代码不会产生越界问题时,它就会竭尽全力地避免边界检查。一个具有“原型意义”22的例子是从 0 开始遍历到数组的 Length,例如:

public int Sum(int[] arr)
{
    int sum = 0;
    for (int i = 0; i < arr.Length; i++) sum += arr[i];
    return sum;
}

那么 JIT 会产生像这样的代码:

; Program.Sum(Int32[])
       xor       eax,eax
       xor       ecx,ecx
       mov       r8d,[rdx+8]
       test      r8d,r8d
       jle       short M02_L01
M02_L00:
       movsxd    r9,ecx
       add       eax,[rdx+r9*4+10]
       inc       ecx
       cmp       r8d,ecx
       jg        short M02_L00
M02_L01:
       ret
; Total bytes of code 29

注意到这代码的最后没有产生 call 指令以及它后面的 int3 指令;这是因为我们不再需要一个抛出异常的 helper 了,因为这里压根就没有边界检查。JIT 在构建时就得知了,循环里的代码是绝对不可能越过数组的任何一个边界的,也就不再需要生成边界检查的代码了。

每一次 .NET 新版本里我们都能看到 JIT 变更聪明了:它找到了越来越多的能安全地省略边界检查的代码“模式”(pattern),.NET 6 也是如此。来自 @nathan-mooredonet/runtime#40180dotnet/runtime#43568 就是很好的例子。考虑下面的 benchmark:

private char[] _buffer = new char[100];

[Benchmark]
public bool TryFormatTrue() => TryFormatTrue(_buffer);

private static bool TryFormatTrue(Span<char> destination)
{
    if (destination.Length >= 4)
    {
        destination[0] = 't';
        destination[1] = 'r';
        destination[2] = 'u';
        destination[3] = 'e';
        return true;
    }

    return false;
}

你可能会在很多底层的关于格式化的代码中看到这样的代码典例,即先检查 span 的长度,然后再向 span 中写入数据。在过去的 JIT 中,对于能够识别出哪种防御模式(guard patterns)还是相当挑剔的,.NET 6 则依靠上面提到的几个 PR 让它变得好了许多。在 .NET 5 中,上面的 benchmark 可能会产生这样的汇编代码:

; Program.TryFormatTrue(System.Span~1<Char>)
       sub       rsp,28
       mov       rax,[rcx]
       mov       edx,[rcx+8]
       cmp       edx,4
       jl        short M01_L00
       cmp       edx,0
       jbe       short M01_L01
       mov       word ptr [rax],74
       cmp       edx,1
       jbe       short M01_L01
       mov       word ptr [rax+2],72
       cmp       edx,2
       jbe       short M01_L01
       mov       word ptr [rax+4],75
       cmp       edx,3
       jbe       short M01_L01
       mov       word ptr [rax+6],65
       mov       eax,1
       add       rsp,28
       ret
M01_L00:
       xor       eax,eax
       add       rsp,28
       ret
M01_L01:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3

汇编的开头是将 span 的引用(地址)传入到 eax 寄存器中,并且将 span 的长度传入到 edx 寄存器当中:

       mov       rax,[rcx]
       mov       edx,[rcx+8]

然后每一次对 span 的赋值都会产生一次索引和长度的比较,例如上面的 destination[2] = 'u'

       cmp       edx,2
       jbe       short M01_L01
       mov       word ptr [rax+4],75

为了节约你一些去查 ASCII 表的时间,小写字母 ‘u’ 的十六进制 ASCII 值为 0x75,所以上面的代码是在校验 2 是否要比 span 的长度更小(如果不是的话就会跳转掉 call CORINFO_HELP_RNGCHKFAIL 并抛出异常)。这样一来总共就有四次边界检查了,"true" 中所有字符都执行了一次,哪怕是我们早就知道所有小标访问都是合法的。.NET 6 的 JIT 就能知道这一点:

; Program.TryFormatTrue(System.Span~1<Char>)
       mov       rax,[rcx]
       mov       edx,[rcx+8]
       cmp       edx,4
       jl        short M01_L00
       mov       word ptr [rax],74
       mov       word ptr [rax+2],72
       mov       word ptr [rax+4],75
       mov       word ptr [rax+6],65
       mov       eax,1
       ret
M01_L00:
       xor       eax,eax
       ret

真是好太多了。上面的更改还能回退一些曾经的边界检查 hack(例如 @SingleAccretiondotnet/runtime#49450),这些 hack 为了消除核心库在这些情况下的边界检查做了许多“规避”的修改(work around)。

还有一个来自 @SingleAccretion 的关于优化边界检查的提交 dotnet/runtime#49271。在曾经的版本中 JIT 存在一个问题:当内联了一个方法调用后,这个方法里应该被消除的边界检查却没有被正确地消除。上述 PR 修复了这个问题,下面的这个 benchmark 就是一个证明:

private long[] _buffer = new long[10];
private DateTime _now = DateTime.UtcNow;

[Benchmark]
public void Store() => Store(_buffer, _now);

[MethodImpl(MethodImplOptions.NoInlining)]
private static void Store(Span<long> span, DateTime value)
{
    if (!span.IsEmpty)
    {
        span[0] = value.Ticks;
    }
}
; .NET 5.0.9
; Program.Store(System.Span~1<Int64>, System.DateTime)
       sub       rsp,28
       mov       rax,[rcx]
       mov       ecx,[rcx+8]
       test      ecx,ecx
       jbe       short M01_L00
       cmp       ecx,0
       jbe       short M01_L01
       mov       rcx,0FFFFFFFFFFFF
       and       rdx,rcx
       mov       [rax],rdx
M01_L00:
       add       rsp,28
       ret
M01_L01:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 46

; .NET 6.0.0
; Program.Store(System.Span~1<Int64>, System.DateTime)
       mov       rax,[rcx]
       mov       ecx,[rcx+8]
       test      ecx,ecx
       jbe       short M01_L00
       mov       rcx,0FFFFFFFFFFFF
       and       rdx,rcx
       mov       [rax],rdx
M01_L00:
       ret
; Total bytes of code 27

有时候,在无法消除边界检查的情况下,问题不再是到底有没有边界检查,而是我们为了边界检查会生成什么样的代码。例如 dotnet/runtime#42295 针对访问数组下标 0(在代码中其实相当常见)的情况做了特殊处理,在生成边界检查时使用了 test 指令来替代 cmp 指令,这样生成出来的代码会稍稍小一点,并且稍稍快一些。

还有能自成一类的边界检查优化,称之为“克隆循环”(loop cloning)。其思想是 JIT 能够“复制”(duplicate)一段循环,以得到一个原始的循环和一个去除了边界检查的循环,随后在运行的时候再去通过额外的前置检查来决定使用哪一个。例如,考虑下面的代码:

public static int Sum(int[] array, int length)
{
    int sum = 0;
    for (int i = 0; i < length; i++)
    {
        sum += array[i];
    }
    return sum;
}

这里 JIT 依然需要对 array[i] 这个访问进行边界检查,虽然 JIT 能得到 i >= 0 && i < length,但是它不知道 length <= array.Length 是否成立,也就无法推断 i < array.Length 了。然鹅,每次循环迭代都需要有一次额外的判断和分支来检查边界。Loop cloning 能够让 JIT 生成类似下面这样的代码:

public static int Sum(int[] array, int length)
{
    int sum = 0;
    if (array is not null && length <= array.Length)
    {
        for (int i = 0; i < length; i++)
        {
            sum += array[i]; // bounds check removed
        }
    }
    else
    {
        for (int i = 0; i < length; i++)
        {
            sum += array[i]; // bounds check not removed
        }
    }
    return sum;
}

最终我们引入了一次额外的前置验证,但只要能多进行几次循环迭代,那么消除边界检查带来的性能提升足以弥补前置验证带来的损耗。很好。但是跟其他的边界消除优化项一样,JIT 都会去寻找非常特殊的模式(pattern),那些稍有偏差的代码23就无法被优化了。例如简单的数组类型变换:把上面例子中的 int[] 类型改为 byte[],就足以让 JIT 摸不着头脑了……当然,仅限 .NET 5 及以前。好在 dotnet/runtime#48894 让 .NET 6 能够在这种情况下正确地克隆循环了,我们也能在 benchmark 中看到:

private byte[] _buffer = Enumerable.Range(0, 1_000_000).Select(i => (byte)i).ToArray();

[Benchmark]
public void Sum() => Sum(_buffer, 999_999);

public static int Sum(byte[] array, int length)
{
    int sum = 0;
    for (int i = 0; i < length; i++)
    {
        sum += array[i];
    }
    return sum;
}
方法 运行时 均值 比例 代码大小
Sum .NET 5.0 471.3 us 1.00 54B
Sum .NET 6.0 350.0 us 0.74 97B
; .NET 5.0.9
; Program.Sum()
       sub       rsp,28
       mov       rax,[rcx+8]
       xor       edx,edx
       xor       ecx,ecx
       mov       r8d,[rax+8]
M00_L00:
       cmp       ecx,r8d
       jae       short M00_L01
       movsxd    r9,ecx
       movzx     r9d,byte ptr [rax+r9+10]
       add       edx,r9d
       inc       ecx
       cmp       ecx,0F423F
       jl        short M00_L00
       add       rsp,28
       ret
M00_L01:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 54

; .NET 6.0.0
; Program.Sum()
       sub       rsp,28
       mov       rax,[rcx+8]
       xor       edx,edx
       xor       ecx,ecx
       test      rax,rax
       je        short M00_L01
       cmp       dword ptr [rax+8],0F423F
       jl        short M00_L01
       nop       word ptr [rax+rax]
M00_L00:
       movsxd    r8,ecx
       movzx     r8d,byte ptr [rax+r8+10]
       add       edx,r8d
       inc       ecx
       cmp       ecx,0F423F
       jl        short M00_L00
       jmp       short M00_L02
M00_L01:
       cmp       ecx,[rax+8]
       jae       short M00_L03
       movsxd    r8,ecx
       movzx     r8d,byte ptr [rax+r8+10]
       add       r8d,edx
       mov       edx,r8d
       inc       ecx
       cmp       ecx,0F423F
       jl        short M00_L01
M00_L02:
       add       rsp,28
       ret
M00_L03:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 97

不仅仅是 bytes 类型,其他的非原生(non-primitive)结构数组也存在这样的问题。dotnet/runtime#55612 修复了。另外,dotnet/runtime#55299 对于各种各样的多维数组的 loop cloning 也进行了改进。

既然我们已经谈论到了关于循环的优化,我们来考虑下循环反转。“循环反转”(loop inversion)是一种标准的编译器转换(compiler transform),用于消除循环中的一些分支。例如循环:

while (i < 3)
{
    ...
    i++;
}

能够执行 loop inversion 的编译器会将代码转换(transform)为:

if (i < 3)
{
    do
    {
        ...
        i++;
    }
    while (i < 3);
}

概括而言就是,将 while 转换为了 do..while、把在每次循环开头都会执行的条件检测放到了循环末尾、最后在最前面增加了一个一次性的条件判断来弥补(循环迭代的第一次缺失的条件判断)。假如当前 i == 2,对于原先的代码逻辑就会是,进入循环,然后 i 自增,之后我们又跳转回循环的开头处并且再执行一次条件判断,这一次判断会失败(i 现在是 3),然后我们又要跳回到刚刚我们才进行跳跃的地方(循环尾部)。现在我们重新考虑下循环反转的情况,因为当前 i == 2,所以我们先通过了(最开始的)if 判断,然后进入了 do..while 循环,之后 i 自增了,最后我们要进行条件判断了,这次判断也同样会失败,但是现在我们已经在循环的末尾了,所以我们不再需要重新跳回到循环开头,只需要继续往下走过这个循环就行。总而言之:我们省略了两次跳跃。在另一种情况下,如 i >= 3,我们(在循环反转后)需要的跳跃次数跟原先逻辑是一样的,因为都是直接跳转到了 while/if 的最后。这样“反转”了的结构在多数情况下都能有额外的优化;例如 JIT 能够识别到并进行 loop cloning 的优化与上述针对不同类型变量的优化24,这些优化都是基于循环的,并且也都使用了反转了的形式。dotnet/runtime#50982 以及 dotnet/runtime#52347 都提升了 JIT 对循环反转的处理能力。

Ok,目前为止我们讨论了内联优化、边界条件优化以及循环优化,那么常量呢?

“常量折叠”(constant folding)这个“高大上”的概念其实就是编译器在编译的时候(compile-time)去算出这些常量值,不需要等到运行时(run-time)再去计算。“折叠”能出现在编译的各种各样的层次(level)上。例如 C# 代码:

public static int M() => 10 + 20 * 30 / 40 ^ 50 | 60 & 70;

C# 编译器会在将它翻译成 IL 语言时就将它优化(折叠)掉,并且调用这个方法的时候总会得到常量 47

IL_0000: ldc.i4.s 47
IL_0002: ret

折叠也能发生在 JIT(编译的时候),尤其在与内联进行配合的时候就会效果拔群。例如在 C# 中:

public static int M() => 10 + N();
public static int N() => 20;

这时 C# 编译器就无法(多数情况下也不应该)通过一些过程分析(interprocedural analysis)来得知 N 始终会返回常量 20,所以最终 IL 就会长成这样:

IL_0000: ldc.i4.s 10
IL_0002: call int32 C::N()
IL_0007: add
IL_0008: ret

但有了内联之后,JIT 就能直接对 M 生成这样的指令:

L0000: mov eax, 0x1e
L0005: ret

上面例子中将 20 内联进了方法,然后常量折叠会去计算 10 + 20,最后就能得到常量 30(十六进制为 0x1e)。常量折叠通常也会伴随着“常量传播”(constant propagation)——一种编译器将(计算后的)常量替换进表达式的“实践”,随后编译器时常能再遍历(表达式)、执行更多的常量折叠、用常量传播替换掉更多的常量、等等。假设我们有一些不寻常(non-trivial)的方法 helpers:

public bool ContainsSpace(string s) => Contains(s, ' ');

private static bool Contains(string s, char c)
{
    if (s.Length == 1)
    {
        return s[0] == c;
    }

    for (int i = 0; i < s.Length; i++)
    {
        if (s[i] == c)
            return true;
    }

    return false;
}

因为某些奇奇怪怪的需求,写出 Contains(string, char) 的开发者认为25这个方法在被调用时传入的字符串(string)参数几乎都是常量,而传入的字符(char)也通常都是常量。现在:

[Benchmark]
public bool M() => ContainsSpace(" ");

那么 JIT 对 M 的处理结果为:

L0000: mov eax, 1
L0005: ret

震惊,这不可能,这到底是怎么实现的?其实,JIT 先在 ContainsSpace(string) 中将方法 Contains(string, char) 内联了,然后又在方法 M() 这把 ContainsSpace(string) 也内联了。因此(方法 M 内联的)最终实现上 ContainsSpace(string, char) 就会基于 string s is " " 以及 char c is ' ' 这样的事实来进行后续处理。随后就能再推到(propagate)出 s.Length 始终为常量值 1,这样 JIT 就会将 if 代码块之外的无用代码都省略掉。然后 JIT 又发现 s[0] 的索引位于边界内,因此也能把边界检查给省掉;后面发现 s[0] 表示的是取常量字符串 " " 的第一个字符,JIT 知道它就是 ' ',那么 ' ' == ' ' 就使得 JIT 将整个过程变为返回一个常量 true 值,也就是 mov eax, 1。这不是非常好吗?当然,你可能还是会问一个问题,“代码真的会像上面这样只传递常量参数吗?”,答案是一个坚定的 yes,即使是大多数情况下。.NET 5 的一些 PRs 引入了对 "literalString".Length 常量化的处理(将常量字符串的长度也算出一个常量),这让整个核心库最终生成的汇编代码减少了上千个字节并且提升了性能26。在 .NET 6 中也有这样使用“特例”(extra-special use)来进行的优化,一个很好的例子是 dotnet/runtime#57217。这个 PR 引入的改动主要是,如果一个方法被调用的地方在编译完成之后生成的代码中参数为字面值常量,并且能够通过如字符串长度的多少来特化(specialize)出多个不同的实现,那么 JIT 就会去根据它在调用处所了解到的这些字面值的使用情况,去选择哪个实现,最终在使用这样的字面值时就能生成出更快且更小的代码27

但是,JIT 对哪些东西能够被折叠依然需要一些“指引”。在 dotnet/runtime#49930 中教会了 JIT 在使用常量字符串时如何把 null 折叠掉,如同上面文章所介绍的其他方法一样,都对内联(和优化)有着重要的作用。例如 Microsoft.Extensions.Logging.Console.ConsoleFormatter 这个抽象基类(abstract base class),它有一个 protected 的构造函数(constructor):

protected ConsoleFormatter(string name)
{
    Name = name ?? throw new ArgumentNullException(nameof(name));
}

这个构造函数就很平平无奇:检查参数是不是为 null,如果是的话就抛出异常,不是就把参数存(store)起来。我们继续来看一个它的子类(derived from it),这是个自带的(built-in)类型:

public JsonConsoleFormatter(IOptionsMonitor<JsonConsoleFormatterOptions> options)
    : base(ConsoleFormatterNames.Json)
{
    ReloadLoggerOptions(options.CurrentValue);
    _optionsReloadToken = options.OnChange(ReloadLoggerOptions);
}

对于上面的 base(ConsoleFormatterNames.Json) 这个调用,ConsoleFormatterNames.Json 的定义为:

public const string Json = "json";

所以实际上 base 这个调用其实就只是:

base("json")

现在,当 JIT 将基类(base)构造函数进行内联的时候,它就能得知输入的参数一定不可能为 null,这样一来也就能省略掉不可能执行到的无用代码 ?? throw new ArgumentNullException(nameof(name)) 了,最终内联的结果就是非常简单的 Name = "json"

还有类似的 dotnet/runtime#50000。多亏了之前提到过的分层编译(tiered compilation),那些用 staic readonly 修饰的变量在 tier 0 中可以被计算出来,然后在 tier 1 中就能直接当作常量来用了。这个特性在之前的 .NET 版本中就已经存在了。比如你可能看到过的一些代码例子,一些功能的特性能够通过读取环境变量的值来进行动态地开启和禁用,而且之后也会将读到的环境变量的值存放在一个 static readonly bool 的字段当中。一些会去读这个字段的代码,在经过 tier 1 重新编译后,就会将上面的布尔字段视为常量,之后 JIT 就能够根据这个值来决定哪些条件分支可以被裁剪掉。例如下面的这个 benchmark:

private static readonly bool s_coolFeatureEnabled = GetCoolFeatureEnabled();

private static bool GetCoolFeatureEnabled()
{
    string envVar = Environment.GetEnvironmentVariable("EnableCoolFeature");
    return envVar == "1" || "true".Equals(envVar, StringComparison.OrdinalIgnoreCase);
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static void UsedWhenCoolEnabled() { }

[MethodImpl(MethodImplOptions.NoInlining)]
private static void UsedWhenCoolNotEnabled() { }

[Benchmark]
public void CallCorrectMethod()
{
    if (s_coolFeatureEnabled)
    {
        UsedWhenCoolEnabled();
    }
    else
    {
        UsedWhenCoolNotEnabled();
    }
}

因为我并没有设置环境变量,所以当我运行程序后观测 tier 1 阶段对方法 CallCorrectMethod 生成的汇编代码时,我发现:

; Program.CallCorrectMethod()
       jmp       near ptr Program.UsedWhenCoolNotEnabled()
; Total bytes of code 5

这就是整个函数的实现了,可以看到没有任何 UsedWhenCoolEnabled 的调用,因为 JIT 能够得知 s_coolFeatureEnabled 现在是一个布尔常量 false,从而能够消除掉 if 分支中不可能被执行到的代码。同样对于 null 折叠来说,前面提到的 PR 也能对这样的变量进行处理。假设一个库提供了这样的方法:

public static bool Equals<T>(T i, T j, IEqualityComparer<T> comparer)
{
    comparer ??= EqualityComparer<T>.Default;
    return comparer.Equals(i, j);
}

这个方法用传入的自定义比较器对值进行比较,如果传入的比较器为 null 就用 EqualityComparer<T>.Default 来比较。现在我们来写个传入 EqualityComparer<T>.Default 参数的 benchmark:

[Benchmark]
[Arguments(1, 2)]
public bool Equals(int i, int j) => Equals(i, j, EqualityComparer<int>.Default);

public static bool Equals<T>(T i, T j, IEqualityComparer<T> comparer)
{
    comparer ??= EqualityComparer<T>.Default;
    return comparer.Equals(i, j);
}

在 .NET 5 和 .NET 6 下分别会产生下面的汇编代码:

; .NET 5.0.9
; Program.Equals(Int32, Int32)
       mov       rcx,1503FF62D58
       mov       rcx,[rcx]
       test      rcx,rcx
       jne       short M00_L00
       mov       rcx,1503FF62D58
       mov       rcx,[rcx]
M00_L00:
       mov       r11,7FFE420C03A0
       mov       rax,[7FFE424403A0]
       jmp       rax
; Total bytes of code 51

; .NET 6.0.0
; Program.Equals(Int32, Int32)
       mov       rcx,1B4CE6C2F78
       mov       rcx,[rcx]
       mov       r11,7FFE5AE60370
       mov       rax,[7FFE5B1C0370]
       jmp       rax
; Total bytes of code 33

对于 .NET 5 生成的代码来说,开头的两个 mov 指令是用来加载 EqualityComparer<int>.Default 的。随后将在调用 Equals<T>(int, int, IEqualityComparer<T>) 方法的地方执行了内联,指令 test rcx, rcx 就是对传入的 comparer 参数 EqualityComparer<int>.Default 进行 null 检测。如果它不是 null(也不可能是),就会跳转到 M00_L00 处,这里后面的两个 mov 指令与一个 jmp 指令是 Equals 接口方法的尾调用(tail call)28。在 .NET 6 中你能看到最开始的两个指令(加载)依然存在,但是中间部分的四条指令(testjnemovmov)都不见了,因为(JIT)编译器可以推导出 static readonly 字段必定不为 null,然后在内联时就能完全省略掉 comparer ??= EqualityComparer<T>.Default; 这条语句。

dotnet/runtime#47321 也同样对折叠(folding)做了许多的强化。大多数的 Math 类方法现在都能参与到常量折叠中来了,所以只要它们的输入无论怎样最终都能被视为常量的话,那么算出来的结果也一定会是常量;并且借助常量传播(constant propagation),这很可能减少掉相当程度的运行时的计算开销。下面的 benchmark 是我从 System.Math 文档里拷过来的一些样例代码,并且修改了一下,创建了一个计算不规则四边形(trapezoid)的方法:

[Benchmark]
public double GetHeight() => GetHeight(20.0, 10.0, 8.0, 6.0);

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static double GetHeight(double longbase, double shortbase, double leftLeg, double rightLeg)
{
    double x = (Math.Pow(rightLeg, 2.0) - Math.Pow(leftLeg, 2.0) + Math.Pow(longbase, 2.0) + Math.Pow(shortbase, 2.0) - 2 * shortbase * longbase) / (2 * (longbase - shortbase));
    return Math.Sqrt(Math.Pow(rightLeg, 2.0) - Math.Pow(x, 2.0));
}

结果如下:

方法 运行时 均值 比例 代码大小
GetHeight .NET 5.0 151.7852 ns 1.000 179 B
GetHeight .NET 6.0 0.0000 ns 0.0000 12 B

可以观察到 .NET 6 运行的时间被降到了 0,并且代码大小也从 179 字节缩小到 12 字节。这是怎么会事呢?其实就是因为整个操作都变成了一个简单的常量。.NET 5 生成的汇编代码为:

; .NET 5.0.9
; Program.GetHeight()
       sub       rsp,38
       vzeroupper
       vmovsd    xmm0,qword ptr [7FFE66C31CA0]
       vmovsd    xmm1,qword ptr [7FFE66C31CB0]
       call      System.Math.Pow(Double, Double)
       vmovsd    qword ptr [rsp+28],xmm0
       vmovsd    xmm0,qword ptr [7FFE66C31CC0]
       vmovsd    xmm1,qword ptr [7FFE66C31CD0]
       call      System.Math.Pow(Double, Double)
       vmovsd    xmm2,qword ptr [rsp+28]
       vsubsd    xmm3,xmm2,xmm0
       vmovsd    qword ptr [rsp+30],xmm3
       vmovsd    xmm0,qword ptr [7FFE66C31CE0]
       vmovsd    xmm1,qword ptr [7FFE66C31CF0]
       call      System.Math.Pow(Double, Double)
       vaddsd    xmm2,xmm0,qword ptr [rsp+30]
       vmovsd    qword ptr [rsp+30],xmm2
       vmovsd    xmm0,qword ptr [7FFE66C31D00]
       vmovsd    xmm1,qword ptr [7FFE66C31D10]
       call      System.Math.Pow(Double, Double)
       vaddsd    xmm1,xmm0,qwor44562d ptr [rsp+30]
       vsubsd    xmm1,xmm1,qword ptr [7FFE66C31D20]
       vdivsd    xmm0,xmm1,[7FFE66C31D30]
       vmovsd    xmm1,qword ptr [7FFE66C31D40]
       call      System.Math.Pow(Double, Double)
       vmovsd    xmm2,qword ptr [rsp+28]
       vsubsd    xmm0,xmm2,xmm0
       vsqrtsd   xmm0,xmm0,xmm0
       add       rsp,38
       ret
; Total bytes of code 179

可以看到有五次调用到了 Math.Pow,还有各种双精度浮点数(double)的加减、平方根等等运算。与此同时,.NET 6 我们能看到:

; .NET 6.0.0
; Program.GetHeight()
       vzeroupper
       vmovsd    xmm0,qword ptr [7FFE5B1BCE70]
       ret
; Total bytes of code 12

就是直接返回了一个常量的双精度浮点数。很难不保持微笑(笑。

还有一些其他的关于 JIT 进行折叠的提升。@SingleAccretion 提出的 dotnet/runtime#48568 优化了针对无符号类型比较的常量折叠和传播;(同样是)@SingleAccretiondotnet/runtime#47133 中改变了 JIT 执行折叠(folding)的阶段,以此提升折叠在内联时产生的作用;dotnet/runtime#43567 优化了针对交换算子(commutative operators)的折叠效果。还有更多,例如对于 ReadyToRun,来自 @nathan-mooredotnet/runtime#42831 保证了常量数组(array)的 Length 也能被视为(传播成)为常量。

到目前为止,我们讨论的多数优化都是互相交织的(cross-cutting)。不过有时候一些优化也倒是非常专一,像针对某一种非常特定的模式(pattern)来生成更优化的代码。这部分改动在 .NET 6 中还是很多的,可以列举一些出来:

  • dotnet/runtime#37245:当隐式转换(implicitly cast)发生在将 string 转为 ReadOnlySpan<char> 时,会有一次判断输入是否为 null 的检查,如果字符串为 null 时就会返回一个空 span。这个操作内联的比较激进,所以只要调用方保证了字符串不可能为 null,那么上面的 null 测查就能被省掉了。

    [Benchmark]
    public ReadOnlySpan<char> Const() => "hello world";
    
    ; .NET 5.0.9
    ; Program.Const()
           mov       rax,12AE3A09B48
           mov       rax,[rax]
           test      rax,rax
           jne       short M00_L00
           xor       ecx,ecx
           xor       r8d,r8d
           jmp       short M00_L01
    M00_L00:
           cmp       [rax],eax
           cmp       [rax],eax
           add       rax,0C
           mov       rcx,rax
           mov       r8d,0B
    M00_L01:
           mov       [rdx],rcx
           mov       [rdx+8],r8d
           mov       rax,rdx
           ret
    ; Total bytes of code 53
    
    ; .NET 6.0.0
    ; Program.Const()
           mov       rax,18030C4A038
           mov       rax,[rax]
           add       rax,0C
           mov       [rdx],rax
           mov       dword ptr [rdx+8],0B
           mov       rax,rdx
           ret
    ; Total bytes of code 31
    
  • dotnet/runtime#37836:方法 BitOperations.PopCount 在 .NET Core 3.0 时被引入,返回关于输入的“popcount”,或者称之为“一的数量”(population count),就是比特集(bit set)中 1 的数量。如果硬件支持的话它就会直接调用硬件指令(hardware intrinsic),不支持的话就会使用软件版本去计算。当然如果输入是常量的时候结果能在编译期就被计算出来(或者在 JIT 的角度中输入能被当作常量,例如 static readonly)。这个 PR 将 PopCount 方法植入到了 JIT 内部中29,让 JIT 能够在它觉得合适的情况下将整个函数调用替换为常量值。

    [Benchmark]
    public int PopCount() => BitOperations.PopCount(42);
    
    ; .NET 5.0.9
    ; Program.PopCount()
           mov       eax,2A
           popcnt    eax,eax
           ret
    ; Total bytes of code 10
    
    ; .NET 6.0.0
    ; Program.PopCount()
           mov       eax,3
           ret
    ; Total bytes of code 6
    
  • dotnet/runtime#50997:这个例子很好地阐述了,一些针对 JIT 的优化实际上也是在顺应库(libraries)的发展进程。具体而言,这是关于后续我们会提到的“字符串内插”(string interpolation)的一些优化。在之前的版本中,例如一个插值字符串 "${_nullableValue}" 其中 _nullableValue 传入的类型为 int?,最终会生成一次 string.Format 调用,并且将 _nullableValue 作为 object 类型传入。对 int? 类型进行装箱(boxing)要么值为 null 时会转为 null,要么值不为 null 时会转为 int 值的装箱30。在 C# 10 和 .NET 6 中,(调用 string.Format)改为了调用一个泛型方法,针对传入的 _nullableValue 转换为强类型(strongly-typed)T == int?;这个泛型方法会针对 T 检测各种接口,如果找到了就使用它31。在针对这个特性进行的性能测试中,我们发现代码生成(code generation)针对 nullable 的类型时会出现性能下降,包括分配和吞吐。这个 PR 对于存在接口检测和使用的模式(pattern)下,优化了装箱,从而避免性能下降:

    private int? _nullableValue = 1;
    
    [Benchmark]
    public string Format() => Format(_nullableValue);
    
    private string Format(T value, IFormatProvider provider = null)
    {
        if (value is IFormattable)
        {
            return ((IFormattable)value).ToString(null, provider);
        }
    
        return value.ToString();
    }
    
    方法 运行时 均值 比例 代码大小
    Format .NET 5.0 87.71 ns 1.00 154 B
    Format .NET 6.0 51.88 ns 0.59 100 B
  • dotnet/runtime#50112


  1. 译注:这句话应该是在插诨打科(笑,翻译的时候我读了好半天。。 ↩︎

  2. 译注:原文带了个“surface area”,不太清楚该如何翻译;我的理解是基于 .NET 4.8 的 API 进行编译,但是最后运行的时候并不一定需要用 .NET 4.8,只需要 API 一致即可。 ↩︎

  3. 译注:就是去除 virtual 调用,这个词有点不太好翻译的样子。。 ↩︎

  4. 译注:汇编代码的语法应该是 Intel syntax。 ↩︎

  5. 译注:原文是说“assembly code”,似乎指代的是汇编代码,但是我觉得二进制可能更加贴切一些。 ↩︎

  6. 译注:这句话是我自己加上去的,应该是这个意思,因为 JIT 是分阶段的。 ↩︎

  7. 译注:原文是“Net net”,这种说法感觉很有意思 233。 ↩︎

  8. 译注:这是不是很有教科书那种吹 bee 的味道?讲人话应该就是“JIT 需要有一些启发式手段来衡量方法是否应该被内联”,好像也没有太人话的样子 orz。 ↩︎

  9. 译注:是不是会感觉很拗口。。我也在考虑到底是直接写 callee 还是都将它们翻译成“被调用者”,但似乎无论哪个词都非常的可能令人疑惑。大致意思应该就是内联器考虑的东西更多了,更加会考虑最终调用的一些函数(从而忽略掉中间的调用,也就是“callee 调用的方法”)。 ↩︎

  10. 译注:这句话感觉有点难翻译,原文是“even though TryFormat gets inlined, in .NET 5, the JIT is unable to connect the dots to see that the 'D' passed into the StandardFormat constructor will influence which branch of that switch statement in TryFormatInt64 gets taken.”,我选择了拆成多个分句来翻译,水平有限,应该存在更好的译法。。 ↩︎

  11. 译注:不是很清楚为啥要加上后面的那句话(还是我理解的有点偏差?),可能是用于 StandardFormat 参数可能存在多个时的情况? ↩︎

  12. 译注:应该就是类似于 C++ 的虚表,但是这个 dispatch 不太清楚在 .NET 中该翻译为什么,望指教。 ↩︎

  13. 译注:这个 jmp 应该就能够理解为是“查虚表、跳转”这样的“虚”化步骤,因为测试代码中我们标识了 tITuple,而不是具体的类型,所以需要查找虚方法。 ↩︎

  14. 译注:原文是“improves devirtualization with generics such that information about the exact class obtained is then made available to improve inlining.”,似乎意思就是将信息暴露(或者传递)给了内联器?这点意译得不是特别的确定。 ↩︎

  15. 译注:也同样是我最喜欢的一个特性 233,甚至都嫉妒为何 C++ 这种语言无法实现(逃;另外这个 PGO 不太知道该译作什么,因为一般的性能优化中的 profile 都不怎么会被翻译的样子。。所以后续都使用缩写 PGO 了。 ↩︎

  16. 译注:原文说的是“members”,按照我的理解应该指的是被优化的内容不止会是方法,也有可能是成员变量? ↩︎

  17. 译注:例如循环,每完成一次循环后都需要“回退”到开始再进行。 ↩︎

  18. 译注:原文的“enough”感觉是有一层“受够了”的味道,好像翻译不太出来。。 ↩︎

  19. 译注:这句话原文比较绕,“is a private class used to implement Enumerable.Range inside of the Enumerable implementation”,翻译出来就是“实现的实现”,所以我还是选择了简化一下。。 ↩︎

  20. 译注:“cast helper”翻译起来感觉有点找不到词?另外这句话其实也揭露了,性能优化的一个点就是加上各种预先检测 hhh ↩︎

  21. 译注:不是非常确定,原文为“a cascading set of if/else if checks”,应该指的是嵌套?(级联?) ↩︎

  22. 译注:“prototypical”,应该译为“常见”更好? ↩︎

  23. 译注:“deviate and fall off the golden path”,感觉像是一些谚语? ↩︎

  24. 译注:这段话相当拗口,感觉翻译的可能有点不太对,原文为“the JIT’s pattern recognition used for loop cloning and the hoisting of invariants depend on the loop being in an inverted form”。 ↩︎

  25. 译注:原先的表达是“decided”,又是某种笑话么 emm。 ↩︎

  26. 译注:原文只是提到“improvements”,这里看应该是针对空间和时间都有的优化? ↩︎

  27. 译注:一个不太恰当(可能也不太准确)的例子是,假如一个方法定义为 char M(string a) { return a.Length >= 0 ? a[0] : '*'; },那么如果我们传入字面量,这个函数就会有两种不同的实现 char M(string a) { return a[0]; } 以及 char M(string a) { return '*'; },JIT 可能就会根据真正传入的字面值参数来选择“特化并内联”其中一个函数。应该是这样? ↩︎

  28. 译注:就是不使用 call,而是直接 jmp 到函数位置的调用方法;还是挺精妙的,可能会涉及到 calling convention。 ↩︎

  29. 译注:“intrinsic”到底要怎么翻译呢。。 ↩︎

  30. 译注:这句话让我非常迷惑,“Boxing that int? translates into either null if the nullable value is null or boxing its int value if it’s not null.”,是因为我对 C# 的装箱不太了解的原因吗。。 ↩︎

  31. 译注:已经感觉开始头晕了(。是我对 C# 泛型不太了解的原因吗。。这一整段都比较建议看原文,还有对应的 GitHub 链接(。 ↩︎