砍掉序列化,50毫秒重建的秘密
· 阅读约 3 分钟
先贴代码。
/* 常见的做法:手写序列化 */
serialize(zir_node, buffer);
write(fd, buffer, len);
/* Zig 的做法:直接写 */
writev(fd, zir_buffers, count);
Zig 搞增量编译,改一行代码重建只要 50 到 70 毫秒。我翻完 mlugg 那篇博文,满脑子留下的就上面这两行对比。50 毫秒,这玩意儿已经不是快了,是把编译器干成了热重载。
编译器记下每个分析单元依赖哪些源码字节,你改了哪块,就把相关的声明标成脏数据重跑,生成的新机器码直接打补丁塞回旧二进制文件里。不重新生成整个文件,只 patch 变化的字节。思路极简,没花里胡哨的魔法。
但我今天最想说的不是依赖图。让我拍大腿的是 ZIR 的缓存设计。
ZIR 是 Zig 解析完 AST 后生成的中间表示。按惯例,这种内部数据结构要存盘,你得手写一套序列化。写个把结构体转成字节的辅助函数,再配一套反序列化。很烦,而且容易错。
Zig 怎么干的?数据导向设计。ZIR 在内存里的布局天然就是连续字节,缓存写盘直接一次 writev 搞定。读盘也是一次 readv。没有序列化步骤。
一次系统调用搞定。没有分配,没有拷贝,没有把字段一个一个塞进 JSON 的废话。这要求你在设计数据结构时就想着它在磁盘上长什么样,而不是先随便长、再补一层序列化胶水。
说句题外话。我之前写那个小键值存储,RDB 持久化折腾了好几天。当时就是没想通这事——内存里的跳表节点用一堆零散指针连着,往磁盘上一写,指针全废了,读回来得重新拼。后来咬牙把热数据的内存布局改成物理连续的数组,写盘直接 memcpy。那一版砍掉了差不多三百行胶水代码,整个人都通畅了 😅。
数据结构对了,其他一切都顺。硬要在烂布局上糊一层抽象,那就是交抽象税。
Zig 的链接器也是单线程的。把 MIR 转成机器码,塞进输出文件。增量更新时把输出二进制内存映射,维护一棵节点树。哪个函数重编了,对应的节点空间不够就挪位置,打个 dirty 标记,统一修一下虚拟地址和重定位表。
单线程链接。听起来好像不够那个味儿。但你看那个 Tracy 火焰图,一次 37 毫秒的增量更新,链接只花 170 微秒。语义分析 1.2 毫秒,代码生成 240 微秒。剩下 31 毫秒全耗在遍历引用图上。瓶颈根本不在 CPU 算力,也不在并发,在图遍历。你给它开十六个线程也救不了。
我特别烦一种论调:编译器这种重型工具必须上多线程榨干多核。先把数据结构盘明白,把没用的间接层全砍掉,单线程跑到 50 毫秒不比你在那卡死锁强。
说句题外话,为什么我们这么怕让一个东西只做一件事。这功能现在只面向 x86_64-linux,没彻底稳定,偶尔会有错误编译。谁要是现在就拿它往生产环境上梭哈,翻车了别来找我。
用倒是能用,命令行加参数就行:
zig build --watch -fincremental
第一次跑会全量重建一次,因为增量编译跟旧缓存不兼容。这个全量重建的代价没法绕,得认。
评论
还没有评论,写下第一条讨论。