原文地址:https://github.com/malisper/pgrust/pull/84
修复 #83:generate_series 聚合及其底层的两个成本 - #84
#84
开启
gergesh
希望将 3 个提交合并到
malisper:main
从
gergesh:fix/83-generate-series
+1,507
-197
变更行数:1507 处添加和 197 处删除
对话 3 (3)
提交 3 (3)
检查 0 (0)
文件变更 37 (37)
对话
@gergesh
gergesh 评论于
上周
·
修复 #83。
SELECT sum(i) FROM generate_series(1, 100000000) t(i) 耗时 11.4 秒------比 C 语言的 Postgres (8.6 秒) 慢,比 DuckDB/ClickHouse 慢约 110 倍。包含三个提交:修复本身,以及调查过程中发现的其下的两个通用成本。
1. 将 generate_series 上的普通聚合直接折叠(Fold)
FunctionNext 是 C 语言精确的,因此第一次拉取(pull)会将整个 SRF 排空到一个 Tuplestore 中------1 亿个堆元组,超出 work_mem 后溢出到磁盘------而后续每次拉取只会读回一个元组。该存储为向后扫描、重新扫描和 WITH ORDINALITY 提供了支持;而一次通过的普通聚合(plain aggregate)不会用到其中任何一个。
nodefunctionscan::series 识别出 generate_series(int4|int8) 扫描,并批量生成其值。SRF 自身的 GenerateSeries*::new 仍然打开数据源(feed),因此参数求值、严格的 NULL 契约和步长为零时的 22023 错误保持不变,并且在拉取中位于相同的位置。只有发出循环(emission loop)是新的,它是一个计数形式,而不是每个值的状态机,因此批处理循环不带分支(branch)。matches_srf_state_machine 在六个批处理粒度上,针对方向、非精确跨度和下一个值溢出等角落情况,将两者相互固定(pins)。
lanev2::series_fold 消费它:一个 AGG_PLAIN 节点通过 lanefold::fold_batch 折叠每个分阶段(staged)的批次,这与堆(heap)普通折叠源(plain-fold feed)运行的内核相同。
在已确认形态之外的所有内容都会被拒绝,并回退到未更改的存储路径------包括 WITH ORDINALITY、ROWS FROM、扫描上的限定条件(qual)或投影、EXPLAIN ANALYZE、支持向后扫描的扫描、FILTER 化的/受保护的/剩余的过渡(transition)、GROUP BY,以及任何非聚合消费者。每个拒绝点都在参数求值之前,因此易变(volatile)参数永远不会被求值两次;重新扫描会重放保留的生成器,而不是重新运行 SRF,这与 C 语言中带有 chgParam-NULL 的重新扫描对存储所做的操作相同。
控制开关 PGRUST_LANE_V2_SERIESFOLD,默认开启;设为 =0/off 会以字节方式恢复存储路径。
2. 像携带 fn_extra 那样携带 SRF user_fctx
FuncCallContext::user_fctx 之前是 Option<Box<dyn Any>>,因此对于每个值调用的 SRF(generate_series、unnest、regexp_matches、json_each、pg_prepared_xacts ......)的每一行,都需要付出 vtable 加载、间接的 type_id() 调用和 128 位 TypeId 比较的开销才能访问其自身状态,而 C 语言只是一个指针读取。
fmgr 层已经在上层反驳了这种形态:FnExtra 将 FmgrInfo::fn_extra 作为一个瘦指针携带,其指向的对象以 (TypeId, dropper) 开头。user_fctx 现在重用这个指针,而不是创建一个第二载体,调用点通过 user_fctx_mut::<T>() / user_fctx_ref::<T>() 读取------一行代码替换了一个六行链加上两个手写的 .expect 字符串。
这是从每个 SRF 中移除的真正每行成本,但它并不是与 C 语言之间普遍存在的差距。SRF 调用在约 37 纳秒的行处理中约占 3 纳秒;其余是 tuplestore 的 form-tuple/write/read-back 开销。这个差距仍然未被归因,并在提交中照此记录。
3. 使用计数循环减少密集批次,而非位扫描
for_each_row 使用 trailing_zeros 遍历选择位图,并通过一个循环携带的标量累加器(带有每行的 isnull 分支)进行折叠。这对于稀疏选择是正确的;当每一行都被选中时------一个无条件的全可见页面、一个通过了所有内容的内核限定条件、或一个生成的通道(lane)------这纯粹是开销。
当选择字是一个密集前缀时,内核现在在一个预先切片的通道上运行计数循环,通过选择恒等(identity)而非分支来处理 null。没有存活的边界检查,循环体中没有控制依赖,因此向量化器会发出 SIMD 归约------这正是 DuckDB 和 ClickHouse 对不可空列求和时的形态。
在重新关联(reassociation)下的位精确性是安全性论据,因为向量化器会将归约拆分为每个通道的部分和:i64/i128 包裹加法是模 2^N 环,整数 min/max 是可结合和可交换的(这与 CseGroup 推导所依赖的相同事实)。仅限整数通道------lane_value 解引用 VarLen 宽度的数据(datum),而无分支形式会读取每一行,包括 null 行。
涉及的运算:base_sum、sum_selected、CountAny、整数 Min/Max。dense_kernels_alias_the_sparse_walk 将每个运算与其自身之前的实现,在三种宽度、四种变换、跨越字边界的六个前缀长度、两端和内部的 null,以及位于类型边界上的通道值(使得 min/max 候选者可以是运算符自身的恒等值)等条件下进行固定。
这不是特定于 generate_series 的:每个在无条件的全可见批次上的通道折叠都会使用它。
数值
本地:这台笔记本电脑(Apple silicon),-Copt-level=3,无 LTO,单线程,进程内线路路径(in-process wire path)。不是集群数字------官方数字来自 Graviton 上的 release/dist,下面的跨引擎行是在问题报告者的机器上测量的,因此请将此比较视为指示性的,而非一个断言。
SELECT sum(i) FROM generate_series(1, 100000000) t(i) 的执行时间:
| 系统 | 时间 |
|---|---|
| pgrust v0.2(问题报告) | 11,397 毫秒 |
| PostgreSQL 17.7(问题报告) | 8,651 毫秒 |
| DuckDB 1.5.5(问题报告) | 103 毫秒(约 1.7 线程) |
| ClickHouse(问题报告) | 99 毫秒(多线程) |
| 此分支 | 59 毫秒(单线程) |
组件测量,每个都是两个已保存二进制文件的交错 A/B 测试,取 7 次中的最小值:
| 组件 | 修改前 | 修改后 | 变化 |
|---|---|---|---|
| SRF 每个值调用路径(2000 万次调用,无存储,无执行器) | 4.15 纳秒/行 | 3.06 纳秒/行 | -26% |
通过存储路径对 400 万行执行 count(*) |
149.5 毫秒 | 146.9 毫秒 | -1.7% |
| 折叠内核,291 行 int4 堆页批次 | 1.55 纳秒/行 | 0.31 纳秒/行 | 4.9 倍 |
对 1 亿行执行 sum(i) 端到端 |
163 毫秒 | 59 毫秒 | 2.76 倍 |
折叠内核的记录可作为被忽略的 lanefold 测试 dense_arm_vs_walk 复现。
遗留范围
此修复涵盖了 generate_series 上的普通聚合。SELECT * FROM generate_series(...),或将其馈送给连接或排序的查询,仍然会经过 tuplestore------这正是与 C 语言之间剩余差距所在。对 generate_series 的多线程处理(它本质上可以通过索引范围进行"小份"切分)未被触及。
验证
6166 个工作区单元测试通过。两项失败------adt_float::math_domains_and_live_pg_values 和 session::tls_source_census_and_session_surface_are_pinned------在 main 分支的干净检出版本上两者也以相同方式失败;浮点那项是 macOS erf() 与 Linux 黄金值之间的最后一位 ULP 差异。我无法端到端运行回归套件:它需要一个由 C 语言编写的 PG 18 initdb 初始化过的数据目录,而这里只有 PG 14/17 可用,因此 SQL 级别的证据是进程内线路路径测试(simple_query_count_sum_over_generate_series 等),而非 regress 运行。
🤖 由 Claude Code 生成
https://claude.ai/code/session_01HubH4aHTikSmpD6LV1JyFU
CodeRabbit 摘要
性能
- 改进了对
generate_series上符合条件的聚合的执行速度,减少了中间物化和每行处理开销。 - 为求和、计数以及整数最小/最大值计算添加了优化的批次归约。
兼容性
- 不支持的查询形态继续使用现有的执行路径。
- 保留了对 null 值、重新扫描、溢出、无效步长和回退行为的处理。
可靠性
- 扩展了对优化后的系列执行以及密集或稀疏批次处理的测试覆盖。
gergesh 和其他人于上周添加了 3 个提交
@gergesh
@claude
执行器:直接将 generate_series 上的普通聚合折叠 (malispe...
e929084
@gergesh
@claude
fmgr:像携带 fn_extra 那样携带 SRF user_fctx
dc6a6c4
@gergesh
@claude
lanefold:使用计数循环减少密集批次,而非位扫描
24e033c
@coderabbitai
coderabbitai 机器人 评论于 上周
·
审查变更栈
📝 代码走查
🚥 合并前检查 | ✅ 5
✨ 收尾工作
感谢使用 CodeRabbit!它对 OSS 免费,你的支持帮助我们成长。如果你喜欢,请考虑为我们宣传。
❤️ 分享
评论 @coderabbitai help 以获取可用命令列表。
coderabbitaibot
coderabbitai 机器人 于上周进行了审查
coderabbitai 机器人
留下了一条评论
已发布 1 条可操作的评论(Actionable comments)。
🧹 挑刺评论(Nitpick comments) (2)
🤖 使用 AI 代理提示所有审查评论
🪄 自动修复
ℹ️ 审查信息
crates/backend/executor/lanefold/src/lib.rs
评论于第 L1865-L1896 行
rust
fn dense_sum(values: &[Datum], isnull: &[bool], width: LaneWidth, divk: i64) -> (i64, i64) {
macro_rules! reduce {
($conv:expr, $post:expr) => {{
let (mut c, mut s) = (0i64, 0i64);
for (d, &nul) in values.iter().zip(isnull.iter()) {
let v: i64 = $post($conv(*d));
c += !nul as i64;
// SELECT,非分支:空行贡献加法恒等(additive identity),
// 因此循环体没有控制依赖。
s = s.wrapping_add(if nul { 0 } else { v });
}
(c, s)
}};
}
// divk 被提升到实例化中,以便常见的 (divk == 1) 通道
// 是一个纯粹的加法------每行除法会直接阻止向量化。
macro_rules! per_width {
($post:expr) => {
match width {
LaneWidth::I16 => reduce!(|d: Datum| d.as_i16() as i64, $post),
LaneWidth::I32 => reduce!(|d: Datum| d.as_i32() as i64, $post),
LaneWidth::I64 => reduce!(|d: Datum| d.as_i64(), $post),
_ => unreachable!("dense sum admits integer widths only"),
}
};
}
if divk == 1 {
per_width!(|v: i64| v)
} else {
per_width!(|v: i64| v / divk)
}
}
@coderabbitai
coderabbitai 机器人
上周
🩺 稳定性与可用性 | 🟠 主要 | ⚡ 快速修复
密集内核(dense kernels)在丢弃 null 行之前应用仿射变换。两个密集归约都会为每一行计算变换后的值,然后才为 null 行选择运算符恒等。该变换包含除法,因此 null 行的未指定数据字(datum word)会进入 / divk,而 i64::MIN / -1 在 Rust 中会引发恐慌(panic)。稀疏遍历(sparse walk)仅对选中的非 null 值进行除法,因此密集分支可能会使折叠在遍历完成的地方中止。
crates/backend/executor/lanefold/src/lib.rs#L1865-L1896:在reduce!中,首先选择原始值(if nul { 0 } else { raw }),然后应用$post,这样除法就永远不会看到 null 行的数据字。crates/backend/executor/lanefold/src/lib.rs#L1911-L1928:在dense_minmax中,在调用xform之前对int_lane_value(*d, width)进行掩码,或将xform移到恒等选择内部。crates/backend/executor/lanefold/src/tests.rs#L5181-L5187:将divk == -1添加到奇偶校验矩阵中,在 null 位置使用i64::MIN通道值,以便密集分支和稀疏遍历针对溢出除数进行固定。
📍 影响 2 个文件
🤖 为 AI 代理提供的提示
合并信息
所有检查均已通过
1 项成功检查
与基础分支无冲突
更改可以干净地合并。