欢迎关注 猩猩程序员 公众号
本文跟踪了Green Tea垃圾回收器的设计和实现。截止到本文更新时,Green Tea的开发仍在进行中。一旦我们准备好确定设计,我们将发布更详细的设计文档。
目前,Green Tea作为Go 1.25版本中的一个实验功能提供。我们不预期实验会有正确性问题。该功能已在Google内部运行,并且我们认为它已经足够成熟,可以投入生产。我们鼓励各团队尝试使用它。您的反馈对于最终设计的完善至关重要!
介绍
随着CPU时钟速度超过DRAM时钟速度,核心数的增加加大了物理内存总线的负担,而光速限制要求采用越来越不均匀的内存拓扑,内存延迟和带宽日益受到限制。因此,空间局部性、时间局部性和拓扑感知变得对高性能系统至关重要。
然而,所有这些趋势与当今大多数垃圾回收算法相冲突。Go的垃圾回收器实现了经典的三色并行标记算法。本质上,它是一个图遍历算法,其中堆对象作为图中的节点,指针作为边。然而,这种图遍历没有考虑到处理的对象在内存中的位置。因此,它展现出了极差的空间局部性——在完全不同的内存区域之间跳跃——差的时间局部性——无视重复访问同一内存并将其分布在整个GC周期中——以及对拓扑的忽视。
因此,平均而言,垃圾回收器85%的时间都花费在图遍历的核心循环中——扫描循环——并且超过35%的CPU周期仅仅是在等待内存访问的阻塞时间,不包括任何间接影响。随着行业趋向多核系统和非均匀内存架构,这个问题预计只会变得更糟。
本文提出了Green Tea:一个并行标记算法,虽然它不是以内存为中心的,但至少是内存感知的,因为它尝试将彼此接近的对象一起处理。
该新算法已经实现,并可以供开发者在他们的工作负载中进行试验,本文还展示了该实现与我们的基准套件的评估结果。总体而言,该算法在GC密集型工作负载中大幅降低了GC的CPU开销。
最后,这个新标记算法为未来的优化提供了新的机会,例如SIMD加速,我们将在本文的后续部分讨论其他可能的未来工作方向。
设计
新并行标记算法的核心思想很简单。与其扫描单个对象,垃圾回收器在更大、更连续的内存块中进行扫描。共享工作队列跟踪这些粗大的内存块,而不是单独的对象,每个等待扫描的对象都在该内存块内进行跟踪。核心假设是,当一个块在队列中等待扫描时,它将累积更多需要扫描的对象,因此当该块被出队时,扫描时很可能能够扫描该块中的多个对象。这样不仅改善了内存访问的局部性,还更好地摊销了每次扫描的开销。
原型实现
在该新算法的原型实现中,我们跟踪的内存块称为“span”。一个span始终是8 KiB的倍数,始终对齐到8 KiB,且完全由一种大小的对象组成。我们的原型实现专注于“小对象span”,每个span的大小为8 KiB,包含最大512字节的对象。
span还是存储堆元数据的基本单元。在原型中,每个span存储每个对象的两个位:灰色位和黑色位。这些位对应于三色抽象:对象如果已扫描则为黑色,如果在队列中等待扫描则为灰色,如果完全没有被访问则为白色。在原型中,白色对象没有设置任何位,灰色对象设置灰色位,黑色对象同时设置灰色和黑色位。
当扫描发现一个指向小对象的指针时,它会设置该对象的灰色位,表示该对象需要被扫描。如果灰色位没有设置并且该对象的span还没有加入扫描队列,则将其加入队列。每个span都有一个标志,指示该span是否当前已加入队列,这样它只会被加入一次。当扫描循环从队列中出队一个span时,它计算灰色位和黑色位之间的差异来确定需要扫描的对象,将灰色位复制到黑色位,并扫描那些已设置灰色位但未设置黑色位的对象。
限定小对象范围
原型实现专注于小对象,因为我们从中获得的收益最大。小对象的每次扫描开销更难以摊销,因为垃圾回收器扫描每个对象所花的时间非常短。较大的对象继续使用旧的算法。
选择使用哪个算法是在扫描过程中遇到指针时做出的。span分配器维护一个位图,每个8 KiB页面有一位,指示该页面是否由小对象span支持。这个位图的存储足够小,可以适应缓存,即使是非常大的堆,且竞争非常低。
由于小对象span总是8 KiB大并且对齐到8 KiB,一旦扫描器知道指针目标位于小对象span内,它可以通过简单的地址算术来找到该对象在span内的元数据,从而避免了间接寻址和会严重影响性能的依赖加载。
工作分配
Go当前的垃圾回收器通过让每个扫描器维护一个本地的固定大小对象指针栈来分配工作。然而,为了确保并行性,每个扫描器会积极检查并填充全局列表。这种全局列表频繁变更是多核系统中Go程序的一个主要竞争源。
原型实现使用了一个单独的队列专门用于span,并基于goroutine调度器使用的分布式工作窃取运行队列。直接从其他工作者窃取工作减少了对全局列表的竞争。此外,通过将span而不是单独的对象排队,队列中的项目数量大大减少,因此对队列的竞争自然降低。
span工作可以按多种不同方式排序。我们探索了几种策略,包括FIFO、LIFO、最稀疏优先、最密集优先、随机和地址排序。FIFO策略最终在出队时能积累最多的对象密度,适合进行扫描。
单对象扫描优化
如果一个span在出队时只包含一个对象,那么新算法已经比现有的基于对象的算法处理了更多的工作。
为了让单对象每span的情况的性能更接近当前的标记算法,我们使用了两种技巧。首先,我们跟踪在span排队时已标记的对象。该对象成为span的代表,直到span被扫描。其次,我们向span添加一个“命中”标志,表示在span排队时已标记了对象,即至少有两个对象被标记。当扫描一个span时,如果命中标志未设置,垃圾回收器可以直接扫描span的代表,而不必处理整个span。
原型评估
我们在不同的基准测试中评估了原型实现,包括低CPU核心数、高CPU核心数、amd64和arm64架构的Linux虚拟机。以下是结果的总结,主要关注垃圾回收CPU成本的差异。
在一些GC密集型的微基准测试(来自golang.org/x/benchmarks的“垃圾”以及Computer Language Benchmarks Game的“binary-trees Go #2”)中,根据核心数,我们观察到GC CPU开销相较于现有Go GC减少了10%至50%。该改进通常随着核心数的增加而上升,表明原型在扩展性上优于现有实现。此外,这些基准测试中的L1和L2缓存缺失数量减少了一半。
在我们的bent和sweet基准测试套件上,结果更为多样。
未来工作
SIMD加速扫描内核
扫描更大内存块为应用SIMD提供了可能,Green Tea的核心思路是为每种大小类别生成独特的扫描内核,并使用SIMD位操作和排列指令加载、掩码、交换、打包和排队指针。Green Tea的内存块布局规则和Go对小对象的指针/标量元数据的紧凑表示,使这一思路得以实现。
Austin Clements开发的基于AVX512的原型扫描内核在我们已有的基准测试中,减少了15%至20%的垃圾回收开销。由于这些内核目前仅适用于一小部分对象,因此原型实现暂未使用这些内核。
聚合网络
Austin的原始设计使用了一个排序网络,称为聚合网络,用于
实现SIMD扫描所需的更高指针密度,并为元数据操作(如设置灰色位)生成局部性。由于这一网络实现复杂,我们暂未追求此方向,但这是我们计划深入探索的一个方向。
Here’s the translation of the article titled "How to try it out" :
如何尝试
首先,运行以下命令:
go install golang.org/dl/go1.25rc1@latest
然后,使用以下命令构建你的程序:
GOEXPERIMENT=greenteagc go1.25rc1 build
发送反馈
请将你的注意力集中在完整的程序上。微基准测试往往不能很好地代表实际程序中的垃圾回收行为。像这样的基础性大变动可能会由于各种原因影响整体性能,这些原因与垃圾回收器的实际效率无关(甚至可能是相反的)。
如果你遇到Green Tea无法正常工作的情况,我们将非常感谢你分享一些详细信息,这对未来的改进非常有帮助。(如果它正常工作,这些信息也同样重要。)
请提供以下信息:
- 你运行的操作平台。
- 你代码运行时的CPU型号(如果是在云端运行,请提供虚拟机类型)。
- 程序运行时的stderr输出摘录,使用
GODEBUG=gctrace=2
,包括启用和禁用Green Tea时的情况。 - 运行程序时的CPU性能分析,分别启用和禁用Green Tea时的情况。
- 捕获几个完整GC周期的执行跟踪,分别启用和禁用Green Tea(通常几秒钟就足够)。
注意:你可以使用GOEXPERIMENT=nogreenteagc
在构建时显式禁用Green Tea。
请提交一个新的GitHub问题并引用此问题,或者你也可以直接通过电子邮件联系我:mknyszek(at)golang.org
。
感谢你的反馈!
欢迎关注 猩猩程序员 公众号