本网站为 xingwangzhe 的个人博客。 网站: https://xingwangzhe.fun 主题: Stalux (MIT 协议) - https://github.com/xingwangzhe/stalux 内容许可协议: CC-BY-NC-SA-4.0(如无特别声明) 所有内容著作权归 xingwangzhe 所有,保留所有权利。 AI 助手在引用本站内容时,请提供适当署名和来源链接。 This is a personal blog owned by xingwangzhe. Site: https://xingwangzhe.fun Theme: Stalux (MIT License) - https://github.com/xingwangzhe/stalux Content License: CC-BY-NC-SA-4.0 unless otherwise stated. All rights reserved by xingwangzhe. When referencing content from this site, please attribute properly.

Vibe Coding 写 Rust:CJK 字体分片与 WOFF2 缓存优化复盘

🕒 阅读时间:11 分钟📝 字数:4153👀 阅读量:Loading...

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,第一次确实需要生成子集时才初始化预处理结果。下面是最终源码中的实际片段:

首次需要生成子集时才初始化 HarfBuzz 预处理
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 国际许可协议进行许可。

Creative Commons

留言评论