在 Rust 里面嵌入 python 代码

用户头像
lipi
关注
发布于: 2020 年 09 月 03 日

最近在做数据和模型处理过程中,需要提高 python 性能。用 Rust 来替代一些分布式调度和内存方面的功能,但是不可能全部用 Rust 来写,尤其是 模型部分。所以想着看看是不是可以结合。



之前也看了 Pyo3 的做法,感觉技术上可以通。最近看到一个基于 PyO3 的 Macro 封装,有点眼前一亮。如下:



fn main() {
let data = vec![(4, 3), (2, 8), (3, 1), (4, 0)];
python! {
import matplotlib.pyplot as plt
plt.plot('data)
plt.show()
}
}





原理比较简单,就是用 Macro 封装了 python! ,然后在里面写 python 代码。往下走,还需要解决很多细节问题。例如 Rust 和 Python 互通、异常处理等问题



所以我们还是有必要从 Pyo3 开始来了解原理



PyO3 和 rust-cpython 的渊源



Rust-cpython 先出现,是 daniel grunwald 发起的。他的主页这样描述自己:



I am a 31-year-old software developer. I have been programming since I was 7 years old.



在 2017 年,他在 Hacker News 上留言



rust-cpython will most likely stay abandoned; I rarely have the time (or interest) to work on it. The commit today was me just finally finishing up a change I started in February...



看来是一位任性的程序员大牛



然后一个微软的程序员 fafhrd91发起了 Pyo3 项目。值得一提的是,fafhrd91还是 Actix 的主创,Actix 是基于 Rust 的 Web 框架。fafhrd91在今年还闹了一个事,退出开源届,因为被人怼,老是用 unsafe



又一位任性的大牛



然后,Daniel 又回归了,重新开始 rust-cpython。可以看一下 rust-cpython 和 Pyo3 的更新时间线比较







上面是 rust-cpython ,下面是 Pyo3。有个挺有意思的是,Daniel Grunwald 也是 Pyo3 的 contributor。如下图





Pyo3的发展正是在 rust-cpython 没有更新之后。在 Pyo3 文档页面还有一个和 rust-cpython 的比较



PyO3 began as fork of rust-cpython when rust-cpython wasn't maintained. Over the time PyO3 has become fundamentally different from rust-cpython.



这个过程感觉挺有意思的,所以我花了一点时间八卦了一下。我们还是回到正题



Pyo3 和 Rust-cpython 的不同



在上面说的 Pyo3 的描述了两者的不同



While rust-cpython has a macro based dsl for declaring modules and classes, PyO3 uses proc macros and specialization.



in rust-cpython you always own python objects, PyO3 allows efficient borrowed objects and most APIs are available with references.



rust-cpython requires a Python parameter for constructing a PyErr。PyO3 on other hand does not require Python for constructing a PyErr



主要有三点:



  1. Pyo3 基于 Rust 宏和 specialization 实现。 Rust-cpython 基于 Macro Dsl 来申明模块和类

  2. Rust-cpython 一般 own python 的对象。而 Pyo3 允许 borrow 对象,大部分都是采用应用方式

  3. 异常处理方式的不同



根本区别上还是实现方式的不同,我们可以分别来看看它们是怎么实现的



Rust-cpython 的实现方式



先看一下 Rust-cpython 的一个例子:



use cpython::{Python, PyDict, PyResult};
fn main() {
let gil = Python::acquire_gil();
hello(gil.python()).unwrap();
}
fn hello(py: Python) -> PyResult<()> {
let sys = py.import("sys")?;
let version: String = sys.get(py, "version")?.extract(py)?;
let locals = PyDict::new(py);
locals.set_item(py, "os", py.import("os")?)?;
let user: String = py.eval("os.getenv('USER') or os.getenv('USERNAME')", None, Some(&locals))?.extract(py)?;
println!("Hello {}, I'm Python {}", user, version);
Ok(())
}



acquire gil 的操作,就是 python 的 Global Interpreter Lock。Python 的 GIL 是 CPython 实现 Python 的时候的一个设计



In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)



上面是官方的解释。也就是有一个 mutex 阻止原生线程同时访问 bytecode。



在 rust-cpython 直接使用了 cpython 的 GIL 。定义了一个 GIL State,如下:



pub struct GILGuard {
gstate: ffi::PyGILState_STATE,
// hack to opt out of Send on stable rust, which doesn't
// have negative impls
no_send: marker::PhantomData<rc::Rc<()>>,
}



Rust-Cpython 是利用 cpython 的基础库来做 FFI 调用 。 CPython 是 Python 的实现,用 C 来实现。上面的 ffi::PyGILState_Ensure 和 prepare_freethreaded_python 里面的大部分都是通过 ffi 调用 CPython 来实现的,如下:



pub fn acquire() -> GILGuard {
if !cfg!(feature = "no-auto-initialize") {
crate::pythonrun::prepare_freethreaded_python();
}
let gstate = unsafe { ffi::PyGILState_Ensure() }; // acquire GIL
GILGuard {
gstate: gstate,
no_send: marker::PhantomData,
}
}



我们来了解一下,rust-cpython 是如何调用 CPython 的



在 rust-cpython 的 Cargo 定义了两个 dependencies,如下:



pub fn prepare_freethreaded_python() {
START.call_once(|| unsafe {
if ffi::Py_IsInitialized() != 0 {
assert!(ffi::PyEval_ThreadsInitialized() != 0);
} else {
#[cfg(feature = "python27-sys")]
{
assert!(ffi::PyEval_ThreadsInitialized() == 0);
ffi::Py_InitializeEx(0);
ffi::PyEval_InitThreads();
let _thread_state = ffi::PyEval_SaveThread();
}
});
}

我们来了解一下,rust-cpython 是如何调用 CPython 的

在 rust-cpython 的 Cargo 定义了两个 dependencies,如下:



[dependencies.python27-sys]
optional = true
path = "python27-sys"
version = "0.5.0"
[dependencies.python3-sys]
optional = true
path = "python3-sys"
version = "0.5.0"

在 Cargo 文档里面有个说明



Some Cargo packages that link to system libraries have a naming convention of having a -sys suffix. Any package named foo-sys should provide two major pieces of functionality:

  • The library crate should link to the native library libfoo. This will often probe the current system for libfoo before resorting to building from source.

  • The library crate should provide declarations for functions in libfoo, but not bindings or higher-level abstractions.



例如,dependencies.python3-sys,需要 link 到原生的 cpython 库 libpython3 。rust-cpython 需要为 libpython3 定义 declaration ,这个在 rust-python 的 python3-sys 子 crate 定义了



libpython3 在哪里呢



在 builder.rs 里面有这么一段:



#[cfg(feature = "python27-sys")]
const PYTHONSYS_ENV_VAR: &'static str = "DEP_PYTHON27_PYTHON_FLAGS";
#[cfg(feature = "python3-sys")]
const PYTHONSYS_ENV_VAR: &'static str = "DEP_PYTHON3_PYTHON_FLAGS";
let flags = match env::var(PYTHONSYS_ENV_VAR) {...}



这里面 PYTHONSYS_ENV_VAR 环境变量的定义在 python3-sys 的 build.rs 定义。python3-sys 先解析 Cargo.toml 得到 python 版本,然后找到目录,获取 python 版本对应的环境变量。然后把这些环境变量赋值给 "cargo:python_flags"。然后主目录的 build.rs 设置这些 flag 到 ""cargo:rustc-cfg" ,rustc-cfg 的作用是:



The rustc-cfg instruction tells Cargo to pass the given value to the --cfg flag to the compiler. This may be used for compile-time detection of features to enable conditional compilation.



这样 rust 编译器就可以针对这些环境变量做编译器检测了



具体来说,ubuntu 操作系统的 cpython 库在 /usr/lib/x86_64-linux-gnu 目录,如下:



:/usr/lib/x86_64-linux-gnu$ ls libpy*
libpytalloc-util.cpython-38-x86-64-linux-gnu.so.2 libpython3.7m.so.1
libpytalloc-util.cpython-38-x86-64-linux-gnu.so.2.3.0 libpython3.7m.so.1.0
libpython2.7.so.1 libpython3.8.a
libpython2.7.so.1.0 libpython3.8.so
libpython3.7m.a libpython3.8.so.1
libpython3.7m.so libpython3.8.so.1.0



搞清楚了和 cpython 的绑定,我们继续来看 rust-cpython 对 GIL 的操作。

GILGUARD::acquire 里面, 通过 ffi::PyGILState_Ensure 来获得 GIL ,把状态保存到 GILGuard 。

以上就是 rust-cpython 调用 python 的过程,还有很多细节,都是通过同样的方式,直接调用 cython 的 ffi 来实现的。



Pyo3 的实现方式



看过 rust-cpython 的实现,再看 Pyo3, 在底层结构上是一样的。有几个很小的不同点:



  • 除了 cpython ,还支持 pypy

  • rust-cpython 用 macro 包住了整个 python 语言,pyo3 用 proc_macro 和 specialization 来实现

  • pyo3 不增加对引用指针的计数,PyObject 不需要 GIL 手工 Drop

  • Pyo3 因为使用 proc_macro 和 specialization ,需要 rust nightly



所以核心的差异点就是 Pyo3 使用了 proc_macro 和 specialization 来优化了 macro,能够更灵活的支持对 cpython 的调用



proc_macro



关于 proc_macro 的介绍在这篇文章 Procedural Macros for Generating Code from Attributes。里面有一段描述



which act more like functions (and are a type of procedure). Procedural macros accept some code as an input, operate on that code, and produce some code as an output rather than matching against patterns and replacing the code with other code as declarative macros do



proc_macro 类似一个函数,输入一些 code ,做一些操作,然后输出新的 code。类似这样:



use proc_macro;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {}



TokenStream 是 Tokens 的序列。Token 就是编译器的语法单元,例如关键字、常量、标记、字符串、数字、操作符、标点符号等。



上面的文章有一个 proc_macro 的例子。proc_macro 有点像 python 的注解,标记下面的代码段被吃进,然后编译的时候,编译成另外一个代码段。为了隐藏一些没必要的信息或者是重发操作。例如下面:



#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
let ast = syn::parse(input).unwrap();
impl_hello_macro(&ast)
}



定义了一个 HelloMacro,主要作用是完成 Hello Struct 的 impl ,标记了 HelloMacro 的结构,自动加上 impl



#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro();
}



这里面还有两个包,syn 和 quote。sync 把 Rust 代码从 String 解析到一个能够操作的数据结构,quota 把 syn 数据结构解析回 Rust 代码



#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
let ast = syn::parse(input).unwrap();
impl_hello_macro(&ast)
}
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}



impl_hello_macro 的 ident ,是一个 syn 的结构,如下:



DeriveInput {
ident: Ident {
ident: "Pancakes",
span: #0 bytes(95..103)
},
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}



所以在 quota! 里面,#name 就会把 syn 的 ident 的这个结构内容带进去。生成新的 Rust 代码。最后 impl_hello_macro 输出的时候,用了 stringify!(#name) ,是把一个表达式转换成 String 的宏



参考:Writing Python inside your Rust code



发布于: 2020 年 09 月 03 日 阅读数: 299
用户头像

lipi

关注

fn 2018.08.28 加入

上海,从事金融行业 喜欢 Rust、React 和 Nodejs ,对分布式计算和数据处理比较熟悉。喜欢和热爱开发的人交流。微信:lipengsh

评论

发布
暂无评论
在Rust里面嵌入python代码