C# Memory.Span 意外慢

Nog*_*gog 5 c#

在尝试新功能Span<byte>Memory<byte>功能时,我发现Memory<byte>与其他与字节数组交互的方法相比,使用解析二进制数据的速度比我预期的要慢得多。

我设置了一个基准测试套件,使用多种方法从数组中读取单个整数,发现内存是最慢的。正如预期的那样,它比 Span 慢,但令人惊讶的是,它也比直接使用数组慢,也比我自己开发的我期望 Memory 内部相似的版本慢。

// Suite of tests comparing various ways to read an offset int from an array
public class BinaryTests
{
    static byte[] arr = new byte[] { 0, 1, 2, 3, 4 };
    static Memory<byte> mem = arr.AsMemory();
    static HomegrownMemory memTest = new HomegrownMemory(arr);

    [Benchmark]
    public int StraightArrayBitConverter()
    {
        return BitConverter.ToInt32(arr, 1);
    }

    [Benchmark]
    public int MemorySlice()
    {
        return BinaryPrimitives.ReadInt32LittleEndian(mem.Slice(1).Span);
    }

    [Benchmark]
    public int MemorySliceToSize()
    {
        return BinaryPrimitives.ReadInt32LittleEndian(mem.Slice(1, 4).Span);
    }

    [Benchmark]
    public int MemorySpanSlice()
    {
        return BinaryPrimitives.ReadInt32LittleEndian(mem.Span.Slice(1));
    }

    [Benchmark]
    public int MemorySpanSliceToSize()
    {
        return BinaryPrimitives.ReadInt32LittleEndian(mem.Span.Slice(1, 4));
    }

    [Benchmark]
    public int HomegrownMemorySlice()
    {
        return BinaryPrimitives.ReadInt32LittleEndian(memTest.Slice(1).Span);
    }

    [Benchmark]
    public int HomegrownMemorySliceToSize()
    {
        return BinaryPrimitives.ReadInt32LittleEndian(memTest.Slice(1, 4).Span);
    }

    [Benchmark]
    public int HomegrownMemorySpanSlice()
    {
        return BinaryPrimitives.ReadInt32LittleEndian(memTest.Span.Slice(1));
    }

    [Benchmark]
    public int HomegrownMemorySpanSliceToSize()
    {
        return BinaryPrimitives.ReadInt32LittleEndian(memTest.Span.Slice(1, 4));
    }

    [Benchmark]
    public int SpanSlice()
    {
        return BinaryPrimitives.ReadInt32LittleEndian(arr.AsSpan().Slice(1));
    }

    [Benchmark]
    public int SpanSliceToSize()
    {
        return BinaryPrimitives.ReadInt32LittleEndian(arr.AsSpan().Slice(1, 4));
    }
}

// Personal "implementation" of Memory<T>, for testing
struct HomegrownMemory
{
    byte[] _arr;
    int _startPos;
    int _length;

    public HomegrownMemory(byte[] b)
    {
        this._arr = b;
        this._startPos = 0;
        this._length = b.Length;
    }

    public Span<byte> Span => _arr.AsSpan(start: _startPos, length: _length);

    public HomegrownMemory Slice(int start)
    {
        return new HomegrownMemory()
        {
            _arr = _arr,
            _startPos = _startPos + start,
            _length = _length - start
        };
    }

    public HomegrownMemory Slice(int start, int length)
    {
        return new HomegrownMemory()
        {
            _arr = _arr,
            _startPos = _startPos + start,
            _length = length
        };
    }
}

Run Code Online (Sandbox Code Playgroud)

以下是上述代码的 BenchmarkNet 结果:

BenchmarkDotNet=v0.11.5, OS=Windows 10.0.17134.765 (1803/April2018Update/Redstone4)
Intel Core i7-4790K CPU 4.00GHz (Haswell), 1 CPU, 8 logical and 4 physical cores
Frequency=3984652 Hz, Resolution=250.9629 ns, Timer=TSC
.NET Core SDK=2.1.700-preview-009618
  [Host]     : .NET Core 2.1.11 (CoreCLR 4.6.27617.04, CoreFX 4.6.27617.02), 64bit RyuJIT
  DefaultJob : .NET Core 2.1.11 (CoreCLR 4.6.27617.04, CoreFX 4.6.27617.02), 64bit RyuJIT
|                         Method |      Mean |     Error |    StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated |
|------------------------------- |----------:|----------:|----------:|------:|------:|------:|----------:|
|      StraightArrayBitConverter | 1.0832 ns | 0.0323 ns | 0.0270 ns |     - |     - |     - |         - |
|                    MemorySlice | 5.8882 ns | 0.0654 ns | 0.0612 ns |     - |     - |     - |         - |
|              MemorySliceToSize | 6.0191 ns | 0.0983 ns | 0.0919 ns |     - |     - |     - |         - |
|                MemorySpanSlice | 5.0230 ns | 0.0626 ns | 0.0555 ns |     - |     - |     - |         - |
|          MemorySpanSliceToSize | 5.0189 ns | 0.0335 ns | 0.0313 ns |     - |     - |     - |         - |
|           HomegrownMemorySlice | 3.9217 ns | 0.0419 ns | 0.0392 ns |     - |     - |     - |         - |
|     HomegrownMemorySliceToSize | 1.5233 ns | 0.0199 ns | 0.0186 ns |     - |     - |     - |         - |
|       HomegrownMemorySpanSlice | 0.8301 ns | 0.0243 ns | 0.0227 ns |     - |     - |     - |         - |
| HomegrownMemorySpanSliceToSize | 0.8303 ns | 0.0223 ns | 0.0208 ns |     - |     - |     - |         - |
|                      SpanSlice | 0.6891 ns | 0.0241 ns | 0.0214 ns |     - |     - |     - |         - |
|                SpanSliceToSize | 0.6804 ns | 0.0174 ns | 0.0163 ns |     - |     - |     - |         - |
Run Code Online (Sandbox Code Playgroud)

所有这些时间对我来说都很有意义,除了Memory<T>时间,它们都比我预期的要慢。

据我了解,这只是可以存在于堆上Memory<T>的实现,例如......而不是引用结构。Span<T>

我原以为它的执行速度比 Span 慢,但至少比 Straight Array 实现快一点。我用自己开发的版本取得的结果正是我所期望的结果Memory<T>

关于 的用例Memory<T>,或者它试图实现的目标,我是否缺少一些基本的东西?看到这些结果后,我的理解似乎有些不对劲。

编辑:在 Cowen 发表评论后,我找到了 Memory 源代码并看了一下。在检索跨度时,它似乎确实做了很多事情,特别是检查和转换其通用对象字段以找出它的类型,以便正确转换。

我很惊讶他们没有提供使用不同的内存选项和/或提供内存工厂来构造具有更强类型内部数据字段的类。相反,他们选择有一个字段,该字段是一个必须不断检查/转换才能获得 Span 的对象,我觉得这是在使用过程中应该/会不断发生的事情。

我仍然很好奇他们为什么这样设计内存,更重要的是,它的用例是这样设计的。我觉得很多使用 Span/Memory 的人都在追求速度优势,而通用对象字段似乎鼓励不使用它。

Dan*_*iel 5

重要的是要记住,Span<T>andMemory<T>的存在不仅仅是为了相对于数组表现良好,而且性能本身是多方面的。

例如,它们存在的一个重要原因是使开发人员能够编写可以在一般情况下处理连续内存的算法,无论类型如何,部分或全部,并且以高效和安全的方式做到这一点。

您的自制内存类型比本实例更快Memory<T>,但那是因为它不具备Memory<T>. 例如,如果您在字符串解析算法中使用它,您可能会发现它的性能非常差并且需要额外的代码,因为每次遇到新字符串或需要传递一个子集时都需要创建和填充新数组。字符串到不接受startIndexor的方法length。您必须围绕此限制来设计算法,即使您能够将对性能的影响降至最低,这样做也会对代码的可读性、编写代码所花费的时间等产生负面影响, Span<T>并且Memory<T>不会出现此陷阱。

Span<T>速度快如闪电,因为它可以安全地将引用存储在堆栈中。 Memory<T>除了要求存在于堆上之外,还有类似的要求,这意味着它不能从引用或指针中受益。在这种情况下,平台必须提供的最好的功能是Object,虽然这无疑比强类型数组慢一点,但它比制作许多不必要的数据副本要快得多它并不适合所有情况,但在许多情况下,它可能足以允许开发人员编写一个单一的通用方法,否则他们可能会觉得不得不编写 2-8 个更难看的重载。这有很多价值。