如何在C#中解析文本文件并被绑定?

Alo*_*aus 7 c# performance file-io

众所周知,如果您从光盘读取数据,那么您就是IO绑定的,并且您可以比从光盘读取数据更快地处理/解析读取数据.

但这种常识(神话?)并没有反映在我的测试中.当我读取带有double和int的文本文件时,每行以空格分隔,我比物理光盘速度慢得多(因子6).文本文件如下所示

1,1 0
2,1 1
3,1 2
Run Code Online (Sandbox Code Playgroud)

更新 我在一次读取时使用完整缓冲区执行ReadFile时包含了PInvoke性能,以获得"真实"性能.

  • ReadFile性能 - ReadFileIntoByteBuffer
  • StringReader.ReadLine性能 - CountLines
  • StringReader.Readline unsafe perf - ParseLinesUnsafe
  • StringReader.Read unsafe char buf - ParseLinesUnsafeCharBuf
  • StringReader.ReadLine +解析性能 - ParseLines

结果是

Did native read 179,0MB in                    0,4s, 484,2MB/s
Did read 10.000.000 lines in                  1,6s, 112,7MB/s
Did parse and read unsafe 179,0MB in          2,3s,  76,5MB/s
Did parse and read unsafe char buf 179,0MB in 2,8s,  63,5MB/s
Did read and parse 179,0MB in                 9,3s,  19,3MB/s
Run Code Online (Sandbox Code Playgroud)

虽然我确实尝试跳过ParseLinesUnsafeCharBuf中的字符串构造开销,但它仍然比每次分配新字符串的版本慢得多.它仍然比最简单的解决方案的原始20 MB好很多,但我认为.NET应该能够做得更好.如果remoe是解析字符串的逻辑,我确实得到258,8 MB/s,这非常好,接近本机速度.但我没有看到使用不安全代码的方法使我的解析更简单.我必须处理不完整的线条,这使得它非常复杂.

更新 从数字中可以清楚地看出,一个简单的string.split已经花费太多了.但是StringReader也花了不少钱.高度优化的解决方案如何看起来更接近真实的光盘速度?我已经尝试了许多不安全的代码和char缓冲区的方法,但性能提升可能是30%,但我不需要大小的数量级.我可以100MB/s的解析速度.这应该可以通过托管代码实现,还是我错了?

用C#解析的速度是否比我从硬盘读取的速度快?它是Intel Postville X25M.CPU是旧的Intel双核.我有3 GB RAM Windows 7 .NET 3.5 SP1和.NET 4.

但我确实在普通硬盘上看到了相同的结果.使用当今的硬盘,线性读取速度可高达400MB/s.这是否意味着我应该重新构建我的应用程序,以便在实际需要时按需读取数据,而不是以更高GC时间为代价急切地将其读入内存,因为增加的对象图使GC周期更长.

我注意到,如果我的托管应用程序使用超过500MB的内存,它的响应速度会慢得多.一个主要因素似乎是对象图的复杂性.因此,在需要时读取数据可能会更好.至少这是我对当前数据的结论.

这是代码

using System;
using System.Collections.Generic;
using System.Text;
using System.IO;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
using System.ComponentModel;

namespace IOBound
{
    class Program
    {
        static void Main(string[] args)
        {
            string data = @"C:\Source\IOBound\NumericData.txt";
            if (!File.Exists(data))
            {
                CreateTestData(data);
            }

            int MB = (int) (new FileInfo(data).Length/(1024*1024));

            var sw = Stopwatch.StartNew();
            uint bytes = ReadFileIntoByteBuffer(data);
            sw.Stop();
            Console.WriteLine("Did native read {0:F1}MB in {1:F1}s, {2:F1}MB/s",
                bytes/(1024*1024), sw.Elapsed.TotalSeconds, MB / sw.Elapsed.TotalSeconds);

            sw = Stopwatch.StartNew();
            int n = CountLines(data);
            sw.Stop();
            Console.WriteLine("Did read {0:N0} lines in {1:F1}s, {2:F1}MB/s",
                n, sw.Elapsed.TotalSeconds, MB / sw.Elapsed.TotalSeconds);

            sw = Stopwatch.StartNew();
            ParseLinesUnsafe(data);
            sw.Stop();
            Console.WriteLine("Did parse and read unsafe {0:F1}MB in {1:F1}s, {2:F1}MB/s",
                MB, sw.Elapsed.TotalSeconds, MB / sw.Elapsed.TotalSeconds);

            sw = Stopwatch.StartNew();
            ParseLinesUnsafeCharBuf(data);
            sw.Stop();
            Console.WriteLine("Did parse and read unsafe char buf {0:F1}MB in {1:F1}s, {2:F1}MB/s",
                MB, sw.Elapsed.TotalSeconds, MB / sw.Elapsed.TotalSeconds);

            sw = Stopwatch.StartNew();
            ParseLines(data);
            sw.Stop();
            Console.WriteLine("Did read and parse {0:F1}MB in {1:F1}s, {2:F1}MB/s",
                MB, sw.Elapsed.TotalSeconds, MB / sw.Elapsed.TotalSeconds);

        }

        private unsafe static uint ReadFileIntoByteBuffer(string data)
        {
            using(var stream = new FileStream(data, FileMode.Open))
            {
                byte[] buf = new byte[200 * 1024 * 1024];
                fixed(byte* pBuf = &buf[0])
                {
                    uint dwRead = 0;
                    if (ReadFile(stream.SafeFileHandle, pBuf, 200 * 1000 * 1000, out dwRead, IntPtr.Zero) == 0)
                    {
                        throw new Win32Exception();
                    }
                    return dwRead;
                }

            }
        }

        private static int CountLines(string data)
        {
            using (var reader = new StreamReader(data))
            {
                string line;
                int count = 0;
                while ((line = reader.ReadLine()) != null)
                {
                    count++;
                }

                return count;
            }
        }

        unsafe private static void ParseLinesUnsafeCharBuf(string data)
        {
            var dobules = new List<double>();
            var ints = new List<int>();

            using (var reader = new StreamReader(data))
            {
                double d = 0;
                long a = 0, b = 0;
                int i = 0;
                char[] buffer = new char[10*1000*1000];
                int readChars = 0;
                int startIdx = 0;

                fixed(char *ln = buffer)
                {
                    while ((readChars = reader.Read(buffer, startIdx, buffer.Length - startIdx)) != 0)
                    {
                        char* pEnd = ln + readChars + startIdx;
                        char* pCur = ln;
                        char* pLineStart = null;

                        while (pCur != pEnd)
                        {
                            a = 0;
                            b = 0;

                            while (pCur != pEnd && *pCur == '\r' || *pCur == '\n')
                            {
                                pCur++;
                            }
                            pLineStart = pCur;

                            while(pCur != pEnd && char.IsNumber(*pCur))
                            {
                                a = a * 10 + (*pCur++ - '0');
                            }
                            if (pCur == pEnd || *pCur == '\r')
                            {
                                goto incompleteLine;
                            }

                            if (*pCur++ == ',')
                            {
                                long div = 1;
                                while (pCur != pEnd && char.IsNumber(*pCur))
                                {
                                    b += b * 10 + (*pCur++ - '0');
                                    div *= 10;
                                }
                                if (pCur == pEnd || *pCur == '\r')
                                {
                                    goto incompleteLine;
                                }
                                d = a + ((double)b) / div;
                            }
                            else
                            {
                                goto skipRest;
                            }

                            while (pCur != pEnd && char.IsWhiteSpace(*pCur))
                            {
                                pCur++;
                            }
                            if (pCur == pEnd || *pCur == '\r')
                            {
                                goto incompleteLine;
                            }

                            i = 0;
                            while (pCur != pEnd && char.IsNumber(*pCur))
                            {
                                i = i * 10 + (*pCur++ - '0');
                            }
                            if (pCur == pEnd)
                            {
                                goto incompleteLine;
                            }

                            dobules.Add(d);
                            ints.Add(i);

                            continue;

incompleteLine:
                            startIdx = (int)(pEnd - pLineStart);
                            Buffer.BlockCopy(buffer, (int)(pLineStart - ln) * 2, buffer, 0, 2 * startIdx);
                            break;
skipRest:
                            while (pCur != pEnd && *pCur != '\r')
                            {
                                pCur++;   
                            }
                            continue;
                        }
                    }
                }
            }
        }

        unsafe private static void ParseLinesUnsafe(string data)
        {
            var dobules = new List<double>();
            var ints = new List<int>();

            using (var reader = new StreamReader(data))
            {
                string line;
                double d=0;
                long a = 0, b = 0;
                int ix = 0;
                while ((line = reader.ReadLine()) != null)
                {
                    int len = line.Length;
                    fixed (char* ln = line)
                    {
                        while (ix < len && char.IsNumber(ln[ix]))
                        { 
                            a = a * 10 + (ln[ix++] - '0');
                        }

                        if (ln[ix] == ',')
                        {
                            ix++;
                            long div = 1;
                            while (ix < len && char.IsNumber(ln[ix]))
                            {
                                b += b * 10 + (ln[ix++] - '0');
                                div *= 10;
                            }
                            d = a + ((double)b) / div;
                        }

                        while (ix < len && char.IsWhiteSpace(ln[ix]))
                        {
                            ix++;
                        }

                        int i = 0;
                        while (ix < len && char.IsNumber(ln[ix]))
                        { 
                            i = i * 10 + (ln[ix++] - '0');
                        }

                        dobules.Add(d);
                        ints.Add(ix);
                    }
                }
            }
        }



        private static void ParseLines(string data)
        {
            var dobules = new List<double>();
            var ints = new List<int>();

            using (var reader = new StreamReader(data))
            {
                string line;
                char[] sep  = new char[] { ' ' };
                while ((line = reader.ReadLine()) != null)
                {
                    var parts = line.Split(sep);
                    if (parts.Length == 2)
                    {
                        dobules.Add( double.Parse(parts[0]));
                        ints.Add( int.Parse(parts[1]));
                    }
                }
            }
        }

        static void CreateTestData(string fileName)
        {
            FileStream fstream = new FileStream(fileName, FileMode.Create);
            using (StreamWriter writer = new StreamWriter(fstream, Encoding.UTF8))
            {
                for (int i = 0; i < 10 * 1000 * 1000; i++)
                {
                    writer.WriteLine("{0} {1}", 1.1d + i, i);
                }
            }
        }

        [DllImport("kernel32.dll", SetLastError = true)]
        unsafe static extern uint ReadFile(SafeFileHandle hFile, [Out] byte* lpBuffer, uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead, IntPtr lpOverlapped);

    }
}
Run Code Online (Sandbox Code Playgroud)

csh*_*net 5

所以这里有几个问题。其他人已经对 Windows 的 IO 缓存以及实际的硬件缓存发表了评论,所以我将不再讨论它。

另一个问题是测量 read() + parse() 的组合操作,并将其与 read() 的速度进行比较。本质上,您需要意识到 A + B 始终大于 A(假设非负)。

因此,要了解您是否受到 IO 限制,您需要了解读取该文件需要多长时间。你已经做到了。在我的机器上,您的测试运行时间约为 220 毫秒,用于读取文件。

现在您需要测量解析这么多不同的字符串需要多长时间。隔离起来有点棘手。因此,假设我们将它们放在一起,并从解析时间中减去读取所需的时间。此外,我们并不是试图衡量您对数据做了什么,而只是衡量解析,所以扔掉 List 和 List,让我们只解析。在我的机器上运行它大约需要 1000 毫秒,减去读取的 220 毫秒,您的解析代码每 100 万行大约需要 780 毫秒。

那么为什么它这么慢(比读取慢 3-4 倍)?让我们再次消除一些东西。注释掉 int.Parse 和 double.Parse 并再次运行。这比 220 的读取时间少了 460 毫秒,现在解析时间为 240 毫秒。当然,“解析”只是调用 string.Split()。Hrmmm 看起来 string.Split 的成本与磁盘 IO 一样多,考虑到 .NET 如何处理字符串,这并不奇怪。

那么 C# 的解析速度是否可以与从磁盘读取一样快或更快呢?嗯,是的,可以,但是你必须变得讨厌。你会看到 int.Parse 和 double.Parse 受到文化意识这一事实的困扰。由于这一点以及这些解析例程处理多种格式的事实,它们对于您的示例的规模来说有点昂贵。我的意思是说,我们每微秒(百万分之一秒)都会解析一个 double 和 int,这通常不错。

因此,为了匹配磁盘读取的速度(从而受到 IO 限制),我们需要重写处理文本行的方式。这是一个令人讨厌的例子,但它适用于你的例子......

int len = line.Length;
fixed (char* ln = line)
{
    double d;
    long a = 0, b = 0;
    int ix = 0;
    while (ix < len && char.IsNumber(ln[ix]))
        a = a * 10 + (ln[ix++] - '0');
    if (ln[ix] == '.')
    {
        ix++;
        long div = 1;
        while (ix < len && char.IsNumber(ln[ix]))
        {
            b += b * 10 + (ln[ix++] - '0');
            div *= 10;
        }
        d = a + ((double)b)/div;
    }

    while (ix < len && char.IsWhiteSpace(ln[ix]))
        ix++;

    int i = 0;
    while (ix < len && char.IsNumber(ln[ix]))
        i = i * 10 + (ln[ix++] - '0');
}
Run Code Online (Sandbox Code Playgroud)

运行这段糟糕的代码会产生大约 450 毫秒的运行时间,或者大约是读取时间的 2n。因此,假装你认为上面的代码片段是可以接受的(上帝希望你不要这样),你可以让一个线程读取字符串,另一个线程解析,这样你就接近 IO 限制了。将两个线程放在解析上,您将受到 IO 限制。 你是否应该这样做是另一个问题。

那么让我们回到你最初的问题:

众所周知,如果从光盘读取数据,则受到 IO 限制,并且处理/解析读取数据的速度比从光盘读取数据的速度快得多。

但这是常识(神话?)

好吧,不,我不会称这是一个神话。事实上,我会争论你的原始代码仍然是 IO 绑定的。您碰巧单独运行测试,因此影响很小,只是从设备读取时间的 1/6。但考虑一下如果该磁盘繁忙会发生什么?如果您的防病毒扫描程序正在遍历每个文件怎么办?简而言之,您的程序会随着 HDD 活动的增加而变慢,并且可能会受到 IO 限制。

恕我直言,这种“常识”的原因是这样的:

写入时的 IO 绑定比读取时更容易。

写入设备需要更长的时间,并且通常比生成数据更昂贵。如果您想查看 IO Bound 的实际情况,请查看“CreateTestData”方法。CreateTestData 方法将数据写入磁盘所需的时间是调用 String.Format(...) 的两倍。这是完全缓存的情况。关闭缓存 ( FileOptions.WriteThrough ) 并重试...现在 CreateTestData 速度慢了 3-4 倍。您可以通过以下方法亲自尝试一下:

static int CreateTestData(string fileName)
{
    FileStream fstream = new FileStream(fileName, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough);
    using (StreamWriter writer = new StreamWriter(fstream, Encoding.UTF8))
    {
        for (int i = 0; i < linecount; i++)
        {
            writer.WriteLine("{0} {1}", 1.1d + i, i);
        }
    }
    return linecount;
}
static int PrintTestData(string fileName)
{
    for (int i = 0; i < linecount; i++)
    {
        String.Format("{0} {1}", 1.1d + i, i);
    }
    return linecount;
}
Run Code Online (Sandbox Code Playgroud)

这仅适用于初学者,如果您确实想要绑定 IO,则可以开始使用直接 IO。请参阅有关使用 FILE_FLAG_NO_BUFFERING 的CreateFile的文档。当您开始绕过硬件缓存并等待 IO 完成时,写入速度会变慢。这是传统数据库写入速度非常慢的主要原因之一。他们必须强制硬件完成写入并等待它。只有这样,他们才能将事务称为“已提交”,数据位于物理设备上的文件中。

更新

好吧,阿洛伊斯,看来你只是在寻找你能走多快。为了更快,您需要停止处理字符串和字符并删除分配以更快。下面的代码对上面的行/字符解析器进行了大约一个数量级的改进(仅比计算行数增加了大约 30 毫秒),同时在堆上仅分配一个缓冲区。

警告您需要意识到我正在证明它可以快速完成。我并不是建议你走这条路。该代码有一些严重的限制和/或错误。就像当您击中“1.2589E+19”形式的双倍时会发生什么?坦率地说,我认为您应该坚持使用原始代码,而不用担心尝试对其进行如此多的优化。或者将文件格式更改为二进制而不是文本(请参阅BinaryWriter)。如果您使用二进制,则可以将以下代码的变体与BitConvert.ToDouble / ToInt32一起使用,它会更快。

private static unsafe int ParseFast(string data)
{
    int count = 0, valid = 0, pos, stop, temp;
    byte[] buffer = new byte[ushort.MaxValue];

    const byte Zero = (byte) '0';
    const byte Nine = (byte) '9';
    const byte Dot = (byte)'.';
    const byte Space = (byte)' ';
    const byte Tab = (byte) '\t';
    const byte Line = (byte) '\n';

    fixed (byte *ptr = buffer)
    using (Stream reader = File.OpenRead(data))
    {
        while (0 != (temp = reader.Read(buffer, valid, buffer.Length - valid)))
        {
            valid += temp;
            pos = 0;
            stop = Math.Min(buffer.Length - 1024, valid);
            while (pos < stop)
            {
                double d;
                long a = 0, b = 0;
                while (pos < valid && ptr[pos] >= Zero && ptr[pos] <= Nine)
                    a = a*10 + (ptr[pos++] - Zero);
                if (ptr[pos] == Dot)
                {
                    pos++;
                    long div = 1;
                    while (pos < valid && ptr[pos] >= Zero && ptr[pos] <= Nine)
                    {
                        b += b*10 + (ptr[pos++] - Zero);
                        div *= 10;
                    }
                    d = a + ((double) b)/div;
                }
                else
                    d = a;

                while (pos < valid && (ptr[pos] == Space || ptr[pos] == Tab))
                    pos++;

                int i = 0;
                while (pos < valid && ptr[pos] >= Zero && ptr[pos] <= Nine)
                    i = i*10 + (ptr[pos++] - Zero);

                DoSomething(d, i);

                while (pos < stop && ptr[pos] != Line)
                    pos++;
                while (pos < stop && !(ptr[pos] >= Zero && ptr[pos] <= Nine))
                    pos++;
            }

            if (pos < valid)
                Buffer.BlockCopy(buffer, pos, buffer, 0, valid - pos);
            valid -= pos;
        }
    }
    return count;
}
Run Code Online (Sandbox Code Playgroud)