在 Python 中运行 Rust 库(使用 PyO3)
Libraries Run Rust Inside Python (With PyO3)

原始链接: https://belderbos.dev/blog/how-libraries-run-rust-inside-python/

本文介绍了如何使用 **PyO3** 和 **Maturin** 将 Rust 与 Python 桥接,并以构建高性能 JSON 解析器为例进行了说明。 集成过程分为四步:编写 Rust 代码、使用 PyO3 宏(如 `#[pyfunction]`)进行标注、使用 Maturin 编译,以及在 Python 中导入。虽然 Rust 端的解析速度通常远快于原生 Python,但作者提醒,**边界转换**是至关重要的性能瓶颈。 为了向 Python 返回结构化结果(如 JSON 树),Rust 必须将其内部数据类型(如枚举)转换为原生 Python 对象(如字典和列表)。这种序列化过程——即重构为 Python 对象树——所耗费的时间可能比实际解析过程更长。 **关键点:** 如果您要移植返回大型数据结构的代码,那么从 Rust 到 Python 的“往返”就是性能优劣的关键。不要完全序列化大型树结构,而应考虑采用诸如“基于 Rust 的惰性视图”之类的架构优化,以避免不必要的对象创建。使用 PyO3 时,请务必对接口边界进行性能分析,而不仅仅是针对算法本身。

Hacker News 的讨论强调了利用 PyO3 将 Rust 集成到 Python 项目中的增长趋势。用户指出了这一做法的几个主要优势与注意事项: * **简化分发:** 开发人员正利用 Rust 来驱动 Python 库,使得终端用户可以通过标准的 `pip` 命令安装高性能工具,而无需管理单独的可执行文件。 * **增强构建兼容性:** Maturin 和 PyO3 等工具实现了“manylinux”二进制文件创建的自动化,极大地简化了在不同 Linux 环境下的分发工作。 * **WebAssembly (WASM) 支持:** 尽管最初有人担心 Rust 扩展会破坏 Python 的 WebAssembly 兼容性 (Pyodide),但这些问题已基本得到解决。现代工作流程现已支持为 Rust 扩展编译并发布 WASM 构建版本到 PyPI,`pydantic-core` 软件包便是例证。 总的来说,人们普遍认为,Rust 扩展曾经被视为一种新奇事物,但如今已成为一种稳健且专业的解决方案,能够在提升 Python 性能的同时保持无缝的用户体验。
相关文章

原文

Every time you validate data with Pydantic v2, the data-validation library most Python apps reach for, a Rust extension does the work. Its core, pydantic-core, is built with PyO3, the same toolchain we'll use here.

This post builds that same kind of bridge, small enough to read in one sitting: a JSON parser written in Rust, exposed to Python, so you can import it like any other package. The last step, turning the Rust result into Python objects, is the one to understand before you port anything: for a parser like this, it can cost more than the parsing itself.

The four steps from Rust to import

Getting Rust code into Python takes four steps:

  1. Write a normal Rust module.
  2. Annotate it with PyO3 macros.
  3. Let maturin compile and install it.
  4. Import the result.

Rust in Python: write a Rust module, annotate it with PyO3 macros, build it with maturin, import the shared library

#[pyfunction] and #[pymodule] are the two Rust macros that do the wiring. A Rust attribute macro is close to a Python decorator: it rewrites the function it sits on, here adding the glue that lets Python call it and handles the type conversions and reference counting at the boundary.

Maturin then compiles the crate to a shared library (.so, .dylib, .dll) and drops it into your virtual environment, so import just works. I walk through this whole setup, from cargo new to the first import, in How to run Rust in Python with PyO3 and Maturin.

That first tutorial returns a single number. This one picks up where it left off, because the interesting part starts once you return a structure instead of a scalar.

The parser produces a Rust value first

The structure this parser returns is a JSON tree, and it's the running example for the rest of this post. In our Python to Rust cohort, students spend six weeks writing a JSON parser from scratch in Rust, a hand-rolled tokenizer and recursive-descent parser with no serde, then expose it to Python through PyO3. Josh's version beat CPython's C json module on real-world fixtures; Jochen's ran up to 3.5x faster than the Python version.

The public reference implementation, the clean version students start from, is the code I'll walk through here.

The parser produces a plain Rust enum. A Rust enum holds one of several shapes, and each variant can carry data, so it maps a JSON tree cleanly:

pub enum JsonValue {
    Null,
    Boolean(bool),
    Number(f64),
    String(String),
    Array(Vec<JsonValue>),
    Object(HashMap<String, JsonValue>),
}

That tree lives entirely in Rust. Python never sees it. The PyO3 layer is a thin adapter on top.

Exposing one function

Exposing a function to Python takes two lines:

#[pyfunction]
fn parse_json<'py>(py: Python<'py>, input: &str) -> PyResult<Bound<'py, PyAny>> {
    parse(input)?.into_pyobject(py)
}

For a Python reader, the signature is the most interesting part:

  • py: Python<'py> is a token representing access to the Python interpreter and is what you pass to PyO3 APIs that need access to Python objects. On traditional Python builds, this access is associated with holding the GIL. PyO3 hands it to you and you pass it along wherever you touch a Python object.
  • Bound<'py, PyAny> is a handle to a Python object of any type, the Rust side of what you'd think of as a PyObject.
  • PyResult<T> is Result<T, PyErr>: return the value, or an error PyO3 raises as a Python exception.
  • ? propagates that error. If parse fails, the function returns early and Python sees an exception; otherwise it unwraps the JsonValue and moves on.

So parse(input)? does the real work, and .into_pyobject(py) builds the Python objects the caller asked for. That last call is where the cost lives: it has to create Python objects for the nodes in the tree, and on a large document that can add up to more work than the parse itself.

The return trip is the expensive part

Here is why that conversion is not free. .into_pyobject walks the entire JsonValue tree and rebuilds it as native Python objects: a dict per object, a list per array, a float or str per leaf. You provide that translation by implementing the IntoPyObject trait, which PyO3 calls to convert a Rust value into a Python one:

impl<'py> IntoPyObject<'py> for JsonValue {
    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
        match self {
            JsonValue::Null => Ok(py.None().into_bound(py)),
            JsonValue::Number(n) => Ok(n.into_pyobject(py)?.to_owned().into_any()),
            JsonValue::Object(obj) => {
                let py_dict = PyDict::new(py);
                for (k, v) in obj {
                    py_dict.set_item(k, v.into_pyobject(py)?)?;
                }
                Ok(py_dict.into_any())
            }

        }
    }
}

A document with 100,000 values means on the order of 100,000 Python objects being created at the boundary, all after parsing is completely done. On a large document this materialization loop, not the parsing, can dominate the end-to-end time.

Errors cross the boundary the same way

The return value is not the only thing that has to translate. A parse failure is a typed Rust error, and Python wants an exception. One From impl, the trait Rust uses to convert one type into another, lets ? do the work:

impl From<JsonError> for PyErr {
    fn from(err: JsonError) -> PyErr {
        match err {
            JsonError::UnterminatedString { position } => PyValueError::new_err(
                format!("Unterminated string starting at position {position}")
            ),

        }
    }
}

Now malformed input raises a ValueError carrying the offset where parsing broke. The file-reading path gets the same treatment for free: std::io::Error already converts to the matching Python exception, so a missing path raises FileNotFoundError.

The caller gets Python semantics without the Rust layer leaking through.

What this means for your own port

If the Rust function you're porting returns a scalar, port it and move on. The boundary is usually small enough to ignore.

If it returns a large structure, the conversion is your real cost, and it is the next thing to optimize once the parser itself is fast. Preallocating the PyDict can help at the margins, but the bigger win is architectural: don't materialize the whole tree if the caller won't touch all of it. Hand back a lazy, Rust-backed view and build Python objects on demand.

So when you reach for PyO3, profile the boundary, not just the algorithm. Getting Rust to run fast is the easy half. What you build on the way out, the trip from Rust values to Python objects, is the half that decides whether the port was worth it.

联系我们 contact @ memedata.com