用 Rust Polars 替代 Python pandas 处理数据,实测性能提升 20 倍。不是换电脑,只是换了个库。
为什么 Python 跑数据会慢?
首先需要明确,Python 本身并非不好。它开发速度快、易于上手、生态丰富,是数据科学领域的主力。但其设计初衷并非追求极致运行速度。
我们可以把 Python pandas 想象成一个勤恳的厨师,每道菜都亲力亲为:切菜、炒菜、装盘,严格按照顺序来。问题在于,当餐厅高峰期需要一小时出500道菜时,一个人慢慢炒就会导致整个后厨堵塞。具体来说,pandas 存在几个明显的性能瓶颈:
- 解释执行:Python 代码是逐行解释执行的,而非预先编译,这导致了固有的效率损失。
- 对象开销:pandas 的每个单元格都是一个独立的 Python 对象,大量的内存和 CPU 周期都消耗在处理这些“包装盒”上。
- 单核限制:默认情况下,pandas 仅使用单个 CPU 核心进行计算,其他核心处于闲置状态。
- 内存不友好:数据在内存中的布局可能较为分散,无法充分利用 CPU 的高速缓存。
而 Polars 采用 Rust 编写,作为编译型语言,天生具有速度优势。更重要的是,它采用了截然不同的数据处理架构。
Polars 为什么能快 20 倍?
沿用厨房的比喻,Polars 不是一个人在战斗,而是一条高效的流水线——切菜、炒菜、装盘等工序可以并行。每个环节都配备了专业工具:工业级切菜机、商用灶台、自动装盘器。从技术层面看,Polars 的成功得益于以下几点:
1. 列式存储
传统的 pandas 采用“行式存储”,类似于 Excel 表格,一行就是一条完整记录。Polars 则使用“列式存储”,将同一列的数据连续地存放在内存中。

图:列式存储(左)与行式存储(右)的内存布局示意图
这种方式为何更快?因为现代 CPU 极其擅长处理连续的内存块。就像阅读一本书,连续读完100页远比在不同页码间来回翻找100次要快得多。对数据分析中常见的聚合操作(如求和、求平均值),列式存储能极大减少数据访问量。
2. 懒执行 + 查询优化
Polars 提供“懒评估”(lazy)模式。它不会立即执行你的每一个指令,而是先记录下整个操作链,形成一个执行计划,并进行全局优化,最后一次性高效执行。
例如,你需要:1) 筛选出国家为“中国”的数据;2) 计算价格乘以数量得到新列;3) 按日期分组求和。pandas 会按顺序一步步执行。而 Polars 的优化器会分析整个计划,可能会决定在读取数据时就过滤掉无关行,并且只加载需要的列,避免了大量不必要的数据移动和计算。
3. 默认并行
Polars 天生支持多线程。如果你的电脑拥有8个CPU核心,它就能调动8个“工人”同时处理任务。相比之下,pandas 默认模式下只有一个核心在工作,其他核心则在“围观”。
4. SIMD 向量化
这是一个更底层的优化。简单来说,现代 CPU 支持 SIMD(单指令多数据流)指令集,可以一次性对一组数据(而非单个数据)进行相同操作。Polars 的设计充分挖掘了这一硬件潜力,实现了真正的向量化计算。
实测数据:性能差距显著
理论再好,不如实际测试。以下为本次的测试环境与结果:
- 测试环境:8核 CPU,32GB 内存,NVMe 固态硬盘。
- 测试数据:1000 万行,包含整数、浮点数、字符串等12列。
- 处理任务:数据筛选、计算衍生列、多列分组聚合,并输出为 Parquet 文件。
结果对比如下:
| 方案 |
耗时 |
性能提升倍数 |
| Python pandas 2.x |
14.2 秒 |
1x (基准) |
| pandas + pyarrow 后端 |
10.7 秒 |
1.3x |
| Rust Polars |
0.71 秒 |
20x |
20倍的提升,意味着效率的质变。假设每天需要运行100次此类任务,使用 pandas 将累计耗时近24分钟,而 Polars 仅需约1分钟。节省下来的时间相当可观。
代码对比:简洁与高效并存
来看看 Polars 的 Rust 代码是如何实现的,其表达力与 pandas 相近,但底层效率天差地别:
use polars::prelude::*;
fn main() -> PolarsResult<()> {
// 读取 CSV 文件
let df = CsvReadOptions::default()
.with_has_header(true)
.try_into_reader_with_file_path(Some("events.csv".into()))?
.finish()?;
// 数据处理:筛选、计算、分组、聚合
let result = df
.lazy() // 开启懒执行模式
.filter(col("country").eq(lit("CN"))) // 筛选中国数据
.with_column((col("price") * col("qty")).alias("amount")) // 计算金额
.group_by([col("day"), col("channel"), col("category")]) // 三列分组
.agg([
col("amount").sum().alias("revenue"), // 求和
col("id").count().alias("orders"), // 计数
])
.sort(["revenue"], SortMultipleOptions::default().with_order_descending(true)) // 排序
.collect()?; // 执行优化后的计划
// 写入 Parquet 文件
ParquetWriter::new(std::fs::File::create("output.parquet")?)
.with_compression(ParquetCompression::Zstd(None))
.finish(&mut result.clone())?;
Ok(())
}
作为对比,以下是实现相同功能的 Python pandas 代码:
import pandas as pd
df = pd.read_csv("events.csv")
df = df[df["country"] == "CN"].copy()
df["amount"] = df["price"] * df["qty"]
result = (
df.groupby(["day", "channel", "category"], as_index=False)
.agg(revenue=("amount", "sum"), orders=("id", "count"))
.sort_values("revenue", ascending=False)
)
result.to_parquet("output.parquet", compression="zstd")
两段代码的逻辑清晰度和代码量相差无几,但执行时间却一个是14秒,另一个仅为0.7秒。
适用场景与迁移建议
当然,Polars 并非万能钥匙,选择合适的工具很重要。
适合使用 Polars 的场景:
- 数据量庞大(数百万、数千万行以上)。
- 需要运行批处理任务或对执行时间有严格要求的流水线。
- 追求极致的性能表现,愿意为效率投入学习成本。
Pandas 仍是合适选择的场景:
- 进行探索性数据分析(EDA),数据量较小。
- 重度依赖 pandas 特定生态库(如某些统计、绘图库)。
- 团队技术栈以 Python 为主,引入 Rust 的学习成本过高。
补充一点:Polars 也提供了 Python 版本(py-polars)。虽然性能不及原生 Rust 版本,但仍显著快于 pandas,是 Python 用户一个不错的折中升级方案。
如果你打算迁移,建议采取渐进式策略:
- 试点先行:挑选一个最耗时的 pandas 任务进行迁移测试。
- 结果验证:确保 Polars 和 pandas 在处理相同输入时,输出结果完全一致。
- 并行运行:在一段时间内让两套逻辑并行,通过开关控制,验证稳定性。
- 全面切换:确认无误后,再正式替换核心流程。
迁移过程中,重点关注执行时间、CPU利用率、内存峰值和输出文件一致性这四个核心指标。
总结
总而言之,Rust Polars 能实现相比 Python pandas 数十倍的性能飞跃,其核心在于列式存储、查询优化和多线程并行等现代数据处理理念的深入应用。对于大规模数据处理任务,它提供了极具吸引力的解决方案。如果你正在为某个缓慢的数据管道而烦恼,尝试一下 Polars,或许就能体验到“代码运行完毕,咖啡尚且温热”的高效感。
探索更多数据处理与性能优化技巧,欢迎关注云栈社区的技术讨论。