引言
Python 以简洁优雅著称,但在性能方面一直被人诟病。面对大量数据运算时,大多数开发者会选择 NumPy、pandas 或 Polars 等成熟库。然而,当你的场景是"对小型字典进行海量逐元素运算"时,这些重量级库的开销反而成了瓶颈。
最近,一个名为 Redbear 的小型开源库用 Rust + 数据导向设计(Data-Oriented Design)的思路,在特定场景下跑出了比 NumPy 快 2~4 倍的成绩:
本文将带你复盘 Redbear 的整个优化之旅——从最初比纯 Python 还慢 3~5 倍,到最终全面超越 NumPy。如果你正在学习 Rust,这篇文章能让你看到 PyO3、Arc 智能指针、Vec 替代 HashMap 等实战技巧是如何一步步榨干性能的。
一、为什么不能直接用 Rust 替换 Python?
很多人的第一直觉是:把 Python 字典操作直接翻译成 Rust 的 HashMap,速度不就起飞了吗?
现实很骨感。借助 PyO3 库,我们确实可以轻松地将 Rust 函数暴露给 Python 调用。比如一个简单的标量加法:
use std::collections::HashMap;
use pyo3::prelude::*;
// 将 Rust 函数导出为 Python 函数
#[pyfunction]
fn add_scalar(d: HashMap<String, f64>, value: f64) -> HashMap<String, f64> {
// 遍历字典,将每个值加上 value 后返回新字典
d.into_iter().map(|(k, v)| (k, v + value)).collect()
}
两个字典相加也类似:
// 两个字典逐键相加,缺失键使用 fill 默认值
#[pyfunction]
#[pyo3(signature = (d1, d2, fill=0.0))]
fn add(d1: HashMap<String, f64>, d2: HashMap<String, f64>, fill: f64) -> HashMap<String, f64> {
d1.iter()
.map(|(k, v)| (k.clone(), v + d2.get(k).unwrap_or(&fill)))
.collect()
}
看起来很清爽,但跑起来却比纯 Python 实现的 Blackbear 库慢了 3~5 倍:
问题的根源在于数据转换的开销。 每次函数调用都要把 Python 字典转成 Rust HashMap,运算完再转回 Python 字典。当你要执行几十万次操作时,这个来回拷贝的代价远超 Rust 本身带来的计算提速。
二、第一次优化:用专用数据结构消除重复转换
既然每次转换都很昂贵,那就只转换一次。NumPy、pandas、Polars 都是这么做的——它们有自己的内部数据结构,数据进来后一直待在高效的内部表示中,直到用户需要结果时才转回 Python。
我们也为 Redbear 创建一个 RedDict 类:
use std::collections::HashMap;
use std::sync::Arc;
use pyo3::prelude::*;
// 用 pyclass 宏将 Rust 结构体导出为 Python 类
#[pyclass]
struct RedDict {
values: HashMap<String, f64>,
}
#[pymethods]
impl RedDict {
// new 宏对应 Python 的 __init__ 方法
#[new]
fn new(dict: HashMap<String, f64>) -> Self {
Self { values: dict }
}
// 两个 RedDict 相加,避免了重复的 Python ↔ Rust 数据转换
#[pyo3(signature = (other, fill=0.0))]
fn add(&self, other: &Self, fill: f64) -> Self {
let values = self
.values
.iter()
.map(|(k, v)| (k.clone(), v + other.values.get(k).unwrap_or(&fill)))
.collect();
Self { values }
}
}
这样在 Python 端就可以链式调用了:
# 数据只在创建时转换一次,后续运算全部在 Rust 侧完成
result = RedDict(py_dict).add_scalar(2).multiply_scalar(10)
效果立竿见影——从比 Blackbear 慢 3 倍,一下追平甚至小幅超越:
三、第二次优化:Arc 智能指针 + 就地修改
上面的实现虽然干净,但 .collect() 构建新 HashMap 时需要不断扩容,性能并不理想。更好的方式是:先克隆结构,再就地修改值。
关键技巧有两个:
- 1. 用
Arc(原子引用计数智能指针)包裹 HashMap,这样克隆时只拷贝指针,不拷贝数据。 - 2. 用
Arc::make_mut 实现写时复制(Copy-on-Write),只在真正需要修改时才深拷贝。
#[pyclass]
struct RedDict {
// Arc 包裹,克隆时只增加引用计数
values: Arc<HashMap<String, f64>>,
}
#[pymethods]
impl RedDict {
#[pyo3(signature = (other, fill=0.0))]
fn add(&self, other: &Self, fill: f64) -> Self {
// 克隆 Arc 指针(廉价操作)
let mut values = self.values.clone();
// make_mut 实现写时复制:如果只有一个引用则直接修改,否则先深拷贝
Arc::make_mut(&mut values)
.iter_mut()
.for_each(|(key, val)| {
*val += other.values.get(key).unwrap_or(&fill)
});
Self { values }
}
}
这个看似很小的改动直接让吞吐量翻倍,在小集合上已经超越了 NumPy:
四、第三次优化:抛弃 HashMap,拥抱 Vec
HashMap 擅长随机访问,但我们的操作全部是逐元素的——根本不需要按键查找。对于逐元素操作,Vec(连续内存数组)才是性能之王。CPU 的缓存预取、SIMD 指令等硬件优化都是为连续内存设计的。
核心思路:把键和值分离存储——键到索引的映射用 HashMap,值用 Vec 紧密排列。
#[pyclass]
#[derive(Clone)]
struct RedDict {
/// 键 → 值数组索引的映射
index: Arc<HashMap<String, usize>>,
/// 紧密排列的浮点值数组
values: Arc<Vec<f64>>,
}
#[pymethods]
impl RedDict {
#[new]
fn new(dict: HashMap<String, f64>) -> Self {
// 预分配容量,避免扩容开销
let mut values = Vec::with_capacity(dict.len());
let mut index = HashMap::with_capacity(dict.len());
// 构建索引映射和值数组
for (pos, (k, v)) in dict.into_iter().enumerate() {
values.push(v);
index.insert(k, pos);
}
Self {
index: Arc::new(index),
values: Arc::new(values),
}
}
}
这个布局还释放了 Arc 的真正威力——由于逐元素运算不会改变键的集合,index 的 Arc 永远只需要克隆指针,不需要深拷贝。
对应的 add 方法需要通过索引来对齐两个字典的值:
#[pymethods]
impl RedDict {
#[pyo3(signature = (other, fill=0.0))]
fn add(&self, other: Self, fill: f64) -> Self {
let mut new = self.clone();
let new_vals = Arc::make_mut(&mut new.values);
// 遍历自身索引,从 other 中查找对应位置的值
for (key, &i) in self.index.iter() {
let rhs = other
.index
.get(key) // 查找键在 other 中的索引
.map(|&j| other.values[j]) // 用索引直接访问值
.unwrap_or(fill); // 找不到则用默认值
new_vals[i] += rhs;
}
new
}
}
五、终极优化:共享索引时跳过所有查找
实际使用中,很多运算都是基于同一个原始字典的衍生结果之间进行的:
rd = RedDict(py_dict)
# rd 和 rd.add_scalar(2) 共享同一个 index
rd2 = rd.add_scalar(2).add(rd)
既然 self 和 other 共享同一个 index(同一个 Arc 指针),那值数组的顺序一定完全一致,根本不需要任何键查找,直接 zip 两个 Vec 逐元素相加即可!
#[pyo3(signature = (other, fill=0.0))]
fn add(&self, other: Self, fill: f64) -> Self {
let mut new = self.clone();
let new_vals = Arc::make_mut(&mut new.values);
if new.index == other.index {
// 快速路径:索引相同,直接 zip 遍历,零查找开销
for (lhs, rhs) in new_vals.iter_mut().zip(other.values.iter()) {
*lhs += rhs;
}
} else {
// 慢速路径:索引不同,需要逐键查找
for (key, &i) in self.index.iter() {
let rhs = other
.index
.get(key)
.map(|&j| other.values[j])
.unwrap_or(fill);
new_vals[i] += rhs;
}
}
new
}
至此,Redbear 全面超越 NumPy:
六、优化历程回顾
让我们用一张表回顾整个优化过程(以 1000000 × 5 次操作、集合大小 10 为例):
从 10.931 秒到 0.583 秒,提速近 19 倍,最终代码不到 50 行。
总结
Redbear 这个案例完美诠释了数据导向设计的威力。整个优化过程中用到的核心思想可以归纳为以下几点:
减少跨语言边界的数据转换。 用专用结构体(pyclass)将数据保留在 Rust 侧,只在输入和输出时转换一次。这是所有 Rust-Python 混合编程的首要原则。
选择适合访问模式的数据结构。 HashMap 适合随机访问,但逐元素操作用 Vec 性能更好。把键和值分离后,运算部分只操作连续内存,充分利用 CPU 缓存。
利用智能指针实现廉价克隆。Arc 让不可变部分(索引映射)的"克隆"退化为指针拷贝,只有值数组通过 Arc::make_mut 进行写时复制。
根据运行时条件选择快慢路径。 当两个 RedDict 共享同一个索引时,跳过所有键查找,直接 zip 遍历值数组。这一个判断就带来了数量级的提升。
当然,作者也坦诚地指出:Redbear 只在小型字典 + 大量逐元素运算这个狭窄场景下有优势。如果你面对的是通用数据分析任务,NumPy、pandas、Polars 仍然是不二之选。但对于学习 Rust 性能优化和 PyO3 实战来说,这个不到 50 行代码的项目堪称教科书级别的案例。
参考文章
1 Fast Python with Rust: a data-oriented approach:https://hackeryarn.com/post/fast-python-with-rust/
书籍推荐
这本《Rust权威指南》中文版第二版是由 Rust 核心开发团队编写的权威学习资料,由中国 Rust 社区成员翻译。它适合所有希望评估、入门、提高和研究 Rust 语言的软件开发人员,被视为 Rust 开发工作的必读书目。
本书由浅入深地介绍了 Rust 语言的基础概念到独有实用工具的方方面面,涵盖了所有权、trait、生命周期、安全保证等高级概念,以及模式匹配、错误处理、包管理、函数式特性和并发机制等实用工具。书中包含三个完整的项目开发实战案例,手把手教读者从零开始开发 Rust 实践项目。
特别值得注意的是,本书已更新至 Rust 2021 版本内容,既能满足初学者系统学习需求,也适合有经验的开发者作为参考指南,是构建扎实 Rust 技能的最佳入口。