串是邪恶的
将内存分配从7.5GB减少到32KB
内容
- 问题的背景
- 建立基线
- 轻松获胜1
- 轻松获胜2
- 拆分永远都不酷
- 列表并不总是很好
- 合并字节数组
- 再见StringBuilder
- 跳过逗号
- 类与结构之间的战争
- 再见StreamReader
- TLDR —给我一张桌子
问题的背景
Codeweavers是一家金融服务软件公司,我们所做的部分工作是使客户能够将其数据批量导入到我们的平台中。 对于我们的服务,我们需要来自所有客户的最新信息,其中包括英国的贷方和制造商。 这些导入中的每个导入都可以包含数百兆字节的未压缩数据,这些数据通常每天都会导入。
然后,这些数据将用于支持我们的实时计算。 当前,由于导入过程会对内存使用产生影响,因此必须在工作时间之外进行。
在本文中,我们将探讨导入过程的潜在优化,特别是在导入过程中减少内存的情况下。 如果您想自己尝试一下,可以使用此代码生成示例输入文件,并且可以找到此处讨论的所有代码。
建立基线
当前实现使用StreamReader并将每一行传递给lineParser。
我们最初看起来像这样的行解析器的最幼稚的实现:
在稍后的导入过程中,将使用ValueHolder类将信息插入数据库:
作为命令行应用程序运行此示例并启用监视:-
今天我们的主要目标是减少分配的 内存 。 简而言之,我们分配的内存越少,垃圾收集器要做的工作就越少。 垃圾回收器针对的是三代 ,我们还将对其进行监视。 垃圾收集是一个复杂的主题,不在本文讨论范围之内; 但是一个很好的经验法则是,短期对象永远都不能升级到第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
用于解析300兆文件的近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。 但这仍然是用于处理300 MB文件的大量内存分配。
轻松获胜2
快速分析输入文件可以发现有10,047,435行,我们只对以MNO为前缀的行有10,036,466行感兴趣。 这意味着我们不必要地处理了额外的10969条线。 快速更改为V03,仅解析以MNO为前缀的行:
这意味着我们将拆分整个行推迟到知道这是我们感兴趣的行之前。不幸的是,这并没有为我们节省太多内存。 主要是因为我们对文件中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生态系统中的字符串是不可变的。 意味着我们对字符串所做的任何操作都会始终返回一个全新的副本。 因此,在每一行上调用string.Split(',')(请记住,我们感兴趣的行数为10,036,466行)将返回该行,并将其拆分为几个较小的字符串。 每行至少有五个要处理的部分。 这意味着在导入过程的生命周期中,我们至少创建了50,182,330个字符串。 接下来,我们将探讨如何消除使用string.Split(',')。
拆分永远都不酷
我们感兴趣的典型生产线如下所示:-
MNO,3,813496,36,30000,78.19,,
在上一行调用string.Split(',')将返回一个string [],其中包含:-
'MNO' '3' '813496' '36' '30000' '78.19' '' ''
现在,我们可以对要导入的文件做出一些保证:
- 每行长度不固定
- 逗号分隔的节数是固定的
- 我们仅使用每行的前三个字符来确定我们对该行的兴趣
- 这意味着我们感兴趣的部分有五个,但部分长度未知
- 部分不会更改位置(例如,MNO始终是第一部分)
建立担保后,我们现在可以为给定行建立所有逗号位置的短期索引:
一旦知道每个逗号的位置,我们就可以直接访问我们关心的部分并手动解析该部分。
全部放在一起:
运行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的成本非常高。 幸运的是,这是一个快速修复,我们在V05的构造上构造了一个StringBuilder,并在每次使用前将其清除。 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
ew,我们又回到了下降趋势。 我们最初的容量为7.5GB,现在已降至3.2GB。
列表并不总是很好
此时,dotTrace成为优化过程的重要组成部分。 查看V05 dotTrace输出:-
建立逗号位置的短暂索引很昂贵。 正如任何List <T>之下,只是一个标准T []数组。 当添加元素时,框架负责调整底层数组的大小。 这在典型情况下非常有用并且非常方便。 但是,我们知道需要处理六个部分(但是我们仅对其中五个部分感兴趣),因此至少要为七个索引添加索引。 我们可以为此进行优化:-
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相当不错,但是如果我们强制编译器对此方法使用字节而不是默认使用int的编译器,会发生什么:-
重新运行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甚至更好。 这是因为int的范围比字节大得多 。
合并字节数组
V06现在具有一个byte []数组,该数组保存每一行的每个逗号的索引。 这是一个短暂的数组,但是创建了很多次。 通过使用最新添加的.NET生态系统,我们可以消除为每行创建新的byte []的开销; 系统缓冲区 亚当·希特尼克(Adam Sitnik)在使用它以及为什么要使用它方面有很大的缺陷。 使用ArrayPool <T> .Shared时要记住的重要一点是,使用完缓冲区后必须始终返回租用的缓冲区,否则会在应用程序中引起内存泄漏。
这是V07的样子:-
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
最低为2.2GB,而最低为7.5GB。 很好,但是我们还没有完成。
再见StringBuilder
剖析V07揭示了下一个问题:
在小数和int解析器内部调用StringBuilder.ToString()非常昂贵。 现在该弃用StringBuilder并编写我们自己的int和decimal解析器了,而不必依赖字符串并调用int.parse()/ decimal.parse()。 根据分析器,这应该减少大约1GB。 在编写了自己的整数和十进制解析器之后,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)还好。
¹可以在这里找到代码
跳过逗号
在V08之前,我们的策略是找到每行上每个逗号的索引,然后使用该信息创建一个子字符串,然后通过调用int.parse()/ decimal.parse()对其进行解析。 V08不赞成使用子字符串,但仍使用逗号位置的短期索引。
一种替代策略是,通过计算前面的逗号数来跳到我们感兴趣的部分,然后在所需的逗号数后解析任何内容,并在点击下一个逗号时返回。
我们之前已经保证:
- 每个部分前面都有一个逗号。
- 并且一行中每个部分的位置都不会改变。
这也意味着我们可以弃用所租借的byte []数组,因为我们不再构建短暂的索引:
不幸的是,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的另一个好处是,它的读取内容与原始实现更加接近。
类与结构之间的战争
这篇博客文章不会涵盖类与结构的区别或优缺点。 这个主题已经涵盖 很多 次 。 在这种特定情况下,使用结构是有益的。 在V10中将ValueHolder更改为结构具有以下统计信息:
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的障碍。 另外,请注意不要盲目使用结构,请始终测试您的代码并确保用例正确。
再见StreamReader
从V10开始,行解析器本身几乎没有分配。 dotTrace显示剩余分配发生的位置:
好吧,这很尴尬,该框架使我们浪费了内存分配。 我们可以在比StreamReader更低的级别上与文件进行交互:-
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来防止很多小字节[]。 我们可以在这里做同样的事情:
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的内存分配。 那就是我一直在寻找的高潮。