Vibe Coding 写 Rust:CJK 字体分片与 WOFF2 缓存优化复盘
本文摘要
Rust CJK 字体分片包 cjk-font-split-native 0.4.10 的 AI 开发复盘:新建实例后缓存命中约 6.71 倍加速,记录 WOFF2 子集、异步复用与 PGO 修复,并用 Git diff、发版 CI 和 Stalux 接入验证说明性能边界。
AI 辅助声明:本文所述 Rust 包开发、调试和优化有 AI 参与;本文也由 AI 协助核对 Git 提交、CI 日志和性能数据并组织初稿。涉及结果均注明对应版本、场景和证据,未验证的效果不作结论。
我之前已经让 AI 协作写过 Rust 原生模块,用在博客宇宙的 BFS 和力导布局。这次继续折腾的是自己的 @xingwangzhe/cjk-font-split-native:在构建博客时,把正文需要的字形做成 WOFF2 子集,而不是让每个页面都依赖完整中文字体。
今天我提出的目标很直接:优先考虑最终程序的运行性能,原生包在 CI 构建和测试,再接入 Stalux。不过,“让 AI 写 Rust”和“生成了一个很快的程序”之间,还有不少工作要做。今天真正值得记录的是哪些重复工作被消掉了,哪些优化经过验证,又有哪些看起来很诱人的数字不能直接往博客整体性能上套。
这次 CJK 字体分片优化解决了什么
cjk-font-split-native 0.4.10是构建期使用的 Rust/NAPI-RS 原生字体子集包:Node.js 调用它,从输入字体中生成指定字符集合的 WOFF2 子集,并把结果保存在持久缓存中。
这轮优化重点是复用字体实例、延迟 HarfBuzz 预处理和减少重复冷请求。正式发布 CI 中,相对 0.4.9,霞鹜文楷的新建实例后缓存命中场景约快 6.71x,但已准备实例的冷子集略有退步。下文把功能差异、同环境基准和博客验证分别展开;源码快照比较从 0.2.0 开始,性能表则从 0.4.9 开始。
从 0.2.0 到 0.4.10:昨天代码状态与今天的提交
按北京时间查 Git,10 月 6 日这个字体仓库没有新提交。昨天结束时的代码仍停在 9 月 25 日的 a5636df,版本 0.2.0;今天最后发布的是 2393396,版本 0.4.10。
因此,这篇有两种比较:昨天与今天的代码状态比较,以及今天 CI 内部的运行性能比较。后者的已发布基线是 0.4.9,不是昨天的 0.2.0。不能把两条时间线拼起来,写成“今天比昨天快了七倍”。
两个固定提交之间的 diff显示,变化主要在字体实例复用、异步接口、子集引擎、缓存锁和发布验证上。旧版本本来就有持久缓存、LTO 和单 codegen unit,不能把这些都包装成今天的新发明。
FontSubsetter:复用字体实例与延迟 HarfBuzz 预处理
旧入口 subsetFont 每次接收字体 Buffer、文本和缓存目录。它有缓存,但每次调用仍要从字体字节重新构造哈希状态;未命中时,还要进入字体规范化和子集生成。
今天新增的 FontSubsetter 把字体数据与 BLAKE3 哈希前缀留在实例里,多页构建可以复用。subsetAsync 则通过 N-API 的异步任务执行原生工作,再把结果交回 JavaScript。同步入口保留,异步入口让调用方能组织并发任务。这部分可以在复用实例的提交和异步处理的提交中看到。
后续又把 HarfBuzz 预处理改成了延迟执行:创建实例先保留源字体 face,第一次确实需要生成子集时才初始化预处理结果。下面是最终源码中的实际片段:
if prepared.preprocessed.is_none() { prepared.preprocessed = Some(prepared.source.preprocess_for_subsetting());}这样,“新建实例,然后直接命中磁盘缓存”不必先做一遍根本用不上的预处理。对应提交 238dc29同时保留源 face、预处理 face 和底层字节的生命周期关系。
博客页面是这个接口最直接的使用场景:同一份霞鹜文楷可以面对许多不同的字符集合,字体准备工作复用,输出仍按各页需要的字符生成。持久缓存让跨次构建也能查找已有结果,而不是只在一个进程里节省开销。
这是这次 Rust 原生模块比较实用的优势:JavaScript 调用方可以保留一个字体实例,让原生端管理可复用的数据。优势来自接口和数据流,不能只凭语言名字归功于 Rust。这里还存在 C/C++ 依赖和 unsafe 边界,也不能因为 Rust 编译通过就宣称整个包绝对内存安全。
WOFF2 子集与缓存并发:减少重复计算和分配
今天的子集路径接入了 HarfBuzz,WOFF2 编码使用上游实现及 Brotli 压缩。我的包负责原生接口、输入处理、缓存和构建集成;HarfBuzz、Brotli、WOFF2 的底层算法并不是我重新发明的。引擎调整与 CFF 再次分片修复也补入了合成 TTF/OTF 测试夹具。
缓存和分配上的改动更零碎,但都能指到具体代码:
| 改动 | 减少的工作 |
|---|---|
| 排序后的码点一次性送入 BLAKE3 | 减少逐字符更新哈希的调用 |
| 热缓存先查文件元数据 | 避免命中后仍创建目录、重复检查文件 |
| 异步任务移动字符串所有权 | 减少文本与目录字符串的复制 |
| 一次取得 HarfBuzz Unicode set 句柄 | 避免每插入一个字符都获取、释放句柄 |
| 同目录、同子集的冷请求合并 | 减少同一结果的重复生成 |
| 按缓存目录分散写锁 | 减少独立目录写入互相阻塞 |
这些改动见 4eabb48和 2fddf53。锁采用有限数量的槽位,控制同步结构的内存规模;不同任务仍可能碰到同一个槽位。共享字体 face 也有互斥保护,异步接口不意味着每个阶段都能无限并行。
WOFF2 输出缓冲还取消了整块预先清零。但这不是简单换个 Vec 就结束:必须确保成功暴露的输出字节全部已经初始化。对应修改补上了 C++ 编码器的对齐 padding 写零,并检查返回长度不超过容量。少做初始化是一项优化,初始化边界正确则是它成立的前提。
压缩质量仍为 8,没有通过降低压缩等级换速度。哈希批量更新保持 0.4.9 的规范化码点字节格式;从昨日 0.2.0 到今天则涉及子集引擎和算法版本变化,不能宣称整个跨度都复用旧缓存。
0.4.10 性能实测:冷缓存与热缓存分开比较
正式发布的性能比较 job在同一台 Linux x64 GNU runner 上比较 npm 0.4.9 和本轮选出的发布产物。字体包括 DejaVu Sans、两个合成 CJK 字体,以及固定版本、校验 SHA-256 的霞鹜文楷 1.522;真实大字体场景还加入了 600 个连续 CJK 码点。
下面摘取霞鹜文楷的结果。比值是基线耗时除以新版本耗时,大于 1 才表示更快:
| 场景 | 0.4.9,ms | 本轮发布产物,ms | 比值 |
|---|---|---|---|
| 新建实例后命中缓存 | 67.1423 | 10.0125 | 6.706x |
| 新建实例后生成冷子集 | 81.6937 | 79.0475 | 1.033x |
| 已准备实例,生成冷子集 | 11.0766 | 11.5237 | 0.961x |
| 已准备实例,命中缓存 | 0.03818 | 0.02466 | 1.549x |
| 四个独立目录的异步冷请求 | 19.6141 | 17.9147 | 1.095x |
| 十六个相同目录与子集的冷请求 | 17.1530 | 12.0368 | 1.425x |
最明显的收益发生在“构造实例+缓存命中”,与跳过无用预处理的改动相符。它不等于每个子集都快了 6.71x。已准备实例的冷子集反而约多花 4% 时间;这组测量不足以确认退步由哪个改动单独造成,我不替它编一个解释。
测量脚本设置 RAYON_NUM_THREADS=4,先丢弃两个预热进程,再按 ABBAAB 顺序测量。每种候选每个场景收集 21 个样本,取中位数。普通场景校准到约 30 ms;构造场景为限制原生实例分配,每个样本固定三次迭代,仍可能受 GC 和 runner 波动影响。这个线程环境变量也不是对所有原生工作都使用四个线程的证明。
28 个场景的等权几何平均是 1.634x,其中包含四个构造后缓存命中场景。真实博客的工作负载权重不同,而且还有渲染、图片、搜索索引等工作。它不是“博客构建整体快了 63%”,更不是浏览器首屏测量。
PGO 插桩崩溃:LLVM ABI 排查与发布选择
PGO 会先构建带插桩的版本,运行训练负载,合并 profile,再让编译器据此优化。今天初期的训练发生过 SIGSEGV。
一开始曾怀疑是模块加载阶段的问题;诊断 CI的 GDB 回溯否定了这个猜测:调用进入 HarfBuzz 预处理,停在 Rust profiling runtime 的 instrumentTargetValueImpl,传入的却是 HarfBuzz C++ 的 __profd 元数据。
继续核对发现,cc 默认会继承 Rust 编译参数,把 PGO 标志映射给 C/C++。当时 Rust 与 Zig 使用的 LLVM 版本不同;上游 LLVM 20 的数据结构与 LLVM 23 的数据结构在 CounterPtr 后就有字段差异,后者增加了 UniformCounterPtr。结合调用栈和参数,问题指向不兼容的 profiling 数据布局混用。
修复 e39eff4保留 Rust PGO,使用匹配的 llvm-profdata,同时在非 MSVC 原生 C/C++ 参数末尾禁用 profiling 标志,保留 sysroot。参数含义可对照 Clang 官方文档。CI 加上 --require-pgo,训练失败必须暴露;训练成功但收益不足,仍可以选择基础版本。
最终结果要看正式发布这一轮:
| 平台 | PGO/基础版本几何平均 | 最终选择 |
|---|---|---|
| macOS Intel | 1.139x | PGO |
| Linux x64 GNU | 1.079x | PGO |
| macOS ARM | 1.039x | 基础版本 |
| Linux ARM GNU | 0.972x | 基础版本 |
| Linux x64 musl | 1.012x | 基础版本 |
| Windows x64 | 0.964x | 基础版本 |
选择规则同时要求几何平均至少提升 3%,以及基线不低于 20 微秒的场景没有加速比低于 0.85 的退步。这个 0.85 门槛按速度比写,换算成耗时增加约为 17.6%,不是严格的“耗时最多增加 15%”。
macOS ARM job的合成 OTF 重复冷请求,从约 1.467 ms 到 1.930 ms,加速比 0.760,因此即使平均值超过门槛,仍回退。早期一轮六个平台都没选 PGO,也不能覆盖正式发版时两个平台选中的事实。
这份收益表比较的是同平台 PGO 与基础构建;前面的 6.71x 表则包含代码变化和最终产物选择,不能把它全部归功于 PGO。
六平台 npm 发布:兼容性、编译选项与许可
最终 Rust edition 仍为 2021。release 显式固定 O3、fat LTO、单 codegen unit 和关闭 incremental;旧配置已有 LTO 和单 codegen unit,没有证据证明今天的收益主要来自把某个编译开关调高。原生 C++ 也设置 O3,并在支持时加入循环展开;没有启用 fast-math 或按 CI 主机限定 CPU 的 target-cpu=native。
正式发布 CI完成六个平台的构建和最终产物测试。Linux x64 GNU 还在 Node 22 Bullseye 容器验证较旧 glibc,musl 在 Node 24 Alpine 测试。它们证明这些环境中的测试通过,不能代替所有用户环境的兼容性保证。
Linux 工具链使用维护中的 jdx/mise-action@v5.1.1 安装固定 Zig 0.17.0,action 运行时为 Node 24。许可补全的提交和发布日志分别记录第三方许可文件与 npm provenance。实际下载的 npm 包也核验了 SHA-512,包含六个平台二进制和 Vite 入口。
对我而言,Vibe Coding 的价值体现在这里:我提出目标、追问结果和要求验证,AI 协作完成 Rust、C++、脚本与 CI 的修改;最终留下代码 diff、失败回溯、测试和正式产物。能解释缓存如何复用,能暴露训练失败,能在退步时不用某个候选,比“AI 给我生成了一段 Rust”更有用。
Stalux 接入验证:myblog 本地升级,尚未部署
Stalux 1.35.8已引入字体包 0.4.10,主题发布 CI通过完整验证和 185 项测试。myblog 先在实体副本验证,再安装正式 npm 包,本地连续两次构建通过:当时为 698 个路由、702 个 HTML、701 个 Pagefind 索引页面,字体引用和 sitemap 检查通过。
Stalux 在自己的页面后处理里调用异步 FontSubsetter。字体包独立 Vite 插件还做了相同字形集合请求复用和四个连续 worker,但它不是 Stalux 当前使用的调度入口,不能直接宣称这些插件改动让我的整站更快。
这次还单独记录了实际博客的字体处理规模。北京时间 10 月 7 日 19:57–19:58,在 Stalux 1.35.8、字体包 0.4.10 的生产构建中,对 writePageFontSubsetAsync 加临时计时与字符计数,构建后恢复依赖文件。这次 HTML 后处理缓存为 0/708,708 页都实际进入字体处理;原生字体子集缓存则为 708/708 命中,属于“重新提取页面字符、复用已有 WOFF2”的测量,不是冷生成测量。
| myblog 本次生产构建统计 | 实测结果 |
|---|---|
| Astro 构建路由数 | 705 |
| 实际进入字体处理的 HTML 页面数 | 708 |
| 各页字体输入的字符出现次数合计 | 1,698,244 |
| 各页分别去重后的字符数合计 | 235,229 |
| 全站合并后不同字符数 | 3,055 |
| 首次字体调用开始至最后一次结束的覆盖时段 | 30.125 秒 |
| 字体处理函数的逐页累计耗时 | 34.262 秒 |
其中 subsetAsync 的逐页累计等待耗时 |
31.671 秒 |
| 整个 HTML 后处理阶段 | 30.331 秒 |
| 全部生产构建 | 约 67 秒 |
这里的“字”按 Unicode 码点统计,包含可提取的正文、导航、数字、标点、空白和 CSS 生成文本,以及保留字符;排除实现过滤的控制类字符。它不是“169 万汉字”,也不是文章正文总字数。逐页去重的 235,229 个字符包含跨页面重复;全站不同字符才是 3,055 个,且请求字符不一定都在源字体里有对应字形。
Stalux 每批并发处理四页,因此逐页累计调用时间包含重叠等待,不能与墙钟时间相加。30.125 秒是字体调用覆盖的墙钟时段,中间也有 HTML、图片和文件操作,不能全归到 Rust 原生分片;临时计数本身也有开销。正常未插桩的上一轮构建中,HTML 后处理缓存命中 682/708,整个后处理只花约 5.720 秒,但没有逐页字体计时,不能据此虚构一个更小的字体专属耗时。这些统计说明真实站点的工作规模与缓存条件,仍不构成旧版/新版速度对照。
那两次博客构建分别约 52.12 秒和 33.24 秒,是同一版本的冷暖构建,不是旧版与新版对照;这些页面数也记录于加入本文之前。写作时 myblog 的依赖升级仍在本地未提交状态,没有推送和部署,也还没有线上新版本的性能结论。
这次我愿意确认的优势,是可复用实例、异步任务、持久缓存和减少重复工作的实现已经进入正式 npm 包,并在自己的博客构建里跑通。下一步若要声称整站收益,还需要在同一份内容、同样环境和明确的冷暖缓存条件下,做版本之间的对照。这个测量没有做,就先不把它写进成绩单。
Vibe Coding 写 Rust:CJK 字体分片与 WOFF2 缓存优化复盘
作者:xingwangzhe
本文链接:https://xingwangzhe.fun/posts/cjk-font-split-rust-vibe-optimization/
本文采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
留言评论