尺寸特化内存分配

Hacker News Top 产品

摘要

Go 1.27 引入尺寸特化内存分配,适用于80字节及以下的分配,将分配速度提升20-30%,并提升分配密集型代码的整体程序性能最多1%。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/09/19 00:31

# 针对特定大小的内存分配 - Go 编程语言 来源:https://go.dev/blog/size-specialized-allocations Go 1.27 引入了针对 80 字节及以下分配的更快内存分配机制。分配速度最高可提升 20-30%,从而使内存分配密集型程序的整体运行速度提升最高 1%。Go 运行时通过添加专门用于分配特定大小的函数来改善这些分配的性能。这些专用函数可以做出某些假设,从而使其更快且更易于优化。本文将解释其工作原理以及如何提升程序性能。 堆分配由运行时的 `mallocgc` 函数创建,该函数需要分配大小以及是否包含指针。当编译器确定某个对象逃逸到堆上或其他情况需要动态分配时,它会插入对 `newobject` 的调用。这是一个简单的包装函数,提取对象的大小及其是否包含指针,并将这些信息传递给 `mallocgc`。 这两个信息决定了分配器大部分工作。大小很重要,因为分配器定义了称为“大小类别”的范围。对于大多数不太大也不太小的分配,它会从自由对象列表中返回一块内存,这些对象的大小均被设置为该大小类别的最大值。例如,大小类别 3 为 17-24 字节。因此,无论你分配 17 字节还是 24 字节,分配器都会从其 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`(https://go.dev/pkg/go/ast)包进行解析和格式化,以及使用 `golang.org/x/tools/go/ast/astutil`(https://go.dev/pkg/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 垃圾回收器优化指南(https://go.dev/doc/gc-guide#Optimization_guide)。 虽然我们确信大小特化不应导致你的代码性能下降,但如果必要,你可以使用 `GOEXPERIMENT=nosizespecializedmalloc` 构建程序以禁用它。如果你确实需要这样做来解决你遇到的与大小特化分配相关的问题,请在 go.dev/issue/new(https://go.dev/issue/new)提交一个 issue,以便我们进行调查。

相似文章

Pony 的区域分配器

Lobsters Hottest

这篇文章描述了一种为 Pony 语言设计的新区域分配器,它解决了在压力测试中发现的内存增长和性能问题,设计灵感来源于 snmalloc。

静态分配,恒定工作

Lobsters Hottest

本文探讨了静态分配策略,以防止释放后使用、类型混淆等内存安全问题,讨论了对象池和代际索引,并介绍了来自TigerStyle的技巧,以在初始化后避免动态内存分配。

mimalloc:面向现代时代的新型高性能可扩展内存分配器

Lobsters Hottest

mimalloc 是一个开源、高性能、可扩展的内存分配器,可作为 malloc 和 free 的直接替代品。它专为现代高并发应用和大内存规模而设计,被用于 Bing 等主要服务,并集成到 NoGIL CPython 和 Unreal Engine 等项目中。

std.Io.Writer.Allocating 消耗了我所有内存

Lobsters Hottest

一篇博客文章揭示了 Zig 的 std.Io.Writer.Allocating 中存在的内存过度分配错误,原因是 `drain` 函数在每个数据切片上错误地预留了 splat 参数的空间,导致内存意外增长。