在这篇博文中,我将介绍 Shopify 如何通过 Variable Width Allocation 项目优化垃圾收集器中的内存布局,从而提高 CRuby 在 Ruby 3.2 中的性能。
Ruby 是一种垃圾回收语言。它在创建对象时自动分配内存,并在检测到不再使用对象时释放内存。这意味着垃圾收集器参与了对象的整个生命周期,因此优化垃圾收集器的内存布局有可能提高 Ruby 解释器的整体性能。
如果您对垃圾收集器如何为 Ruby 3.0(今天仍然广泛适用)工作感兴趣,请查看我之前的文章。
具有 3 个堆页且每个页具有 16 个槽的 Ruby 堆的图示
所有 Ruby 对象(除了像nil, true, false, 小整数和浮点数这样的立即数)都存在于称为RVALUE. 这意味着 Ruby 的垃圾收集器只分配单一大小的内存。这样做是为了简单起见,因为垃圾收集器只需要管理单一大小的内存块。然而,缺点也很明显:不同类型的对象具有截然不同的内存特性。例如,数组的大小是动态的,并且可以在运行时更改大小,而类的大小是静态的。这意味着任何不能放入的数据RVALUE都必须从外部分配,例如通过系统使用malloc.
这些插槽在 Ruby 堆页面中进行管理,每个页面的大小为 16 KB。这导致每个页面有 409 个槽。Ruby 使用堆页面作为一个单元来管理元数据,例如用于跟踪该页面上有多少活动对象的计数器。它还提高了分配性能,因为从系统分配内存会带来性能成本。我们希望通过一次分配大块来最大程度地减少从系统分配内存的次数。
尽管在 Ruby 垃圾收集器中只管理一个大小的插槽简化了实现并避免了内存碎片等问题,但它引入了一些限制。我将在这里介绍这些限制,稍后讨论我们如何通过我们的项目“可变宽度分配”来解决每个问题。
我们通常认为我们的系统只有一级内存,即主 RAM。但是,CPU 内部实际上有多层内存,称为缓存。每一级缓存的容量都比上一级大,但也有更高的延迟和更低的带宽。例如,在我的计算机上,只有 384 KB 的一级缓存与 32 MB 的三级缓存相比,但是一级缓存要快得多(1500 GB/s 对 500 GB/s)并且延迟更低(0.9 ns与 10 ns)。与高速缓存相比,主 RAM 慢得多 (50 GB/s) 并且延迟高得多 (80 ns)。
将这些数字放在人的尺度上,如果我们需要一秒钟从一级缓存中读取数据,那么从 RAM 中读取相同的数据将使CPU 等待大约一分半钟。所以很明显,我们希望使用缓存中的数据而不是从内存中获取数据来提高性能。
每当从主存中获取一条数据时,它会以缓存行为单位从主 RAM 中获取该数据并将其存储在缓存中。缓存行的大小因平台而异,在 x86 上为 64 字节,在 Apple 芯片上为 128 字节。当缓存已满时,它将驱逐最近最少使用的条目,为新条目腾出空间。
然而,40 字节RVALUE并没有利用缓存行的大小,这意味着一些获取的内存未被使用。此外,由于RVALUE没有提供足够的空间,对象可能会在其他位置分配内存,从而增加缓存中使用的空间量。
从使用的系统分配内存malloc不是免费的,它会带来性能开销。通过最小化malloc调用次数,我们可以减少这种开销。减少malloc调用次数可以缩短启动时间并加快对象创建速度。
由于 Ruby 中不同的数据类型具有截然不同的内存特性,强制所有对象都在一个 40 字节的槽中意味着一些对象将无法利用所有空间,而其他需要更多空间的对象需要存储指向其他区域的指针的记忆。存储指针需要额外的空间,并且一些实现malloc需要额外的内存来存储有关分配内存的元数据,这两者都会增加使用的内存量。
Java 等其他语言支持多种类型的垃圾收集器。这使研究人员能够试验和开发新的垃圾收集技术。此外,这允许用户根据他们的用例(例如,更低的内存使用率、更快的启动时间或更快的性能)更改垃圾收集器。在 Ruby 中,由于所有对象的大小都相同,并且对象的大部分数据都是通过 外部分配malloc的,因此其他垃圾收集器实现很难进行任何优化。
在Enabling Cutting-Edge Research部分,您将看到一个示例,说明这项工作如何使学术研究人员能够对 Ruby 执行前沿的垃圾收集器研究。
可变宽度分配是Matt Valentine-House、 Aaron Patterson 和我共同创作的一项功能。可变宽度分配的目标是引入 API 来分配动态大小的对象,并在 Ruby 垃圾收集器中添加对可变大小插槽的支持。具有可变大小的插槽允许具有不同内存特性的类型为其分配最优化的大小。
具有 5 个大小的池和大小池中的堆页面的可变宽度分配后的 Ruby 堆的图示。
通过可变宽度分配,我们在垃圾收集器中引入了一个新概念,称为“大小池”。每个大小的池都有特定大小的插槽。通过在大小池中保持固定大小的插槽,我们继续避免内存碎片问题并保持快速分配。
我们目前有五个大小的池,槽大小是大小的两倍的幂RVALUE,这意味着它们是 40 字节、80 字节、160 字节、320 字节和 640 字节。我们选择了两个倍数的幂,因为这确保了我们平均有 75% 的利用率。它的性能更高,因为它避免了在对象扩大或缩小大小时频繁调整大小。我们通过执行 Ruby 进程的内存转储来衡量这个假设是正确的,并确定插槽中大约 75% 的内存已被使用。我们选择了大小的倍数,RVALUE这样我们就可以容纳一个RVALUE,而不会为尚未进行可变宽度分配的类型浪费内存。
我们考虑的另一个解决方案是创建一个特殊大小的池,其RVALUE大小允许我们使其他大小的池的槽大小为 2 的幂(32 字节、64 字节、128 字节等)。但是,我们确定小的效率提升不值得拥有特殊大小的池的额外复杂性。
作为这项工作的一部分,我们将堆页面大小从 16 KB 增加到 64 KB。我们这样做是因为对于具有较大槽大小的堆页面(尤其是 640 字节槽),每页的槽并不多(大约 25 个)。由于每页的槽位不多,因此需要分配更多的页面,频繁的页面分配会降低性能。此外,由于每个堆页面都有一个用于存储元数据的标头,因此分配的元数据的增加增加了内存开销。
但是,为了增加页面大小,我们必须将页面大小与垃圾收集器的工作方式分离,否则更改页面大小会影响性能。将页面大小增加到 64 KB 也可以提高性能,因为它减少了页面分配的数量。它还具有另一个优点:在具有 64 KB 系统内存页面的系统(例如 PowerPC)上,它允许 Ruby 页面大小等于系统页面大小。通过使 Ruby 页面大小等于系统页面大小,可以在这些系统上使用其他功能(例如,压缩)。
到目前为止,我们只讨论了通过可变宽度分配来分配对象。但是,我们知道对象在运行时可以改变大小。对象的大小可以向上调整到它不适合插槽的程度,或者可以向下调整到浪费大量内存的程度。那么我们如何处理这两种情况呢?对于这两者,我们目前依靠垃圾收集器的压缩功能来使我们达到最佳状态。
压缩是垃圾收集期间的一个阶段,它将对象移动到一起以减少碎片。我们修改了压缩以将对象移动到最佳大小的池中。对于需要增加大小的对象,我们首先回退到通过系统分配,malloc 直到压缩通过再次将内容放置在对象附近使我们达到最佳状态,重新获得数据局部性。
可变宽度分配目前涵盖四种类型:类、数组、字符串和对象。我将讨论这些是如何实现的。
类是我们引入可变宽度分配的第一种类型。我们首先选择类是因为它们具有固定大小,所以我们知道分配时的确切大小,因此我们可以实现可变宽度分配而不需要实现任何调整大小的逻辑。
对于类,我们现在存储rb_classext_struct与类对象相邻的对象。rb_classext_struct存储类的数据,包括实例变量、超类和常量。在可变宽度分配之前,由于rb_classext_struct太大而无法放入,因此在创建类时RVALUE使用单独分配。malloc由于它现在与类对象一起分配,我们不再需要在分配类时从系统分配任何内存。
字符串是我们引入可变宽度分配的第二种类型。我们选择字符串是因为它们的大小是动态的,并且是 Ruby 程序中的常见类型。字符串已经支持存储与对象相邻的内容,称为“嵌入”。但是,由于RVALUE以前的大小限制为 40 个字节,因此只能嵌入最大 23 个字节的字符串。在可变宽度分配之后,现在可以嵌入最多 615 个字节的字符串。太大的字符串仍会将其内容存储在系统使用 分配的内存中malloc。我们在微基准测试中看到了显着的加速(高达 35% 的加速)。
然后我们为数组实现了可变宽度分配。就像字符串一样,已经有嵌入的数组。但是,它们仅限于三个或更少的元素。可变宽度分配后,可以嵌入长度最多为 78 个元素的数组。太大的数组仍会将其内容存储在系统使用 分配的内存中malloc。
可变宽度分配的最后一个类型是对象。只是为了避免混淆,即使 Ruby 中的一切都是对象,我们还是专门讨论从代码中定义的类创建的对象(换句话说,不是 Ruby 中的核心类型之一,例如数组、类、字符串、散列、和整数)。
对象存储它们的实例变量列表,因此我们选择在可变宽度分配上实现它。就像数组一样,已经有嵌入式对象,但它们仅限于三个或更少的实例变量。可变宽度分配后,最多可以嵌入 78 个实例变量的对象。具有超过 78 个实例变量的对象仍会将其内容存储在通过系统使用 分配的内存中malloc。我们在读取和写入实例变量时看到了微基准测试的显着加速(大约 20% 的加速)。
可变宽度分配项目使学术研究人员能够通过在 Ruby 中实施内存管理工具包 (MMTk)项目来试验新的内存布局和垃圾收集技术。MMTk 是澳大利亚国立大学 Steve Blackburn 教授的尖端垃圾收集器研究项目. 这是令人兴奋的,因为 MMTk 提供了一组标准的内存管理 API,这将允许我们在不修改 Ruby 源代码的情况下尝试不同的垃圾收集算法。不同的垃圾收集器具有不同的特性,并允许用户根据他们的用例进行切换。例如,标记清除垃圾收集器(这是目前在 CRuby 中使用的垃圾收集器)针对较低的内存使用进行了优化,而半空间复制垃圾收集器针对更好的性能进行了优化(以使用两倍的内存为代价)。
这项研究部分由 Shopify 赞助,您可以从我们的帖子 Shopify 大规模投资Ruby 研究中了解有关 Shopify 赞助的各种 Ruby 研究项目的更多信息。
从可变到宽度分配的加速
我们可以在几乎所有基准测试中看到性能改进,在大量读取和写入数据结构的工作负载上有显着改进,例如解析 YAML 文件 (psych-load) 或生成 PDF 文件 (hexapdf)。在 railsbench 中,我们可以看到可观的 2.1% 的性能提升。
您可以在附录中找到基准测试设置和原始数据。
可变宽度分配引入了一个新的 API 来从垃圾收集器收集统计信息。GC.stat_heap是一种返回有关大小池的统计信息的新方法。此方法可用于确定 Ruby 程序的内存特征或调试特定大小的池是否会导致性能问题。由于此方法返回有关垃圾收集器的内部信息,因此返回数据的结构可能会发生变化。有关详细信息,请参阅方法文档。
如果您遇到性能或兼容性问题(尤其是原生 gem),您可以在构建 Ruby 时CPPFLAGS='-DUSE_RVARGC=0'通过传递到配置步骤来禁用可变宽度分配。还请通过向bugs.ruby-lang.org提交错误报告让我们知道这个问题。请注意,此逃生舱仅在 Ruby 3.2 上可用,并且将来会取消支持。
基准测试是在配备 AMD Ryzen 5 3600X 和 32 GB RAM 的 Ubuntu 22.04.1 机器上完成的。Ruby 在 3.2.0 的开发版本(commit 80e56d1)上进行了基准测试。
所有基准测试均在提交0cf6940yjit-bench时使用 (禁用 YJIT 以测试解释器性能)运行。
单次迭代的所有基准测试结果都以毫秒为单位,因此越低越好。
启用 VWA | VWA 禁用 | 加速 | |
活动记录(毫秒) | 121 | 124 | 1.025x |
十六进制PDF(毫秒) | 2206 | 2322 | 1.053x |
液体渲染(毫秒) | 142 | 145 | 1.021x |
邮件(毫秒) | 121 | 119 | 0.983x |
心理负荷(毫秒) | 1791 | 1967年 | 1.098x |
railsbench(毫秒) | 1782 | 1820 | 1.021x |
ruby-lsp(毫秒) | 60 | 64 | 1.067x |
作者:Peter Zhu
Peter是 Shopify 的 Ruby 核心提交者和高级开发人员。他目前致力于通过可变宽度分配项目优化内存布局来提高 Ruby 的性能。他是 ruby_memcheck 的作者,这是一种用于在本机 gem 中查找内存泄漏的 gem。它在 Nokogiri、protobuf、gRPC 和 liquid-c 等流行的 gem 中发现了内存泄漏。
出处
:https://shopify.engineering/ruby-variable-width-allocation