.NET中更快(但不安全)的二进制读取器

28
我遇到了一个情况,需要从一个非常大的文件中读取二进制数据。因此,我意识到 .NET 中默认的 BinaryReader 实现相当慢。通过使用 .NET Reflector 查看它,我发现了以下内容:
public virtual int ReadInt32()
{
    if (this.m_isMemoryStream)
    {
        MemoryStream stream = this.m_stream as MemoryStream;
        return stream.InternalReadInt32();
    }
    this.FillBuffer(4);
    return (((this.m_buffer[0] | (this.m_buffer[1] << 8)) | (this.m_buffer[2] << 0x10)) | (this.m_buffer[3] << 0x18));
}

在我看来,这似乎非常低效,因为计算机的设计是为了使用32位值,自从32位CPU被发明以来。

因此,我自己编写了(不安全的)FastBinaryReader类,并使用了以下代码:

public unsafe class FastBinaryReader :IDisposable
{
    private static byte[] buffer = new byte[50];
    //private Stream baseStream;

    public Stream BaseStream { get; private set; }
    public FastBinaryReader(Stream input)
    {
        BaseStream = input;
    }


    public int ReadInt32()
    {
        BaseStream.Read(buffer, 0, 4);

        fixed (byte* numRef = &(buffer[0]))
        {
            return *(((int*)numRef));
        }
    }
...
}

我成功地将读取一个500MB文件所需的时间缩短了5-7秒,但总体上仍然相当慢(最初需要29秒,现在使用我的FastBinaryReader大约需要22秒)。

我还是有些困惑,为什么读取这样一个相对较小的文件仍然需要这么长时间。如果我将文件从一个磁盘复制到另一个磁盘,只需要几秒钟,因此磁盘吞吐量不是问题。

我进一步内联了ReadInt32等调用,最终得到了以下代码:

using (var br = new FastBinaryReader(new FileStream(cacheFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 0x10000, FileOptions.SequentialScan)))

  while (br.BaseStream.Position < br.BaseStream.Length)
  {
      var doc = DocumentData.Deserialize(br);
      docData[doc.InternalId] = doc;
  }
}

   public static DocumentData Deserialize(FastBinaryReader reader)
   {
       byte[] buffer = new byte[4 + 4 + 8 + 4 + 4 + 1 + 4];
       reader.BaseStream.Read(buffer, 0, buffer.Length);

       DocumentData data = new DocumentData();
       fixed (byte* numRef = &(buffer[0]))
       {
           data.InternalId = *((int*)&(numRef[0]));
           data.b = *((int*)&(numRef[4]));
           data.c = *((long*)&(numRef[8]));
           data.d = *((float*)&(numRef[16]));
           data.e = *((float*)&(numRef[20]));
           data.f = numRef[24];
           data.g = *((int*)&(numRef[25]));
       }
       return data;
   }

还有什么更好的想法可以让这个过程更快吗?我在想,也许我可以使用marshalling将整个文件映射到某个自定义结构的内存中,因为数据是线性的、固定大小的和顺序的。

已解决:我得出结论,FileStream的缓冲/BufferedStream存在缺陷。请参见下面的被接受的答案和我的答案(包括解决方案)。


这可能会有帮助:https://dev59.com/8WIk5IYBdhLWcg3wIq9U#19837238?noredirect=1#19837238 - Amir Pournasserian
5个回答

24

我在使用BinaryReader/FileStream时遇到了类似的性能问题,经过分析,我发现问题不是在于FileStream缓冲,而是与这行代码有关:

while (br.BaseStream.Position < br.BaseStream.Length) {

具体来说,在一个 FileStream 上,属性 br.BaseStream.Length 在每次循环时都会进行一个相对较慢的系统调用以获取文件大小。在将代码更改为以下内容后:

long length = br.BaseStream.Length;
while (br.BaseStream.Position < length) {

使用适当的缓冲区大小,并使用 FileStream,我实现了与 MemoryStream 示例类似的性能。


12

有趣的是,将整个文件读入缓冲区并在内存中处理它确实产生了巨大的差异。这是以内存为代价的,但我们有很多内存可以使用。

这让我觉得FileStream(或者说BufferedStream)的缓冲实现存在缺陷,因为无论我尝试使用什么大小的缓冲区,性能都很糟糕。

  using (var br = new FileStream(cacheFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 0x10000, FileOptions.SequentialScan))
  {
      byte[] buffer = new byte[br.Length];
      br.Read(buffer, 0, buffer.Length);
      using (var memoryStream = new MemoryStream(buffer))
      {
          while (memoryStream.Position < memoryStream.Length)
          {
              var doc = DocumentData.Deserialize(memoryStream);
              docData[doc.InternalId] = doc;
          }
      }
  }

现在已经降到了2-5秒(取决于磁盘缓存),而原来是22秒。现在这已经足够好了。


所以我的答案并不是那么有缺陷 ;^) - Toad
3
谢谢。不过,实际上.NET的缓冲实现存在问题,因为我尝试使用与文件大小完全相同的缓冲区大小(应该等效于中间的MemoryStream),但在性能方面仍然很差。理论上,您的建议应该是多余的,但在实践中,却收到了意外的好效果。 - andreialecu
6
您可以直接使用以下代码:var buffer = File.ReadAllBytes(cacheFilePath); 这样不仅可以减少代码量,而且会更快。 - gjvdkamp

10

当你执行文件复制操作时,大块的数据会被读取并写入磁盘。

你每次只读取四个字节来读取整个文件。这肯定会更慢。即使流实现足够智能以进行缓冲,你仍然需要至少500 MB/4 = 131072000个API调用。

难道不是更明智的做法是读取一大块数据,然后顺序地遍历它,并重复此步骤,直到文件被处理完毕吗?


1
在FileStream构造函数中有一个参数可以指定缓冲区大小,因此读取确实是分块进行的。我尝试了各种缓冲区大小的值,但并没有什么明显的改进。在我的测试中,超大的缓冲区大小实际上会损害性能。 - andreialecu
你仍然在做大量的“ReadInt32”调用。直接从连续的内存块中获取数据会更快。 - Toad
请重新阅读问题,我在实际实现中没有使用ReadInt32,每个对象只有1次读取,并且所有转换都是内联的,请参见最后两个代码块。 - andreialecu
对不起,我猜这么多小内存分配可能是问题所在。 - Toad
我将授予您的问题为被接受的答案,因为您建议从文件中读取大块数据。如果实际的FileStream缓冲实现没有缺陷,那么这将是多余的,但显然不是这样。 - andreialecu

6

需要注意的一点是,您可能需要仔细检查您的CPU字节序......假设小端序并不完全安全(考虑:Itanium等)。

您还可以查看BufferedStream是否有任何区别(我不确定它会有什么区别)。


是的,我知道字节序问题,但这是一个专有应用程序,我对部署有完全控制权。关于BufferedStream,据我了解,FileStream已经有缓冲区,所以它只会增加一个不必要的中间缓冲区。附言:在这个项目中,我还使用了你们的protobuf库,非常感谢! - andreialecu
3
我刚使用一个包装的BufferedStream进行了新的测量,正如预期的那样,并没有任何区别。 - andreialecu

0

我曾经在二进制文件的前几个字节中写入了该文件中数据行的总数,或者一行数据所需的字节数。

然而,后来我发现了一个名为TeaFiles的解决方案,它的性能甚至比我开发的原始二进制文件解决方案快两倍。有趣的是,它看起来所需的磁盘空间与二进制文件所需的完全相同,因此这个库在底层可能有很多共同之处。

enter image description here enter image description here

在超过2百万条时间序列记录上,我得到了以下不同解决方案的读取性能

  • SQLite: 11287毫秒
  • JSON: 3842毫秒
  • BIN(gzip压缩):35308毫秒
  • BIN(非压缩):7058毫秒
  • TEA: 595毫秒
  • CSV: 3074毫秒
  • BIN(使用结构而不是类,非压缩):11042毫秒
  • BIN(自定义逻辑,使用BinaryReader / Writer编写纯二进制文件):930毫秒

在我的测试中,没有什么比TeaFiles更好的了。很抱歉没有为所有不同选项发布完整的代码。您可以运行一些测试,看看提出的选项是否有用。

需要记住的一件事是,无法从文件中删除行。因此,您基本上必须读取文件,附加一个新行,并重新编写或创建现有文件的新版本,其中大部分解决方案都排除了完全承诺的基于SQL的解决方案(sqlite)。因此,与生活中的大多数事物一样,非SQL解决方案的用例是情境性的 :)

顺便说一句,如果我不懒并且将来有时间,我会更新主题并提供复制代码库的链接。


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