DeepSeek总结的修复pgrust v0.2 #83:generate_series 聚合及其底层的两个成本 - #84

原文地址: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 ORDINALITYROWS 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_seriesunnestregexp_matchesjson_eachpg_prepared_xacts ......)的每一行,都需要付出 vtable 加载、间接的 type_id() 调用和 128 位 TypeId 比较的开销才能访问其自身状态,而 C 语言只是一个指针读取。

fmgr 层已经在上层反驳了这种形态:FnExtraFmgrInfo::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_sumsum_selectedCountAny、整数 Min/Maxdense_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_valuessession::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 项成功检查

与基础分支无冲突

更改可以干净地合并。

相关推荐
厦门德仔1 小时前
【YiFeiWebApi】易飞ERP与WMS系统接口对接实战:从0到1全流程指南
大数据·数据库
内存漫游1 小时前
Page 到底是什么:数据库如何把一条条记录放进磁盘
数据库
小镇敲码人1 小时前
【深入浅出】之Qt 事件系统实战:鼠标、键盘、滚轮、窗口与定时器事件
数据库·qt·网络协议·mysql·http
Sirens.1 小时前
MySQL数据库:索引
数据库·mysql
treacle田2 小时前
达梦数据库-Linux DM主备集群动态增加节点-记录总结
linux·运维·数据库·达梦主备集群动态扩展
一只专注api接口开发的技术猿2 小时前
Open Claw 实战|快速搭建电商商品监控与数据分析能力
大数据·数据库·数据挖掘·数据分析
杨云龙UP2 小时前
Oracle 19c RMAN历史备份清理失败:CONTROL_FILE_RECORD_KEEP_TIME 问题排查与整改实战
linux·运维·网络·数据库·oracle·rac
2601_962073972 小时前
【MySQL】全面学习数据库查询技巧:查询指令深度学习指南
数据库·学习·mysql
新知图书2 小时前
12.3 智能体中Skill的约束与自进化实战
java·前端·数据库