读取一个50M行的CSV文件,如何提高性能

Reading a CSV file with 50M lines, how to improve performance

我有一个 CSV(逗号分隔值)格式的数据文件,其中包含大约 5000 万行。

每一行被读入一个字符串,解析,然后用来填充一个FOO类型对象的字段。然后将该对象添加到最终包含 5000 万个项目的(FOO 的)列表中。

一切正常,并且适合内存(至少在 x64 机器上),但速度很慢。每次加载文件并将文件解析到列表中大约需要 5 分钟。我想让它更快。 我怎样才能让它更快?

代码的重要部分如下所示。

Public Sub LoadCsvFile(ByVal FilePath As String)
    Dim s As IO.StreamReader = My.Computer.FileSystem.OpenTextFileReader(FilePath)

    'Find header line
    Dim L As String
    While Not s.EndOfStream
        L = s.ReadLine()
        If L = "" Then Continue While 'discard blank line
        Exit While
    End While
    'Parse data lines
    While Not s.EndOfStream
        L = s.ReadLine()
        If L = "" Then Continue While 'discard blank line          
        Dim T As FOO = FOO.FromCSV(L)
       Add(T)
    End While
    s.Close()
End Sub


Public Class FOO
    Public time As Date
    Public ID As UInt64
    Public A As Double
    Public B As Double
    Public C As Double

    Public Shared Function FromCSV(ByVal X As String) As FOO
        Dim T As New FOO
        Dim tokens As String() = X.Split(",")
        If Not DateTime.TryParse(tokens(0), T.time) Then
            Throw New Exception("Could not convert CSV to FOO:  Invalid ISO 8601 timestamp")
        End If
        If Not UInt64.TryParse(tokens(1), T.ID) Then
            Throw New Exception("Could not convert CSV to FOO:  Invalid ID")
        End If
        If Not Double.TryParse(tokens(2), T.A) Then
            Throw New Exception("Could not convert CSV to FOO:  Invalid Format for A")
        End If
        If Not Double.TryParse(tokens(3), T.B) Then
            Throw New Exception("Could not convert CSV to FOO:  Invalid Format for B")
        End If
        If Not Double.TryParse(tokens(4), T.C) Then
            Throw New Exception("Could not convert CSV to FOO:  Invalid Format for C")
        End If
        Return T
    End Function
End Class

我做了一些基准测试,结果如下。

在这一点上,我看到的唯一方法是每隔几秒向用户显示一次状态,这样他们就不会怀疑程序是否被冻结或其他什么。

更新
我创建了两个新函数。可以使用 System.IO.BinaryWriter 将数据集从内存保存到二进制文件中。另一个函数可以使用 System.IO.BinaryReader 将该二进制文件加载回内存。二进制版本比 CSV 版本快得多,二进制文件占用的空间也少得多 space.

以下是基准测试结果(所有测试的数据集相同):

我对 CSV 有很多经验,但坏消息是您无法使它更快。 CSV 库在这里不会提供太多帮助。库试图处理的 CSV 难题是处理具有嵌入逗号或换行符的字段,这需要引号和转义。您的数据集没有这个问题,因为 none 列是字符串。

如您所见,大部分时间花在了解析方法上。 Andrew Morton 有一个很好的建议,对 DateTime 值使用 TryParseExact 比 TryParse 快很多。我自己的 CSV 库 Sylvan.Data.Csv(可用于 .NET 的最快的库)使用优化,它直接从流读取缓冲区中解析原始值,而不首先转换为字符串(仅当 运行 .NET 核心),这也可以加快速度。但是,我不希望在坚持使用 CSV 的同时将处理时间缩短一半。

这是使用我的库 Sylvan.Data.Csv 在 C# 中处理 CSV 的示例。

static List<Foo> Read(string file)
{
    // estimate of the average row length based on Andrew Morton's 4GB/50m
    const int AverageRowLength = 80;

    var textReader = File.OpenText(file);
    // specifying the DateFormat will cause TryParseExact to be used.
    var csvOpts = new CsvDataReaderOptions { DateFormat = "yyyy-MM-ddTHH:mm:ss" };
    var csvReader = CsvDataReader.Create(textReader, csvOpts);

// estimate number of rows to avoid growing the list.
    var estimatedRows = (int)(textReader.BaseStream.Length / AverageRowLength);            
    var data = new List<Foo>(estimatedRows);

    while (csvReader.Read())
    {
        if (csvReader.RowFieldCount < 5) continue;
        var item = new Foo()
        {
            time = csvReader.GetDateTime(0),
            ID = csvReader.GetInt64(1),
            A = csvReader.GetDouble(2),
            B = csvReader.GetDouble(3),
            C = csvReader.GetDouble(4)
        };
        data.Add(item);
    }
    return data;
}

只要您 运行 在 .NET 核心上,我希望这会比您当前的实施快一些。 运行 在 .NET Framework 上,差异(如果有的话)不会很大。但是,我不希望这对您的用户来说是可以接受的速度,它仍然可能需要几十秒或几分钟来读取整个文件。

鉴于此,我的建议是完全放弃 CSV,这意味着您可以放弃 解析,这会减慢速度。相反,以二进制形式读写数据。您的数据记录有一个很好的 属性,因为它们是固定宽度的:每条记录包含 5 个 8 字节(64 位)宽的字段,因此每条记录需要恰好 40 字节的二进制形式。 50 米 x 40 = 2GB。因此,假设 Andrew Morton 对 CSV 的 4GB 估计是正确的,那么转向二进制将使存储需求减半。立即,这意味着读取相同数据所需的磁盘 IO 减少了一半。但除此之外,您不需要解析任何内容,值的二进制表示实际上将直接复制到内存中。

这里有一些如何在 C# 中执行此操作的示例(我不太了解 VB,抱歉)。


static List<Foo> Read(string file)
{
    var stream = File.OpenRead(file);
    // the exact number of records can be determined by looking at the length of the file.
    var recordCount = stream.Length / 40;
    var data = new List<Foo>(recordCount);
    var br = new BinaryReader(stream);
    for (int i = 0; i < recordCount; i++)
    {
        var ticks = br.ReadInt64();
        var id = br.ReadInt64();
        var a = br.ReadDouble();
        var b = br.ReadDouble();
        var c = br.ReadDouble();
        var f = new Foo()
        {
            time = new DateTime(ticks),
            ID = id,
            A = a,
            B = b,
            C = c,
        };
        data.Add(f);
    }
    return data;
}

static void Write(List<Foo> data, string file)
{
    var stream = File.Create(file);
    var bw = new BinaryWriter(stream);
    foreach(var item in data)
    {
        bw.Write(item.time.Ticks);
        bw.Write(item.ID);
        bw.Write(item.A);
        bw.Write(item.B);
        bw.Write(item.C);
    }
}

这几乎肯定比基于 CSV 的解决方案快一个数量级。那么问题就变成了:有什么理由让您必须使用 CSV 吗?如果数据源不受你控制,你必须使用 CSV,我会问:数据文件是每次都改变,还是只附加新数据?如果它被附加到,我会研究一个解决方案,每次应用程序启动时,只转换附加的 CSV 数据的新部分,并将其添加到一个二进制文件,然后您将从中加载所有内容。然后你只需要支付每次处理新的CSV数据的成本,并且会从二进制形式快速加载所有内容。

这可以通过创建固定布局结构 (Foo)、分配它们的数组并使用基于跨度的技巧直接从 FileStream 读取数组数据来更快。这是可以做到的,因为您的所有数据元素都是“blittable”的。这将是将此数据加载到您的程序中的绝对最快的方法。从 BinaryReader/Writer 开始,如果您发现速度仍然不够快,请调查一下。

如果您发现此解决方案有效,我很想听听结果。