字符串是邪恶的
内容
将内存分配从 7.5GB 减少到 32KB
内容
- 问题背景
- 建立基线
- 轻松获胜1
- 轻松获胜2
- 分裂从来都不酷
- 清单并不总是好的
- 池化字节数组
- 再见 StringBuilder
- 跳过逗号
- 类和结构之间的战争
- 再见 StreamReader
- TLDR - 给我一张桌子
问题背景
Codeweavers是一家金融服务软件公司,我们的部分业务是帮助客户批量导入数据到我们的平台。为了提供服务,我们需要所有客户(包括英国各地的贷款机构和制造商)提供最新信息。每次导入的数据都可能包含数百兆未压缩的数据,这些数据通常每天都会导入。
这些数据随后将用于支持我们的实时计算。目前,由于会影响内存使用,此导入过程必须在工作时间以外进行。
在本文中,我们将探讨导入过程中潜在的优化方案,尤其是在减少内存占用方面。如果您想亲自尝试,可以使用此代码生成示例输入文件,所有讨论的代码都可以在这里找到。
建立基线
当前实现使用StreamReader
并将每一行传递给lineParser
。
using (StreamReader reader = File.OpenText(@"..\..\example-input.csv"))
{
try
{
while (reader.EndOfStream == false)
{
lineParser.ParseLine(reader.ReadLine());
}
}
catch (Exception exception)
{
throw new Exception("File could not be parsed", exception);
}
}
我们最初最简单的行解析器实现看起来像这样:-
public sealed class LineParserV01 : ILineParser
{
public void ParseLine(string line)
{
var parts = line.Split(',');
if (parts[0] == "MNO")
{
var valueHolder = new ValueHolder(line);
}
}
}
该类ValueHolder
稍后将在导入过程中用于将信息插入数据库:-
public class ValueHolder
{
public int ElementId { get; }
public int VehicleId { get; }
public int Term { get; }
public int Mileage { get; }
public decimal Value { get; }
public ValueHolder(string line)
{
var parts = line.Split(',');
ElementId = int.Parse(parts[1]);
VehicleId = int.Parse(parts[2]);
Term = int.Parse(parts[3]);
Mileage = int.Parse(parts[4]);
Value = decimal.Parse(parts[5]);
}
}
作为命令行应用程序运行此示例并启用监控:-
public static void Main(string[] args)
{
AppDomain.MonitoringIsEnabled = true;
// do the parsing
Console.WriteLine($"Took: {AppDomain.CurrentDomain.MonitoringTotalProcessorTime.TotalMilliseconds:#,###} ms");
Console.WriteLine($"Allocated: {AppDomain.CurrentDomain.MonitoringTotalAllocatedMemorySize / 1024:#,#} kb");
Console.WriteLine($"Peak Working Set: {Process.GetCurrentProcess().PeakWorkingSet64 / 1024:#,#} kb");
for (int index = 0; index <= GC.MaxGeneration; index++)
{
Console.WriteLine($"Gen {index} collections: {GC.CollectionCount(index)}");
}
}
我们今天的主要目标是减少分配的 内存。简而言之,分配的内存越少,垃圾收集器需要做的工作就越少。垃圾收集器针对三代对象进行操作,我们也将对其进行监控。垃圾收集是一个复杂的话题,超出了本文的讨论范围;但一个好的经验法则是,短寿命对象永远不应该被提升到第 0 代之后。
我们可以看到V01
以下统计数据:-
Took: 8,750 ms
Allocated: 7,412,303 kb
Peak Working Set: 16,720 kb
Gen 0 collections: 1809
Gen 1 collections: 0
Gen 2 collections: 0
解析一个三百兆的文件需要分配近 7.5 GB 的内存,这显然不太理想。既然我们已经确定了基准,接下来让我们找到一些简单的解决方案……
轻松获胜1
眼尖的读者应该已经注意到了,我们用了string.Split(',')
两次;一次是在行解析器中,另一次是在 的构造函数中ValueHolder
。这很浪费时间,我们可以重载 的构造函数ValueHolder
来接受一个string[]
数组,然后在解析器中对行进行一次拆分。经过这个简单的修改后, 的统计数据V02
现在是:
Took: 6,922 ms
Allocated: 4,288,289 kb
Peak Working Set: 16,716 kb
Gen 0 collections: 1046
Gen 1 collections: 0
Gen 2 collections: 0
太棒了!我们从 7.5GB 降到了 4.2GB。但对于处理一个 300MB 的文件来说,这仍然是一个很大的内存分配。
轻松获胜2
快速分析输入文件后发现,其中有10,047,435
行,我们只对以 为前缀的行感兴趣。这意味着我们处理了多余的行,MNO
而这些行是不必要的。快速修改一下,只解析以 为前缀的行:10,036,466
10,969
V03
MNO
public sealed class LineParserV03 : ILineParser
{
public void ParseLine(string line)
{
if (line.StartsWith("MNO"))
{
var valueHolder = new ValueHolder(line);
}
}
}
99.89%
这意味着我们推迟拆分整行,直到我们知道它是我们感兴趣的行。不幸的是,这并没有节省太多内存。主要是因为我们只对文件中的行感兴趣。V03
以下统计数据:
Took: 8,375 ms
Allocated: 4,284,873 kb
Peak Working Set: 16,744 kb
Gen 0 collections: 1046
Gen 1 collections: 0
Gen 2 collections: 0
现在是时候使用值得信赖的分析器了,在本例中是dotTrace:-
.NET 生态系统中的字符串是不可变的。这意味着我们对 a 所做的任何操作string
都会返回一个全新的副本。因此,调用string.Split(',')
每一行(记住,有些10,036,466
行是我们感兴趣的)都会返回被拆分成几个较小字符串的行。每行至少包含五个我们要处理的部分。这意味着在导入过程的生命周期中,我们至少会创建50,182,330 strings
..!接下来,我们将探索如何消除 .. 的使用string.Split(',')
。
分裂从来都不酷
我们感兴趣的典型线条看起来像这样:-
MNO,3,813496,36,30000,78.19,,
调用string.Split(',')
上述代码将返回以下string[]
内容:-
'MNO'
'3'
'813496'
'36'
'30000'
'78.19'
''
''
现在我们可以对导入的文件做出一些保证:-
- 每行的长度不固定
- 逗号分隔的部分数量是固定的
- 我们仅使用每行的前三个字符来确定我们对该行的兴趣
- 这意味着我们感兴趣的是五个部分,但部分长度未知
- 部分不会改变位置(例如
MNO
始终是第一部分)
保证建立后,我们现在可以为给定的行构建一个所有逗号位置的短期索引:-
private List<int> FindCommasInLine(string line)
{
var list = new List<int>();
for (var index = 0; index < line.Length; index++)
{
if (line[index] == ',')
{
list.Add(index);
}
}
return list;
}
一旦我们知道每个逗号的位置,我们就可以直接访问我们关心的部分并手动解析该部分。
private decimal ParseSectionAsDecimal(int start, int end, string line)
{
var sb = new StringBuilder();
for (var index = start; index < end; index++)
{
sb.Append(line[index]);
}
return decimal.Parse(sb.ToString());
}
private int ParseSectionAsInt(int start, int end, string line)
{
var sb = new StringBuilder();
for (var index = start; index < end; index++)
{
sb.Append(line[index]);
}
return int.Parse(sb.ToString());
}
综合起来:-
public void ParseLine(string line)
{
if (line.StartsWith("MNO"))
{
var findCommasInLine = FindCommasInLine(line);
var elementId = ParseSectionAsInt(findCommasInLine[0] + 1, findCommasInLine[1], line); // equal to parts[1] - element id
var vehicleId = ParseSectionAsInt(findCommasInLine[1] + 1, findCommasInLine[2], line); // equal to parts[2] - vehicle id
var term = ParseSectionAsInt(findCommasInLine[2] + 1, findCommasInLine[3], line); // equal to parts[3] - term
var mileage = ParseSectionAsInt(findCommasInLine[3] + 1, findCommasInLine[4], line); // equal to parts[4] - mileage
var value = ParseSectionAsDecimal(findCommasInLine[4] + 1, findCommasInLine[5], line); // equal to parts[5] - value
var valueHolder = new ValueHolder(elementId, vehicleId, term, mileage, value);
}
}
跑步V04
揭示了以下统计数据:-
Took: 9,813 ms
Allocated: 6,727,664 kb
Peak Working Set: 16,872 kb
Gen 0 collections: 1642
Gen 1 collections: 0
Gen 2 collections: 0
哎呀,情况比想象的还要糟糕。这是一个很容易犯的错误,但 dotTrace 可以帮助我们……
为每一行的每个部分构建一个非常昂贵。幸运的是,这有一个快速解决方案,我们在构造时StringBuilder
构造一个,并在每次使用前清除它。现在有以下统计数据:StringBuilder
V05
V05
Took: 9,125 ms
Allocated: 3,199,195 kb
Peak Working Set: 16,636 kb
Gen 0 collections: 781
Gen 1 collections: 0
Gen 2 collections: 0
呼,我们又回到了下降趋势。一开始是 7.5GB,现在降到了 3.2GB。
清单并不总是好的
此时,dotTrace 已成为优化过程中不可或缺的一部分。查看V05
dotTrace 的输出:
构建逗号位置的短期索引开销很大。因为 any 的底层List<T>
只是一个标准T[]
数组。框架会在添加元素时调整底层数组的大小。这在典型场景中非常有用且非常方便。然而,我们知道需要处理六个部分(但我们只关注其中五个部分),因此至少需要为七个逗号创建索引。我们可以针对此进行优化:
private int[] FindCommasInLine(string line)
{
var nums = new int[7];
var counter = 0;
for (var index = 0; index < line.Length; index++)
{
if (line[index] == ',')
{
nums[counter++] = index;
}
}
return nums;
}
V06
统计数据:-
Took: 8,047 ms
Allocated: 2,650,318 kb
Peak Working Set: 16,560 kb
Gen 0 collections: 647
Gen 1 collections: 0
Gen 2 collections: 0
2.6GB 已经很不错了,但如果我们强制编译器使用byte
这种方法,而不是编译器默认使用,会发生什么情况呢int
:-
private byte[] FindCommasInLine(string line)
{
byte[] nums = new byte[7];
byte counter = 0;
for (byte index = 0; index < line.Length; index++)
{
if (line[index] == ',')
{
nums[counter++] = index;
}
}
return nums;
}
重新运行V06
:-
Took: 8,078 ms
Allocated: 2,454,297 kb
Peak Working Set: 16,548 kb
Gen 0 collections: 599
Gen 1 collections: 0
Gen 2 collections: 0
2.6GB 已经很不错了,2.4GB 甚至更好。这是因为 an的范围比 aint
大得多。byte
池化字节数组
V06
现在有一个byte[]
数组,用于保存每行每个逗号的索引。它是一个短暂存在的数组,但会被多次创建。我们可以byte[]
通过使用 .NET 生态系统中的最新功能来消除为每行创建新数组的成本;Systems.Buffers
。Adam Sitnik 对此进行了详细的分析,并解释了为什么应该使用它。使用时需要记住的重要一点ArrayPool<T>.Shared
是,在使用完毕后必须始终归还租用的缓冲区,否则会导致应用程序内存泄漏。
看起来是这样V07
的:-
public void ParseLine(string line)
{
if (line.StartsWith("MNO"))
{
var tempBuffer = _arrayPool.Rent(7);
try
{
var findCommasInLine = FindCommasInLine(line, tempBuffer);
// truncated for brevity
}
finally
{
_arrayPool.Return(tempBuffer, true);
}
}
}
private byte[] FindCommasInLine(string line, byte[] nums)
{
byte counter = 0;
for (byte index = 0; index < line.Length; index++)
{
if (line[index] == ',')
{
nums[counter++] = index;
}
}
return nums;
}
并V07
有以下统计数据:-
Took: 8,891 ms
Allocated: 2,258,272 kb
Peak Working Set: 16,752 kb
Gen 0 collections: 551
Gen 1 collections: 0
Gen 2 collections: 0
从最初的 7.5GB 降到了 2.2GB。这已经很不错了,但我们还没完。
再见 StringBuilder
分析V07
揭示了下一个问题:
在和解析器StringBuilder.ToString()
内部调用非常昂贵。是时候弃用它,并编写我们自己的1和解析器了,不再依赖字符串,也不再调用/ 。根据性能分析器显示,这应该可以节省大约 1GB 的内存。编写我们自己的和解析器后,现在的运行速度为:decimal
int
StringBuilder
int
decimal
int.parse()
decimal.parse()
int
decimal
V08
Took: 6,047 ms
Allocated: 1,160,856 kb
Peak Working Set: 16,816 kb
Gen 0 collections: 283
Gen 1 collections: 0
Gen 2 collections: 0
1.1GB 比我们之前的(2.2GB)有了巨大的改进,甚至比基线(7.5GB)更好。
1代码可以在这里找到
跳过逗号
直到V08
我们的策略是找到每一行上每个逗号的索引,然后使用该信息创建一个子字符串,然后通过调用int.parse()
/来解析该子字符串decimal.parse()
。V08
不推荐使用子字符串,但仍然使用逗号位置的短暂索引。
另一种策略是通过计算前面逗号的数量跳到我们感兴趣的部分,然后解析所需数量的逗号之后的任何内容,并在遇到下一个逗号时返回。
我们之前已保证:-
- 每个部分前面都有一个逗号。
- 并且一行中每个部分的位置不会改变。
这也意味着我们可以弃用租用的byte[]
数组,因为我们不再构建短期索引:-
public sealed class LineParserV09 : ILineParser
{
public void ParseLine(string line)
{
if (line.StartsWith("MNO"))
{
int elementId = ParseSectionAsInt(line, 1); // equal to parts[1] - element id
int vehicleId = ParseSectionAsInt(line, 2); // equal to parts[2] - vehicle id
int term = ParseSectionAsInt(line, 3); // equal to parts[3] - term
int mileage = ParseSectionAsInt(line, 4); // equal to parts[4] - mileage
decimal value = ParseSectionAsDecimal(line, 5); // equal to parts[5] - value
var valueHolder = new ValueHolder(elementId, vehicleId, term, mileage, value);
}
}
}
不幸的是V09
,它并没有节省我们的内存,但它确实减少了所花费的时间:-
Took: 5,703 ms
Allocated: 1,160,856 kb
Peak Working Set: 16,572 kb
Gen 0 collections: 283
Gen 1 collections: 0
Gen 2 collections: 0
另一个好处是V09
它读起来更接近原始实现。
类和结构之间的战争
这篇博文不会讨论类和结构体的区别或优缺点。这个话题已经讨论过 很多 次了。在这个特定的上下文中,使用 是有益的struct
。将ValueHolder
其更改为具有以下统计数据:-struct
V10
Took: 5,594 ms
Allocated: 768,803 kb
Peak Working Set: 16,512 kb
Gen 0 collections: 187
Gen 1 collections: 0
Gen 2 collections: 0
终于,我们已经低于 1GB 的限制了。另外,请注意,不要struct
盲目使用,务必测试你的代码,确保用例正确。
再见 StreamReader
从V10
行解析器本身来看,它实际上不需要分配。dotTrace 揭示了剩余分配发生的位置:-
这有点尴尬,框架占用了我们的内存。我们可以在比StreamReader
:-更低的级别与文件进行交互。
private static void ViaRawStream(ILineParser lineParser)
{
var sb = new StringBuilder();
using (var reader = File.OpenRead(@"..\..\example-input.csv"))
{
try
{
bool endOfFile = false;
while (reader.CanRead)
{
sb.Clear();
while (endOfFile == false)
{
var readByte = reader.ReadByte();
// -1 means end of file
if (readByte == -1)
{
endOfFile = true;
break;
}
var character = (char)readByte;
// this means the line is about to end so we skip
if (character == '\r')
{
continue;
}
// this line has ended
if (character == '\n')
{
break;
}
sb.Append(character);
}
if (endOfFile)
{
break;
}
var buffer = new char[sb.Length];
for (int index = 0; index < sb.Length; index++)
{
buffer[index] = sb[index];
}
lineParser.ParseLine(buffer);
}
}
catch (Exception exception)
{
throw new Exception("File could not be parsed", exception);
}
}
}
V11
统计数据:-
Took: 5,594 ms
Allocated: 695,545 kb
Peak Working Set: 16,452 kb
Gen 0 collections: 169
Gen 1 collections: 0
Gen 2 collections: 0
嗯,695MB 仍然比 768MB 好。好吧,这并没有达到我预期的提升(而且相当令人失望)。不过,我们记得之前遇到过这个问题,并且已经解决了。V07
我们以前经常会用这种方法ArrayPool<T>.Shared
来避免很多小问题byte[]
。我们也可以在这里做同样的事情:-
private static void ViaRawStream(ILineParser lineParser)
{
var sb = new StringBuilder();
var charPool = ArrayPool<char>.Shared;
using (var reader = File.OpenRead(@"..\..\example-input.csv"))
{
try
{
bool endOfFile = false;
while (reader.CanRead)
{
// truncated for brevity
char[] rentedCharBuffer = charPool.Rent(sb.Length);
try
{
for (int index = 0; index < sb.Length; index++)
{
rentedCharBuffer[index] = sb[index];
}
lineParser.ParseLine(rentedCharBuffer);
}
finally
{
charPool.Return(rentedCharBuffer, true);
}
}
}
catch (Exception exception)
{
throw new Exception("File could not be parsed", exception);
}
}
}
的最终版本V11
有以下统计数据:-
Took: 6,781 ms
Allocated: 32 kb
Peak Working Set: 12,620 kb
Gen 0 collections: 0
Gen 1 collections: 0
Gen 2 collections: 0
是的,只分配了 32kb 内存。这正是我期待的高潮。
通过 Twitter、LinkedIn或GitHub找到我。
TLDR-给我一张桌子
版本 | 耗时(毫秒) | 已分配(kb) | 峰值工作集(kb) | Gen 0 集合 |
---|---|---|---|---|
01 | 8,750 | 7,412,303 | 16,720 | 1,809 |
02 | 6,922 | 4,288,289 | 16,716 | 1,046 |
03 | 8,375 | 4,284,873 | 16,744 | 1,046 |
04 | 9,813 | 6,727,664 | 16,872 | 1,642 |
05 | 8,125 | 3,199,195 | 16,636 | 781 |
06 | 8,078 | 2,454,297 | 16,548 | 599 |
07 | 8,891 | 2,258,272 | 16,752 | 551 |
08 | 6,047 | 1,160,856 | 16,816 | 283 |
09 | 5,703 | 1,160,856 | 16,572 | 283 |
10 | 5,594 | 768,803 | 16,512 | 187 |
11 | 6,781 | 三十二 | 12,620 | 0 |