使用 Span<T> 提升 C# 代码性能






4.78/5 (35投票s)
Span C# 功能概述以及它如何改进现有代码库的示例。
引言
根据我的经验,提高应用程序性能的主要方法是减少 IO 调用的数量和持续时间。然而,一旦采用了这种方法,开发人员的另一个途径是使用栈内存。栈内存允许非常快速地分配和释放,尽管它只应用于分配小块内存,因为栈空间非常有限。此外,使用栈内存可以减轻 GC 的压力。为了在栈上分配内存,可以使用 值类型 或 stackalloc
运算符并结合非托管内存的使用。
第二个选项很少被开发人员使用,因为访问非托管内存的 API 非常冗长。
Span<T>
是 C# 7.2 中引入的一系列值类型,它是一种无分配的、表示来自不同内存源的内存的方式。Span<T>
允许开发人员更方便地处理连续内存区域,同时确保内存和类型的安全。
Span<t> 实现
Ref 返回
对于那些不密切关注 C# 语言更新的读者来说,理解 Span<T>
实现的第一步是了解 C# 7.0 中引入的 ref 返回。
虽然大多数读者熟悉按引用传递方法参数,但现在 C# 允许返回值的引用而不是值本身。
让我们来看看它是如何工作的。我们将创建一个简单的包装器,围绕一个著名的音乐家数组,该包装器同时展示了传统行为和新的 ref
返回特性。
public class ArtistsStore
{
private readonly string[] _artists =
new[] { "Amenra", "The Shadow Ring", "Hiroshi Yoshimura" };
public string ReturnSingleArtist()
{
return _artists[1];
}
public ref string ReturnSingleArtistByRef()
{
return ref _artists[1];
}
public string AllAritsts => string.Join(", ", _artists);
}
现在让我们调用这些方法
var store = new ArtistsStore();
var artist = store.ReturnSingleArtist();
artist = "Henry Cow";
var allArtists = store.AllAritsts; //Amenra, The Shadow Ring, Hiroshi Yoshimura
artist = store.ReturnSingleArtistByRef();
artist = "Frank Zappa";
allArtists = store.AllAritsts; //Amenra, The Shadow Ring, Hiroshi Yoshimura
ref var artistReference = ref store.ReturnSingleArtistByRef();
artistReference = "Valentyn Sylvestrov";
allArtists = store.AllAritsts; //Amenra, Valentyn Sylvestrov, Hiroshi Yoshimura
请注意,在第一个和第二个示例中,原始集合未被修改,而在最后一个示例中,我们成功地修改了集合中的第二个艺术家。正如您将在本文的后续内容中看到的,这个有用的特性将帮助我们以引用类型的方式操作位于栈上的数组。
Ref 结构
我们知道,值类型可以分配在栈上。此外,它们不一定依赖于使用该值的上下文。为了确保值始终分配在栈上,C# 7.0 中引入了 ref 结构 的概念。Span<T>
是一个 ref struct
,因此我们可以确定它始终分配在栈上。
Span<t> 实现
Span<T>
是一个 ref struct
,它包含一个指向内存的指针和一个 span
的长度,类似如下:
public readonly ref struct Span<T>
{
private readonly ref T _pointer;
private readonly int _length;
public ref T this[int index] => ref _pointer + index;
...
}
请注意指针字段附近的 ref
修饰符。在 .NET Core 中,这种结构不能在纯 C# 中声明,它是通过 ByReference<T>
实现的。
所以,正如您所看到的,索引是通过 ref 返回
实现的,这允许仅在栈上使用的 struct
具有引用类型式的行为。
Span<t> 限制
为了确保 ref struct
始终在栈上使用,它具有一些限制,例如,它们不能被装箱,不能被赋值给 object
、dynamic
或任何接口类型的变量,它们不能成为引用类型的字段,也不能在 await
和 yield
边界之间使用。此外,对 Equals
和 GetHashCode
这两个方法的调用会抛出 NotSupportedException
。Span<T>
是一个 ref struct
。
使用 Span<T> 替代 string
重构现有代码库以使用 Span<t>
让我们来看一段将 Linux 权限 转换为八进制表示的代码。您可以在 此处 访问它。这是原始代码
internal class SymbolicPermission
{
private struct PermissionInfo
{
public int Value { get; set; }
public char Symbol { get; set; }
}
private const int BlockCount = 3;
private const int BlockLength = 3;
private const int MissingPermissionSymbol = '-';
private readonly static Dictionary<int, PermissionInfo> Permissions =
new Dictionary<int, PermissionInfo>() {
{0, new PermissionInfo {
Symbol = 'r',
Value = 4
} },
{1, new PermissionInfo {
Symbol = 'w',
Value = 2
}},
{2, new PermissionInfo {
Symbol = 'x',
Value = 1
}} };
private string _value;
private SymbolicPermission(string value)
{
_value = value;
}
public static SymbolicPermission Parse(string input)
{
if (input.Length != BlockCount * BlockLength)
{
throw new ArgumentException
("input should be a string 3 blocks of 3 characters each");
}
for (var i = 0; i < input.Length; i++)
{
TestCharForValidity(input, i);
}
return new SymbolicPermission(input);
}
public int GetOctalRepresentation()
{
var res = 0;
for (var i = 0; i < BlockCount; i++)
{
var block = GetBlock(i);
res += ConvertBlockToOctal(block) * (int)Math.Pow(10, BlockCount - i - 1);
}
return res;
}
private static void TestCharForValidity(string input, int position)
{
var index = position % BlockLength;
var expectedPermission = Permissions[index];
var symbolToTest = input[position];
if (symbolToTest != expectedPermission.Symbol &&
symbolToTest != MissingPermissionSymbol)
{
throw new ArgumentException($"invalid input in position {position}");
}
}
private string GetBlock(int blockNumber)
{
return _value.Substring(blockNumber * BlockLength, BlockLength);
}
private int ConvertBlockToOctal(string block)
{
var res = 0;
foreach (var (index, permission) in Permissions)
{
var actualValue = block[index];
if (actualValue == permission.Symbol)
{
res += permission.Value;
}
}
return res;
}
}
public static class SymbolicUtils
{
public static int SymbolicToOctal(string input)
{
var permission = SymbolicPermission.Parse(input);
return permission.GetOctalRepresentation();
}
}
原因很简单:string
是一个 char
数组,为什么不将其分配在栈上而不是堆上呢。
因此,我们的第一个目标是将 SymbolicPermission
的 _value
字段标记为 ReadOnlySpan<char>
而不是 string
。为了实现这一点,我们必须将 SymbolicPermission
声明为 ref struct
,因为字段或属性不能是 Span<T>
类型,除非它是 ref struct
的实例。
internal ref struct SymbolicPermission
{
...
private ReadOnlySpan<char> _value;
}
现在,我们将所有我们能够触及的 string
更改为 ReadOnlySpan<char>
。唯一值得关注的是 GetBlock
方法,因为在这里我们将 Substring
替换为 Slice
。
private ReadOnlySpan<char> GetBlock(int blockNumber)
{
return _value.Slice(blockNumber * BlockLength, BlockLength);
}
评估版
让我们来衡量结果
我们注意到了速度的提升,约为 50 纳秒,大约提高了 10% 的性能。有人可能会争辩说 50 纳秒并不多,但这对我们来说几乎是零成本!
现在,我们将评估在具有 18 个块(每个块 12 个字符)的权限上进行此改进,看看我们是否能获得显著的提升。
正如您所看到的,我们成功地获得了 0.5 微秒或 5% 的性能提升。同样,这可能看起来是一项温和的成就。但请记住,这真的是一个很容易实现的改进。
使用 Span<T> 替代数组
让我们扩展到其他类型的数组。考虑 ASP.NET Channels 管道 中的示例。下面的代码背后的原因是数据经常以块的形式通过网络到达,这意味着数据片段可能同时存在于多个缓冲区中。在示例中,此类数据被解析为 int
。
public unsafe static uint GetUInt32(this ReadableBuffer buffer) {
ReadOnlySpan<byte> textSpan;
if (buffer.IsSingleSpan) { // if data in single buffer, it’s easy
textSpan = buffer.First.Span;
}
else if (buffer.Length < 128) { // else, consider temp buffer on stack
var data = stackalloc byte[128];
var destination = new Span<byte>(data, 128);
buffer.CopyTo(destination);
textSpan = destination.Slice(0, buffer.Length);
}
else {
// else pay the cost of allocating an array
textSpan = new ReadOnlySpan<byte>(buffer.ToArray());
}
uint value;
// yet the actual parsing routine is always the same and simple
if (!Utf8Parser.TryParse(textSpan, out value)) {
throw new InvalidOperationException();
}
return value;
}
让我们稍微分解一下这里发生的事情。我们的目标是将字节序列 textSpan
解析为 uint
。
if (!Utf8Parser.TryParse(textSpan, out value)) {
throw new InvalidOperationException();
}
return value;
现在,让我们看看我们是如何填充输入参数到 textSpan
中的。输入参数是可读字节序列的缓冲区实例。ReadableBuffer
继承自 ISequence<ReadOnlyMemory<byte>>
,这意味着它由多个内存段组成。
如果缓冲区仅包含一个段,我们只需使用第一个段的底层 Span
。
if (buffer.IsSingleSpan) {
textSpan = buffer.First.Span;
}
否则,我们在栈上分配数据并基于它创建一个 Span<byte>
。
var data = stackalloc byte[128];
var destination = new Span<byte>(data, 128);
然后,我们使用 buffer.CopyTo(destination)
方法,该方法会迭代缓冲区的每个内存段并将其复制到目标 Span
。之后,我们只需对缓冲区的长度进行切片。
textSpan = destination.Slice(0, buffer.Length);
这个例子向我们展示了新的 Span<T>
API 允许我们比以前更方便地手动处理在栈上分配的内存。
使用 Span<T> 替代 List<T>
让我们回到之前将 Span<char>
替代 string 的部分。您可能还记得,SymbolicPermission
类的静态工厂接受 ReadOnlySpan<char>
作为输入。
public static SymbolicPermission Parse(ReadOnlySpan<char> input)
{
...
return new SymbolicPermission(input);
}
我们反过来向工厂提供了 string
,一切都能顺利编译。
public static int SymbolicToOctal(string input)
{
var permission = SymbolicPermission.Parse(input);
return permission.GetOctalRepresentation();
}
由于 string
是一个 char
数组,我们可能会期望 List<char>
具有类似的行为。毕竟,它们都是使用 array
作为底层实现的索引集合。
但实际上,情况并非如此乐观
解决这个问题的方法是使用 CollectionsMarshal.AsSpan
辅助方法
public static int SymbolicToOctal(List<char> input)
{
var permission = SymbolicPermission.Parse(CollectionsMarshal.AsSpan(input));
return permission.GetOctalRepresentation();
}
F# 支持
.NET Core 不仅限于 C#。在我 之前的几篇博文 中,我提到了一些您可能考虑使用 F# 的原因。从 4.5 版本开始,F# 也支持 Span<T>
。让我们将一些 Linux 权限转换功能委托给 F# 助手,看看它在性能方面能否跟上 C#。
我们将声明 Helpers
类型来计算八进制表示。
[<Struct>]
type PermissionInfo(symbol: char, value: int) =
member x.Symbol = symbol
member x.Value = value
type Helpers =
val private Permissions : PermissionInfo[]
new () = {
Permissions =
[|PermissionInfo('r', 4);
PermissionInfo('w', 2);
PermissionInfo('x', 1); |]
}
member x.ConvertBlockToOctal (block : ReadOnlySpan<char>) =
let mutable acc = 0
for i = 0 to x.Permissions.Length - 1 do
if block.[i] = x.Permissions.[i].Symbol then
acc <- acc + x.Permissions.[i].Value
else
acc <- acc
acc
这里一个值得注意的点是 Permissions
数组被标记为 val
。正如 文档 所述,它允许在类或结构类型中声明一个存储值的字段,而无需初始化。
在 C# 中调用它非常流畅。
var block = GetBlock(i);
res += new Helpers().ConvertBlockToOctal(block) * (int)Math.Pow(10, BlockCount - i - 1);
这是基准测试
虽然 F# 版本分配了更多内存,但执行时间差异却相当可观。
结论
Span<T>
提供了一种安全且易于使用的 stackallock
替代方案,可以轻松地获得性能提升。虽然每次使用的收益相对较小,但持续使用可以避免所谓的“千刀万剐”。Span<T>
被广泛用于 .NET Core 3.0 代码库中,与之前的版本相比,这带来了 性能提升。
当您决定是否使用 Span<T>
时,可以考虑以下几点:
-
如果您的方法接受一个数据数组并且不改变其大小。如果您不修改输入,可以考虑
ReadOnlySpan<T>
。 -
如果您的方法接受一个
string
来计算某些统计信息或执行语法分析,您应该接受ReadOnlySpan<char>
。 -
如果您的方法返回一个简短的数据数组,您可以使用
Span<T> buf = stackalloc T[size]
返回Span<T>
。请记住,T
应该是值类型。
历史
- 2020 年 5 月 30 日:初始版本
- 2020 年 6 月 28 日:添加了另一个使用
ReadOnlySpan<byte>
的示例 - 2020 年 8 月 7 日:在结论中添加了更多要点,并解释了最新的代码示例
- 2020 年 10 月 10 日:添加了 F# 支持部分
- 2021 年 11 月 27 日:添加了将 List 转换为 Span 的部分