加速 Plush 垃圾收集器
2026年8月17日,那些一直在阅读这个博客或在 X 上关注我的人知道,我往往会在多个副项目之间跳跃。前一段时间,我做了一个有意识的决定,允许自己追随动机并探索新想法,因为我认为副项目保持有趣很重要,绝不能变成一种负担。话虽如此,我时不时会想到我之前搁置的某个项目,以及如何进一步推进它。去年,我写了一系列关于 Plush 的博客文章,这是一种我创建的类似 Lox 的玩具语言。我将其组合在一起是为了玩转不同解释器和虚拟机设计的想法。值得一提的是,它具有基于演员的并行性,并且设计得没有全局 VM 锁定在任何关键路径上,也没有任何需要整个 VM 暂停的情况。后来,我在 Plush 解释器中实现了一些基本优化,然后为 VM 编写了一个复制垃圾收集器(GC)。GC 本身并不特别,但它的酷炫之处在于每个演员都有自己完全独立的 GC。每个演员可以在没有任何同步的情况下运行一个收集周期。然而,有点不幸的是,这个 GC 的性能却相当令人失望。我为 Plush GC 设定了一个个人目标。我希望它能够在 20 毫秒内收集一百万个活动对象,想法是能够构建一个 3D 游戏引擎而不必担心 GC 暂停的明显存在。我编写了一个小的 gc_many_objs.psh 微基准,分配一个有一百万个节点的链表,然后在循环中触发 GC,但性能远未达到我的目标。在我的 MacBook Air M5 上,这个实现的收集时间大约为 117 毫秒,这慢了好几倍。原因是我在实现我的复制 GC 时采取了一个方便的捷径。传统的 Cheney 复制收集器将对象从一个内存块(from-space)复制到另一个(to-space),并且它使用一个转发指针,该指针位于每个对象的头部,同时利用 to-space 作为工作列表,在复制过程中传递遍历活动对象的图。在 Plush 中,每个演员都有自己的私有分配器,用于分配对象,还有一个作为缓冲区的消息分配器,用于接收来自其他演员的消息。当一个对象被作为消息发送时,发送者会将其复制到接收者的消息分配器中。这种设计是为了使发送者与接收者解耦。它意味着发送者和接收者不必在交换消息时进行锁定和同步。我希望能够复用一个复制算法,既用于 GC,也用于将消息复制到接收者的消息分配器。为此,我不想使用来自发送者堆的转发指针,因为这将改变发送者中的对象。相反,我使用了一个哈希映射来跟踪对象及其副本之间的对应关系。我以为这不会对性能产生太大影响,因为哈希指针的速度很快,但我错了。一个演员的两个分配器,以及消息经过的两个副本。我的朋友和同事 Laurent Huberdeau 指出了一个简单的基本问题,在那时我并不知道,即 Rust 的默认 HashMap 使用了一个安全哈希函数,专门设计用于防止 HashDoS。这个哈希函数不影响 HashMap 的功能,但确实影响性能。值得庆幸的是,在 rustc_hash crate 中有一个等价的 FxHashMap,它由 rust-lang 项目维护,是一个可以直接替换的选择。Laurent 还发现了一个可以避免的冗余哈希表查找。这些简单的更改使得复制 GC 的运行速度提高了两倍多,在我的 M5 笔记本电脑上降至 43 毫秒。速度更快了,但仍然远未达到我最初的 20 毫秒目标。剖析显示,大部分开销仍然来自哈希表。不过,还有更坏的消息:转发指针哈希表本身占用的空间比在收集过程中正在被复制的活动数据还要多。如果你想一想,这也是有道理的。我们在复制一个链表。链表节点相当小,每个节点只有一个指向下一个节点的指针和一个值字段。哈希表条目本身是一对指针,但更重要的是,哈希映射需要一些额外的容量(空槽)来确保良好的性能,否则可能会碰到哈希冲突并导致性能崩溃。除此之外,哈希函数是为了具有不可预测性而设计的。输出应呈现出一种准随机分布。如果你仔细想一想,从缓存性能的角度看,这实际上是很糟糕的。这意味着在 GC 期间,我们最终会以一种不可预测的模式触碰到各处的内存,甚至超过我们正在复制的数据。这并不理想。以两种方式完成相同的复制:通过哈希表和通过转发地址。这个 GC 还有其他低效之处。在传统的 Cheney GC 中,to-space 是线性遍历的,并且...
本站免费、广告极少。如果觉得有帮助,可以请我们喝杯咖啡 —— 任何金额都对持续运营有实际帮助。
☕请我喝杯咖啡