Skip to content
DC' Blog
Go back

06-polars Lazy API

Tip

所有的代码分析基于 polars 1.43.0 版本

分析polars的Lazy API

前两篇分析了表达式(做什么)和物理执行(怎么做),而 LazyFrame 把两者串成了完整流水线:构建逻辑计划 → 优化器重写 → 编译物理计划 → 执行。惰性 API 的全部价值,都在”推迟执行、换来整体优化”这一句话里。本文分析其架构。

架构总览

flowchart TD subgraph Python A["lazyframe/frame.py: LazyFrame 类"] A1["QueryOptFlags: 优化开关"] end subgraph pyo3 B["PyLazyFrame(RwLock<LazyFrame>)"] end subgraph "polars-lazy (LazyFrame)" C["LazyFrame{logical_plan: DslPlan, opt_state: OptFlags}"] C2["collect 流水线"] end subgraph "polars-plan (逻辑层)" D["DslPlan 递归枚举 = 逻辑计划"] D2["DSL→IR: to_alp (Arena<AExpr> + Arena<IR>)"] D3["优化器: StackOptimizer 规则引擎"] end subgraph "polars-mem-engine (物理层)" E["create_physical_plan → Executor 树"] F["execute → DataFrame"] end A --> B A1 --> C2 B --> C C --> D D --> D2 --> D3 --> E E --> F

1. LazyFrame:逻辑计划的抽象

1.1 Python 层

py-polars/src/polars/lazyframe/frame.pyGitHub
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()

py-polars/src/polars/lazyframe/frame.pyGitHub
    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 核心:三字段结构

crates/polars-lazy/src/frame/mod.rsGitHub
/// 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 的转换:

crates/polars-lazy/src/frame/mod.rsGitHub
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):

crates/polars-plan/src/dsl/plan.rsGitHub
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> 指向子计划,表达式直接内嵌 ExprLazyFrame 是入口,DslPlan 是数据,Expr 是叶子

2. 惰性 vs 即时:两种模式的本质

文档的 iris 例子:read_csv → filter → group_by.mean

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 总入口

crates/polars-lazy/src/frame/mod.rsGitHub
    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 的流程:

  1. 选引擎Engine::Auto 默认映射到 InMemoryPOLARS_FORCE_STREAMING 环境变量可强制流式
  2. to_alp_optimized() — DslPlan → IR(Arena 化)+ 跑优化器
  3. ensure_root_node_is_sink() — 保证根节点是 Sink(结果落内存)
  4. create_physical_plan() — IR → Executor 树(polars-mem-engine)
  5. execute() — 返回 DataFrame

3.2 优化前的准备

crates/polars-lazy/src/frame/mod.rsGitHub
    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 复制进 ArenaArena<IR> + Arena<AExpr>),用下标(Node)代替 Arc 指针。Arena 化的意义:

3.3 优化开关:OptFlags 位标志

crates/polars-plan/src/frame/opt_state.rsGitHub
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:

crates/polars-plan/src/plans/optimizer/mod.rsGitHub
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 三类优化器

类型代表方式
专用 passSlicePushDownPredicatePushDownprojection_pushdown手写的一次性遍历,处理跨节点重排
规则引擎StackOptimizer.optimize_loop一组 OptimizationRule 反复迭代直到不动点(fixpoint)
全局改写CommonSubExprOptimizerexpand_datasets整树重写

4.2 执行顺序的讲究

注释里写满了依赖关系:

crates/polars-plan/src/plans/optimizer/mod.rsGitHub
    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);
    }

规则循环 + debug 自检:

crates/polars-plan/src/plans/optimizer/mod.rsGitHub
    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→优化”两个阶段并打印:

crates/polars-lazy/src/frame/mod.rsGitHub
    /// 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()
        }
    }

Python 端把 optimizedQueryOptFlags 传给 Rust:

py-polars/src/polars/lazyframe/frame.pyGitHub
        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 的两种用法(对应文档):

  1. 看优化效果 — q.explain() 展示谓词/投影下推是否生效
  2. 看表达式展开 — LazyFrame(schema=...).select((pl.col(pl.Float64) * 1.1)...) 按给定 schema 展示 pl.col(pl.Float64) 会展开成哪几列(第 05 篇的 expression expansion 在这里可视化)

6. 三种执行引擎

同一个逻辑计划可以编译到不同引擎:

crates/polars-lazy/src/frame/mod.rsGitHub
        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,
引擎实现特点
InMemorypolars-mem-engine → Executor 树默认,全量驻留内存,本系列前几篇分析的执行器
Streamingpolars-streamrun_query分块流式处理,控制内存峰值
GpuGPU 引擎设置 OptFlags::GPU

这正是第 05 篇”逻辑/物理分层”的价值体现:优化后的 IR 是同一份,选择不同引擎只是换一个编译器

Python 侧 collect 的最终调用:

py-polars/src/polars/lazyframe/frame.pyGitHub
            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_enginepost_opt_callback 是给测试用的优化后钩子。

7. 小结:完整调用链

flowchart LR A["lf.filter(...)"] --> B["增量修改 DslPlan"] B --> C["lf.collect()"] C --> D["collect_with_engine"] D --> E["to_alp_optimized"] E --> F["StackOptimizer 跑 N 个优化 pass"] F --> G["ensure_root_node_is_sink"] G --> H["create_physical_plan"] H --> I["Executor 树 execute"] I --> J["DataFrame"]

设计亮点总结

  1. LazyFrame 是逻辑计划的薄壳 — 三字段(DslPlan + OptFlags + 缓存 arena),所有方法只是增量改计划,不碰数据

  2. DslPlan / AExpr / IR 三层表达 — DSL 面向人写(Arc 递归、可序列化),IR 面向优化(Arena + 下标),物理计划面向执行(trait object);各层各司其职

  3. 优化开关是位标志OptFlags 每个优化一个 bit,QueryOptFlags 精确控制到单个优化,eager 只是打开 EAGER 位跳过跨节点优化

  4. 优化顺序即正确性 — 每个 pass 的先后都有注释明确依赖(slice 下推要重跑、谓词先于投影、filter_constraint 先于 simplify_boolean),非随意排列

  5. 可验证的优化器 — debug 构建下对比优化前后 schema,改坏就 panic,保证重写不破坏结果

  6. explain 零执行预览 — 只走 DSL→IR→优化,不跑数据,把优化效果和表达式展开可视化

  7. 一份 IR 多引擎编译 — InMemory / Streaming / GPU 共享同一份优化后的逻辑计划,换引擎只是换编译器(第 05 篇”逻辑/物理分层”的直接受益者)



Previous Post
07-polars Streaming
Next Post
05-polars Expressions and contexts