.NET 7 中的性能改进(十)

原文 | Stephen Toub

翻译 | 郑子铭

最后一个有趣的与IndexOf有关的优化。字符串早就有了IndexOf/IndexOfAny/LastIndexOf/LastIndexOfAny,显然对于字符串来说,这都是关于处理字符。当ReadOnlySpan和Span出现时,MemoryExtensions被添加进来,为spans和朋友提供扩展方法,包括这样的IndexOf/IndexOfAny/LastIndexOf/LastIndexOfAny方法。但是对于spans来说,这不仅仅是char,所以MemoryExtensions增长了它自己的一套实现,基本上与string的实现分开。多年来,MemoryExtensions的实现已经专门化了越来越多的类型,但特别是字节和char,这样一来,随着时间的推移,string的实现大多被委托到与MemoryExtensions使用的相同的实现中而取代。然而,IndexOfAny和LastIndexOfAny一直是统一的保留者,它们各有自己的方向。.string.IndexOfAny对于被搜索的1-5个值确实委托给与MemoryExtensions.IndexOfAny相同的实现,但是对于超过5个值,string.IndexOfAny使用一个 "概率图",基本上是一个布鲁姆过滤器。它创建了一个256位的表,并根据被搜索的值快速设置该表中的位(本质上是散列,但用一个微不足道的散列函数)。然后,它对输入进行迭代,而不是将每个输入字符与每个目标值进行对照,而是首先在表中查找输入字符。如果相应的位没有被设置,它就知道输入的字符与任何目标值都不匹配。如果相应的位被设置,那么它就会继续将输入的字符与每个目标值进行比较,它很有可能是其中之一。MemoryExtensions.IndexOfAny对5个以上的值缺乏这样的过滤器。相反,string.LastIndexOfAny没有为多个目标值提供任何矢量,而MemoryExtensions.LastIndexOfAny则为两个和三个目标值提供矢量。从dotnet/runtime#63817开始,所有这些现在都是统一的,这样字符串和MemoryExtensions都得到了对方的优点。

private readonly char[] s_target = new[] { 'z', 'q' };
const string Sonnet = """
    Shall I compare thee to a summer's day?
    Thou art more lovely and more temperate:
    Rough winds do shake the darling buds of May,
    And summer's lease hath all too short a date;
    Sometime too hot the eye of heaven shines,
    And often is his gold complexion dimm'd;
    And every fair from fair sometime declines,
    By chance or nature's changing course untrimm'd;
    But thy eternal summer shall not fade,
    Nor lose possession of that fair thou ow'st;
    Nor shall death brag thou wander'st in his shade,
    When in eternal lines to time thou grow'st:
    So long as men can breathe or eyes can see,
    So long lives this, and this gives life to thee.
    """;

[Benchmark]
public int LastIndexOfAny() => Sonnet.LastIndexOfAny(s_target);

[Benchmark]
public int CountLines()
{
    int count = 0;
    foreach (ReadOnlySpan<char> _ in Sonnet.AsSpan().EnumerateLines())
    {
        count++;
    }

    return count;
}
方法 运行时 平均值 比率
LastIndexOfAny .NET 6.0 443.29 ns 1.00
LastIndexOfAny .NET 7.0 31.79 ns 0.07
       
CountLines .NET 6.0 1,689.66 ns 1.00
CountLines .NET 7.0 1,461.64 ns 0.86

同样的PR也清理了IndexOf系列的使用,特别是在检查包含性而不是检查结果的实际索引的使用。IndexOf系列的方法在找到一个元素时返回一个非负值,否则返回-1。这意味着当检查一个元素是否被找到时,代码可以使用>=0或!=-1,而当检查一个元素是否被找到时,代码可以使用< 0或==-1。事实证明,针对0产生的比较代码比针对-1产生的比较要稍微有效一些,这不是JIT可以自己替代的,因为IndexOf方法是内在的,这样JIT就可以理解返回值的语义。因此,为了一致性和少量的性能提升,所有相关的调用站点都被切换为与0而不是与-1比较。

说到调用站点,拥有高度优化的IndexOf方法的好处之一是在所有可以受益的地方使用它们,消除开放编码替换的维护影响,同时也收获了perf的胜利。dotnet/runtime#63913在StringBuilder.Replace里面使用IndexOf来加速寻找下一个要替换的字符。

private StringBuilder _builder = new StringBuilder(Sonnet);

[Benchmark]
public void Replace()
{
    _builder.Replace('?', '!');
    _builder.Replace('!', '?');
}
方法 运行时 平均值 比率
Replace .NET 6.0 1,563.69 ns 1.00
Replace .NET 7.0 70.84 ns 0.04

dotnet/runtime#60463来自@nietras在StringReader.ReadLine中使用IndexOfAny来搜索'\r'和'\n'行结束字符,这导致了一些可观的吞吐量提升,即使是在方法设计中固有的分配和复制。

[Benchmark]
public void ReadAllLines()
{
    var reader = new StringReader(Sonnet);
    while (reader.ReadLine() != null) ;
}
方法 运行时 平均值 比率
ReadAllLines .NET 6.0 947.8 ns 1.00
ReadAllLines .NET 7.0 385.7 ns 0.41

而dotnet/runtime#70176清理了大量的额外用途。

最后,在IndexOf方面,如前所述,多年来在优化这些方法方面花费了大量的时间和精力。在以前的版本中,其中一些精力是以直接使用硬件本征的形式出现的,例如,有一个SSE2代码路径和一个AVX2代码路径以及一个AdvSimd代码路径。现在我们有了Vector128和Vector256,许多这样的使用可以被简化(例如,避免SSE2实现和AdvSimd实现之间的重复),同时仍然保持同样好甚至更好的性能,同时自动支持其他平台上的矢量化,有自己的本征,如WebAssembly。dotnet/runtime#73481, dotnet/runtime#73556, dotnet/runtime#73368, dotnet/runtime#73364, dotnet/runtime#73064, and dotnet/runtime#73469都在这方面做出了贡献,在某些情况下产生了有意义的吞吐量的提升。

[Benchmark]
public int IndexOfAny() => Sonnet.AsSpan().IndexOfAny("!.<>");
方法 运行时 平均值 比率
IndexOfAny .NET 6.0 52.29 ns 1.00
IndexOfAny .NET 7.0 40.17 ns 0.77

IndexOf系列只是字符串/内存扩展中的一个,它已经有了很大的改进。另一个是SequenceEquals系列,包括Equals, StartsWith, 和EndsWith。在整个版本中,我最喜欢的一个变化是dotnet/runtime#65288,它正处于这个领域。我们经常看到对StartsWith等方法的调用,这些方法有一个恒定的字符串参数,例如value.StartsWith("https://"),value.SequenceEquals("Key"),等等。这些方法现在可以被JIT识别,它现在可以自动展开比较,并一次比较多个字符,例如,将四个字符作为一个长字符串进行一次读取,并将该长字符串与这四个字符的预期组合进行一次比较。其结果是美丽的。dotnet/runtime#66095使它变得更好,它增加了对OrdinalIgnoreCase的支持。还记得之前讨论过的char.IsAsciiLetter和朋友们的那些ASCII位扭动的技巧吗?JIT现在采用了同样的技巧作为解卷的一部分,所以如果你做同样的value.StartsWith("https://"),但改为value.StartsWith("https://", StringComparison.OrdinalIgnoreCase),它将认识到整个比较字符串是ASCII,并将在比较常数和从输入的读取数据上进行适当的屏蔽,以便以不分大小写的方式执行比较。

private string _value = "https://dot.net";

[Benchmark]
public bool IsHttps_Ordinal() => _value.StartsWith("https://", StringComparison.Ordinal);

[Benchmark]
public bool IsHttps_OrdinalIgnoreCase() => _value.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
方法 运行时 平均值 比率
IsHttps_Ordinal .NET 6.0 4.5634 ns 1.00
IsHttps_Ordinal .NET 7.0 0.4873 ns 0.11
       
IsHttps_OrdinalIgnoreCase .NET 6.0 6.5654 ns 1.00
IsHttps_OrdinalIgnoreCase .NET 7.0 0.5577 ns 0.08

有趣的是,从.NET 5开始,由RegexOptions.Compiled生成的代码在比较多个字符的序列时将执行类似的unrolling,而当源码生成器在.NET 7中被添加时,它也学会了如何做这个。然而,由于字节数的原因,源码生成器在这种优化方面存在问题。被比较的常量会受到字节排序问题的影响,因此源码生成器需要发出的代码可以处理在小字节或大字节机器上的运行。JIT没有这样的问题,因为它是在将执行代码的同一台机器上生成代码的(在它被用来提前生成代码的情况下,整个代码已经与特定的架构绑定)。通过将这种优化转移到JIT中,相应的代码可以从RegexOptions.Compiled和regex源码生成器中删除,然后利用StartsWith生成更容易阅读的代码,其速度也同样快(dotnet/runtime#65222和dotnet/runtime#66339)。胜利就在身边。(这只能在dotnet/runtime#68055之后从RegexOptions.Compiled中移除,它修复了JIT在DynamicMethods中识别这些字符串字面的能力,RegexOptions.Compiled使用反射emit来吐出正在编译的regex的IL。)

dotnet/runtime#63734(由dotnet/runtime#64530进一步改进)增加了另一个非常有趣的基于JIT的优化,但要理解它,我们需要理解字符串的内部布局。字符串在内存中基本上表示为一个int length,后面是许多字符和一个空终止符。实际的System.String类在C#中表示为一个int _stringLength字段和一个char _firstChar字段,这样_firstChar确实与字符串的第一个字符一致,如果字符串为空,则为空终止符。在System.Private.CoreLib内部,特别是在字符串本身的方法中,当需要查询第一个字符时,代码通常会直接引用_firstChar,因为这样做通常比使用str[0]更快,特别是因为不涉及边界检查,而且通常不需要查询字符串的长度。现在,考虑一个类似于字符串上的public bool StartsWith(char value)的方法。在.NET 6中,其实现方式是。

return Length != 0 && _firstChar == value;

考虑到我刚才描述的情况,这是有道理的:如果Length是0,那么字符串就不是以指定的字符开始的,如果Length不是0,那么我们就可以把这个值与_firstChar进行比较。但是,为什么还需要Length检查呢?难道我们不能直接返回_firstChar == value;吗?这将避免额外的比较和分支,而且工作得很好......除非目标字符本身是'\0',在这种情况下,我们可能会在结果中得到误报。现在说说这个PR。这个PR引入了一个内部的JIT intrinsinc RuntimeHelpers.IsKnownConstant,如果包含的方法被内联,并且传递给IsKnownConstant的参数被认为是一个常量,JIT会将其替换为true。在这种情况下,实现可以依靠其他JIT优化来启动和优化方法中的各种代码,有效地使开发者能够编写两种不同的实现,一种是当参数是常数时,另一种是不常数。有了这些,PR能够对StartsWith进行如下优化。

public bool StartsWith(char value)
{
    if (RuntimeHelpers.IsKnownConstant(value) && value != '\0')
        return _firstChar == value;

    return Length != 0 && _firstChar == value;
}

如果参数值不是一个常量,那么IsKnownConstant将被替换为false,整个起始if块将被删除,而方法将被完全保留。但是,如果这个方法被内联,并且值实际上是一个常量,那么值!='\0'的条件也将在JIT-编译时被评估。如果值确实是'\0',那么,整个if块将被消除,我们也不会更糟。但在常见的情况下,如果值不是空的,整个方法最终会被编译成空的。

return _firstChar == ConstantValue;

这样我们就省去了读取字符串的长度、比较和分支的过程。dotnet/runtime#69038然后对EndsWith采用了类似的技术。

private string _value = "https://dot.net";

[Benchmark]
public bool StartsWith() =>
    _value.StartsWith('a') ||
    _value.StartsWith('b') ||
    _value.StartsWith('c') ||
    _value.StartsWith('d') ||
    _value.StartsWith('e') ||
    _value.StartsWith('f') ||
    _value.StartsWith('g') ||
    _value.StartsWith('i') ||
    _value.StartsWith('j') ||
    _value.StartsWith('k') ||
    _value.StartsWith('l') ||
    _value.StartsWith('m') ||
    _value.StartsWith('n') ||
    _value.StartsWith('o') ||
    _value.StartsWith('p');
方法 运行时 平均值 比率
StartsWith .NET 6.0 8.130 ns 1.00
StartsWith .NET 7.0 1.653 ns 0.20

(另一个使用IsKnownConstant的例子来自dotnet/runtime#64016,它在指定MidpointRounding模式时使用它来改进Math.Round。这方面的调用站点几乎总是明确地将枚举值指定为常量,然后允许JIT将方法的代码生成专用于正在使用的特定模式;这反过来又使Arm64上的Math.Round(..., MidpointRounding.AwayFromZero)调用降低为一条frinta指令)。

EndsWith在dotnet/runtime#72750中也得到了改进,特别是当StringComparison.OrdinalIgnoreCase被指定时。这个简单的PR只是切换了用于实现该方法的内部辅助方法,利用了一个足以满足该方法需求且开销较低的方法的优势。

[Benchmark]
[Arguments("System.Private.CoreLib.dll", ".DLL")]
public bool EndsWith(string haystack, string needle) =>
    haystack.EndsWith(needle, StringComparison.OrdinalIgnoreCase);
方法 运行时 平均值 比率
EndsWith .NET 6.0 10.861 ns 1.00
EndsWith .NET 7.0 5.385 ns 0.50

最后,dotnet/runtime#67202和dotnet/runtime#73475采用了Vector128和Vector256来代替直接使用硬件本征,就像之前为各种IndexOf方法展示的那样,但这里分别为SequenceEqual和SequenceCompareTo。

在.NET 7中,另一个方法似乎受到了一些关注,那就是MemoryExtensions.Reverse(以及Array.Reverse,因为它共享相同的实现),它可以执行目标跨度的就地反转。来自@alexcovington的dotnet/runtime#64412通过直接使用AVX2和SSSE3硬件本征,提供了一个矢量化的实现,来自@SwapnilGaikwad的 dotnet/runtime#72780跟进,为 Arm64增加了一个AdvSimd本征实现。(最初的矢量化变化引入了一个意外的回归,但这被dotnet/runtime#70650所修复)。

private char[] text = "Free. Cross-platform. Open source.\r\nA developer platform for building all your apps.".ToCharArray();

[Benchmark]
public void Reverse() => Array.Reverse(text);
方法 运行时 平均值 比率
Reverse .NET 6.0 21.352 ns 1.00
Reverse .NET 7.0 9.536 ns 0.45

String.Split在dotnet/runtime#64899中也看到了来自@yesmey的矢量化改进。与之前讨论的一些PR一样,它将现有的SSE2和SSSE3硬件本征的使用切换到了新的Vector128帮助器上,在改进现有实现的同时也隐含了对Arm64的矢量化支持。

转换各种格式的字符串是许多应用程序和服务都会做的事情,无论是从UTF8字节转换到字符串还是格式化和解析十六进制值。这类操作在.NET 7中也有不同程度的改进。例如,Base64编码是一种在只支持文本的媒介上表示任意二进制数据(想想byte[])的方法,将字节编码为64个不同的ASCII字符之一。.NET中的多个API实现了这种编码。为了在以ReadOnlySpan表示的二进制数据和同样以ReadOnlySpan表示的UTF8(实际上是ASCII)编码数据之间进行转换,System.Buffers.Text.Base64类型提供EncodeToUtf8和DecodeFromUtf8方法。这些方法在几个版本前就已经矢量化了,但在.NET 7中通过@a74nh的dotnet/runtime#70654得到了进一步改进,它将基于SSSE3的实现转换为使用Vector128(这又隐含地在Arm64上实现了矢量化)。然而,为了在以ReadOnlySpan/byte[]和ReadOnlySpan/char[]/string表示的任意二进制数据之间进行转换,System.Convert类型暴露了多种方法,例如Convert.ToBase64String,而这些方法在历史上并没有被矢量化。这种情况在.NET 7中有所改变,dotnet/runtime#71795和dotnet/runtime#73320将ToBase64String、ToBase64CharArray和TryToBase64Chars方法矢量化。他们这样做的方式很有意思。他们没有有效地复制Base64.EncodeToUtf8的矢量化实现,而是在EncodeToUtf8之上,调用它将输入的字节数据编码成输出的Span。然后,他们将这些字节 "拓宽 "为字符(记住,Base64编码的数据是一组ASCII字符,所以从这些字节到字符需要在每个元素上添加一个0字节)。这种拓宽本身可以很容易地以矢量的方式完成。这种分层的另一个有趣之处在于,它实际上并不要求对编码的字节进行单独的中间存储。实现可以完美地计算出将X字节编码为Y个Base64字符的结果字符数(有一个公式),实现可以分配该最终空间(例如在ToBase64CharArray的情况下)或确保提供的空间是足够的(例如在TryToBase64Chars的情况下)。因为我们知道初始编码需要的字节数正好是一半,所以我们可以编码到相同的空间(目标跨度被重新解释为字节跨度而不是char跨度),然后 "就地 "扩容:从字节的末端和char空间的末端走,把字节复制到目标空间。

方法 运行时 平均值 比率
TryToBase64Chars .NET 6.0 623.25 ns 1.00
TryToBase64Chars .NET 7.0 81.82 ns 0.13

就像加宽可以用来从字节到字符,缩小可以用来从字符到字节,特别是如果字符实际上是ASCII,因此有一个0的上位字节。这种缩小可以被矢量化,内部的NarrowUtf16ToAscii工具助手正是这样做的,作为Encoding.ASCII.GetBytes等方法的一部分使用。虽然这个方法以前是矢量化的,但它的主要快速路径利用了SSE2,因此不适用于Arm64;由于@SwapnilGaikwad的dotnet/runtime#70080,该路径被改变为基于跨平台的Vector128,使其在支持的平台上具有相同水平的优化。同样,来自@SwapnilGaikwad的dotnet/runtime#71637为GetIndexOfFirstNonAsciiChar内部助手添加了Arm64矢量化,该助手被Encoding.UTF8.GetByteCount等方法使用。(同样,dotnet/runtime#67192将内部HexConverter.EncodeToUtf16方法从使用SSSE3本征改为使用Vector128,自动提供一个Arm64实现)。

Encoding.UTF8也得到了一些改进。特别是,dotnet/runtime#69910精简了GetMaxByteCount和GetMaxCharCount的实现,使其小到可以在直接使用Encoding.UTF8时被普遍内联,这样JIT就能对调用进行虚拟化。

[Benchmark]
public int GetMaxByteCount() => Encoding.UTF8.GetMaxByteCount(Sonnet.Length);
方法 运行时 平均值 比率
GetMaxByteCount .NET 6.0 1.7442 ns 1.00
GetMaxByteCount .NET 7.0 0.4746 ns 0.27

可以说,.NET 7中围绕UTF8的最大改进是C# 11对UTF8字样的新支持。UTF8字头最初在dotnet/roslyn#58991的C#编译器中实现,随后在dotnet/roslyn#59390、dotnet/roslyn#61532和dotnet/roslyn#62044中实现,UTF8字头使编译器在编译时执行UTF8编码到字节。开发者不需要写一个普通的字符串,例如 "hello",而是简单地将新的u8后缀附加到字符串字面,例如 "hello "u8。在这一点上,这不再是一个字符串。相反,这个表达式的自然类型是一个ReadOnlySpan。如果你写

public static ReadOnlySpan<byte> Text => "hello"u8;

C#编译器会编译,相当于你写的。

public static ReadOnlySpan<byte> Text =>
    new ReadOnlySpan<byte>(new byte[] { (byte)'h', (byte)'e', (byte)'l', (byte)'l', (byte)'o', (byte)'\0' }, 0, 5);

换句话说,编译器在编译时做了相当于Encoding.UTF8.GetBytes的工作,并对所得字节进行了硬编码,节省了在运行时进行编码的成本。当然,乍一看,这种数组分配可能看起来效率很低。然而,外表可能是骗人的,在这种情况下就是如此。在几个版本中,当C#编译器看到一个字节[](或sbyte[]或bool[])被初始化为一个恒定的长度和恒定的值,并立即被转换为或用于构造一个ReadOnlySpan时,它会优化掉字节[]的分配。相反,它将该跨度的数据混合到汇编的数据部分,然后构造一个跨度,直接指向加载的汇编中的数据。这就是上述属性的实际生成的IL。

IL_0000: ldsflda valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=6' '<PrivateImplementationDetails>'::F3AEFE62965A91903610F0E23CC8A69D5B87CEA6D28E75489B0D2CA02ED7993C
IL_0005: ldc.i4.5
IL_0006: newobj instance void valuetype [System.Runtime]System.ReadOnlySpan`1<uint8>::.ctor(void*, int32)
IL_000b: ret

这意味着我们不仅节省了运行时的编码成本,而且我们不仅避免了存储结果数据可能需要的托管分配,我们还受益于JIT能够看到关于编码数据的信息,比如它的长度,从而实现连带优化。通过检查为一个方法生成的汇编,你可以清楚地看到这一点。

public static int M() => Text.Length;

为之,JIT产生了。

; Program.M()
       mov       eax,5
       ret
; Total bytes of code 6

JIT内联属性访问,看到跨度的长度是5,所以它没有发出任何数组分配或跨度构建或任何类似的东西,而是简单地输出mov eax, 5来返回跨度的已知长度。

主要由于dotnet/runtime#70568, dotnet/runtime#69995, dotnet/runtime#70894, dotnet/runtime#71417 来自 @am11, dotnet/runtime#71292, dotnet/runtime#70513, and dotnet/runtime#71992, u8现在在整个dotnet/runtime中被使用超过2100次。这几乎不是一个公平的比较,但下面的基准测试表明,在执行时,u8实际执行的工作是多么少。

[Benchmark(Baseline = true)]
public ReadOnlySpan<byte> WithEncoding() => Encoding.UTF8.GetBytes("test");

[Benchmark] 
public ReadOnlySpan<byte> Withu8() => "test"u8;
方法 平均值 比率 已分配 分配比率
WithEncoding 17.3347 ns 1.000 32 B 1.00
Withu8 0.0060 ns 0.000 0.00

就像我说的,不公平,但它证明了这一点

编码当然只是创建字符串实例的一种机制。其他机制在.NET 7中也得到了改进。以超级常见的long.ToString为例。以前的版本改进了int.ToString,但32位和64位的算法之间有足够的差异,所以long没有看到所有相同的收益。现在由于dotnet/runtime#68795的出现,64位的格式化代码路径与32位的更加相似,从而使性能更快。

你也可以看到string.Format和StringBuilder.AppendFormat的改进,以及其他在这些之上的辅助工具(如TextWriter.AppendFormat)。dotnet/runtime#69757检修了Format内部的核心例程,以避免不必要的边界检查,支持预期情况,并普遍清理了实现。然而,它也利用IndexOfAny来搜索下一个需要填入的插值孔,如果非孔字符与孔的比例很高(例如,长的格式字符串有很少的孔),它可以比以前快很多。

private StringBuilder _sb = new StringBuilder();

[Benchmark]
public void AppendFormat()
{
    _sb.Clear();
    _sb.AppendFormat("There is already one outstanding '{0}' call for this WebSocket instance." +
                     "ReceiveAsync and SendAsync can be called simultaneously, but at most one " +
                     "outstanding operation for each of them is allowed at the same time.",
                     "ReceiveAsync");
}
方法 运行时 平均值 比率
AppendFormat .NET 6.0 338.23 ns 1.00
AppendFormat .NET 7.0 49.15 ns 0.15

说到StringBuilder,除了前面提到的对AppendFormat的修改之外,它还看到了额外的改进。一个有趣的变化是dotnet/runtime#64405,它实现了两个相关的事情。首先是取消了作为格式化操作一部分的钉子。举例来说,StringBuilder有一个Append(char* value, int valueCount)重载,它将指定的字符数从指定的指针复制到StringBuilder中,其他API也是以这个方法实现的;例如,Append(string? value, int startIndex, int count)方法基本上被实现为。

fixed (char* ptr = value)
{
    Append(ptr + startIndex, count);
}

这个固定的声明转化为一个 "钉住指针 (pinning pointer)"。通常情况下,GC可以自由地在堆上移动被管理的对象,它可能这样做是为了压缩堆(例如,避免对象之间出现小的、不可用的内存碎片)。但是,如果GC可以移动对象,一个正常的本地指针进入该内存将是非常不安全和不可靠的,因为在没有注意到的情况下,被指向的数据可能会移动,你的指针现在可能指向垃圾或其他被转移到该位置的对象。有两种方法来处理这个问题。第一种是 "托管指针 (managed pointer)",也被称为 "引用 "或 "ref",因为这正是你在C#中使用 "ref "关键字时得到的东西;它是一个指针,当运行时移动被指向的对象时,它将用正确的值进行更新。第二种是防止被指向的对象被移动,将其 "钉 "在原地。这就是 "固定 "关键字的作用,在固定块的持续时间内固定被引用的对象,在此期间,使用所提供的指针是安全的。值得庆幸的是,在没有发生GC的情况下,钉住是很便宜的;然而,当GC发生时,被钉住的对象不能被移动,因此,钉住会对应用程序的性能(以及GC本身)产生全面的影响。钉住也会抑制各种优化。随着C#的进步,可以在更多的地方使用ref(例如ref locals、ref returns,以及现在C# 11中的ref fields),以及.NET中所有用于操作ref的新API(例如Unsafe.Add、Unsafe.AreSame),现在可以重写使用pinning指针的代码,转而使用托管指针,从而避免了pinning带来的问题。这就是这个PR所做的。与其用Append(char*, int)帮助器来实现所有的Append方法,不如用Append(ref char, int)帮助器来实现它们。因此,举例来说,之前显示的Append(string?value, int startIndex, int count)实现,现在变成了类似于

Append(ref Unsafe.Add(ref value.GetRawStringData(), startIndex), count);

其中string.GetRawStringData方法只是公共的string.GetPinnableReference方法的内部版本,返回一个ref,而不是一个只读的ref。这意味着StringBuilder内部所有的高性能代码都可以继续使用指针来避免边界检查等,但现在也不用钉住所有的输入了。

这个StringBuilder的变化所做的第二件事是统一了对字符串输入的优化,也适用于char[]输入和ReadOnlySpan输入。具体来说,由于向StringBuilder追加字符串实例是很常见的,所以很久以前就有一个特殊的代码路径来优化这种输入,特别是在StringBuilder中已经有足够的空间来容纳整个输入的情况下,此时可以使用一个有效的拷贝。有了一个共享的Append(ref char, int)帮助器,这种优化可以下移到该帮助器中,这样它不仅可以帮助字符串,而且还可以帮助任何其他调用相同帮助器的类型。这方面的效果在一个简单的微测试中可以看到。

private StringBuilder _sb = new StringBuilder();

[Benchmark]
public void AppendSpan()
{
    _sb.Clear();
    _sb.Append("this".AsSpan());
    _sb.Append("is".AsSpan());
    _sb.Append("a".AsSpan());
    _sb.Append("test".AsSpan());
    _sb.Append(".".AsSpan());
}
方法 运行时 平均值 比率
AppendSpan .NET 6.0 35.98 ns 1.00
AppendSpan .NET 7.0 17.59 ns 0.49

改进堆栈中低层的东西的一个好处是它们有一个倍增效应;它们不仅有助于提高直接依赖改进功能的用户代码的性能,它们还可以帮助提高核心库中其他代码的性能,然后进一步帮助依赖的应用程序和服务。你可以看到这一点,例如,DateTimeOffset.ToString,它依赖于StringBuilder。

private DateTimeOffset _dto = DateTimeOffset.UtcNow;

[Benchmark]
public string DateTimeOffsetToString() => _dto.ToString();
方法 运行时 平均值 比率
DateTimeOffsetToString .NET 6.0 340.4 ns 1.00
DateTimeOffsetToString .NET 7.0 289.4 ns 0.85

随后,StringBuilder本身被@teo-tsirpanis的dotnet/runtime#64922进一步更新,它改进了Insert方法。过去,StringBuilder上的Append(primitive)方法(例如Append(int))会在值上调用ToString,然后追加结果字符串。随着ISpanFormattable的出现,作为一个快速路径,这些方法现在尝试直接将值格式化到StringBuilder的内部缓冲区,只有当没有足够的剩余空间时,他们才会采取旧的路径作为后备。当时Insert并没有以这种方式进行改进,因为它不能只是格式化到构建器末端的空间;插入的位置可以是构建器中的任何地方。这个PR解决了这个问题,它将格式化到一些临时的堆栈空间中,然后委托给之前讨论过的PR中现有的基于Ref的内部帮助器,将得到的字符插入到正确的位置(当堆栈空间对ISpanFormattable.TryFormat来说不够时,它也会退回到ToString,但这只发生在难以置信的角落,比如一个浮点值格式化到数百位数)。

private StringBuilder _sb = new StringBuilder();

[Benchmark]
public void Insert()
{
    _sb.Clear();
    _sb.Insert(0, 12345);
}
方法 运行时 平均值 比率 已分配 分配比率
Insert .NET 6.0 30.02 ns 1.00 32 B 1.00
Insert .NET 7.0 25.53 ns 0.85 0.00

对StringBuilder也做了其他小的改进,比如dotnet/runtime#60406删除了Replace方法中一个小的int[]分配。不过,即使有了这些改进,StringBuilder最快的用途也没有用;dotnet/runtime#68768删除了StringBuilder的一堆用途,这些用途用其他的字符串创建机制会更好。例如,传统的DataView类型有一些代码将排序规范创建为一个字符串。

private static string CreateSortString(PropertyDescriptor property, ListSortDirection direction)
{
    var resultString = new StringBuilder();
    resultString.Append('[');
    resultString.Append(property.Name);
    resultString.Append(']');
    if (ListSortDirection.Descending == direction)
    {
        resultString.Append(" DESC");
    }
    return resultString.ToString();
}

我们在这里实际上不需要StringBuilder,因为在最坏的情况下,我们只是将三个字符串连接起来,而string.Concat有一个专门的重载,用于这个确切的操作,它有可能是这个操作的最佳实现(如果我们找到了更好的方法,这个方法会被改进)。所以我们可以直接使用这个方法。

private static string CreateSortString(PropertyDescriptor property, ListSortDirection direction) =>
    direction == ListSortDirection.Descending ?
        $"[{property.Name}] DESC" :
        $"[{property.Name}]";

注意,我通过一个插值字符串来表达连接,但是C#编译器会将这个插值字符串 "降低 "到对string.Concat的调用,所以这个IL与我写的没有区别。

private static string CreateSortString(PropertyDescriptor property, ListSortDirection direction) =>
    direction == ListSortDirection.Descending ?
        string.Concat("[", property.Name, "] DESC") :
        string.Concat("[", property.Name, "]");

作为一个旁观者,扩展后的string.Concat版本强调了这个方法如果改为写成:"IL",那么它的结果可能会少一点。

private static string CreateSortString(PropertyDescriptor property, ListSortDirection direction) =>
    string.Concat("[", property.Name, direction == ListSortDirection.Descending ? "] DESC" : "]");

但这并不影响性能,在这里,清晰度和可维护性比减少几个字节更重要。

[Benchmark(Baseline = true)]
[Arguments("SomeProperty", ListSortDirection.Descending)]
public string WithStringBuilder(string name, ListSortDirection direction)
{
    var resultString = new StringBuilder();
    resultString.Append('[');
    resultString.Append(name);
    resultString.Append(']');
    if (ListSortDirection.Descending == direction)
    {
        resultString.Append(" DESC");
    }
    return resultString.ToString();
}

[Benchmark]
[Arguments("SomeProperty", ListSortDirection.Descending)]
public string WithConcat(string name, ListSortDirection direction) =>
    direction == ListSortDirection.Descending?
        $"[{name}] DESC" :
        $"[{name}]";
方法 平均值 比率 已分配 分配比率
WithStringBuilder 68.34 ns 1.00 272 B 1.00
WithConcat 20.78 ns 0.31 64 B 0.24

还有一些地方,StringBuilder仍然适用,但它被用在足够热的路径上,以至于以前的.NET版本看到StringBuilder实例被缓存起来。一些核心库,包括System.Private.CoreLib,有一个内部的StringBuilderCache类型,它在一个[ThreadStatic]中缓存了一个StringBuilder实例,这意味着每个线程最终都可能有这样一个实例。这样做有几个问题,包括当StringBuilder没有被使用时,StringBuilder使用的缓冲区不能用于其他任何东西,而且因为这个原因,StringBuilderCache对可以被缓存的StringBuilder实例的容量进行了限制;试图缓存超过这个容量的实例会导致它们被丢弃。最好的办法是使用不受长度限制的缓存数组,并且每个人都可以访问这些数组以进行共享。许多核心的.NET库都有一个内部的ValueStringBuilder类型,这是一个基于Ref结构的类型,可以使用堆栈分配的内存开始,然后如果需要的话,可以增长到ArrayPool数组。而随着dotnet/runtime#64522和dotnet/runtime#69683的出现,许多剩余的StringBuilderCache的使用已经被取代。我希望我们能在未来完全删除StringBuilderCache。

原文链接

Performance Improvements in .NET 7