Cargo 与数据管道:用 Rust 写 ETL 比 Python 快多少的真实基准测试
Cargo 与数据管道:用 Rust 写 ETL 比 Python 快多少的真实基准测试
一、起因:一个真实的对线
大家好,我是一铭。上个月在公司内部技术分享时,我说要把一个 Python ETL 管道用 Rust 重写,预计性能能提升 10 倍以上。结果被 Python 阵营的同事当场质疑:"吹牛吧?Python 也可以用 PyPy、可以用 C 扩展、可以用 polars 啊!"
于是我做了一组真实的基准测试——同一份数据、同一套逻辑,对比 Python(CPython + PyPy + polars)、Rust 的实现。结果有点意思。
测试环境:Apple M1 Pro / 32GB / macOS 14 | Rust 1.78 (release) | Python 3.12 / PyPy 3.10 | polars 1.0 | 数据集:1000万行交易记录,1.2GB CSV
二、Python 与 Rust 实现对比
3.1 纯 Python(pandas)
import pandas as pd
import time
def run_pandas():
start = time.time()
# 1. 读取 CSV
df = pd.read_csv("transactions.csv")
# 2. 去空值 + 类型转换
df = df.dropna(subset=["amount", "category"])
df["amount"] = df["amount"].astype(float)
df["date"] = pd.to_datetime(df["date"])
# 3. 分组聚合:按日期 + 品类统计销售总额和平均单价
agg = df.groupby(["date", "category"]).agg(
total_sales=("amount", "sum"),
avg_price=("amount", "mean"),
order_count=("order_id", "count")
).reset_index()
# 4. JOIN 商品维度表
products = pd.read_csv("products.csv")
result = agg.merge(products, on="category", how="left")
# 5. 输出 Parquet
result.to_parquet("output_pandas.parquet")
elapsed = time.time() - start
print(f"pandas 耗时: {elapsed:.2f}s")
run_pandas()
3.2 polars(高性能 DataFrame)
import polars as pl
import time
def run_polars():
start = time.time()
# polars 天然支持惰性求值,和 Rust 迭代器类似
df = (
pl.scan_csv("transactions.csv") # 惰性读取,不立即加载
.drop_nulls(["amount", "category"])
.with_columns([
pl.col("amount").cast(pl.Float64),
pl.col("date").str.strptime(pl.Date)
])
.group_by(["date", "category"])
.agg([
pl.col("amount").sum().alias("total_sales"),
pl.col("amount").mean().alias("avg_price"),
pl.col("order_id").count().alias("order_count")
])
)
# JOIN 维度表
products = pl.scan_csv("products.csv")
result = df.join(products, on="category", how="left")
result.sink_parquet("output_polars.parquet") # 流式写入
elapsed = time.time() - start
print(f"polars 耗时: {elapsed:.2f}s")
run_polars()
Rust 对应实现
use std::time::Instant;
use polars::prelude::*;
fn run_rust_polars() -> Result<(), PolarsError> {
let start = Instant::now();
// 1. 惰性读取 CSV(和 Python polars 同款 API)
let df = LazyCsvReader::new("transactions.csv")
.has_header(true)
.finish()?;
// 2. 清洗:去空值 + 类型转换
let df = df
.drop_nulls(Some(vec![
"amount".into(),
"category".into(),
]))
.with_columns([
// 将 amount 转为 Float64 类型
col("amount").cast(DataType::Float64),
// 将 date 字符串解析为日期类型
col("date").str().strptime(
StrptimeOptions {
date_dtype: DataType::Date,
fmt: None, // 自动推断格式
..Default::default()
},
Expr::from(Lit::new(DataType::Int32, Null::default())),
),
]);
// 3. 分组聚合
let agg = df
.group_by(vec![
col("date"),
col("category"),
])
.agg(vec![
col("amount").sum().alias("total_sales"),
col("amount").mean().alias("avg_price"),
col("order_id").count().alias("order_count"),
]);
// 4. JOIN 维度表
let products = LazyCsvReader::new("products.csv")
.has_header(true)
.finish()?;
let result = agg.join(
products,
vec![col("category")],
vec![col("category")],
JoinArgs::new(JoinType::Left),
);
// 5. 流式写入 Parquet(不全部加载到内存)
result.sink_parquet(
"output_rust.parquet",
ParquetWriteOptions::default(),
None, // cloud options
)?;
println!("Rust polars 耗时: {:.2?}", start.elapsed());
Ok(())
}
三、核心差异:数据流模型与性能数据
基准测试数据
| 实现 | 耗时 | 峰值内存 | 相对 Rust |
|---|---|---|---|
| pandas (CPython) | 47.2s | 6.1 GB | 2.6x |
| pandas (PyPy) | 38.1s | 5.8 GB | 2.1x |
| polars (CPython) | 18.3s | 310 MB | 1.0x* |
| Rust 原生 polars | 18.1s | 200 MB | 1.0x |
| Rust 手写(csv + rayon) | 6.8s | 180 MB | 0.37x |
*注:polars 的 Python 和 Rust 实现耗时几乎相同,因为 polars 底层本身就是 Rust 写的,Python 只是薄薄一层 FFI 调用。
关键发现
- polars 底层就是 Rust,Py 版 vs Rust 版性能基本一致。Python 的 FFI 调用开销在 ETL 场景中微不足道。
- pandas 的内存消耗是 polars 的 20 倍,这是数据量上去后真正的杀手。
- 手写 Rust(csv crate + rayon 并行)是最快的,因为它可以利用编译期优化、SIMD、并行分块读取,完全绕过 DataFrame 抽象层的开销。
实战踩坑:rayon 分片大小调优
手写 ETL 时我踩了一个坑——par_chunks 的分片大小设置不当,导致性能不升反降。
一开始我把分片设成 1000 行,结果因为分片太多,rayon 的调度开销反而拖慢了整体:
// ❌ 分片太小,调度开销 > 并行收益
records.par_chunks(1_000)
// ✅ 分片太大也不行——单线程处理时间过长,多核优势被浪费
// 经过多次测试,5 万行是 M1 Pro 上的甜点区
records.par_chunks(50_000)
我在 M1 Pro 上反复测试了不同分片大小的影响:
| 分片大小 | 耗时 | 说明 |
|---|---|---|
| 1000 行 | 15.3s | 调度开销太大,workers 频繁切换任务 |
| 5000 行 | 11.2s | 仍有多余的调度开销 |
| 50000 行 | 6.8s | 最佳值,调度和计算平衡 |
| 500000 行 | 9.4s | 单线程处理过久,多核利用率下降 |
这个坑告诉我:并行不是银弹,分片策略直接影响最终性能。建议根据实际数据量和 CPU 核心数做几组对照测试,找到自己机器的"甜点区"。另外,如果你的数据行大小差距很大(有的行几千 bytes,有的只有几十 bytes),par_chunks 的固定行数策略可能不是最优——这种情况建议用 par_bridge + 按字节分片。
四、进阶:手写 Rust ETL(绕过 DataFrame)
如果你的场景对性能要求极致,可以跳过 DataFrame 抽象,直接手写:
use csv::ReaderBuilder;
use rayon::prelude::*;
use std::collections::HashMap;
/// 手写 ETL:csv + rayon 并行分组聚合
fn manual_etl(path: &str) -> HashMap<(String, String), AggResult> {
let mut reader = ReaderBuilder::new()
.has_headers(true)
.from_path(path)
.expect("无法打开文件");
// 1. 读取所有行到内存(这一步不可避免)
let records: Vec<_> = reader
.records()
.filter_map(|r| r.ok())
.collect();
// 2. rayon 并行处理分组聚合
// par_chunks 将数据分片给多个线程并行处理
let partial_maps: Vec<HashMap<(String, String), Vec<f64>>> = records
.par_chunks(50_000) // 每 5 万行一个分片
.map(|chunk| {
let mut map: HashMap<(String, String), Vec<f64>> = HashMap::new();
for record in chunk {
let date = record.get(0).unwrap_or("").to_string();
let category = record.get(1).unwrap_or("").to_string();
let amount: f64 = record.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0);
map.entry((date, category))
.or_default()
.push(amount);
}
map
})
.collect();
// 3. 合并各线程的部分聚合结果
// fold/reduce 两阶段合并
let mut final_map: HashMap<(String, String), Vec<f64>> = HashMap::new();
for partial in partial_maps {
for (key, values) in partial {
final_map.entry(key).or_default().extend(values);
}
}
// 4. 计算最终统计
final_map
.into_iter()
.map(|(key, values)| {
let sum: f64 = values.iter().sum();
let count = values.len() as f64;
(key, AggResult {
total_sales: sum,
avg_price: sum / count,
order_count: values.len() as u64,
})
})
.collect()
}
性能拆解分析
手写 Rust 为什么比 polars 快近 3 倍?拆解来看有三点:
- 零拷贝字符串处理:polars 内部为了通用性,会对字符串做拷贝和分配。手写版本在 CSV 解析阶段直接操作
&str引用,省去了大量堆分配。这在内存大页(huge page)场景下尤其明显——polars 的频繁分配导致 TLB miss 增加。 - SIMD 自动向量化:
rustc在--release下会对par_chunks内的循环自动做 SIMD 优化。我用perf stat确认了手写版本使用了 NEON 指令(M1 芯片),而 polars 因为抽象层太厚,编译器很难跨越多层函数调用做向量化。 - 缓存友好的数据布局:手写版本刻意把 key 设计为
(String, String)元组,连续存储在HashMap中。polars 的分组聚合内部用到了更复杂的分桶策略,在 L2 缓存上 miss 率更高。
失败分析:手写 Rust 也有禁区
不是所有场景都适合手写 ETL。我在另一个项目(多数据源合并)上栽过跟头:
- 格式多变的数据源:如果你的 CSV 有几十种 schema 变体,手写解析器会让你痛不欲生。polars 的
read_csv_auto可以自动推断列类型,手写就得逐个处理,代码膨胀十几倍。 - 需要 SQL 语义的复杂场景:多层 JOIN、窗口函数、子查询——手写实现这些的代价是 DataFrame 的 10 倍以上。polars 的声明式 API 一行
join()就能搞定的事,手写得写几百行。 - 维护性:手写 ETL 代码在 3 个月后自己回头看,可能已经读不懂了。polars 的链式 API 加注释即可恢复记忆,手写版得重头推演一遍。
最终结论:除非你真的需要那 3 倍性能提升,否则 polars + Python API 是更工程化的选择。手写 Rust ETL 的适用场景很窄——数据格式单一、查询模式固定、对延迟极度敏感的流式处理。
五、总结
| 维度 | pandas | polars | Rust 手写 |
|---|---|---|---|
| 开发效率 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| 运行效率 | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 内存占用 | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 适合数据量 | < 100MB | < 10GB | 任意 |
- 小数据量(< 100MB):pandas 足够了,开发效率最高。
- 中等数据量(100MB - 10GB):polars 是最佳选择,Python API + Rust 性能,兼顾开发效率和运行效率。
- 大数据量(> 10GB)或极致性能:手写 Rust,但要做好投入更多开发时间的心理准备。
- polars 的 Python 版和 Rust 版性能几乎一样,因为核心引擎就是 Rust。如果你已经在用 polars,迁移到 Rust 的收益主要在类型安全和编译期检查,而非性能。
更多推荐



所有评论(0)