前言:一杯咖啡都没喝完,活儿就干完了
昨天跑个数据处理任务要 14 秒,今天换了个库,0.7 秒。我还以为是程序挂了,结果一看输出文件,数据整整齐齐躺在那儿。同样的电脑,同样的数据,同样的逻辑,就换了一个环节——从 Python pandas 换成了 Rust 的 Polars。
这感觉怎么形容呢,就像你平时骑自行车送快递,突然有人给你换了辆货车。路还是那条路,货还是那些货,但你到的时候,别人还在路上蹬呢。今天咱们就聊聊这个叫 Polars 的 Rust 库,到底凭什么能快这么多。
为什么 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),它不会你说啥就立刻干啥,而是先把你的要求记下来,分析一下有没有可以优化的地方,然后一次性执行。比如你说:先筛选国家等于"中国"的数据,然后计算价格乘数量,最后按日期分组求和。pandas 会老老实实一步一步来,但 Polars 会想:等等,我可以在读数据的时候就只读需要的列,筛选也可以提前做,省得搬那么多没用的数据。这就像聪明的采购员,不是让你买啥就跑一趟超市,而是先列个清单,一趟全搞定。
3. 默认并行
Polars 天生就是多线程的。你的电脑有 8 个核心?它就派 8 个工人同时干活。pandas 呢?一个人在那儿吭哧吭哧,其他 7 个核心在休息。
4. SIMD 向量化
这个有点技术,简单说就是:CPU 可以一次处理一批数据,而不是一个一个来。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 倍,不是 20%,是 20 倍。
你要是每天跑 100 次这种任务,pandas 要 23 分钟,Polars 只要 1 分钟多一点。一天省下来的时间,够你喝两杯咖啡了。
代码长什么样?
来看看 Polars 的代码,其实很简洁:
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 不适合的场景:
适合用 Polars 的情况:
- 数据量大,几百万、几千万行
- 需要跑批处理任务,每天定时执行
- 对性能有硬性要求,比如必须在 5 秒内出结果
- 愿意学一点 Rust(其实 Polars 也有 Python 版本)
不适合的情况:
- 只是临时看看数据,探索性分析
- 数据量小,几千几万行,pandas 够用
- 需要用到 pandas 生态里某个特定的库
- 团队没人会 Rust,学习成本太高
顺便说一句,Polars 其实也有 Python 版本。虽然没有 Rust 原生版那么快,但也比 pandas 快不少。对于不想学 Rust 的同学,这是个折中方案。
怎么安全地迁移?
如果你心动了,想把现有的 pandas 代码换成 Polars,建议这么做:
- 先挑一个最耗时的任务试水,别一上来就全换
- 用同样的输入,对比输出,确保结果一致
- 两套代码并行跑一段时间,加个开关随时切换
- 确认没问题了再正式切换
重点监控这几个指标:
- 执行时间
- CPU 使用率
- 内存峰值
- 输出文件大小
如果这四个数字都对得上,而且时间大幅缩短,那就可以放心切换了。
总结
三句话:Polars 比 pandas 快 20 倍,不是因为魔法,而是因为列式存储、查询优化、多线程并行;不是所有场景都需要换,小数据、探索性分析,pandas 依然是好选择;热点路径优先,把最耗时的那个任务换掉,收益最大。
如果你的数据管道里有个任务每次都要等半天,不妨试试 Polars。说不定你也能体验一下"咖啡还没凉,活儿已经干完"的感觉。
如果觉得有用,顺手点个赞让更多人看到;身边有同事还在等 pandas 跑完的,转发给他救救急。想看更多这种"换个思路快 20 倍"的内容,可以关注梦兽编程。另外你的数据处理任务一般要跑多久?评论区聊聊,咱们下篇文章见。