【译】.NET 7 中的性能改进(十)
原文 | Stephen Toub
翻译 | 郑子铭
最后一个有趣的与IndexOf有关的优化。字符串早就有了IndexOf/IndexOfAny/LastIndexOf/LastIndexOfAny,显然对于字符串来说,这都是关于处理字符。当ReadOnlySpan
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
[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
在.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
转换各种格式的字符串是许多应用程序和服务都会做的事情,无论是从UTF8字节转换到字符串还是格式化和解析十六进制值。这类操作在.NET 7中也有不同程度的改进。例如,Base64编码是一种在只支持文本的媒介上表示任意二进制数据(想想byte[])的方法,将字节编码为64个不同的ASCII字符之一。.NET中的多个API实现了这种编码。为了在以ReadOnlySpan
方法 | 运行时 | 平均值 | 比率 |
---|---|---|---|
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
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_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
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
原文链接
Performance Improvements in .NET 7
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。
如有任何疑问,请与我联系 (MingsonZheng@outlook.com)