![]()
一、过去是直接崩,现在只是慢
2026 年 10 月 6 日,Polars 作者 Ritchie Vink 在官方博客挂出了 2.0 的发布说明。上一个 1.x 版本 1.44.2 是 9 月 9 日发的,两者只隔 27 天——但这次跨大版本的间隔本身很长:1.0.0 发布于 2024 年 7 月 1 日,到 1.44.2 走了 800 天、76 个小版本,才等来第一个 2.0。
官方列了五个卖点:初版 out-of-core(落盘)默认开启、大量核心性能改进、SQL 成为一等公民、新增 Map dtype、对 dtype 更严格。最后一条官方给的理由很直白,说更严格能"更快给反馈、更快做 AI 迭代"。
战绩也在:在从 TPC-H 与 TPC-DS 派生的数据上,Polars SQL 与最新的 DuckDB 1.5.6、DuckDB 2.0 alpha、DataFusion 54.0.0 同台跑,默认配置在 8 组对比里赢了 6 组。
先说清口径:这是 Polars 官方自测的基准。官方脚注原话承认结果"无法与已发表的 TPC-H / TPC-DS 基准结果相比",因为不满足 TPC 的合规要求。基准仓库公开在 github.com/pola-rs/polars-2.0-benchmark,官方鼓励别人来复现或反驳。
但这次发布里真正值得写的,不是赢了几组,而是那句话本身:数据放不下的时候,它不再直接崩,而是变慢。
把这件事推到底,是在一台 10 核 macOS 机器上做的一组实验。取一份 800 万行、33.7MB 的 Parquet,只做一次双列排序,然后把可用预算从"随便用"一路压到 8MB:
内存预算
查询耗时
累计写盘块数
是默认的几倍
默认不限
0.515 秒
0 块
1.0
压到 256MB
1.189 秒
2633 块
2.3 倍
压到 32MB
2.478 秒
4644 块
4.8 倍
压到 8MB
2.412 秒
5414 块
4.7 倍
8MB 预算下,它往磁盘写了五千多块、每块写完再捞回来接着算,查询最终还是返回了正确的三行结果。默认配置只要 0.515 秒、一次盘都不碰;压到 8MB 之后是 2.412 秒,慢了 4.7 倍,但没有失败。同一台机器重复三轮,8MB 那一档的写盘块数在 4620 到 5414 之间波动,耗时稳定在 2.2 秒上下。
这就是 2.0 的分水岭:它在"崩掉"和"慢一点"之间选了后者。
顺带一个体积上的反差。从 PyPI 下载的主包 polars-2.0.0-py3-none-any.whl 只有 0.84MB,是个纯 Python 外壳;引擎在另一个依赖包里,macOS arm64 平台那份 45.71MB,解开后的 _polars_runtime.abi3.so 实测 158.0MB。装完落地一共 166.1MB,是主包的 197.7 倍。
Polars 是 MIT 许可、完全免费的开源项目,源码在 github.com/pola-rs/polars,到 2026 年 10 月 7 日有 39957 个 star、3155 个 fork,主语言 Rust,仓库 2020 年 5 月 13 日建。
二、落盘这件事,官方给了十几个旋钮触发线是 80%,磁盘预算是 64GB
官方给的两个数字很具体:内存用到大约 80% 开始往外写,默认磁盘预算是 64GB。目前支持落盘的算子只有排序、窗口函数和很多表达式,join 和 group-by 官方明确说还在路上。
这句话要划重点。最吃内存的两类操作——大表关联和分组聚合——恰好还没进落盘名单。所以"数据放不下也能算"这句话,现在只对一半场景成立。
从 158MB 的那个二进制里,能直接抠出这套机制的完整开关表:
环境变量
作用
POLARS_OOC_MEMORY_BUDGET_MB
内存预算绝对值
POLARS_OOC_MEMORY_BUDGET_FRACTION
内存预算比例,默认 0.8 那条线
POLARS_OOC_DISK_BUDGET_MB
磁盘预算,默认 64GB
POLARS_OOC_SPILL_MIN_BYTES
多大的数据才值得写盘
POLARS_OOC_SPILL_FORMAT
落盘格式,目前只认 ipc
POLARS_OOC_SPILL_COMPRESSION_LEVEL
落盘压缩级别
POLARS_OOC_MAX_PARALLEL_SPILL_TASKS
并发写盘任务数
POLARS_OOC_MEMORY_PREFETCH_FRACTION
提前回捞的触发比例
POLARS_OOC_LOG_METRICS
打印落盘统计日志
POLARS_OOMKILL_THRESHOLD_MB
内存杀进程阈值
POLARS_OVERRIDE_TOTAL_MEMORY_MB
手动声明总内存
十一个旋钮拧一个功能,说明它不是"能用"级别的实现。二进制里的源码路径也印证了这一点:crates/polars-ooc/src/ 下面有 memory_manager.rs、spill_file.rs、spill_frame.rs、spill_token.rs,还有 spill_context/mod.rs 与 spill_context/stats.rs,另有一个专门清垃圾的后台线程 polars-ooc-cleaner。它是个独立的 crate,不是一个函数。
复现上面那张表只要改一个环境变量:
POLARS_OOC_MEMORY_BUDGET_MB=8 \POLARS_OOC_SPILL_MIN_BYTES=1 \POLARS_OOC_LOG_METRICS=1 \python -c "import polars as plpl.scan_parquet('data.parquet').sort(['h','g']).select(pl.col('tag').head(3), pl.len().alias('n')).collect()日志里那个累计值不能直接读把 POLARS_OOC_LOG_METRICS 打开,每轮落盘会打一行统计。8MB 那一档的原始输出是:
spill_stats(sort): relief_mb_s(201.92), io(spill=2.37s, unspill=0.00ns),spill(succ=100.0%, n=89), spill_explore(succ=1.5%, n=5847)spill_stats(sort-partition): io(spill=2.15s), spill(succ=100.0%, n=5198)spill_stats(multiplexer): io(spill=303.51ms), spill(succ=100.0%, n=127)三个阶段的写盘块数分别是 89、5198、127,合计 5414。两个数字值得盯:succ=100.0% 是真正落盘的成功率;spill_explore 是"评估后判断不划算、又放弃"的比例——调度器每试四次,只有一次真的写盘。
这里有个容易读错的地方:三个阶段写盘 I/O 加起来是 4.82 秒,而整个查询墙钟只有 2.412 秒。不是数据错了——io 那一项是各线程的累加值,写盘本身是多线程并行的。日志里的 io 是总工作量,不是你坐在那儿等的时间。
还有一条边界:把磁盘预算改成 256MB,查询会直接报错——
RuntimeError: BindingsError: "query aborted, raise POLARS_OOC_DISK_BUDGET_MB"也就是说 64GB 默认值是第二道防线。第一道是内存,第二道是磁盘,两道都用完才认输。压得越狠写盘次数也不是单调上升:8MB 那档写了 5414 块,反而比 32MB 那档的 4644 块更多——预算越小,单次写的块越小、越频繁。
三、SQL 成一等公民:30 条语法过了 26 条
"把 SQL 当一等公民"是这次喊得最响的一句。官方说要让同一个引擎接更多工作负载,为此在优化器上补了三样东西:join 重排序、更好的公共子计划消除、动态谓词与布隆过滤器。
覆盖度提升需要验证,所以直接拿 30 条常用语法去打:
结果
语法
通过(26 条)
WITH 公用表表达式、ROW_NUMBER 窗口函数、SUM OVER、QUALIFY 过滤窗口结果、GROUP BY ROLLUP、GROUP BY CUBE、GROUPING SETS、CASE WHEN、LEFT JOIN、UNION ALL、INTERSECT、EXCEPT、DISTINCT、HAVING、LIKE、IN 子查询、标量子查询、EXISTS 子查询、EXTRACT 日期函数、字符串拼接、LIMIT 与 OFFSET、CAST 到 DECIMAL、STRING_AGG、CREATE TABLE AS、DROP TABLE、SHOW TABLES
不支持(4 条)
BOOL_AND 聚合、INSERT INTO 已有表、DESCRIBE、PIVOT
四条的报错各不相同,恰好画出边界。BOOL_AND 报的是 unsupported function 'bool_and',函数没实现;INSERT INTO 报 statement type is not supported,语句类型没认;DESCRIBE 同样报语句类型不支持,但错误里保留了 hive_format 这类字段,说明解析器认得、执行器没接;PIVOT 报得最直白,not yet implemented。
这里有个细节值得留意:CREATE TABLE AS 能跑通,INSERT INTO 不行。意味着它现在的 SQL 是"读"这一侧的一等公民,还不是"写"这一侧的一等公民——你可以用 SQL 描述查询,但还不能用它维护一张有状态的表。当查询语言用没问题,当数据库用会撞墙。
跑通一条带 HAVING 的聚合是这样的:
import polars as plctx = pl.SQLContext(a=df, eager=True)ctx.execute("SELECT dept, sum(amt) s FROM a GROUP BY dept HAVING sum(amt)>30")四、赢 6 组输 2 组,输的那 2 组更有信息量官方附录里的原始数据,单位秒,越小越好:
机器
规模与基准
Polars
Polars 32线程
DuckDB 1.5.6
DuckDB 2.0 alpha
DataFusion
16 核 / 32GB
SF10 TPC-H
3.22
4.44
4.27
7.41
16 核 / 32GB
SF10 TPC-DS
16 核 / 32GB
SF100 TPC-H
16 核 / 32GB
SF100 TPC-DS
192 核 / 384GB
SF10 TPC-H
3.10
1.97
2.46
3.12
7.10
192 核 / 384GB
SF10 TPC-DS
9.63
192 核 / 384GB
SF100 TPC-H
9.85
192 核 / 384GB
SF100 TPC-DS
一行行核排名,会得到和"赢了 6 组"不一样的信息:默认配置输掉的两组,全出在同一台 192 核机器的小数据上。SF10 TPC-H 里 DuckDB 1.5.6 用 2.46 秒,Polars 要 3.10 秒;SF10 TPC-DS 里 Polars 的 32 线程版本只要 9.63 秒,默认的 192 线程版本却要 23.16 秒,差了 2.4 倍。
官方自己也认了,原话大意是默认配置在 192 线程时有一份固定开销、小数据查询会吃亏,限制到 32 核以后在所有基准里都能打平或领先,原因已定位、希望下个版本修掉。限制线程数反而更快,这不是黑料,是并行化的常识:数据量不够大时,线程间的同步成本超过并行带来的收益。
另一组数字是扩展性。从 16 核换到 192 核、数据放大到 SF100,Polars 在 TPC-H 上快 3.8 倍、TPC-DS 上 2.2 倍;同一口径下 DuckDB 1.5.6 是 3.2 倍和 1.9 倍,DuckDB 2.0 alpha 是 2.2 倍和 1.5 倍,DataFusion 是 1.7 倍和 1.0 倍。加机器的收益,Polars 确实是最大的,这一条比那 45% 的领先更能说明引擎结构。
还有一处口径与实现的落差
官方说流式引擎改成默认之后,join、group_by、unpivot 这几类操作不再保证行序,需要行序就设 maintain_order=True。挨个查签名:join 有 maintain_order,group_by 有 maintain_order,unpivot 没有这个参数,传进去直接报 TypeError: unexpected keyword argument。也就是说 unpivot 的行序目前没有开关可拧,只能自己在外面再排一次。发布说明里那句话,对 unpivot 是不成立的。
五、升级的代价落在老代码上
2.0 是跨大版本,代价具体到数字:源码里标记 removed_in="2.0" 的参数共 110 处,其中 58 处直接移除、52 处改名。挨个跑一遍旧写法,报错长这样:
ArgumentRemovedError: the argument 'streaming' for 'LazyFrame.collect'was deprecated in version 1.25.0 and has been removed in version 2.0.Use `engine="streaming"` instead.ArgumentRemovedError: the argument 'join_nulls' for 'LazyFrame.join'was deprecated in version 1.24 and has been removed in version 2.0.It was renamed to 'nulls_equal'.高频改名集中在几组:min_periods 改叫 min_samples,descending 改叫 reverse,by 改叫 group_by,join_nulls 改叫 nulls_equal,future 改叫 compat_level,columns 改叫 on,dtypes 改叫 schema_overrides。这些报错是严格策略的另一面:它不只在新功能上严格,也在老接口上严格。
谁该升,谁可以不急
该升的三种人。第一种,数据量卡在内存边界上的人——落盘解决的是"跑到 90% 突然 OOM"这类最难受的失败,它不是让查询变快,是让查询有结果。第二种,只想写 SQL 的人——26/30 的语法覆盖加上优化器补的三块,能接住大部分分析型查询,但记住 INSERT INTO 还没有。第三种,用 AI 写查询的人——collect_schema() 能在真正读数据之前把类型和列名解析一遍,实测拿一个引用了不存在列的计划去调,直接抛 ColumnNotFoundError,连数据都不碰;AI 生成的错误查询会在编译阶段被拦下,不用等跑完二十分钟才发现列名写错。
可以不急的三种人。数据量离内存还远的人,那 64GB 磁盘预算和 80% 触发线对你没有意义;还在 Pandas 上的人,这次升级不解决迁移问题;以及生产代码依赖行序的人——你需要的不是升级,是先给那几个 join 和 group_by 补上 maintain_order。
升级前要做的三件事。把 collect(streaming=...) 这类旧写法全局搜一遍换成 engine="streaming";把上面那七组改名逐个扫一遍;把落盘目录挂到一块不跟系统盘抢带宽的盘上,64GB 预算配机械盘会很难看。
六、互动话题
这次发布最有价值的不是那 6 组领先,而是两个反常识:把线程从 192 限制到 32 反而更快,以及数据放不下时选择慢下来而不是崩掉。
一个引擎成熟与否,不看它最快的时候有多快,看它最难受的时候会不会把活交出来。
如果你是做数据分析的,更愿意要一个跑得飞快但遇到大数据就崩的库,还是一个慢一点、但总能给你结果返回的库?评论区说说你踩过的 OOM。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.