用rust代替cython加速python

Python在很多情况下可以提供便捷快速的编程体验,但是同时在一些计算密集情况下无法兼顾性能。

常用的方法包括cython、使用C/C++等

Rust是一门编译型、强类型、内存安全的编程语言,在一定程度上能覆盖到C的各种领域,其优缺点简单总结为:

参考:https://cheats.rs/

> 优点
    * 编译后的代码性能与 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的基本用法:

  1. python导入的是pymodule,所有pyfunctionpyclass都需要被添加进pymodule

  2. pymodule可被Python导入的名字默认与函数名相同,除非通过添加#[pyo3(name = "xxx")]自定义

  3. pyclass用来定义一些Python可用的rust结构体,结构体成员通过#[pyo3(get, set)]获得get和set方法

  4. 通过#[pymethods]修饰结构体接口,使python端可用此方法

  5. new方法目的是允许Python端使用__new__方法创建新对象

  6. 其他的方法可以参考https://pyo3.rs/v0.14.5/class.html,有详细的说明

  7. 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"

其中所不同的是:

  1. 需要依赖:setuptools-rustmaturincffi

  2. build-system如上所示,这里build-backend = "maturin"对poetry其实是不起作用的,poetry build仍然不会调用maturin;这里的作用如文档所述“Maturin将为您的软件包构建一个源代码发行版”

  3. tool.maturin是maturin的参数,与其命令行形式下的参数一致,包括compatibility, skip-auditwheel, bindings, strip, cargo-extra-args , rustc-extra-args

1. bindings表示使用哪种类型的绑定。取值为pyo3rust-cpythoncffibin

2. compatibility只在linux平台有用,仅用来表示一种Linux标准

3. skip-auditwheel是和compatibility相关的,具体的解释可以看github页面的一段介绍

4. strip通过剥离不必要的内容,将库文件缩小

  1. 要指定编译期间需要的额外目录,可以通过添加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"

只需要以下几点:

  1. build-system和依赖应包含setuptools-rust

  2. tool.poetry.build 指定build.py文件,会在setup.py中使用

  3. 如果提示找不到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文件。

参考

  1. https://kushaldas.in/posts/writing-python-module-in-rust-using-pyo3.html

  2. https://pyo3.rs/v0.14.5/

  3. https://blog.schuetze.link/2018/07/21/a-dive-into-packaging-native-python-extensions.html

  4. https://github.com/PyO3/maturin

  5. https://docs.rs/pyo3/0.14.5/pyo3/index.html

  6. https://docs.python.org/3.8/distutils/sourcedist.html

  7. https://github.com/PyO3/setuptools-rust

  8. https://github.com/mre/hyperjson

转自:https://www.jianshu.com/p/b94a938be039

posted @ 2023-01-18 22:54  MasonLee  阅读(250)  评论(0编辑  收藏  举报