如何在C#中快速读取二进制文件?(ReadOnlySpan vs MemoryStream)

22

我希望能够尽快解析二进制文件。所以这就是我最初尝试的做法:

using (FileStream filestream = path.OpenRead()) {
   using (var d = new GZipStream(filestream, CompressionMode.Decompress)) {
      using (MemoryStream m = new MemoryStream()) {
         d.CopyTo(m);
         m.Position = 0;

         using (BinaryReaderBigEndian b = new BinaryReaderBigEndian(m)) {
            while (b.BaseStream.Position != b.BaseStream.Length) {
               UInt32 value = b.ReadUInt32();
}  }  }  }  }

其中BinaryReaderBigEndian类的实现如下:

public static class BinaryReaderBigEndian {
   public BinaryReaderBigEndian(Stream stream) : base(stream) { }

   public override UInt32 ReadUInt32() {
      var x = base.ReadBytes(4);
      Array.Reverse(x);
      return BitConverter.ToUInt32(x, 0);
}  }

然后,我尝试使用 ReadOnlySpan 替代 MemoryStream 来提高性能。所以,我尝试了以下操作:

Then, I tried to get a performance improvement using ReadOnlySpan instead of MemoryStream. So, I tried doing:

using (FileStream filestream = path.OpenRead()) {
   using (var d = new GZipStream(filestream, CompressionMode.Decompress)) {
      using (MemoryStream m = new MemoryStream()) {
         d.CopyTo(m);
         int position = 0;
         ReadOnlySpan<byte> stream = new ReadOnlySpan<byte>(m.ToArray());

         while (position != stream.Length) {
            UInt32 value = stream.ReadUInt32(position);
            position += 4;
}  }  }  }

BinaryReaderBigEndian类在何处更改:

public static class BinaryReaderBigEndian {
   public override UInt32 ReadUInt32(this ReadOnlySpan<byte> stream, int start) {
      var data = stream.Slice(start, 4).ToArray();
      Array.Reverse(x);
      return BitConverter.ToUInt32(x, 0);
}  }

但是,不幸的是,我没有注意到任何改善。那么,我做错了什么?


你的瓶颈在哪里?CPU、内存还是磁盘访问? - Richard Hubley
没有地方!这就是为什么我感到惊讶。 - heliosophist
你为什么要先将整个文件复制到内存流中?这会耗费很多时间。 - Scott Chamberlain
原因是因为这样可以让我从硬盘上读取数据的时间更短,同时也避免了页面错误。 - heliosophist
1个回答

36

我在我的电脑上对你的代码进行了一些测量(Intel Q9400, 8 GiB RAM, SSD 硬盘, Win10 x64 Home, .NET Framework 4/7/2, 使用 15 MB(解压后)文件进行测试),结果如下:

无 Span 版本:520 毫秒
Span 版本:720 毫秒

所以 Span 版本实际上更慢!为什么?因为 new ReadOnlySpan<byte>(m.ToArray()) 进行了整个文件的额外复制,而且 ReadUInt32()Span 进行了许多切片(切片是便宜的,但不是免费的)。由于你执行了更多的工作,你不能指望性能会更好,只是因为你使用了 Span

那么我们能做得更好吗?可以。事实证明,你的代码最慢的部分实际上是由 .ToArray() 调用在 ReadUInt32() 方法中重复分配 4 字节的 Array 引起的垃圾回收。你可以通过自己实现 ReadUInt32() 来避免这种情况。这很容易实现,也消除了对 Span 切片的需求。你还可以用 new ReadOnlySpan<byte>(m.GetBuffer()).Slice(0, (int)m.Length); 替换 new ReadOnlySpan<byte>(m.ToArray()),它执行便宜的切片而不是整个文件的复制。所以现在代码看起来像这样:

public static void Read(FileInfo path)
{
    using (FileStream filestream = path.OpenRead())
    {
        using (var d = new GZipStream(filestream, CompressionMode.Decompress))
        {
            using (MemoryStream m = new MemoryStream())
            {
                d.CopyTo(m);
                int position = 0;

                ReadOnlySpan<byte> stream = new ReadOnlySpan<byte>(m.GetBuffer()).Slice(0, (int)m.Length);

                while (position != stream.Length)
                {
                    UInt32 value = stream.ReadUInt32(position);
                    position += 4;
                }
            }
        }
    }
}

public static class BinaryReaderBigEndian
{
    public static UInt32 ReadUInt32(this ReadOnlySpan<byte> stream, int start)
    {
        UInt32 res = 0;
        for (int i = 0; i < 4; i++)
            {
                res = (res << 8) | (((UInt32)stream[start + i]) & 0xff);
        }
        return res;
    }
}

通过这些更改,我将执行时间从720毫秒降至165毫秒(加速4倍)。听起来很棒,不是吗?但我们可以做得更好。我们可以完全避免MemoryStream的复制,并且内联和进一步优化ReadUInt32()

public static void Read(FileInfo path)
{
    using (FileStream filestream = path.OpenRead())
    {
        using (var d = new GZipStream(filestream, CompressionMode.Decompress))
        {
            var buffer = new byte[64 * 1024];

            do
            {
                int bufferDataLength = FillBuffer(d, buffer);

                if (bufferDataLength % 4 != 0)
                    throw new Exception("Stream length not divisible by 4");

                if (bufferDataLength == 0)
                    break;

                for (int i = 0; i < bufferDataLength; i += 4)
                {
                    uint value = unchecked(
                        (((uint)buffer[i]) << 24)
                        | (((uint)buffer[i + 1]) << 16)
                        | (((uint)buffer[i + 2]) << 8)
                        | (((uint)buffer[i + 3]) << 0));
                }

            } while (true);
        }
    }
}

private static int FillBuffer(Stream stream, byte[] buffer)
{
    int read = 0;
    int totalRead = 0;
    do
    {
        read = stream.Read(buffer, totalRead, buffer.Length - totalRead);
        totalRead += read;

    } while (read > 0 && totalRead < buffer.Length);

    return totalRead;
}

现在它只需要不到90毫秒的时间(比原始版本快8倍!)而且没有使用Span!在允许执行切片并避免数组复制的情况下,Span非常好用,但是盲目使用它并不能提高性能。毕竟,Span设计上旨在与Array具有相同的性能特征,而不是更好(仅适用于具有特殊支持的运行时,如.NET Core 2.1)。

谢谢您的回答。我已经测试了您提供的两种解决方案,但是我只能获得大约3倍的加速。我正在创建一个包含所有未压缩文件的大型缓冲区。大部分时间都花在了您的FillBuffer函数上。是否有一种方法可以获得更好的FillBufferReadBytes(我需要一个可以像stream.readBytes()或span slice一样工作的函数),或者更好的库来解压GZip文件? - heliosophist
我在一个相对较小的文件上进行了测试,这个文件可能由于我的反复测试而被Windows缓存在RAM中,因此读取这个文件非常快。如果您的文件很大并且没有被缓存,程序很可能会花费更多时间从磁盘读取数据,因此速度会变慢。您可以尝试使用其中一个new FileStream()重载打开文件,该重载允许指定高级选项,如缓冲区大小、顺序文件访问等。您还可以尝试调整我的缓冲区大小var buffer = new byte[64 * 1024]; - 更大的缓冲区允许更少的调用操作系统... - Ňuf
另一方面,较小的尺寸增加了缓冲区完全适合L1/L2高速缓存的机会。我有意避免使用ReadBytes(),因为它会导致垃圾收集变慢的问题。我还注意到(出于无法解释的原因),当我以.NET Framework 4.7.1为目标时,我的程序比以4.7.2为目标时慢了约100毫秒。我从未使用过任何第三方GZip库,所以您需要向Google寻求帮助 :) - Ňuf
1
好的,仍然感谢您的帮助。您知道是否存在一种方法来改进我的第一个代码版本中避免垃圾回收的问题,即使用MemoryStream的那个版本吗? - heliosophist
只要调用了 base.ReadBytes(4),你可能没有太多办法。你可以尝试推迟垃圾回收,使用GC 服务器后台模式... - Ňuf
...或者使用.NET Core 2.x,甚至可以编写自己的GC ;-) 但仍然最好的方法是完全避免分配并直接使用 MemoryStream 的内部缓冲区(就像我在第一个示例中使用 new ReadOnlySpan<byte>(m.GetBuffer()).Slice(0, (int)m.Length) 那样)。 - Ňuf

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接