DB::DuckDB — duckdb-perf-next-steps
DB::DuckDB 性能再优化评估:瓶颈定位与 API 形态选择
环境:Windows 11 / Rakudo Star 2026.09 / DuckDB v2.0.0-dev1(Alien::Libduckdb 自编译) 方法:绕开 DB::DuckDB 直接用 NativeCall 绑定的 C API 建基线,与现有 Raku 层 API 同数据集对照(内存库)。 所有数字为本机实测,非估算。
一、结论摘要
Raku 侧落地(解码 + 装箱)占整个读取耗时的 99% 以上,DuckDB 引擎本身不是瓶颈。 1M 行 × 4 列全扫:C 层 29–41 ms,现有
column-arrays15 104 ms(≈390×)。现有解码快路径有一个结构性缺陷:只要该列 chunk 带 validity bitmap(含 NULL),整列退回逐值 decoder。 BIGINT 1M 行:无 NULL 745 ms → 10% NULL 4 286 ms(5.7×)。这是单点收益最大的修复项。
写入是当前与 SQLite 差距的主要来源,且有一条 19× 的现成解法: 10 万行 × 4 列,Appender 330 ms vs 多值 INSERT(事务内)6 453 ms。
脱离
DBrole 自写 API,对性能的直接收益很小(点查场景约 15–20%,其余 <5%); role 只是薄壳,真正决定性能的是 API 形态(列式/流式/零拷贝),而这些在保留 role 的前提下也能加。 → 建议"双轨"而非"弃用 role"。第二~四节是早期探针数据,部分已被复核推翻:见第七节(2026-10-28 实测复核)。 列视图"20×"不成立、数值列"0.2 µs/值"是惰性容器造成的假快、prepared 复用实际快 37% 而非 5%。 请以第七节数字为准。
二、实测数据
2.1 单列 100 万行落地成本(现有 column($i))
| 列类型 | 无 NULL | 10% NULL | 每值成本(无 NULL) |
|---|---|---|---|
| BIGINT | 745 ms | 4 286 ms | 0.75 µs |
| INTEGER | 887 ms | — | 0.89 µs |
| SMALLINT | 1 754 ms | — | 1.75 µs |
| DOUBLE | 2 252 ms | 6 070 ms | 2.25 µs |
| VARCHAR | 5 169 ms | 6 328 ms | 5.17 µs |
| DECIMAL(21,1) | 6 783 ms | — | 6.78 µs |
对照:同一份数据在 C 层(duckdb_fetch_chunk + duckdb_vector_get_data 遍历)29–41 ms。
2.2 语句级固定开销(500 次主键点查)
| 路径 | 总耗时 | 单次 |
|---|---|---|
SELECT 1 × 500(仅 execute + destroy) | 44 ms | 0.090 ms |
| prepared 复用 + bind + execute + destroy(纯 C) | 193 ms | 0.387 ms |
| 字面量 SQL + destroy(纯 C) | 206 ms | 0.413 ms |
| 现有 Raku 层 prepared 复用(含 Result + 解码) | 243 ms | 0.486 ms |
两个要点:
prepared 复用相对字面量只快 5%,固定成本在
execute(DuckDB 每次建结果/事务),不在prepare。Raku 层相对纯 C 多出 0.07–0.10 ms/语句,这就是
DBrole + Result 对象 + 解码的全部开销。
2.3 写入:Appender vs 多值 INSERT(10 万行 × 4 列)
| 路径 | 耗时 |
|---|---|
Appender(duckdb_append_* + end_row,Raku 循环 40 万次调用) | 330 ms |
| 多值 INSERT,1 000 行/句,包在事务里 | 6 453 ms |
19.5×。注意 Appender 这 330 ms 里还有大量 Raku→C 调用开销,若改用 duckdb_append_data_chunk 整块提交还能再降。
2.4 物化 vs 流式(1M × 4 列全扫)
| 路径 | 首块可用 | 总耗时 |
|---|---|---|
duckdb_execute_prepared(全物化) | 28 ms | 37–41 ms |
duckdb_execute_prepared_streaming + stream_fetch_chunk | 7.4 ms | 29.5 ms |
流式总时间只快 1.1–1.3×,但首块延迟低 3.8×,且峰值内存不随结果集线性增长——对"边读边处理/大结果集"是结构性优势。
2.5 会话参数影响(1M 行扫描)
| 配置 | 耗时 |
|---|---|
| 默认 | 37.0 ms |
SET preserve_insertion_order=false + SET threads=8 | 15.8 ms(2.34×) |
2.6 其它
100 万行 4 列
column-arrays:15 104 ms;hashes10 万行 × 4 列:1 939 ms。聚合不下传 Raku(
SELECT count(*), sum(v), max(d)):5.3 ms —— 引擎侧几乎免费,能推到 SQL 侧的工作都应推。
三、瓶颈归因
| 层 | 现象 | 归因 |
|---|---|---|
| DuckDB 引擎 | 1M×4 列 29–41 ms | 已接近极限,无优化空间 |
| 语句固定成本 | 0.09 ms/语句(SELECT 1) | DuckDB 2.0 每次 execute 建结果对象,绑定层无法绕过 |
| C API 边界 | fetch_chunk 全走完只加 8–12 ms | 不是瓶颈 |
| Raku 落地 | 占 99% | 每值一个 Int/Num/Str 装箱 + Array push |
| NULL 退化 | 5.7× 变慢 | decode-into 里 return False if $validity.defined 整列退回逐值 decoder |
| DECIMAL | 6.78 µs/值 | 走文本渲染/bignum 路径,未用 scale 做定点换算 |
| 写入 | 6.45 s / 10 万行 | 走 SQL 文本多值 INSERT,被解析器逐值解析 |
四、优化清单(按 ROI 排序)
P0 立即做(改动局部,收益 5–20×)
1. validity 位图内联处理,禁止整列退化
现状:Result::!decode-into 开头 return False if $validity.defined。
改法:不整列退化,在批量循环里按 64 位字做位测试:
my CArray[uint64] $m = nativecast(CArray[uint64], $validity);
loop (; $r < $size; $r++) {
@dst.push: ($m[$r +> 6] +& (1 +< ($r +& 63)))
?? $a[$r].Int !! Any; # Any = NULL 占位
}预期:BIGINT 带 NULL 4 286 → ~800 ms。若要更快,沿用 VarcharShim 的思路写一个 C shim:
一次遍历把有效值 compact 成连续数组 + 输出 null 索引表,Raku 侧只做无分支 push。
2. DECIMAL 定点快路径
duckdb-decimal-width/scale/internal-type 已有绑定。scale ≤ 9 且 internal 为 int64 时直接用
$a[$r] / 10**$scale(10**$scale 提到循环外),不必走文本渲染。
预期:6.78 → 1 µs 级(6×)。
3. 写入走 Appender
Connection.execute/批量 insert 检测"同一表连续 INSERT"时,切 duckdb_appender_create +
append_* + end_row,事务包一层。若能整批构造 duckdb_data_chunk 后 duckdb_append_data_chunk,
比逐值 append 更快。
预期:6.45 s → 0.3 s 量级(19×),直接抹平与 SQLite 的写入差距。
4. 会话默认参数可配
preserve_insertion_order=false、threads、memory_limit 通过构造参数/属性下发(默认保守,读密集场景建议开)。
预期:扫描 2.3×。
P1 结构性改造(收益最大,需要新 API 面)
5. 零装箱列视图 column-view($i) ✅ 已实现(0.6.0,DB::DuckDB::ColumnView;惰性解码 0.6.4)
实测复核见第七节:本项"745 ms → 30 ms(20×+)"的预期不成立。列视图省的是"没读的列", 读到的列仍要付解码钱(VARCHAR 3.2 µs/值、BIGINT 0.8–1.0 µs/值)。 返回对象内部持有
Buf(把 vector datamemcpy到 Raku 管理的缓冲区,1M×8B ≈ 8 MB、约 2 ms)或对结果保活的裸指针视图, 暴露.int64($i)/.num64($i)/.sum/.slice。用户遍历时不做任何对象分配。 预期:1M BIGINT 745 ms → 30 ms 量级(20×+)。 注意:duckdb_data_chunk_copy在 2.0 未导出,chunk 生命周期只能靠 result 保活或自行 memcpy。
6. 流式 chunk 迭代 ✅ 已实现(0.6.0,Result.chunks + DB::DuckDB::Chunk,含块过期语义)
for $res.chunks -> $chunk { ... },基于 duckdb_execute_prepared_streaming + duckdb_stream_fetch_chunk(2.0 均已导出,实测可用)。
收益:首块 7.4 ms、峰值内存恒定。
7. Arrow 直通 🟡 可行性已验完,结论与立项预期不同(0.6.6 起有 DB::DuckDB::Arrow 只读视图)
实现:duckdb_result_get_arrow_options + duckdb_data_chunk_to_arrow 在 2.0 的 duckdb.dll 中已导出且可用
(返回的 error_data 为 NULL,即成功)。不需要 LOAD arrow 扩展,也不需要 NanoArrow:Arrow C data
interface 只是 ArrowArray/ArrowSchema 两个固定 10 字段 C 结构,Raku 侧直接 repr('CStruct') 声明即可读,
实测转换成本 1M 行 × 3 列仅 35 ms(≈0.035 µs/行)。
实测(1M 行,id BIGINT / v DOUBLE / s VARCHAR,489 个 2048 行 chunk,含每 chunk 转换开销):
| 路径 | 耗时 | 备注 |
|---|---|---|
| Arrow int64 遍历(含转换) | 655 ms | 与 DuckDB vector 直读 587 ms 基本持平,差值≈转换成本 |
Arrow VARCHAR(offsets + memcpy + substr) | 6.3 s | 比现有 join+split 的 1.7 s 慢 3.8× |
column-view.to-list(现有) | 1.6 s | 装箱是主要成本 |
column(2)(现有 VARCHAR) | 1.7 s |
结论:Arrow 不是性能优化项。逐值 CArray 索引在 Raku 侧本来就要 ~0.6 µs,数值列换成 Arrow 后与此持平;
VARCHAR 反而更差(Arrow 没有分隔符,物化只能逐值 substr,而现有 join+split 是一次大字符串切分)。
真正的价值是能力扩展:LIST/STRUCT/MAP 拿到的是可递归遍历的数组,而现有 column() 只能返回 DuckDB 的
文本表示("[0, 0]"、"{'a': 0}" 字符串)。
已落地的最小可用面(lib/DB/DuckDB/Arrow.rakumod,t/11-arrow.rakutest 13 项全过):
arrow-chunk($res, $i) 返回 Chunk,.column($j) 取列,支持 .ints/.nums/.int32s/.nums/.strs/.bools、
.is-null($i)、.children(嵌套递归)、.free(幂等);with-arrow-chunks($res, &code) 自动释放。
默认读取路径不变,Arrow 仅在需要嵌套类型或对外导出 buffer 时显式使用。
内存:一个真正吃内存的坑(0.6.6 已修)
duckdb_result_get_chunk 返回的 chunk 是调用方拥有的(duckdb.h 明写 "The result must be destroyed with
duckdb_destroy_data_chunk"),原实现把它当借用句柄,结果每取一块就漏一块:2048 行 × 3 列实测
~235 KB/块,200k 行(98 块)= 23 MB/查询,200 轮堆到 4.9 GB。
修复(三处):
Arrow.arrow-chunk:duckdb_data_chunk_to_arrow是拷贝语义(把 chunk 销毁后再读 Arrow 数据仍然正确, 由t/11的 zero-copy 用例覆盖),所以转换后立刻duckdb_destroy_data_chunk;Result!decode-parallel:worker 全部 await 完、数据已拷进@parts后统一销毁@chunks;Result.column-views:惰性解码要把 chunk 留到首次取值,改为登记进@!owned-chunks,在free里统一销毁。
复测(200k 行 × 3 列,200 轮):Arrow 路径 4.9 GB → 223 MB 起步、600 轮仍 ~270 MB;column-views 路径
300 轮 206 → 561 MB 后进入锯齿平台(GC 高水位,非线性泄漏);noarrow 路径 196 MB 恒定。
另一处静默失效也一并修掉:ArrowShim::Native 里的入口原来是词法 sub,$mod.WHO<&dkd-arrow-release> 取到
Any,try 吞掉类型错误后静默回退到 Raku 回调释放(正是会泄漏 + 每条 note 的那条路)。改为 our sub 后
shim 才真正接管释放——判断 shim 是否生效请开 DB_DUCKDB_ARROW_DEBUG=1 看 ARROW DEBUG: shim release。
11. 零拷贝文本列 TextColumn(新增 API 面) ✅ 已实现(0.9.0,column-view($i).text)
P1-5 的 ColumnView 解决的是"不装箱就摸到值",但 at/to-list 仍逐值造 Str;本节补的是
整列一次性取字节的出口:一段连续缓冲 + offset/length/is-null 三张表,不经过 Str,
bytes-at($i) 直接切 Buf 视图。
实测(1M 行 VARCHAR(40),Windows,best-of-3):
| 出口 | 耗时 | 备注 |
|---|---|---|
.text 构建 | 156 ms | 含 C 解包 + 三张表填充 |
同列 to-list | 1535 ms | 约 10×,差距全在 Raku 侧装箱 |
byte-size | 41,896,900 | 与 sum(length) 精确相等 |
对 Python 的位置:Python fetchnumpy 68 ms(memcpy 语义),fetchall 325 ms。.text 是"贴近 bytes"
这一档,156 ms 与 68 ms 的剩余差距主要是 Raku 侧两张 CArray 的填充与 GC 记账,不在 C 解包。
ABI 影响:给 dkd_varchar_unpack 加 offset_base 参数 → DKD_SHIM_ABI 5 → 6,旧符号保留为 base=0
包装。踩坑:原实现每块 offset 从 0 起算,多块拼同一缓冲后第二块偏移错位(第 2048 行指向字节 0);
在 Raku 里逐行补基址 503.6 ms/百万行不可接受,所以修在 C 侧。
未做(下一档,见 HANDOFF §8.6 之后):.text 目前是串行填充,按 byte-size 阈值切块并行预计还能
再降 2–3×;slice 视图 / 有序时按值二分查找也未做。
P2 可选
8. 分块并行解码 column-arrays(:parallel) ✅ 已实现(0.6.1)
任务切分单位是 (列, chunk 跨度):worker 独占一列的连续若干 chunk,直接 append 进该列预分配的数组,
worker 之间不共享任何容器。列数少于 worker 数时改为把单列的 chunk 列表再切成多段,回收后按 chunk 顺序拼接,
因此单列宽结果也能并行,且行序不变、结果与串行逐值一致(t/08-parallel.rakutest)。
实测(1M 行、整列落地墙钟、四轮取中位):5 数值列 8.5 s → 3.4 s(2.2×)、4 数值列 5.2 s → 2.6 s(2.0×)、
7 混合列 22.5 s → 10.5 s(2.1×)、VARCHAR 单列 4.7 s → 1.9 s(2.4×);2 列窄结果几乎无收益(1.1×),
因为装箱与 GC 是所有 worker 共享的瓶颈。同一形状重复跑波动可达 ±50%,机器负载噪声大于优化差异。
不到 20 000 行一律走串行(启动成本大于收益);流式结果与已部分消费的结果无法切分,自动回退串行。
两个已排除的死路(勿重走):
在 worker 内用共享 Hash 收集各列结果(
%col{$c}.append):Raku 跨线程的 Hash 传递会丢键, 表现为部分列整列为空;必须写进主线程预建的@cols[$col]槽位。用嵌套
List当任务表(@tasks[$i] = ($col, $from, $to)):取回来是 item 化的,各 span 变空。 扁平int数组(@t-col/@t-from/@t-to/@t-w)才可靠;拼接时用|@parts[$ti](slip 强制展平), 直接append: @parts[$ti]会把整个 Array 当成一个元素,合并结果只剩 worker 数个元素。SELECT 1级别的 0.09 ms 固定成本属 DuckDB 内部,绑定层无解;点查密集场景用 IN 列表批量化更有效。
P2 遗留补齐(0.6.7)
8a. 并行粒度按列权重分配(补 P2-8 的长尾) ✅
问题不在"能不能并行",而在切得不均:0.6.1 的规则是"列数少于 worker 才按跨度切,且所有列切同样的段数"。
SELECT id, s(BIGINT + VARCHAR)这类偏斜结果里 VARCHAR 一个值的解码成本约是 BIGINT 的 4 倍,
nt=4 时两列各给一个 worker、谁也不切,最慢的那个 worker 决定总时长,多给的 worker 全在空转——
实测 deg=4 比 deg=2 还慢(0.93×,serial 为 1.0)。
改法:先算每列权重(VARCHAR 4 / LIST·STRUCT·MAP·ARRAY·UNION 3 / DECIMAL·HUGEINT 2 / 其余 1),
按 spans = ceil(nt × w / Σw) 给每列分配段数,上限 nt 与 $nch;数值列默认保持整列(切它要付一次拼接拷贝,
省下的和花掉的差不多),只有"整个结果就这一列"时才切。
实测(20 万行 id BIGINT + s VARCHAR(40),同进程内 serial/deg2/deg4 交替 20 轮,取配对比值中位):
| 配置 | 0.6.6 | 0.6.7 |
|---|---|---|
| deg=2 | 1.22–1.24× | 1.20–1.38× |
| deg=4 | 0.92–0.93× | 1.29–1.32× |
要点:给更多 worker 不再掉速,这是本次的实质收益;绝对值受机器负载影响可达 ±20%。
顺带修掉一个只在偏斜切分下才暴露的崩溃:拼接用 append: |@parts[...],单个 span 超过 65535 个元素时
Raku 报 "Too many arguments";改用 splice 一次性插入即可。回归:t/08-parallel.rakutest 新增 2 列偏斜 × 4 种
degree 与串行逐值比对。
10. 流式双缓冲预取(补 P1-6 的遗留) ✅(0.6.7,DB_DUCKDB_NO_PREFETCH=1 可关)
0.6.0 的 chunks 是"取一块 → 解码 → 再取一块":取块时引擎在算、解码时引擎在等,两边轮流空转。
现在 streaming 结果首次取块时起一个 worker 线程,在调用方解码当前块的同时把下一块取出来放进队列。
两个槽位,不做深队列:一块约 235 KB,解码成本是取块的好几倍,队列再深只增加首行延迟和内存。 槽位用"令牌 Channel"实现(本机 Rakudo 的
Channel.new($cap)不接受位置参数),worker 先取令牌再取块, 调用方销毁上一块时归还令牌,因此生存的块最多两个。收尾必须 join:worker 在读
$!raw,free里先!prefetch-stop(关两个 Channel → 唤醒/打断 worker →await→ 销毁队列里剩下的块)再duckdb_destroy_result,否则是 use-after-free。 实测在本机 Rakudo,receive打到已关闭的 Channel 是抛异常而不是返回 Nil,两侧都要 try 兜住。收益(30 万行、7–9 轮中位):解码主导的
id,k,s全扫 1.06–1.13×;引擎主导的range(200k)×range(60) GROUP BY流式 1.03–1.18×;首块延迟不变(7.4–8.0 ms),符合此前"首块之外空间不大"的判断。代价:多一个线程栈 + 一个在飞的块,240 次流式压测(120 次读完 + 120 次读一块就
free)RSS 平台期 374 MB vs 关掉时 357 MB,无斜率;t/07-streaming.rakutest新增 9 项(两种模式逐值一致、中途放弃、取一块即 drop)。
P3 写入
9. 列类型驱动 filler(原计划:append_data_chunk 整块提交) ✅ 已实现(0.6.2),但走的不是 chunk 路线
原计划是把每列攒成连续 buffer 后一次 duckdb_append_data_chunk。实测发现更省事的一步就够:
写入的真正开销不在 FFI 次数,而在 Raku 侧的逐值 given/when 分派(每个值一次多分派 + 一次 check-rc 子调用)。
改成「建 appender 时读一次列类型(duckdb_appender_column_type),每列生成一个只调对应 duckdb_append_* 的闭包,
返回码内联比较」之后,10 万行 × 4 列 insert-rows 对比 0.6.1(同机同日、各取五轮中位):
纯 int/num/bool/str 值 11.6 → 4.2 µs/行(2.8×);值是 Rat(如 $_ / 3 不转 .Num)时
15.5 → 11.3 µs/行(1.4×),因为每行 Rat→num 强转成了新瓶颈,filler 省下的部分被它吃掉。
结论:先做类型特化,再谈 chunk 拼装。剩余差距(num 数据 ~4.2 µs vs 裸 FFI 的 1.3 µs)在 Raku 每行的
循环/分派/强转开销里,继续往下压需要 C shim(把整批行一次性传给 C,由 C 侧填 chunk),目前收益不抵复杂度。
注意 tools/perf/bench-write.raku 的 d 列写的是 $_ / 3(Rat),跑出来自然偏慢,别拿它当极限值。
五、"不用 DB role、自己做 API"的评估
5.1 role 的真实成本
| 场景 | Raku 层附加开销 | 占比 |
|---|---|---|
| 点查(单行结果) | 0.07–0.10 ms/语句 | 15–20% |
| 大结果集读取 | 15 104 ms 中 role 协议部分 < 1% | ≈0 |
| 写入 | 与 role 无关 | 0 |
DB role 是薄壳:DB::Result 提供 .row/.array/.hash/.hashes/.arrays,DB::Connection 提供 .query/.execute/.prepare。
它带来的成本主要是"必须构造一个 Result 对象 + 支持逐行协议",而不是每行每列的额外代理。
5.2 结论
为性能放弃 role,收益上限约 15%(点查),平均 <5%,不值。 真正的 20–400× 收益来自"要不要把数据落地成 Raku 对象",与 role 无关。
现有
DB::SQLite兼容层、用户已有代码、以及DB生态(DB::Result的 hashes/arrays 契约)都应保留。建议双轨:
DB::DuckDB(does DB)保持现状 + P0 修复,DB 接口使用者无感知地变快;新增原生快路径命名空间(如
DB::DuckDB::Raw/DB::DuckDB::Chunks),提供column-view、chunks、appender、arrow,供追求极限吞吐的代码使用;在 Result 上直接挂
.chunks/.column-view等价方法也可以,等价于把快路径做成 role 的超集。
5.3 若坚持完全重写(不 does DB)
可行,工作量 ≈ 重写 Result(497 行)+ Connection(151 行)+ Statement(118 行),主要风险不是性能而是:
chunk/result 生命周期与释放顺序(
duckdb_data_chunk_copy不可用,2.0 ABI 无 copy 语义);与 DBIish/DB 生态脱钩后的迁移成本;
现有
DB::SQLite对比基准与用户脚本需要改写。
除非目标是做成"Raku 的 Arrow/chunk 级数据库客户端"而非"DuckDB 的 DB 驱动",否则不建议。
六、建议执行顺序
P0-1(NULL 位图)→ 2. P0-3(Appender 写入)→ 3. P0-2(DECIMAL)→ 4. P0-4(会话参数)
P1-5(
column-view零装箱)→ 6. P1-6(流式 chunks)→ 7. P1-7(Arrow,按需)→ 8. P2-8(并行解码,可选)
P0 全部为局部改动,预计能把"读取 1M 行 4 列"从 15 s 压到 2 s 内、写入 10 万行从 6.4 s 压到 0.3 s; P1-5 落地后才能进入 100 ms 量级(受 Raku 对象模型限制,纯 Raku 层无法再往下)。 P2-8 已在 0.6.1 落地,1M 行整列落地墙钟再降 2–3.6×,剩余瓶颈是 Raku 装箱与 GC,不再是单线程解码。
七、实测复核与惰性 column-view(2026-10-28)
第二节的表格是项目早期一次性探针跑出来的,参数量级、机器负载、是否强制物化都与现在不同。 本轮把关键项重跑了一遍,结论有对有错,逐条记录如下。
7.1 复核方法
1M 行内存库,Rakudo Star 2026.09 / libduckdb v2.0.0-dev1,同机同日,多轮取最优。
所有读取结果强制物化(
.List遍历或.to-list),不取.elems——惰性容器上.elems不触发解码,会给出虚快数字。脚本放临时目录,不入仓库:
bench-lazy-final.raku(VARCHAR 场景)、bench-num2.raku(数值 + 点查)。
7.2 原声明 vs 复测
| 项目 | 原文声明 | 本轮实测 | 判定 |
|---|---|---|---|
1M×4 列 column-arrays | 15 104 ms | 2 VARCHAR + BIGINT 7.2 s;单 VARCHAR 2.8 s | 量级一致,原值对应更宽结果 |
| P1-5 列视图 | 745 ms → 30 ms(20×+) | 建视图 10 ms(不读);读单列 0.97–3.2 s | 20× 不成立,见 7.3 |
| 数值列 bulk 拷 | 0.69 → 0.2 µs/值 | 0.8–1.0 µs/值 | 0.2 µs 是 CArray.list 惰性造成的假快 |
| 点查 prepared 复用 | 0.486 ms/句 | 0.43–0.46 ms/句 | 吻合 |
| 字面量 SQL vs prepared | prepared 仅快 5% | 0.69 vs 0.43 ms(prepared 快 37%) | 原结论有误,prepared 复用收益被低估 |
SELECT 1 固定成本 | 0.090 ms/句 | 0.20–0.25 ms/句 | Raku 层固定开销被低估约 2× |
7.3 本次改造:column-view 改成真惰性
改法(0.6.4):非 raw 列(VARCHAR/BLOB/时间/嵌套等)不再在 column-views 时全量解码,改为每个 chunk 持有一个
owned chunk 句柄,首次访问该段才 decode-column-into 并缓存;to-list 复用同一条分段解码,.at / is-null 只解所读行所在段。
1M 行(2 VARCHAR + 1 BIGINT):
| 场景 | 改前(建视图即全解) | 改后(惰性) | 倍数 |
|---|---|---|---|
column-views 建视图 | 5.0 s | 0.10 s | 50× |
只取 at(0) / at(last) | 5.0 s | 0.09 s | 55× |
只读 1 个 VARCHAR 列 to-list | 5.0 + 3.2 s | 0.10 + 3.2 s | 1.5× |
三列全读 to-list | ≈8.2 s | 4.9 s | 1.7× |
对照:eager column-arrays 三列强制物化 7.2 s。
要点:收益来自"没读的列不付钱",不来自"读得更快"。改造过程中读单个 VARCHAR 列 to-list 3.2 s,比 eager 单列 2.8 s
慢约 16%(逐段解码比一次性解码多了 489 次分派与列表拼接);BIGINT 列 to-list 0.97 s 与 eager 1.01 s 持平。
那 16% 属于"逐段
!ensure-seg建 List 再拼接"的过渡版本;0.6.4 最终实现改走!lazy-all直解进@out后已不存在, 0.6.8 复测惰性路径反而略快(见 7.7-A)。该数字保留作改造过程记录,勿再当现状引用。
7.4 顺带修掉的两个真实缺陷
点查路径被"段表拷贝"拖死(
ColumnView::!seg) 每读一个值都my @b = @!bases复制整张段表(1M 行 = 489 段)。1M 次int64()实测 107 s(107 µs/值),at()216 s。 改为直接访问属性后:int64()3.7 s(3.7 µs/值)、at()8.7 s(8.7 µs/值);at()一千次 202 ms → 19 ms(约 10×)。 → 原文"点查快路径 0.38→0.15 ms 不成立"的判断,当时测到的其实是这次数组拷贝,不是 DuckDB 内部固定成本。raw 列
to-list未走类型特化循环:CArray存在无类型变量里,每次索引都重新分派。按类型分派到 10 个 类型化小循环后,BIGINT 1Mto-list1.71 s → 0.97 s,与column-arrays的 1.01 s 持平。
残余:at() 8.7 µs/值仍是"每值一次方法调用"的代价,逐值 .at 不适合 1M 行级遍历(用 .chunks 或 to-list);
真实单行点查不受影响(语句固定成本 0.43 ms 才是主项)。
7.5 回归
9 个 .rakutest 全绿(01–09),新增 t/09-lazy-view.rakutest 22 项:shape / 随机访问 / NULL / to-list / slice /
单列表 / 聚合拒绝 / eager 回退。参考:is ... eqv 对 List/Array 类型敏感,比较解码结果要统一转 Array 或用逐值 eq。
7.6 对清单的影响
P1-5 目标改写:机制已落地,"20×"应改成"未读列零成本"。读到的列仍是 VARCHAR 3.2 µs/值、BIGINT 0.8–1.0 µs/值; 再往下只能减少 Raku 侧逐值
substr(P4 已确认瓶颈在 Rakusubstr而非 C shim)。审查规则:列视图类里任何"为了少写一行"的数组拷贝都会进入每值成本,新增访问器要按这条过一遍。
待办(0.6.7 已补齐):
Arrow 直通(0.6.6)、并行粒度细化、流式双缓冲——清单里的 P1/P2 项至此清空, 只剩 P3 写入再压(已判定收益不抵复杂度,主动搁置)。新增小项:VARCHAR 分段解码的 ~16% 折损,可用"跨段合并解码"(一次解多段)削平。2026-10-28 实测否决(7.7-A):该 16% 已随 0.6.4 的实现更换消失,现差异在 ±1% 到 −7% 之间, 且合并解码需先 memcpy 成连续缓冲,净收益为负。
7.7 两项遗留候选的实测否决(2026-10-28)
7.6 留了两个"还能再压一压"的候选,各做一次针对性实测,结论都是不做。
A. VARCHAR 跨段合并解码 —— 不做
动机:7.3 当年逐段解码比整列慢 16%,设想把相邻段合并成一次 decode 把 16% 削平。
实测(1M 行,bench-varchar-shapes.raku,best-of 3,已扣掉同扫描的整数列成本):
| 列形状 | eager µs/值 | view µs/值 | view 相对 |
|---|---|---|---|
| short(2 字符) | 0.95 | 0.96 | +1% |
| long(91 字符) | 1.33 | 1.25 | −6% |
| mb(中文,5 字符) | 1.13 | 1.05 | −7% |
惰性路径已不比 eager 慢:长字符串反而略快,因为 !lazy-all 直接解进 @out,省掉 eager 的中间数组。
16% 是"逐段 !ensure-seg 建 List 再拼接"过渡版本的遗留数字,0.6.4 换实现后即消失——没有 16% 可回收。
而合并解码还得先把各段 char 数据 memcpy 进一块连续缓冲(1M × 91 字符 ≈ 91 MB/列),净收益为负。否决。
B. 惰性 views 的内存缓升 —— 无泄漏,不做
用新探针(见 C)跑 200k 行 × 3 列,每轮建视图并把三列全部 to-list 出来,200 轮,外部 500 ms 采一次:
| 路径 | 采样 | 首值 | 斜率 前/中/后三段 | 后段 r² | 平台(后 80%,带宽) |
|---|---|---|---|---|---|
| views | 276 点 / 141.6 s | 17.3 MB | 177.2 / 44.2 / 30.0 MB/min | 0.07 | 682–965 MB(284) |
| chunks 对照 | 800 点 / 411.2 s | 15.4 MB | 11.6 / 7.2 / 2.1 MB/min | 0.01 | 404–615 MB(211) |
两条路径同一个形状:先爬到平台,之后在宽区间里锯齿——斜率逐段衰减、后段 r²≈0,后段 47 s 只涨 23 MB(带宽的 8%)。 反证同前:三列 × 98 块若每轮漏块,200 轮就是 GB 级,实测平台不到 1 GB。 结论:Rakudo GC 高水位 + DuckDB 分配延迟回收,不是原生泄漏,没有可优化项。否决。
C. 旧探针作废,换一对能跑的
probe-views-gc.raku(nqp::collectgarbage 在 Rakudo 2026.09 不存在,编译期就 SORRY)、
probe-views-scale.raku / probe-views-variants.raku($chunk.rows,而 Chunk 只有
column-count / column-view / columns / row / to-rows)——三个脚本一个都跑不起来,
此前流传的"0.05 MB/轮"就出自第一个,数据不可信。三个已删,替以:
raku -Ilib tools/perf/probe-views-rss.raku # 只干活 + 打 round N 心跳
powershell -File tools/perf/probe-views-rss.ps1 -Rows 200000 -Rounds 200 [-Path chunks]Raku 侧只管干活并打心跳,
-Path views|chunks切换被测路径;RSS 由 PowerShell 侧外部采样
WorkingSet64,算整体/分三段斜率、平台带宽、后段 r²,输出 CSV 并给判读。 采样必须放外部:从 Rakudo 里再拉 PowerShell 子进程会把父控制台打成一堆 CLIXML。
7.8 时间/日期原生解码通道(0.6.9,2026-10-29)
7.6 列的"时间/日期/DECIMAL/ENUM 目前都经 DuckDB 文本渲染返回 Str"这一条,先做时间/日期。
A. 做法
DATE / TIMESTAMP(_S/_MS/_NS) / TIME(_NS) 的底层载荷分别是 int32 天数与 int64 计数,
文本可以从整数直接算出来,不必让 DuckDB 先 CAST(... AS VARCHAR) 再让 Raku 收 Str:
resources/duckdb-varchar-shim.c增加dkd_temporal_join(data, validity, n, out, cap, kind): 按 kind 把整块(最多 2048 行)渲染进一块带0x01分隔的缓冲,NULL 写0x02, Raku 侧一次split取出整段,与 VARCHAR join 通道同构。Result.rakumod的push-temporal优先走 shim;没有 C 编译器时回落到纯 Raku 渲染器 (push-days/push-stamps/push-times),两者输出逐字节一致。TIMESTAMP_TZ / TIMESTAMP_TZ_NS 仍走 DuckDB 自己的渲染:它们的文本含会话时区, 光凭 payload 无法复现。
B. 三个踩到的坑
kind 编码不能从 0 起。 第一版用
DKD_TEMPORAL_DATE = 0,结果 DATE 列仍慢 —— Raku 侧用elsif temporal-kind($tid) -> $kind绑定,而0在 Raku 为假,分支根本没进, DATE 悄悄落回逐值通用解码。现在 kind 从 1 起,同时把 shim ABI 从 3 提到 4,Result.rakumod的探测条件同步改成$abi >= 4,避免旧 dll 按旧语义读新编码。日历要向下取整。 C 的
/向零截断,1970 年前的秒数会算错一天;shim 里用dkd_floor_div统一向负无穷取整,再套 Howard Hinnant 的 civil-from-days。文本格式有四处不是"补零就完事":公元前日期是
0001-12-31 (BC)(年份取1-y再补 4 位)、24:00:00是合法值、小数部分要去掉尾零(00:00:00.000400→01:02:03.0004)、 TIMESTAMP 固定最多 6 位而 TIMESTAMP_NS 最多 9 位。这几条都写进了t/12-temporal.rakutest。
C. 实测(1M 行,tools/perf/bench-temporal.raku,单进程单模式,best-of 3)
| 场景 | 纯 Raku 渲染 µs/值 | shim µs/值 | 倍数 |
|---|---|---|---|
| DATE 单列 | 8.38 | 1.38 | 6.1× |
| TIMESTAMP 单列 | 23.35 | 1.22 | 19.1× |
| TIME 单列 | 4.58 | 1.27 | 3.6× |
三列 .arrays(µs/行) | 35.88 | 4.78 | 7.5× |
参照系:SELECT ts::VARCHAR 走 DuckDB 自己渲染再收 Str 是 1.30 µs/值,
即 shim 已经把"Raku 侧生成文本"压到和"DuckDB 生成文本 + Raku 收 Str"同一量级,
而 DuckDB 侧不再需要任何格式化工作。纯 Raku 回退路径仍有 3.6–6× 收益(TIMESTAMP 仍慢,
因为它要拆微秒、逐段补零,是三条里最重的)。
验证:t/12-temporal.rakutest 32 项断言;2.1 万个值(7 种 temporal 类型 × 含 NULL)
与 ::VARCHAR 逐值比对 0 差异;C 通道、纯 Raku 通道、完全无 shim 三条路径的 digest 完全相同。
D. 对剩余清单的影响
时间通道已落地。DECIMAL 与 ENUM 见 7.9(已做完)。
7.9 DECIMAL 与 ENUM(0.7.0,2026-10-29)
7.8 之后剩下的两项。先各测一次再动手;测出来的结论和预期不一样。
A. 实测(1M 行,tools/perf/probe-dec-enum.raku,best-of 3)
改前:
| 场景 | 现状 µs/值 | ::VARCHAR 参照 | 裸读载荷 |
|---|---|---|---|
| DECIMAL(9,2)(int32 定点) | 2.45 | 1.39 | ~1.0 |
| DECIMAL(18,4)(int64 定点) | 3.52 | 1.19 | 1.05 |
| DECIMAL(38,10)(scale>9 → 通用解码) | 8.26 | 1.35 | — |
| ENUM(通用逐值 decoder) | 2.71 | 1.30 | 1.34(enum_code) |
两个反直觉点:
DECIMAL 的 Raku 定点路径比 DuckDB 自己格式化还慢 3×。 纯 Raku 侧单值格式化就要 1.71 µs,比"DuckDB 格式化 + 读整列"(1.19)还贵——在 Raku 里逐值拼十进制文本注定赢不了。
DECIMAL(38,10) 的 8.26 不是定点路径。
scale > 9会return False落到通用逐值 decoder,hugeint 定点路径(push-decimals-huge)在这一档根本没被用到。
B. 做法
ENUM:读
duckdb_enum_internal_type决定键宽(UTINYINT/USMALLINT/UINTEGER),duckdb_enum_dictionary_size/value取字典(每 chunk 一次,取完duckdb_free), 整列变成一次查表。同一 chunk 内同键复用同一个 Str,零分配。DECIMAL:
dkd_decimal_join(ABI 5)在 C 里做数字循环,覆盖 int16/int32/int64, 128 位走__int128(__SIZEOF_INT128__才有,MSVC 下该档回落 Raku)。 标度上限从 9 放宽到 38,scale <= 9的旧限制只留给回退路径。
C. 两个复现出来的坑
全小数位宽要省掉前导 0。
DECIMAL(9,9)打印.5,而DECIMAL(10,9)打印0.5; 判据是width == scale。这条 DuckDB 没写进文档,是拿::VARCHAR逐类型比出来的, C 与 Raku 两条路都要有。hugeint 负数低位为正时算错(既有 bug,本次修):
push-decimals-huge把高字为-1一律当作 int64 的符号扩展,直接返回低位;对 -10^19 这类值低位是正数,结果就成了 8446744073709551616。高字-1只在低位也为负时才是符号扩展。
D. 结果(1M 行,单进程单模式)
| 场景 | 改前 | 改后 | 参照 ::VARCHAR |
|---|---|---|---|
| DECIMAL(9,2) | 2.45 | 1.23 | 1.27 |
| DECIMAL(18,4) | 3.52 | 1.24 | 1.24 |
| DECIMAL(38,10) | 9.42 | 1.48 | 1.42 |
| ENUM | 2.71 | 0.61 | 1.12 |
四列 .arrays | 18.3–22 | 8.28 | — |
DECIMAL 三条都打平了 DuckDB 自己的渲染(1.2–1.5),比改前快 2–6×;ENUM 反而比 VARCHAR 通道
还快一倍,因为它不做任何分配。四列 .arrays 整体 2.2×。
验证:t/13-decimal-enum.rakutest 24 项断言(9 种宽度/标度 × 含 NULL、128 位四角、
全小数位宽、400 项字典、惰性视图、三条解码路径 digest 全等);13 个测试文件全绿。
E. 剩余
时间、DECIMAL、ENUM 三条原生通道已闭环。清单里只剩 Parquet 直读写与"VARCHAR 批量造 Str", 前者是端到端最大项,后者按 7.6 的判定条件(< 0.4 µs/值才做)仍未达标。
7.10 时序 :typed 与 ON CONFLICT 暂存(0.9.1,2026-10-31)
§7.8 把 DATE/TIME/TIMESTAMP 的文本渲染做到了 1.2–1.4 µs/值,代价是调用方只拿到 Str。
0.9.1 补了一条并行的出口:at($i, :typed) / to-list(:typed) 直接把整数载荷造成
Date / UTC DateTime(秒以下留 Rat),DATE 用 Base-Date-Time 加法,TIMESTAMP 用
DateTime.new($secs[, timezone => 0]);floor-div 处理 1970 前的负值(div 向零截断会偏一秒)。
TIME / TIMESTAMPTZ 不做映射——裸时钟与带时区的戳没有无损的 Raku 值。
写入侧:带 ON CONFLICT 的批次此前只能逐行 prepared。0.9.1 改成「TEMP 暂存表 + 一条
INSERT ... SELECT 回放」,20k 行 55.6 s → 0.25 s(226×),100k 行 245.7 s → 0.69 s。
判定细节与踩坑见 HANDOFF.md §8.8,这里只记两条实测结论:
批量回放的
DO UPDATE是「每键一次」,逐行路径是「每行一次」。 所以批内有重复键时 必须回落逐行(末值胜),否则语义会变。DO NOTHING不受影响,两种语义等价。CTAS
... LIMIT 0正好给出 appender 需要的形状:列类型照抄目标表、约束不带过来; 而给 appender 传:schema<temp>会失败,裸表名才会被解析到tempschema。
# 写入路径基准(单进程单模式,避免 GC 噪声交叉污染)
raku -Ilib tools/perf/bench-write.raku <sql|prepared|insert-rows|exec-many|appender> 100000 1000
# P0-3 / P0-4 回归(写入路径 + 会话参数)
raku -Ilib tools/perf/regress-p03-p04.raku
# 惰性视图的内存曲线(外部采样,paths: views|chunks)
powershell -File tools/perf/probe-views-rss.ps1 -Rows 200000 -Rounds 200
# 时间列解码(单进程单模式,两次分别跑)
raku -Ilib tools/perf/bench-temporal.raku 1000000
$env:DB_DUCKDB_NO_TEMPORAL_SHIM='1'; raku -Ilib tools/perf/bench-temporal.raku 1000000
# DECIMAL / ENUM 探针(单进程单模式)
raku -Ilib tools/perf/probe-dec-enum.raku 1000000早期一次性探针(
probe3/6/7等)已随调试残留清理,基线数据见上文表格。
九、0.9.2 四项优化的实测结论(2026-11-01)
按 ROI 顺序做了四件事,两件落地、一件改判、一件半落地,逐条记录:
| 项 | 结论 | 数据 |
|---|---|---|
1. TextColumn 并行构建 | 否决,代码回退为单线程 | 2M×200B:1 线程 355 ms,8 线程 320 ms(1.1×,唯一正收益);1M×200B 196 → 199 ms 持平;1M×40B 上 4 线程 94 ms 慢于单线程 67 ms |
2. TextColumn.slice 零拷贝子视图 | 落地 | 1M 行父列上取 1000 次 10 万行切片共 23 ms(≈23 µs/次) |
| 3. staging 覆盖批内重复键 | 落地(ON CONSTRAINT 不可做) | 去重后 200k 唯一键 upsert 1.2 s 正确入库;批内重复键逐值等价于逐行末值胜 |
4. column-arrays(:parallel) 默认开启 | 落地 + 自适应线程数 | 1M×7 列 8.0 s → 4.6 s(1.8×);25k 行仍走串行 |
9.1 为什么并行构建没有收益(重要,别再试第二次)
build 的两个 pass 几乎全部时间在 shim 里,而 MoarVM 在每进程里同时只跑一个 NativeCall。
把 chunk 切成 span 丢给 start 之后,worker 们排队等那一把锁,实际只是把「一次顺序调用」
换成「一次顺序调用 + 线程调度开销」。能测到的最大收益 1.1×,出现在 200B 宽载荷、数据本身
吃满内存带宽的场景;窄载荷上线程化直接变慢。所以并行版本(!worker-cap / !sizing-spans
/ !spans 三个辅助方法 + 两处 promise 扇出)已整体删除。
判据:文本列构建的真实成本是「一次
Buf.allocate+ 每 chunk 一次 memcpy」,1M×40B 全 列为 67 ms。要再快只能减少拷贝次数(比如让 DuckDB 直接给出 string 列的string_t布局), 而不是加线程。
9.2 slice 的边界语义
越界抛异常(
slice start 20000 is out of range 0..10000),不静默裁剪;$count省略表示到末尾;$count = 0给出空列;子切片再切片时起始行相加,
is-slice恒为 True;.bytes仍返回整个父载荷(裁剪就是视图要避免的那次拷贝),.byte-size返回窗口自身跨度;窗口内 NULL 在
at/to-list仍读作Any。
9.3 staging 的批内去重做法
回放前先 probe 是否真有重复键(group by keys having count(*)>1),只有真重复时才套
row_number() over (partition by keys order by rowid desc) = 1 —— 窗口本身约占回放时间的三分之一,
不值得常态付。键列含 NULL 的行整组豁免(UNIQUE 允许 NULL 重复,SQL 会全部插入)。
ON CONFLICT ON CONSTRAINT <name> 依然是逐行路径:DuckDB 自己就不支持这个子句,在计划阶段
直接报错,没有可暂存的东西。
9.4 默认并行的阈值
PARALLEL-MIN-ROWS = 20_000,worker 数取 min(配置上限, 核心数, $total div 20000),
因此 25k 行的小结果不会被线程开销吃掉,百万行才铺开。DB_DUCKDB_NO_COLUMN_PARALLEL=1
整进程关闭默认,:degree 仍可单独收紧。t/08-parallel.rakutest 31 项覆盖默认开、
显式关、环境变量关、行数阈值四条路径。
(内容由AI生成,仅供参考)