【FFI】 Python 侧——用 Rust 扩展 Python 的三种方式

16 阅读 1512 字 · 约 6 分钟

Python 是数据科学最顺手的语言,但纯 Python 跑计算密集型任务慢得离谱。一个斐波那契递归,纯 Python 算 fib(40) 要几十秒。Rust 算同样的东西,毫秒级。问题是怎么让 Python 用上 Rust 的速度?FFI 提供了三条路。

一个例子看清三道门

先用 PyO3 写个最简单的 Rust 扩展:

use pyo3::prelude::*;

#[pyfunction]
fn fib(n: u32) -> u32 {
    if n <= 1 { return n }
    fib(n - 1) + fib(n - 2)
}

#[pymodule]
fn my_math(m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(fib, m)?)?;
    Ok(())
}

maturin build 编译成 .so,Python 侧直接 import my_math; my_math.fib(40)。你不用写 C、不用管 .so 怎么加载、不用处理内存管理。#[pyfunction] 宏展开后自动生成一套 Python C API 调用代码——把 Rust 的 u32 映射成 Python 的 int,把函数注册到 Python 模块里。

同样的 Rust 函数,如果走 ctypes:

import ctypes
lib = ctypes.CDLL("./libmy_math.so")
lib.fib.argtypes = [ctypes.c_uint32]
lib.fib.restype = ctypes.c_uint32
lib.fib(40)

你得手动加载 .so、手动描述函数签名(argtypes/restype)、手动确保类型匹配。写错一个参数类型,运行时崩。libffi 在底层帮你搞定调用栈,但数据的 marshal 和类型的检查全部是你自己的事。

如果走 pybind11(C++ 侧):

#include <pybind11/pybind11.h>

int fib(int n) {
    return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}

PYBIND11_MODULE(my_math, m) {
    m.def("fib", &fib, "Fibonacci function");
}

比 ctypes 清爽,编译后 Python 同样 import my_math; my_math.fib(40)。但 pybind11 只支持 C++,不支持 Rust。如果你要 Rust 的性能 + 无 GC,这条路走不通。

三条路的取舍

三条路本质上是编译期 vs 运行时的取舍:

ctypes/cffi:动态绑定,靠 libffi。不用编译任何代码,Python 运行时加载 .so 描述签名直接调。最方便,也最慢——每次调用都要 marshal 参数、解析签名。适合一次性脚本和原型验证。

pybind11:静态绑定,编译期生成 C++ Python 扩展。比 ctypes 快(省掉运行时解析签名),模板代码写起来啰嗦,且只能绑 C++。适合已有 C++ 生态的项目。

PyO3/maturin:Rust 的原生方案。#[pyfunction] 宏编译期自动展开成 Python C API 调用代码,不用手写 Py_INCREF/DECREF。比 pybind11 更进一步——Rust 的所有权和 borrow checker 在编译期就保证了内存安全,不会 double free。polars、ruff、tokenizers 这些新项目都用 PyO3,不是没道理。

选型不复杂:你的库用 C++ 写就走 pybind11,用 Rust 写就走 PyO3。ctypes 留给不想编译、只想试一把的场景。三种方案的底层都是 C ABI + libffi,只是在"编译期生成多少胶水代码"上分了高下。