DB::DuckDB — HANDOFF
HANDOFF — DB::DuckDB 0.9.1
交接对象:接手维护者。本文所有数字为本机实测,出处见
duckdb-perf-next-steps.md与CHANGES.md。 交接时点:2026-10-29,提交f66db4e,工作区干净。 0.7.2 补记:§8.3 的 README 过时描述与stamp-file/abi-stamp死代码已清理 (提交f22811a),Windows 13 测试与 WSLzef install双平台复核通过。 0.8.0 补记:§8.1 的 Parquet 直读写已落地(DB::DuckDB::Parquet+ 驱动/连接方法), 实测读 Parquet 与读表同速、写 1M×4 列 0.033 s,详见 §8.1 与 CHANGES 0.8.0。 0.8.1 补记:§8.4 的 Arrow 无 shim 释放泄漏已修(缓存 callable,+69 MB → +2 MB), 并补DB_DUCKDB_NO_ARROW_SHIM=1。 0.8.2 补记:§8.4 又补两处——无编译器时「回落」原本会抛异常(已在调用前探测库)、 Arrow shim 构建补齐并发加固(私有名 + per-file ABI 戳);另清 5 处注释乱码。 0.8.3 补记:§8.5 的缓存卫生做成DB::DuckDB::ShimCache统一清理(实测 8 个文件 → 4 个全活); Bridge 的两处同类问题一并修掉——mtime 判断换成 FNV-1a 内容指纹、构建加并发加固。 0.9.0 补记:新增DB::DuckDB::TextColumn零拷贝文本列视图(column-view($i).text), VARCHAR/BLOB 不再逐值造Str,而是给出连续字节缓冲 + offset/length/NULL 三张表(详见 §8.6)。 shim ABI 由 5 抬到 6:新增dkd_varchar_unpack_base(..., offset_base),旧符号保留为base=0包装。 实测 1M×VARCHAR(40) 构建 156 ms(to-list仍 1535 ms,差距在 Raku 侧装箱); 标签v0.9.0已推 origin。 0.9.1 补记:三件事——ColumnView.at(:typed)/to-list(:typed)把 DATE / TIMESTAMP 直接建成Date/DateTime(不再先渲染文本再解析,见 §8.7);TextColumn.to-list改整窗 解码 ASCII(1M×40 B 3.91 s → 1.91 s);execute-many让带ON CONFLICT的批次走 TEMP 暂存表
一条
INSERT ... SELECT回放(20k 行 55.6 s → 0.25 s,见 §8.8)。
0. 三十秒版本
一个 Raku 的 DuckDB 驱动(NativeCall 绑定 libduckdb 2.x,实现 DB role)。
当前 0.9.1 的状态:读写两侧都有绕开逐值装箱的出口——VARCHAR / 时间 / DECIMAL / ENUM 四类值
都有 C 通道从原生载荷直接渲染;Parquet 读写已在 0.8.0 接上(走 SQL 层,见 §8.1);
Arrow 无 shim 路径的释放泄漏在 0.8.1 修掉(§8.4);三个 shim 的构建与缓存卫生在 0.8.2/0.8.3 统一(§8.5);
0.9.0 新增 TextColumn 零拷贝文本列(§8.6),需要吞吐时改走 .text;
0.9.1 补上时序的 :typed 值(§8.7)与 ON CONFLICT 批次的暂存写入(§8.8)。
16 个测试文件(514 个断言),Windows 与 WSL Ubuntu 双平台全绿
无 C 编译器时全线回落到纯 Raku(含
TextColumn的逐值兜底),功能不变、只是慢遗留:逐值取文本仍是 1.26 µs/值;批量读已由
.text绕开(§8.2、§8.6)
1. 环境
Windows(主开发环境)
| 项目 | 值 |
|---|---|
| OS | Windows 11 |
| Rakudo | Rakudo Star 2026.09,C:\rakudo\bin\raku.exe |
| libduckdb | v2.0.0-dev1(Alien::Libduckdb:ver<1.1.0> 自编译产物) |
| C 编译器 | gcc(MSVC 亦可用,但 __int128 档只在 GCC/Clang 下生效) |
| 仓库 | C:\Users\Admin\DB-DuckDB,remote [email protected]:skyter10086/DB-DuckDB.git,分支 master |
WSL Ubuntu(第二验证环境,0.7.1 才打通)
| 项目 | 值 |
|---|---|
| 发行版 | Ubuntu(WSL2),用户 zby,HOME /home/zby |
| Rakudo | rakubrew 装的 2026.09,不在默认 PATH:/home/zby/.rakubrew/versions/moar-2026.09/install/bin |
| zef | /home/zby/.local/bin/zef |
| C 编译器 | /usr/bin/cc、gcc |
| Windows 仓库挂载 | /mnt/c/Users/Admin/DB-DuckDB |
注意:~/.rakubrew/versions/moar-2026.09 是源码构建目录,bin/ 不存在;rakudo 可执行文件在
install/bin/ 下。用 rakubrew init 在当前环境里没生效,直接走绝对路径最省事。
2. 仓库结构
lib/DB/DuckDB.rakumod 驱动本体,does DB
lib/DB/DuckDB/Native.rakumod libduckdb 全部 C 符号绑定(含 duckdb_result 固定布局结构体)
lib/DB/DuckDB/Bridge.rakumod 仅用于两个「按值传 duckdb_result」的访问器,改传指针绕过
lib/DB/DuckDB/Connection.rakumod 连接、prepared 缓存、事务
lib/DB/DuckDB/Statement.rakumod 占位符绑定(? 与 $name;DuckDB 不支持 :name)
lib/DB/DuckDB/Result.rakumod 结果读取总调度:解码路径选择、shim 探测、纯 Raku 回落
lib/DB/DuckDB/Decoder.rakumod 逐值通用解码器(所有回落路径的终点)
lib/DB/DuckDB/Parquet.rakumod Parquet 读写的 SQL 构造:路径绑定、字面量/标识符转义、选项白名单
lib/DB/DuckDB/ShimCache.rakumod 三个 shim 共用的缓存清理(.bak / 孤儿戳 / 过期私有构建)
lib/DB/DuckDB/Chunk.rakumod chunk 包装、生命周期
lib/DB/DuckDB/ColumnView.rakumod 零装箱列视图(0.6.8 起是真惰性)
lib/DB/DuckDB/TextColumn.rakumod 零拷贝文本列视图(0.9.0,字节缓冲 + offset/length/NULL 表)
lib/DB/DuckDB/Appender.rakumod 批量写入
lib/DB/DuckDB/VarcharShim.rakumod VARCHAR/时间/DECIMAL shim 的构建、缓存、ABI 校验
lib/DB/DuckDB/VarcharShim/Native.rakumod shim 的 NativeCall 声明(join-varchar / join-temporal / join-decimal)
lib/DB/DuckDB/Arrow.rakumod Arrow C data interface 只读视图 + memcpy 绑定
lib/DB/DuckDB/ArrowShim.rakumod Arrow 释放回调用的小 shim(Windows 上必需)
resources/duckdb-varchar-shim.c 自包含 C:string_t 布局解包 + 时间渲染 + DECIMAL 渲染
resources/duckdb-arrow-shim.c Arrow release 回调
resources/duckdb-result-shim.c by-value 结果结构的辅助
tools/perf/*.raku|*.ps1 基准与探针(见 §9)
t/01..16-*.rakutest 测试,16 个,可单独 raku 运行3. 版本演进(只列对性能有实质影响的)
| 版本 | 内容 | 关键数字 |
|---|---|---|
| 0.6.0 | 列式读取 + Appender 填充 + 并行解码雏形 | 读路径从逐值解码改为 chunk/vector |
| 0.6.1 | column-arrays(:parallel) | 按列并行解码 |
| 0.6.4 | 惰性 column-views、点查修复 | 未读列零成本 |
| 0.6.5 | VARCHAR 走 join+split 缓冲 | column() 2.7–3.4× |
| 0.6.6 | Arrow C data interface 视图;修 chunk 所有权泄漏 | 修复每轮约 23 MB 泄漏(200k 行 × 多轮,累计 4.9 GB 量级) |
| 0.6.7 | 按列权重切分并行、流式双缓冲预取 | 首块可用 28 ms → 7.4 ms(1M×4 列) |
| 0.6.8 | 真惰性视图;META6 补 provides/resources、去 BOM | — |
| 0.6.9 | DATE/TIME/TIMESTAMP 从整数载荷渲染(shim ABI 4) | DATE 8.38→1.38、TIMESTAMP 23.35→1.22、TIME 4.58→1.27 µs/值 |
| 0.7.0 | DECIMAL 在 C 里渲染、ENUM 查字典(ABI 5) | DECIMAL(9,2) 2.45→1.23、(18,4) 3.52→1.24、(38,10) 9.42→1.48、ENUM 2.71→0.61 µs/值 |
| 0.7.1 | 修 Linux 安装(msvcrt/libc 依赖误判)、shim 并发构建加固 | 见 §7.10–7.12 |
| 0.7.2 | README 与代码对齐、清 stamp-file / abi-stamp 死代码 | — |
| 0.8.0 | Parquet 读写在 SQL 层落地(DB::DuckDB::Parquet) | 写 1M×4 列 0.033 s;读与表扫描同速 |
| 0.8.1 | Arrow 无 shim 释放不再每次造 call stub | 60 轮 +69 MB → +2 MB |
| 0.8.2 | Arrow 回落真实化、Arrow shim 并发加固 | 6 进程并发首建 6/6 通过 |
| 0.8.3 | ShimCache 统一缓存清理;Bridge 改 FNV-1a 内容指纹 | 缓存 8 个文件(4 死)→ 4 个全活 |
| 0.9.0 | TextColumn 零拷贝文本列(shim ABI 6,新增 offset_base) | 1M 值构建 156 ms;to-list 1.9 s |
| 0.9.1 | 时序 :typed 值、to-list 整窗解码、execute-many 的 ON CONFLICT 暂存 | to-list 3.91→1.91 s;20k upsert 55.6 s→0.25 s |
4. 性能基线(0.7.1,1M 行,单进程单模式,best-of 3)
4.1 列读取(µs/值)
| 类型 | 当前 | 说明 |
|---|---|---|
| BIGINT | 0.8–1.0 | to-list 类型特化后 1.71 s → 0.97 s |
| VARCHAR short(2 字符) | 0.95–0.96 | 惰性视图与 eager 持平 |
| VARCHAR long(91 字符) | 1.25–1.33 | 视图反而快 6% |
| VARCHAR 中文(5 字符) | 1.05–1.13 | 视图快 7% |
| DATE | 1.38 | 参照 ::VARCHAR 1.30 |
| TIMESTAMP | 1.22 | — |
| TIME | 1.27 | — |
| DECIMAL(9,2) | 1.23 | 参照 1.27 |
| DECIMAL(18,4) | 1.24 | 参照 1.24 |
| DECIMAL(38,10) | 1.48 | 参照 1.42 |
| ENUM | 0.61 | 比 VARCHAR 通道快一倍,零分配 |
多列(µs/行):时间三列 .arrays 4.78(原 35.88);DECIMAL+ENUM 四列 .arrays 8.28(原 18.3–22)。
4.2 语句与写入
| 场景 | 数字 |
|---|---|
SELECT 1 × 500(仅 execute + destroy) | 0.090 ms/次 |
| prepared 复用 + bind + execute(纯 C) | 0.387 ms/次 |
| 现有 Raku 层 prepared(含 Result + 解码) | 0.486 ms/次 |
| Appender 写 10 万行 × 4 列 | 330 ms |
| 多值 INSERT(1000 行/句,事务内) | 6 453 ms(19.5× 慢于 Appender) |
execute-many + ON CONFLICT,20k 行(暂存路径) | 0.25 s(逐行 prepared 55.6 s,226×) |
| 同上,100k 行 | 0.69 s(逐行 prepared 245.7 s) |
结论:语句固定成本的大头在 execute 本身,不在 prepare;Raku 层相对纯 C 只多 0.07–0.10 ms/语句。
单行点查不需要再优化,逐值 .at() 的 8.7 µs/值只影响大循环遍历,那种场景应改用 .chunks / to-list。
5. 架构要点(改代码前必读)
duckdb_result按固定 48 字节结构体映射(LP64:3 个idx_t+ 3 指针),加载时断言尺寸。 ABI 变了要立刻炸,而不是悄悄踩内存。结果只走 chunk/vector API。
duckdb_result_chunk_count/duckdb_fetch_chunk拿duckdb_data_chunk,直接读 vector 数据缓冲。不要用CStruct数组元素访问——NativeCall 在 Windows 上会 fault,一律用uint32/uint64视图逐字段解剖。禁止按值返回/接收结构体,唯一例外是那两个仍声明 by-value
duckdb_result的访问器, 走DB::DuckDB::Bridge把同样 48 字节当指针传。NativeCall 的 by-value marshalling 在 MS x64 碰巧能跑,在 SysV 下会被误编译(表现为 DuckDB 内部unique_ptrNULL 崩溃)。值的文本与 DuckDB 自己的
CAST(x AS VARCHAR)逐字节一致,不转 RakuDate/DateTime/Rat。 精确性优先,需要结构化时在调用点自己转。shim 全程可选。没编译器就回落纯 Raku,同一份数据两条路的 digest 必须相等(测试里逐字节比对)。
DB::DuckDB::Result的默认abi=0,只有 shim 真正可用才把 ABI 抬上去。shim ABI 版本 = 契约。当前 6。新增通道必须:C 侧加函数 →
SHIM-ABI加一 →Result.rakumod的探测条件同步(如$abi >= 4)→ 加 digest 三路径比对测试。 kind 编码一律从 1 起:0在 Raku 里是假值,elsif分支会静默跳过(0.6.9 踩过)。 给已有符号加参数也算破 ABI(0.9.0:dkd_varchar_unpack→..._base多一个offset_base): 老库里同名符号参数个数不同,NativeCall 不会报错只会读错栈,必须靠 ABI 号拒绝加载 并回落到 Raku 兜底路径。prepared 语句按连接缓存,连接销毁时一并销毁;
close兜底清理调用者忘finish的连接, 否则 DuckDB 不会释放文件锁。Alien 依赖是钉死的(
Alien::Libduckdb:ver<1.1.0>= libduckdb 2.0.0)。C API 跨大版本不 ABI 稳定(枚举重编号、访问器改按值传)。升级流程:读duckdb.h→ 核对 result 结构布局 → 跑t/→ 再 bump pin。运行时还有第二道防线:加载后校验 major 版本,非 2.x 直接拒绝启动。
6. 已否决的方案(不要再花时间)
| 候选 | 结论 | 依据 |
|---|---|---|
| 列视图"20× 提升" | 概念错误 | 真实收益是「未读列零成本」,不是 20×;读到的列仍是 0.8–3.2 µs/值 |
| VARCHAR 跨段合并解码 | 否决 | 想回收的 16% 在 0.6.4 换实现后已消失,现差异 +1% 到 −7%;合并要先 memcpy 91 MB/列,净负 |
| 惰性 views 内存缓升(怀疑泄漏) | 非泄漏,不优化 | 200k×3 列 200 轮外部采样:斜率逐段衰减、后段 r²≈0,平台不到 1 GB;三列 98 块若真漏早就 GB 级。是 Rakudo GC 高水位 + DuckDB 延迟回收 |
弃用 DB role 自写 API | 否决 | 点查场景 15–20%,其余 <5%;role 是薄壳,真正决定性能的是 API 形态(列式/流式/零拷贝),保留 role 也能加 |
| 去装箱(弃用 Raku 装箱类型) | 伪命题 | 装箱不是瓶颈;瓶颈是逐值方法调用与字符串构造 |
nqp:: 下沉热循环 | 仅可选 | 收益不抵可读性损失,且 nqp::collectgarbage 在 Rakudo 2026.09 已不存在 |
RakuAST 优化 | 无用 | 只作用于编译期,与运行期解码无关 |
| DECIMAL 用纯 Raku 定点渲染 | 搬进 C | 纯 Raku 单值格式化 1.71 µs > DuckDB 自己格式化 1.19 µs;在 Raku 里逐值拼十进制文本注定输 |
| 写入路径继续压(P3) | 主动搁置 | 收益不抵复杂度,Appender 已比多值 INSERT 快 19.5× |
VARCHAR 批量造 Str | 暂不做(0.9.0 绕开) | 判定线是 <0.4 µs/值,实测未达;改用 TextColumn 提供不走 Str 的出口(§8.6) |
TextColumn 构建多线程化 | 否决(0.9.2 实测后回退) | MoarVM 每进程只跑一个 NativeCall,8 线程最多 1.1×,窄载荷反而更慢;详见 §8.11 |
7. 踩过的坑(复现过一次就别再踩)
kind从 0 起 → 分支不进:0在 Raku 为假,elsif ... -> $kind直接跳过,DATE 悄悄回落逐值解码。C 的整数除法向零截断:1970 年前的秒数会差一天。shim 里统一用
dkd_floor_div向负无穷取整。DECIMAL(9,9)打印.5而非0.5:判据是width == scale。DuckDB 没写进文档,是拿::VARCHAR逐类型比出来的;C 与 Raku 两条路都要有这条判断。hugeint 负数低位为正时算错:高字
-1只在低位也为负时才是 int64 符号扩展; 否则 -10^19 会读成 8446744073709551616。文本格式四处不是「补零就完事」:公元前是
0001-12-31 (BC);24:00:00合法; 小数部分去尾零(01:02:03.000400→01:02:03.0004);TIMESTAMP 最多 6 位、TIMESTAMP_NS 最多 9 位。TIMESTAMP_TZ/TIME_TZ不能从 payload 复现:文本含会话时区,只能走 DuckDB 自己渲染 (TIMEZONE变更后要刷新偏移)。Windows 上
libduckdb实际 dll 名是duckdb.dll,且不需要 MSVC.lib(运行时LoadLibrary)。跨线程
Hash会丢键:并行解码时共享 Hash 必须在主线程预建。PowerShell 的 CLIXML 噪声:从 Raku 拉 PowerShell 子进程会把父控制台打成一堆 XML。 取进程 RSS 之类要外部采样(见
tools/perf/probe-views-rss.ps1),命令输出用cmd /c+Where-Object过滤。zef / raku-pm 会扫描源码里的
is native('...')字面量,全部当硬依赖。写is native('msvcrt')→ Linux 上装不上(缺 msvcrt);写is native('libc')→ Windows 上装不上(缺 libc)。 正确做法:名字用常量拼出来,例如my constant CLIB = $*DISTRO.is-win ?? 'msvc' ~ 'rt' !! 'libc' ~ '.so.6';NativeCall 会自动给裸名字加
lib前缀:裸libc变成找liblibc.so(不存在)。 带点的名字(如libc.so.6)才按原样使用。.modified在本 Rakudo 对刚写入的文件可能返回负 Instant,拿它判新旧会误把新库当陈旧库删掉。 缓存过期判断改用文件名里编码的 epoch 秒(lib-epoch)。共享 shim 库被进程映射时
unlink/rename会失败(Windows 更明显)。这不是 bug, 驱动器会自动落到私有名继续工作;缓存复用实测 306 ms 命中、13 次测试只留一份库。
8. 未完成事项(按 ROI 排序)
8.1 Parquet 直读写 —— 0.8.0 已完成
duckdb.h(2.0)里没有任何 parquet 符号,所以不存在可绑的 C 入口,"直"只能落在 SQL:
读走 read_parquet(...) 表函数,写走 COPY ... TO ... (FORMAT PARQUET)。新增
DB::DuckDB::Parquet 负责构造 SQL、转义与白名单校验,驱动与连接各挂
read-parquet / write-parquet / parquet-metadata / parquet-schema / parquet-row-groups。
关键设计(改代码前必读):
读侧路径全部绑定(多文件是
read_parquet([?, ?]),一个占位符一路径),所以带单引号的 路径不需要转义;写侧必须插值目标路径与表名,走parquet-literal/parquet-ident。读 Parquet 不是第二条解码路径:走同一套 chunk/vector,VARCHAR/时间/DECIMAL 的 C 通道自动生效。
row_group_size只在 2048 行 vector 边界生效,实际行组是它的整数倍。分区目录读取时分区列不在文件里,
hive_partitioning把它追加在最后,按列序号读会踩到。
实测(1M 行 × 4 列,best-of-3,单进程,zstd):写 0.033 s / 2.05 MiB;读 BIGINT 0.58 s
(表扫描 0.58)、VARCHAR 1.15(表 1.20)、四列 5.08 µs/行(表 5.05)、首块 19.8 ms
(表 37.7);parquet-metadata 只读 footer,0.6 ms。对比 execute-many 的 11.3 µs/行,
COPY 写每行快约 400 倍。
8.2 VARCHAR 批量造 Str —— 判定线未达(0.9.0 从另一侧绕开,见 §8.6)
判定条件是 <0.4 µs/值,当前 0.95–1.33。要再往下只能减少逐值 substr(已确认瓶颈在 Raku substr,
不在 C shim)。没想清楚之前不要动——需要吞吐的调用点改走 column-view($i).text,不造 Str。
8.3 README 与代码不同步(0.7.2 已解决)
## Status仍写 Not implemented (yet): …Appender—— 实际 Appender 早已实现并有t/05-appender.rakutest## Requirements写 tested on Rakudo 2025.06, Windows 11 —— 实际双平台 2026.09 验证通过顺手可清:
VarcharShim.rakumod里stamp-file/abi-stamp已无调用方
8.4 Arrow 在「无 shim」路径上的释放 —— 0.8.1 已修,0.8.2 收尾
根因不是所有权语义,而是 nativecast(:(Pointer), $ptr) 每次都会造一个新的 native call
stub,且 VM 不回收。实测:release 指针在所有 chunk 上是同一个(245 个 chunk 只有 1 个不同
值),所以按指针值缓存 Code 即可,一次 cast 终身复用;Lock 只保护首次填充。
对比(60 轮 × 5 万行,约 1500 次释放):修前 +69 MB,修后 +2 MB(GC 噪声),有 shim 时 +5 MB。
同时补了 DB_DUCKDB_NO_ARROW_SHIM=1(此前 Arrow 没有对应 VARCHAR 的禁用开关),
t/11 增加 2 条断言专测这条路径,否则它只能靠「机器上没有编译器」偶然触发。
0.8.2 又补了两处(都是查遗留时发现的,不是新功能):
所谓「无编译器回落」原本不成立:
require只能证明模块能编译,is native(&shim-library)是惰性解析,所以真正的失败被推迟到第一次 native 调用——那时数组已在手上、没有第二条释放路。 现在先问 shim 模块「库到底存不存在」再决定用不用。验证方法:在库该在的位置放一个同名目录 让编译必然失败,t/11在这条路上仍然 17/17 通过而不是抛异常。Arrow shim 的构建补齐了 0.7.1 给 VARCHAR shim 做过的并发加固:编译产物先落到私有名
libduckdb-arrow-shim-<pid>-<epoch>.<ext>,共享名空闲时才 rename;ABI 戳改成 per-file (<lib>.abi)而非目录级。6 进程并发首建 6/6 通过。
8.5 shim 缓存目录清理 —— 0.8.3 已系统解决
三个 shim 各自经历过命名方案变更,缓存目录里会积累再也没人读的文件:.bak、库已消失的
戳(含旧的目录级 libduckdb-x-shim.abi)、共享名被占用时留下的私有名构建。
现在统一由 DB::DuckDB::ShimCache::sweep 在每个进程解析到 shim 后清一次,三条保守规则:
有可用库才清(不会把缓存清空)、不碰当前解析到的库、可 DB_DUCKDB_NO_CACHE_SWEEP=1 关闭。
实测真实缓存 8 个文件(4 个是死的)→ 一次查询后 4 个,全部在用。t/15-shim-cache.rakutest 覆盖。
顺带修掉 Bridge 的两处同类问题(它此前是三个 shim 里唯一没加固过的):
用 mtime 判断缓存是否新鲜 → 这个 Rakudo 对刚写入的文件返回负 Instant,导致桥接库 每次运行都重编译。改成 C 源内容的 FNV-1a 指纹(
sha1不在核心,属 Digest::SHA1)。直接链接到共享名 → 改成私有名 + rename,6 进程并发首建 6/6 通过。
8.6 零拷贝文本列 TextColumn —— 0.9.0 已完成
动机:column() / to-list 每个值都要在 Raku 里造一个 Str(VARCHAR 1.26 µs/值),
这就是与 Python fetchnumpy 的差距所在(1M 行 1240 ms vs 68 ms),而瓶颈在 Raku 侧
装箱与 substr,不在 C 绑定。0.9.0 给出一条完全不造 Str 的出口:
$result.column-view($i).text(仅 VARCHAR / BLOB)返回 DB::DuckDB::TextColumn,内含
一段连续的字节缓冲:
bytes/byte-size,bytes-at($i)直接切Buf视图;三张并行的表:
offset($i)/length($i)/is-null($i),也可整张拿offset-buffer/length-buffer/null-buffer自己做批处理(例如交给 C 或 NQP 层);at($i)/to-list是便利出口,会造Str,慢(见下),只用于校验与小结果。 0.9.1 起to-list把一个窗口内的 ASCII 载荷整块decode后按substr切片,只在窗口 非纯 ASCII(chars< 字节数)时回落到逐行subbuf+decode;窗口上限 8 MiB,DB_DUCKDB_NO_TEXT_WHOLE=1可强制旧路径。
实现要点(改之前必读):
复用
resources/duckdb-varchar-shim.c的解包循环,新增dkd_varchar_unpack_base(data, validity, n, out, cap, offsets, lens, nulls, offset_base); 旧的dkd_varchar_unpack(...)保留为offset_base = 0的包装,DKD_SHIM_ABI5 → 6,VarcharShim.rakumod的SHIM-ABI同步抬到 6(低于 6 的缓存库会被丢掉重建)。踩坑(本版唯一的实质 bug):原
dkd_varchar_unpack每块都从total = 0起算 offset, 写进offsets的是块内相对值。多个 chunk 拼进同一缓冲后,第二块的 offset 又从 0 开始, 于是第 2048 行声称自己在字节 0(tc-debug.raku打印bad=5,从i = 2048起全错)。 修在 C 侧:在 Raku 里逐行补基址实测 503.6 ms/百万行(offbase.raku),不可接受。Raku 侧两趟:pass 1 只累加 sizing(总字节数 + 每行长度),pass 2 按 row-base / byte-base 推进指针写三张表。非 flat 向量(嵌套 / 字典编码)回落
!from-values逐值解码,语义不变、只是慢。unpack-varchar-into增offset-base参数;运行时遇到 ABI < 6 的旧库走 Raku 兜底循环。校验:
tc-verify-1m.raku100 万行逐值对比to-list0 处不一致;offset 断层扫描 0 处;sum(length)与byte-size精确相等。t/16-text-column.rakutest45 条断言,覆盖跨 chunk 边界(2048)、inline/pointer 分界(12 字节)、多字节 UTF-8、NULL 与空串区分、拒绝 numeric、 拒绝 decoded-only、无 shim 时的可用性判断。
实测(1M 行 VARCHAR(40),Windows,best-of-3):
| 出口 | 耗时 |
|---|---|
column-view($i).text 构建 | 67 ms(0.9.2 复测;0.9.0 首发时记 156 ms) |
同列 to-list | 1 910 ms(整窗解码;逐行 3 910 ms) |
byte-size | 41,896,900 |
8.7 时序值的 :typed 出口 —— 0.9.1 已完成
动机:at() / column() 为了和 DuckDB 的 ::VARCHAR 逐字一致,把 DATE / TIMESTAMP 渲染成文本
(t/12 里 1.38 / 1.22 µs/值);调用方拿到的是 Str,做日期算术还得自己解析。
at($i, :typed) / to-list(:typed) 反过来:直接把整数载荷造成 Date / UTC DateTime
(秒以下保留为 Rat),DATE 用 Date.new(1970,1,1).later(days => $days),
TIMESTAMP 用 DateTime.new($secs[, timezone => 0]),floor-div 保证 1970 前的负时间戳落在正确的秒。
实现要点(改之前必读):
原始视图的 typed 段有独立缓存
@!tdec:同一段普通读返回文本、typed 读返回值,两者不能共用一格。切片 / 批量 / 脱离视图(
from-list)拿不到载荷,只能从 DuckDB 自己的文本重建(!typed-from-text);(BC)后缀、infinity、TIME、TIMESTAMPTZ 一律原样返回文本,宁可给文本也不给错的日期。TIME 与 TIMESTAMPTZ 不做映射:裸时钟 / 带时区的戳没有无损的 Raku 值可落。
覆盖:
t/12-temporal.rakutest53 条(raw / 切片 / 脱离三种视图 × DATE / 三种 TIMESTAMP / 回退形态)。
8.8 execute-many 的 ON CONFLICT 暂存 —— 0.9.1 已完成
历史事实(§8.1 记过):带 ON CONFLICT 的批次只能逐行 prepared,11.3 µs/行量级;
而「灌 TEMP 表再 INSERT ... SELECT」比 appender 慢 850× 的结论只对逐行 statement 成立,
批量回放实测快两个数量级。
现在的路径:语句能解析成 INSERT INTO t [(cols)] VALUES (?...) 且带 ON CONFLICT 尾子句、
批量 ≥ STAGING-MIN-ROWS = 32 时,!insert-plan 把尾子句原样存进 tail,然后
CREATE TEMP TABLE __db_duckdb_stage_N AS SELECT <table 全列> FROM <target> LIMIT 0——CTAS 复制类型、丢掉约束,正好是 appender 需要的样子;appender 灌暂存表(
execute-many传裸表名即可,别传:schema<temp>,会报Table ""."temp".x could not be found);一条
INSERT INTO <target> (<cols>) SELECT <cols> FROM <stage> <tail>回放,返回变更行数;LEAVE里drop table if exists,失败时上层事务回滚,残留 0 行、0 张表。
判定规则(改之前必读):
DO NOTHING恒可暂存:跳过一行在「逐行」与「一次性」两种语义下结果相同(含批内重复键)。DO UPDATE只在显式冲突目标ON CONFLICT (cols) DO UPDATE时暂存:批量INSERT ... SELECT对同一键只应用一次DO UPDATE,逐行路径是每行一次(末值胜)。0.9.2 起批内重复键不再回落逐行, 而是在回放里先折叠成每键一行(row_number() over (partition by keys order by rowid desc) = 1,rowid是暂存表的插入顺序,取末行即逐行路径的末值胜);键列含 NULL 的行整组豁免(UNIQUE 允许 NULL 重复)。窗口约占回放时间的三分之一,所以先group by keys having count(*)>1探一次, 真重复才建窗口。ON CONSTRAINT、RETURNING、表达式、子查询、窄批次一律走原来的逐行路径(ON CONSTRAINT是 DuckDB 自己不支持的子句,计划阶段就报错,没有可暂存的东西)。语义差异(有意接受):批量回放里同一语句的多行对同一目标的
DO UPDATE只发生一次。
实测(Windows,单进程,含建表开销):20k 行 55.6 s → 0.25 s;100k 行 245.7 s → 0.69 s。
t/05-appender.rakutest 增 14 条断言(DO NOTHING 计数与 DEFAULT 保留、全重复 0 行、临时表清理、
DO UPDATE、批内重复键末值胜、失败回滚无残留、列序置换)。
8.9 TextColumn.slice 零拷贝子视图 —— 0.9.2 已完成
slice($from, $count?) 返回同类的另一个 TextColumn:共享父列的 Buf 与三张表,只多记一个起始行
($!row0),所以代价与父列大小无关。实测在 1M 行父列上取 1000 次 10 万行切片共 23 ms(≈23 µs/次)。
边界语义(改之前必读):
越界抛异常(
slice start 20000 is out of range 0..10000),不静默裁剪;$count省略表示到末尾。.bytes仍返回整个父载荷(裁剪就是视图要避免的那次拷贝),.byte-size返回窗口自身跨度; 这个方法头上的文档写了这一点,不要"顺手"改成裁剪版。子切片再切片起始行相加,
is-slice恒 True;窗口内 NULL 在at/to-list仍读作Any。t/16-text-column.rakutest63 条(原 45 条 + slice 的窗口 / 尾窗 / 空窗 / 越界 / 叠加 / NULL)。
8.10 column-arrays 默认并行 + 自适应线程数 —— 0.9.2 已完成
签名改为 Bool :$parallel = !%*ENV<DB_DUCKDB_NO_COLUMN_PARALLEL>,即默认开;
worker 数不再恒等于核心数,而是 min(上限, 核心数, $total div PARALLEL-MIN-ROWS),
PARALLEL-MIN-ROWS = 20_000,所以 25k 行的小结果仍走串行(线程开销会吃掉收益),百万行才铺开。
实测:1M 行 × 7 列 8.0 s → 4.6 s(1.8×),结果与串行逐值一致。开关三条路径都测了:
默认开、:!parallel 强制关、DB_DUCKDB_NO_COLUMN_PARALLEL=1 整进程关(t/08 31 条)。
8.11 TextColumn 并行构建 —— 做了、测了、删了
本版曾按「byte-size 切块并行」改过 build(!worker-cap / !sizing-spans / !spans 三个辅助方法
两处 promise 扇出),实测没有收益,代码已整体回退为单线程:
| 载荷 | 1 线程 | 8 线程 |
|---|---|---|
| 1M × 20B | 51 ms | 50 ms |
| 1M × 40B | 67 ms | 66 ms(4 线程 94 ms,更慢) |
| 1M × 200B | 196 ms | 199 ms |
| 2M × 200B | 355 ms | 320 ms(唯一一次正收益,1.1×) |
原因:两个 pass 几乎全在 shim 内,而 MoarVM 每进程同时只跑一个 NativeCall,worker 只是排队等锁, 额外付出线程调度开销。别再试第二次,除非换到能真正并行跑 native 的运行时。 真要再快只有减少拷贝次数(例如直接读 string_t 布局),不是加线程。
9. 复核命令
# 全测试(Windows)
cd C:\Users\Admin\DB-DuckDB
Get-ChildItem t\*.rakutest | Sort-Object Name | ForEach-Object {
$o = cmd /c "C:\rakudo\bin\raku.exe -Ilib $($_.FullName) 2>&1"
"$($_.Name) notok=$(($o | Select-String '^not ok').Count)"
}
# 读写基准
raku -Ilib tools/perf/bench-write.raku <sql|prepared|insert-rows|exec-many|appender> 100000 1000
raku -Ilib tools/perf/bench-varchar-shapes.raku
raku -Ilib tools/perf/bench-parquet.raku 1000000
raku -Ilib tools/perf/bench-temporal.raku 1000000
raku -Ilib tools/perf/probe-dec-enum.raku 1000000
# 零拷贝文本列(构建耗时、offset 连续性、与 to-list 逐值比对)
raku -Ilib t\16-text-column.rakutest
# 惰性视图内存曲线(外部采样子进程 RSS,别在 Raku 里拉 PowerShell)
powershell -File tools/perf/probe-views-rss.ps1 -Rows 200000 -Rounds 200 [-Path chunks]
# 并发安全(首建缓存时多进程同时跑)
1..6 | ForEach-Object { Start-Job { cd C:\Users\Admin\DB-DuckDB; cmd /c "C:\rakudo\bin\raku.exe -Ilib t\13-decimal-enum.rakutest" } } | Wait-Job | Receive-JobWSL 侧:
export PATH=/home/zby/.rakubrew/versions/moar-2026.09/install/bin:/home/zby/.local/bin:$PATH
cd /mnt/c/Users/Admin/DB-DuckDB
zef install . --install-to=inst#/tmp/dkd-test-$$ --force10. 发布约定
版本号:SemVer。pre-1.0 阶段 minor 同时承载新功能与行为变更;只有破
DBrole 契约时才升api。每次发布:
META6.json版本 +CHANGES.md新段(Fixed / Changed / Added,附实测数字)。提交风格:
<area>: <what> (<version>),例如Render DECIMAL in C and decode ENUM by dictionary (0.7.0)。必跑门槛:16 个测试全绿 + Windows 与 WSL 双平台(Linux 首次安装容易漏,0.7.1 就是漏在这里)。
新增 shim 通道清单:C 函数 →
SHIM-ABI+1 →Result探测条件 → digest 三路径比对(C / 纯 Raku / 无 shim)→ 更新 README 的 Design notes 与 Type mapping。
11. 交接检查清单
工作区干净,
1f97c25已推origin/masterWindows:16 个测试 461 个断言 notok=0
WSL Ubuntu:
zef installTesting [OK] → InstallingParquet 直读写 —— 0.8.0
Arrow 无 shim 释放泄漏 —— 0.8.1
Arrow 无编译器回落失效 + shim 并发加固 —— 0.8.2
shim 缓存清理 + Bridge 的 mtime/并发加固 —— 0.8.3
TextColumn.slice零拷贝视图 + 批内重复键折叠 +column-arrays默认并行 —— 0.9.2零拷贝文本列
TextColumn+ shim ABI 6 —— 0.9.0标签
v0.9.0(annotated)已推 originREADME 两处过时描述(§8.3)—— 0.7.2
stamp-file/abi-stamp死代码清理 —— 0.7.2
12. 提交历史(近 18 条)
85ad33f feat: slice TextColumn without copying, fold duplicate keys in staged batches, decode columns in parallel by default (0.9.2)
d2c8a7c 0.9.1: typed temporal reads, windowed to-list, staged ON CONFLICT batches
1f97c25 Add DB::DuckDB::TextColumn for zero-copy VARCHAR/BLOB export
e0b1670 fix: fingerprint the bridge build and sweep the shim cache (0.8.3)
46ad0a7 fix: make the Arrow fallback real and harden the shim build (0.8.2)
bab0019 fix: stop the Arrow release path from leaking a call stub per chunk (0.8.1)
30935b7 feat: read and write Parquet through the SQL layer (0.8.0)
595701d docs: record the 0.7.2 cleanup in the handoff checklist (0.7.2)
9e002d9 docs: add the 0.7.1 handoff notes (0.7.2)
f22811a docs: sync the README with 0.7.x and drop the dead shim stamps (0.7.2)
f66db4e Fix Linux install and harden the shim cache (0.7.1)
3be12d3 Render DECIMAL in C and decode ENUM by dictionary (0.7.0)
7c1bdd7 Add a DECIMAL/ENUM probe and record what it says
8c36bb5 Add the temporal benchmark and record the measured numbers
c3aff07 Decode DATE/TIME/TIMESTAMP from their integer payload (0.6.9)
f4b04e3 Record the two rejected candidates; replace the broken view probes
52fadb9 fix: drop the UTF-8 BOM from META6.json
b073e40 Arrow module in META6; exports reachable by full name (0.6.8)
41ae064 docs: bring README in line with 0.6.6/0.6.7
d16ab4f Parallel decode by column weight; double-buffer streaming prefetch