65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.78/5 (35投票s)

2020 年 5 月 30 日

CPOL

7分钟阅读

viewsIcon

85794

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 始终在栈上使用,它具有一些限制,例如,它们不能被装箱,不能被赋值给 objectdynamic 或任何接口类型的变量,它们不能成为引用类型的字段,也不能在 awaityield 边界之间使用。此外,对 EqualsGetHashCode 这两个方法的调用会抛出 NotSupportedExceptionSpan<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 的部分
© . All rights reserved.