原文来自互联网,由长沙DotNET技术社区编译。如译文侵犯您的署名权或版权,请联系小编,小编将在24小时内删除。
作者介绍:
史蒂夫·戈登(Steve Gordon)是Microsoft MVP,Pluralsight的作者,布莱顿(英国西南部城市)的高级开发人员和社区负责人。
编写高性能的C#代码(三)使用SPAN
这篇文章继续了我有关编写高性能C#代码的系列文章[1]。在本文中,我们将通过介绍Span 类型从上两篇文章继续,并通过将其转换为基于Span的版本来重构一些现有代码。我们将使用Benchmark.NET比较这些方法并验证我们的更改是否改进了代码。
如果您想遵循示例代码,可以在GitHub上找到[2]。
什么是SPAN ?
Span是C#7.2引入的一种新类型,在.NET Core 2.1运行时中受支持。现有的.NET Standard 1.0运行时都有一个.NET Standard实现,但是在.NET Core中,我将重点介绍运行时更改,以支持可能的最佳版本,也称为“fast span”。
Span提供对内存连续区域的类型安全访问。该内存可以位于堆,堆栈上,甚至可以由非托管内存组成。Span具有相关的类型ReadOnlySpan ,该类型提供内存中数据的只读视图。ReadOnlySpan可用于查看不可变类型(例如字符串)占用的内存。我更喜欢将Span视为进入某些现有内存的窗口,而不管其分配在何处。
在上图中,Span 引用一些已经分配的连续内存。现在,我们在该内存上有了一个窗口。
Span 被定义为引用结构,这意味着它仅限于仅在堆栈上分配。这减少了一些潜在的用例,例如将其存储为类中的字段或在异步方法中使用它。这些限制可以通过使用类似的新型Memory来解决,我们将在以后的文章中介绍它。引用结构设计的主要原因是要确保在使用Span时,我们不会引起其他堆分配。这是它支持高性能代码路径中如此高度优化的用例的原因之一。
我将避免为这篇文章过多地介绍实现细节(毕竟这是一篇介绍),而将重点放在一个示例中,我们可能在哪里使用它以及它如何影响我们的基准。
如果您想阅读有关Span的更多详细信息,我建议以下链接:
•Span 结构[3]•有关Span的所有信息:探索新的.NET主体[4]•C#7.2:了解Span[5]•Span By Adam Sitnik[6]
加快现有代码的速度并减少分配
在上一篇文章中,我们对一些代码进行了基准测试,这些代码用于从全名字符串中“解析”姓氏。通过Benchmark.NET,我们确定该方法需要125.8 ns的时间运行,并且每次运行分配160个字节。
在使用基于Span 的方法进行重构之前,我希望这是一个公平的竞争,因此我将首先不使用Span 来优化代码。这有望成为一个很好的例子,因为它着重指出,即使不使用Span之类的新功能,也可以通过对正在执行的工作进行一些思考来优化现有代码。
当前代码在任何空格上分割字符串,这将组成一个字符串数组。如果考虑到这一点,我们将分配一个数组,在使用名称“ Steve J Gordon”的情况下,这样做时将分配三个较小的字符串“ Steve”,“ J”和“ Gordon”。正如我们在基准测试中所看到的那样,这会导致分配160个字节。
对于查找姓氏的要求,我们不在乎存储名称的所有部分,而只是存储我们希望是姓氏的最后一部分。请注意,在此示例中,我忽略了多词姓氏等情况!
让我们向NameParser添加另一个方法,该方法而不是拆分字符串,而是获取最后一个空格字符的索引,并使用该方法获取代表姓氏的子字符串。
public string GetLastNameUsingSubstring(string fullName){var lastSpaceIndex = fullName.LastIndexOf(" ", StringComparison.Ordinal);return lastSpaceIndex == -1? string.Empty: fullName.Substring(lastSpaceIndex + 1);}
首先,我们获取全名字符串中最后一次出现空格的索引。如果它为-1,则找不到任何空格,因此我们将返回一个空字符串作为默认结果。如果找到索引,则使用Substring方法提取姓氏并将其返回。
我们稍后会将该版本包含在我们的基准测试中。但是,实际上,值得在进行代码改进的每个迭代时对其进行测试,以验证您是在改进方面还是使它们变得更糟。
使用SPAN
让我们看看这次如何使用Span 重新编写此代码。在高性能需求旺盛的场景中,我们既要提高速度又要减少代码中的内存分配。
public ReadOnlySpan<char> GetLastNameWithSpan(ReadOnlySpan<char> fullName){var lastSpaceIndex = fullName.LastIndexOf(' ');return lastSpaceIndex == -1 ? ReadOnlySpan<char>.Empty : fullName.Slice(lastSpaceIndex + 1);}
首先要注意的是,方法参数“ fullName”现在的类型为ReadOnlySpan 。某些类型(例如字符串)可以隐式转换为chars的ReadOnlySpan,因此此方法签名可以正常工作。现在,返回类型也是ReadOnlySpan。
首先,以与上面的优化代码非常相似的方式,我们寻找空格字符的最后一个索引。
同样,如果其值为-1,则我们找不到空格,并且将返回空的ReadOnlySpan 结果。
如果找到空格字符,我们现在可以使用Span的一种功能,即“切片”(Slice)。
切片是一项非常强大的操作,我们可以将现有的Span和“切片”放到更紧密的窗口中。切片时,我们为切片指定起始位置的索引,并为切片指定终止位置的长度。省略长度会导致从起始位置到Span结束的切片。
切片是一种低成本的操作,因为我们不复制任何内容,而只是创建一个新的Span,该Span表示一个进入现有内存范围子集的窗口。
在上图中,我们可以创建原始Span的Slice来查看其中的5个元素,而无需分配原始内存的任何其他副本。
在新的基于Span的代码中,我们从空格字符后的索引处开始获取fullName的一部分。由于我们未指定长度,因此此切片将运行到现有Span的末尾。
对Span进行切片后,会在切片的部分上产生一个新的Span,然后将其作为方法的结果返回。
至此,我们有两个潜在的改进代码版本,一个使用Substring,另一个使用Span 。让我们更新基准并比较结果。
衡量改进基准
添加两个新基准后,基准类现在如下所示:
[RankColumn][Orderer(SummaryOrderPolicy.FastestToSlowest)][MemoryDiagnoser]public class NameParserBenchmarks{private const string FullName = "Steve J Gordon";private static readonly NameParser Parser = new NameParser();[Benchmark(Baseline = true)]public void GetLastName(){Parser.GetLastName(FullName);}[Benchmark]public void GetLastNameUsingSubstring(){Parser.GetLastNameUsingSubstring(FullName);}[Benchmark]public void GetLastNameWithSpan(){Parser.GetLastNameWithSpan(FullName);}}
我们定义了三个基准,每个基准在NameParser中采用不同的方法。运行基准测试在我的计算机上给出以下结果…
此列表中的最后一项是我们原始的GetLastName方法。因为我们要求获得排名结果,并且此方法运行的最慢,所以它在最后显示出来。
这次大约花了125ns的时间运行,当然仍然分配了160个字节。
第二快的是我们尝试在不使用Span 的情况下改进代码的情况,该代码使用Substring。此代码比原始方法快大约3倍。重要的是,我们现在将分配减少到只有40个字节。这说明了我们在调用子字符串时要分配的姓氏字符串。
总的赢家是基于Span 的方法。这比我们的原始代码快10倍,比基于子字符串的方法快2.8倍。
这里真正重要的是,因为我们要对Span进行切片以查找姓氏的位置,并且还返回Span作为方法的输出,所以我们永远不会分配新的字符串。通过已分配的内存状态(现在为空)可以明显看出这一点。
对于单个调用,节省的160个字节(或子字符串方法节省40个字节)并不庞大,但是在特定场景下上,节省的费用加起来了。
如果此代码需要在我维护的每天处理约2000万条消息的数据处理服务中运行,那么我们每天将节省3.2 GB的分配。这些可能是短暂的分配,但是即使如此,它们仍将导致垃圾回收。根据估算的Gen 0 / 1k操作数(译者注,是指0代回收,每次回收1k字节),原始代码每天将触发2,000个操作,共506个GC。
这是CPU时间和暂停时间,我们可以通过避免分配任何资源来帮助减少时间。
摘要
在本文中,我们研究了新的Span 类型,并使用它重构了一些代码以实现最佳性能。最初,Span听起来可能有点复杂,但正如我希望我已经展示的那样,在本示例中使用它非常简单。
谢谢阅读!
如果您想了解有关高性能.NET和C#代码的更多信息,可以在此处[7]查看我的完整博客文章系列。
References
[1]
有关编写高性能C#代码的系列文章: https://www.stevejgordon.co.uk/motivations-for-writing-high-performance-csharp-code[2]
示例代码,可以在GitHub上找到: https://github.com/stevejgordon/BenchmarkAndSpanExample[3]
Span 结构: https://docs.microsoft.com/en-us/dotnet/api/system.span-1?view=netcore-2.2[4]
有关Span的所有信息:探索新的.NET主体: https://msdn.microsoft.com/en-us/magazine/mt814808.aspx[5]
C#7.2:了解Span: https://channel9.msdn.com/Events/Connect/2017/T125[6]
Span By Adam Sitnik: https://adamsitnik.com/Span/[7]
在此处: https://www.stevejgordon.co.uk/writing-high-performance-csharp-and-dotnet-code