DUCKDB INTERNALS · CHAPTER 8
08 · Profiling、源码调试与性能方法
建立从端到端基准、执行计划、算子指标到 CPU Profile 的分层诊断方法,并避免常见的性能测试误区。
本章目录
Profiler 的目标是缩小搜索空间
“查询慢”只是现象。有效的性能分析应该逐层回答:时间消耗在哪个阶段、处理了多少数据、为什么生成这个计划、算子的 CPU 时间又花在哪段代码。直接打开通用 CPU Profiler 虽然能得到热点函数,却可能不知道热点背后的 SQL 算子和基数是否合理。
我习惯把诊断分成四层:
Level 1 Query:端到端延迟、吞吐、峰值内存、临时文件
Level 2 Plan :物理算子、estimated/actual cardinality、数据流
Level 3 Engine:Pipeline、线程并行、阻塞与局部/全局状态
Level 4 CPU :函数热点、Cache miss、分支、锁与内存带宽
每一层的输出都应决定下一步检查什么,而不是一次性收集所有指标。
先用 EXPLAIN 验证计划
EXPLAIN
SELECT region, SUM(amount)
FROM sales
WHERE sale_date >= DATE '2025-01-01'
GROUP BY region;
规划阶段先看:
- Filter 是否到达 Scan 附近;
- 扫描是否只保留必要列;
- Join 顺序与 build/probe 侧是否符合过滤后规模;
- Order + Limit 是否生成 Top-N;
- 是否出现意料之外的 Cross Product、Cast 或重复物化。
EXPLAIN ANALYZE 会执行查询,并把真实行数和时间附到物理计划上。重点比较 estimated 与 actual cardinality:若某节点从估计几千行变成实际几千万行,后续 Hash Table、Sort 或 Aggregate 的高耗时往往只是连锁结果。
启用 DuckDB Profiling
交互分析可以启用 Query Profiler,并选择树形或 JSON 输出。具体设置名称会随版本演进,使用时以当前版本官方文档为准;常见写法如下:
SET enable_profiling = 'json';
SET profiling_mode = 'detailed';
SET profiling_output = '/tmp/duckdb-profile.json';
SELECT ...;
SET enable_profiling = 'no_output';
JSON 更适合脚本化比较,它可以保留算子层级、基数、耗时及额外信息。Detailed 模式还会增加规划器和优化器阶段指标,但 Profiling 本身有成本,不应默认在生产式性能测试中无限期开启。
解释指标时要注意:
- 算子时间可能在多个线程上累计,不能总与 wall time 直接相加比较;
- Pipeline 并行时,不同算子的运行区间可能重叠;
- 极短查询中,规划、Profiler 和结果格式化占比会显著放大;
- 输出行数小不代表工作量小,Filter 或 Aggregate 可能消费了大量输入才得到少量结果。
从物理算子映射到源码
确定热点算子后,再沿稳定接口进入源码:
| Profile 中的现象 | 优先检查 |
|---|---|
| Scan 慢、过滤后行数很少 | Filter/Projection Pushdown、存储段跳过、解码与 I/O |
| Hash Join build 内存高 | build 侧基数/行宽、payload、键分布、external join |
| Probe 输出远超输入 | 重复键、Join 语义、下游基数爆炸 |
| Hash Aggregate combine 慢 | group 数、Local Table 数量、radix 分区与倾斜 |
| Sort/Window 出现大量临时数据 | 排序键、payload 宽度、内存限制和临时盘 |
| CPU 利用率低 | Pipeline 依赖、Task 数量、单线程 finalize、I/O 或阻塞 |
物理算子的 GetData、Execute、Sink、Combine、Finalize 是最有价值的断点。先确定它在 Pipeline 中扮演 Source、Operator 还是 Sink,再看对应 Local/Global State;不要从一个巨大调用栈中随机挑函数阅读。
系统级 Profile 应回答什么
当算子级范围已经足够小,再使用 perf、Flame Graph、采样 Profiler 或硬件计数器检查:
- CPU 时间是否集中在哈希、比较、Cast、字符串处理或内存分配;
- 是否存在锁竞争、原子变量或线程唤醒开销;
- LLC miss、内存带宽是否成为上限;
- 分支未命中是否来自复杂类型/NULL/选择向量路径;
- 编译是否为 Release,符号与栈回溯是否完整。
火焰图中的热点只说明“CPU 在这里”,不自动说明“这段代码有问题”。例如哈希函数占比高,可能是 Join 输入基数被错误计划放大;修复 Join Order 比微调哈希循环有效得多。
一套可复现的 Benchmark 方法
1. 固定实验条件
记录 DuckDB 版本、编译类型、机器、数据快照、线程数、内存限制和临时目录。不要把不同 commit、不同数据缓存状态的结果混在一起。
2. 区分冷启动与稳态
冷运行包含文件缓存、扩展加载和初始化;稳态运行更接近重复查询。两者都可能有业务意义,但必须分别报告。每组做预热和多次重复,保留 median、P95 或完整分布。
3. 一次只验证一个假设
例如“列裁剪降低 Hash Join build 内存”,应同时对比物理计划、build payload、峰值内存和延迟。一次修改多个优化器规则,即使变快也很难证明原因。
4. 正确性先于性能
用结果行数、排序无关校验、Hash/差分测试和边界数据验证语义。Join/聚合优化尤其要覆盖 NULL、重复键、空输入、Outer Join、Decimal 与浮点特殊值。
5. 防止 Benchmark 被错误测量
确保客户端真正消费结果;否则只测到查询启动或首批返回。输出到终端、DataFrame 转换和网络/IPC 也可能掩盖引擎时间,应根据目标分别测量 engine-only 与 end-to-end。
一次典型定位过程
假设某 Join 查询从 2 秒退化到 20 秒:
- 用固定数据复现,确认不是 I/O 冷缓存或环境变化;
- 对比两个版本的 EXPLAIN,发现 Join 顺序变化;
- EXPLAIN ANALYZE 显示中间结果从 50 万行变为 8000 万行;
- 检查统计信息与谓词,定位基数估计偏差;
- 修正计划或统计信息利用,而不是先优化 probe 内层循环;
- 用端到端延迟、CPU、峰值内存与结果校验验证修复;
- 增加回归 Benchmark,避免同类计划再次退化。
这条路径的核心是从“哪里慢”继续追到“为什么会有这么多工作”。
源码阅读入口
src/include/duckdb/main/query_profiler.hpp、src/main/query_profiler.cpp:查询阶段与算子指标。src/include/duckdb/main/profiling_info.hpp:Profiling 节点和指标结构。src/execution/explain/:Explain 输出与计划展示。src/parallel/pipeline_executor.cpp:算子执行与 Pipeline 热路径。benchmark/、tools/shell/:Benchmark 与命令行入口。
面试时容易被追问
CPU Profile 中 Hash Join 最热,下一步是什么? 先核对输入/输出和 estimated/actual cardinality,确认工作量是否合理;再区分 build、probe、键表达式、输出物化哪部分热,最后才做函数级优化。
算子累计时间为什么可能大于查询 wall time? 多线程执行会累计各线程时间,且 Pipeline 可能并发运行;wall time 是关键路径的真实时间跨度,两者统计口径不同。
怎样证明一次优化不是噪声? 固定环境,多次重复并报告分布;同时证明计划或资源指标按假设变化,并保留正确性校验和回归基准。