所有的代码分析基于 polars 1.43.0 版本
分析polars的Lazy API
前两篇分析了表达式(做什么)和物理执行(怎么做),而 LazyFrame 把两者串成了完整流水线:构建逻辑计划 → 优化器重写 → 编译物理计划 → 执行。惰性 API 的全部价值,都在”推迟执行、换来整体优化”这一句话里。本文分析其架构。
架构总览
1. LazyFrame:逻辑计划的抽象
1.1 Python 层
class LazyFrame:
"""
Representation of a Lazy computation graph/query against a DataFrame.
This allows for whole-query optimisation in addition to parallelism, and
is the preferred (and highest-performance) mode of operation for polars.LazyFrame.__init__ 直接复用 DataFrame 构造再 .lazy():
def __init__(
self,
data: FrameInitTypes | None = None,
schema: SchemaDefinition | None = None,
*,
schema_overrides: SchemaDict | None = None,
strict: bool = True,
orient: Orientation | None = None,
infer_schema_length: int | None = N_INFER_DEFAULT,
nan_to_null: bool = False,
height: int | None = None,
) -> None:
from polars.dataframe import DataFrame
self._ldf = (
DataFrame(
data=data,
schema=schema,
schema_overrides=schema_overrides,
strict=strict,
orient=orient,
infer_schema_length=infer_schema_length,
nan_to_null=nan_to_null,
height=height,
)
.lazy()
._ldf
)1.2 Rust 核心:三字段结构
/// Lazy abstraction over an eager `DataFrame`.
///
/// It really is an abstraction over a logical plan. The methods of this struct will incrementally
/// modify a logical plan until output is requested (via [`collect`](crate::frame::LazyFrame::collect)).
#[derive(Clone, Default)]
#[must_use]
pub struct LazyFrame {
pub logical_plan: DslPlan,
pub(crate) opt_state: OptFlags,
pub(crate) cached_arena: Arc<Mutex<Option<CachedArena>>>,
}LazyFrame 只有 3 个字段:
| 字段 | 作用 |
|---|---|
logical_plan: DslPlan | 递归的逻辑计划(这才是 LazyFrame 的本体) |
opt_state: OptFlags | 位标志,控制哪些优化运行 |
cached_arena: Arc<Mutex<Option<CachedArena>>> | 缓存的 arena(AExpr/IR 的分配器),collect_all 多次查询时复用 |
源码注释说得很直白:“It really is an abstraction over a logical plan. The methods of this struct will incrementally modify a logical plan until output is requested (via collect).” — 每个 .filter() / .select() 调用都只是在增量修改逻辑计划,不碰数据。
DataFrame → LazyFrame 的转换:
pub trait IntoLazy {
fn lazy(self) -> LazyFrame;
}
impl IntoLazy for DataFrame {
/// Convert the `DataFrame` into a `LazyFrame`
fn lazy(self) -> LazyFrame {
let lp = DslBuilder::from_existing_df(self).build();
LazyFrame {
logical_plan: lp,
opt_state: Default::default(),
cached_arena: Default::default(),
}
}
}DslPlan 本身是递归枚举(类似第 05 篇的 Expr):
pub enum DslPlan {
#[cfg(feature = "python")]
PythonScan {
options: crate::dsl::python_dsl::PythonOptionsDsl,
},
/// Filter on a boolean mask
Filter {
input: Arc<DslPlan>,
predicate: Expr,
},
/// Cache the input at this point in the LP
Cache {
input: Arc<DslPlan>,
id: UniqueId,
},
Scan {Filter { input: Arc<DslPlan>, predicate: Expr } — 每个节点用 Arc<DslPlan> 指向子计划,表达式直接内嵌 Expr。LazyFrame 是入口,DslPlan 是数据,Expr 是叶子。
2. 惰性 vs 即时:两种模式的本质
文档的 iris 例子:read_csv → filter → group_by.mean。
- eager:每步立即执行,返回中间结果。读了整份 CSV,filter 后再丢弃没用到的行和列
- lazy:只构建计划,
collect()时才执行。查询规划器可以:- 谓词下推 — filter 下沉到读取阶段,只读
sepal_length > 5的行 - 投影下推 — 只读
species/sepal_width两列,跳过 petal 列
- 谓词下推 — filter 下沉到读取阶段,只读
explain 输出能直接看到这两个优化生效:
AGGREGATE [...] BY [col("species")]
FROM
simple π 2/2 ["species", "sepal_width"] ← 投影下推(只读 2/5 列)
Csv SCAN [...]
PROJECT 3/5 COLUMNS
SELECTION: col("sepal_length") > 5.0 ← 谓词下推(读时过滤)
ESTIMATED ROWS: 167
这就是惰性的核心价值:把执行权交给规划器,让它在动手前做全局决策。
3. collect 流水线:优化 → 物理 → 执行
3.1 总入口
pub fn collect_with_engine(mut self, engine: Engine) -> PolarsResult<QueryResult> {
let engine = match engine {
Engine::Streaming => Engine::Streaming,
_ if std::env::var("POLARS_FORCE_STREAMING").as_deref() == Ok("1") => Engine::Streaming,
Engine::Auto => Engine::InMemory,
v => v,
};
if engine != Engine::Streaming
&& std::env::var("POLARS_AUTO_STREAMING").as_deref() == Ok("1")
{
feature_gated!("streaming", {
if let Some(r) = self.clone()._collect_with_streaming_suppress_todo_panic() {
return r;
}
})
}
match engine {
Engine::Streaming => {
feature_gated!("streaming", self = self.with_streaming(true))
},
Engine::Gpu => self = self.with_gpu(true),
_ => (),
}collect_with_engine 的流程:
- 选引擎 —
Engine::Auto默认映射到InMemory;POLARS_FORCE_STREAMING环境变量可强制流式 to_alp_optimized()— DslPlan → IR(Arena 化)+ 跑优化器ensure_root_node_is_sink()— 保证根节点是Sink(结果落内存)create_physical_plan()— IR → Executor 树(polars-mem-engine)execute()— 返回 DataFrame
3.2 优化前的准备
pub(crate) fn optimize_with_scratch(
self,
ir_arena: &mut Arena<IR>,
expr_arena: &mut Arena<AExpr>,
scratch: &mut Vec<Node>,
) -> PolarsResult<Node> {
let mut opt_flags = self.opt_state;
// Unset CSE
// This can be turned on again during ir-conversion.
#[allow(clippy::eq_op)]
#[cfg(feature = "cse")]
if opt_flags.contains(OptFlags::EAGER) {
opt_flags &= !(OptFlags::COMM_SUBEXPR_ELIM | OptFlags::COMM_SUBEXPR_ELIM);
}
let root = to_alp(self.logical_plan, expr_arena, ir_arena, &mut opt_flags)?;
let lp_top = optimize(
root,
opt_flags,
ir_arena,
expr_arena,
scratch,
apply_scan_predicate_to_scan_ir,
)?;
Ok(lp_top)
}核心是 to_alp:把 DSL 的 DslPlan/Expr 复制进 Arena(Arena<IR> + Arena<AExpr>),用下标(Node)代替 Arc 指针。Arena 化的意义:
- 节点改内存中紧凑连续,缓存友好
- 优化器频繁移动/替换节点时只需改下标,没有 Arc 引用计数开销
- debug 模式下 CSE 被关掉(
opt_flags &= !COMM_SUBEXPR_ELIM),因为 eager 查询没必要做 CSE
3.3 优化开关:OptFlags 位标志
bitflags! {
#[derive(Copy, Clone, Debug)]展开折叠代码 (5-6 行,共 2 行)
/// Allowed optimizations.
pub struct OptFlags: u32 { /// Only read columns that are used later in the query.
const PROJECTION_PUSHDOWN = 1;
/// Apply predicates/filters as early as possible.
const PREDICATE_PUSHDOWN = 1 << 2;
/// Cluster sequential `with_columns` calls to independent calls.
const CLUSTER_WITH_COLUMNS = 1 << 3;
/// Run many type coercion optimization rules until fixed point.
const TYPE_COERCION = 1 << 4;
/// Run many expression optimization rules until fixed point.
const SIMPLIFY_EXPR = 1 << 5;
/// Do type checking of the IR.
const TYPE_CHECK = 1 << 6;
/// Pushdown slices/limits.
const SLICE_PUSHDOWN = 1 << 7;
/// Run common-subplan-elimination. This elides duplicate plans and caches their
/// outputs.
const COMM_SUBPLAN_ELIM = 1 << 8;
/// Run common-subexpression-elimination. This elides duplicate expressions and caches their
/// outputs.
const COMM_SUBEXPR_ELIM = 1 << 9;
/// Is the query going to run on the GPU engine.
const GPU = 1 << 10;
/// Run on the streaming engine.
const STREAMING = 1 << 11;
/// Run every node eagerly. This turns off multi-node optimizations.
const EAGER = 1 << 12;
/// Try to estimate the number of rows so that joins can determine which side to keep in memory.
const ROW_ESTIMATE = 1 << 13;
/// Replace simple projections with a faster inlined projection that skips the expression engine.
const FAST_PROJECTION = 1 << 14;
/// Check if operations are order dependent and unset maintaining_order if
/// the order would not be observed.
const CHECK_ORDER_OBSERVE = 1 << 15;
/// Collapse consecutive sort nodes and pull them up through selecting nodes.
const SORT_COLLAPSE = 1 << 16;
/// Pre-partition hive partitioned joins or group-by's
/// Only works if PREDICATE_PUSHDOWN is set
const PARTITION_HIVE = 1 << 17;
}每个优化一个 bit,Python 侧通过 QueryOptFlags 控制(lazyframe/opt_flags.py),_eager() 打开 EAGER 位——这就是第 04 篇里 eager DataFrame 调用 lazy 引擎时”跳过跨节点优化”的机制。
4. 优化器:StackOptimizer 规则引擎
optimize() 是整条流水线的心脏,按精心编排的顺序跑多个 pass:
pub fn optimize(
mut root: Node,
opt_flags: OptFlags,
ir_arena: &mut Arena<IR>,
expr_arena: &mut Arena<AExpr>,
scratch: &mut Vec<Node>,
apply_scan_predicate_to_scan_ir: fn(
Node,
&mut Arena<IR>,
&mut Arena<AExpr>,
) -> PolarsResult<()>,
) -> PolarsResult<Node> {展开折叠代码 (97-116 行,共 20 行)
#[allow(dead_code)]
let verbose = verbose();
// Gradually fill the rules passed to the optimizer
let opt = StackOptimizer {};
let mut rules: Vec<Box<dyn OptimizationRule>> = Vec::with_capacity(8);
#[allow(unused_assignments)]
let mut comm_subplan_elim = false;
// Don't run optimizations that don't make sense on a single node.
// This keeps eager execution more snappy.
#[cfg(feature = "cse")]
{
comm_subplan_elim = opt_flags.contains(OptFlags::COMM_SUBPLAN_ELIM);
}
#[cfg(feature = "cse")]
let comm_subexpr_elim = opt_flags.contains(OptFlags::COMM_SUBEXPR_ELIM);
#[cfg(not(feature = "cse"))]
let comm_subexpr_elim = false;
// Note: This can be in opt_flags in the future if needed.
let pushdown_maintain_errors = pushdown_maintain_errors();4.1 三类优化器
| 类型 | 代表 | 方式 |
|---|---|---|
| 专用 pass | SlicePushDown、PredicatePushDown、projection_pushdown | 手写的一次性遍历,处理跨节点重排 |
| 规则引擎 | StackOptimizer.optimize_loop | 一组 OptimizationRule 反复迭代直到不动点(fixpoint) |
| 全局改写 | CommonSubExprOptimizer、expand_datasets | 整树重写 |
4.2 执行顺序的讲究
注释里写满了依赖关系:
let mut repeat_slice_pd_after_filter_pd = false;展开折叠代码 (165-172 行,共 8 行)
if opt_flags.slice_pushdown() {
let mut slice_pushdown_opt = SlicePushDown::new();
let ir = slice_pushdown_opt.optimize(root, ir_arena, expr_arena)?;
ir_arena.replace(root, ir);
repeat_slice_pd_after_filter_pd = slice_pushdown_opt.slice_node_in_optimized_plan;
}
// Should be run before projection pushdown.
// This allows columns only needed for filters to be dropped early.
if opt_flags.predicate_pushdown() {
let mut predicate_pushdown_opt = PredicatePushDown::new(
pushdown_maintain_errors,
opt_flags.streaming(),
opt_flags.partition_hive(),
);
let ir = ir_arena.take(root);
let ir = predicate_pushdown_opt.optimize(ir, ir_arena, expr_arena)?;
ir_arena.replace(root, ir);
}- slice pushdown 先跑,并记录
slice_node_in_optimized_plan,若谓词下推改变了计划则再跑一次(mod.rs:249) - 谓词下推先于投影下推 — 先下沉 filter,让只用于过滤的列能被投影提前丢弃(“Should be run before projection pushdown. This allows columns only needed for filters to be dropped early.”)
- filter_constraint 先于 simplify_boolean —
a > 5 AND a < 3先折叠成false,同一轮里SimplifyBooleanRule就能把整个 filter 折叠成空扫描
规则循环 + debug 自检:
if opt_flags.fast_projection() {
rules.push(Box::new(SimpleProjectionAndCollapse::new(
opt_flags.eager(),
)));
}展开折叠代码 (222-245 行,共 24 行)
if !opt_flags.eager() {
rules.push(Box::new(DelayRechunk::new()));
}
// This optimization removes branches, so we must do it when type coercion
// is completed.
if opt_flags.simplify_expr() {
// FilterConstraintRule turns an impossible filter like `a > 5 AND a < 3`
// into `false`. It runs before SimplifyBooleanRule so that, in the same
// pass, SimplifyBooleanRule can use that `false` to collapse the whole
// filter into an empty scan.
rules.push(Box::new(filter_constraint::FilterConstraintRule {
maintain_errors: pushdown_maintain_errors,
}));
rules.push(Box::new(SimplifyBooleanRule {
maintain_errors: pushdown_maintain_errors,
}));
}
if !opt_flags.eager() {
#[cfg(feature = "merge_sorted")]
rules.push(Box::new(FlattenMergeSortedRule::new()));
rules.push(Box::new(FlattenUnionRule {}));
}
root = opt.optimize_loop(&mut rules, expr_arena, ir_arena, root)?;设计亮点:debug 构建下,优化前后对比最终 schema(只比列名,因为类型可能超类型提升),任何优化 pass 改了 schema 就 panic(mod.rs:305-324)。这让优化器的正确性可验证。
5. explain:查询计划可视化
explain 不执行,只走”DSL→IR→优化”两个阶段并打印:
/// Return a String describing the naive (un-optimized) logical plan.
pub fn describe_plan(&self) -> PolarsResult<String> {
Ok(self.clone().to_alp()?.describe())
}
/// Return a String describing the naive (un-optimized) logical plan in tree format.
pub fn describe_plan_tree(&self) -> PolarsResult<String> {
Ok(self.clone().to_alp()?.describe_tree_format())
}
/// Return a String describing the optimized logical plan.
///
/// Returns `Err` if optimizing the logical plan fails.
pub fn describe_optimized_plan(&self) -> PolarsResult<String> {
Ok(self.clone().to_alp_optimized()?.describe())
}
/// Return a String describing the optimized logical plan in tree format.
///
/// Returns `Err` if optimizing the logical plan fails.
pub fn describe_optimized_plan_tree(&self) -> PolarsResult<String> {
Ok(self.clone().to_alp_optimized()?.describe_tree_format())
}
/// Return a String describing the logical plan.
///
/// If `optimized` is `true`, explains the optimized plan. If `optimized` is `false`,
/// explains the naive, un-optimized plan.
pub fn explain(&self, optimized: bool) -> PolarsResult<String> {
if optimized {
self.describe_optimized_plan()
} else {
self.describe_plan()
}
}describe_plan→to_alp()(未优化)+describe()describe_optimized_plan→to_alp_optimized()(优化后)+describe()
Python 端把 optimized 和 QueryOptFlags 传给 Rust:
engine = _select_engine(engine)
if optimized:
optimizations = optimizations.__copy__()
optimizations._pyoptflags.streaming = engine == "streaming"
ldf = self._ldf.with_optimizations(optimizations._pyoptflags)
if format == "tree":
return ldf.describe_optimized_plan_tree()
else:
return ldf.describe_optimized_plan()
if format == "tree":
return self._ldf.describe_plan_tree()
else:
return self._ldf.describe_plan()explain 的两种用法(对应文档):
- 看优化效果 —
q.explain()展示谓词/投影下推是否生效 - 看表达式展开 —
LazyFrame(schema=...).select((pl.col(pl.Float64) * 1.1)...)按给定 schema 展示pl.col(pl.Float64)会展开成哪几列(第 05 篇的 expression expansion 在这里可视化)
6. 三种执行引擎
同一个逻辑计划可以编译到不同引擎:
let mut ir_plan = self.to_alp_optimized()?;
ir_plan.ensure_root_node_is_sink();
match engine {
Engine::Streaming => feature_gated!("streaming", {
polars_stream::run_query(
ir_plan.lp_top,
&mut ir_plan.lp_arena,
&mut ir_plan.expr_arena,
)
}),
Engine::InMemory | Engine::Gpu => {展开折叠代码 (672-687 行,共 16 行)
if let IR::SinkMultiple { inputs } = ir_plan.root() {
polars_ensure!(
engine != Engine::Gpu,
InvalidOperation:
"collect_all is not supported for the gpu engine"
);
return create_multiple_physical_plans(
inputs.clone().as_slice(),
&mut ir_plan.lp_arena,
&mut ir_plan.expr_arena,
BUILD_STREAMING_EXECUTOR,
)?
.execute()
.map(QueryResult::Multiple);
}
let mut physical_plan = create_physical_plan(
ir_plan.lp_top,
&mut ir_plan.lp_arena,
&mut ir_plan.expr_arena,
BUILD_STREAMING_EXECUTOR,| 引擎 | 实现 | 特点 |
|---|---|---|
| InMemory | polars-mem-engine → Executor 树 | 默认,全量驻留内存,本系列前几篇分析的执行器 |
| Streaming | polars-stream → run_query | 分块流式处理,控制内存峰值 |
| Gpu | GPU 引擎 | 设置 OptFlags::GPU |
这正是第 05 篇”逻辑/物理分层”的价值体现:优化后的 IR 是同一份,选择不同引擎只是换一个编译器。
Python 侧 collect 的最终调用:
if k not in ( # except "private" kwargs展开折叠代码 (2601-2617 行,共 17 行)
"post_opt_callback",
):
error_msg = f"collect() got an unexpected keyword argument '{k}'"
raise TypeError(error_msg)
engine = _select_engine(engine)
callback = _gpu_engine_callback(
engine,
background=background,
_eager=optimizations._pyoptflags.eager,
)
if isinstance(engine, GPUEngine):
engine = "gpu"
ldf = self._ldf.with_optimizations(optimizations._pyoptflags) if background:
issue_unstable_warning("background mode is considered unstable.")
return InProcessQuery(ldf.collect_concurrently())ldf.collect(engine, callback) → PyLazyFrame.collect(crates/polars-python/src/lazyframe/general.rs:607)→ collect_with_engine。post_opt_callback 是给测试用的优化后钩子。
7. 小结:完整调用链
设计亮点总结
-
LazyFrame 是逻辑计划的薄壳 — 三字段(
DslPlan+OptFlags+ 缓存 arena),所有方法只是增量改计划,不碰数据 -
DslPlan / AExpr / IR 三层表达 — DSL 面向人写(
Arc递归、可序列化),IR 面向优化(Arena + 下标),物理计划面向执行(trait object);各层各司其职 -
优化开关是位标志 —
OptFlags每个优化一个 bit,QueryOptFlags精确控制到单个优化,eager 只是打开EAGER位跳过跨节点优化 -
优化顺序即正确性 — 每个 pass 的先后都有注释明确依赖(slice 下推要重跑、谓词先于投影、filter_constraint 先于 simplify_boolean),非随意排列
-
可验证的优化器 — debug 构建下对比优化前后 schema,改坏就 panic,保证重写不破坏结果
-
explain 零执行预览 — 只走 DSL→IR→优化,不跑数据,把优化效果和表达式展开可视化
-
一份 IR 多引擎编译 — InMemory / Streaming / GPU 共享同一份优化后的逻辑计划,换引擎只是换编译器(第 05 篇”逻辑/物理分层”的直接受益者)