执行器批处理:面向批量的元组处理
引言
PostgreSQL 的执行器长期以来都是 逐元组(tuple-at-a-time) 的:每个计划节点通常向子节点要一个元组、处理后再向上返回一个结果元组。这种设计简单,在 OLTP 场景下表现良好,但在分析型和批量负载中,每元组的开销——尤其是重复的函数调用和表达式求值——往往会成为主要成本。在 PGConf.dev 2025 上,社区讨论了 PostgreSQL 是否能够支持一种 额外的批处理执行器,在节点之间传递 成批元组 而不是一次一个 slot。
在那次讨论以及 Andres Freund 和 David Rowley 的私下交流之后,Amit Langote 于 2025 年 9 月在 pgsql-hackers 上发布了题为 「Batching in executor」 的补丁系列。该系列引入了 批处理表访问方法(Table AM)API,在执行器中增加了 支持批处理的接口(ExecProcNodeBatch、TupleBatch),并原型化了 面向批量的表达式求值(包括批量 qual 和聚合转换函数)。目标是降低每元组开销、为聚合函数中的 SIMD 等未来优化铺路,并为受益于批量执行的列存或压缩表 AM 打基础。
为何重要
- 执行器开销:在 CPU 受限、IO 极少的负载下(例如全缓存表),大量时间消耗在执行器内部。批处理减少了进入表 AM 和表达式解释器的调用次数,并可通过一次对多行求值来削减函数调用开销。
- 聚合与分析:批量转换求值(如
count(*)、sum()、avg())可以按批而非按行支付 fmgr 成本,并为向量化或 SIMD 友好路径打开空间。 - 未来表 AM:批量执行器便于列存或压缩表 AM(如 Parquet 风格)以原生批量格式传递数据,而不必过早物化为堆元组。
- OLTP 安全:设计上保留现有逐行路径不变;批处理为可选(例如通过
executor_batchingGUC),因此 OLTP 负载不受影响。
理解本线程的内容有助于把握 PostgreSQL 如何可能增加一条面向批量的执行路径,以及社区在物化、ExprContext、EEOP 设计等方面正在权衡的内容。
技术分析
补丁结构
系列分为两部分:
- 0001–0003 — 基础:批处理表 AM API、heapam 批处理实现、与 SeqScan 对接的执行器批处理接口。
- 0004–0008 — 原型(WIP/PoC):支持批处理的 Agg 节点、TupleBatch 相关新 EEOP、批量 qual 求值、批量聚合转换(按行循环与「直接」批 fmgr)。
核心抽象
表 AM 批处理 API(0001) 新增回调允许表 AM 一次返回 多个元组 而非一个。对 heap 而言:
HeapBatch保存单页内的元组;大小受EXEC_BATCH_ROWS(当前 64)和「不跨页」限制。heapgettup_pagemode_batch()从当前页填充HeapTupleData数组,逻辑与heapgettup_pagemode()对应,但面向一批。可见性和扫描方向处理方式一致。
通用层在 tableam.h 中引入 batch 类型与操作,以便其他 AM 提供自己的批量格式与实现。
执行器批处理路径(0002–0003)
TupleBatch是批处理模式下在节点间传递的容器,可持有 AM 原生批(如堆元组)或物化后的 slot,视路径而定。ExecProcNodeBatch()对应ExecProcNode():返回TupleBatch*而非TupleTableSlot*。PlanState增加ExecProcNodeBatch函数指针,沿用与逐行路径相同的「首次调用」与插桩包装。- SeqScan 获得:
- 批量驱动的 slot 路径:仍每次返回一个 slot,但从内部批中填充,减少对 AM 的调用。
- 批路径:当父节点支持批处理时,SeqScan 的
ExecProcNodeBatch直接返回TupleBatch(如通过ExecSeqScanBatch*)。
因此前三个补丁提供:(1) 能产生批的表 AM;(2) 请求与传递批的执行器 API;(3) SeqScan 作为首个既能消费又能产生批的节点。
面向批量的表达式求值(0004–0008)
后续补丁尝试对 一批 行做表达式求值:
- Agg 的批量输入:Agg 可通过
ExecProcNodeBatch()从子节点拉取TupleBatch,并成批喂入聚合转换函数。 - 新 EEOP:表达式解释器增加针对 TupleBatch 的步骤——例如将属性取到批量向量、对一批求 qual、以及按行在解释器内循环(ROWLOOP)或按批调用转换函数(DIRECT)执行聚合转换。
- 批量 qual 求值:一批元组可用单次遍历完成过滤(ExecQualBatch 及相关 EEOP),降低每行解释器和 fmgr 开销。
提供了两种批量聚合原型路径:一是在解释器内按行迭代(每行转换);二是每批调用一次转换函数(每批 fmgr)。在 Amit 的基准中,当执行器成本占主导时,后者收益更大。
设计选择与未决点
- 单页批:堆批限于一页,因此批大小可能小于
EXEC_BATCH_ROWS(例如每页元组少或 qual 选择性高)。线程中提到未来可改进:跨页批或扫描在批未满时继续要元组。 - TupleBatch 与 ExprContext:补丁在
ExprContext上扩展了scan_batch、inner_batch、outer_batch。每批表达式求值仍使用ecxt_per_tuple_memory,Amit 指出这「 arguably 滥用了」每元组契约。批作用域内存 的更清晰模型仍待定义。 - 物化:目前面向批的表达式求值通常作用在已物化到 slot(或堆元组数组)的元组上。长期目标是在 原生批格式(如列存或压缩)上做表达式求值而不强制物化;这需要更多基础设施(如 AM 控制的表达式求值或面向批的算子)。
社区观点
Tomas Vondra:批设计与索引预取
Tomas 将本补丁与 索引预取 工作(他参与其中)对比,后者也在索引 AM 与执行器之间引入「批」概念。他指出两种设计因目标不同而不同:
- 索引预取:共享的批结构由索引 AM 填充,之后由
indexam.c管理;批在此之后与 AM 无关。 - 执行器批处理:每个表 AM 可产生自己的批格式(如
HeapBatch),包装在带 AM 特定操作的通用TupleBatch中。执行器保留 TAM 特定优化,并依赖 TAM 对批内容进行操作。
Amit 同意:执行器批处理旨在保留 TAM 特定行为并尽可能避免过早物化;预取则追求由 indexam 统一的批格式。两种设计都与各自目标一致。
Tomas 还问:(1) 何时必须将 TupleBatch 物化为通用格式(如 slot)?(2) 表达式能否直接在「自定义」批(如压缩/列存)上执行?Amit 回复说目前表达式求值仍需物化,但设计上不应阻碍未来在原生批数据上求值(如列存或 Parquet 风格)。给表 AM 更多控制「如何在其批数据上求值」是可能的后续扩展。
Tomas Vondra:TPC-H Q22 段错误与 v3 修复
Tomas 报告在启用批处理运行 TPC-H 时出现 段错误,且 仅出现在 Q22,堆栈始终指向同一处:numeric_avg_accum 收到 NULL 的 datum(DatumGetNumeric(X=0)),从 ExecAggPlainTransBatch 到 agg_retrieve_direct_batch。因此问题在批量聚合路径:转换函数收到了本不应为 NULL 的 NULL。
Amit 将崩溃追溯到 表达式解释器。两个不同的 EEOP(分别对应 ROWLOOP 和 DIRECT 批量聚合路径)都调用了 同一个辅助函数。该辅助函数在运行时再次推导 opcode(如通过 ExecExprEvalOp(op))。在某些构建(如 macOS 上的 clang-17)中,这两个 EEOP 分支编译成相同代码,导致 分发标签地址相同。解释器按标签地址做反向查找时可能返回错误的 EEOP;初始化路径可能以为在执行 ROWLOOP EEOP,而执行路径却按 DIRECT EEOP 行为,导致状态错误和 NULL/崩溃。
v3 中的修复(补丁 0009)是 将共享辅助拆成两个函数,每个 EEOP 一个,这样辅助不再重新推导 opcode。修改后 Amit 在 macOS clang-17 上无法再复现崩溃。同一修复也解决了 Tomas 遇到的 TPC-H Q22 段错误。
Bruce Momjian:POSETTE 与 OLTP
Bruce 引用了 POSETTE 2025 的两场演讲做背景:一场讲数据仓库需求,一场讲 「Hacking Postgres Executor For Performance」。Amit(第二场演讲者)确认批处理设计上不会给 OLTP 路径增加明显开销;逐行路径仍是默认且未改动。
关闭批处理时的回归
Tomas 观察到在 关闭批处理(executor_batching=off)时,打补丁的树可能比未打补丁的 master 更慢——即新代码路径未启用时存在回归。Amit 复现了该现象:例如单聚合 SELECT count(*) FROM bar 和多聚合 SELECT avg(a), … FROM bar 在关闭批处理时相比 master 有约 3–18% 的变慢,具体取决于行数和并行度。他承认回归并表示正在排查。确保在关闭批处理时零或极小成本是合入基础补丁的重要前提。
技术细节
实现要点
- 批大小:
EXEC_BATCH_ROWS为 64。堆批还受单页限制,实际批大小可能更小(如 Amit 的 1000 万行测试表中每页约 43 行)。 - 插桩:
ExecProcNodeBatch使用与逐行路径相同的插桩钩子;批调用的「元组」数记为返回的TupleBatch的有效行数(b->nvalid),便于 EXPLAIN ANALYZE 等统计保持意义。 - GUC:在 v4/v5 中 GUC 为
executor_batch_rows(0 = 关闭批处理;例如 64 = 批大小)。
边界与限制
- 稀疏批:高选择性 qual 下,过滤后批内有效行可能很少。线程建议未来支持跨页批或扫描在批未满时继续填充。
- ExprContext 与批生命周期:用
ecxt_per_tuple_memory承担每批工作是目前的设计债;独立的批作用域分配器或上下文会更清晰。 - 并行与嵌套 Agg:Tomas 崩溃的堆栈涉及并行 worker(Gather/GatherMerge)和嵌套聚合(如子计划上的 Agg)。NULL datum 问题出在该场景下使用的批量转换路径;v3 的 EEOP 辅助拆分从根因上修复,而非针对单条查询。
基准摘要(来自 Amit v1 邮件)
均在完全 VACUUM 的表、大 shared_buffers 且预热缓存下运行;时间单位为 ms,「off」= 批处理关,「on」= 批处理开;负 %diff 表示「on」更快。
- 单聚合、无 WHERE(如
SELECT count(*) FROM bar_N):仅批量 SeqScan(0001–0003)约快 8–22%;加上批量 agg(0001–0007)在部分规模下约快 33–49%。 - 单聚合、有 WHERE:批量 agg + 批量 qual(0001–0008)约快 31–40%。
- 五聚合、无 WHERE:批量转换(每批 fmgr,0001–0007)约快 22–31%。
- 五聚合、有 WHERE:批量转换 + 批量 qual(0001–0008)约快 18–32%。
因此在执行器占主导(IO 极少)时,批处理一致降低 CPU 时间,最大收益来自减少每行 fmgr 调用和对整批求 qual。
演进:v4 与 v5
后续修订在基础之上增加了可观测性与批量 qual 工作:
-
v4(2025 年 10 月):新增 EXPLAIN (BATCHES)(补丁 0003)用于展示元组批处理统计,对应此前「插桩」的待办项。Amit 报告在 v4 中 关闭批处理时的回归(相对未打补丁的 master)已不再出现——可能与移除
HeapScanData中的多余字段以及避免混用编译器(gcc vs clang)比较有关。新基准使用SELECT * FROM t LIMIT 1 OFFSET n;在batch=64下,无 WHERE 时约快 22–26%,WHERE a > 0时约快 21–48%;变形开销大的情况(如对最后一列求 qual)收益较小。Daniil Davydov 审阅了堆批处理代码(如SO_ALLOW_PAGEMODE断言、heapgettup_pagemode_batch逻辑与风格),Amit 在 v4 中已回应。 -
v5(2026 年 1 月):0001–0003 仍为核心(批表 AM API、SeqScan + TupleBatch、EXPLAIN BATCHES)。0004 增加 ExecQualBatch 用于批量 qual 求值(WIP);0005 将批量 qual 的 opcode 移到 专用解释器,使逐行路径(
ExecInterpExpr)不被修改,从而在executor_batch_rows=0时避免额外成本。Amit 移除了 BatchVector 中间表示(qual 直接读取批内 slot 的tts_values)。仍有两个待解决问题:(1) 在 0% 选择性(所有行不满足 qual)时,即使关闭批处理,打上批量 qual 补丁后逐行路径仍更热;(2) 对靠后列的 qual(变形开销大)批处理几乎无收益。近期补丁中的 GUC 为executor_batch_rows(0 = 关闭)。
当前状态
- 线程 仍在进行;最近消息为 2026 年 1 月。系列仍为 进行中。
- v5 为当前版本。0001–0003(表 AM 批 API、heapam 批、SeqScan + TupleBatch、EXPLAIN BATCHES)是拟先审阅并争取合入的部分。
- v5 的 0004–0005 为 实验性(ExecQualBatch、批量 qual 专用解释器)。
- v3 已包含针对 TPC-H Q22 / 批量 agg 崩溃的 段错误修复(拆分 EEOP 辅助);v4/v5 在此基础上演进。
- 待办:(1) 当批量 qual(0004–0005)在树中但
executor_batch_rows=0时的逐行路径回归(如 0% 选择性);(2) 批作用域内存与 ExprContext;(3) 跨页批与在原生/压缩批格式上求值等后续工作。
小结
Amit Langote 的「Batching in executor」系列在 PostgreSQL 执行器中引入了一条 面向批量的路径:表 AM 可返回成批元组,执行器通过 TupleBatch 请求与传递批,SeqScan 是首个接入该路径的节点。v4、v5 增加了 EXPLAIN (BATCHES) 用于可观测性,并原型化了 批量 qual 求值 与专用解释器,以保持逐行路径不变。基准显示在开启批处理时收益可观(多为 20–50%);此前「关闭批处理」时的回归在 v4 中已解决,但仍有问题:在打上批量 qual 补丁且关闭批处理时(如 0% 选择性)逐行路径的成本。
审阅者提出了重要问题:与其他「批」类工作(如索引预取)的协调、物化与未来「在批上求值」的设计、TPC-H Q22 段错误(v3 修复)以及 Daniil 对堆批处理的审阅(v4 已回应)。当前审阅与合入重点为基础补丁(0001–0003)与 EXPLAIN BATCHES。