按大小特化的内存分配

Go 1.27 为不超过 80 字节的内存分配带来了更快的实现。分配操作最多可加快 20%~30%,让大量分配内存的程序最多提速 1%。Go 运行时通过增加专门用于分配特定大小内存的函数,改善这些分配操作的性能。专用函数可以利用某些已知条件,从而运行得更快,也更容易优化。本文将解释其工作方式,以及它如何让程序变快。

堆分配由运行时的 mallocgc 函数创建,它需要知道分配大小以及其中是否包含指针。当编译器判定某个对象会逃逸到堆上,或因其他原因需要动态分配时,就会插入对 newobject 的调用。这个简单的包装函数提取对象大小及是否包含指针的信息,再将它们传给 mallocgc。

这两项信息决定了分配器需要执行的大部分工作。大小很重要,因为分配器定义了一系列大小范围,称为“大小类别”。对于大多数既不太大也不太小的分配,它会从空闲对象列表中返回一块内存,列表中的对象都采用该类别的最大大小。例如,大小类别 3 覆盖 17~24 字节。因此,无论分配 17 字节还是 24 字节,分配器都会从 24 字节对象列表中提供下一个可用的空闲对象。下表列出了不超过 80 字节的各大小类别范围。由于垃圾收集器需要不同的记账方式,包含指针与不包含指针的分配使用不同的空闲列表集合,我们称之为 span。

大小类别 大小范围
1 1-8 字节
2 9-16 字节
3 17-24 字节
4 25-32 字节
5 33-48 字节
6 49-64 字节
7 65-80 字节

大小类别以及分配是否包含指针,决定了我们从哪个 span 分配内存,因此分配器定义了 span 类别这一类型,其值编码了两项信息,定义为 sizeClass<<1 | noPointers。实现按大小特化的 malloc 时,我们发现,最合理的方式就是按照这些 span 类别进行特化,因为分配器的大部分行为由 span 类别决定。

我们为每个 span 类别生成一个专用的 mallocgc 变体。例如,为大小类别 3 分配无指针对象的函数名为 mallocgcSmallNoScanSC3。Small 表示既不是微小对象也不是大对象,NoScan 表示不包含指针,SC3 表示大小类别 3(17~24 字节)。专用函数力求简单,不能处理分配时的所有边界情况。检测到此类情况,例如 GC 正在运行时,它们会退回到更通用的分配例程。

最终,我们为微小对象情况(始终不包含指针)增加了一个专用 mallocgc 函数,并为每个非微小对象的 span 类别增加一个:大小类别 2 及以上的无指针 span,以及大小类别 1 及以上的含指针 span。这些专用 mallocgc 变体本身比调用 mallocgc 更快,但随着大小增加,特化收益会减少:内存清零通常逐渐占据分配时间的主要部分,到某个程度后,分配的其他工作便微不足道。而且,仅仅比 mallocgc 快还不够!采用按大小特化的分配后,如果编译器知道所需的 span 类别,就可以直接插入对专用函数的调用,替代对 newobject 的调用。但生成代码时,编译器往往不知道会分配哪个 span 类别,例如动态长度的切片。由于无法确定分配大小,编译器会保留对 mallocgc 的调用,而 mallocgc 本身必须判断是否存在可用的专用函数、具体是哪一个,再调用它。因此,专用函数必须足够快,即使加上动态调用开销也仍然更快。

调用开销并不是唯一的问题。如果只有这个问题,我们可以创建处理更大对象的专用函数,仅供编译器在编译时已知大小的情况下插入调用。不动态调用它们,就无需承担动态调用开销。然而,每新增一个专用函数,都会增大编译器生成的可执行文件,更重要的是,还会占用宝贵的指令缓存空间。由于分配十分频繁,单一的 mallocgc 函数往往已在指令缓存中。如果专用函数太多,且未驻留在缓存中,将专用代码装入缓存的开销就可能抵消全部收益。指令缓存中的专用分配代码越多,就越会挤占用户代码的空间,让用户代码的获取与执行变慢。我们对不同大小类别截止点进行了大量基准测试,确定 80 字节是最佳平衡点。

当然,增加专用函数意味着需要维护更多代码。如果每个函数都手写,各函数的代码很容易逐渐分歧、失去同步。为此,我们提取了专用函数的公共部分,并编写了一个内联器:使用标准库 go/ast 包进行解析与格式化,使用 golang.org/x/tools/go/ast/astutil 包操作 AST。函数的公共部分全部以标准 Go 代码编写,与运行时其他代码一起构建并进行类型检查,使工具能够发现问题,不过它们主要充当内联器使用的桩代码。

我们测得按大小特化的分配函数确实有所改善,但它们究竟为什么更快?最明显的优化在内存清零方面。mallocgc 返回的内存并非总要清零,但经常需要,而清零往往占据分配的大部分时间。内存清零函数 memclrNoHeapPointers 使用高度优化的汇编编写,但对于较小的清零操作,我们还能做得更好。在专用函数中,如果清零大小是常量,编译器可以把对 memclrNoHeapPointers 的调用替换为直接清零的指令。这样,分配就能省去一次函数调用和若干分支。这对很小的分配有明显作用,但随着分配增大,函数调用开销会变得微不足道。

更快的内存清零带来了最大的改善,专用函数还可以采用其他技巧:由于按 span 类别特化,获取 span 时无需再计算 span 类别。又因为分配大小是常量,编译器可以进行一些优化,加快分配所需的记账工作。例如,标记所分配内存中指针所在位置的工作就可以受益。

专用函数还可以手动内联若干辅助函数。Go 编译器能够内联代码,但会避免内联它认为过大的函数。在生成的代码中,我们可以将函数体插入调用方来绕过这一限制。借助生成器,可以生成每个函数体的副本,无需担心各副本逐渐分歧。我们还可以将处理较少见情况的代码,例如运行时调试标志的处理,移到慢路径函数中,从而缩小专用函数。

我们希望这份对按大小特化分配的解释让你觉得有趣,不过作为 Go 程序员,编写代码时无需考虑这些细节。内存分配只会稍微更快,而一些最常见的分配大小收益最大,尤其是 16 和 24 字节。这些分配很常见,因为它们由两个或三个 64 位值构成,包括由两个值组成的接口值和字符串,以及由三个值组成的切片。我们花了很多时间调整按大小特化 malloc 的行为,并确保指令缓存效应的影响尽可能小:我们原本计划在 Go 1.26 发布这项功能,但决定再等一个版本,以进一步调整,并尽可能压缩新增代码的大小。 只需使用 Go 1.27 构建程序,即可获得按大小特化分配带来的性能改善。如果你想采取更具体的措施来改善内存分配和垃圾收集性能,请阅读 Go Garbage Collector Optimization Guide。

虽然我们确信按大小特化分配不应导致你的代码出现性能回退,但必要时,可以使用 GOEXPERIMENT=nosizespecializedmalloc 构建程序来禁用它。如果你确实需要通过这种方式解决按大小特化分配带来的问题,请在 go.dev/issue/new 提交问题,以便我们调查。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容