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 同数据集对照(内存库)。 所有数字为本机实测,非估算。

一、结论摘要

  1. Raku 侧落地(解码 + 装箱)占整个读取耗时的 99% 以上,DuckDB 引擎本身不是瓶颈。 1M 行 × 4 列全扫:C 层 29–41 ms,现有 column-arrays 15 104 ms(≈390×)。

  2. 现有解码快路径有一个结构性缺陷:只要该列 chunk 带 validity bitmap(含 NULL),整列退回逐值 decoder。 BIGINT 1M 行:无 NULL 745 ms → 10% NULL 4 286 ms(5.7×)。这是单点收益最大的修复项。

  3. 写入是当前与 SQLite 差距的主要来源,且有一条 19× 的现成解法: 10 万行 × 4 列,Appender 330 ms vs 多值 INSERT(事务内)6 453 ms。

  4. 脱离 DB role 自写 API,对性能的直接收益很小(点查场景约 15–20%,其余 <5%); role 只是薄壳,真正决定性能的是 API 形态(列式/流式/零拷贝),而这些在保留 role 的前提下也能加。 → 建议"双轨"而非"弃用 role"。

  5. 第二~四节是早期探针数据,部分已被复核推翻:见第七节(2026-10-28 实测复核)。 列视图"20×"不成立、数值列"0.2 µs/值"是惰性容器造成的假快、prepared 复用实际快 37% 而非 5%。 请以第七节数字为准。

二、实测数据

2.1 单列 100 万行落地成本(现有 column($i))

列类型无 NULL10% NULL每值成本(无 NULL)
BIGINT745 ms4 286 ms0.75 µs
INTEGER887 ms—0.89 µs
SMALLINT1 754 ms—1.75 µs
DOUBLE2 252 ms6 070 ms2.25 µs
VARCHAR5 169 ms6 328 ms5.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 ms0.090 ms
prepared 复用 + bind + execute + destroy(纯 C)193 ms0.387 ms
字面量 SQL + destroy(纯 C)206 ms0.413 ms
现有 Raku 层 prepared 复用(含 Result + 解码)243 ms0.486 ms

两个要点:

  • prepared 复用相对字面量只快 5%,固定成本在 execute(DuckDB 每次建结果/事务),不在 prepare。

  • Raku 层相对纯 C 多出 0.07–0.10 ms/语句,这就是 DB role + 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 ms37–41 ms
duckdb_execute_prepared_streaming + stream_fetch_chunk7.4 ms29.5 ms

流式总时间只快 1.1–1.3×,但首块延迟低 3.8×,且峰值内存不随结果集线性增长——对"边读边处理/大结果集"是结构性优势。

2.5 会话参数影响(1M 行扫描)

配置耗时
默认37.0 ms
SET preserve_insertion_order=false + SET threads=815.8 ms(2.34×)

2.6 其它

  • 100 万行 4 列 column-arrays:15 104 ms;hashes 10 万行 × 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
DECIMAL6.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 data memcpy 到 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。

修复(三处):

  1. Arrow.arrow-chunk:duckdb_data_chunk_to_arrow 是拷贝语义(把 chunk 销毁后再读 Arrow 数据仍然正确, 由 t/11 的 zero-copy 用例覆盖),所以转换后立刻 duckdb_destroy_data_chunk;

  2. Result!decode-parallel:worker 全部 await 完、数据已拷进 @parts 后统一销毁 @chunks;

  3. 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-list1535 ms约 10×,差距全在 Raku 侧装箱
byte-size41,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.60.6.7
deg=21.22–1.24×1.20–1.38×
deg=40.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 驱动",否则不建议。

六、建议执行顺序

  1. P0-1(NULL 位图)→ 2. P0-3(Appender 写入)→ 3. P0-2(DECIMAL)→ 4. P0-4(会话参数)

  2. 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-arrays15 104 ms2 VARCHAR + BIGINT 7.2 s;单 VARCHAR 2.8 s量级一致,原值对应更宽结果
P1-5 列视图745 ms → 30 ms(20×+)建视图 10 ms(不读);读单列 0.97–3.2 s20× 不成立,见 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 preparedprepared 仅快 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 s0.10 s50×
只取 at(0) / at(last)5.0 s0.09 s55×
只读 1 个 VARCHAR 列 to-list5.0 + 3.2 s0.10 + 3.2 s1.5×
三列全读 to-list≈8.2 s4.9 s1.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 顺带修掉的两个真实缺陷

  1. 点查路径被"段表拷贝"拖死(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 内部固定成本。

  2. raw 列 to-list 未走类型特化循环:CArray 存在无类型变量里,每次索引都重新分派。按类型分派到 10 个 类型化小循环后,BIGINT 1M to-list 1.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 已确认瓶颈在 Raku substr 而非 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.950.96+1%
long(91 字符)1.331.25−6%
mb(中文,5 字符)1.131.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%,带宽)
views276 点 / 141.6 s17.3 MB177.2 / 44.2 / 30.0 MB/min0.07682–965 MB(284)
chunks 对照800 点 / 411.2 s15.4 MB11.6 / 7.2 / 2.1 MB/min0.01404–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. 三个踩到的坑

  1. 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 按旧语义读新编码。

  2. 日历要向下取整。 C 的 / 向零截断,1970 年前的秒数会算错一天;shim 里用 dkd_floor_div 统一向负无穷取整,再套 Howard Hinnant 的 civil-from-days。

  3. 文本格式有四处不是"补零就完事":公元前日期是 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.381.386.1×
TIMESTAMP 单列23.351.2219.1×
TIME 单列4.581.273.6×
三列 .arrays(µs/行)35.884.787.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.451.39~1.0
DECIMAL(18,4)(int64 定点)3.521.191.05
DECIMAL(38,10)(scale>9 → 通用解码)8.261.35—
ENUM(通用逐值 decoder)2.711.301.34(enum_code)

两个反直觉点:

  1. DECIMAL 的 Raku 定点路径比 DuckDB 自己格式化还慢 3×。 纯 Raku 侧单值格式化就要 1.71 µs,比"DuckDB 格式化 + 读整列"(1.19)还贵——在 Raku 里逐值拼十进制文本注定赢不了。

  2. 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. 两个复现出来的坑

  1. 全小数位宽要省掉前导 0。 DECIMAL(9,9) 打印 .5,而 DECIMAL(10,9) 打印 0.5; 判据是 width == scale。这条 DuckDB 没写进文档,是拿 ::VARCHAR 逐类型比出来的, C 与 Raku 两条路都要有。

  2. hugeint 负数低位为正时算错(既有 bug,本次修):push-decimals-huge 把高字为 -1 一律当作 int64 的符号扩展,直接返回低位;对 -10^19 这类值低位是正数,结果就成了 8446744073709551616。高字 -1 只在低位也为负时才是符号扩展。

D. 结果(1M 行,单进程单模式)

场景改前改后参照 ::VARCHAR
DECIMAL(9,2)2.451.231.27
DECIMAL(18,4)3.521.241.24
DECIMAL(38,10)9.421.481.42
ENUM2.710.611.12
四列 .arrays18.3–228.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,这里只记两条实测结论:

  1. 批量回放的 DO UPDATE 是「每键一次」,逐行路径是「每行一次」。 所以批内有重复键时 必须回落逐行(末值胜),否则语义会变。DO NOTHING 不受影响,两种语义等价。

  2. CTAS ... LIMIT 0 正好给出 appender 需要的形状:列类型照抄目标表、约束不带过来; 而给 appender 传 :schema<temp> 会失败,裸表名才会被解析到 temp schema。

# 写入路径基准(单进程单模式,避免 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生成,仅供参考)

DB::DuckDB v0.9.2

DuckDB driver for the Raku DB interface - NativeCall binding over libduckdb

Authors

  • skyter10086

License

Artistic-2.0

Dependencies

DBAlien::Libduckdb:ver<1.1.0>

Test Dependencies

Provides

  • DB::DuckDB
  • DB::DuckDB::Appender
  • DB::DuckDB::Arrow
  • DB::DuckDB::ArrowShim
  • DB::DuckDB::ArrowShim::Native
  • DB::DuckDB::Bridge
  • DB::DuckDB::Chunk
  • DB::DuckDB::ColumnView
  • DB::DuckDB::Connection
  • DB::DuckDB::Decoder
  • DB::DuckDB::Error
  • DB::DuckDB::Native
  • DB::DuckDB::Parquet
  • DB::DuckDB::Result
  • DB::DuckDB::ShimCache
  • DB::DuckDB::Statement
  • DB::DuckDB::TextColumn
  • DB::DuckDB::VarcharShim
  • DB::DuckDB::VarcharShim::Native

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.