https://www.youtube.com/watch?v=eyhmeMq8mTE&list=PL85XCvVPmGQgR1aCC-b0xx7sidGfopjCj&index=7
Summary
这个视频介绍了在Python中集成基于Rust的垃圾收集器的工作。演讲者简要介绍了垃圾收集的背景,详细说明了项目中使用的内存管理工具包(MMTK),并解释了如何将其与Python实现(PyPy)集成。
Highlights
- 🧑💻 项目背景:讲者在IBM实验室和加拿大新不伦瑞克大学进行研究,主要开发一个基于Rust的垃圾收集器,并集成到Python中。
- 🗑️ 垃圾收集简介:垃圾收集是一种自动内存管理形式,消除程序员手动管理内存的责任,已有60年历史。
- 🐍 Python实现:项目在PyPy(Python的一个实现)中进行,因为其具有比CPython更强大的垃圾收集功能和性能。
- 🛠️ MMTK框架:MMTK是一个基于Rust的垃圾收集框架,设计成与语言无关,可用于不同编程语言的垃圾收集实现。
- ⚙️ 集成过程:讲解了将MMTK集成到PyPy中的技术细节,包括对PyPy的改造、绑定层的实现和与JIT(即时编译)集成的挑战。
- 📊 性能评估:尽管在一些性能测试中,集成MMTK后的PyPy表现出一些开销,但在垃圾收集时间上表现出了改进。
keyword
- Rust
- 垃圾收集器
- Python
大家好,感谢各位来参加我的演讲。我叫Jon,今天我将讨论我们过去一年多来一直在研究的基于Rust的Python垃圾收集器。
关于我的背景,我在加拿大新不伦瑞克大学的IBM实验室做研究。我是一名Python核心开发者,从2019年开始担任这个角色。在Python中,我主要负责CI和子解释器方面的工作。去年,我很荣幸地获得了RAS基金会12个月的资助,用于在Python内部集成这个基于Rust的垃圾收集器。我还担任过Python软件基金会(PSF)的董事,任期两年多。目前,我正在IBM实验室攻读博士学位,并参与多项合作研究。
关于今天演讲的内容安排,我会先简要介绍垃圾收集的背景知识,特别是针对可能不太熟悉这个主题的听众。然后我会讨论我们所使用的Python实现。接下来,我会详细介绍我们使用的垃圾收集框架MMTK(全称是Memory Management Toolkit,即内存管理工具包)。最后,我会深入讲解如何将MMTK集成到PyPy中。
什么是垃圾收集?它是一种自动内存管理的形式。我们基本上是从程序员那里接过内存管理的责任,而不是剥夺他们的权力。自动内存管理,或者说以垃圾收集形式的内存管理,是一个非常古老的领域,已有60年的历史。第一个垃圾收集器可以追溯到Lisp语言时代。目前,包括Python在内的许多语言都支持某种形式的垃圾收集。
今天,我会采用垃圾收集领域中更常见的术语,这些术语不仅出现在各种文献中,也是我们在不同社区交流时使用的。所以,当我提到”收集器”时,我指的是回收未使用内存的那部分。而当我提到”mutator”时,我指的是应用程序。
垃圾收集器通常分为三大类。我们根据它们如何分配对象、如何识别和使用内存,以及如何回收内存来对垃圾收集器进行分类。
在早期,就分配而言,我们可以连续地在内存中分配对象,或者通过空闲列表来分配。连续分配意味着我们按照对象到来的顺序分配它们。而空闲列表方法则是在内存中维护多个特定大小的单元,当需要分配对象时,我们寻找与incoming对象大小匹配的特定单元。
对于垃圾的识别,我们可以遍历对象图(tracing垃圾收集器起源于此),或者我们可以为给定对象保持一个引用计数,这样当引用计数降到零时,我们就可以回收内存。
此外,某些垃圾收集器的命名取决于它们实际回收内存的方式,如清扫(sweeping)、压缩(compaction)或复制(copying)。压缩意味着当我们移除任何未使用的对象时,我们会重新安排内存以避免碎片化等问题。
让我给你一个简单的例子。假设我们有这样一个对象图,我们有根集(root set)的概念,图中纯色的对象就是根集的例子。当程序运行时,我们会有一个对象图,任何与根集有层级关系的对象都被认为是活动对象。任何无法追溯到根集的对象都被视为可能未使用。在这个例子中,对象L、K和N相互引用,但我们无法将它们的引用映射回根集。所以在这种情况下,我们可以说这些是未使用的。垃圾收集后,这个图中的三个对象会被回收:对象I、对象K和对象N。
那么,我们的项目是在哪个Python实现中进行的呢?我们在PyPy中集成了基于Rust的垃圾收集器。PyPy是Python的一种实现,比参考实现CPython更快。区别在于PyPy是用Python编写的(虽然是受限制的Python子集),但它比参考实现更快,原因有几个。最主要的原因是它具有即时编译器(JIT)。目前,CPython还没有JIT,虽然未来可能会支持,但目前还没有。除此之外,PyPy的性能优势还来自于其强大的垃圾收集器实现。PyPy有六种垃圾收集器,根据应用程序的需求,你可以调整解释器以使用你选择的垃圾收集器。
我们选择在这个Python实现中工作,是因为它有强大的垃圾收集策略历史,我们可以轻松地插入并展示基于Rust的垃圾收集的一些优势。RPython是受限制的Python,它支持Python的所有语法,但它是静态类型的,能够对对象和地址进行很低级的操作。为了能够对对象进行这些低级操作,他们不得不在Python中做出一些妥协。
那么,什么是MMTK呢?MMTK是我今天要重点讲的基于Rust的垃圾收集框架。它最初并不是一个Rust项目。就像现在很多项目一样,我们似乎在重写一切。我们有一种语言,然后当Rust出现时,我们说:”哦,现在我们要重写所有东西。”
MMTK的历史可以追溯到近20年前。它最初是一个研究项目,最初是用Java编写的,其设计与当时的Java实现Jikes RVM虚拟机紧密相连。在MMTK上20年的工作给了我们对垃圾收集研究的深入洞察。MMTK让研究人员能够提出新的垃圾收集策略,但他们主要在Java生态系统中展示他们的工作,因为当时MMTK与Java紧密相连。
几年后,MMTK团队中的一位研究员写了一篇论文或做了一个实验,关于在Rust中实现MMTK。结果非常有趣。正如这篇论文中所记录的(你可以去读一读),实验表明Rust实际上是一种非常适合实现高性能垃圾收集功能或策略的语言。当时我们可以做到这一点,同时只需要打破语言的一些很小的安全保证。这篇论文发表于近10年前,其结果促使该团队将MMTK从Java重写为Rust。现在,默认的MMTK (内存管理工具包)已经作为Rust项目发布,不再是Java项目。
那么MMTK是什么呢?MMTK由四个主要部分组成。你在粉色部分看到的是MMTK核心,这是用Rust实现的库,作为Rust crate提供,人们可以用它来为不同的编程语言实现垃圾收集器。另一个粉色部分是运行时绑定,这是特定于语言的代码,使用MMTK。请记住,MMTK是一个设计为高度语言独立的库,它不包含任何特定编程语言的细节,比如对象模型。如果我们想了解Python、Ruby或OpenJDK等语言的对象模型,我们必须使用绑定来编写那些特定于语言的代码。但是绑定会回调到MMTK,后者是更加通用的。
在我左边的白色框中是运行时特定的代码。我们也会有运行时绑定。有些特性必须在语言端编写,但要与MMTK接口。我们称之为运行时或语言绑定,我稍后会详细讨论这一点。然后我们当然还有运行时代码,也就是解释器或语言代码本身,用于垃圾收集,它调用绑定,最终调用MMTK。
那么MMTK核心是什么呢?正如我之前提到的,它是基于四个关键原则设计的:
它是用Rust编写的,作为crate提供,旨在实现高性能的垃圾收集策略。20年来在Java生态系统中进行的所有研究都为MMTK提供了信息,使其能够提出非常精细调整的垃圾收集计划和策略,可以处理不同的工作负载。它利用了20年的知识来制定可以很好地适用于不同语言的策略。当然,没有一种垃圾收集策略可以统治一切,但我们试图在两者之间取得微妙的平衡。
第二点也是最重要的一点是,他们试图使垃圾收集的方面与语言无关。我们已经习惯了每种语言都实现自己的垃圾收集器,但我们知道,如果你曾经写过垃圾收集器,你就会知道这是一项非常复杂的任务,不是一两天就能完成的。所以,如果我们把这种复杂性抽象出来,放在一个更加与语言无关的库中,那么对于语言实现者来说,编写新的垃圾收集器就会变得更加可行。我们确保不将实现与特定语言绑定。
由于MMTK的模块化设计,它能够用于不同的运行时和不同的开发者。
最后是正确性。对于像垃圾收集器这样复杂的东西,如果你降低了启动新项目的门槛,那么你就减少了从头开始编写时可能出现的错误。但是如果你调用库,那么写出更正确的代码、减少错误的保证就会高一些。
这就是20年前MMTK被编写时的四个主要目标。
这是MMTK使用方式的层次结构,我只是用更具体的术语说明了它对使用MMTK的运行时意味着什么。我们会有像左边紫色的Julia这样的运行时,然后是绑定。橙色的绑定是运行时中特定于MMTK的绑定,也就是调用MMTK的运行时代码。白色的是Ruby的不同绑定,但在MMTK方面负责扫描和其他事情。
具体来说,这就是绑定的样子。因为MMTK是用Rust编写的,所以具有语言对象模型知识的绑定也必须用Rust编写。我们称之为核心绑定的MMTK或绑定也是用Rust编写的,但它们具有语言知识。这就是我们所说的核心绑定。运行时绑定是用实现运行时的语言编写的。例如,对于PyPy,这些绑定应该用RPython编写,但出于性能原因,你可以看到这实际上是C++。这不是RPython,因为在PyPy中,对于某些非常关键的性能例程,我们可以为这些方面编写C或C++。所以这实际上是用于启动线程处理收集的C++代码,因为MMTK需要知道根,因为运行时需要给它根和许多其他东西才能工作。所以这是用运行时语言编写的运行时代码,但调用MMTK。
最后是运行时代码,也就是实际的垃圾收集器代码。在这个例子中,它是用RPython编写的,因为这是PyPy,而且因为我们最终调用Rust,所以我们必须通过某种形式的C来调用Rust。由于它是Python,我们必须使用某种形式的FFI接口。PyPy支持自己的FFI接口来调用C和其他东西。这带来了一些开销,我稍后会讨论,但我们的结果显示无论如何都有好处。
正如我已经说过的,MMTK已经在不同的语言的生产环境中得到了证明,目前有很多工作正在进行,将MMTK垃圾收集器移植到不同的运行时中。有Ruby的工作,有Julia的,有OpenJDK的,OpenJDK的工作更加先进,因为它花了更多的时间,MMTK的垃圾收集器已经显示出比一些默认的垃圾收集器更好的性能。但今天我要谈的是PyPy,因为那是我一直在工作的团队,也是我工作的内容。
在PyPy内部,我们采用了不同的设计。我们没有真正遵循我之前讨论过的设计,因为它对PyPy不是很完美。我们无法实现绑定的那种分离或抽象,特别是因为PyPy的编写方式。所以我们最终将运行时绑定和核心绑定混合在一起,因为那种分离是不可能的。尽管如此,它并不需要编辑MMTK,比如让它包含PyPy的细节。所以我们仍然有MMTK核心和一个绑定层,这个绑定层是核心和运行时绑定的混合,然后是运行时代码。
蓝色的块是RPython翻译工具链。像PyPy这样的语言是用RPython编写的,它被编译成字节码,这个字节码经过一个阶段,我们将其转换为控制流图。然后,控制流图经过类型推断,所有变量都被赋予类型。接下来,它经过一个转换阶段,这些转换使控制流图的操作看起来更接近目标后端。当然,RPython翻译工具链将这个现在是控制流图的字节码转换为目标后端,比如C或任何其他面向对象的后端。
PyPy解释器是用RPython编写的,但它被编译成C,而不是面向对象的后端。我们利用这个RPython工具链的转换阶段来支持MMTK垃圾收集。我们编写了一个基于MMTK的转换,以利用控制流图中的这些操作,并在程序被翻译时替换程序中任何与内存管理相关的调用,使其调用我们的垃圾收集器。我会给一个例子。
我们利用所有的分配或malloc操作,在我们转换到最终后端之前,我们将所有对分配的调用转换为实际垃圾收集器中的调用。所以MMTK_alloc替换了malloc操作来进行分配,收集也是一样的。我们实现了一个转换,并实现了实际的垃圾收集器,因为转换只是映射调用。当然,我们还有MMTK绑定。
在PyPy工具链中非常有用的是其中的跟踪JIT。我们还必须做大量工作将GC操作集成到JIT中,JIT集成涉及两个主要部分:前端分析和后端分析。前端分析只是生成跟踪,因为跟踪JIT只是生成程序运行时涉及的所有操作的跟踪。对于垃圾收集器,我们必须做的是在跟踪中生成所有GC操作,并确保将它们转换为一种更低级的语言,我们称之为残留操作,然后发送到后端。后端对跟踪中与GC相关的操作进行一些重写。
这些跟踪重写的一个例子可能包括,因为当我们将malloc映射到MMTK分配时,我们会在跟踪中有多个分配操作。一个例子可能是将所有这些分配操作合并为一个,以及在后端分析期间可能发生的其他几个优化。
对于PyPy来说,它很简单,我们遵循正常的MMTK业务,但跟踪JIT带来了一些其他复杂性。我已经讲了一些GC集成的内容,也许我可以补充的唯一一点是GC引用跟踪器。我们还会遍历所有的跟踪,识别指向常量的GC指针,并尝试用它们的实际值替换它们,作为后端的一种优化。
实际的工作是重写了大约四个垃圾收集策略,将PyPy的GC策略改为使用MMTK。正如你所知,我们是在调用一个库,所以难度或工作量并不太大。工作量是可控的,因为我们调用的是一个库。就像我说的,我们有一个叫做绑定的整个层,但这也被称为重写。所以工作量较少,但由于我们在绑定中所做的工作,也不是真的少很多。但总的来说,我们不必从头开始编写垃圾收集器的核心,这就是发生的情况。
如果你关心代码量,如果你是一个软件工程师,喜欢计算圈复杂度之类的东西,我们几乎只写了一半的代码。如果你比较两个RPython代码,我们写了一半的代码,因为我们调用的是MMTK,它是一个库。由于模块化,MMTK的GC更容易维护。我们可以看到,可维护性指标更好,结果代码比我们现有的GC更容易维护。由于从MMTK获得的正确性保证,也许我们能够写出更少的bug。在这方面,我们无法量化,我们仍然在与一些bug作斗争,但我们希望,因为我们调用的是正确的代码,这种情况不太可能发生。
在性能方面,我们对标准Python基准测试套件进行了一些基准测试。我们将使用MMTK垃圾收集器的PyPy与默认的PyPy GC进行了对比。在mutator时间方面,情况并不是很好,我们看到有25%到35%的开销。这并不一定是因为MMTK,实际上MMTK在20年里有很多优化。大部分开销都在分配上,我无法将分配和mutator代码分开,很难进行性能分析。所以mutator代码包括分配时间。
大部分开销都在分配上。我们对此进行了优化,事实上,在优化分配的快速路径和慢速路径之前,速度几乎慢了五倍。但通过这个优化,我们改善了这一点。所以mutator时间的开销归因于我们如何将Python接口到Rust,这是我们无法避免的FFI开销,这只是PyPy语言实现的一个限制。
然而,当我看GC时间时,这就是我们可以得出结论,实际上使用用Rust编写的GC确实带来了性能提升,因为MMTK的GC在GC时间方面表现更好,收集所需的时间减少了6%到10%。我们不否认这一点,那篇论文实际上是正确的,我们确实从使用基于Rust的GC中获得了一些好处。当然,mutator时间的开销抵消了我们在GC时间上获得的一些好处。再次强调,原因是Python和Rust的接口问题。
总的来说,我们看到在总体性能上有2%到17%左右的开销,特别是对于像二叉树这样的内存密集型基准测试。
那么,这里的关键见解是什么呢?当然,正如我所说,那篇论文是正确的,我们确实看到了使用MMTK GC的一些好处。我们确实打破了一些安全保证,我们无法量化这一点。论文告诉我们Rust的某些方面对实现垃圾收集器很好,比如安全性等,这些我们暂时无法量化。我不知道现在是否有一个银弹,但是将Python接口到C仍然显示出一些我们无法忽视的开销。MMTK对写屏障和分配都有慢速路径和快速路径,但我们仍然需要在某个时候调用MMTK库,所以我们无法避免这个开销。
就这样,我的演讲到此结束。感谢大家,我会在这里回答任何关于MMTK的问题。
原文链接: https://dashen.tech/2018/07/13/RustConf-2023-A-Rust-based-garbage-collector-for-Python/
版权声明: 转载请注明出处.