用rust代替cython加速python
Python在很多情况下可以提供便捷快速的编程体验,但是同时在一些计算密集情况下无法兼顾性能。
常用的方法包括cython、使用C/C++等
Rust是一门编译型、强类型、内存安全的编程语言,在一定程度上能覆盖到C的各种领域,其优缺点简单总结为:
> 优点
* 编译后的代码性能与 C/C++相当,并且具有出色的内存和能源效率。
* 可避免的所有安全问题,70%存在于C / C ++,和大多数内存问题。
* 强类型系统可防止数据竞争,带来“无畏并发”(等等)。
* 无缝 C 互操作,以及数十种支持的平台(基于 LLVM)。
* 连续6年“最喜爱的语言”。
* 现代工具:(cargo
构建_正常工作_),clippy
(450 多个代码质量 lint),rustup
(简单的工具链管理)。
缺点
* 陡峭的学习曲线;1编译器强制执行(尤其是内存)规则,这些规则在其他地方将是“最佳实践”。
* 在某些领域、目标平台(尤其是嵌入式)、IDE 功能中缺少 Rust 原生库。1
* 比其他语言中的“类似”代码编译时间更长。1
* 没有正式的语言规范,可以阻止在某些领域(航空、医疗等)中的合法使用。
* 粗心(使用unsafe
库)会偷偷破坏安全保证。
在rust中,可以使用PyO3,通过FFI(外部函数接口)来沟通rust和Python
PyO3提供了两个层次的工具供我们使用,一个是简单零配置的maturin,另一个是与setuptools-rust
这里,我们仍然使用poetry作为python依赖的管理工具
从Python调用Rust
rust部分
要从Python调用rust代码,需要对rust代码做一些改变。
基础的,就是编写带#[pyfunction],#[pyclass], #[pymodules], #[pymethod]等宏的函数,来自作者的一个例子(lib.rs):
// lib.rs
use pyo3::prelude::*;
use pyo3::types::PyDict;
use pyo3::wrap_pyfunction;
use std::collections::HashMap;
#[pyfunction]
/// Formats the sum of two numbers as string.
fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
Ok((a + b).to_string())
}
#[pyfunction]
/// Formats the sum of two numbers as string.
fn get_result() -> PyResult<HashMap<String, String>> {
let mut result = HashMap::new();
result.insert("name".to_string(), "kushal".to_string());
result.insert("age".to_string(), "36".to_string());
Ok(result)
}
#[pyfunction]
// Returns a Person class, takes a dict with {"name": "age", "age": 100} format.
fn give_me_a_person(data: &PyDict) -> PyResult<Person> {
let name: String = data.get_item("name").unwrap().extract().unwrap();
let age: i64 = data.get_item("age").unwrap().extract().unwrap();
let p: Person = Person::new(name, age);
Ok(p)
}
#[pyclass]
#[derive(Debug)]
struct Person {
#[pyo3(get, set)]
name: String,
#[pyo3(get, set)]
age: i64,
}
#[pymethods]
impl Person {
#[new]
fn new(name: String, age: i64) -> Self {
Person { name, age }
}
}
#[pymodule]
/// A Python module implemented in Rust.
fn myfriendrust(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_wrapped(wrap_pyfunction!(sum_as_string))?;
m.add_wrapped(wrap_pyfunction!(get_result))?;
m.add_wrapped(wrap_pyfunction!(give_me_a_person))?;
m.add_class::<Person>()?;
Ok(())
}
这个例子已经比较清楚的展示了PyO3的基本用法:
-
python导入的是
pymodule
,所有pyfunction
和pyclass
都需要被添加进pymodule
-
pymodule
可被Python导入的名字默认与函数名相同,除非通过添加#[pyo3(name = "xxx")]
自定义 -
pyclass
用来定义一些Python可用的rust结构体,结构体成员通过#[pyo3(get, set)]
获得get和set方法 -
通过
#[pymethods]
修饰结构体接口,使python端可用此方法 -
new方法目的是允许Python端使用
__new__
方法创建新对象 -
其他的方法可以参考https://pyo3.rs/v0.14.5/class.html,有详细的说明
-
PyResult<>
表示Python调用的结果,失败时返回PyErr
/// Represents the result of a Python call.
pub type PyResult<T> = Result<T, PyErr>;
build和调用
在一个标准cargo项目里,直接通过cargo build --release
即可构建出一个.so文件,Python可以直接导入这个so模块
数据类型
这里总结了Python和rust的数据类型的对应关系:https://pyo3.rs/v0.14.5/conversions/tables.html
小结
从上面的例子可以看出,只要对rust做一些简单的封装即可被Python调用,还是比较简单的。
建议rust部分单独开发,Python接口部分进行单独的再封装,比如Python用到的函数,一些Python字典或者对象用pyclass重新包装。
构建和发布混合项目
使用maturin
目录结构
maturin是一个零配置的wheel发布工具,最主要的限制在于项目的目录结构是固定的,如下所示:
my-project
├── Cargo.toml
├── my_project
│ ├── __init__.py
│ └── bar.py
├── pyproject.toml
├── Readme.md
└── src
└── lib.rs
其中,Cargo.toml
在根目录下,python的pyproject.toml
也在根目录下,rust项目必须在src目录下,这是cargo的要求,而python项目应放在其他目录下,这里是my_project下。
my_project下的Python项目可以正常编写。src下应该有一个lib.rs告诉cargo这里有一个库项目供其他人调用,这也是rust的正常操作,只有lib.rs里面的rust代码才能被Python调用。
Cargo设置
[package]
name = "my-project"
version = "0.1.0"
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1"
bio = "0.37.1"
pyo3 = "0.14.5"
[lib]
name = "my_project"
# "cdylib" is necessary to produce a shared library for Python to import from.
#
# Downstream Rust code (including code in `bin/`, `examples/`, and `tests/`) will not be able
# to `use string_sum;` unless the "rlib" or "lib" crate type is also included, e.g.:
# crate-type = ["cdylib", "rlib"]
crate-type = ["cdylib"]
[features]
# We must make this feature optional to build binaries such as the profiling crate
default = ["pyo3/extension-module"]
基本的配置就是这样,需要注意的就是以下几点:
1. lib.name
应该和你的Python目录一致,或者和你的lib.rs中指定的#[pymoudle]
名一致
2. package.name
应该和你的根目录一致
3. lib.crate-type
说明你将构建的库的类型,至少应包含cdylib
,它表明你将构建一个链接到C/C++的库。如果你同时也想把他作为一个普通的rust库,也可以添加另一个flag"rlib"
,不必要时不要添加,这会增加不必要的负担。
更多的库类型还有:
lib — Generates a library kind preferred by the compiler, currently defaults to rlib.
rlib — A Rust static library.
staticlib — A native static library.
dylib — A Rust dynamic library.
cdylib — A native dynamic library.
bin — A runnable executable program.
proc-macro — Generates a format suitable for a procedural macro library that may be loaded by the compiler.
4. features启用一些pyo3的功能,extension-module
表明你要构建一个Python的模块,按照文档,这在Linux上是必须的。当在rust中调用Python时,auto-initialize是推荐选项。另外,默认情况下只有macros
被启用,这提供了包括 #[pymodule]
#[pyfunction]
的常用宏。
feature的更详细参考可以查看https://pyo3.rs/latest/features.html#features-reference
5. 如果你的Python想自定义目录,可以通过package.metadata.maturin.python-source
指定
[package.metadata.maturin]
python-source = "new_dir"
pyproject设置
因为我们仍然使用poetry做依赖管理,所以基本内容和poetry一样:
[tool.poetry]
name = "my_project"
version = "0.1.0"
description = ""
authors = ["pls <pls@pls.com>"]
[[tool.poetry.source]]
name = "china"
url = "https://mirrors.aliyun.com/pypi/simple"
default = true
[tool.poetry.dependencies]
python = "^3.8"
[tool.poetry.dev-dependencies]
pylint = "^2.10.2"
autopep8 = "^1.5.7"
maturin = "^0.11.3"
setuptools-rust = "^0.12.1"
cffi = "^1.14.6"
[tool.maturin]
bindings = "cffi"
compatiblility = "linux"
[build-system]
requires = ["poetry-core>=1.0.0", "setuptools", "wheel", "setuptools-rust", "maturin>=0.11,<0.12"]
build-backend = "maturin"
其中所不同的是:
-
需要依赖:
setuptools-rust
,maturin
,cffi
等 -
build-system
如上所示,这里build-backend = "maturin"
对poetry其实是不起作用的,poetry build仍然不会调用maturin;这里的作用如文档所述“Maturin将为您的软件包构建一个源代码发行版” -
tool.maturin
是maturin的参数,与其命令行形式下的参数一致,包括compatibility
,skip-auditwheel
,bindings
,strip
,cargo-extra-args
,rustc-extra-args
:
1. bindings
表示使用哪种类型的绑定。取值为pyo3
、rust-cpython
、cffi
和bin
2. compatibility
只在linux平台有用,仅用来表示一种Linux标准
3. skip-auditwheel
是和compatibility
相关的,具体的解释可以看github页面的一段介绍
4. strip
通过剥离不必要的内容,将库文件缩小
- 要指定编译期间需要的额外目录,可以通过添加tool.maturin.sdist-include 来指定
[tool.maturin]
sdist-include = ["path/**/*"]
构建配置
poetry不能正确使用maturin来构建和打包,为此,你需要直接在poetry虚拟环境下使用maturin命令行操作,比如:
为了方便,可以编写一个makefile简化常用的命令,比如:
# Needed SHELL since I'm using zsh
SHELL := /bin/bash
ts := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
.PHONY: help
help: ## This help message
@echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/\\x1b[36m\1\\x1b[m:\2/' | column -c2 -t -s :)"
.PHONY: build
build: ## Builds Rust code and Python modules
poetry run maturin build
.PHONY: build-release
build-release: ## Build module in release mode
poetry run maturin build --release
.PHONY: nightly
nightly: ## Set rust compiler to nightly version
rustup override set nightly
.PHONY: install
install: ## Install module into current virtualenv
poetry run maturin develop --release
.PHONY: publish
publish: ## Publish crate on Pypi
poetry run maturin publish
.PHONY: clean
clean: ## Clean up build artifacts
cargo clean
.PHONY: dev-packages
dev-packages: ## Install Python development packages for project
poetry install
.PHONY: cargo-test
cargo-test: ## Run cargo tests only
cargo test
.PHONY: test
test: cargo-test dev-packages install quicktest ## Intall module and run tests
.PHONY: quicktest
quicktest: ## Run tests on already installed module
poetry run pytest tests
这里来源于hyperjson: https://github.com/mre/hyperjson/blob/master/Makefile
直接使用setuptools-rust
maturin简化Python和rust混合项目的构建的同时,也增加了一些限制,如果需要更灵活的方式,可以直接使用setuptools-rust,就像使用cython或者pybind11一样。
示例项目
project
├── project
│ ├── utils
│ │ └── tools.py
│ │ └── __init__.py
│ └── main.py
└── rust_project
├── __init__.py
├── Cargo.toml
└── src
└── lib.rs
├── pyproject.toml
├── MANIFEST.in
├── build.py
└── Readme.md
这里把rust_project作为rust项目的目录,这样的rust项目可以有很多,这里只以一个为例。
pyproject.toml
由poetry生成或自己写。
Cargo.toml
放在对应的rust目录下。
在rust目录下放一个__init__.py
方便Python导入。
build.py
将作为setup.py的补充,其中添加rust代码的编译设置,setup.py稍后由poetry自动生成。
MANIFEST.in
用来描述要包含的源文件,发布时,将包含这些目录和文件。
Cargo.toml
Cargo.toml设置与上文相同
[package]
name = "rust_project"
version = "0.1.0"
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1"
bio = "0.37.1"
pyo3 = "0.14.5"
[lib]
name = "rust_project"
crate-type = ["cdylib"]
[features]
# We must make this feature optional to build binaries such as the profiling crate
default = ["pyo3/extension-module"]
pyproject.toml
[tool.poetry]
name = "project"
version = "0.1.0"
description = ""
authors = ["pls <pls@pls.com>"]
build = "build.py"
packages = [
{ include = "rust_project" },
{ include = "project"}
]
[[tool.poetry.source]]
name = "china"
url = "https://mirrors.aliyun.com/pypi/simple"
default = true
[tool.poetry.dependencies]
python = "^3.8"
pandas = "^1.3.2"
typer = "^0.4.0"
rich = "^10.9.0"
setuptools-rust = "^0.12.1"
cffi = "^1.14.6"
[tool.poetry.dev-dependencies]
pylint = "^2.10.2"
autopep8 = "^1.5.7"
[build-system]
requires = ["poetry-core>=1.0.0", "setuptools", "wheel", "setuptools-rust"]
build-backend = "poetry.core.masonry.api"
只需要以下几点:
-
build-system
和依赖应包含setuptools-rust
-
tool.poetry.build
指定build.py文件,会在setup.py中使用 -
如果提示找不到packages,可以通过
tools.poetry.packages
指定
build.py
from setuptools import setup
from setuptools_rust import Binding, RustExtension
from typing import Dict, Any
ext_modules = [
RustExtension("rust_project.rust_project", "rust_project/Cargo.toml", binding=Binding.PyO3)
]
def build(setup_kwargs: Dict[str, Any]) -> None:
setup_kwargs.update(
{
"rust_extensions": ext_modules,
}
)
基本上就是向setup函数添加对应的rust参数。
RustExtension
的基本参数包括,插入的lib,Cargo.toml文件位置,binding
是绑定方式。
ext_modules
列表包含你需要的所有RustExtension
,在build函数中向setup函数参数中添加。
MANIFEST.in
MANIFEST.in确保发布时包含rust代码
参见:https://packaging.python.org/guides/using-manifest-in/
例如:
include pyproject.toml
include rust_project Cargo.toml
recursive-include rust_project/src *
recursive-include project *
构建
正常使用poetry build构建即可。
注意,build时,cargo编译的是debug版本,install时才是release版本。生产环境下不要直接用build出的.so文件。