库在 Python 中运行 Rust(借助 PyO3),但边界决定速度
在一个解析器示例展示了为何原生速度本身并不能保证 Python 库更快之后,PyO3 引发了新一轮开发者讨论。
9 月 13 日的演示解释了库如何借助 PyO3 在 Python 中运行 Rust,并以一个手工构建的 JSON 解析器作为测试案例。Rust 首先解析输入内容,随后 PyO3 将得到的树转换为字典、列表、字符串、数字、布尔值和 Python 异常。
第二步由此带来了矛盾。快速的 Rust 算法可能在集成层完成工作之前就已结束。对于大型结果,将原生值转换为 Python 对象所消耗的时间,可能超过开发者原本希望加速的操作。
这并非一项新的 Python 能力,也不是 PyO3 的新版本。数十年来,CPython 一直支持原生扩展,包括使用 C、C++ 和 Fortran 编写的模块。目前的关注反映出,Rust 如何让这一旧有架构重新吸引新一代库作者。
Pydantic、Polars、cryptography 及其他项目,已经将 Rust 置于用户熟悉的 Python 接口背后。它们的成功促使维护者重新审视缓慢的内部路径,但也带来了有关打包和平台覆盖的更棘手问题。
因此,真正重要的较量并非 Rust 对阵 Python,而是原生计算对阵边界开销。这场较量决定了哪些移植能带来实质收益,哪些只是把复杂性转移进一个编译后的软件包。
库借助 PyO3 通过熟悉的导入方式在 Python 中运行 Rust
PyO3 可将编译后的 Rust 转换为 CPython 能够导入的原生扩展,同时不要求应用开发者放弃 Python 语法。
Bob Belderbos 通过一个用 Rust 编写并通过 Python 函数暴露的 JSON 解析器展示了这一路径。他的解析器讲解将流程归纳为四个阶段。
开发者编写普通的 Rust 模块,添加 PyO3 属性,使用 maturin 构建软件包,然后从 Python 导入生成的扩展。Maturin 是面向 Rust Python 模块的构建和打包工具。
编译产物是共享库中的原生机器码。根据操作系统不同,该文件通常以 .so、.dylib 或 .dll 结尾。Python 通过与旧式原生模块相同的广义扩展机制加载它。
这种表述很重要。Python 不会在运行时解释 Rust 源代码。Rust 编译器会生成机器码,而 CPython 则通过其原生应用二进制接口调用导出的函数。
PyO3 提供绑定层。其宏会生成函数调用、引用管理、参数提取、返回值和异常处理所需的大部分胶水代码。
在该演示中,#[pyfunction] 标记了一个 Python 可以调用的 Rust 函数。#[pymodule] 宏定义了 Python 在导入时初始化的扩展模块。
公开使用体验仍是普通的 Python。调用者导入一个模块,并将字符串传给解析函数。这一交互不要求调用者理解 Rust 的所有权、trait、生命周期或 Cargo。
实现则走了不同的路线。输入从 Python 字符串跨越边界,成为 Rust 字符串引用。Rust 执行解析,并构建一个表示 JSON 树的枚举。
枚举是一种 Rust 类型,可以持有若干预定义变体中的一种。在这个案例中,这些变体表示 null、布尔值、数字、字符串、数组和对象。
得到的树最初完全属于 Rust。Python 无法直接使用该结构,因为其解释器需要由 Python 内存和类型系统管理的对象。
PyO3 的官方指南介绍了该项目支持的两个方向。开发者可以用 Rust 创建 Python 模块,或在 Rust 应用程序中嵌入 Python 解释器。
第一个方向推动了这个具体案例。它让维护者可以保留面向 Python 的接口,同时将选定工作迁移到编译后的代码中。
这一模式在 Python 生态系统中已很常见。NumPy 通过以原生计算为后端、提供便捷 Python 操作,确立了这一更广泛的模式。PyO3 改变的是用于构建扩展的语言和工具链,而不是根本架构。
最新讨论之所以重要,是因为它让隐藏的边界变得可见。值得关注的并不是 Python 突然学会执行原生代码,而是更多维护者如今可以在不手工编写每一层 CPython 胶水代码的情况下构建这些扩展。
更低的实现门槛扩大了值得考虑进行原生移植的函数范围。它并未消除测量完整调用的必要性,包括所有进入和离开 Rust 的内容。
Python 维护者面临将热点路径迁移至 Rust 的压力
成功的 Rust 驱动软件包已将原生扩展从专家技术变为主流 Python 项目可信的维护策略。
Pydantic 提供了最清晰的参照点。其第二个主要版本将验证功能迁移至 pydantic-core,这是一个使用 Rust 实现的独立软件包。
该项目早期的Pydantic V2 设计说明称,重写后的核心相比第一版实现了显著性能提升。这些数据来自 Pydantic 自身的预发布基准测试,因此结果仍取决于工作负载。
架构信号比单个基准测试更重要。Python 代码定义模型并生成模式,Rust 核心则在对性能敏感的路径上执行验证和序列化。
Polars 采用了同一模式的更广泛版本。其核心使用 Rust 编写,并通过包括 Python 在内的多种语言接口提供能力。
该项目的Polars 架构将查询规划、列式操作和并行执行紧贴原生数据结构。Python 用户仍然编写表达式,并获得熟悉的高层结果。
这些项目给解析器、验证器、分词器、压缩工具、数据库客户端和数据引擎的维护者带来了压力。用户如今知道,Python 软件包可以保留易用接口,同时替换部分内部实现。
这种压力并不只是关乎基准排名。对于定义明确的操作,原生代码可以减少 CPU 时间、提升吞吐量,并使内存使用更可预测。
Rust 对维护者还有另一项吸引力。其所有权和类型系统能在编译期间捕获某些类别的内存错误,尽管不安全代码和依赖缺陷仍有可能存在。
PyO3 还提供许多常见 Python 与 Rust 类型之间的转换。它会将 Rust 错误转换为 Python 异常,并参与 Python 的引用管理。
这套工具可以让扩展比手写的 C 接口更易维护。它不会让原生集成自动完成,但会减少所需自定义机制的数量。
维护者因而面临不同的自建与采用决策。此前,一个缓慢的 Python 函数可能仍会留在 Python 中,因为原生重写需要稀缺的 C 专业能力。
如今,具备 Rust 经验的团队可以通过 PyO3 暴露一个更小的原生核心。Maturin 随后可以构建该扩展,并将其放入 Python 软件包中。
当一个狭窄函数处理紧凑输入、执行大量计算并返回紧凑结果时,这一路线最为适用。压缩、哈希、解析摘要、验证和数值内核通常符合这一形态。
当函数反复跨越语言边界时,其表现就不那么可预测。大量微小调用可能将时间耗费在参数检查、分派、分配和转换上。
大型结果也会带来相关问题。算法可能在 Rust 中高效执行,但调用者仍期待普通 Python 对象。
这种期待使数据表示成为决定因素。一个将列式缓冲区保留在原生内存中的库,与返回数千个嵌套字典的解析器相比,成本结构截然不同。
因此,维护者正被推动去做架构决策,而不仅仅是语言重写。他们必须决定数据由哪一侧拥有,以及 Python 对象应在何时存在。
这种压力将持续下去,因为用户比较的是端到端行为。他们关心的是调用函数到收到可用结果之间的时间,而不是其内部循环的孤立速度。
返回过程可能抹去原生速度收益
核心机制是对象具体化:将大型 Rust 结果转换为 Python 对象,可能主导整个已完成操作的耗时。
Belderbos 的解析器首先创建一棵 Rust 树,其中包含 JSON 文档中的每个值。该阶段可以受益于 Rust 的编译执行方式和明确的内存模型。
下一阶段会再次遍历整棵树。每个 Rust 对象变为 Python 字典,每个数组变为列表,每个叶节点变为 Python 值。
这个过程称为具体化。它会创建调用者预期检查、修改、序列化或传递给其他位置的具体 Python 对象。
PyO3 的 IntoPyObject trait 协调这一转换。trait 定义了类型可以实现的行为,使每个 JSON 变体能够描述其对应的 Python 表示形式。
转换是递归的。一个对象需要一个新字典,再为每个键和值创建条目。一个数组需要一个包含已转换子项的列表。
每个 Python 对象还会进入 CPython 的内存管理系统。分配、类型元数据和引用计数都带来了 Rust 树中不存在的成本。
传统 CPython 构建版本还增加了一项限制。触及 Python 对象的代码通常需要与全局解释器锁(GIL)关联的解释器访问权限。
GIL 一次只允许一个线程驱动传统 Python 解释器。脱离 Python 对象的 Rust 工作可以在不持有该锁的情况下运行。
PyO3 记录了一个 Python::detach 机制,可在 Rust 执行独立计算时释放解释器访问权限。其并行执行指南说明,其他 Python 线程随后可以继续运行。
这一技术仅在 Rust 操作远离 Python 对象时才有帮助。重新构建 Python 字典或列表会使执行回到解释器边界。
该解析器示例估计,包含 100,000 个值的文档大约需要同等数量级的 Python 对象创建。这是一种说明性关系,而非通用性能基准。
形状与大小同样重要。扁平的数值缓冲区有时可以通过共享表示跨越边界。深度嵌套的文档则需要更多对象分配和指针遍历。
调用频率构成另一个维度。一次处理大型连续缓冲区的原生调用可以摊薄其设置成本。数千次处理单个值的调用通常做不到。
错误处理同样跨越边界。一个 Rust 解析错误需要转换为具有预期类别、消息和位置信息的 Python 异常。
PyO3 可以将有类型的 Rust 错误映射为 PyErr。因此,格式错误的字符串可以在 Python 调用者看来表现为 ValueError,而不可用文件则可以变为 FileNotFoundError。
这种转换很有价值,因为它保护了公开 API。用户不应仅仅因为维护者更换了实现语言,就需要面对一套不同的错误模型。
不过,保留 Python 语义也意味着额外工作。原生重写必须复现边界情况、异常类型、迭代行为、所有权规则,有时还包括子类之间的交互。
因此,真正需要优化的是完整接口。当表示形式的转换保持不变且主导整体耗时时,仅仅提升解析速度远远不够。
库可以在架构上采用多种应对方式:返回更小的摘要、暴露迭代器、批量处理回调,或让一个不透明的 Rust 支持对象持续存活。
惰性视图对于大型树结构尤为相关。扩展不必立即构造每一个 Python 对象,而是只在调用方请求时才实例化相应的值。
当应用程序仅读取结果的一小部分时,这种设计能够减少不必要的转换。但它也会将复杂性转移到对象生命周期、缓存、变更和 API 设计中。
列式库可以通过将数据保留在连续的原生缓冲区中,避免部分开销。Python 接收到的是引用底层存储的轻量对象,而不是复制每个元素。
这也解释了为何 Polars 的原生架构比机械式逐函数重写更具优势。它的 Python 接口控制着一个 Rust 引擎,将大量计算和数据维持在一起。
返回普通字典的解析器则面临更不利的边界。其输出格式要求扩展创建 Python 代码所期待的完整对象图。
关键不在于 PyO3 的转换特别低效。任何外部函数接口都必须在两端之间协调表示形式、生命周期、错误与所有权。
PyO3 让这些责任更易于表达,但无法消除其成本。
Rust 与 Python 是合作伙伴,但打包会算总账
快速扩展会带来分发义务,因为编译后的 wheel 必须匹配操作系统、处理器、解释器和二进制接口。
纯 Python 包拥有巨大的可移植性优势。一个通用 wheel 往往就能跨操作系统和处理器架构运行。
编译型扩展会生成平台特定的机器码。包维护者必须提供兼容的构件,或者要求用户在本地编译项目。
wheel 是 Python 的二进制分发格式。它包含可安装的软件包文件,并带有解释器、应用二进制接口和平台的兼容性标签。
Python 打包指南介绍了默认矩阵。维护者通常需要覆盖 Python 版本、操作系统和架构的构建。
持续集成可以自动化其中的大部分工作。Maturin 及相关工具可以构建和发布 wheel,而 cibuildwheel 等项目则协调不同目标环境下的构建。
自动化并不能免除测试。wheel 可能能够成功安装,却因不受支持的 CPU 特性、缺失的系统库或不兼容的运行时预期而失败。
Linux 尤为复杂,因为不同发行版提供的系统组件版本不同。Manylinux 规范为项目提供了标准化构建环境,用于生成广泛兼容的 wheel。
Windows 和 macOS 需要各自的构件。Apple 在 Intel 与 Arm 硬件之间的分化又增加了一个维度,尽管通用二进制文件有时可以合并多种架构。
稳定 ABI 可以减少解释器版本这一维度。ABI 是控制编译代码如何调用二进制运行时的底层契约。
CPython 最初的 abi3 稳定 ABI 允许符合条件的扩展针对多个 Python 3 版本,为每个平台和架构只发布一个 wheel。扩展必须限制自己使用 Limited API。
这种限制以部分 API 访问能力和潜在优化空间为代价,换取更广泛的兼容性。PyO3 支持在构建 abi3 扩展时选择合适的最低 Python 版本。
随着自由线程 CPython 的出现,打包问题再次演变。自由线程构建移除了传统的 GIL 配置,改变了原生扩展此前所依赖的假设。
当前 PyO3 文档区分了传统 abi3 wheel 与面向自由线程构建的新稳定 ABI 路径。维护者必须验证其构件实际支持哪些解释器配置。
围绕该解析器文章的公开讨论很快就出现了平台问题。开发者询问:基于 Rust 的依赖是否仍能在 Python 可以运行的所有地方工作。
简短回答是否定的。编译型依赖只能在维护者发布兼容 wheel 的地方运行,或者由用户通过受支持的工具链自行构建。
这种限制同样适用于其他语言编写的原生扩展。Rust 改变了可用的编译器目标和构建依赖,但并未消除基本的可移植性问题。
cryptography 包说明了用户体验。其文档称,大多数用户会获得预构建 wheel,无需安装 Rust。
不在已发布 wheel 覆盖范围内的用户,可能需要 Rust 编译器和其他原生依赖。对于原本以为 pip install 与语言无关的人来说,这种后备方案可能令人意外。
浏览器托管的 Python 则是另一个边界案例。Pyodide 通过 WebAssembly 运行 Python,因此传统桌面和服务器 wheel 并不能自动在其中工作。
生态系统已在含 Rust 包的 WebAssembly 构建方面取得进展。根据 Hacker News 讨论中提到的链接,Pydantic-core 现已提供相关构件。
不过,WebAssembly 支持需要有意识地进行打包与测试。输入和输出行为还可能受到浏览器在线程、文件、套接字和异步运行时方面限制的影响。
移动系统、嵌入式环境、非主流处理器和较旧的企业发行版也会带来类似压力。纯 Python 后备实现可以保障覆盖面,但维护两套实现会增加工作量。
因此,库团队必须将可移植性成本计入每一次原生重写。代码路径或许更快,但项目也会更难分发给完整的用户群。
对于热门包而言,这一取舍可能值得接受。庞大的贡献者群体和成熟的发布流水线能够支持广泛的 wheel 矩阵。
较小项目面临的则是另一种计算。原生构建可能让一个小型库变成跨多个环境的发布工程承诺。
PyO3 和 maturin 大幅降低了这一承诺,但并未将其抹去;每一个未构建的目标平台,都会以安装失败的形式被用户感知。
怀疑论的起点是端到端测量
“用 Rust 编写”是一个实现事实,而不是性能结果;维护者需要包含转换和消费过程的基准测试。
与等效 Python 循环相比,Rust 往往能提升 CPU 密集型代码的速度。但这种比较本身几乎无法说明应用程序完整工作负载的表现。
一次扩展调用包括参数转换、验证、原生调度、计算、输出转换、内存分配和错误处理。调用方随后还可能再次转换结果。
有价值的基准测试应覆盖完整路径:从应用程序所用的表示形式开始,到下一阶段可直接消费的数据结束。
微基准测试仍然有其作用。它们能够识别耗时阶段,并揭示算法是否在代码修改后得到改进。
但当团队只展示最快的内部阶段时,它们就会产生误导。解析器吞吐量数据可能掩盖之后构建大型 Python 对象图的成本。
基准测试也需要具有代表性的输入。小型文档可能夸大固定调用开销,而统一的合成数据则可能遗漏生产环境中的分配模式。
热缓存、发布构建、编译器标志和 CPU 特性都会改变结果。比较时应明确展示这些条件,并测试相同的公开行为。
正确性同样应被赋予同等权重。解析器和验证器会遇到格式错误的编码、极深嵌套、重复键、数值边界情况以及意外的资源消耗。
如果移植版本改变了异常行为或接受了不同输入,它之所以更快,可能是因为它做的是不同的工作。兼容性测试必须与性能测试并行进行。
内存消耗可能会逆转表面上的优势。同时持有完整 Rust 树和新实例化的 Python 树,可能暂时需要两套表示形式。
流式或惰性设计能够减少这种重复,但也可能改变错误发生的时机,以及原生缓冲区保持分配状态的时长。
并发方面的主张同样需要谨慎看待。Rust 代码可以使用多线程,PyO3 也可以在合适的原生工作期间释放解释器访问权。
扩展无法从任意原生线程安全地操作普通 Python 对象。每当与这些对象交互时,它都必须回到 PyO3 的解释器规则之内。
自由线程 Python 改变了其中一部分情况,但并未消除应用程序数据的同步需求。线程安全仍是库需要明确承担的责任。
安全性同样无法被简单口号概括。Rust 的安全子集能够防止若干类内存误用,但原生扩展可能包含 unsafe 代码块和存在漏洞的依赖。
编译代码中的缺陷可能导致解释器进程崩溃,而不是抛出普通 Python 异常。这一后果适用于各种原生扩展语言。
因此,PyO3 最有力的理由是具体而明确的:当计算和数据布局足以证明跨边界的合理性时,它让维护者能够将 Python 接口与 Rust 实现结合起来。
最薄弱的理由则是表面性的重写。当一个函数执行的工作有限,或绝大部分时间花在 I/O 上时,用原生机制替换简洁的 Python 代码几乎没有价值。
项目应首先对现有应用程序进行性能分析。它应识别稳定的热点路径、定义兼容性要求,并衡量交换值的规模和形态。
随后,团队可以对一个边界进行原型验证。如果转换占据主导地位,下一个决策应关注数据表示形式,而非解析器指令或编译器优化。
这种怀疑性测试并非反对基于 Rust 的 Python。它保护这种方法,使其不会沦为每一种性能抱怨的默认答案。
成功的原生扩展会将其实现细节隐藏在普通用户视野之外。安装能够正常完成,异常仍可识别,类型提示依然有用,且真实性能工作负载得到提升。
用户不应需要为语言选择而欢呼。他们只需注意到,原有的 Python 操作更快完成了。
三个信号将显示 PyO3 的下一步走向
下一阶段取决于边界感知型 API、更广泛的 wheel 覆盖,以及同时适用于传统和自由线程 Python 的扩展。
第一个信号是公开基准测试的转变。更多项目应报告包含对象创建在内的端到端耗时,而不仅是原生内核吞吐量。
这种转变将强化 PyO3 的价值,因为它会将语言选择与可观察的应用程序结果联系起来。它也会暴露那些在转换过程中消失的移植收益。
关注比较多种返回设计的基准测试。原生支持的视图、迭代器、批量结果和完全实例化的字典,可能产生截然不同的结果。
内存分析结果应与耗时结果一同出现。它们能够显示扩展是否会短暂地同时持有重复的 Rust 和 Python 结构。
第二个信号是更广泛的 wheel 覆盖。成功的项目将为主流操作系统、处理器系列和受支持的 Python 版本发布可靠构件。
WebAssembly 值得特别关注。更完善的工具链,以及更多托管在 PyPI 上的 WebAssembly wheel,将有助于降低浏览器端 Python 用户提出的可移植性顾虑。
移动端和较少见的 Linux 架构仍是有价值的测试场景。每增加一个受支持目标,都会拓展普通 Python 依赖在实践中的含义。
项目也应记录其从源码构建的流程。当错误信息、编译器要求和可复现的构建说明足够清晰时,缺少 wheel 的影响就会小得多。
如果原生包反复放弃纯 Python 版本原本支持的目标平台,可移植性的担忧就会加剧;如果构建自动化能够弥补这些缺口,这种担忧便会减弱。
第三个信号是对自由线程 CPython 的稳定支持。扩展必须调整其对解释器锁、共享状态和对象访问的既有假设。
PyO3 持续演进的 API 与稳定 ABI 支持,将塑造这一转型。维护者需要的不只是成功编译;他们还需要在真实工作负载下进行并发测试。
成熟的结果应当让一个项目能够同时支持传统解释器和自由线程解释器,而不会令发布复杂度失控地增加。
失败则会呈现另一种面貌:碎片化的 wheel 集合、不清晰的 ABI 标签,或隐藏的串行化瓶颈,都会让 Rust 支持的包更难成为值得信赖的默认依赖。
整体方向依然令人信服。Python 提供庞大的用户基础、易读的编排能力和高效的应用层;Rust 提供编译型内核、明确的数据结构和更安全的原生工具链。
不过,只有当两者之间的边界也成为设计的一部分,这种合作才能成功。库通过 PyO3 在 Python 中运行 Rust,但用户实际使用的是 Python 对象和 Python 包产物。
评估迁移方案的开发者应先从一个具体问题开始:快速的 Rust 函数返回后,究竟有哪些内容必须跨越这条边界?
在决定重写之前,先分析这条返回路径的性能。对每个承诺支持的目标平台测试 wheel,然后将完整操作与用户已经依赖的 Python 实现进行比较。
如果原生版本依然胜出,PyO3 就证明了它的价值;如果转换过程吞噬了性能收益,应先调整接口,再修改更多代码。



