读取一个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
我做了一些基准测试,结果如下。
- 上面的完整算法用了 314 秒来加载整个文件并将对象放入列表中。
- 随着 FromCSV() 的主体减少到只返回一个具有默认字段值的 FOO 类型的新对象,整个过程耗时 84 秒。因此,将文本行处理到对象字段似乎需要 230 秒(总时间的 73%)。
- 除了解析 ISO 8601 日期字符串之外,其他所有操作都需要 175 秒。因此处理日期字符串似乎需要 139 秒,这是文本处理时间的 60%,仅针对该一个字段。
- 仅读取文件中的行而不进行任何处理或创建对象需要 41 秒。
- 使用 StreamReader.ReadBlock 以大约 1KB 的块读取整个文件需要 24 秒,但这在总体方案中是一个小改进,可能不值得增加复杂性。为了使用 TryParse,我现在需要手动创建临时字符串,而不是使用 String.Split().
在这一点上,我看到的唯一方法是每隔几秒向用户显示一次状态,这样他们就不会怀疑程序是否被冻结或其他什么。
更新
我创建了两个新函数。可以使用 System.IO.BinaryWriter 将数据集从内存保存到二进制文件中。另一个函数可以使用 System.IO.BinaryReader 将该二进制文件加载回内存。二进制版本比 CSV 版本快得多,二进制文件占用的空间也少得多 space.
以下是基准测试结果(所有测试的数据集相同):
- 加载 CSV:340 秒
- 保存 CSV:312 秒
- 保存 BIN:29 秒
- 加载 BIN:41s
- CSV 文件大小:3.86GB
- BIN 文件大小:1.63GB
我对 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 开始,如果您发现速度仍然不够快,请调查一下。
如果您发现此解决方案有效,我很想听听结果。
我有一个 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
我做了一些基准测试,结果如下。
- 上面的完整算法用了 314 秒来加载整个文件并将对象放入列表中。
- 随着 FromCSV() 的主体减少到只返回一个具有默认字段值的 FOO 类型的新对象,整个过程耗时 84 秒。因此,将文本行处理到对象字段似乎需要 230 秒(总时间的 73%)。
- 除了解析 ISO 8601 日期字符串之外,其他所有操作都需要 175 秒。因此处理日期字符串似乎需要 139 秒,这是文本处理时间的 60%,仅针对该一个字段。
- 仅读取文件中的行而不进行任何处理或创建对象需要 41 秒。
- 使用 StreamReader.ReadBlock 以大约 1KB 的块读取整个文件需要 24 秒,但这在总体方案中是一个小改进,可能不值得增加复杂性。为了使用 TryParse,我现在需要手动创建临时字符串,而不是使用 String.Split().
在这一点上,我看到的唯一方法是每隔几秒向用户显示一次状态,这样他们就不会怀疑程序是否被冻结或其他什么。
更新
我创建了两个新函数。可以使用 System.IO.BinaryWriter 将数据集从内存保存到二进制文件中。另一个函数可以使用 System.IO.BinaryReader 将该二进制文件加载回内存。二进制版本比 CSV 版本快得多,二进制文件占用的空间也少得多 space.
以下是基准测试结果(所有测试的数据集相同):
- 加载 CSV:340 秒
- 保存 CSV:312 秒
- 保存 BIN:29 秒
- 加载 BIN:41s
- CSV 文件大小:3.86GB
- BIN 文件大小:1.63GB
我对 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 开始,如果您发现速度仍然不够快,请调查一下。
如果您发现此解决方案有效,我很想听听结果。