Polars 这两年在 python 数据科学领域很火,性能号称能够碾压 pandas。今天我们设计一个简单的性能对比测试,看看 polars 是否存在显著优势。
实验目的
在 500 万行的数据集上,测 4 项常见数据操作的耗时,看看差距到底有多大。
我的电脑是 MacBook(M1 pro,16GB 内存),所以数据集不能太大,既要让差异显现出来,又要在合理时间内跑完,500 万行刚好。
实验设计
数据:用脚本生成合成数据,500 万行 × 8 列。包含一个主键,一个分组键(1000 个组),5 个数值列,1 个时间列。用 numpy 固定随机种子生成,先写入 CSV,然后 pandas 和 polars 各自从同一个文件读,保证数据一致性。
测试项(常见数据操作):
- 筛选 :
value_0 > 500且group_id是偶数 - 分组聚合 :按
group_id分组,对所有数值列求 sum 和 mean - 排序 :按
value_0降序 - 滑动均值 :
value_0的窗口为 100 的滚动均值
计时方法 :用 time.perf_counter,每项跑 3 次取最快那次。
为啥不取平均?因为干扰因素(GC,系统调度,后台进程)只会让某次运行变慢,不会变快。最小值最接近算法的真实下界,而且天然不受离群值影响。
另外说明:读 CSV 的时间不计算在内,只测纯计算耗时。
环境:Python 3.13 / pandas 3.0.6 / polars 2.0.0。
完整代码
python
import time
import os
import shutil
import numpy as np
import pandas as pd
import polars as pl
# 配置
N_ROWS = 5_000_000 # 500 万行
N_NUMERIC = 5 # 数值列数
N_GROUPS = 1_000 # 分组基数
DATA_DIR = "data"
CSV_PATH = os.path.join(DATA_DIR, "synthetic.csv")
# ==================== 数据准备 ====================
def generate_and_save() -> None:
"""生成合成数据集并保存为 CSV 文件。
"""
os.makedirs(DATA_DIR, exist_ok=True)
print(f"[数据准备] 生成 {N_ROWS:,} 行 x {N_NUMERIC + 3} 列 数据集...")
rng = np.random.default_rng(seed=42)
n = N_ROWS
cols = {
"id": np.arange(n, dtype=np.int64),
"group_id": rng.integers(0, N_GROUPS, size=n).astype(np.int32),
}
for i in range(N_NUMERIC):
cols[f"value_{i}"] = rng.random(n).astype(np.float64) * 1000
df = pd.DataFrame(cols)
df.to_csv(CSV_PATH, index=False)
print(f"[数据准备] 已保存至 {CSV_PATH} (约 {os.path.getsize(CSV_PATH) / 1024**2:.0f} MB)")
# ==================== 基准测试框架 ====================
def run_bench(label: str, fn_pd: callable, fn_pl: callable, repeat: int = 3) -> dict:
"""对同一逻辑分别执行 Pandas 与 Polars 实现并计时对比。
Args:
label: 测试项名称。
fn_pd: Pandas 实现函数,参数为 DataFrame。
fn_pl: Polars 实现函数,参数为 DataFrame。
repeat: 重复执行次数,取最快一次以消除抖动。
Returns:
包含 pandas/polars 耗时及加速比的字典。
"""
times = {"pandas": [], "polars": []}
for _ in range(repeat):
# Pandas
df_pd = pd.read_csv(CSV_PATH)
t0 = time.perf_counter()
try:
fn_pd(df_pd)
times["pandas"].append(time.perf_counter() - t0)
except Exception as e:
print(f" [Pandas 异常] {e}")
times["pandas"].append(float("nan"))
# Polars
df_pl = pl.read_csv(CSV_PATH)
t0 = time.perf_counter()
try:
fn_pl(df_pl)
times["polars"].append(time.perf_counter() - t0)
except Exception as e:
print(f" [Polars 异常] {e}")
times["polars"].append(float("nan"))
# 排除失败项,取最小值
pd_t = np.nanmin(times["pandas"]) if not all(np.isnan(t) for t in times["pandas"]) else float("nan")
pl_t = np.nanmin(times["polars"]) if not all(np.isnan(t) for t in times["polars"]) else float("nan")
return {"label": label, "pandas": pd_t, "polars": pl_t}
# ==================== 具体操作实现 ====================
# 1. 筛选 (Filter)
def filter_pd(df: pd.DataFrame) -> pd.DataFrame:
"""基于复杂条件的行筛选 (Pandas 实现)。"""
return df[(df["value_0"] > 500) & (df["group_id"] % 2 == 0)]
def filter_pl(df: pl.DataFrame) -> pl.DataFrame:
"""基于复杂条件的行筛选 (Polars 实现)。"""
return df.filter((pl.col("value_0") > 500) & (pl.col("group_id") % 2 == 0))
# 2. 分组聚合 (GroupBy + Aggregation)
def group_agg_pd(df: pd.DataFrame) -> pd.DataFrame:
"""按 group_id 分组并对所有数值列求和与均值 (Pandas 实现)。"""
return df.groupby("group_id").agg(["sum", "mean"])
def group_agg_pl(df: pl.DataFrame) -> pl.DataFrame:
"""按 group_id 分组并对所有数值列求和与均值 (Polars 实现)。"""
value_cols = [c for c in df.columns if c.startswith("value_")]
return df.group_by("group_id").agg(
[pl.col(c).sum().alias(f"{c}_sum") for c in value_cols]
+ [pl.col(c).mean().alias(f"{c}_mean") for c in value_cols]
)
# 3. 排序 (Sort)
def sort_pd(df: pd.DataFrame) -> pd.DataFrame:
"""按 value_0 降序排序 (Pandas 实现)。"""
return df.sort_values("value_0", ascending=False)
def sort_pl(df: pl.DataFrame) -> pl.DataFrame:
"""按 value_0 降序排序 (Polars 实现)。"""
return df.sort("value_0", descending=True)
# 4. 窗口函数 (Rolling Mean)
def rolling_pd(df: pd.DataFrame) -> pd.DataFrame:
"""计算 value_0 的滑动均值,窗口大小 100 (Pandas 实现)。"""
return df["value_0"].rolling(window=100).mean()
def rolling_pl(df: pl.DataFrame) -> pl.DataFrame:
"""计算 value_0 的滑动均值,窗口大小 100 (Polars 实现)。"""
return df.with_columns(
pl.col("value_0").rolling_mean(window_size=100).alias("rolling_mean")
)
# ==================== 主流程 ====================
if __name__ == "__main__":
generate_and_save()
benchmarks = [
("筛选 (Filter)", filter_pd, filter_pl),
("分组聚合 (GroupBy + Agg)", group_agg_pd, group_agg_pl),
("排序 (Sort)", sort_pd, sort_pl),
("滑动均值 (Rolling Mean)", rolling_pd, rolling_pl),
]
results = [run_bench(label, fn_pd, fn_pl) for label, fn_pd, fn_pl in benchmarks]
# 输出汇总报告
print("\n" + "=" * 55)
print(f"{'基准测试结果汇总':^45}")
print(f"{'(数据规模: 500万行, 取3次最快)':^45}")
print("=" * 55)
print(f"{'测试项':<28} {'Pandas (s)':>12} {'Polars (s)':>12} {'加速比':>8}")
print("-" * 55)
for r in results:
speedup = r["pandas"] / r["polars"] if r["polars"] else float("nan")
print(f"{r['label']:<28} {r['pandas']:>12.3f} {r['polars']:>12.3f} {speedup:>7.1f}x")
print("=" * 55)
# 清理临时数据
shutil.rmtree(DATA_DIR)
print("\n[清理] 已删除临时测试数据。")
结果分析
测试数据如下(每项 3 次取最快):
| 测试项 | Pandas (s) | Polars (s) | 加速比 |
|---|---|---|---|
| 筛选 (Filter) | 0.048 | 0.012 | 4.1x |
| 分组聚合 (GroupBy + Agg) | 0.189 | 0.111 | 1.7x |
| 排序 (Sort) | 1.076 | 0.182 | 5.9x |
| 滑动均值 (Rolling Mean) | 0.044 | 0.079 | 0.6x |
解读:
- 排序差距最大,约 5.9 倍。Polars 的排序是多线程并行的,而且列式内存布局对缓存更友好。pandas 底层的 NumPy 排序是单线程的,差距明显。
- 筛选 4.1 倍。Polars 会把多个条件融合成一个表达式,一次性跑完,还能分片到多个核上处理。
- 分组聚合 1.7 倍。推测是分组本身的哈希开销占了大头,这部分两边都躲不掉,差异就缩小了。
- 滑动均值,pandas 反而更快。这是最反直觉的一个结果。pandas 的 rolling 是高度优化的 C 实现,单列计算已经很快。Polars 虽然也快,但并行调度的开销在这里收益不大,反而拖了后腿。
实验没兼顾的地方
这个实验比较粗糙,有几个明显的坑:
- 规模单一。只有 500 万行,没做梯度测试。数据量更小或更大时,加速比可能完全不同。
- 没测内存。Polars 的一大卖点是省内存,我只测了时间,没测峰值内存占用。
- 忽略 I/O 和惰性求值。polars 的
scan_csv能做谓词下推和投影下推,可能才是它真正的杀手锏。 - 没测字符串和数据合并。字符串处理是 pandas 的传统弱项,Join 是实际开发的常客,本次均没有测试。
- 没测超内存数据集。polars 的流失处理模式专门处理比内存大的数据,这是 pandas 做不到的。