Skip to content
DC' Blog
Go back

07-polars Streaming

Tip

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

分析polars的Streaming

第 06 篇讲了惰性 API,流式引擎是它的直接受益者:同一份优化后的 IR,换一个”编译器”——polars-stream crate。与内存引擎”全量驻留内存”不同,流式引擎把数据切成小块(morsel)流水线式处理,既能处理放不进内存的数据集,又因为数据局部性好而往往更快。本文分析其架构。

架构总览

flowchart TD subgraph "polars-lazy" A["collect(engine=streaming)"] end subgraph "polars-stream (流式引擎)" B["skeleton: run_query"] C["physical_plan: IR → PhysNode DAG"] D["graph: nodes + pipes"] E["execute: 阶段式执行循环"] F["Morsel 数据块 / SpillFrame 溢出"] end subgraph "polars-mem-engine (回退)" G["InMemoryMap / InMemoryJoin"] end A --> B B --> C --> D --> E E --> F D -- 不支持的节点 --> G G -- 结果回流式图 --> D

1. 从 collect 到流式引擎

触发流式执行只需传 engine="streaming"。在 Rust 侧,collect_with_engine 匹配引擎后分派:

crates/polars-lazy/src/frame/mod.rsGitHub
        match engine {
            Engine::Streaming => {
                feature_gated!("streaming", self = self.with_streaming(true))
            },
            Engine::Gpu => self = self.with_gpu(true),
            _ => (),
        }

        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,
                )
            }),

流式引擎的真正入口 run_query

crates/polars-stream/src/skeleton.rsGitHub
/// Executes the IR with the streaming engine.
///
/// Unsupported operations can fall back to the in-memory engine.
///
/// Returns:
/// - `Ok(QueryResult::Single(DataFrame))` when collecting to a single sink.
/// - `Ok(QueryResult::Multiple(Vec<DataFrame>))` when collecting to multiple sinks.
/// - `Err` if the IR can't be executed.
///
/// Returned `DataFrame`s contain data only for memory sinks,
/// `DataFrame`s corresponding to file sinks are empty.
pub fn run_query(
    node: Node,
    ir_arena: &mut Arena<IR>,
    expr_arena: &mut Arena<AExpr>,
) -> PolarsResult<QueryResult> {
    StreamingQuery::build(node, ir_arena, expr_arena)?.execute()
}

注释写得很清楚:“Unsupported operations can fall back to the in-memory engine.” 整个流式引擎的骨架就 3 步:build(IR → 物理 DAG)→ execute(阶段式执行)→ 收集结果。

2. 数据单元:Morsel

流式引擎的最小处理单位不是行、不是整个 DataFrame,而是 Morsel(一口):

crates/polars-stream/src/morsel.rsGitHub
#[derive(Debug)]
pub struct Morsel {
    /// The data contained in this morsel.
    sf: SpillFrame,

    /// The sequence number of this morsel. May only stay equal or increase
    /// within a pipeline.
    seq: MorselSeq,

    /// A token that indicates which source this morsel originates from.
    source_token: SourceToken,

    /// Used to notify someone when this morsel is consumed, to provide backpressure.
    consume_token: Option<WaitToken>,
}

默认 morsel 大小约 10 万行DEFAULT_IDEAL_MORSEL_SIZE),由 POLARS_STREAM_MORSEL_SIZE 可调。

3. 物理计划:IR → 流式 DAG

3.1 构建

StreamingQuery::build 把 IR 降级成物理节点:

crates/polars-stream/src/skeleton.rsGitHub
impl StreamingQuery {
    pub fn build(
        node: Node,
        ir_arena: &mut Arena<IR>,
        expr_arena: &mut Arena<AExpr>,
    ) -> PolarsResult<Self> {
展开折叠代码 (94-108 行,共 15 行)
        if let Ok(visual_path) = std::env::var("POLARS_VISUALIZE_IR") {
            let plan = IRPlan {
                lp_top: node,
                lp_arena: ir_arena.clone(),
                expr_arena: expr_arena.clone(),
            };
            let visualization = plan.display_dot().to_string();
            std::fs::write(visual_path, visualization).unwrap();
        }
        let mut phys_sm = SlotMap::with_capacity_and_key(ir_arena.len());
        let sortedness = IRPlanSorted::resolve(node, ir_arena, expr_arena);
        let ctx = StreamingLowerIRContext {
            prepare_visualization: cfg_prepare_visualization_data(),
            sortedness: &sortedness,
        };
        let root_phys_node = crate::physical_plan::build_physical_plan(
            node,
            ir_arena,
            expr_arena,
            &mut phys_sm,
            ctx,
        )?;
        if let Ok(visual_path) = std::env::var("POLARS_VISUALIZE_PHYSICAL_PLAN") {
            let visualization =
                crate::physical_plan::visualize_plan(root_phys_node, &phys_sm, expr_arena);
            std::fs::write(visual_path, visualization).unwrap();
        }

        let (mut graph, phys_to_graph) =
            crate::physical_plan::physical_plan_to_graph(root_phys_node, &phys_sm, expr_arena)?;

链路:IR → build_physical_plan → PhysNode(SlotMap)→ physical_plan_to_graph → Graph

PhysNodeKind 是物理节点类型枚举(类似内存引擎的 Executor):

crates/polars-stream/src/physical_plan/mod.rsGitHub
#[derive(Clone, Debug)]
pub enum PhysNodeKind {
    InMemorySource {
        df: Arc<DataFrame>,
        disable_morsel_split: bool,
    },

流式引擎的”执行体”是一个极简 trait:

crates/polars-stream/src/nodes/mod.rsGitHub
pub trait ComputeNode: Send {
    /// The name of this node.
    fn name(&self) -> &str;

    /// Update the state of this node given the state of our input and output
    /// ports. May be called multiple times until fully resolved for each
    /// execution phase.
    ///
    /// For each input pipe `recv` will contain a respective state of the
    /// send port that pipe is connected to when called, and it is expected when
    /// `update_state` returns it contains your computed receive port state.
    ///
    /// Similarly, for each output pipe `send` will contain the respective
    /// state of the input port that pipe is connected to when called, and you
    /// must update it to contain the desired state of your output port.
    fn update_state(
        &mut self,
        recv: &mut [PortState],
        send: &mut [PortState],
        state: &StreamingExecutionState,
    ) -> PolarsResult<()>;

ComputeNode 只有两个关键方法:

3.2 图结构

crates/polars-stream/src/graph.rsGitHub
/// Represents the compute graph.
///
/// The `nodes` perform computation and the `pipes` form the connections between nodes
/// that data is sent through.
#[derive(Default)]
pub struct Graph {
    pub nodes: SlotMap<GraphNodeKey, GraphNode>,
    pub pipes: SlotMap<LogicalPipeKey, LogicalPipe>,
}

Graph有向无环图nodes(ComputeNode)+ pipes(LogicalPipe,数据通道)。物理节点之间通过 pipe 相连,每个 pipe 两端各有一个 PortState

4. 执行模型:阶段式流水线

4.1 主循环

crates/polars-stream/src/execute.rsGitHub
    let mut pipe_seq_offsets = SecondaryMap::new();
    loop {
        // Update the states.
        if polars_core::config::verbose() {
            eprintln!("polars-stream: updating graph state");
        }
        graph.update_all_states(&state, metrics.as_deref())?;
展开折叠代码 (335-346 行,共 12 行)
        if let Some(m) = metrics.as_ref() {
            m.lock().flush(&graph.pipes);
        }

        ASYNC.block_in_place_on(async {
            // TODO: track this in metrics.
            while let Ok(handle) = subphase_tasks_recv.try_recv() {
                handle.await.unwrap()?;
            }
            PolarsResult::Ok(())
        })?;
        // Find a subgraph to run.
        let (nodes, pipes) = find_runnable_subgraph(graph);
展开折叠代码 (349-357 行,共 9 行)
        if polars_core::config::verbose() {
            for node in &nodes {
                eprintln!(
                    "polars-stream: running {} in subgraph",
                    graph.nodes[*node].compute.name()
                );
            }
        }
        if nodes.is_empty() {
            break;
        }

        // Run the subgraph until phase completion.
        run_subgraph(
            graph,
            &nodes,
            &pipes,
            &mut pipe_seq_offsets,
            &state,
            metrics.clone(),
        )?;
        ASYNC.block_in_place_on(async {
            // TODO: track this in metrics.
            while let Ok(handle) = subphase_tasks_recv.try_recv() {
                handle.await.unwrap()?;
            }
            PolarsResult::Ok(())
        })?;
        if polars_core::config::verbose() {
            eprintln!("polars-stream: done running graph phase");
        }
    }

execute_graph 是一个”状态传播 → 找可运行子图 → 执行”的循环:

  1. update_all_states — 让每个节点的 update_state 迭代传播 PortState 直到不动点(graph.rs:77-120)
  2. find_runnable_subgraph — 找到所有”可运行的 pipeline blocker”(见下),并向上游扩展出本次能跑的子图
  3. run_subgraph — 并行 spawn 子图内所有节点的任务,通过 pipe 传 morsel,等全部完成

4.2 Pipeline Blocker:阶段的分界

crates/polars-stream/src/execute.rsGitHub
/// Finds all runnable pipeline blockers in the graph, that is, nodes which:
///  - Only have blocked output ports.
///  - Have at least one ready input port connected to a ready output port.
fn find_runnable_pipeline_blockers(graph: &Graph) -> Vec<GraphNodeKey> {
    let mut blockers = Vec::new();
    for (node_key, node) in graph.nodes.iter() {
        // TODO: how does the multiplexer fit into this?
        let only_has_blocked_outputs = node
            .outputs
            .iter()
            .all(|o| graph.pipes[*o].send_state == PortState::Blocked);
        if !only_has_blocked_outputs {
            continue;
        }

        let has_input_ready = node.inputs.iter().any(|i| {
            graph.pipes[*i].send_state == PortState::Ready
                && graph.pipes[*i].recv_state == PortState::Ready
        });
        if has_input_ready {
            blockers.push(node_key);
        }
    }
    blockers
}

核心概念:有些节点(GroupBySortJoin)必须先收齐所有输入才能产出结果——它们是 pipeline blocker。它们把执行切成多个”阶段”:

flowchart LR subgraph Phase1 A["Scan 源"] --> B["Filter"] end B --> C{"GroupBy
(pipeline blocker)"} C --> D["聚合输出"]

阶段 1 里 Scan → Filter 是纯流水线:边读边过滤边喂给 GroupBy,不需要一次性加载全表。GroupBy 攒够数据完成分组后,自己转变身份(见 4.3),把结果作为新的源继续往下游流。

4.3 节点状态机:Sink → Source → Done

InMemoryJoin 为例,它演示了 blocker 如何”转岗”:

crates/polars-stream/src/nodes/joins/in_memory.rsGitHub
    fn update_state(
        &mut self,
        recv: &mut [PortState],
        send: &mut [PortState],
        state: &StreamingExecutionState,
    ) -> PolarsResult<()> {
展开折叠代码 (50-68 行,共 19 行)
        assert!(recv.len() == 2 && send.len() == 1);

        // If the output doesn't want any more data, transition to being done.
        if send[0] == PortState::Done && !matches!(self.state, InMemoryJoinState::Done) {
            self.state = InMemoryJoinState::Done;
        }

        // If the input is done, transition to being a source.
        if let InMemoryJoinState::Sink { left, right } = &mut self.state {
            if recv[0] == PortState::Done && recv[1] == PortState::Done {
                let left_df = left.get_output()?.unwrap();
                let right_df = right.get_output()?.unwrap();
                let source_node = InMemorySourceNode::new(
                    Arc::new((self.joiner)(left_df, right_df)?),
                    MorselSeq::default(),
                );
                self.state = InMemoryJoinState::Source(source_node);
            }
        }

        match &mut self.state {
            InMemoryJoinState::Sink { left, right, .. } => {
                left.update_state(&mut recv[0..1], &mut [], state)?;
                right.update_state(&mut recv[1..2], &mut [], state)?;
                send[0] = PortState::Blocked;
            },
            InMemoryJoinState::Source(source_node) => {
                recv[0] = PortState::Done;
                recv[1] = PortState::Done;
                source_node.update_state(&mut [], send, state)?;
            },
            InMemoryJoinState::Done => {
                recv[0] = PortState::Done;
                recv[1] = PortState::Done;
                send[0] = PortState::Done;
            },
        }
        Ok(())
    }

    fn is_memory_intensive_pipeline_blocker(&self) -> bool {
        matches!(self.state, InMemoryJoinState::Sink { .. })
    }

这就是”阶段”的本质:一个 blocker 的完成,就是下一个阶段的开端。整个查询就是若干次”阻塞 → 转变 → 流动”。

5. 内存回退:不支持的操作用内存引擎

文档说”有些操作本质不可流式,或尚未实现——此时回退到内存引擎,用户无需感知”。物理计划降级时发现不支持,就生成 InMemoryMap 节点:

crates/polars-stream/src/physical_plan/mod.rsGitHub
    /// Generic fallback for (as-of-yet) unsupported streaming mappings.
    /// Fully sinks all data to an in-memory data frame and uses the in-memory
    /// engine to perform the map.
    InMemoryMap {
        input: PhysStream,
        map: Arc<dyn DataFrameUdf>,
        /// A formatted string of what the in-memory map is. This usually calls format on the IR.
        format_str: Option<String>,
    },

实现上它真的去调用内存引擎:

crates/polars-stream/src/physical_plan/lower_ir.rsGitHub
            if options.maintain_order && options.keep_strategy == UniqueKeepStrategy::Last {
                // Unfortunately the order-preserving groupby always orders by the first occurrence
                // of the group so we can't lower this and have to fallback.
                let input_schema = phys_input.output_schema(phys_sm).clone();
                let lmdf = Arc::new(LateMaterializedDataFrame::default());
展开折叠代码 (1520-1537 行,共 18 行)
                let mut lp_arena = Arena::default();
                let input_lp_node = lp_arena.add(lmdf.clone().as_ir_node(input_schema));
                let distinct_lp_node = lp_arena.add(IR::Distinct {
                    input: input_lp_node,
                    options,
                });
                let executor = Mutex::new(create_physical_plan(
                    distinct_lp_node,
                    &mut lp_arena,
                    expr_arena,
                    Some(crate::dispatch::build_streaming_query_executor),
                )?);

                let format_str = ctx.prepare_visualization.then(|| {
                    let mut buffer = String::new();
                    write_ir_non_recursive(
                        &mut buffer,
                        ir_arena.get(node),
                        expr_arena,
                        phys_input.output_schema(phys_sm),

设计亮点

6. 可视化:show_graph(plan_stage=“physical”)

Python 的 show_graph(plan_stage="physical", engine="streaming")to_dot_streaming_phys(crates/polars-python/src/lazyframe/general.rs:505),内部调用 visualize_physical_plan(skeleton.rs:41-59),给每个物理节点按内存强度着色:

crates/polars-stream/src/physical_plan/fmt.rsGitHub
pub enum NodeStyle {
    InMemoryFallback,
    MemoryIntensive,
    Generic,
}

impl NodeStyle {
    const COLOR_IN_MEM_FALLBACK: &str = "0.0 0.3 1.0"; // Pastel red
    const COLOR_MEM_INTENSIVE: &str = "0.16 0.3 1.0"; // Pastel yellow

    /// Returns a style for a node kind.
    pub fn for_node_kind(kind: &PhysNodeKind) -> Self {
        use PhysNodeKind as K;
        match kind {
            K::InMemoryMap { .. } | K::InMemoryJoin { .. } | K::ColumnarFunction { .. } => {
                Self::InMemoryFallback
            },
            K::InMemorySource { .. }
            | K::InputIndependentSelect { .. }
            | K::NegativeSlice { .. }
            | K::InMemorySink { .. }
            | K::Sort { .. }
            | K::GroupBy { .. }
            | K::EquiJoin { .. }
            | K::SemiAntiJoin { .. }
            | K::CrossJoin { .. }
            | K::Multiplexer { .. }
            | K::Gather { .. } => Self::MemoryIntensive,
            #[cfg(feature = "iejoin")]
            K::RangeJoin { .. } => Self::MemoryIntensive,
            #[cfg(feature = "merge_sorted")]
            K::MergeSorted { .. } => Self::MemoryIntensive,
            _ => Self::Generic,
        }
    }
图例颜色节点含义
InMemoryFallback粉红InMemoryMap / InMemoryJoin / ColumnarFunction回退到内存引擎
MemoryIntensive黄色GroupBy / Sort / Join / InMemorySource需要在内存/磁盘缓存大量数据
Generic默认Filter / Select纯流水线,边读边处理

调试内存/性能问题时,看图里有哪些红色/黄色节点即可定位瓶颈——黄色意味着”这里的数据会被攒起来”,红色意味着”这里断流了”。

7. 流式 vs 内存引擎

维度InMemoryStreaming
数据流全量驻留内存,一次算完morsel 分块,流水线式
超内存数据集放不下就 OOMSpillFrame 溢出写盘
并行度列级/表达式级 rayon节点级任务 + num_pipelines 条并行流水线
阶段划分无(单阶段)blocker 切分多阶段
语义完整部分操作回退内存引擎

8. 为什么 streaming 不是默认引擎?

既然流式更省内存、大数据上更快,为什么默认还是内存引擎?这是 Polars 工程取舍的核心。

8.1 功能覆盖不完整(最根本原因)

流式引擎并非所有操作都支持,代码里大量 todo!() / unimplemented!()(如 lower_ir.rs:831 的 AnonymousScan、ExtContext、csv 负切片等),碰到就 panic。Polars 为此专门写了 panic 捕获回退机制

crates/polars-lazy/src/frame/mod.rsGitHub
        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
            Ok(v) => Some(v),
            Err(e) => {
                // Fallback to normal engine if error is due to not being implemented
                // and auto_streaming is set, otherwise propagate error.
                if e.downcast_ref::<&str>()
                    .is_some_and(|s| s.starts_with("not yet implemented"))
                {
                    if polars_core::config::verbose() {
                        eprintln!(
                            "caught unimplemented error in new streaming engine, falling back to normal engine"
                        );
                    }
                    None
                } else {
                    std::panic::resume_unwind(e)
                }
            },
        }

catch_unwind 捕获 “not yet implemented” panic 后回退内存引擎。这意味着流式执行结果不确定——同一个查询可能因”恰好撞上不支持的操作”而中断重来。作为默认引擎,行为必须可预测。

8.2 “不容易 OOM”是有代价的

流式的 OOM 防护靠 SpillFrame 溢出写盘。但 spill 只在内存吃紧时触发——数据放得下时它根本不会发生,这时候流式没有这个收益,反而背着全部分块/调度开销。

8.3 数据放得下时,流式常常更慢

“流式更快”主要来自两个特定收益:缓存局部性(小数据块热于缓存)和 IO 与计算重叠(边读边算)。当数据量中等、能全部进缓存时,内存引擎的整列处理反而赢。“差不多”只在某个数据规模区间成立。

8.4 语义和优化差异

8.5 Polars 的做法:显式 opt-in + 两个逃生舱

环境变量行为
POLARS_FORCE_STREAMING=1强制流式
POLARS_AUTO_STREAMING=1自动尝试流式,撞上不支持的操作回退内存(见 8.1 的 panic 捕获)

把”要不要赌一把流式”的选择权交给用户,而不是让引擎替你猜——因为一旦猜错(数据其实放得下),反而得到更慢的查询。这本质是 “可预测性优先于极端性能” 的工程取舍。

9. 流式的未来:DuckDB 的启示

9.1 DuckDB 不是”默认流式”,而是”生来流式”

DuckDB 执行器是向量化火山模型:数据以 DataChunk(默认 2048 行)为单位,在 operator 之间的 pipeline 里拉取式流动。对 DuckDB 而言”流式”不是可选模式,而是执行器的物理形态——它只有一套引擎,没有 engine="streaming" 这种参数。

它”不 OOM”靠两件事:

  1. 流水线批处理 — 中间结果不整体驻留内存(和 Polars 流式的 morsel 同构)
  2. Out-of-Core spill(默认开启) — 关键差异:DuckDB 的 HashJoin/HashAggregate 自带外部化,memory_limit(默认 80% 物理内存)触顶时自动把 hash 表写盘分片,无需用户开启
DuckDBPolars
引擎单引擎,天然批处理双引擎:内存(默认)+ 流式(opt-in)
大数据join/聚合默认 spill内存引擎不 spill,只有流式引擎会 spill
”流式”参数无此概念engine="streaming"
出身数据库内核(SQL 查询 = operator pipeline)DataFrame 库(内存优先)

当然 DuckDB 也不是绝对不 OOM:spill 只在 memory_limit 内有效,超限且无法 spill 的操作(大 cross join 输出、物化窗口函数、递归 CTE)会直接 abort。

9.2 对 Polars 演进方向的启示

DuckDB 证明了单引擎 + 默认批处理 + 默认 spill 是可行的终态。从 Polars 代码里也能看到明确方向:

可能的演进路径

阶段 1(现在)  覆盖补全中,用户显式 engine="streaming"
阶段 2          覆盖与内存引擎语义对齐,AUTO_STREAMING 默认开启
                (按计划形状/数据规模/算子类型自动路由)
阶段 3          引擎边界模糊:统一调度器按算子选择
                "流式 or 内存" —— 流式为主干,特殊算子自动嵌内存执行

但 Polars 不能像 DuckDB 那样一刀切,因为历史包袱:内存 DataFrame 生态(df.filter(...) 直接操作、与 Arrow 零拷贝互操作、read_csv 返回 DataFrame)天然需要”整表在内存”。所以更可能走”双引擎 + 自动路由 + 逐步把 spill 下沉”,而不是革命式改成单引擎。未来不是”默认用流式”,而是”一个引擎,每个算子自动选最合适的执行策略”。

注:以上演进路径是基于代码结构的推断(重写投入、覆盖速度、AUTO_STREAMING 机制),不代表官方 roadmap 承诺。

设计亮点总结

  1. Morsel + 背压 — 数据流的最小单位带序号(保序)+ consume token(背压),管道天然限流,不惧慢消费者

  2. 阶段式执行 — pipeline blocker(GroupBy/Sort/Join)把执行切成阶段:阻塞上游 → 收齐数据 → 转变身份为 Source → 注入下游,全查询可流式处理

  3. 状态传播到不动点update_state 沿 pipe 迭代传播 PortState 直到收敛,节点的就绪/阻塞/完成由整图状态决定,而非硬编码阶段号

  4. 局部内存回退 — 不支持的节点用 InMemoryMap 吸收数据、调内存引擎、再回流式图,只断一个点不断全链;is_memory_intensive_pipeline_blocker 让可视化如实反映内存压力

  5. OOC 溢出SpillFrame 把 DataFrame 用 IPC+ZSTD 落盘再读回,配合 morsel 切分,让”数据集超过内存”成为可能

  6. 可视化驱动调试 — 物理计划图用颜色标注 InMemoryFallback / MemoryIntensive / Generic 三类节点,一眼定位断流点和内存瓶颈

  7. 复用惰性基建 — 与内存引擎共享同一份优化后的 IR,只是”编译器”不同(呼应第 05 篇的逻辑/物理分层)



Next Post
06-polars Lazy API