我们将YJIT Ruby编译器移植到Rust的经验
去年,我在Shopify的团队实施了YJIT,这是一个用于CRuby的新的即时编译器(JIT),最近作为Ruby 3.1的一部分被上游化。因为CRuby代码库是用C99实现的,所以我们也决定用C99实现YJIT,这样与CRuby代码库的其他部分的整合就会尽可能简单。然而,我们发现用纯C语言实现JIT编译器很快就变得乏味了,而且随着我们不断为YJIT增加功能,我们发现我们项目的复杂性变得难以管理。
很多人会告诉你,用C语言编程时最大的负担是担心缓冲区溢出和意外地取消空指针。然而,我认为,能使C语言编程在日常工作中变得乏味的是,C语言没有提供很多工具来管理复杂性。没有模块或命名空间,所以你必须给标识符加前缀以避免名称冲突。你必须担心你的声明的顺序和在正确的地方添加原型。很多信息在各种头文件中都是重复的。常量和宏使用C语言的预处理器,这可能导致奇怪的错误。没有类或接口类型来干净地封装功能,也没有标准的容器类型。我们实现了自己的动态数组类型,我们不得不通过笨拙的预处理器宏来操作,没有类型检查。
编译器是现存最复杂的软件之一,我认为有理由认为JIT编译器甚至比超前编译器更复杂(或者至少更难调试)。在YJIT中,我们开始直接将CRuby字节码编译成x86机器代码。不过,我们还是计划实现我们自己的中间表征(IR),这样我们就可以将机器代码生成与前端解耦,并在x86之外增加对ARM64平台的支持。实现一个自定义的IR,增加额外的指示层,并以C预处理器宏的方式实现它,同时手动管理动态内存分配,而没有像接口类型这样的东西,这似乎是太多了。我们觉得我们已经达到了在C语言中可以轻松完成的极限。
Alan Wu,C语言专家和YJIT团队的高级开发人员,开始对Rust感兴趣,他建议我们可以将YJIT移植到这种语言上。他对Rust感兴趣是因为它提供了强大的类型安全保证。我很快就对他的建议感兴趣了,因为我相信Rust会给我们提供更好的工具来管理YJIT不断增长的复杂性。我们不确定CRuby核心开发团队是否会对这样的举动开绿灯,因为CRuby代码库中没有其他Rust代码。值得庆幸的是,他们在一月份对我们竖起了大拇指,于是我们开始努力将YJIT移植到Rust。
除了Rust之外,我们还短暂地考虑了其他的选择,比如将YJIT移植到Zig。这种移植本来是可能的,因为YJIT的依赖性非常小。然而,我们选择了Rust,因为它的相对成熟度和它的庞大而活跃的社区。在这篇文章中,我想对我们将YJIT从C语言移植到Rust的经验给出一个细微的观点。我将谈论积极的一面,但也会讨论我们在经验中发现的具有挑战性或不理想的地方。
YJIT团队有六个开发人员,其中四个人积极参与了Rust的移植工作。我是创始成员之一,18个月来一直担任团队负责人。我已经有24年的编程经验,1998年从学习C++开始。从那时起,我已经有了一些使用各种系统语言的经验,包括C、C++和D。我只用Rust编程了四个月,所以你将读到的很多内容都来自于一个相对较新的、仍在学习语言的人的角度。
YJIT的依赖性非常少,这可以看作是一个积极因素。然而,我们最大的依赖是我们必须与CRuby链接和整合。YJIT是一个相对简单的JIT编译器,总共有大约11000行的C代码。CRuby代码库是一个庞大的C99代码库,接近30年历史。两者之间的接口是不简单的。在其他方面,YJIT需要能够解析CRuby的字节码并操作Ruby语言中的每一个原始类型。我们使用的一些API是内部的,不能保证随着时间的推移是稳定的。
初步印象
YJIT并不是一个庞大的项目,但编译器足够复杂,很容易引入一些难以追踪的细微错误。我们决定采取的移植策略是尝试将C语言的代码或多或少地直接翻译成Rust。我们确实在进行过程中做了各种小的改进,但在移植过程中我们避免了重大的架构变化。我们注释了我们的C代码,并开始一个一个地移植函数和结构,在这样做的同时尽可能地保持总体结构与原版相似。我们采取的策略的好处是,它使移植代码的速度更快,错误更少,但坏处是,我们还没有充分利用Rust的习性。
Rust的市场定位是系统编程语言,而我的第一个惊喜是,用Rust编程感觉比写C++更接近于写ML代码。语法和语义上的差异足以让人觉得这门语言与C和C++并不属于同一家族。后来我了解到,有趣的是,第一个Rust编译器实际上是用OCaml写的,所以ML风格语言的文化影响肯定从一开始就存在了。
Rust的学习曲线比较陡峭,需要大量的谷歌搜索,但YJIT团队的大多数成员在移植工作进行了两到三周后开始变得比较适应。值得庆幸的是,由于Rust有一个庞大而活跃的社区,文档非常丰富,而且很容易找到,这使得学习过程更加顺利。
Rust能够在没有垃圾收集器的情况下运行,这是JIT编译器的一大优势。许多人抱怨说,Rust的借贷检查器很难让人信服。由于我在编译器设计方面读过博士,而且对数据流分析非常熟悉,所以对我来说没有什么大的惊喜。不过,尽管我认为我对借贷检查器的工作原理非常了解,但我还是需要学习系统的规则,即如何以及在什么地方允许借贷,而且我们还是遇到了使用可变RefCells进行动态借贷检查的情况,这是一个问题。
模式匹配和宏
Rust语言最好的特点之一是受ML启发的模式匹配语法。它简单而强大,并且与Rust的枚举(标签联盟)和结构类型很好地结合在一起。
Rust的宏系统与C语言的预处理器宏相比,在安全性和人机工程学方面都有很大的改进。宏很好地重用了Rust的模式匹配语法,因此你可以定义代码的生成方式,感觉相当直观和自然。
我们在很多地方都使用了宏。特别是,我们希望有一种方法可以让YJIT生成的机器代码增加一些统计/分析计数器,但只有当YJIT在dev(debug)模式下编译时。在其他方面,我们使用这些计数器来跟踪在运行我们的基准时导致YJIT退出到解释器的原因,我们使用产生的数据来决定下一步的优化。
构建系统
Cargo是Rust的构建系统和软件包管理器。我们对它的体验总体上是积极的。它是一个不错的工具。用于条件编译的可选功能系统运行得非常好;这比在C代码库中拥有大量的预处理器ifdefs ,要好得多。将测试嵌入到源代码中的能力也非常好。
为了与CRuby代码库集成,我们将Rust项目编译成一个静态库,在CRuby的makefile中引用。最棘手的部分是如何让C端与CRuby的链接过程协同工作。
CRuby的源代码是以tarball的形式发布和构建的,并从源代码中构建。为了使CRuby更容易分发和打包,同时保持其自成一体,对我们来说,重要的是YJIT可以在不访问互联网的情况下离线构建,也不需要从crates.io 。 不幸的是,事实证明用cargo来实现这一点是有难度的。有一个cargo build —-offline ,你可以指定,但当我们这样做时,货物工具抱怨说它不能访问互联网,尽管我们在构建中没有使用任何外部板条。我们在GitHub上发布了一个问题来报告这个问题。得到的答复是,我们应该使用cargo vendor ,并将结果输入版本控制,以解决这个问题。这个解决方案感觉是次优的。我觉得,当你不构建任何需要外部板条的功能时,应该可以直接离线构建,而不需要使用cargo vendor 。我们趋于一致的解决方案是,在发布模式下构建时直接使用rustc 。
YJIT的主要依赖项之一是平台C库。对我们来说,有一件事有点烦人,就是你需要依靠crates.io 来访问libc crate。感觉这应该是内置在默认安装中的,你可以离线使用,但事实并非如此。我们最终做的是在CRuby代码库中定义我们自己的辅助函数,并在Rust端公开。
Bindgen和FFI
YJIT需要与CRuby集成,这是一个庞大的现有代码库。我们与140个C函数、30个结构体/单元和500个常量的代码库对接,这些代码库跨越了数百个源文件。Bindgen是Rust的工具,用于自动导出C语言的定义。它可以解析C语言头文件,并导出函数、结构、枚举、联盟和全局变量的定义。它甚至可以解析一些简单的预处理程序常量。
Bindgen要求你为你想导出的函数、结构和常量的名称添加重合码式的模式到允许列表中。不幸的是,我们遇到了这个系统的挑战。对于某些模式,它找不到任何定义,而导出只是默默地失败,没有错误信息。我们找不到一个 "verbose "模式来解释导入失败的情况。是bindgen没有解析出头文件吗?它没有找到我们要的定义吗?是其他原因吗?我们唯一能做的就是猜测和尝试改变各种设置。我们在GitHub上开了一个问题来报告这个问题,但在写这篇文章时,还没有得到回应。
Bindgen可能对小型项目或主要使用Rust而只有少量C代码的项目来说效果相当好。特别是,bindgen可能最适合于创建与C库的公共API接口的Rust crates。这种API的定义通常包含在一组小的、定义明确的头文件中。不幸的是,YJIT必须与一个庞大的现有C代码库对接,而我们需要的许多定义并不是稳定的公共API的一部分。我们不得不用手工编写的C语言绑定列表来补充bindgen的不足,因为bindgen并没有导入所有的东西。
整数类型和投射
因为我们正在编写一个编译器,我们要处理大量的整数数学,我们基本上需要使用Rust提供的所有整数类型:有符号和无符号的值,8、16、32和64位宽。我们还经常需要执行涉及不同类型的整数的操作。与C不同,Rust不会自动将整数类型提升为更宽的类型。它迫使你在每一个操作中手动投掷任何不匹配的整数类型。它还迫使你在需要对数组或片断进行索引的地方使用usize 类型(类似于C的size_t )。
在我看来,Rust处理整数转换的方式还有待改进,而且我知道我不是唯一有这种感觉的人,因为早在几年前就有关于这些问题的讨论。这让程序员感到很沮丧,因为从先验的角度来看,你没有理由不把一个u8 、u16 、或u32 安全地提升为usize ,而要求程序员到处手动投掷整数会使代码更加嘈杂和冗长。
在我看来,Rust坚持到处手动铸造,鼓励人们写低效的代码,因为写冗长的代码感觉不舒服,而且增加了摩擦。你可以通过减少整数转换的数量来使你的代码不那么冗长,你也可以通过在任何地方使用最宽的整数类型来减少转换的数量。如果你这样做,你的代码表面上看起来会更漂亮,但也会降低效率。
在许多情况下,你可能可以使用64位整数而不是8位整数。我们谈论的只是节省空间的字节数,对吗?好吧,也许不是。我们关心这个问题是因为JIT编译器可以分配数千万甚至数亿个对象,这些对象越小,它们就越适合放在数据缓冲区内。紧凑性对性能很重要,而且只要处理器有缓存和有限的内存带宽,它就仍然重要。通过减少整数转换的摩擦,Rust实际上可以帮助程序员编写更有效的代码。
循环数据结构
对于任何一种优化编译器来说,YJIT都需要处理一个代表控制流图(CFG)的循环数据结构。这个数据结构需要在运行中分配和修改,这有点棘手,因为借贷检查器不允许循环。Rust提供了各种机制,如RefCell 和Rc(引用计数的类型,一种智能指针),允许多个对象集体拥有和变异另一个对象。Rust把这称为 "内部可变性",文档中说这是 "最后的手段"。它是可用的,但使用起来并不完全符合人体工程学。这是Rust中的一个已知问题。
Rc 和RefCell 类型对于一个纯粹用Rust编写并提前编译代码而不是及时编译的编译器来说,可能工作得很好,但我们在引用计算的内存管理方面遇到了一个微妙的错误,因为我们生成的机器代码必须保持对CFG中块的引用。最终,我们将为机器代码实现一个垃圾收集器(GC),与Ruby GC对接,这使得所有权问题变得更加复杂。我们的结论是,实现我们想要的东西的最好方法是使用Box 类型来管理我们的CFG。Box 是Rust提供的用于手动管理内存分配的类型。有时候,除了手动管理内存,没有其他选择。值得庆幸的是,Rust也提供了这方面的工具。
字符串操作
在C语言中操作字符串是一个潜在的缓冲区溢出和其他内存安全漏洞的雷区,Rust中的字符串操作比C语言有明显的改进。Rust可以自动管理内存的分配和删除,大大降低了缓冲区溢出、越界访问或内存泄漏的风险,并提供了比C语言标准库更多的字符串操作函数。
在Rust进行字符串操作的方式中,有很多好的想法。不太理想的是,Rust中有许多不同的字符串类型。有自有的字符串类型String,也有unicode字符串切片类型&str 。因为我们与C语言代码库对接,我们还需要处理自有的CString 类型,其借用的对应类型&CStr ,当然还有原始c_char 指针。Rust的字符串操作需要不同的字符串类型组合,而在这些不同类型之间的转换并不总是简单的。每次我想用Rust字符串做一些事情的时候,我都需要浏览很多页的文档,寻找正确的公式。
根据Stack Overflow的说法,这就是如何将C语言字符串转换为Rust字符串。
`let c_buf: *const c_char = unsafe { hello() }; let c_str: &CStr = unsafe { CStr::from_ptr(c_buf) }; let str_slice: &str = c_str.to_str().unwrap(); let str_buf: String = str_slice.to_owned();`
在C语言中做过字符串标记化和解析后,我认为Rust的方法是一种进步。然而,这绝对是一个Rust将安全性置于人体工程学和用户友好性之上的领域,这似乎是我在学习Rust时反复出现的一个主题。
处处不安全
Rust对别名、可变性和类型安全有严格的规定。这些规则的存在是为了减少崩溃、死锁和安全漏洞的风险,并使Rust编译器能够执行各种优化。当涉及到与C代码的接口或做一些JIT编译器必须做的内存操作时,这就变得棘手了。
在Rust中,对C函数的调用和对导出的C球的访问都需要用不安全块来包装。原始指针的操作也需要在不安全块中进行。不安全块就像一扇陷阱之门,在那里,Rust类型系统的规则没有被强制执行。让我们有点沮丧的是,我们需要进行大量的C语言调用,而这些调用往往与指针操作相吻合,所以我们需要将数百个简短的代码片段包裹在不安全块中。
在某些时候,到处都是不安全的块,感觉就像视觉噪音。为什么我需要把每个C函数的调用都包进不安全块呢?Rust编译器知道我在调用一个C函数,而且这个函数并不遵循Rust的类型规则。我把每个单独的C函数调用包装成一个不安全块,真的能告诉编译器什么吗?根据定义,C函数调用是 "不安全的",我不应该告诉Rust编译器这一点。每次调用C函数时都要写不安全,这似乎增加了不必要的麻烦。它不断地提醒我,Rust编译器正在默默地评判我对C语言的依赖性。
Rust提供了许多绕过类型系统的陷阱门。有一些不安全的块和不安全的整数 "as "铸型,没有边界检查。许多Rust类型,如Rc ,也有一些方法,如into_raw 和from_raw 。这可能会让人感到不一致和奇怪。一方面,你有一门有时严格得令人痛苦的类型系统的语言,以及一个对 "不正确 "的代码风格发出警告的编译器,但你也有各种各样的方法来告诉编译器拿着你的啤酒,所以你可以选择性地弯曲,并可能在你想要的时候打破编译器的安全假设。只要你不做任何不安全的事情,Rust就是完全安全的。要完全确定你没有做任何不安全的事情,需要对Rust编译器的假设有广泛的了解。如果说C++是一把电锯,那么Rust就是一把电钉枪,它配有安全护目镜和400页的安全手册,现实中,你可能并没有花时间去完全阅读。
发展空间
在我看来,Rust在很多方面都比C或C++有很大的进步。如果我面对Rust和C/C++之间的选择,我想我十有八九会选择Rust。我想使用Rust的主要原因是,我认为它能为我们提供比C语言更好的工具来管理代码的复杂性,我认为Rust在这方面做到了。我们在移植过程中遇到了一些小的挑战,但我们已经能够找到解决方案或解决这些问题。不过,在我看来,Rust仍有可以改进的地方,我希望这篇博文中的一些反馈能促使我们对该语言或其他正在开发的系统编程语言进行建设性的讨论。
需要记住的一件事是,我们在移植YJIT时面临的一些挑战,如果你从头开始一个新的Rust项目,你可能不必处理,或者可以更容易地解决这些问题。特别是,Rust和C代码之间存在着阻抗不匹配。现实上,如果你从头开始一个新的Rust项目,你可能会尝试直接与尽可能少的C代码对接,而且你会比我们现在更好地使用Rust的习性,这可能会导致更干净的代码。
Rust生态系统仍有成熟的空间。我们已经提到了我们在cargo和bindgen工具中遇到的问题。Rust中还有许多实验性的API,它们提供了各种可用性改进,但还没有标准化。特别是,我们本可以在YJIT中使用SyncLazy,但它暂时只能作为夜间构建的一部分使用。
我们花了三个月的时间完成了YJIT从C语言到Rust的移植,我们对这个结果感到非常满意。在我看来,最终的产品比原来的C语言代码库更容易维护,也更容易工作。我觉得这次移植给项目带来了新的活力,而且Rust,不管有什么缺陷,都提供了非常好的抽象和代码组织机制。在接下来的几个月里,我们将为YJIT提供新的功能和改进,我们还将花一些时间重构YJIT的源代码,使其更像Rusty的习惯,而不像C的直接翻译,这样我们就可以更好地利用这种强大的语言的优势。