Performance Improvements in .NET 8 -- JIT部分翻译

发布时间 2023-11-01 17:29:55作者: yahle

相关视频 动态PGO

基准测试设置

在本文中,我包括微基准测试以突出讨论的各个方面。其中大部分基准测试都是使用BenchmarkDotNet v0.13.8实现的,除非另有说明,否则每个基准测试都有一个简单的设置。

要跟随本文,首先确保已安装.NET 7和.NET 8。对于本文,我使用了.NET 8 Release Candidate (8.0.0-rc.1.23419.4)。

完成这些先决条件后,在新的基准目录中创建一个新的C#项目:

dotnet new console -o benchmarks
cd benchmarks

该目录将包含两个文件:benchmarks.csproj(包含有关应用程序应如何构建的信息的项目文件)和Program.cs(应用程序的代码)。将benchmarks.csproj的全部内容替换为以下内容:

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>net8.0;net7.0</TargetFrameworks>
    <LangVersion>Preview</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <ServerGarbageCollection>true</ServerGarbageCollection>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.13.8" />
  </ItemGroup>

</Project>

上述项目文件告诉构建系统我们想要:

  • 构建可运行的应用程序(而不是库);
  • 能够在.NET 8和.NET 7上运行(以便BenchmarkDotNet可以运行多个进程,一个使用.NET 7,一个使用.NET 8,以便能够比较结果);
  • 即使C# 12尚未正式发布,也能够使用C#语言的所有最新功能;
  • 自动导入常见的命名空间;
  • 能够在代码中使用unsafe关键字;
  • 并将垃圾收集器(GC)配置为其“服务器”配置,这会影响它在内存消耗和吞吐量之间做出的权衡(这不是绝对必要的,我只是习惯使用它,而且它是ASP.NET应用程序的默认设置)。

最后的从NuGet中提取BenchmarkDotNet,以便我们能够在Program.cs中使用该库。(少数基准测试需要添加其他软件包;我已在适用的情况下注明了这些。)

对于每个基准测试,我都包括了完整的Program.cs源代码;只需将该代码复制并粘贴到Program.cs中,替换其全部内容即可。在每个测试中,您会注意到可以将多个属性应用于Tests类。[MemoryDiagnoser]属性表示我希望它跟踪托管分配,[DisassemblyDiagnoser]属性表示我希望它报告测试生成的实际汇编代码(默认情况下,还会调用测试的一个级别的函数),而[HideColumns]属性仅仅是抑制了BenchmarkDotNet可能会默认发出但在这里不必要的一些数据列。

在这之后,运行基准测试就很简单了。每个显示的测试还包括一个注释,说明运行基准测试的dotnet命令。通常,它是这样的:

dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

上述dotnet run命令:

  • 以Release构建基准测试。这对于性能测试非常重要,因为在Debug构建中,C#编译器和JIT编译器中的大多数优化都被禁用。
  • 针对.NET 7的主机项目。通常情况下,使用BenchmarkDotNet时,您希望针对所有要执行的运行时的最低公共分母进行目标设置,以确保所有使用的API都在需要它们的地方都可用。
  • 运行整个程序中的所有基准测试。--filter参数可以缩小范围,仅运行所需的基准测试子集,但“*”表示“运行所有基准测试”。
  • 在.NET 7和.NET 8上运行测试。

在整篇文章中,我展示了许多基准测试和我运行它们时得到的结果。所有代码都可以在所有支持的操作系统和架构上正常工作。除非另有说明,否则基准测试的结果都是在Linux(Ubuntu 22.04)上运行的(x64处理器)。我的标准警告:这些是微基准测试,通常测量非常短的操作时间,但是当这些时间的改进一遍又一遍地执行时,它们的影响是显著的。不同的硬件、不同的操作系统、其他正在运行的程序、您当前的心情以及您早餐吃了什么都可能影响涉及的数字。简而言之,不要期望您看到的数字与我在此报告的数字完全匹配,尽管我选择了差异幅度完全可重复的示例。

现在,让我们开始吧...

JIT

代码生成贯穿我们编写的每一行代码,编译器生成的代码质量对应用程序的端到端性能至关重要。在.NET中,这是Just-In-Time(JIT)编译器的工作,它既可以在应用程序执行时“即时”使用,也可以在Ahead-Of-Time(AOT)场景中作为工作马在构建时执行代码生成。每个.NET版本都在JIT方面进行了重大改进,.NET 8也不例外。事实上,我敢说.NET 8在JIT方面的改进是一个惊人的飞跃,远远超过了过去所取得的成就,这在很大程度上要归功于动态PGO...

分层和动态PGO

要理解动态PGO,我们首先需要了解“分层”。多年来,.NET方法只编译一次:在第一次调用该方法时,JIT会启动以生成该方法的代码,然后该调用和每个后续调用都将使用该生成的代码。那是一个简单的时代,但也是一个充满冲突的时代...特别是,JIT应该在多大程度上投入到方法的代码质量以及从增强的代码质量中获得多少好处之间存在冲突。优化是编译器执行里最耗时的操作之一;编译器可以花费无数的时间搜索额外的方法来替换一个指令或改进指令序列。但是,我们没有无限的时间等待编译器完成,特别是在“即时”场景中,编译是在应用程序运行时发生。因此,在一个方法只需要为该进程编译一次的情况中,JIT必须要么降低代码质量,要么降低运行时间,这意味着需要在吞吐量和启动时间之间进行权衡。

然而,事实证明,在应用程序中调用的绝大多数方法只会被调用一次或少数几次。花费大量时间来优化这些方法实际上会导致不划算,因为优化它们可能需要比这些优化所获得的时间更长的时间。因此,.NET Core 3.0 引入了新的JIT功能,称为“tiered compilation”。通过分层,一个方法可能会被编译多次。在第一次调用时,该方法将在“tier 0”中编译,在该层中,JIT优先考虑编译速度而不是代码质量;实际上,JIT使用的模式通常被称为“min opts”或最小优化,因为它尽可能少地进行优化(它仍然保持一些优化,主要是那些导致要编译的代码更少,从而使JIT实际运行更快的优化)。除了最小化优化之外,它还加入调用计数“stubs”;当您调用该方法时,调用通过一个小的代码片段(the stub),该代码片段计算该方法被调用的次数,一旦该计数超过预定阈值(例如30次调用),该方法将排队进行重新编译,这次在 tier 1 中,JIT将所有能够进行的优化都投入到该方法中。只有少数方法才会进入 tier 1 ,而那些能够进入 tier 1 的方法是值得额外优化的方法。有趣的是,JIT可以从 tier 0 中了解到关于该方法的信息,这可以导致比直接编译到 tier 1 更好的 tier 1 代码质量。例如,JIT知道从 tier 0 升级到 tier 1 的方法已经被执行过了,如果它已经被执行过了,那么它访问的任何静态只读字段现在已经被初始化了,这意味着JIT可以查看这些字段的值,并基于实际字段中的内容(例如,如果它是一个静态只读bool,则JIT现在可以将该字段的值视为const bool)来生成 tier 1 代码。如果该方法直接编译到 tier 1 ,JIT可能无法进行相同的优化。因此,通过分层,我们既可以获得良好的启动时间,又可以获得良好的性能。

然而,这种方案有一个问题,存在一类运行时间较长的方法。方法可能很重要,因为它们被调用多次,但它们也可能很重要,因为它们只被调用了几次,但由于循环而一直运行着。因此,默认情况下,对于包含后向分支的方法,分层被禁用,这样这些方法将直接进入 tier 1 。为了解决这个问题,.NET 7引入了On-Stack Replacement(OSR)。使用OSR,循环生成的代码还包括计数机制,当循环迭代到一定阈值时,JIT将编译该方法的新优化版本,并从最小优化代码跳转到优化变体中继续执行。非常巧妙,因此在.NET 7中,分层也适用于具有循环的方法。

但是,为什么OSR很重要?如果只有少数这样的长时间运行的方法,如果它们直接进入 tier 1 ,那有什么大不了的呢?毫无疑问,启动时间不会受到显著的负面影响吧?首先,它可能会:如果您试图缩短启动时间的毫秒数,每个方法都很重要。但是,正如前面所述,通过 tier 0 进行优化可以获得吞吐量的好处,因为JIT可以从 tier 0 中了解到关于方法的信息,然后可以改进其 tier 1 编译。而且,JIT可以从动态PGO中学到的东西列表变得更加庞大。

基于配置文件的优化(PGO)已经存在了几十年,适用于许多语言和环境,包括.NET世界。典型的流程是使用一些额外的检查来构建应用程序,然后在关键场景下运行应用程序,收集该检查的结果,然后重新构建应用程序,将该检查数据提供给优化器,以便它使用有关代码执行方式的知识来影响其优化。这种方法通常被称为“静态PGO”。 “动态PGO”类似,只是不需要关注应用程序的构建方式、运行场景或任何其他方面。通过分层,JIT已经生成了代码的 tier 0 版本和 tier 1 版本……为什么不在 tier 0 代码中添加一些检查呢?然后JIT可以使用该检查的结果来更好地优化 tier 1 。这与静态PGO的基本“构建、运行和收集、重新构建”流程相同,但现在是在每个方法的基础上,在应用程序的执行中完全处理,由JIT自动为您处理,无需额外的开发工作或构建自动化或基础设施的额外投资。

动态PGO首次出现在 .NET 6 中预览,默认情况下是关闭的。它在 .NET 7 中得到了改进,但默认情况下仍然关闭。现在,在.NET 8 中,我很高兴地说,它不仅得到了显著改进,而且现在默认开启。这个dotnet/runtime#86225 只有一个字符的PR可能是.NET 8中最有价值的PR。

在.NET 8中,有许多PR使所有这些工作更加顺畅,包括分层和动态PGO。其中一个更有趣的变化是dotnet/runtime#70941,它增加了更多的分层,尽管我们仍将未优化的称为“tier 0”,将优化的称为“tier 1”。这主要是出于两个原因。首先,优化不是不产生消耗的;如果 tier 0 的目标是尽可能降低编译成本,那么我们希望避免添加更多要优化的代码。因此,该PR添加了一个新的层来解决这个问题。大多数代码首先编译为未经优化和未经检测的层(尽管具有循环的方法当前跳过此层)。然后,在一定数量的调用之后,它将被重新编译为未优化但已经过检查的代码。然后,在一定数量的调用之后,它将使用生成的检查过代码编译为优化的代码。其次,crossgen/ReadyToRun(R2R)映像以前无法参与动态PGO。但这对充分利用所有动态PGO所提供的优势的一个大问题,特别是因为每个.NET应用程序都使用已经使用了R2R的核心库,其中包含大量代码。。ReadyToRun是一种AOT技术,它使大部分代码生成工作可以在构建时完成,只需在准备执行预编译代码时应用一些最小的修复即可。该代码已经进行了优化,但没有进行检测,因为检测会减慢它的速度。因此,该PR还为R2R添加了一个新的层。在R2R方法被调用了一定次数之后,它会被重新编译,再次进行优化,但这次还会进行检测,然后当它被充分调用时,它会再次被提升,这次使用在前一层中收集的检测数据进行优化实现。

还有多个修改专注于在 tier 0 中进行更多的优化。如前所述,JIT希望能够尽快编译 tier 0 ,但是代码质量中的一些优化实际上有助于它实现这一点。例如,dotnet/runtime#82412 教它进行一定量的常量折叠(在编译时评估常量表达式而不是在执行时),因为这可以使它生成更少的代码。 JIT在编译 tier 0 时花费的大部分时间都是与.NET运行时的虚拟机(VM)层交互,例如解析类型,因此,如果它可以显着减少永远不会使用的分支,它实际上可以加快 tier 0 编译速度,同时获得更好的代码质量。我们可以通过以下简单的重现应用程序来看到这一点:

// dotnet run -c Release -f net8.0

MaybePrint(42.0);

static void MaybePrint<T>(T value)
{
    if (value is int)
        Console.WriteLine(value);
}

我可以将 DOTNET_JitDisasm 环境变量设置为 *MaybePrint*;这将导致JIT将其发出的代码打印到控制台上。在.NET 7上,当我运行此代码(dotnet run -c Release -f net7.0)时,我得到以下 tier 0 代码:

; Assembly listing for method Program:<<Main>$>g__MaybePrint|0_0[double](double)
; Emitting BLENDED_CODE for X64 CPU with AVX - Windows
; Tier-0 compilation
; MinOpts code
; rbp based frame
; partially interruptible

G_M000_IG01:                ;; offset=0000H
       55                   push     rbp
       4883EC30             sub      rsp, 48
       C5F877               vzeroupper
       488D6C2430           lea      rbp, [rsp+30H]
       33C0                 xor      eax, eax
       488945F8             mov      qword ptr [rbp-08H], rax
       C5FB114510           vmovsd   qword ptr [rbp+10H], xmm0

G_M000_IG02:                ;; offset=0018H
       33C9                 xor      ecx, ecx
       85C9                 test     ecx, ecx
       742D                 je       SHORT G_M000_IG03
       48B9B877CB99F97F0000 mov      rcx, 0x7FF999CB77B8
       E813C9AE5F           call     CORINFO_HELP_NEWSFAST
       488945F8             mov      gword ptr [rbp-08H], rax
       488B4DF8             mov      rcx, gword ptr [rbp-08H]
       C5FB104510           vmovsd   xmm0, qword ptr [rbp+10H]
       C5FB114108           vmovsd   qword ptr [rcx+08H], xmm0
       488B4DF8             mov      rcx, gword ptr [rbp-08H]
       FF15BFF72000         call     [System.Console:WriteLine(System.Object)]

G_M000_IG03:                ;; offset=0049H
       90                   nop

G_M000_IG04:                ;; offset=004AH
       4883C430             add      rsp, 48
       5D                   pop      rbp
       C3                   ret

; Total bytes of code 80

请注意,所有与 Console.WriteLine 相关的代码都需要可重编辑(emitted),包括JIT需要解析的方法标记(这是它知道要打印“System.Console:WriteLine”的方式),即使该分支永远不会被执行(仅在value为int且JIT可以看到value为double时才会执行)。现在,在.NET 8中,它应用了之前为 tier 1 保留的常量折叠优化,以识别该值不是int,并相应地生成 tier 0 代码(dotnet run -c Release -f net8.0):

; Assembly listing for method Program:<<Main>$>g__MaybePrint|0_0[double](double) (Tier0)
; Emitting BLENDED_CODE for X64 with AVX - Windows
; Tier0 code
; rbp based frame
; partially interruptible

G_M000_IG01:                ;; offset=0x0000
       push     rbp
       mov      rbp, rsp
       vmovsd   qword ptr [rbp+0x10], xmm0

G_M000_IG02:                ;; offset=0x0009

G_M000_IG03:                ;; offset=0x0009
       pop      rbp
       ret

; Total bytes of code 11

dotnet/runtime#77357dotnet/runtime#83002 还启用了一些JIT内部函数在 tier 0 中使用(JIT内部函数是JIT具有某些特殊知识的方法,可以根据其行为进行优化,或者在许多情况下实际上提供自己的实现以替换方法体中的实现)。这在某种程度上是出于同样的原因;许多内部函数可以导致更好的死代码消除(例如,if(typeof(T).IsValueType){...})。但更重要的是,如果不将内部函数识别为特殊函数,我们可能会为内部函数生成代码,而我们在任何情况下都不需要为其生成代码,即使在 tier 1 中也是如此。 dotnet/runtime#88989 还消除了 tier 0 中的一些装箱形式。

将所有这些度量标准收集在 tier 0,度量标准代码中会带来一些自身的挑战。即时编译器 (JIT) 增强了一组方法来跟踪大量附加数据:它在哪里以及如何跟踪它?当多个线程可能同时访问所有这些数据时,它如何安全且正确地做到这一点?例如,JIT 在度量标准方法中跟踪的一个事情是哪些分支被执行以及执行的频率;这需要它在代码每次遍历分支时进行计数。您可以想象,这种情况,好吧,发生得很多。它如何在线程安全且高效的方式下进行计数?

之前的答案是,它没有。它使用了竞争条件的、以非同步的方式更新共享值,例如 _branches[branchNum]++。这意味着在多线程访问的情况下,一些更新可能会丢失,但由于这里的答案只需要是近似值,所以被认为是可以接受的。然而,事实证明,在某些情况下,这还是导致了大量的计数丢失,进而导致JIT对错误的内容进行了优化。在 dotnet/runtime#82775 中为了比较而尝试的另一种方法是使用原子操作(例如,如果这是C#,使用 Interlocked.Increment );这会导致完美的准确性,但这种显式同步在线程高度竞争的情况下会成为一个巨大的瓶颈。dotnet/runtime#84427 提供了现在在.NET 8中默认启用的方法。它是一个可扩展的近似计数器的实现,它使用一定量的伪随机性来决定何时以及以增加多少共享计数。在 dotnet/runtime wiki中有一个关于所有这些的描述。这里是有关于该计数逻辑的C#实现:

static void Count(ref uint sharedCounter)
{
    uint currentCount = sharedCounter, delta = 1;
    if (currentCount > 0)
    {
        int logCount = 31 - (int)uint.LeadingZeroCount(currentCount);
        if (logCount >= 13)
        {
            delta = 1u << (logCount - 12);
            uint random = (uint)Random.Shared.NextInt64(0, uint.MaxValue + 1L);
            if ((random & (delta - 1)) != 0)
            {
                return;
            }
        }
    }

    Interlocked.Add(ref sharedCounter, delta);
}

对于当前计数器小于8192的情况,它最终只是做了相当于 Interlocked.Add(ref counter, 1) 的操作。然而,随着计数增加到超过该阈值,它开始随机地只有一半的时间进行加法,而且当它这样做时,它会加2。然后随机地四分之一的时间它会加4。然后八分之一的时间它会加8。以此类推。通过这种方式,随着越来越多的增量被执行,它需要越来越少地写入共享计数器。

我们可以使用以下类似的小应用程序进行测试(如果您想尝试运行它,请将上面的Count复制到程序中):

// dotnet run -c Release -f net8.0

using System.Diagnostics;

uint counter = 0;
const int ItersPerThread = 100_000_000;

while (true)
{
    Run("Interlock", _ => { for (int i = 0; i < ItersPerThread; i++) Interlocked.Increment(ref counter); });
    Run("Racy     ", _ => { for (int i = 0; i < ItersPerThread; i++) counter++; });
    Run("Scalable ", _ => { for (int i = 0; i < ItersPerThread; i++) Count(ref counter); });
    Console.WriteLine();
}

void Run(string name, Action<int> body)
{
    counter = 0;
    long start = Stopwatch.GetTimestamp();
    Parallel.For(0, Environment.ProcessorCount, body);
    long end = Stopwatch.GetTimestamp();
    Console.WriteLine($"{name} => Expected: {Environment.ProcessorCount * ItersPerThread:N0}, Actual: {counter,13:N0}, Elapsed: {Stopwatch.GetElapsedTime(start, end).TotalMilliseconds}ms");
}

当我运行它时,我得到了如下结果:

Interlock => Expected: 1,200,000,000, Actual: 1,200,000,000, Elapsed: 20185.548ms
Racy      => Expected: 1,200,000,000, Actual:   138,526,798, Elapsed: 987.4997ms
Scalable  => Expected: 1,200,000,000, Actual: 1,193,541,836, Elapsed: 1082.8471ms

我发现这些结果很有趣。原子操作方法得到了完全正确的计数,但它非常慢,比其他方法慢了约20倍。最快的是竞争条件的加法方法,但它的计数也非常不准确:它偏离预期值的8倍!可扩展计数器的解决方案只比竞争条件的解决方案慢一点,但它的计数只偏离预期值的0.5%。这种可扩展的方法使JIT能够以所需的效率和近似准确性跟踪所需的信息。其他的PR,如dotnet/runtime#82014dotnet/runtime#81731dotnet/runtime#81932,也有助于提高JIT在跟踪这些信息方面的效率。

事实证明,这不是动态PGO中随机性的唯一用途。另一个用途是作为确定虚拟和接口方法调用的最常见目标类型的一部分。在给定的调用点,JIT想知道哪种类型最常用,以及使用的百分比;如果有一个明显的赢家,它就可以生成一个特定于该类型的快速路径。与前面的例子一样,跟踪可能通过的每种可能类型的计数是昂贵的。相反,它使用一种称为“reservoir sampling”的算法。假设我有一个包含约60% 'a'、30% 'b'和10% 'c'的char[1_000_000],我想知道哪个是最常见的。使用reservoir sampling,我可以这样做:

// dotnet run -c Release -f net8.0

// Create random input for testing, with 60% a, 30% b, 10% c
char[] chars = new char[1_000_000];
Array.Fill(chars, 'a', 0, 600_000);
Array.Fill(chars, 'b', 600_000, 300_000);
Array.Fill(chars, 'c', 900_000, 100_000);
Random.Shared.Shuffle(chars);

for (int trial = 0; trial < 5; trial++)
{
    // Reservoir sampling
    char[] reservoir = new char[32]; // same reservoir size as the JIT
    int next = 0;
    for (int i = 0; i < reservoir.Length && next < chars.Length; i++, next++)
    {
        reservoir[i] = chars[i];
    }
    for (; next < chars.Length; next++)
    {
        int r = Random.Shared.Next(next + 1);
        if (r < reservoir.Length)
        {
            reservoir[r] = chars[next];
        }
    }

    // Print resulting percentages
    Console.WriteLine($"a: {reservoir.Count(c => c == 'a') * 100.0 / reservoir.Length}");
    Console.WriteLine($"b: {reservoir.Count(c => c == 'b') * 100.0 / reservoir.Length}");
    Console.WriteLine($"c: {reservoir.Count(c => c == 'c') * 100.0 / reservoir.Length}");
    Console.WriteLine();
}

当我运行这个时,我得到了如下的结果:

a: 53.125
b: 31.25
c: 15.625

a: 65.625
b: 28.125
c: 6.25

a: 68.75
b: 25
c: 6.25

a: 40.625
b: 31.25
c: 28.125

a: 59.375
b: 25
c: 15.625

注意,在上述示例中,实际上我在开始之前就已经拥有了所有数据;相比之下,JIT 可能具有多个线程,所有这些线程都在运行带有插件的代码并覆盖保留池中的元素。我还恰好选择了与JIT在dotnet/runtime#87332中使用的相同大小的保留池,这突显了该值是如何为其使用场景而选择的,以及为什么需要对其进行微调。

在上面的五次运行中,它正确地发现了比'b'更多的'a',比'c'更多的'b',并且通常与实际百分比相当接近。但是,重要的是,这里涉及到随机性,每次运行都会产生略微不同的结果。我之所以提到这一点,是因为这意味着JIT编译器现在包含了随机性,这意味着产生的动态PGO仪器化数据很可能在每次运行时略有不同。然而,即使没有明确使用随机性,在这样的代码中已经存在不确定性,通常产生足够的数据,整体行为是相当稳定和可重复的。

有趣的是,JIT基于PGO的优化不仅仅基于在仪器化的tier 0执行期间收集的数据。通过 dotnet/runtime#82926(以及一些后续的PR,如dotnet/runtime#83068dotnet/runtime#83567、dotnet/runtime#84312dotnet/runtime#84741),JIT现在将创建一个基于静态分析代码和估计配置文件的合成配置文件,例如使用各种方法进行静态分支预测。然后,JIT可以将这些数据与仪器化数据混合在一起,帮助填补数据的空白处(想象一下“侏罗纪公园”,使用现代爬行动物的DNA来填补恢复的恐龙DNA中的空白处)。

除了在.NET 8中启用分层和动态PGO的机制变得更好之外(我提到了吗,它默认开启了?!),它执行的优化也变得更好了。动态PGO提供的主要优化之一是能够针对每个调用点去虚拟化虚拟和接口调用。如前所述,JIT跟踪使用的具体类型,然后可以为最常见的类型生成快速路径;这称为保护式虚拟化(GDV)。考虑以下基准测试:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    internal interface IValueProducer
    {
        int GetValue();
    }

    class Producer42 : IValueProducer
    {
        public int GetValue() => 42;
    }

    private IValueProducer _valueProducer;
    private int _factor = 2;

    [GlobalSetup]
    public void Setup() => _valueProducer = new Producer42();

    [Benchmark]
    public int GetValue() => _valueProducer.GetValue() * _factor;
}

GetValue()会执行:

return _valueProducer.GetValue() * _factor;

不开启PGO,这只是一个普通的接口调用。然而,有了PGO,JIT最终会发现 _valueProducer 的实际类型最常见的是 Producer42,并且它最终会生成更接近于我的基准测试的tier 1代码:

int result = _valueProducer.GetType() == typeof(Producer42) ?
    Unsafe.As<Producer42>(_valueProducer).GetValue() :
    _valueProducer.GetValue();
return result * _factor;

我们可以通过运行上面的基准测试来确认这一点。结果数字肯定显示了一些情况:

Method Runtime Mean Ratio Code Size
GetValue .NET 7.0 1.6430 ns 1.00 35 B
GetValue .NET 8.0 0.0523 ns 0.03 57 B

我们看到它既更快(我们预期的),又更多的代码(我们也预期的)。现在看汇编代码。在.NET 7上,我们得到了这个:

; Tests.GetValue()
       push      rsi
       sub       rsp,20
       mov       rsi,rcx
       mov       rcx,[rsi+8]
       mov       r11,7FF999B30498
       call      qword ptr [r11]
       imul      eax,[rsi+10]
       add       rsp,20
       pop       rsi
       ret
; Total bytes of code 35

我们可以看到它执行了接口调用(三个movs后面跟着call),然后将结果乘以 _factor(imul eax,[rsi+10])。现在在.NET 8上,我们得到了这个:

; Tests.GetValue()
       push      rbx
       sub       rsp,20
       mov       rbx,rcx
       mov       rcx,[rbx+8]
       mov       rax,offset MT_Tests+Producer42
       cmp       [rcx],rax
       jne       short M00_L01
       mov       eax,2A
M00_L00:
       imul      eax,[rbx+10]
       add       rsp,20
       pop       rbx
       ret
M00_L01:
       mov       r11,7FFA1FAB04D8
       call      qword ptr [r11]
       jmp       short M00_L00
; Total bytes of code 57

我们仍然看到了调用,但它被埋藏在最后的冷区域中。相反,我们看到对象的类型与 MT_Tests+Producer42 进行比较,如果匹配(cmp [rcx],rax后面跟着jne),我们将2A存储到eax中;2A是42的十六进制表示,因此这是虚拟化的 Producer42.GetValue 调用的内联体的全部内容。.NET 8还能够执行多个GDV,这意味着它可以为多个类型生成快速路径,这在很大程度上得益于dotnet/runtime#86551dotnet/runtime#86809 。但是,默认情况下此功能处于关闭状态,现在需要使用配置设置进行选择(将DOTNET_JitGuardedDevirtualizationMaxTypeChecks环境变量设置为要测试的最大类型数)。我们可以通过这个基准测试看到它的影响(请注意,因为我已经在代码本身中明确指定了要使用的配置,所以我省略了dotnet命令中的 --runtimes 参数):

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

var config = DefaultConfig.Instance
    .AddJob(Job.Default.WithId("ChecksOne").WithRuntime(CoreRuntime.Core80))
    .AddJob(Job.Default.WithId("ChecksThree").WithRuntime(CoreRuntime.Core80)
    .WithEnvironmentVariable("DOTNET_JitGuardedDevirtualizationMaxTypeChecks", "3")); // 这里是测试的重点
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);

[HideColumns("Error", "StdDev", "Median", "RatioSD", "EnvironmentVariables")]
[DisassemblyDiagnoser]
public class Tests
{
    private readonly A _a = new();
    private readonly B _b = new();
    private readonly C _c = new();

    [Benchmark]
    public void Multiple()
    {
        DoWork(_a);
        DoWork(_b);
        DoWork(_c);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static int DoWork(IMyInterface i) => i.GetValue();

    private interface IMyInterface { int GetValue(); }
    private class A : IMyInterface { public int GetValue() => 123; }
    private class B : IMyInterface { public int GetValue() => 456; }
    private class C : IMyInterface { public int GetValue() => 789; }
}
Method Job Mean Code Size
Multiple ChecksOne 7.463 ns 90 B
Multiple ChecksThree 5.632 ns 133 B

而在设置环境变量的汇编代码中,我们确实可以看到它在回退到一般的接口调用之前对三种类型进行了多次检查:

; Tests.DoWork(IMyInterface)
       sub       rsp,28
       mov       rax,offset MT_Tests+A
       cmp       [rcx],rax
       jne       short M01_L00
       mov       eax,7B
       jmp       short M01_L02
M01_L00:
       mov       rax,offset MT_Tests+B
       cmp       [rcx],rax
       jne       short M01_L01
       mov       eax,1C8
       jmp       short M01_L02
M01_L01:
       mov       rax,offset MT_Tests+C
       cmp       [rcx],rax
       jne       short M01_L03
       mov       eax,315
M01_L02:
       add       rsp,28
       ret
M01_L03:
       mov       r11,7FFA1FAC04D8
       call      qword ptr [r11]
       jmp       short M01_L02
; Total bytes of code 88

有趣的是,这种优化在Native AOT中变得更好。在那里,通过dotnet/runtime#87055,可能不需要回退路径。编译器可以看到正在优化的整个程序,并且如果实现目标抽象的类型很少,它可以为所有类型生成快速路径。

dotnet/runtime#75140提供了另一个非常好的优化,仍然与GDV相关,但现在是针对委托和与循环克隆有关。考虑以下基准测试:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser]
public class Tests
{
    private readonly Func<int, int> _func = i => i + 1;

    [Benchmark]
    public int Sum() => Sum(_func);

    private static int Sum(Func<int, int> func)
    {
        int sum = 0;
        for (int i = 0; i < 10_000; i++)
        {
            sum += func(i);
        }

        return sum;
    }
}

动态PGO能够像虚拟和接口方法一样使用GDV来处理委托。JIT对这个方法的分析将突出显示被调用的函数始终是相同的 i => i + 1 lambda,正如我们所看到的,这可以转换为以下伪代码的方法:

private static int Sum(Func<int, int> func)
{
    int sum = 0;
    for (int i = 0; i < 10_000; i++)
    {
        sum += func.Method == KnownLambda ? i + 1 : func(i);
    }

    return sum;
}

在我们的循环内部执行相同的检查并进行分支的情况并不明显。一个常见的编译器优化是“hoisting”,其中一个“loop invariant”(即每次迭代不会改变)的计算可以被拉出循环到它的上面,例如:

private static int Sum(Func<int, int> func)
{
    int sum = 0;
    bool isAdd = func.Method == KnownLambda;
    for (int i = 0; i < 10_000; i++)
    {
        sum += isAdd ? i + 1 : func(i);
    }

    return sum;
}

但即使这样,我们仍然在每次迭代中进行分支。如果我们也可以提升它会很好吧?如果我们可以“clone”循环,将其复制一次用于已知目标的方法,一次用于未知目标的方法。这就是“loop cloning”,这是JIT已经能够进行的优化,现在在.NET 8中,JIT也能够处理这种情况。它将生成的代码最终非常类似于这样:

private static int Sum(Func<int, int> func)
{
    int sum = 0;
    if (func.Method == KnownLambda)
    {
        for (int i = 0; i < 10_000; i++)
        {
            sum += i + 1;
        }
    }
    else
    {
        for (int i = 0; i < 10_000; i++)
        {
            sum += func(i);
        }
    }
    return sum;
}

在.NET 8上生成的汇编代码证实了这一点:

; Tests.Sum(System.Func`2<Int32,Int32>)
       push      rdi
       push      rsi
       push      rbx
       sub       rsp,20
       mov       rbx,rcx
       xor       esi,esi
       xor       edi,edi
       test      rbx,rbx
       je        short M01_L01
       mov       rax,7FFA2D630F78
       cmp       [rbx+18],rax
       jne       short M01_L01
M01_L00:
       inc       edi
       mov       eax,edi
       add       esi,eax
       cmp       edi,2710
       jl        short M01_L00
       jmp       short M01_L03
M01_L01:
       mov       rax,7FFA2D630F78
       cmp       [rbx+18],rax
       jne       short M01_L04
       lea       eax,[rdi+1]
M01_L02:
       add       esi,eax
       inc       edi
       cmp       edi,2710
       jl        short M01_L01
M01_L03:
       mov       eax,esi
       add       rsp,20
       pop       rbx
       pop       rsi
       pop       rdi
       ret
M01_L04:
       mov       edx,edi
       mov       rcx,[rbx+8]
       call      qword ptr [rbx+18]
       jmp       short M01_L02
; Total bytes of code 103

聚焦于M01_L00块:您可以看到它以 jl short M01_L00 结束,以便在edi(存储 i )小于0x2710或10,000十进制的循环上限时回到M01_L00。请注意,中间只有几个指令,根本没有类似于 call... 这是优化的 cloned loop,其中我们的lambda已被内联。还有另一个循环,交替使用M01_L02、M01_L01和M01_L04,其中有一个 call... 那就是回调的循环。如果我们运行基准测试,我们会看到巨大的改进:

Method Runtime Mean Ratio Code Size
Sum .NET 7.0 16.546 us 1.00 55 B
Sum .NET 8.0 2.320 us 0.14 113 B

只要我们讨论提升,就值得注意其他改进也做出了贡献。特别是,dotnet/runtime#81635使JIT能够提升更多用于通用方法调度的代码。我们可以通过这样的基准测试看到它的效果:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    [Benchmark]
    public void Test() => Test<string>();

    static void Test<T>()
    {
        for (int i = 0; i < 100; i++)
        {
            Callee<T>();
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    static void Callee<T>() { }
}
Method Runtime Mean Ratio
Test .NET 7.0 170.8 ns 1.00
Test .NET 8.0 147.0 ns 0.86

在继续之前,有一个关于动态PGO的警告:它非常擅长它所做的事情,真的很好。为什么这是一个“警告”?动态PGO非常擅长看到您的代码正在做什么并为其进行优化,当您谈论生产应用程序时,这非常棒。但是有一种特定的编码方式,您可能不希望发生这种情况,或者至少需要非常清楚地知道它正在发生,而您正在查看的就是这种情况:基准测试。微基准测试的全部内容都是隔离特定的功能,并一遍又一遍地运行它,以便获得有关其开销的良好测量。然而,使用动态PGO,JIT将为您正在测试的确切内容进行优化。如果您正在测试的内容恰好是代码在生产中执行的方式,那么太棒了。但是,如果您的测试不完全代表性,您可能会对涉及的成本产生扭曲的理解,这可能会导致做出不太理想的假设和决策。

例如,考虑以下基准测试:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;

var config = DefaultConfig.Instance
    .AddJob(Job.Default.WithId("No PGO").WithRuntime(CoreRuntime.Core80).WithEnvironmentVariable("DOTNET_TieredPGO", "0"))
    .AddJob(Job.Default.WithId("PGO").WithRuntime(CoreRuntime.Core80));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);

[HideColumns("Error", "StdDev", "Median", "RatioSD", "EnvironmentVariables")]
public class Tests
{
    private static readonly Random s_rand = new();
    private readonly IEnumerable<int> _source = Enumerable.Repeat(0, 1024);

    [Params(1.0, 0.5)]
    public double Probability { get; set; }

    [Benchmark]
    public bool Any() => s_rand.NextDouble() < Probability ?
        _source.Any(i => i == 42) :
        _source.Any(i => i == 43);
}

这将使用两个不同的“Probability”值,运行基准测试。无论该值如何,用于基准测试的代码都完全相同,并且应该产生完全相同的汇编代码(除了一个路径检查值是否为42,另一个是否为43)。在没有PGO的世界中,运行之间的性能差异应该接近于零,如果我们将 DOTNET_TieredPGO 环境变量设置为0(以禁用PGO),那么我们就会看到这一点,但是使用PGO,我们观察到更大的差异:

Method Job Probability Mean
Any No PGO 0.5 5.354 us
Any No PGO 1 5.314 us
Any PGO 0.5 1.969 us
Any PGO 1 1.495 us

当所有调用都使用i == 42(因为我们将概率设置为1,所有随机值都小于该值,我们总是采用第一个分支)时,我们发现吞吐量最终比当一半调用使用i == 42,一半使用i == 43时快25%。如果您的基准测试仅尝试测量使用Enumerable.Any的开销,您可能不会意识到生成的代码正在被优化为每次使用相同的委托调用Any,在这种情况下,您将获得与使用多个委托调用Any并且所有委托被合理地等概率使用时不同的结果。(顺便说一下,禁用和启用动态PGO之间的良好总体改进部分来自于使用Random,它在内部进行虚拟调用,动态PGO可以帮助省略。)

dotnet/runtime#80335和dotnet/runtime#80848在整个dotnet/runtime中推出了这个功能。特别是从第一个PR中可以看出,有数百个地方被识别出来,只需编辑一个字符(例如,将IList<T> 替换为List<T>),我们可能就可以减少开销。

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;

var config = DefaultConfig.Instance
    .AddJob(Job.Default.WithId("No PGO").WithRuntime(CoreRuntime.Core80).WithEnvironmentVariable("DOTNET_TieredPGO", "0"))
    .AddJob(Job.Default.WithId("PGO").WithRuntime(CoreRuntime.Core80));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);

[HideColumns("Error", "StdDev", "Median", "RatioSD", "EnvironmentVariables")]
public class Tests
{
    private readonly IList<int> _ilist = new List<int>();
    private readonly List<int> _list = new();

    [Benchmark]
    public void IList()
    {
        _ilist.Add(42);
        _ilist.Clear();
    }

    [Benchmark]
    public void List()
    {
        _list.Add(42);
        _list.Clear();
    }
}
Method Job Mean
IList No PGO 2.876 ns
IList PGO 1.777 ns
List No PGO 1.718 ns
List PGO 1.476 ns

向量化

.NET 8中代码生成的另一个重要领域是向量化。这是多个.NET版本的持续主题。将近十年前,.NET 添加了 Vector<T> 类型。.NET Core 3.0和.NET 5添加了数千个内部方法,用于直接针对特定的硬件指令。.NET 7提供了数百个跨平台操作,用于Vector128<T> 和 Vector256<T> ,以在固定宽度向量上启用SIMD算法。现在,在.NET 8中,.NET获得了对AVX512的支持,既有新的硬件内部方法直接暴露AVX512指令,也有新的 Vector512 和 Vector512<T> 类型。

有大量的更改用于改进现有的SIMD支持,例如dotnet/runtime#76221,它通过将其降为两个Vector128<T>操作来改进在未经硬件加速时处理Vector256<T>的方式。还有像dotnet/runtime#87283这样的更改,它删除了所有向量类型中T的通用约束,以使它们更容易在更大的上下文集中使用。但是,在这个版本中,这个领域的大部分工作都集中在AVX512上。

Wikipedia对[AVX512(https://en.wikipedia.org/wiki/AVX-512)]有一个很好的概述,它提供了一次处理512位的指令。除了提供先前指令集中看到的256位指令的更大位宽外,它还添加了新操作,几乎所有这些操作都通过System.Runtime.Intrinsics.X86中的新类型来操作,例如Avx512BW、AVX512CD、Avx512DQ、Avx512F和Avx512Vbmi。dotnet/runtime#83040 通过配置文件文件来开启这些功能,然后是数十个PR,填充了功能,例如dotnet/runtime#84909,它添加了已经存在的SSE到SSE4.2内部函数的512位变体;像来自@DeepakRajendrakumaran的dotnet/runtime#75934和dotnet/runtime#77419,它们添加了AVX512指令使用的EVEX编码的支持;像来自@DeepakRajendrakumaran的dotnet/runtime#74113,它添加了检测AVX512支持的逻辑;像来自@DeepakRajendrakumaran的dotnet/runtime#80960和来自@anthonycanino的dotnet/runtime#79544,它们让寄存器分配器和发射器了解AVX512的附加寄存器;以及像来自@Ruihan-Yin的dotnet/runtime#87946和来自@jkrishnavs的dotnet/runtime#84937,它们传递了各种内部函数的知识。

让我们试试它。我正在编写本文的计算机CPU补支持AVX512,但是我的Dev Box有,因此我在使用它进行AVX512比较(使用WSL和Ubuntu)。在去年的.NET 7性能改进中,我们编写了一个Contains方法,如果有足够的数据可用并且它被加速,就使用Vector256,否则使用Vector128,如果有足够的数据可用并且它被加速,否则使用标量回退。将其调整为在AVX512上也“启用”只花了我不到30秒的时间:复制/粘贴Vector256的代码块,然后在该副本中搜索并替换“Vector256”为“Vector512”...完成。这是一个基准测试,使用环境变量禁用JIT使用各种指令集的能力,以便我们可以尝试使用每个加速路径来测试此方法:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Intrinsics;

var config = DefaultConfig.Instance
    .AddJob(Job.Default.WithId("Scalar").WithEnvironmentVariable("DOTNET_EnableHWIntrinsic", "0").AsBaseline())
    .AddJob(Job.Default.WithId("Vector128").WithEnvironmentVariable("DOTNET_EnableAVX2", "0").WithEnvironmentVariable("DOTNET_EnableAVX512F", "0"))
    .AddJob(Job.Default.WithId("Vector256").WithEnvironmentVariable("DOTNET_EnableAVX512F", "0"))
    .AddJob(Job.Default.WithId("Vector512"));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);

[HideColumns("Error", "StdDev", "Median", "RatioSD", "EnvironmentVariables", "value")]
public class Tests
{
    private readonly byte[] _data = Enumerable.Repeat((byte)123, 999).Append((byte)42).ToArray();

    [Benchmark]
    [Arguments((byte)42)]
    public bool Find(byte value) => Contains(_data, value);

    private static unsafe bool Contains(ReadOnlySpan<byte> haystack, byte needle)
    {
        if (Vector128.IsHardwareAccelerated && haystack.Length >= Vector128<byte>.Count)
        {
            ref byte current = ref MemoryMarshal.GetReference(haystack);

            if (Vector512.IsHardwareAccelerated && haystack.Length >= Vector512<byte>.Count)
            {
                Vector512<byte> target = Vector512.Create(needle);
                ref byte endMinusOneVector = ref Unsafe.Add(ref current, haystack.Length - Vector512<byte>.Count);
                do
                {
                    if (Vector512.EqualsAny(target, Vector512.LoadUnsafe(ref current)))
                        return true;

                    current = ref Unsafe.Add(ref current, Vector512<byte>.Count);
                }
                while (Unsafe.IsAddressLessThan(ref current, ref endMinusOneVector));

                if (Vector512.EqualsAny(target, Vector512.LoadUnsafe(ref endMinusOneVector)))
                    return true;
            }
            else if (Vector256.IsHardwareAccelerated && haystack.Length >= Vector256<byte>.Count)
            {
                Vector256<byte> target = Vector256.Create(needle);
                ref byte endMinusOneVector = ref Unsafe.Add(ref current, haystack.Length - Vector256<byte>.Count);
                do
                {
                    if (Vector256.EqualsAny(target, Vector256.LoadUnsafe(ref current)))
                        return true;

                    current = ref Unsafe.Add(ref current, Vector256<byte>.Count);
                }
                while (Unsafe.IsAddressLessThan(ref current, ref endMinusOneVector));

                if (Vector256.EqualsAny(target, Vector256.LoadUnsafe(ref endMinusOneVector)))
                    return true;
            }
            else
            {
                Vector128<byte> target = Vector128.Create(needle);
                ref byte endMinusOneVector = ref Unsafe.Add(ref current, haystack.Length - Vector128<byte>.Count);
                do
                {
                    if (Vector128.EqualsAny(target, Vector128.LoadUnsafe(ref current)))
                        return true;

                    current = ref Unsafe.Add(ref current, Vector128<byte>.Count);
                }
                while (Unsafe.IsAddressLessThan(ref current, ref endMinusOneVector));

                if (Vector128.EqualsAny(target, Vector128.LoadUnsafe(ref endMinusOneVector)))
                    return true;
            }
        }
        else
        {
            for (int i = 0; i < haystack.Length; i++)
                if (haystack[i] == needle)
                    return true;
        }

        return false;
    }
}
Method Job Mean Ratio
Find Scalar 461.49 ns 1.00
Find Vector128 37.94 ns 0.08
Find Vector256 22.98 ns 0.05
Find Vector512 10.93 ns 0.02

然后,在JIT的其他地方,当可用时,利用AVX512支持。例如,与AVX512分开,dotnet/runtime#83945和dotnet/runtime#84530教JIT如何展开SequenceEqual操作,以便JIT可以在至少一个输入的长度为常量时发出优化的矢量替换。 “Unrolling” 意味着不是发出N次迭代的循环,每次迭代都执行一次循环体,而是发出N / M次迭代的循环,每次迭代都执行M次循环体(如果N == M,则根本没有循环)。因此,对于这样的基准测试:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    private byte[] _scheme = "Transfer-Encoding"u8.ToArray();

    [Benchmark]
    public bool SequenceEqual() => "Transfer-Encoding"u8.SequenceEqual(_scheme);
}

我们现在得到了这样的结果:

Method Runtime 平均值 比率 代码大小
SequenceEqual .NET 7.0 3.0558 ns 1.00 65 B
SequenceEqual .NET 8.0 0.8055 ns 0.26 91 B

对于.NET 7,我们看到类似于这样的汇编代码(请注意调用底层SequenceEqual的调用指令):

; Tests.SequenceEqual()
       sub       rsp,28
       mov       r8,1D7BB272E48
       mov       rcx,[rcx+8]
       test      rcx,rcx
       je        short M00_L03
       lea       rdx,[rcx+10]
       mov       eax,[rcx+8]
M00_L00:
       mov       rcx,r8
       cmp       eax,11
       je        short M00_L02
       xor       eax,eax
M00_L01:
       add       rsp,28
       ret
M00_L02:
       mov       r8d,11
       call      qword ptr [7FF9D33CF120]; System.SpanHelpers.SequenceEqual(Byte ByRef, Byte ByRef, UIntPtr)
       jmp       short M00_L01
M00_L03:
       xor       edx,edx
       xor       eax,eax
       jmp       short M00_L00
; Total bytes of code 65

而 .Net8 的代码如下:

; Tests.SequenceEqual()
       vzeroupper
       mov       rax,1EBDDA92D38
       mov       rcx,[rcx+8]
       test      rcx,rcx
       je        short M00_L01
       lea       rdx,[rcx+10]
       mov       r8d,[rcx+8]
M00_L00:
       cmp       r8d,11
       jne       short M00_L03
       vmovups   xmm0,[rax]
       vmovups   xmm1,[rdx]
       vmovups   xmm2,[rax+1]
       vmovups   xmm3,[rdx+1]
       vpxor     xmm0,xmm0,xmm1
       vpxor     xmm1,xmm2,xmm3
       vpor      xmm0,xmm0,xmm1
       vptest    xmm0,xmm0
       sete      al
       movzx     eax,al
       jmp       short M00_L02
M00_L01:
       xor       edx,edx
       xor       r8d,r8d
       jmp       short M00_L00
M00_L02:
       ret
M00_L03:
       xor       eax,eax
       jmp       short M00_L02
; Total bytes of code 91

现在没有调用,整个实现由JIT提供;我们可以看到它大量使用了128位xmm SIMD寄存器。然而,这些PR仅使JIT能够处理最多64个字节的比较(展开会导致更大的代码,因此在某些长度上不再展开是没有意义的)。随着JIT中AVX512支持的出现,dotnet/runtime#84854将其扩展到128个字节。这在类似于先前示例但具有更大数据的基准测试中很容易看到:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    private byte[] _data1, _data2;

    [GlobalSetup]
    public void Setup()
    {
        _data1 = Enumerable.Repeat((byte)42, 200).ToArray();
        _data2 = (byte[])_data1.Clone();
    }

    [Benchmark]
    public bool SequenceEqual() => _data1.AsSpan(0, 128).SequenceEqual(_data2.AsSpan(128));
}

在支持 AVX512 的电脑上, .NET 8编译结果:

; Tests.SequenceEqual()
       sub       rsp,28
       vzeroupper
       mov       rax,[rcx+8]
       test      rax,rax
       je        short M00_L01
       cmp       dword ptr [rax+8],80
       jb        short M00_L01
       add       rax,10
       mov       rcx,[rcx+10]
       test      rcx,rcx
       je        short M00_L01
       mov       edx,[rcx+8]
       cmp       edx,80
       jb        short M00_L01
       add       rcx,10
       add       rcx,80
       add       edx,0FFFFFF80
       cmp       edx,80
       je        short M00_L02
       xor       eax,eax
M00_L00:
       vzeroupper
       add       rsp,28
       ret
M00_L01:
       call      qword ptr [7FF820745F08]
       int       3
M00_L02:
       vmovups   zmm0,[rax]
       vmovups   zmm1,[rcx]
       vmovups   zmm2,[rax+40]
       vmovups   zmm3,[rcx+40]
       vpxorq    zmm0,zmm0,zmm1
       vpxorq    zmm1,zmm2,zmm3
       vporq     zmm0,zmm0,zmm1
       vxorps    ymm1,ymm1,ymm1
       vpcmpeqq  k1,zmm0,zmm1
       kortestb  k1,k1
       setb      al
       movzx     eax,al
       jmp       short M00_L00
; Total bytes of code 154

现在,我们不再看到128位xmm寄存器的使用,而是看到了来自AVX512的512位zmm寄存器的使用。

在.NET 8中,JIT现在展开memmoves(CopyTo,ToArray等)以处理足够小的常量长度,这要归功于dotnet/runtime#83638和dotnet/runtime#83740。然后,随着dotnet/runtime#84348的出现,如果可用,展开将利用AVX512。dotnet/runtime#85501也将此扩展到Span<T>.Fill。

dotnet/runtime#84885扩展了作为字符串/ReadOnlySpan<char> Equals和StartsWith的一部分完成的展开和矢量化,以利用AVX512(如果可用)。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    private readonly string _str = "Let me not to the marriage of true minds admit impediments";

    [Benchmark]
    public bool Equals() => _str.AsSpan().Equals(
        "LET ME NOT TO THE MARRIAGE OF TRUE MINDS ADMIT IMPEDIMENTS",
        StringComparison.OrdinalIgnoreCase);
}
Method Runtime 平均值 比率 代码大小
Equals .NET 7.0 30.995 ns 1.00 101 B
Equals .NET 8.0 1.658 ns 0.05 116 B

在.NET 8中速度非常快,因为与.NET 7不同,它最终会调用底层的方法:

; Tests.Equals()
       sub       rsp,48
       xor       eax,eax
       mov       [rsp+28],rax
       vxorps    xmm4,xmm4,xmm4
       vmovdqa   xmmword ptr [rsp+30],xmm4
       mov       [rsp+40],rax
       mov       rcx,[rcx+8]
       test      rcx,rcx
       je        short M00_L03
       lea       rdx,[rcx+0C]
       mov       ecx,[rcx+8]
M00_L00:
       mov       r8,21E57C058A0
       mov       r8,[r8]
       add       r8,0C
       cmp       ecx,3A
       jne       short M00_L02
       mov       rcx,rdx
       mov       rdx,r8
       mov       r8d,3A
       call      qword ptr [7FF8194B1A08]; System.Globalization.Ordinal.EqualsIgnoreCase(Char ByRef, Char ByRef, Int32)
M00_L01:
       nop
       add       rsp,48
       ret
M00_L02:
       xor       eax,eax
       jmp       short M00_L01
M00_L03:
       xor       ecx,ecx
       xor       edx,edx
       xchg      rcx,rdx
       jmp       short M00_L00
; Total bytes of code 101

在.NET 8中,JIT直接为操作生成代码,利用AVX512的更大位宽,因此能够处理更大的输入而不会显著增加代码大小:


; Tests.Equals()
       vzeroupper
       mov       rax,[rcx+8]
       test      rax,rax
       jne       short M00_L00
       xor       ecx,ecx
       xor       edx,edx
       jmp       short M00_L01
M00_L00:
       lea       rcx,[rax+0C]
       mov       edx,[rax+8]
M00_L01:
       cmp       edx,3A
       jne       short M00_L02
       vmovups   zmm0,[rcx]
       vmovups   zmm1,[7FF820495080]
       vpternlogq zmm0,zmm1,[7FF8204950C0],56
       vmovups   zmm1,[rcx+34]
       vporq     zmm1,zmm1,[7FF820495100]
       vpternlogq zmm0,zmm1,[7FF820495140],0F6
       vxorps    ymm1,ymm1,ymm1
       vpcmpeqq  k1,zmm0,zmm1
       kortestb  k1,k1
       setb      al
       movzx     eax,al
       jmp       short M00_L03
M00_L02:
       xor       eax,eax
M00_L03:
       vzeroupper
       ret
; Total bytes of code 116

即使是极为简单的操作也会参与其中。在这里,我们只是将一个 ulong 类型转换为 double 类型:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD", "val")]
[DisassemblyDiagnoser]
public class Tests
{
    [Benchmark]
    [Arguments(1234567891011121314ul)]
    public double UIntToDouble(ulong val) => val;
}

感谢 khushal1996 提出的 dotnet/runtime#84384 问题,该操作的代码缩小为以下内容:

; Tests.UIntToDouble(UInt64)
       vzeroupper
       vxorps    xmm0,xmm0,xmm0
       vcvtsi2sd xmm0,xmm0,rdx
       test      rdx,rdx
       jge       short M00_L00
       vaddsd    xmm0,xmm0,qword ptr [7FF819E776C0]
M00_L00:
       ret
; Total bytes of code 26

使用 AVX vcvtsi2sd 的方法如下:

; Tests.UIntToDouble(UInt64)
       vzeroupper
       vcvtusi2sd xmm0,xmm0,rdx
       ret
; Total bytes of code 10

使用 AVX512 指令 vcvtusi2sd。
另一个例子是,在 dotnet/runtime#87641 中,我们发现即时编译器(JIT)利用 AVX512 加速了各种数学 API:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD", "left", "right")]
public class Tests
{
    [Benchmark]
    [Arguments(123456.789f, 23456.7890f)]
    public float Max(float left, float right) => MathF.Max(left, right);
}
Method Runtime 平均值 比率
Max .NET 7.0 1.1936 ns 1.00
Max .NET 8.0 0.2865 ns 0.24

分支

分支对于所有有意义的代码都是必不可少的;虽然有些算法是以无分支的方式编写的,但无分支的算法通常很难正确实现和阅读,并且通常仅限于代码的小区域。对于其他所有内容,如 game.Loops,if/else块,三元运算符……很难想象没有它们的任何真实代码。然而,它们也可能代表应用程序中更重要的开销之一。现代硬件从流水线中获得了巨大的速度提升,例如能够在前一条指令仍在处理时开始读取和解码下一条指令。当然,这依赖于硬件知道下一条指令是什么。如果没有分支,那很容易,它就是序列中的下一条指令。对于存在分支的情况,CPU具有内置支持,以分支预测器的形式使用,用于确定下一条指令最可能是什么,它们通常是正确的……但是当它们错误时,由于不正确的分支预测而产生的成本可能是巨大的。因此,编译器努力将分支最小化。

一种减少分支影响的方法是完全删除它们。冗余分支优化器寻找编译器可以证明所有通向该分支的路径都将导致相同结果的地方,以便编译器可以删除分支和未被采用的路径中的所有内容。考虑以下示例:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    private static readonly Random s_rand = new();
    private readonly string _text = "hello world!";

    [Params(1.0, 0.5)]
    public double Probability { get; set; }

    [Benchmark]
    public ReadOnlySpan<char> TrySlice() => SliceOrDefault(_text.AsSpan(), s_rand.NextDouble() < Probability ? 3 : 20);

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ReadOnlySpan<char> SliceOrDefault(ReadOnlySpan<char> span, int i)
    {
        if ((uint)i < (uint)span.Length)
        {
            return span.Slice(i);
        }

        return default;
    }
}

在.NET 7上运行它,我们可以瞥见分支预测失败的影响。当我们总是以相同的方式采取分支时,吞吐量是之前不可能确定我们下一步去哪里时的2.5倍:

Method 概率 平均值 代码大小
TrySlice 0.5 8.845 ns 136 B
TrySlice 1 3.436 ns 136 B

我们还可以将此示例用于.NET 8的改进。那个受保护的ReadOnlySpan<char>.Slice调用具有自己的分支,以确保i在跨度范围内;我们可以通过查看在.NET 7上生成的反汇编来清楚地看到这一点:

; Tests.TrySlice()
       push      rdi
       push      rsi
       push      rbp
       push      rbx
       sub       rsp,28
       vzeroupper
       mov       rdi,rcx
       mov       rsi,rdx
       mov       rcx,[rdi+8]
       test      rcx,rcx
       je        short M00_L01
       lea       rbx,[rcx+0C]
       mov       ebp,[rcx+8]
M00_L00:
       mov       rcx,1EBBFC01FA0
       mov       rcx,[rcx]
       mov       rcx,[rcx+8]
       mov       rax,[rcx]
       mov       rax,[rax+48]
       call      qword ptr [rax+20]
       vmovsd    xmm1,qword ptr [rdi+10]
       vucomisd  xmm1,xmm0
       ja        short M00_L02
       mov       eax,14
       jmp       short M00_L03
M00_L01:
       xor       ebx,ebx
       xor       ebp,ebp
       jmp       short M00_L00
M00_L02:
       mov       eax,3
M00_L03:
       cmp       eax,ebp
       jae       short M00_L04
       cmp       eax,ebp
       ja        short M00_L06
       mov       edx,eax
       lea       rdx,[rbx+rdx*2]
       sub       ebp,eax
       jmp       short M00_L05
M00_L04:
       xor       edx,edx
       xor       ebp,ebp
M00_L05:
       mov       [rsi],rdx
       mov       [rsi+8],ebp
       mov       rax,rsi
       add       rsp,28
       pop       rbx
       pop       rbp
       pop       rsi
       pop       rdi
       ret
M00_L06:
       call      qword ptr [7FF999FEB498]
       int       3
; Total bytes of code 136

特别是看看M00_L03:

M00_L03:
       cmp       eax,ebp
       jae       short M00_L04
       cmp       eax,ebp
       ja        short M00_L06
       mov       edx,eax
       lea       rdx,[rbx+rdx*2]

此时,eax中已经加载了3或20(0x14),并且正在与先前从span的Length中加载的ebp进行比较(mov ebp,[rcx+8])。这里有一个非常明显的冗余分支,因为代码执行了cmp eax,ebp,然后如果它没有作为jae的一部分跳转,它会再次执行完全相同的比较;第一个是我们在TrySlice中编写的,第二个是来自Slice本身的,它被内联了。

在.NET 8上,由于dotnet/runtime#72979和dotnet/runtime#75804,该分支(以及许多类似的分支)被优化掉了。我们可以在.NET 8上运行完全相同的基准测试,如果我们查看相应代码块的汇编(由于其他更改而没有完全相同的编号):

M00_L04:
       cmp       eax,ebp
       jae       short M00_L07
       mov       ecx,eax
       lea       rdx,[rdi+rcx*2]

我们可以看到,确实已经消除了冗余分支。

消除与分支(和分支错误预测)相关的开销的另一种方法是完全避免它们。有时可以使用简单的位操作技巧来避免分支。例如,来自@pedrobsaila的dotnet/runtime#62689找到了像i >= 0 && j >= 0这样的表达式,用于有符号整数i和j,并将它们重写为等效的(i | j) >= 0。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD", "i", "j")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    [Benchmark]
    [Arguments(42, 84)]
    public bool BothGreaterThanOrEqualZero(int i, int j) => i >= 0 && j >= 0;
}

在这里,我们不会像在.NET 7上一样得到涉及&&的代码分支:

; Tests.BothGreaterThanOrEqualZero(Int32, Int32)
      test      edx,edx
      jl        short M00_L00
      mov       eax,r8d
      not       eax
      shr       eax,1F
      ret
M00_L00:
      xor       eax,eax
      ret
; Total bytes of code 16
; Tests.BothGreaterThanOrEqualZero(Int32, Int32)
      test      edx,edx
      jl        short M00_L00
      mov       eax,r8d
      not       eax
      shr       eax,1F
      ret
M00_L00:
      xor       eax,eax
      ret
; Total bytes of code 16

在 .NET 8 中,我们得到了一个更简单的版本,它不涉及分支:

; Tests.BothGreaterThanOrEqualZero(Int32, Int32)
       or        edx,r8d
       mov       eax,edx
       not       eax
       shr       eax,1F
       ret
; Total bytes of code 11

这样的位操作只能帮助你走得更远。为了走得更远,x86/64和Arm都提供了条件移动指令,例如x86/64上的cmov和Arm上的csel,它们将条件封装到单个指令中。例如,csel“有条件地选择”来自两个寄存器参数之一的值,根据条件是真还是假,并将该值写入目标寄存器。然后指令流水线保持填充状态,因为csel之后的指令总是下一条指令;没有控制流,会导致下一条指令不同。

在.NET 8中,JIT现在能够在x86/64和Arm上发出条件指令。通过像dotnet/runtime#73472(来自@a74nh)和dotnet/runtime#77728(来自@a74nh)这样的PR,JIT获得了额外的“if conversion”优化阶段,在其中识别和变形各种条件模式,成为JIT内部表示中的条件节点;然后可以稍后将它们作为条件指令发出,就像dotnet/runtime#78879、dotnet/runtime#81267、dotnet/runtime#82235、dotnet/runtime#82766和dotnet/runtime#83089所做的那样。其他PR,例如dotnet/runtime#84926(来自@SwapnilGaikwad)和dotnet/runtime#82031(来自@SwapnilGaikwad)优化了将使用哪些确切指令,在这些情况下使用Arm cinv和cinc指令。

我们可以在一个简单的基准测试中看到所有这些:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    private static readonly Random s_rand = new();

    [Params(1.0, 0.5)]
    public double Probability { get; set; }

    [Benchmark]
    public FileOptions GetOptions() => GetOptions(s_rand.NextDouble() < Probability);

    private static FileOptions GetOptions(bool useAsync) => useAsync ? FileOptions.Asynchronous : FileOptions.None;
}
Method Runtime 概率 平均值 比率 代码大小
GetOptions .NET 7.0 0.5 7.952 ns 1.00 64 B
GetOptions .NET 8.0 0.5 2.327 ns 0.29 86 B
GetOptions .NET 7.0 1 2.587 ns 1.00 64 B
GetOptions .NET 8.0 1 2.357 ns 0.91 86 B

有两件事需要注意:

  • 在 .NET 7 中,当概率为 0.5 时,开销是概率为 1.0 时的 3 倍,因为分支预测器无法成功预测实际分支将走向何方。
  • 在 .NET 8 中,无论概率是 0.5 还是 1,开销都相同(并且比 .NET 7 更快)。

我们还可以查看生成的汇编代码以查看差异。特别是在 .NET 8 上,我们看到了生成的汇编代码中的以下内容:

; Tests.GetOptions()
       push      rbx
       sub       rsp,20
       vzeroupper
       mov       rbx,rcx
       mov       rcx,2C54EC01E40
       mov       rcx,[rcx]
       mov       rcx,[rcx+8]
       mov       rax,offset MT_System.Random+XoshiroImpl
       cmp       [rcx],rax
       jne       short M00_L01
       call      qword ptr [7FFA2D790C88]; System.Random+XoshiroImpl.NextDouble()
M00_L00:
       vmovsd    xmm1,qword ptr [rbx+8]
       mov       eax,40000000
       xor       ecx,ecx
       vucomisd  xmm1,xmm0
       cmovbe    eax,ecx
       add       rsp,20
       pop       rbx
       ret
M00_L01:
       mov       rax,[rcx]
       mov       rax,[rax+48]
       call      qword ptr [rax+20]
       jmp       short M00_L00
; Total bytes of code 86

其中的vucomisd; cmovbe序列是在随机生成的浮点值和概率阈值之间进行比较,然后是条件移动(“如果小于或等于,则有条件地移动”)。

许多方法都会从这些转换中受益。以简单的方法Math.Max为例,我在这里复制了其代码:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser]
public class Tests
{
    [Benchmark]
    public int Max() => Max(1, 2);

    [MethodImpl(MethodImplOptions.NoInlining)]
    public static int Max(int val1, int val2)
    {
        return (val1 >= val2) ? val1 : val2;
    }
}

那个模式应该很熟悉。这是我们在.NET 7上得到的汇编代码:

; Tests.Max(Int32, Int32)
       cmp       ecx,edx
       jge       short M01_L00
       mov       eax,edx
       ret
M01_L00:
       mov       eax,ecx
       ret
; Total bytes of code 10

这两个参数通过ecx和edx寄存器传入。它们进行比较,如果第一个参数大于或等于第二个参数,则跳转到底部,将第一个参数移动到eax作为返回值;如果不是,则将第二个值移动到eax。在.NET 8上:

; Tests.Max(Int32, Int32)
       cmp       ecx,edx
       mov       eax,edx
       cmovge    eax,ecx
       ret
; Total bytes of code 8

同样,这两个参数通过ecx和edx寄存器传入,然后进行比较。第二个参数随后被移动到eax作为返回值。如果比较显示第一个参数大于第二个参数,则将第一个参数移动到eax(覆盖刚刚移动到eax的第二个参数)。

请注意,如果您想深入研究这个领域,BenchmarkDotNet还提供了一些非常好的额外工具。在Windows上,它使您能够收集硬件计数器,这些计数器公开了有关如何在硬件上实际执行的大量信息,无论是指令退役数量、缓存未命中还是分支预测错误。要使用它,请将另一个包引用添加到您的.csproj中:

<PackageReference Include="BenchmarkDotNet.Diagnostics.Windows" Version="0.13.8" />

并在您的测试类中添加一个额外的属性:

[HardwareCounters(HardwareCounter.BranchMispredictions, HardwareCounter.BranchInstructions)]

然后,请确保您从提升/管理员终端运行基准测试。当我这样做时,现在我看到了这个:

Method Runtime Probability Mean Ratio BranchMispredictions/Op BranchInstructions/Op
GetOptions .NET 7.0 0.5 8.585 ns 1.00 1 5
GetOptions .NET 8.0 0.5 2.488 ns 0.29 0 4
GetOptions .NET 7.0 1 2.783 ns 1.00 0 4
GetOptions .NET 8.0 1 2.531 ns 0.91 0 4

我们可以看到它证实了我们已经知道的事情:在.NET 7上,概率为0.5时,它最终会错误地预测分支。

C#编译器(也称为“Roslyn”)在.NET 8中也参与了分支消除工作,针对一种非常特定的分支。在.NET中,虽然我们认为System.Boolean只是一个两值类型(false和true),但sizeof(bool)实际上是一个字节。这意味着bool在技术上可以有256个不同的值,其中0被认为是false,[1,255]都被认为是true。幸运的是,除非开发人员使用interop或以其它方式使用unsafe代码来有意地操作这些其他值,否则开发人员可以在这里不用担心的,原因有两个。首先,C#不认为bool是一个数值类型,因此您不能对其执行算术运算或将其转换为int之类的类型。其次,运行时和C#生成的所有bool都被规范化为实际上是0或1的值,例如,cmp IL指令的文档为“如果value1大于value2,则将1推送到堆栈上;否则将0推送到堆栈上。”然而,有一类算法,可以依赖这些0和1值是方便的,我们刚刚谈到了它们:无分支算法。

假设我们没有JIT的新能力来使用条件移动,并且我们想为整数编写自己的ConditionalSelect方法:

static int ConditionalSelect(bool condition, int whenTrue, int whenFalse);

如果我们可以依赖bool始终为0或1(我们不能),并且如果我们可以对bool进行算术运算(我们不能),那么我们可以使用乘法的行为来实现我们的ConditionalSelect函数。任何乘以0的东西都是0,任何乘以1的东西都是它本身,因此我们可以像这样编写我们的ConditionalSelect:

// pseudo-code; this won't compile!
static int ConditionalSelect(bool condition, int whenTrue, int whenFalse) =>
    (whenTrue  *  condition) +
    (whenFalse * !condition);

然后,如果条件为1,whenTrue * condition将是whenTrue,whenFalse * !condition将为0,因此整个表达式将计算为whenTrue。反之,如果条件为0,则whenTrue * condition将为0,whenFalse * !condition将为whenFalse,因此整个表达式将计算为whenFalse。如前所述,我们不能编写上述内容,但我们可以编写以下内容:

static int ConditionalSelect(bool condition, int whenTrue, int whenFalse) =>
    (whenTrue  * (condition ? 1 : 0)) +
    (whenFalse * (condition ? 0 : 1));

这提供了我们想要的确切语义...但是我们已经在我们本应该无分支的算法中引入了两个分支。这是在.NET 7中为该ConditionalSelect生成的IL:

.method private hidebysig static  int32 ConditionalSelect (bool condition, int32 whenTrue, int32 whenFalse) cil managed 
{
    .maxstack 8

    IL_0000: ldarg.1
    IL_0001: ldarg.0
    IL_0002: brtrue.s IL_0007

    IL_0004: ldc.i4.0
    IL_0005: br.s IL_0008

    IL_0007: ldc.i4.1

    IL_0008: mul
    IL_0009: ldarg.2
    IL_000a: ldarg.0
    IL_000b: brtrue.s IL_0010

    IL_000d: ldc.i4.1
    IL_000e: br.s IL_0011

    IL_0010: ldc.i4.0

    IL_0011: mul
    IL_0012: add
    IL_0013: ret
}

请注意其中所有的brtrue.s和br.s指令。但是它们是必要的吗?早些时候我注意到,运行时只会生成值为0或1的bool。由于dotnet/roslyn#67191的存在,C#编译器现在已经认识到这一点,并优化了模式(b ? 1 : 0)以无分支方式执行。我们在.NET 8中编译的同样的ConditionalSelect函数如下所示:

.method private hidebysig static  int32 ConditionalSelect (bool condition, int32 whenTrue, int32 whenFalse) cil managed 
{
    .maxstack 8

    IL_0000: ldarg.1
    IL_0001: ldarg.0
    IL_0002: ldc.i4.0
    IL_0003: cgt.un
    IL_0005: mul
    IL_0006: ldarg.2
    IL_0007: ldarg.0
    IL_0008: ldc.i4.0
    IL_0009: ceq
    IL_000b: mul
    IL_000c: add
    IL_000d: ret
}

零分支指令。当然,您现在实际上不想像这样编写此函数;仅仅因为它是无分支的并不意味着它是最有效的。在.NET 8上,这是JIT为上述代码生成的汇编代码:

    movzx    rax, cl
    xor      ecx, ecx
    test     eax, eax
    setne    cl
    imul     ecx, edx
    test     eax, eax
    sete     al
    movzx    rax, al
    imul     eax, r8d
    add      eax, ecx
    ret  

如果您只是将其编写为:

static int ConditionalSelect(bool condition, int whenTrue, int whenFalse) =>
    condition ? whenTrue : whenFalse;

你将会获得:

    test     cl, cl
    mov      eax, r8d
    cmovne   eax, edx
    ret    

即使如此,这种C#编译器优化对于其他无分支算法也很有用。假设我想编写一个Compare方法,用于比较两个int,如果第一个小于第二个,则返回-1,如果它们相等,则返回0,如果第一个大于第二个,则返回1。我可以像这样编写它:

static int Compare(int x, int y)
{
    int gt = (x > y) ? 1 : 0;
    int lt = (x < y) ? 1 : 0;
    return gt - lt;
}

这现在是无分支的,C#编译器生成的代码如下:

    IL_0000: ldarg.0
    IL_0001: ldarg.1
    IL_0002: cgt
    IL_0004: ldarg.0
    IL_0005: ldarg.1
    IL_0006: clt
    IL_0008: stloc.0
    IL_0009: ldloc.0
    IL_000a: sub
    IL_000b: ret

从中,JIT生成的代码如下:

    xor      eax, eax
    cmp      ecx, edx
    setg     al
    setl     cl
    movzx    rcx, cl
    sub      eax, ecx
    ret      

这是否意味着每个人现在都应该以无分支的方式重写他们的算法?绝对不是。这是您工具箱中的另一个工具,在某些情况下非常有益,特别是当它可以提供更一致的吞吐量结果,因为它无论结果如何都会执行相同的工作。然而,并不总是胜利,通常最好不要试图超越编译器。看看我们刚刚看过的例子。核心库中有一个具有完全相同实现的函数:int.CompareTo。如果您查看它在.NET 8中的实现,您会发现它仍在使用基于分支的实现。为什么?因为它通常会产生更好的结果,特别是在操作被内联并且JIT能够将CompareTo方法中的分支与基于处理CompareTo结果的分支组合的常见情况下。大多数CompareTo的用法都涉及基于其结果的附加分支,例如在快速排序分区步骤中决定是否移动元素。因此,让我们看一个根据此类比较结果做出决策的代码示例:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser]
public class Tests
{
    private int _x = 2, _y = 1;

    [Benchmark]
    public int GreaterThanOrEqualTo_Branching()
    {
        if (Compare_Branching(_x, _y) >= 0)
        {
            return _x * 2;
        }

        return _y * 3;
    }

    [Benchmark]
    public int GreaterThanOrEqualTo_Branchless()
    {
        if (Compare_Branchless(_x, _y) >= 0)
        {
            return _x * 2;
        }

        return _y * 3;
    }

    private static int Compare_Branching(int x, int y)
    {
        if (x < y) return -1;
        if (x > y) return 1;
        return 0;
    }

    private static int Compare_Branchless(int x, int y)
    {
        int gt = (x > y) ? 1 : 0;
        int lt = (x < y) ? 1 : 0;
        return gt - lt;
    }
}

结果汇编:分支与无分支汇编差异

请注意,现在两个实现都只有一个分支(“分支”情况下的jl和“无分支”情况下的js),而“分支”实现会产生更少的汇编代码。

边界检查

运行时会对数组、字符串和范围进行边界检查。这意味着对这些数据结构进行索引会产生验证,以确保索引在数据结构的范围内。例如,这里的Get(byte[],int)方法:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser]
public class Tests
{
    private byte[] _array = new byte[8];
    private int _index = 4;

    [Benchmark]
    public void Get() => Get(_array, _index);

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static byte Get(byte[] array, int index) => array[index];
}

结果为该方法生成以下代码:

; Tests.Get(Byte[], Int32)
       sub       rsp,28
       cmp       edx,[rcx+8]
       jae       short M01_L00
       mov       eax,edx
       movzx     eax,byte ptr [rcx+rax+10]
       add       rsp,28
       ret
M01_L00:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 27

这里,byte[]在rcx中传递,int index在edx中,代码将索引的值与存储在数组开头的8字节偏移处的值进行比较:这是数组的长度存储的位置。 jae指令(如果大于或等于跳转)是无符号比较,因此如果(uint)index >= (uint)array.Length,则会跳转到M01_L00,我们在那里看到对一个帮助函数CORINFO_HELP_RNGCHKFAIL的调用,它将引发IndexOutOfRangeException。所有这些都是“边界检查”。实际访问数组的是两个mov和movzx指令,其中将索引移动到eax,然后将位于rcx(数组的地址)+ rax(索引)+ 0x10(数组中数据开始的偏移量)的值移动到返回eax寄存器中。

运行时负责确保所有访问都在范围内。它可以通过边界检查来实现。但是,它也可以通过证明索引始终在范围内来实现,这样它就可以省略添加边界检查,这样只会增加开销。在每个.NET版本里,对于这些实际访问不可能超出范围的情况,JIT都会提高其识别这些不需要添加边界检查模式的能力,.NET 8也不例外,它学习了几个新的有用技巧。

其中一个技巧来自dotnet/runtime#84231,它学习了如何避免在非常普遍的集合中进行的边界检查,特别是在哈希表中。在哈希表中,通常为key计算哈希码,然后使用该值建索引到数组中(通常称为“buckets”)。由于哈希码可能是任何int,而桶数组无疑要比32位整数的完整范围小得多,因此所有哈希码都需要映射到数组中的一个元素,而一种很好的方法是通过将哈希码模除数组的长度,例如:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    private readonly int[] _array = new int[7];

    [Benchmark]
    public int GetBucket() => GetBucket(_array, 42);

    private static int GetBucket(int[] buckets, int hashcode) =>
        buckets[(uint)hashcode % buckets.Length];
}

在.NET 7中,它会产生:

; Tests.GetBucket()
       sub       rsp,28
       mov       rcx,[rcx+8]
       mov       eax,2A
       mov       edx,[rcx+8]
       mov       r8d,edx
       xor       edx,edx
       idiv      r8
       cmp       rdx,r8
       jae       short M00_L00
       mov       eax,[rcx+rdx*4+10]
       add       rsp,28
       ret
M00_L00:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 44

请注意CORINFO_HELP_RNGCHKFAIL,这是边界检查的标志。现在在.NET 8中,JIT认识到,uint值%数组长度不可能超出该数组的范围;如果数组的Length大于0,则%的结果始终>= 0且<array.Length,否则Length为0,% 0 将引发异常。因此,它可以省略边界检查:

; Tests.GetBucket()
       mov       rcx,[rcx+8]
       mov       eax,2A
       mov       r8d,[rcx+8]
       xor       edx,edx
       div       r8
       mov       eax,[rcx+rdx*4+10]
       ret
; Total bytes of code 23

再考虑这个:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    private readonly string _s = "\"Hello, World!\"";

    [Benchmark]
    public bool IsQuoted() => IsQuoted(_s);

    private static bool IsQuoted(string s) =>
        s.Length >= 2 && s[0] == '"' && s[^1] == '"';
}

我们的函数正在检查提供的字符串是否以引号开头和结尾。它至少需要两个字符,并且第一个和最后一个字符需要是引号(s[^1]是缩写,由C#编译器扩展为等效于s[s.Length - 1])。
这是.NET 7汇编:

; Tests.IsQuoted(System.String)
       sub       rsp,28
       mov       eax,[rcx+8]
       cmp       eax,2
       jl        short M01_L00
       cmp       word ptr [rcx+0C],22
       jne       short M01_L00
       lea       edx,[rax-1]
       cmp       edx,eax
       jae       short M01_L01
       mov       eax,edx
       cmp       word ptr [rcx+rax*2+0C],22
       sete      al
       movzx     eax,al
       add       rsp,28
       ret
M01_L00:
       xor       eax,eax
       add       rsp,28
       ret
M01_L01:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 58

请注意,我们的函数两次索引字符串,汇编代码在方法末尾有一个call CORINFO_HELP_RNGCHKFAIL,但只有一个jae引用该调用的位置。这是因为JIT已经知道避免对s[0]访问进行边界检查:它看到已经验证了字符串的Length >= 2,因此可以安全地索引到任何索引<= 2而不进行边界检查。但是,我们仍然对s[s.Length - 1]进行边界检查。
现在在.NET 8中,我们得到了这个:

; Tests.IsQuoted(System.String)
       mov       eax,[rcx+8]
       cmp       eax,2
       jl        short M01_L00
       cmp       word ptr [rcx+0C],22
       jne       short M01_L00
       dec       eax
       cmp       word ptr [rcx+rax*2+0C],22
       sete      al
       movzx     eax,al
       ret
M01_L00:
       xor       eax,eax
       ret
; Total bytes of code 33

请注意,现在没有call CORINFO_HELP_RNGCHKFAIL了,也没有边界检查。JIT不仅认识到s[0]是安全的,因为 s.Length >= 2 ,而且还认识到由于 s.Length >= 2s.Length - 1 >= 0< s.Length ,这意味着它在范围内,因此不需要进行范围检查。这得益于dotnet/runtime#84213。

常量折叠

编译器使用的另一个重要操作是常量折叠(以及紧密相关的常量传播)。常量折叠只是编译器在编译时评估表达式的一种花哨的名称,例如,如果您有2 * 3,它可以在编译时执行乘法并替换为6,而不是发出乘法指令。常量传播是将新常量并将其用于该表达式结果的任何地方的行为,例如,如果您有:

int a = 2 * 3;
int b = a * 4;

编译器可以假装它是:

int a = 6;
int b = 24;

我在这里提到这一点,因为我们刚刚谈到了边界检查消除,因为有些情况下常量折叠和边界检查消除是相辅相成的。如果我们可以在编译时确定数据结构的长度,并且我们可以在编译时确定索引,则在编译时还可以确定索引是否在边界内并避免边界检查。我们还可以进一步:如果我们不仅可以确定数据结构的长度,还可以确定其内容,则可以在编译时进行索引并替换数据结构中的值。

考虑以下示例,它与类型通常在其ToString或TryFormat实现中具有相似性:

考虑以下示例,它与类型通常在其ToString或TryFormat实现中具有相似性:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser]
public class Tests
{
    [Benchmark]
    [Arguments(42)]
    public string Format(int value) => Format(value, "B");

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    static string Format(int value, ReadOnlySpan<char> format)
    {
        if (format.Length == 1)
        {
            switch (format[0] | 0x20)
            {
                case 'd': return DecimalFormat(value);
                case 'x': return HexFormat(value);
                case 'b': return BinaryFormat(value);
            }
        }

        return FallbackFormat(value, format);
    }

    [MethodImpl(MethodImplOptions.NoInlining)] private static string DecimalFormat(int value) => null;
    [MethodImpl(MethodImplOptions.NoInlining)] private static string HexFormat(int value) => null;
    [MethodImpl(MethodImplOptions.NoInlining)] private static string BinaryFormat(int value) => null;
    [MethodImpl(MethodImplOptions.NoInlining)] private static string FallbackFormat(int value, ReadOnlySpan<char> format) => null;
}

我们有一个Format(int value, ReadOnlySpan<char> format)方法,用于根据指定的格式格式化int值。调用站点明确指定要使用的格式,就像许多这样的调用站点一样,在此处明确传递“B”。然后,实现特别处理长度为一个字符且以不区分大小写的方式匹配三种已知格式之一的格式(它使用基于ASCII的技巧,基于小写字母的值与其大写对应项相差一个比特,因此将大写ASCII字母与0x20 OR在一起会将其转换为小写字母)。如果我们查看在.NET 7中为此方法生成的汇编代码,我们会得到这个:

; Tests.Format(Int32)
       sub       rsp,38
       xor       eax,eax
       mov       [rsp+28],rax
       mov       ecx,edx
       mov       rax,251C4801418
       mov       rax,[rax]
       add       rax,0C
       movzx     edx,word ptr [rax]
       or        edx,20
       cmp       edx,62
       je        short M00_L01
       cmp       edx,64
       je        short M00_L00;
       cmp       edx,78
       jne       short M00_L02
       call      qword ptr [7FFF3DD47918]; Tests.HexFormat(Int32)
       jmp       short M00_L03
M00_L00:
       call      qword ptr [7FFF3DD47900]; Tests.DecimalFormat(Int32)
       jmp       short M00_L03
M00_L01:
       call      qword ptr [7FFF3DD47930]; Tests.BinaryFormat(Int32)
       jmp       short M00_L03
M00_L02:
       mov       [rsp+28],rax
       mov       dword ptr [rsp+30],1
       lea       rdx,[rsp+28]
       call      qword ptr [7FFF3DD47948]; Tests.FallbackFormat
M00_L03:
       nop
       add       rsp,38
       ret
; Total bytes of code 105

我们可以在这里看到Format(Int32, ReadOnlySpan<char>)的代码,但这是Format(Int32)的代码,因此被调用者已成功内联。我们也没有看到format.Length == 1的任何代码(第一个cmp是switch的一部分),也没有看到任何边界检查的迹象(没有调用 CORINFO_HELP_RNGCHKFAIL)。但是,我们确实看到它从format加载了第一个字符:

mov       rax,251C4801418       ; 加载format常量字符串引用存储的地址
mov       rax,[rax]             ; 加载format的地址
add       rax,0C                ; 加载format的第一个字符的地址
movzx     edx,word ptr [rax]    ; 读取format的第一个字符

然后使用等效的级联 if/else。现在让我们看看.NET 8:

; Tests.Format(Int32)
       sub       rsp,28
       mov       ecx,edx
       call      qword ptr [7FFEE0BAF4C8]; Tests.BinaryFormat(Int32)
       nop
       add       rsp,28
       ret
; Total bytes of code 18

哇。它不仅看到了format的Length为1,不仅能够避免边界检查,而且实际上读取了第一个字符,将其转换为小写,并将其与所有switch分支匹配,从而将整个操作常量折叠并传播,仅留下对BinaryFormat的调用。这主要得益于dotnet/runtime#78593。

还有许多其他类似的改进,例如dotnet/runtime#77593,它使其能够常量折叠存储在静态只读字段中的字符串或T[]的长度。考虑:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    private static readonly string s_newline = Environment.NewLine;

    [Benchmark]
    public bool IsLineFeed() => s_newline.Length == 1 && s_newline[0] == '\n';
}

在.NET 7上,我得到了以下汇编代码:

; Tests.IsLineFeed()
       mov       rax,18AFF401F78
       mov       rax,[rax]
       mov       edx,[rax+8]
       cmp       edx,1
       jne       short M00_L00
       cmp       word ptr [rax+0C],0A
       sete      al
       movzx     eax,al
       ret
M00_L00:
       xor       eax,eax
       ret
; Total bytes of code 36

这实际上是C#的1:1翻译,没有太多有趣的事情发生:它从s_newline加载字符串,并将其长度与1进行比较;如果不匹配,则返回0(false),否则将数组中第一个元素的值与0xA(换行符)进行比较,并返回它们是否匹配。现在,.NET 8:

; Tests.IsLineFeed()
       xor       eax,eax
       ret
; Total bytes of code 3

这更有趣。我在Windows上运行了此代码,其中Environment.NewLine为“\r\n”。JIT已经常量折叠了整个操作,看到长度不为1,因此整个操作归结为仅返回false。

或者考虑dotnet/runtime#78783和dotnet/runtime#80661,它们教JIT如何实际查看“RVA static”中的内容。这些是“Relative Virtual Address(相对虚拟地址)”静态字段,这是一种说法,它们位于程序集的数据部分中。C#编译器具有将常量数据放入这些字段的优化;例如,当您编写:

private static ReadOnlySpan<byte> Prefix => "http://"u8;

C#编译器实际上会像这样emil IL:

.method private hidebysig specialname static 
    valuetype [System.Runtime]System.ReadOnlySpan`1<uint8> get_Prefix () cil managed 
{
    .maxstack 8

    IL_0000: ldsflda int64 '<PrivateImplementationDetails>'::'6709A82409D4C9E2EC04E1E71AB12303402A116B0F923DB8114F69CB05F1E926'
    IL_0005: ldc.i4.7
    IL_0006: newobj instance void valuetype [System.Runtime]System.ReadOnlySpan`1<uint8>::.ctor(void*, int32)
    IL_000b: ret
}
...
.class private auto ansi sealed '<PrivateImplementationDetails>'
    extends [System.Runtime]System.Object
{
    .field assembly static initonly int64 '6709A82409D4C9E2EC04E1E71AB12303402A116B0F923DB8114F69CB05F1E926' at I_00002868
    .data cil I_00002868 = bytearray ( 68 74 74 70 3a 2f 2f 00 )
}

通过这些PR,当索引到这样的RVA静态时,JIT现在能够实际读取相关位置的数据,将操作常量折叠到该位置的值。因此,考虑以下基准测试:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    [Benchmark]
    public bool IsWhiteSpace() => char.IsWhiteSpace('\n');
}

char.IsWhiteSpace方法是通过查找这样的"RVA static"实现的,使用传递的char作为其索引。如果索引最终成为常量,现在在.NET 8上,整个操作可以被常量折叠。.NET 7:

; Tests.IsWhiteSpace()
       xor       eax,eax
       test      byte ptr [7FFF9BCCD83A],80
       setne     al
       ret
; Total bytes of code 13

和.NET 8:

; Tests.IsWhiteSpace()
       mov       eax,1
       ret
; Total bytes of code 6

你明白了。当然,开发人员希望不会明确编写char.IsWhiteSpace('\n'),但是这样的代码仍然可能产生,特别是通过内联。

GitHub Copilot: 在.NET 8中有许多这种改进。dotnet/runtime#77102使得静态只读值类型的原始字段可以像它们自己的静态只读字段一样被常量折叠,而dotnet/runtime#80431将其扩展到字符串。dotnet/runtime#85804教JIT如何处理RuntimeTypeHandle.ToIntPtr(typeof(T).TypeHandle)(它在诸如GC.AllocateUninitializedArray之类的方法中使用),而dotnet/runtime#87101教它处理obj.GetType()(这样,如果JIT知道实例obj的确切类型,它可以用已知的答案替换GetType()调用)。然而,我最喜欢的例子之一,纯粹是因为它看起来有多神奇,来自一系列PR,包括dotnet/runtime#80622,dotnet/runtime#78961,dotnet/runtime#80888和dotnet/runtime#81005。它们一起使得这个:

// dotnet run -c Release -f net8.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    [Benchmark]
    public DateTime Get() => new DateTime(2023, 9, 1);
}

产生这个:

; Tests.Get()
       mov       rax,8DBAA7E629B4000
       ret
; Total bytes of code 11

JIT能够成功地内联和常量折叠整个操作,将其缩减为单个常量。那个mov指令中的8DBAA7E629B4000是支持DateTime的private readonly ulong _dateData字段的值。确实,如果你运行:

new DateTime(0x8DBAA7E629B4000)

你会看到它产生了:

[9/1/2023 12:00:00 AM]

非常酷。

Non-GC Heap

之前我们看到了加载常量字符串时的代码生成示例。作为提醒,这段代码:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    [Benchmark]
    public string GetPrefix() => "https://";
}

在.NET 7上产生了这个程序集:

; Tests.GetPrefix()
       mov       rax,126A7C01498
       mov       rax,[rax]
       ret
; Total bytes of code 14

这里有两个mov指令。第一个是加载存储字符串对象地址的地址的位置,第二个是读取存储在该位置的地址(这需要两个mov,因为在x64上没有支持移动绝对地址大于32位的值的寻址模式)。即使我们在这里处理的是一个字符串字面量,使得字符串的数据是常量,但是该常量数据仍然被复制到一个堆分配的字符串对象中。该对象被内部化,因此进程中只有一个对象,但它仍然是一个堆对象,这意味着它仍然可能被GC移动。这意味着JIT不能只将字符串对象的地址硬编码,因为地址可能会改变,因此它需要每次读取地址,以便知道它当前在哪里。或者,它可以吗?

如果我们可以确保此字面量的字符串对象在永远不会移动的地方创建,例如在固定对象堆(POH)上,那么JIT就可以避免间接寻址,而是硬编码字符串的地址,知道它永远不会移动。当然,POH保证其上的对象永远不会移动,但它不能保证对它们的地址始终有效;毕竟,它不会根据对象进行根处理,因此POH上的对象仍然可以被GC收集,并且如果它们被收集,它们的地址将指向垃圾或其他数据,这些数据重用了空间。

为了解决这个问题,.NET 8引入了JIT用于这些情况的新机制:Non-GC Heap(Native AOT使用的旧“Frozen Segments”概念的演变)。JIT可以确保相关对象分配在Non-GC Heap上,该堆像其名称一样,不受GC管理,旨在存储JIT可以证明对象没有GC需要注意的引用并且将在进程的生命周期内保持根的对象,这反过来意味着它不能是可卸载上下文的一部分。

自dotnet/runtime#49576起, JIT现在可以避免在生成的代码中访问该对象时的间接寻址,而是直接硬编码对象的地址。这正是它现在针对字符串字面量所做的。现在在.NET 8中,上述相同的方法会产生以下程序集:

; Tests.GetPrefix()
       mov       rax,227814EAEA8
       ret
; Total bytes of code 11

dotnet/runtime#75573采用类似的方法,但是使用typeof(T)生成的RuntimeType对象(受各种约束的限制,例如T不来自不可卸载的程序集,在这种情况下,永久根据对象将防止卸载)。同样,我们可以通过简单的基准测试来看到这一点:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    [Benchmark]
    public Type GetTestsType() => typeof(Tests);
}

在.NET 7和.NET 8之间得到以下差异:

; .NET 7
; Tests.GetTestsType()
       sub       rsp,28
       mov       rcx,offset MT_Tests
       call      CORINFO_HELP_TYPEHANDLE_TO_RUNTIMETYPE
       nop
       add       rsp,28
       ret
; Total bytes of code 25

; .NET 8
; Tests.GetTestsType()
       mov       rax,1E0015E73F8
       ret
; Total bytes of code 11

同样的功能可以扩展到其他类型的对象,例如在dotnet/runtime#85559(基于dotnet/runtime#76112的工作)中,通过将空数组分配到Non-GC Heap上,使Array.Empty()更加高效。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    [Benchmark]
    public string[] Test() => Array.Empty<string>();
}
; .NET 7
; Tests.Test()
       mov       rax,17E8D801FE8
       mov       rax,[rax]
       ret
; Total bytes of code 14

; .NET 8
; Tests.Test()
       mov       rax,1A0814EAEA8
       ret
; Total bytes of code 11

自dotnet/runtime#77737起,它还适用于与静态值类型字段关联的堆对象,至少是那些不包含任何GC引用的字段。等等,值类型字段的堆对象?当存储在字段中时,值类型不是在堆上分配的。好吧,实际上当它们存储在静态字段中时,它们是在堆上分配的;运行时会创建与该字段关联的堆分配的框来存储该值(但是同一个框会重用所有对该字段的写入)。这意味着对于像这样的基准测试:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public partial class Tests
{
    private static readonly ConfigurationData s_config = ConfigurationData.ReadData();

    [Benchmark]
    public TimeSpan GetRefreshInterval() => s_config.RefreshInterval;

    // Struct for storing fictional configuration data that might be read from a configuration file.
    private struct ConfigurationData
    {
        public static ConfigurationData ReadData() => new ConfigurationData
        {
            Index = 0x12345,
            Id = Guid.NewGuid(),
            IsEnabled = true,
            RefreshInterval = TimeSpan.FromSeconds(100)
        };

        public int Index;
        public Guid Id;
        public bool IsEnabled;
        public TimeSpan RefreshInterval;
    }
}

在.NET 7上读取RefreshInterval的汇编代码如下:

; Tests.GetRefreshInterval()
       mov       rax,13D84001F78
       mov       rax,[rax]
       mov       rax,[rax+20]
       ret
; Total bytes of code 18

该代码加载字段的地址,从中读取框对象的地址,然后从该框对象中读取存储在其中的TimeSpan值。但是,现在在.NET 8上,我们得到了您现在期望的程序集:

; Tests.GetRefreshInterval()
       mov       rax,20D9853AE48
       mov       rax,[rax]
       ret
; Total bytes of code 14

该部分代码在Non-GC堆上分配,这意味着JIT可以固定对象的地址,我们可以节省一个mov。

除了访问这些Non-GC堆对象的间接寻址更少之外,还有其他好处。例如,像.NET中使用的“分代GC”一样,将堆分成多个“代”,其中代0(“gen0”)用于最近创建的对象,代2(“gen2”)用于存在一段时间的对象。当GC执行收集时,它需要确定哪些对象仍然存活(仍然被引用)以及哪些对象可以收集,并且为此它必须跟踪所有引用以找出仍然可达的对象。但是,分代模型是有益的,因为它可以使GC扫描的堆比它可能需要的少得多。例如,如果它可以确定gen2中没有从gen0返回的引用,那么在进行gen0收集时,它可以完全避免枚举gen2对象。但是为了能够了解这样的引用,GC需要知道每次将引用写入共享位置的时间。我们可以在这个基准测试中看到这一点:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser]
public class Tests
{
    [Benchmark]
    public void Write()
    {
        string dst = "old";
        Write(ref dst, "new");
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void Write(ref string dst, string s) => dst = s;
}

在.NET 7和.NET 8上生成的Write(ref string, string)方法的代码如下:

; Tests.Write(System.String ByRef, System.String)
       call      CORINFO_HELP_CHECKED_ASSIGN_REF
       nop
       ret
; Total bytes of code 7

CORINFO_HELP_CHECKED_ASSIGN_REF是一个JIT帮助程序函数,其中包含所谓的“GC write barrier (GC写屏障)”,一个小代码片段,用于让GC跟踪正在写入的引用,因为它可能需要知道,例如,因为正在分配的对象可能是gen0,而目标可能是gen2。我们在.NET 7上看到类似的东西,例如对基准测试的微调代码:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser]
public class Tests
{
    [Benchmark]
    public void Write()
    {
        string dst = "old";
        Write(ref dst);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void Write(ref string dst) => dst = "new";
}

现在我们将一个字符串文字存储到目标中,在.NET 7上,我们看到类似地调用CORINFO_HELP_CHECKED_ASSIGN_REF:

; Tests.Write(System.String ByRef)
       mov       rdx,1FF0E4014A0
       mov       rdx,[rdx]
       call      CORINFO_HELP_CHECKED_ASSIGN_REF
       nop
       ret
; Total bytes of code 20

但是,现在在.NET 8上,我们看到了这个:

; Tests.Write(System.String ByRef)
       mov       rax,1B3814EAEC8
       mov       [rcx],rax
       ret
; Total bytes of code 14

没有写屏障。这要归功于dotnet/runtime#76135,它认识到这些Non-GC堆对象不需要被跟踪,因为它们永远不会被收集。还有多个其他的PR可以改进如何与这些Non-GC堆对象一起使用常量折叠,例如dotnet/runtime#85127,dotnet/runtime#85888和dotnet/runtime#86318。

Zeroing 清零

JIT经常需要生成清零内存的代码。例如,除非您使用了[SkipLocalsInit],否则使用stackalloc分配的任何堆栈空间都需要清零,而这是JIT生成执行此操作的代码的责任。考虑以下基准测试:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{    
    [Benchmark] public void Constant256() => Use(stackalloc byte[256]);

    [Benchmark] public void Constant1024() => Use(stackalloc byte[1024]);

    [MethodImpl(MethodImplOptions.NoInlining)] // prevent stackallocs from being optimized away
    private static void Use(Span<byte> span) { }
}

以下是Constant256和Constant1024的.NET 7汇编代码:

; Tests.Constant256()
       push      rbp
       sub       rsp,40
       lea       rbp,[rsp+20]
       xor       eax,eax
       mov       [rbp+10],rax
       mov       [rbp+18],rax
       mov       rax,0A77E4BDA96AD
       mov       [rbp+8],rax
       add       rsp,20
       mov       ecx,10
M00_L00:
       push      0
       push      0
       dec       rcx
       jne       short M00_L00
       sub       rsp,20
       lea       rcx,[rsp+20]
       mov       [rbp+10],rcx
       mov       dword ptr [rbp+18],100
       lea       rcx,[rbp+10]
       call      qword ptr [7FFF3DD37900]; Tests.Use(System.Span`1<Byte>)
       mov       rcx,0A77E4BDA96AD
       cmp       [rbp+8],rcx
       je        short M00_L01
       call      CORINFO_HELP_FAIL_FAST
M00_L01:
       nop
       lea       rsp,[rbp+20]
       pop       rbp
       ret
; Total bytes of code 110

; Tests.Constant1024()
       push      rbp
       sub       rsp,40
       lea       rbp,[rsp+20]
       xor       eax,eax
       mov       [rbp+10],rax
       mov       [rbp+18],rax
       mov       rax,606DD723A061
       mov       [rbp+8],rax
       add       rsp,20
       mov       ecx,40
M00_L00:
       push      0
       push      0
       dec       rcx
       jne       short M00_L00
       sub       rsp,20
       lea       rcx,[rsp+20]
       mov       [rbp+10],rcx
       mov       dword ptr [rbp+18],400
       lea       rcx,[rbp+10]
       call      qword ptr [7FFF3DD47900]; Tests.Use(System.Span`1<Byte>)
       mov       rcx,606DD723A061
       cmp       [rbp+8],rcx
       je        short M00_L01
       call      CORINFO_HELP_FAIL_FAST
M00_L01:
       nop
       lea       rsp,[rbp+20]
       pop       rbp
       ret
; Total bytes of code 110

我们可以看到,在中间,JIT编写了一个清零循环,每次迭代通过将两个8字节的0推入堆栈来清零16字节:

M00_L00:
       push      0
       push      0
       dec       rcx
       jne       short M00_L00

现在,在.NET 8中使用dotnet/runtime#83255,JIT展开并矢量化了该清零,并在达到一定阈值后(截至dotnet/runtime#83274,已更新并与其他本机编译器所做的相同),它切换到使用优化的memset例程,而不是发出大量代码来实现相同的事情。这是我们现在在.NET 8上为Constant256得到的结果(在我的机器上...我之所以这样说是因为限制基于可用的指令集):

; Tests.Constant256()
       push      rbp
       sub       rsp,40
       vzeroupper
       lea       rbp,[rsp+20]
       xor       eax,eax
       mov       [rbp+10],rax
       mov       [rbp+18],rax
       mov       rax,6281D64D33C3
       mov       [rbp+8],rax
       test      [rsp],esp
       sub       rsp,100
       lea       rcx,[rsp+20]
       vxorps    ymm0,ymm0,ymm0
       vmovdqu   ymmword ptr [rcx],ymm0
       vmovdqu   ymmword ptr [rcx+20],ymm0
       vmovdqu   ymmword ptr [rcx+40],ymm0
       vmovdqu   ymmword ptr [rcx+60],ymm0
       vmovdqu   ymmword ptr [rcx+80],ymm0
       vmovdqu   ymmword ptr [rcx+0A0],ymm0
       vmovdqu   ymmword ptr [rcx+0C0],ymm0
       vmovdqu   ymmword ptr [rcx+0E0],ymm0
       mov       [rbp+10],rcx
       mov       dword ptr [rbp+18],100
       lea       rcx,[rbp+10]
       call      qword ptr [7FFEB7D3F498]; Tests.Use(System.Span`1<Byte>)
       mov       rcx,6281D64D33C3
       cmp       [rbp+8],rcx
       je        short M00_L00
       call      CORINFO_HELP_FAIL_FAST
M00_L00:
       nop
       lea       rsp,[rbp+20]
       pop       rbp
       ret
; Total bytes of code 156

请注意,这里没有清零循环,而是看到一堆256位的vmovdqu移动指令,将清零的ymm0寄存器复制到堆栈的下一个部分。然后对于Constant1024,我们看到:

; Tests.Constant1024()
       push      rbp
       sub       rsp,40
       lea       rbp,[rsp+20]
       xor       eax,eax
       mov       [rbp+10],rax
       mov       [rbp+18],rax
       mov       rax,0CAF12189F783
       mov       [rbp],rax
       test      [rsp],esp
       sub       rsp,400
       lea       rcx,[rsp+20]
       mov       [rbp+8],rcx
       xor       edx,edx
       mov       r8d,400
       call      CORINFO_HELP_MEMSET
       mov       rcx,[rbp+8]
       mov       [rbp+10],rcx
       mov       dword ptr [rbp+18],400
       lea       rcx,[rbp+10]
       call      qword ptr [7FFEB7D5F498]; Tests.Use(System.Span`1<Byte>)
       mov       rcx,0CAF12189F783
       cmp       [rbp],rcx
       je        short M00_L00
       call      CORINFO_HELP_FAIL_FAST
M00_L00:
       nop
       lea       rsp,[rbp+20]
       pop       rbp
       ret
; Total bytes of code 119

同样,没有清零循环,而是看到调用CORINFO_HELP_MEMSET,依赖于优化的底层memset来有效地处理清零。这也可以从吞吐量数字中看出效果:

Method Runtime Mean Ratio
Constant256 .NET 7.0 7.927 ns 1.00
Constant256 .NET 8.0 3.181 ns 0.40
Constant1024 .NET 7.0 30.523 ns 1.00
Constant1024 .NET 8.0 8.850 ns 0.29

dotnet/runtime#83488通过使用在矢量化算法时经常使用的标准技巧进一步改进了这一点。假设您想要清零120个字节,并且您可以使用每次清零32个字节的指令。我们可以发出三个这样的指令以清零96个字节,但是我们还剩下24个字节需要清零。我们该怎么办?我们不能从我们离开的地方写入另外32个字节,因为我们可能会覆盖8个字节,我们不应该触摸。我们可以使用标量清零,并为每个8个字节发出三个指令,但是我们能否只使用一个指令完成呢?是的!由于写入是幂等的,因此我们可以仅清零120字节的最后32字节,即使这意味着我们将重新清零已经清零的8个字节。您可以在核心库中的许多矢量化操作中看到使用相同方法,现在JIT也使用它进行清零。

dotnet/runtime#85389进一步使用AVX512来改进此类清零操作。因此,在我的Dev Box上使用AVX512运行相同的基准测试时,我看到为Constant256生成了以下汇编代码:

; Tests.Constant256()
       push      rbp
       sub       rsp,40
       vzeroupper
       lea       rbp,[rsp+20]
       xor       eax,eax
       mov       [rbp+10],rax
       mov       [rbp+18],rax
       mov       rax,992482B435F7
       mov       [rbp+8],rax
       test      [rsp],esp
       sub       rsp,100
       lea       rcx,[rsp+20]
       vxorps    ymm0,ymm0,ymm0
       vmovdqu32 [rcx],zmm0
       vmovdqu32 [rcx+40],zmm0
       vmovdqu32 [rcx+80],zmm0
       vmovdqu32 [rcx+0C0],zmm0
       mov       [rbp+10],rcx
       mov       dword ptr [rbp+18],100
       lea       rcx,[rbp+10]
       call      qword ptr [7FFCE555F4B0]; Tests.Use(System.Span`1<Byte>)
       mov       rcx,992482B435F7
       cmp       [rbp+8],rcx
       je        short M00_L00
       call      CORINFO_HELP_FAIL_FAST
M00_L00:
       nop
       lea       rsp,[rbp+20]
       pop       rbp
       ret
; Total bytes of code 132
; Tests.Use(System.Span`1<Byte>)
       ret
; Total bytes of code 1

现在,请注意,我们不再看到使用ymm0的八个vmovdqu指令,而是看到使用zmm0的四个vmovdqu32指令,因为每个移动指令能够清零两倍的内容,每个指令一次处理64个字节。

值类型

值类型(结构体)越来越多地被用作高性能代码的一部分。然而,虽然它们具有明显的优点(它们不需要堆分配,因此减少了对GC的压力),但它们也有缺点(需要复制更多的数据),并且在历史上并没有像某些人为了性能而大量依赖它们的情况下被优化得那么好。这是.NET的最近几个版本中JIT的重点改进领域,这种改进在.NET 8中仍在继续。

这里的一个具体改进领域是“promotion” (提升)。在这个上下文中,提升是将结构体拆分为其组成字段的想法,有效地将每个字段视为自己的本地变量。这可以导致许多有价值的优化,包括能够将结构体的部分寄存器化。从.NET 7开始,JIT确实支持结构体提升,但有限制,包括仅支持最多四个字段的结构体,不支持嵌套结构体(除了基本类型)。

.NET 8中的许多工作都是为了消除这些限制。dotnet/runtime#83388通过额外的优化传递来改进现有的提升支持,JIT称之为“物理提升”;它消除了上述两个限制,但截至此PR,该功能仍默认禁用。其他PR,如dotnet/runtime#85105和dotnet/runtime#86043进一步改进了它,而dotnet/runtime#88090则默认启用了这些优化。这种改进的净结果可以在以下基准测试中看到:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser]
public class Tests
{
    private ParsedStat _stat;

    [Benchmark]
    public ulong GetTime()
    {
        ParsedStat stat = _stat;
        return stat.utime + stat.stime;
    }

    internal struct ParsedStat
    {
        internal int pid;
        internal string comm;
        internal char state;
        internal int ppid;
        internal int session;
        internal ulong utime;
        internal ulong stime;
        internal long nice;
        internal ulong starttime;
        internal ulong vsize;
        internal long rss;
        internal ulong rsslim;
    }
}

这里有一个结构体,模拟了从Linux上的procfs stat文件中提取的一些数据。基准测试创建了结构体的本地副本,并返回用户时间和内核时间的总和。在.NET 7中,汇编代码如下:

; Tests.GetTime()
       push      rdi
       push      rsi
       sub       rsp,58
       lea       rsi,[rcx+8]
       lea       rdi,[rsp+8]
       mov       ecx,0A
       rep movsq
       mov       rax,[rsp+10]
       add       rax,[rsp+18]
       add       rsp,58
       pop       rsi
       pop       rdi
       ret
; Total bytes of code 40

这里有两个非常有趣的指令:

mov ecx,0A
rep movsq

ParsedStat结构体的大小为80字节,这对指令重复(rep)从源位置rsi(初始化为[rcx+8],即_stat字段的位置)向目标位置rdi(堆栈位置[rsp+8])复制8字节(movsq)10次(已填充为0xA的ecx)。换句话说,这是对整个结构体进行完全复制,尽管我们只需要其中的两个字段。现在在.NET 8中,我们得到了这个:

; Tests.GetTime()
       add       rcx,8
       mov       rax,[rcx+8]
       mov       rcx,[rcx+10]
       add       rax,rcx
       ret
; Total bytes of code 16

啊,好多了。现在它避免了整个复制,只是将相关的ulong值移动到寄存器中并将它们相加。

这里还有另一个例子:

这里的一个具体改进领域是“提升”。在这个上下文中,提升是将结构体拆分为其组成字段的想法,有效地将每个字段视为自己的本地变量。这可以导致许多有价值的优化,包括能够将结构体的部分寄存器化。从.NET 7开始,JIT确实支持结构体提升,但有限制,包括仅支持最多四个字段的结构体,不支持嵌套结构体(除了基本类型)。

.NET 8中的许多工作都是为了消除这些限制。dotnet/runtime#83388通过额外的优化传递来改进现有的提升支持,JIT称之为“物理提升”;它消除了上述两个限制,但截至此PR,该功能仍默认禁用。其他PR,如dotnet/runtime#85105和dotnet/runtime#86043进一步改进了它,而dotnet/runtime#88090则默认启用了这些优化。这种改进的净结果可以在以下基准测试中看到:

// dotnet run -c Release -f net7.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;

var config = DefaultConfig.Instance
    .AddJob(Job.Default.WithId(".NET 7").WithRuntime(CoreRuntime.Core70).AsBaseline())
    .AddJob(Job.Default.WithId(".NET 8 w/o PGO").WithRuntime(CoreRuntime.Core80).WithEnvironmentVariable("DOTNET_TieredPGO", "0"))
    .AddJob(Job.Default.WithId(".NET 8").WithRuntime(CoreRuntime.Core80));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);

[HideColumns("Error", "StdDev", "Median", "RatioSD", "EnvironmentVariables", "Runtime")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    private readonly List<int?> _list = Enumerable.Range(0, 10000).Select(i => (int?)i).ToList();

    [Benchmark]
    public int CountList()
    {
        int count = 0;
        foreach (int? i in _list)
            if (i is not null)
                count++;

        return count;
    }
}

List<T>有一个名为List<T>.Enumerator的结构体,它是从List<T>.GetEnumerator()返回的,这样当你直接使用foreach遍历列表(而不是将其作为IEnumerable<T>),C#编译器会通过迭代器模式绑定到这个结构体的迭代器。这个示例在两个方面违反了之前的限制。那个Enumerator有一个用于当前T的字段,因此如果T是一个非基本值类型,它将违反“不允许嵌套结构”的限制。并且那个Enumerator有四个字段,所以如果那个T具有多个字段,它会将其推到四个字段的限制之外。现在在.NET 8 中,JIT 能够看到结构体中的字段,并将列表的遍历优化为更高效的结果。

Method Job Mean Ratio Code Size
CountList .NET 7 18.878 us 1.00 215 B
CountList .NET 8 w/o PGO 11.726 us 0.62 70 B
CountList .NET 8 5.912 us 0.31 66 B

请注意从.NET 7 到.NET 8 即使没有 PGO 也有显著的吞吐量和解耦大小改进。但是,在这里,.NET 8 没有 PGO 和有 PGO 之间的差距也很有趣,尽管原因不同。我们看到在没有 PGO 的情况下,执行时间减少了一半,但是汇编代码大小只相差四个字节。这四个字节来源于 PGO 能够帮助移除的一个单一mov指令,我们可以通过将两个片段粘贴到差异工具中来轻松看到:

将执行时间从约12微秒降至约6微秒,仅因为一个mov指令的差异,这是一个很大的差距...为什么会有如此大的影响?这最终成为了本文开头提到的一个很好的例子:要小心微基准测试,因为它们可能因机器而异。或者在这种情况下,特别是因处理器而异。我编写本文和运行本文中大部分基准测试的机器是一台几年前的台式机,配备了英特尔Coffee Lake处理器。当我在我的开发机上运行相同的基准测试时,我的开发机配备了英特尔至强铂金8370C处理器,我看到了这个结果:

Method Job Mean Ratio Code Size
CountList .NET 7 15.804 us 1.00 215 B
CountList .NET 8 w/o PGO 7.138 us 0.45 70 B
CountList .NET 8 6.111 us 0.39 66 B

代码大小相同,仍然由于物理提升而有很大的改进,但现在只有小约15%的改进,而不是PGO的小约2倍改进。事实证明,Coffee Lake是受2019年发布的跳转条件代码(JCC)Erratum影响的处理器之一( 这里的“erratum”是一种夸张的说法,意为“错误”(bug),也可以理解为“关于一个错误的文档”。)。问题涉及32字节边界上的跳转指令,以及硬件缓存有关这些指令的信息。然后通过微代码更新修复了该问题,该更新禁用了相关缓存,但这可能会导致性能问题,因为跳转是否在32字节边界上会影响它是否被缓存,从而影响缓存引入的性能提升。如果我将DOTNET_JitDisasm环境变量设置为CountList(以便让JIT直接输出反汇编,而不是依赖于BenchmarkDotNet来提取它),并将DOTNET_JitDisasmWithAlignmentBoundaries环境变量设置为1(以便让JIT在输出中包括对齐边界信息),我会看到这个结果:

G_M000_IG04:                ;; offset=0018H
       mov      r8d, dword ptr [rcx+10H]
       cmp      edx, r8d
       jae      SHORT G_M000_IG05
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ (jae: 1 ; jcc erratum) 32B boundary ...............................
       mov      r8, gword ptr [rcx+08H]

果然,我们看到这个跳转指令正好落在32字节边界上。当PGO启动并删除之前的mov指令时,它会更改对齐方式,使得跳转不再在32字节边界上:

G_M000_IG05:                ;; offset=0018H
       cmp      edx, dword ptr [rcx+10H]
       jae      SHORT G_M000_IG06
       mov      r8, gword ptr [rcx+08H]
; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ (mov: 1) 32B boundary ...............................
       cmp      edx, dword ptr [r8+08H]

这一切都是为了再次说明,有许多因素会影响微基准测试,了解差异的来源而不是仅仅接受它的表面价值是很有价值的。

这里我们讨论的是结构体。与结构体相关的另一个改进是在dotnet/runtime#79346中实现的,它添加了一个比它已经拥有的其他“liveness(存活性)”优化更早的优化步骤(存活性只是指变量是否仍然需要,因为它的值在将来可能会再次使用)。这使得JIT能够删除一些以前无法删除的结构体副本,特别是在最后一次使用结构体是将其传递给另一个方法的情况下。然而,这个额外的存活性步骤还有其他好处,特别是与“forward substitution(前向替换)”有关。前向替换是一种可以被认为是“common subexpression elimination(公共子表达式消除)”(CSE)的相反优化。使用CSE,编译器将表达式替换为包含已经计算出该表达式结果的内容,例如,如果你有:

int c = (a + b) + 3;
int d = (a + b) * 4;

编译器可能使用CSE将其重写为:

int tmp = a + b;
int c = tmp + 3;
int d = tmp * 4;

前向替换可以用来撤销这个操作,将输入到tmp的表达式分发回到使用tmp的地方,这样我们最终回到:

int c = (a + b) + 3;
int d = (a + b) * 4;

为什么编译器要这样做?这可以使某些后续优化更容易看到。例如,考虑以下基准测试:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser]
public class Tests
{
    [Benchmark]
    [Arguments(42)]
    public int Merge(int a)
    {
        a *= 3;
        a *= 3;
        return a;
    }
}

在.NET 7上,这将导致以下汇编代码:

; Tests.Merge(Int32)
       lea       edx,[rdx+rdx*2]
       lea       edx,[rdx+rdx*2]
       mov       eax,edx
       ret
; Total bytes of code 9

这里生成的代码是逐个执行每个乘法。但是,当我们将:

a *= 3;
a *= 3;
return a;

视为:

a = a * 3;
a = a * 3;
return a;

并且知道存储在a中的初始结果是临时的(感谢存活性),前向替换可以将其转换为:

a = (a * 3) * 3;
return a;

此时常量折叠可以开始工作。现在在.NET 8上我们得到:

; Tests.Merge(Int32)
       lea       eax,[rdx+rdx*8]
       ret
; Total bytes of code 4

与存活性相关的另一个更改是来自@SingleAccretion的dotnet/runtime#77990,这会在 JIT 的内部表示上增加一次遍历,消除那些被认为无用的写入操作。

类型转换

在.NET 8中,进行了各种改进以提高类型转换的性能。

dotnet/runtime#75816改进了在T为sealed时使用is T[]的性能。JIT使用CORINFO_HELP_ISINSTANCEOFARRAY助手来确定对象是否为指定的数组类型,但是当T为sealed时,JIT可以不使用助手而发出它,生成的代码就像写成obj is not null && obj.GetType() == typeof(T[])一样。这是另一个动态PGO具有可衡量影响的示例,因此基准测试突出了有和没有它的改进。

// dotnet run -c Release -f net7.0 --filter "*"

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;

var config = DefaultConfig.Instance
    .AddJob(Job.Default.WithId(".NET 7").WithRuntime(CoreRuntime.Core70).AsBaseline())
    .AddJob(Job.Default.WithId(".NET 8 w/o PGO").WithRuntime(CoreRuntime.Core80).WithEnvironmentVariable("DOTNET_TieredPGO", "0"))
    .AddJob(Job.Default.WithId(".NET 8").WithRuntime(CoreRuntime.Core80));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);


[HideColumns("Error", "StdDev", "Median", "RatioSD", "EnvironmentVariables", "Runtime")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    private readonly object _obj = new string[1];

    [Benchmark]
    public bool IsStringArray() => _obj is string[];
}
Method Job Mean Ratio
IsStringArray .NET 7 1.2290 ns 1.00
IsStringArray .NET 8 w/o PGO 0.2365 ns 0.19
IsStringArray .NET 8 0.0825 ns 0.07

这个基准测试的代码如下:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[DisassemblyDiagnoser(maxDepth: 0)]
public class Tests
{
    private readonly string[] _strings = new string[1];

    [Benchmark]
    public string Get1() => _strings[0];

    [Benchmark]
    public string Get2() => Volatile.Read(ref _strings[0]);
}

这里的Get1只是从数组中读取并返回第0个元素。Get2返回的是指向数组中第0个元素的引用。这是.NET 7中的汇编代码:

; Tests.Get1()
       sub       rsp,28
       mov       rax,[rcx+8]
       cmp       dword ptr [rax+8],0
       jbe       short M00_L00
       mov       rax,[rax+10]
       add       rsp,28
       ret
M00_L00:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 29

; Tests.Get2()
       sub       rsp,28
       mov       rcx,[rcx+8]
       xor       edx,edx
       mov       r8,offset MT_System.String
       call      CORINFO_HELP_LDELEMA_REF
       nop
       add       rsp,28
       ret
; Total bytes of code 31

在Get1中,我们立即使用了数组元素,因此C#编译器可以发出ldelem.ref IL指令,但是在Get2中,对数组元素的引用被返回,因此C#编译器发出了ldelema(加载元素地址)指令。在一般情况下,ldelema需要进行类型检查,因为由于协变性,你可以有一个Base[] array = new DerivedFromBase[1];,在这种情况下,如果你将一个指向该数组的ref Base分配给某人,并通过该ref写入一个new AlsoDerivedFromBase(),则会违反类型安全性(因为你将一个AlsoDerivedFromBase存储到DerivedFromBase[]中,即使DerivedFromBase和AlsoDerivedFromBase没有关系)。因此,这段代码的.NET 7汇编包括对CORINFO_HELP_LDELEMA_REF的调用,这是JIT用于执行该类型检查的帮助函数。但是,这里的数组元素类型是string,它是sealed的,这意味着我们无法陷入那种问题的情况:你无法将任何类型存储到string变量中,除了string。因此,这个帮助函数调用是多余的,而且在dotnet/runtime#85256中,JIT现在可以避免使用它。因此,在.NET 8中,我们得到了以下内容:

; Tests.Get2()
       sub       rsp,28
       mov       rax,[rcx+8]
       cmp       dword ptr [rax+8],0
       jbe       short M00_L00
       add       rax,10
       add       rsp,28
       ret
M00_L00:
       call      CORINFO_HELP_RNGCHKFAIL
       int       3
; Total bytes of code 29

看不到CORINFO_HELP_LDELEMA_REF。

然后,dotnet/runtime#86728降低了与通用转换相关的成本。以前,JIT总是使用CastHelpers.ChkCastAny方法来执行转换,但是通过这个改变,它内联了一个快速的成功路径。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly object _o = "hello";

    [Benchmark]
    public string GetString() => Cast<string>(_o);

    [MethodImpl(MethodImplOptions.NoInlining)]
    public T Cast<T>(object o) => (T)o;
}
方法 运行时 Mean Ratio
GetString .NET 7.0 2.247 ns 1.00
GetString .NET 8.0 1.300 ns 0.58

Peephole Optimizations

“Peephole优化”是一种将一小段指令序列替换为预期性能更好的不同序列的优化。这可能包括摆脱被认为是不必要的指令或用一个可以完成相同任务的指令替换两个指令。每个.NET版本都包含大量新的Peephole优化,通常受到现实世界示例的启发,其中一些开销可以通过略微提高代码质量来减少,.NET 8也不例外。以下是.NET 8中的一些这些优化:

  • dotnet/runtime#73120和dotnet/runtime#74806改进了处理常见的位测试模式,如(x & 1) != 0。
  • dotnet/runtime#77874消除了像short Add(short x, short y) => (short)(x + y)中的一些不必要的转换。
  • dotnet/runtime#76981通过用一个三指令mov/shl/add序列替换imul指令来改进了乘以一个离2的幂差1的数字的性能,而dotnet/runtime#77137通过用一个lea指令替换mov/shl序列来改进了其他常数乘法。
  • dotnet/runtime#78786将value < 0 || value == 0之类的单独条件融合成等效的value <= 0。
  • dotnet/runtime#82750消除了一些不必要的cmp指令。
  • dotnet/runtime#79630避免了像static byte Mod(uint i) => (byte)(i % 256)中的不必要的and。
  • dotnet/runtime#77540、dotnet/runtime#84399和dotnet/runtime#85032优化了一对load和store指令,并用Arm上的单个ldp或stp指令替换它们。
  • dotnet/runtime#84350类似地将一对str wzr指令优化为str xzr指令。
  • dotnet/runtime#83458通过用mov指令替换一些ldr指令来优化Arm上的一些冗余内存加载。
  • dotnet/runtime#83176将x < 0表达式从在Arm上发出cmp/cset序列优化为发出lsr指令。
  • dotnet/runtime#82924消除了Arm上某些除法操作的冗余溢出检查。
  • dotnet/runtime#84605将Arm上的lsl/cmp序列合并为单个cmp。
  • dotnet/runtime#84667将neg和cmp序列组合成在Arm上使用cmn。
  • dotnet/runtime#79550用mneg替换Arm上的mul/neg序列。

(这里只涉及一些特定于Arm的改进。有关更详细的信息,请参见.NET 8中的Arm64性能改进)。