翻译 | 郑子铭
原始类型和数值 (Primitive Types and Numerics)
我们已经看过了代码生成和GC,线程和矢量化,互操作......让我们把注意力转向系统中的一些基本类型。像int、bool和double这样的基本类型,像Guid和DateTime这样的核心类型,它们构成了构建一切的支柱,每一个版本都能看到这些类型的改进,这让人兴奋。
来自@CarlVerret的dotnet/runtime#62301极大地提高了double.Parse和float.Parse将UTF16文本解析为浮点值的能力。这一点特别好,因为它是基于@lemire和@CarlVerret最近的一些研究,他们用C#和.NET 5实现了一个非常快速的浮点数解析实现,而这个实现现在已经进入了.NET 7!
方法 | 运行时 | 平均值 | 比率 |
ParseAll | .NET 6.0 | 26.84 ms | 1.00 |
ParseAll | .NET 7.0 | 12.63 ms | 0.47 |
bool.TryParse和bool.TryFormat也得到了改进。dotnet/runtime#64782通过使用BinaryPrimitives执行更少的写和读,简化了这些实现。例如,TryFormat通过执行以下操作而不是写出 "True"。
这需要四次写操作,相反,它可以通过一次写来实现相同的操作。
那0x65007500720054是内存中四个字符的数值,是一个单一的ulong。你可以通过一个微观的基准测试看到这些变化的影响。
方法 | 运行时 | 平均值 | 比率 |
ParseTrue | .NET 6.0 | 7.347 ns | 1.00 |
ParseTrue | .NET 7.0 | 2.327 ns | 0.32 |
FormatTrue | .NET 6.0 | 3.030 ns | 1.00 |
FormatTrue | .NET 7.0 | 1.997 ns | 0.66 |
Enum也得到了一些性能上的提升。例如,当执行像Enum.IsDefined、Enum.GetName或Enum.ToString这样的操作时,该实现会查询所有定义在枚举上的值的缓存。这个缓存包括Enum中每个定义的枚举的字符串名称和值。它也是按数组中的值排序的,所以当这些操作之一被执行时,代码使用Array.BinarySearch来找到相关条目的索引。这方面的问题是开销的问题。当涉及到算法复杂性时,二进制搜索比线性搜索更快;毕竟,二进制搜索是O(log N),而线性搜索是O(N)。然而,在线性搜索中,每一步算法的开销也较少,因此对于较小的N值,简单地做简单的事情会快很多。这就是dotnet/runtime#57973对枚举的作用。对于小于或等于32个定义值的枚举,现在的实现只是通过内部的SpanHelpers.IndexOf(在跨度、字符串和数组上的IndexOf背后的工作程序)进行线性搜索,而对于超过这个值的枚举,它进行SpanHelpers.BinarySearch(这是对Array.BinarySearch的实现)。
方法 | 运行时 | 平均值 | 比率 |
AllDefined | .NET 6.0 | 159.28 ns | 1.00 |
AllDefined | .NET 7.0 | 94.86 ns | 0.60 |
Enums在与Nullable和EqualityComparer.Default的配合下也得到了提升。EqualityComparer.Default缓存了一个从所有对Default的访问中返回的EqualityComparer实例的单子实例。该单例根据相关的T进行初始化,实现者可以从众多不同的内部实现中进行选择,例如专门用于字节的ByteArrayComparer,用于实现IComparable的T的GenericEqualityComparer,等等。对于任意类型来说,万能的是一个ObjectEqualityComparer。dotnet/runtime#68077修复了这一问题,它确保了nullable enums被映射到(现有的)Nullable的专门比较器上,并简单地调整了其定义以确保它能与enums很好地配合。结果表明,以前有多少不必要的开销。
方法 | 运行时 | 平均值 | 比率 |
FindEnum | .NET 6.0 | 421.608 ns | 1.00 |
FindEnum | .NET 7.0 | 5.466 ns | 0.01 |
不容忽视的是,Guid的平等操作也变快了,这要感谢@madelson的dotnet/runtime#66889。以前的Guid实现将数据分成4个32位的值,并进行4个int的比较。有了这个改变,如果当前的硬件支持128位SIMD,实现就会将两个Guid的数据加载为两个向量,并简单地进行一次比较。
方法 | 运行时 | 平均值 | 比率 | 代码大小 |
GuidEquals | .NET 6.0 | 2.119 ns | 1.00 | 90 B |
GuidEquals | .NET 7.0 | 1.354 ns | 0.64 | 78 B |
dotnet/runtime#59857还改进了DateTime.Equals的一些开销。DateTime是用一个单一的ulong _dateData字段实现的,其中大部分位存储了从1/1/0001 12:00am开始的ticks偏移量,每个tick是100纳秒,并且前两个位描述了DateTimeKind。因此,公共的Ticks属性返回_dateData的值,但前两位被屏蔽掉了,例如:_dateData & 0x3FFFFFFFFFFFFFFF。然后,平等运算符只是将一个DateTime的Ticks与其他DateTime的Ticks进行比较,这样我们就可以有效地得到(dt1._dateData & 0x3FFFFFFFFFFF)==(dt2._dateData & 0x3FFFFFFFFFFF)。然而,作为一个微观的优化,可以更有效地表达为((dt1._dateData ^ dt2._dateData) << 2) == 0。在这种微小的操作中很难衡量差异,但你可以简单地从所涉及的指令数量中看出,在.NET 6上,这产生了。
而在.NET 7上则产生。
所以我们得到的不是mov、and、and、cmp,而是xor和shl。
由于@SergeiPavlov的dotnet/runtime#72712和@SergeiPavlov的dotnet/runtime#73277,对DateTime的其他操作也变得更有效率。在另一个.NET受益于最新研究进展的案例中,这些PR实现了Neri和Schneider的 "Euclidean Affine Functions and Applications to Calendar Algorithms "中的算法,以改进DateTime.Day、DateTime.DayOfYear、DateTime.DayOfYear的算法。 DateTime.DayOfYear、DateTime.Month和DateTime.Year,以及DateTime.GetDate()的内部助手,该助手被DateTime.AddMonths、Utf8Formatter.TryFormat(DateTime, ...)、DateTime.TryFormat和DateTime.ToString等一堆其他方法使用。
方法 | 运行时 | 平均值 | 比率 |
Day | .NET 6.0 | 5.2080 ns | 1.00 |
Day | .NET 7.0 | 2.0549 ns | 0.39 |
Month | .NET 6.0 | 4.1186 ns | 1.00 |
Month | .NET 7.0 | 2.0945 ns | 0.51 |
Year | .NET 6.0 | 3.1422 ns | 1.00 |
Year | .NET 7.0 | 0.8200 ns | 0.26 |
TryFormat | .NET 6.0 | 27.6259 ns | 1.00 |
TryFormat | .NET 7.0 | 25.9848 ns | 0.94 |
所以,我们已经谈到了对一些类型的改进,但在这个版本中,围绕原始类型的最重要的是 "通用数学",它几乎影响了.NET中的每一个原始类型。这里有一些重要的改进,有些改进已经酝酿了十几年了。
6月份有一篇关于通用数学的优秀博文,所以我在这里就不多说了。然而,在高层次上,现在有超过30个新的接口,利用新的C# 11静态抽象接口方法功能,暴露了从指数函数到三角函数到标准数字运算符的广泛操作,所有这些都可以通过泛型来实现,因此你可以编写一个实现,对这些接口进行泛型操作,并将你的代码应用于实现接口的任何类型....NET 7中所有的数字类型都是如此(不仅包括基数,还包括例如BigInteger和Complex)。这个功能的预览版,包括必要的运行时支持、语言语法、C#编译器支持、通用接口和接口实现,都在.NET 6和C# 10中提供,但它不支持生产使用,你必须下载一个实验性参考程序集才能获得。在dotnet/runtime#65731中,所有这些支持都作为支持的功能进入了.NET 7。dotnet/runtime#66748、dotnet/runtime#67453、dotnet/runtime#69391、dotnet/runtime#69582、dotnet/runtime#69756、dotnet/runtime#71800都根据.NET 6和.NET 7预览中的使用反馈以及我们API审查小组的适当API审查(.NET中每个新的API都要经过这一过程)更新设计和实施。 dotnet/runtime#67714添加了对用户定义的检查运算符的支持,这是C# 11的一个新特性,它使运算符的非检查和检查变化都能被暴露出来,编译器会根据检查的上下文选择正确的运算符。dotnet/runtime#68096还添加了对C# 11新的无符号右移运算符(>>)的支持。) dotnet/runtime#69651, dotnet/runtime#67939, dotnet/runtime#73274, dotnet/runtime#71033, dotnet/runtime#71010, dotnet/runtime#68251, dotnet/runtime#68217, 以及 dotnet/runtime#68094 都为各种操作增加了大量新的公共面积,所有这些都有高效的管理实现,在许多情况下都是基于开源的AMD数学库。
虽然这些支持都是主要针对外部消费者的,但核心库确实在内部消耗了一些。你可以在dotnet/runtime#68226和dotnet/runtime#68183这样的PR中看到这些API是如何清理消耗代码的,甚至在保持性能的同时,使用接口来重复Enumerable.Sum/Average/Min/Max中大量的LINQ代码。这些方法对int、long、float、double和decimal有多个重载。GitHub上的差异总结讲述了能够删除多少代码的故事。
另一个简单的例子来自.NET 7中新的System.Formats.Tar库,顾名思义,它用于读写多种tar文件格式中的任何一种档案。tar文件格式包括八进制的整数值,所以TarReader类需要解析八进制值。这些值中有些是32位整数,有些是64位整数。与其有两个独立的ParseOctalAsUInt32和ParseOctalAsUInt64方法,dotnet/runtime#74281]将这些方法合并成一个ParseOctal,其中T : struct, INumber的约束。然后,该实现完全以T为单位,并可用于这些类型中的任何一种(加上任何其他符合约束条件的类型,如果有必要的话)。这个例子特别有趣的是ParseOctal方法包括使用checked,例如value = checked((value * octalFactor) + T.CreateTruncating(digit)); 。这只是因为C# 11包括上述对用户定义的检查运算符的支持,使通用数学接口能够同时支持正常和检查品种,例如IMultiplyOperators<,,>接口包含这些方法。
而编译器会根据上下文选择合适的一个。
除了所有获得这些接口的现有类型外,还有一些新的类型。 dotnet/runtime#69204增加了新的Int128和UInt128类型。由于这些类型实现了所有相关的通用数学接口,它们带有大量的方法,每个都超过100个,所有这些都在托管代码中有效实现。在未来,我们的目标是通过JIT进一步优化其中的一些集合,并利用硬件加速的优势。
来自@am11的dotnet/runtime#63881对Math.Abs和Math.AbsF(绝对值)进行了迁移,来自@alexcovington的dotnet/runtime#56236对Math.ILogB和MathF.ILogB(base 2整数对数)进行了迁移。后者的实现是基于相同算法的MUSL libc实现,除了提高性能(部分是通过避免管理代码和本地代码之间的转换,部分是通过实际采用的算法),它还可以从本地代码中删除两个不同的实现,一个来自coreclr端,一个来自mono端,从可维护性的角度来看,这总是一个不错的胜利。
方法 | 运行时 | 参数 | 平均值 | 比率 |
ILogB | .NET 6.0 | 12345.6789 | 4.056 ns | 1.00 |
ILogB | .NET 7.0 | 12345.6789 | 1.059 ns | 0.26 |
其他数学运算也得到了不同程度的改进。Math{F}.Truncate在dotnet/runtime#65014中被@MichalPetryka改进,使其成为JIT的内在因素,这样在Arm64上,JIT可以直接发出frintz指令。 dotnet/runtime#65584对Max和Min做了同样的改进,这样可以使用Arm特有的fmax和fmin指令。在dotnet/runtime#71567中,几个BitConverter APIs也被变成了本征,以便在一些通用数学场景中能够更好地生成代码。
dotnet/runtime#55121来自@key-moon,它也改进了解析,不过是针对BigInteger,更确切地说,是针对非常非常大的BigIntegers。之前采用的将字符串解析为BigInteger的算法是O(N^2),其中N是数字的数量,虽然算法复杂度比我们通常希望的要高,但它的常数开销很低,所以对于合理大小的数值来说还是合理的。相比之下,有一种替代算法可以在O(N * (log N)^2)时间内运行,但涉及的常数因素要高得多。这使得它只值得为真正的大数字而转换。这就是这个PR所做的。它实现了替代算法,并在输入至少为20000位时切换到它(所以,是的,很大)。但是对于这么大的数字,它有很大的区别。
方法 | 运行时 | 平均值 | 比率 |
Parse | .NET 6.0 | 3.474 s | 1.00 |
Parse | .NET 7.0 | 1.672 s | 0.48 |
同样与BigInteger有关(而且不仅仅是针对真正的大数据),来自@sakno的dotnet/runtime#35565将BigInteger的大部分内部结构修改为基于跨度而非数组。这反过来又使得大量使用堆栈分配和分片来避免分配开销,同时还通过将一些代码从不安全的指针转移到安全的跨度来提高可靠性和安全性。主要的性能影响在分配数量上是可见的,特别是与除法有关的操作。
方法 | 运行时 | 平均值 | 比率 | 已分配 | 分配比率 |
ModPow | .NET 6.0 | 1.527 ms | 1.00 | 706 B | 1.00 |
ModPow | .NET 7.0 | 1.589 ms | 1.04 | 50 B | 0.07 |
数组、字符串和跨度 (Arrays, Strings, and Spans)
虽然有许多形式的计算会消耗应用程序中的资源,但一些最常见的计算包括处理存储在数组、字符串和跨度中的数据。因此,在每一个.NET版本中,你都会看到一个焦点,那就是尽可能多地从这种情况下消除开销,同时也找到方法来进一步优化开发人员通常执行的具体操作。
让我们从一些新的API开始,这些API可以帮助编写更有效的代码。在检查字符串解析/处理代码时,很常见的是检查字符是否包含在各种集合中。例如,你可能会看到一个寻找ASCII数字的字符的循环。
或为ASCII字母
或其他此类团体。有趣的是,这类检查的编码方式存在广泛的差异,往往取决于开发者在优化它们方面付出了多少努力,或者在某些情况下甚至可能没有意识到一些性能被留在了桌面上。例如,同样的ASCII字母检查可以被写成。
这虽然更 "紧张",但也更简明、更有效。它利用了一些技巧。首先,它不是通过两次比较来确定该字符是否大于或等于下限和小于或等于上限,而是根据该字符和下限之间的距离进行一次比较((uint)(c - 'a'))。如果'c'超出'z',那么'c'-'a'将大于25,比较将失败。如果'c'早于'a',那么'c'-'a'将是负数,然后将其转换为uint,将导致它环绕到一个巨大的数字,也大于25,再次导致比较失败。因此,我们能够支付一个额外的减法来避免整个额外的比较和分支,这几乎总是一个好的交易。第二个技巧是,|0x20。ASCII表有一些深思熟虑的关系,包括大写的'A'和小写的'a'只差一个位('A'是0b1000001,'a'是0b1100001)。因此,从任何小写ASCII字母到大写ASCII字母,我们只需要& ~0x20(关闭该位),而从任何大写ASCII字母到小写ASCII字母的相反方向,我们只需要| 0x20(打开该位)。我们可以在我们的范围检查中利用这一点,将我们的char c规范化为小写字母,这样我们就可以用一个位的低成本来实现小写和大写的范围检查。当然,这些技巧并不是我们希望每个开发者都必须知道并在每次使用时都要写的。取而代之的是,.NET 7在System.Char上公开了一堆新的助手来封装这些常见的检查,并以一种有效的方式完成。Char已经有了IsDigit和IsLetter这样的方法,它们提供了这些名称的更全面的Unicode含义(例如,有~320个Unicode字符被归为 "数字")。现在在.NET 7中,也有了这些帮助工具。
- IsAsciiDigit
- IsAsciiHexDigit
- IsAsciiHexDigitLower
- IsAsciiHexDigitUpper
- IsAsciiLetter
- IsAsciiLetterLower
- IsAsciiLetterUpper
- IsAsciiLetterOrDigit
这些方法是由dotnet/runtime#69318添加的,它还在dotnet/runtime中执行此类检查的几十个地方采用了这些方法(其中许多采用了效率较低的方法)。
另一个专注于封装通用模式的新API是新的MemoryExtensions.CommonPrefixLength方法,由dotnet/runtime#67929引入。该方法接受两个ReadOnlySpan实例或一个Span和一个ReadOnlySpan,以及一个可选的IEqualityComparer,并返回每个输入跨度开始时相同元素的数量。当你想知道两个输入的第一处不同时,这很有用。来自@gfoidl的dotnet/runtime#68210然后利用新的Vector128功能,提供了一个基本的矢量化实现。因为它要比较两个序列并寻找它们之间的第一个不同点,这个实现使用了一个巧妙的技巧,那就是用一个单一的方法来实现序列与字节的比较。如果被比较的T是bitwise-equatable,并且没有提供自定义的平等比较器,那么它就把跨度中的引用重新解释为字节引用,并使用单一的共享实现。
另一组新的API是IndexOfAnyExcept和LastIndexOfAnyExcept方法,由dotnet/runtime#67941引入,并由dotnet/runtime#71146和dotnet/runtime#71278用于各种附加调用站点。虽然有些拗口,但这些方法还是很方便的。它们的作用就像它们的名字一样:IndexOf(T value)搜索输入中第一个出现的值,而IndexOfAny(T value0, T value1, ...)搜索输入中第一个出现的value0, value1等的任何一个。而 IndexOfAnyExcept(T value) 则是搜索不等于 value 的东西的第一次出现,同样 IndexOfAnyExcept(T value0, T value1, ...) 也是搜索不等于 value0, value1 等东西的第一次出现。例如,假设你想知道一个整数数组是否完全是0,你现在可以写成。
dotnet/runtime#73488也将这一重载矢量化。
方法 | 平均值 | 比率 |
OpenCoded | 370.47 ns | 1.00 |
IndexOfAnyExcept | 23.84 ns | 0.06 |
当然,虽然新的 "索引的 "变化是有帮助的,但我们已经有一堆这样的方法了,而且重要的是它们要尽可能的高效。这些核心的IndexOf{Any}方法被用于大量的地方,其中很多是对性能敏感的,所以每一个版本都会得到额外的温柔呵护。虽然像dotnet/runtime#67811这样的PR通过密切关注正在生成的汇编代码获得了收益(在这种情况下,调整了IndexOf和IndexOfAny中用于Arm64的一些检查以获得更好的利用率),但这里最大的改进是在一些地方添加了矢量化而以前没有使用过,或者矢量化方案被彻底修改以获得显著收益。让我们从dotnet/runtime#63285开始,它为许多使用IndexOf和LastIndexOf的字节和字符的 "子串 "带来了巨大的改进。以前,对于像str.IndexOf("hello")这样的调用,其实现基本上是重复搜索 "h",当找到 "h "时,再执行SequenceEqual来匹配剩余部分。然而,正如你所想象的那样,很容易遇到这样的情况:被搜索的第一个字符非常常见,以至于你不得不经常跳出矢量循环,以便进行完整的字符串比较。相反,PR实现了一种基于SIMD友好算法的子串搜索算法。它不是只搜索第一个字符,而是对第一个和最后一个字符在适当的距离内进行矢量化搜索。在我们的 "hello "例子中,在任何给定的输入中,找到一个 "h "的可能性要比找到一个 "h "后面跟着一个 "o "的可能性大得多,因此这个实现能够在矢量循环中停留更长的时间,获得更少的误报,迫使它走SequenceEqual路线。该实现还可以处理所选的两个字符相等的情况,在这种情况下,它会迅速寻找另一个不相等的字符,以使搜索效率最大化。我们可以通过几个例子看到这一切的影响。
这是从古腾堡计划中拉下《夏洛克-福尔摩斯历险记》的文本,然后用IndexOf来计算文本中出现的 "夏洛克 "和 "初级 "的基准。在我的机器上,我得到这样的结果。
方法 | 运行时 | 基准 | 平均值 | 比率 |
Count | .NET 6.0 | Sherlock | 43.68 us | 1.00 |
Count | .NET 7.0 | Sherlock | 48.33 us | 1.11 |
Count | .NET 6.0 | elementary | 1,063.67 us | 1.00 |
Count | .NET 7.0 | elementary | 56.04 us | 0.05 |
对于 "Sherlock "来说,.NET 7的性能实际上比.NET 6要差一些;不多,但也有10%。这是因为在源文本中只有很少的大写字母 "S",确切地说,在文档的593,836个字符中只有841个。在起始字符的密度只有0.1%的情况下,新的算法并没有带来多少好处,因为现有的算法只搜索了第一个字符,几乎抓住了所有可能的矢量化收益,而且我们在搜索'S'和'k'时确实付出了一些开销,而之前我们只搜索了'S'。相比之下,文件中有54,614个'e'字符,几乎占到源文件的10%。在这种情况下,.NET 7比.NET 6快了20倍,在.NET 7上计算所有的'e'需要53us,而在.NET 6上则需要1084us。在这种情况下,新方案产生了巨大的收益,通过对'e'和特定距离的'y'进行矢量搜索,这种组合的频率低得多。这是其中一种情况,尽管我们可以看到一些特定的输入有小的退步,但总体上还是有巨大的观察收益。
另一个显著改变所采用的算法的例子是dotnet/runtime#67758,它使某种程度的矢量化被应用到IndexOf("...", StringComparison.OrdinalIgnoreCase)。以前,这个操作是通过一个相当典型的子串搜索来实现的,在输入字符串的每个位置做一个内循环来比较目标字符串,除了对每个字符执行ToUpper,以便以不区分大小写的方式进行。现在有了这个基于Regex以前使用的方法的PR,如果目标字符串以ASCII字符开始,实现可以使用IndexOf(如果该字符不是ASCII字母)或IndexOfAny(如果该字符是ASCII字母)来快速跳到第一个可能的匹配位置。让我们来看看与我们刚才看的完全相同的基准,但调整为使用OrdinalIgnoreCase。
在这里,这两个词在.NET 7上比在.NET 6上快了约4倍。
方法 | 运行时 | 基准 | 平均值 | 比率 |
Count | .NET 6.0 | Sherlock | 2,113.1 us | 1.00 |
Count | .NET 7.0 | Sherlock | 467.3 us | 0.22 |
Count | .NET 6.0 | elementary | 2,325.6 us | 1.00 |
Count | .NET 7.0 | elementary | 638.8 us | 0.27 |
因为我们现在做的是一个矢量的IndexOfAny('S', 's')或IndexOfAny('E', 'e'),而不是手动行走每个字符并进行比较。(dotnet/runtime#73533现在使用同样的方法来处理IndexOf(char, StringComparison.OrdinalIgnoreCase)。)
另一个例子来自dotnet/runtime#67492,来自@gfoidl。它更新了MemoryExtensions.Contains,采用了我们之前讨论的在矢量操作结束时处理剩余元素的方法:处理最后一个矢量的数据,即使这意味着重复一些已经完成的工作。这对较小的输入特别有帮助,否则处理时间可能会被这些遗留物的串行处理所支配。
方法 | 运行时 | 平均值 | 比率 |
Contains | .NET 6.0 | 15.115 ns | 1.00 |
Contains | .NET 7.0 | 2.557 ns | 0.17 |
dotnet/runtime#60974来自@alexcovington,扩大了 IndexOf 的影响。在此PR之前,IndexOf是针对一个和两个字节大小的原始类型的矢量化,但此PR也将其扩展到四个和八个字节大小的原始类型。与其他大多数矢量实现一样,它检查T是否是位数相等的,这对矢量化很重要,因为它只看内存中的位数,而不注意可能被定义在该类型上的任何等价实现。在今天的实践中,这意味着这只限于运行时对其有深入了解的少数类型(Boolean, Byte, SByte, UInt16, Int16, Char, UInt32, Int32, UInt64, Int64, UIntPtr, IntPtr, Rune, 和枚举),但在理论上它可以在未来被扩展。
方法 | 运行时 | 平均值 | 比率 |
IndexOf | .NET 6.0 | 252.17 ns | 1.00 |
IndexOf | .NET 7.0 | 78.82 ns | 0.31 |