所有的代码分析基于 polars 1.43.0 版本
分析polars的Streaming
第 06 篇讲了惰性 API,流式引擎是它的直接受益者:同一份优化后的 IR,换一个”编译器”——polars-stream crate。与内存引擎”全量驻留内存”不同,流式引擎把数据切成小块(morsel)流水线式处理,既能处理放不进内存的数据集,又因为数据局部性好而往往更快。本文分析其架构。
架构总览
1. 从 collect 到流式引擎
触发流式执行只需传 engine="streaming"。在 Rust 侧,collect_with_engine 匹配引擎后分派:
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:
/// 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(一口):
#[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>,
}sf: SpillFrame— 实际数据。它是polars-ooc(out-of-core)的产物:内存不足时把 DataFrame 用 IPC + ZSTD 压缩写盘,用时再读回(spill_frame.rs:18-50),这是”处理放不进内存的数据集”的关键seq: MorselSeq— 单调不减的序号,保证 Morsel 在管道中的顺序(值乘 2 是为了给”最后一个 Morsel”标志留出最低位,见 morsel.rs:24-36)source_token/consume_token— 背压机制:consume_token让生产者在消费者处理完前阻塞,防止管道被灌爆
默认 morsel 大小约 10 万行(DEFAULT_IDEAL_MORSEL_SIZE),由 POLARS_STREAM_MORSEL_SIZE 可调。
3. 物理计划:IR → 流式 DAG
3.1 构建
StreamingQuery::build 把 IR 降级成物理节点:
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):
#[derive(Clone, Debug)]
pub enum PhysNodeKind {
InMemorySource {
df: Arc<DataFrame>,
disable_morsel_split: bool,
},流式引擎的”执行体”是一个极简 trait:
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 只有两个关键方法:
update_state— 根据上下游 pipe 的PortState决定自己处于什么状态(阻塞 / 就绪 / 完成),可能被调用多次直到收敛spawn— 真正起任务:接收 morsel → 处理 → 发送给下游
3.2 图结构
/// 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 主循环
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 是一个”状态传播 → 找可运行子图 → 执行”的循环:
update_all_states— 让每个节点的update_state迭代传播PortState直到不动点(graph.rs:77-120)find_runnable_subgraph— 找到所有”可运行的 pipeline blocker”(见下),并向上游扩展出本次能跑的子图run_subgraph— 并行 spawn 子图内所有节点的任务,通过 pipe 传 morsel,等全部完成
4.2 Pipeline Blocker:阶段的分界
/// 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
}核心概念:有些节点(GroupBy、Sort、Join)必须先收齐所有输入才能产出结果——它们是 pipeline blocker。它们把执行切成多个”阶段”:
(pipeline blocker)"} C --> D["聚合输出"]
阶段 1 里 Scan → Filter 是纯流水线:边读边过滤边喂给 GroupBy,不需要一次性加载全表。GroupBy 攒够数据完成分组后,自己转变身份(见 4.3),把结果作为新的源继续往下游流。
4.3 节点状态机:Sink → Source → Done
以 InMemoryJoin 为例,它演示了 blocker 如何”转岗”:
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 { .. })
}Sink状态:吞下左右两个输入的所有 morsel,直到两个输入都Done- 两个输入齐了 → 在
update_state里执行 join,把结果包成InMemorySourceNode,自己变成 Source Source状态:把 join 结果切成 morsel 喂给下游,此时下游的新阶段开始- 全部发完 →
Done
这就是”阶段”的本质:一个 blocker 的完成,就是下一个阶段的开端。整个查询就是若干次”阻塞 → 转变 → 流动”。
5. 内存回退:不支持的操作用内存引擎
文档说”有些操作本质不可流式,或尚未实现——此时回退到内存引擎,用户无需感知”。物理计划降级时发现不支持,就生成 InMemoryMap 节点:
/// 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>,
},实现上它真的去调用内存引擎:
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),设计亮点:
- 回退节点把自己变成流的”吸收端”——上游 morsel 全部汇集成一个 DataFrame
- 用内存引擎(
create_physical_plan+LateMaterializedDataFrame占位源)执行该操作 - 结果再作为 morsel 源重新注入流式图,后续节点继续流式,整条流水线只断这一个点
- 所以是”局部回退”而非”整查询回退”——这正是文档说”用户无需感知”的原因
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),给每个物理节点按内存强度着色:
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 内存引擎
| 维度 | InMemory | Streaming |
|---|---|---|
| 数据流 | 全量驻留内存,一次算完 | morsel 分块,流水线式 |
| 超内存数据集 | 放不下就 OOM | SpillFrame 溢出写盘 |
| 并行度 | 列级/表达式级 rayon | 节点级任务 + num_pipelines 条并行流水线 |
| 阶段划分 | 无(单阶段) | blocker 切分多阶段 |
| 语义 | 完整 | 部分操作回退内存引擎 |
8. 为什么 streaming 不是默认引擎?
既然流式更省内存、大数据上更快,为什么默认还是内存引擎?这是 Polars 工程取舍的核心。
8.1 功能覆盖不完整(最根本原因)
流式引擎并非所有操作都支持,代码里大量 todo!() / unimplemented!()(如 lower_ir.rs:831 的 AnonymousScan、ExtContext、csv 负切片等),碰到就 panic。Polars 为此专门写了 panic 捕获回退机制:
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 数据放得下时,流式常常更慢
- 内存引擎:全量数据驻留内存,整列向量化(SIMD),缓存利用率高,一次性跑完
- 流式引擎:多一套开销——morsel 切分、任务调度、多阶段等待(blocker 必须攒满数据)、状态管理;
GroupBy/Sort/Join这类 blocker 要么占内存要么写盘
“流式更快”主要来自两个特定收益:缓存局部性(小数据块热于缓存)和 IO 与计算重叠(边读边算)。当数据量中等、能全部进缓存时,内存引擎的整列处理反而赢。“差不多”只在某个数据规模区间成立。
8.4 语义和优化差异
- 部分优化只在非流式生效(如 CSE 在流式下处理不同)
- 流式对
maintain_order、窗口函数、部分 join 有限制 - 内存引擎语义完整、结果确定
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”靠两件事:
- 流水线批处理 — 中间结果不整体驻留内存(和 Polars 流式的 morsel 同构)
- Out-of-Core spill(默认开启) — 关键差异:DuckDB 的
HashJoin/HashAggregate自带外部化,memory_limit(默认 80% 物理内存)触顶时自动把 hash 表写盘分片,无需用户开启
| DuckDB | Polars | |
|---|---|---|
| 引擎 | 单引擎,天然批处理 | 双引擎:内存(默认)+ 流式(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 代码里也能看到明确方向:
- 流式引擎是重写而非修补 — 新版
polars-stream是完全重写的 async 引擎,投入大,说明是长期战略 - IO 覆盖已拉齐 — csv/parquet/ipc/ndjson 的 source 和 sink 都已支持,短板主要在计算算子
- 实验性自动选择已就位 —
POLARS_AUTO_STREAMING+ panic 回退(mod.rs:873)就是官方在实验”自动路由到流式”,代码结构已为默认流式优先铺路 - 架构是”壳包零件” —
InMemoryMap/InMemoryJoin让流式引擎能局部吸收内存引擎,未来可以”流式为主干,特殊算子自动镶一块内存执行”
可能的演进路径:
阶段 1(现在) 覆盖补全中,用户显式 engine="streaming"
阶段 2 覆盖与内存引擎语义对齐,AUTO_STREAMING 默认开启
(按计划形状/数据规模/算子类型自动路由)
阶段 3 引擎边界模糊:统一调度器按算子选择
"流式 or 内存" —— 流式为主干,特殊算子自动嵌内存执行
但 Polars 不能像 DuckDB 那样一刀切,因为历史包袱:内存 DataFrame 生态(df.filter(...) 直接操作、与 Arrow 零拷贝互操作、read_csv 返回 DataFrame)天然需要”整表在内存”。所以更可能走”双引擎 + 自动路由 + 逐步把 spill 下沉”,而不是革命式改成单引擎。未来不是”默认用流式”,而是”一个引擎,每个算子自动选最合适的执行策略”。
注:以上演进路径是基于代码结构的推断(重写投入、覆盖速度、AUTO_STREAMING 机制),不代表官方 roadmap 承诺。
设计亮点总结
-
Morsel + 背压 — 数据流的最小单位带序号(保序)+ consume token(背压),管道天然限流,不惧慢消费者
-
阶段式执行 — pipeline blocker(GroupBy/Sort/Join)把执行切成阶段:阻塞上游 → 收齐数据 → 转变身份为 Source → 注入下游,全查询可流式处理
-
状态传播到不动点 —
update_state沿 pipe 迭代传播PortState直到收敛,节点的就绪/阻塞/完成由整图状态决定,而非硬编码阶段号 -
局部内存回退 — 不支持的节点用
InMemoryMap吸收数据、调内存引擎、再回流式图,只断一个点不断全链;is_memory_intensive_pipeline_blocker让可视化如实反映内存压力 -
OOC 溢出 —
SpillFrame把 DataFrame 用 IPC+ZSTD 落盘再读回,配合 morsel 切分,让”数据集超过内存”成为可能 -
可视化驱动调试 — 物理计划图用颜色标注 InMemoryFallback / MemoryIntensive / Generic 三类节点,一眼定位断流点和内存瓶颈
-
复用惰性基建 — 与内存引擎共享同一份优化后的 IR,只是”编译器”不同(呼应第 05 篇的逻辑/物理分层)