让我们构建并优化一个 Rust 的 Python 扩展

文摘   2024-09-28 11:00   上海  

如果你的 Python 代码不够快,你有 许多编译语言选项来编写更快的扩展[1] 。在本文中,我们将重点关注 Rust,它具有以下优势:

  • 现代化的工具,包括一个名为  crates.io[2]  的包仓库,以及内置的构建工具 (cargo)。
  • 出色的 Python 集成和工具。用于 Python 支持的 Rust 包(它们被称为 "crates")是  PyO3[3] 。对于打包,你可以使用  setuptools-rust[4]  来集成现有的 setuptools 项目,或者对于独立扩展,你可以使用  Maturin[5]
  • 内存和线程安全,因此与 C 和 C++ 相比,更不容易崩溃或内存损坏。

具体来说,我们将:

  • 用 Python 实现一个小算法。
  • 将其重新实现为 Rust 扩展。
  • 优化 Rust 版本,使其运行更快。

近似计算唯一值的数量

作为一个激励性的例子,我们将研究计算列表中有多少个唯一值的任务。如果我们想得到一个精确的答案,这很容易实现:

def count_exact(items):
    return len(set(items))

set() 只能包含一个项目一次,所以这会去重所有项目,然后计算它们。

这个解决方案的问题是内存使用。如果我们有 10,000,000 个唯一值,我们将创建一个包含 10,000,000 个条目的集合,这将使用相当多的内存。

因此,如果我们担心内存使用,我们可以使用一种概率算法,给我们一个_近似_答案。在许多情况下,这就足够了,而且它可以使用少得多的内存。我们将使用 Chakraborty、Vinodchandran 和 Meel 的 一个非常简单的算法[6] :

import random

def count_approx_python(items):
    s = set()
    for item in items:
        if random.random() < 0.1:
            s.add(item)
    return len(s) * 10

运行一个例子

让我们看一组示例单词:

import random

words = [f"word{i}" for i in range(100_000)]
random.shuffle(words)

我们有 100,000 个不同的单词,这意味着 count_exact() 将创建一个大小为 100,000 的集合。同时,count_approx() 将有一个大小为 1739 的集合。它最终偶尔会同时有两个集合,但它仍然只会使用准确算法 3% 的内存。

我们可以比较结果:

print("Exact:", count_exact(words))
print("Approximate:", count_approx_python(words))

我连续运行了三次,得到:

Exact: 100000
Approximate: 99710
Exact: 100000
Approximate: 99880
Exact: 100000
Approximate: 100090

近似版本有所变化,但它非常接近 - 同时使用的内存要少得多。

速度比较

我们可以比较两种实现的速度:

import time

start = time.time()
count_exact(words)
print("Exact:", time.time() - start)

start = time.time()
count_approx_python(words)
print("Approximate:", time.time() - start)

这给我们:

Exact: 0.015625
Approximate: 0.078125

我们的新函数使用更少的内存,但速度慢了 5 倍。 让我们看看是否可以通过用 Rust 重写 count_approx_python() 来加速它。

加速:创建我们的 Rust 项目

使用  Maturin[4]  Python 打包工具,我们可以非常快速地创建一个新的 Rust Python 扩展,并使用  PyO3[2]  可以轻松地与 Python 对象交互。

1. 使用 Maturin 初始化项目

你可以 pipxpip install maturin,然后你可以使用 Maturin 来初始化一个全新的基于 PyO3 的项目:

$ maturin init
🤷 What kind of project do you want to create? pyo3
🤷 What's the name of your Python package? approx_count
[...] Done! New project created approx_count

这创建了我们需要的所有文件,用于一个基本的基于 Rust 的 Python 包:

approx_count/
├── Cargo.toml
├── pyproject.toml
└── src
    └── lib.rs

我们可以从一开始就 pip install 这个包,不需要进一步的设置:

$ pip install .

2. 添加依赖项

Rust 不包含内置的随机数生成库,所以我们将使用 Rust 的构建/包管理器 cargo 添加一个第三方 crate(Rust 术语中的可安装包):

$ cargo add rand
    Updating crates.io index
      Adding rand v0.8.5 to dependencies.
             Features:
             + std
             + std_rng
             + alloc
             - custom_derive
             - getrandom
             - libc
             - log
             - min_const_gen
             - packed_simd
             - serde
             - serde1
             - simd_support
    Updating crates.io index

这更新了 Cargo.toml 文件,其中列出了构建我们代码所需的 Rust 依赖项。相关部分现在看起来像这样:

[dependencies]
pyo3 = "0.19.0"
rand = "0.8.5"

当我们初始化项目模板时,Maturin 自动添加了 pyo3 依赖项。

命令输出中的功能列表是可以在编译时启用以添加更多功能的标志;我们稍后会使用其中一个。

3. 第一个 Rust 版本

现在,我们需要更新 src/lib.rs 中的 Rust 代码来实现我们的函数:

use pyo3::prelude::*;
use rand::random;

#[pyfunction]
fn count_approx_rust(items: Vec<PyObject>, py: Python<'_>) -> PyResult<usize> {
    let mut s = pyo3::types::PySet::empty(py)?;
    for item in items {
        if random::<f64>() < 0.1 {
            s.add(item)?;
        }
    }
    Ok(s.len() * 10)
}

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

4. 测量性能

我们的新版本在速度方面如何比较?

首先,我们安装我们的新包:

现在我们可以从 Python 代码中导入我们的新函数并测量其速度:

from approx_count import count_approx_rust
import time

start = time.time()
count_approx_rust(words)
print("Rust (naive):", time.time() - start)

以下是新版本的比较:

版本 耗时(秒)
Python 0.78
Rust (naive) 0.37

它的速度是两倍。为什么不更快?

这在逻辑上与 Python 实现完全相同的代码,只是用 Rust 实现,而且稍微更冗长一些。我们仍然以相同的方式与 Python 对象交互,迭代 Python 列表,并广泛与 Python 集合交互。所以代码的那部分不会有任何不同的运行方式。

让我们更快,第 2 部分:优化 Rust 代码

接下来我们将以四种不同的方式优化我们的代码,我分别测量了所有这些方式都提高了性能。

优化 1:链接时优化

首先,我们将启用链接时优化,Rust 编译器在编译过程的后期(在链接期间)优化代码。这意味着编译速度较慢,但通常会产生运行速度更快的代码。我们通过在 Cargo.toml 中添加以下内容来实现这一点:

[profile.release]
lto = true

优化 2 和 3:更快的随机数生成

我们还从使用 rand::random() (它使用线程本地随机数生成器(RNG))切换到我们自己管理的 RNG,消除了线程本地查找的开销。

同时,我们切换到使用比默认 RNG 更快的 "small" RNG;它在防御哈希拒绝服务攻击方面不太安全,但对我们的目的来说可能没问题。为此,我们在 Cargo.toml 中为 rand crate 添加 smallrng 功能:

rand = { version = "0.8.5", features = ["small_rng"] }

我们需要修改代码,我们将在下面看到。

优化 4:仅存储哈希值

最后,我们从在基于 Python 的集合中存储 Python 对象切换到仅存储 Python 对象的哈希值,该哈希值由 obj.__hash__() 计算。如果两个对象的哈希值相同会发生什么?结果将偏差 1。

我们已经在使用概率函数,所以我们已经可以接受略微错误的结果。冲突应该很少见,如果我们有许多唯一值,偶尔出现一个偏差并不重要;99712 并不比 99713 更错。

由于我们不再存储 Python 对象,我们可以切换到使用 Rust 的 std::collections::HashSet,它有一些更好的 API,可能会稍微快一些。然而,Rust HashSet 会想要对值进行哈希...而我们不想再次对它们进行哈希,它们已经是预先哈希过的。

为了避免双重哈希,我们还添加了 Rust crate nohash_hasher:

$ cargo add nohash-hasher

这将允许我们使用基于 Rust 的 HashSet,它假设它存储的值已经是哈希值,可以按原样在 HashSet 中使用,无需进一步哈希。Cargo.toml 依赖项部分现在看起来像这样:

[dependencies]
pyo3 = "0.19.0"
rand = { version = "0.8.5", features = ["small_rng"] }
nohash-hasher = "0.2.0"

我们还需要更新我们的代码来使用它,我们接下来会看到。

我们的新代码,包含所有 4 个优化

这是我们更新后的代码:

use pyo3::prelude::*;
use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng};
use std::collections::HashSet;
use nohash_hasher::IntSet;

#[pyfunction]
fn count_approx_rust(items: Vec<PyObject>, py: Python<'_>) -> PyResult<usize> {
    let mut rng = SmallRng::from_entropy();
    let mut s: IntSet<u64> = IntSet::default();
    for item in items {
        if rng.gen::<f64>() < 0.1 {
            let hash = item.as_ref(py).hash()?;
            s.insert(hash as u64);
        }
    }
    Ok(s.len() * 10)
}

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

同样,我们可以通过以下方式安装更新后的版本:

以下是我们优化版本与之前版本相比运行所需的时间:

版本 耗时(秒)
Python 0.78
Rust (naive) 0.37
Rust (optimized) 0.21

为什么这不更快,以及其他想法

为什么 Rust 代码不更快?我们最新的优化版本仍然与 Python 列表交互,即 items,并使用 Python 的 __hash__ API 来对每个对象进行哈希。这意味着我们仍然受限于这两个交互的 Python API 的速度。如果我们将项目作为 Arrow 列或整数的 NumPy 数组传入,我们可能会运行得更快。

更广泛地说,如果我们想要更快,我们还应该考虑使用不同的算法。有许多近似计数算法,我选择这个只是因为它简单易懂,而不是因为它特别快。

大局观:为什么选择 Rust?

Rust 允许我们通过给我们访问编译语言的能力来加速我们的代码。但这对 C 或 C++ 或 Cython 也是如此。

然而,与这些语言不同,Rust 有现代化的工具,内置包管理器和构建系统:

  • 添加新的依赖项就像 cargo add <cratename> 一样简单。浏览可用 crate 的好网站是  lib.rs[7]
  • 不太明显,但仍然重要:这段代码将在 macOS 和 Windows 上编译,无需额外工作。相比之下,跨平台管理 C 或 C++ 依赖项可能非常痛苦。

Rust 还有出色的 Python 集成,可以高级访问 Python API,并且有易于使用的打包工具,如 Maturin。

Rust 既可以扩展到小项目 - 如本文中所示 - 也可以扩展到更大的项目,这要归功于内存和线程安全性以及其强大的类型系统。Polars 项目有一个 330K 行的通用 Rust 核心,然后用更多的 Rust 和 Python 包装,制成 Python 扩展。

下次你考虑加速一些 Python 代码时,试试 Rust。它不是一种简单的语言,但值得你花时间学习。

参考链接

  1. 许多编译语言选项来编写更快的扩展: https://pythonspeed.com/articles/rust-cython-python-extensions/
  2. crates.io: https://crates.io/
  3. PyO3: https://pyo3.rs/
  4. setuptools-rust: https://setuptools-rust.readthedocs.io/
  5. Maturin: https://www.maturin.rs/
  6. 一个非常简单的算法: https://arxiv.org/abs/2301.10191
  7. lib.rs: https://lib.rs/

幻想发生器
图解技术本质
 最新文章