Polars 的作者 Ritchie Vink 在 9 月 2 日发了《Pre-release of Polars 2.0》,第一个 release candidate 上线,正式版预计几周内。文章开头有一句我挺喜欢的话,大意是:我们不打算把 2.0 做成一个大功能版本,反而希望它对你是"无聊的"。

一个数据处理库愿意把大版本做成"无聊的",说明它理解了语义化版本该干什么。

为什么升大版本:语义变了,不是功能变了

Polars 从 1.x 到 2.0 的主要理由不是加了什么新特性,而是默认行为变了。文章里明确说,2.0 最大的影响是"流式引擎成为默认"。

这一句话背后是一整代执行模型的切换:

在 1.x 里,默认的执行是收集式(collect-based)的:调用 .collect() 时,引擎把整个结果物化到内存里,算子按阶段跑(先扫、再 join、再 group by、再 sort)。这是很多 dataframe 库的经典模型,简单、可预测,但代价是中间结果全部驻留内存。数据量一旦超过内存,就要么 OOM,要么靠用户显式开启流式模式。

在 2.0 里,流式引擎成了默认:数据不再一次性物化,而是按块(chunk)流过一系列算子,算子之间以流水线方式衔接,内存占用由块大小和算子状态决定,而不是由数据集大小决定。超出内存的中间结果可以被溢写到磁盘。

这是行为变更,不是"加了个开关"。同样的代码,在 1.x 和 2.0 下内存曲线、甚至部分结果顺序都会有差别。所以它需要一个 major 版本,让用户显式地知道"升级之后行为会变"。作者说"希望它无聊",正是指这个:2.0 不需要你学新 API,只需要你确认迁移指南里列的那些行为差异对你没影响。

5 倍:流式引擎的收益从哪来

文章给的性能预期是:在聚合场景下,流式引擎预计能轻松快 5 倍。

这个数字从哪来?流式引擎(或者说"基于块的流水线执行")的收益主要来自三方面:

并行度更高。 收集式执行里,算子之间有严格的阶段划分——join 必须等两侧都扫完,sort 必须等全部数据到齐。流式执行把数据切块之后,多个块可以并行流过多条流水线,CPU 利用率上去了。

内存局部性更好。 块级处理意味着每次处理的数据量小,更容易命中 CPU 缓存。收集式处理全量数据时,每个算子都要遍历一遍完整数据集,缓存基本被冲干净。

不再为峰值内存买单。 收集式执行的内存峰值往往由最"胖"的那个中间结果决定(比如一个大 join 之后的表)。流式执行把峰值摊平,还可以把部分状态溢写到磁盘。这不只是"省内存",而是让引擎可以选择更省时的算法(比如对超出内存阈值的 join 改用分片处理而不是拒绝执行)。

迁移上要注意的

作者提供了一份完整的迁移指南,这本身是个好信号。从我处理这类升级的经验看,这类"默认引擎切换"的升级要在几件事上留心:

  • 内存行为的变化。 原来能跑的就未必还能以同样的内存曲线跑,原来要显式开流式的现在默认就是流式。盯着升级前后的内存峰值对比。
  • 结果顺序的假设。 流式 + 并行通常不保证行序。如果下游代码依赖"collect 之后的顺序",升级后可能出错。Polars 的算子大多本来就不承诺顺序,但如果有地方默认了顺序,要显式加 sort。
  • 错误信息与边界情况。 新引擎对某些边界输入的处理可能和旧引擎不同。跑一遍完整的测试集,不要只看典型查询。
  • 别急着一次全升。 如果有多个服务依赖,先在一个服务上升级验证,参照迁移指南逐条核对行为差异。

一个更大的观察

Polars 这条路线,反映的是数据处理引擎这几年的一个共识:"全量物化 + 算子分阶段"的模型,在大数据上撞墙了。

无论是数据库(从物化视图到流水线执行)、还是 dataframe(从 collect 到 streaming),大家都在往同一个方向走:把数据切成小块,让算子在块之间流水线化,遇到超出内存的部分就溢写。这个方向的好处是它同时改善了吞吐、内存和可预测性——不是用 CPU 换内存那种单点优化,而是执行模型层面的整体收益。

Polars 走这一步的特别之处是它把它变成了默认。多数库会把它做成一个可选开关(streaming=True),让用户自己赌。Polars 选择让所有人默认走新路径,说明作者对这套引擎的成熟度有信心,也说明他认为"收集式默认"已经不该是默认了。

我的做法

处理这类大版本升级,我的流程是:

  1. 先读迁移指南,把"行为变更"逐条列出来,而不是先看新功能。
  2. 在自己的代表性数据上跑一遍基准,记录内存峰值和耗时两个维度,不只比速度。
  3. 把结果顺序相关的代码全部显式化(加 sort 或加断言),不要依赖引擎的隐式行为。
  4. 锁版本。polars==2.0.x 写进依赖,避免小版本的引擎调整带来意外。

Polars 2.0 的"无聊"是它的优点。一个成熟的库,大版本的意义应该是"把该成为默认的东西变成默认",而不是"再塞一堆新特性"。它这次做的正是前者。