---
title: Vibe Coding 写 Rust：CJK 字体分片与 WOFF2 缓存优化复盘
tags:
    - Rust
    - AI
    - Vibe Coding
    - CJK 字体分片
    - WOFF2
    - 性能优化
    - NAPI-RS
    - PGO
categories:
    - 技术
date: "2026-10-07 19:47:01"
desc: Rust CJK 字体分片包 cjk-font-split-native 0.4.10 的 AI 开发复盘：新建实例后缓存命中约 6.71 倍加速，记录 WOFF2 子集、异步复用与 PGO 修复，并用 Git diff、发版 CI 和 Stalux 接入验证说明性能边界。
abbrlink: cjk-font-split-rust-vibe-optimization
---
> **AI 辅助声明**：本文所述 Rust 包开发、调试和优化有 AI 参与；本文也由 AI 协助核对 Git 提交、CI 日志和性能数据并组织初稿。涉及结果均注明对应版本、场景和证据，未验证的效果不作结论。

我之前已经让 AI 协作写过 Rust 原生模块，用在[博客宇宙的 BFS 和力导布局](https://xingwangzhe.fun/posts/ai-rust-bfs-force-two-wheels/)。这次继续折腾的是自己的 `@xingwangzhe/cjk-font-split-native`：在构建博客时，把正文需要的字形做成 WOFF2 子集，而不是让每个页面都依赖完整中文字体。

今天我提出的目标很直接：优先考虑最终程序的运行性能，原生包在 CI 构建和测试，再接入 Stalux。不过，“让 AI 写 Rust”和“生成了一个很快的程序”之间，还有不少工作要做。今天真正值得记录的是哪些重复工作被消掉了，哪些优化经过验证，又有哪些看起来很诱人的数字不能直接往博客整体性能上套。

<!--more-->

## 这次 CJK 字体分片优化解决了什么

[`cjk-font-split-native 0.4.10`](https://www.npmjs.com/package/@xingwangzhe/cjk-font-split-native/v/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](https://github.com/xingwangzhe/cjk-font-split-native/commit/a5636dfd94a1ac9ec93b8ad65b17858dc6e2ad5e)；今天最后发布的是 [`2393396`，版本 0.4.10](https://github.com/xingwangzhe/cjk-font-split-native/commit/23933967626aeb3dfc5cfe5f363e9de1b8d96a87)。

因此，这篇有两种比较：昨天与今天的**代码状态比较**，以及今天 CI 内部的**运行性能比较**。后者的已发布基线是 `0.4.9`，不是昨天的 `0.2.0`。不能把两条时间线拼起来，写成“今天比昨天快了七倍”。

[两个固定提交之间的 diff](https://github.com/xingwangzhe/cjk-font-split-native/compare/a5636dfd94a1ac9ec93b8ad65b17858dc6e2ad5e...23933967626aeb3dfc5cfe5f363e9de1b8d96a87)显示，变化主要在字体实例复用、异步接口、子集引擎、缓存锁和发布验证上。旧版本本来就有持久缓存、LTO 和单 codegen unit，不能把这些都包装成今天的新发明。

## FontSubsetter：复用字体实例与延迟 HarfBuzz 预处理

旧入口 `subsetFont` 每次接收字体 Buffer、文本和缓存目录。它有缓存，但每次调用仍要从字体字节重新构造哈希状态；未命中时，还要进入字体规范化和子集生成。

今天新增的 `FontSubsetter` 把字体数据与 BLAKE3 哈希前缀留在实例里，多页构建可以复用。`subsetAsync` 则通过 N-API 的异步任务执行原生工作，再把结果交回 JavaScript。同步入口保留，异步入口让调用方能组织并发任务。这部分可以在[复用实例的提交](https://github.com/xingwangzhe/cjk-font-split-native/commit/2c345d5)和[异步处理的提交](https://github.com/xingwangzhe/cjk-font-split-native/commit/163b864)中看到。

后续又把 HarfBuzz 预处理改成了延迟执行：创建实例先保留源字体 face，第一次确实需要生成子集时才初始化预处理结果。下面是最终源码中的实际片段：

```rust title="首次需要生成子集时才初始化 HarfBuzz 预处理"
if prepared.preprocessed.is_none() {
  prepared.preprocessed = Some(prepared.source.preprocess_for_subsetting());
}
```

这样，“新建实例，然后直接命中磁盘缓存”不必先做一遍根本用不上的预处理。[对应提交 `238dc29`](https://github.com/xingwangzhe/cjk-font-split-native/commit/238dc29)同时保留源 face、预处理 face 和底层字节的生命周期关系。

博客页面是这个接口最直接的使用场景：同一份霞鹜文楷可以面对许多不同的字符集合，字体准备工作复用，输出仍按各页需要的字符生成。持久缓存让跨次构建也能查找已有结果，而不是只在一个进程里节省开销。

这是这次 Rust 原生模块比较实用的优势：JavaScript 调用方可以保留一个字体实例，让原生端管理可复用的数据。优势来自接口和数据流，不能只凭语言名字归功于 Rust。这里还存在 C/C++ 依赖和 `unsafe` 边界，也不能因为 Rust 编译通过就宣称整个包绝对内存安全。

## WOFF2 子集与缓存并发：减少重复计算和分配

今天的子集路径接入了 HarfBuzz，WOFF2 编码使用上游实现及 Brotli 压缩。我的包负责原生接口、输入处理、缓存和构建集成；HarfBuzz、Brotli、WOFF2 的底层算法并不是我重新发明的。[引擎调整与 CFF 再次分片修复](https://github.com/xingwangzhe/cjk-font-split-native/commit/8ad202ccb0e8ab2d770c4bdeec6530c61d56e911)也补入了合成 TTF／OTF 测试夹具。

缓存和分配上的改动更零碎，但都能指到具体代码：

| 改动 | 减少的工作 |
| --- | --- |
| 排序后的码点一次性送入 BLAKE3 | 减少逐字符更新哈希的调用 |
| 热缓存先查文件元数据 | 避免命中后仍创建目录、重复检查文件 |
| 异步任务移动字符串所有权 | 减少文本与目录字符串的复制 |
| 一次取得 HarfBuzz Unicode set 句柄 | 避免每插入一个字符都获取、释放句柄 |
| 同目录、同子集的冷请求合并 | 减少同一结果的重复生成 |
| 按缓存目录分散写锁 | 减少独立目录写入互相阻塞 |

这些改动见 [`4eabb48`](https://github.com/xingwangzhe/cjk-font-split-native/commit/4eabb48)和 [`2fddf53`](https://github.com/xingwangzhe/cjk-font-split-native/commit/2fddf53)。锁采用有限数量的槽位，控制同步结构的内存规模；不同任务仍可能碰到同一个槽位。共享字体 face 也有互斥保护，异步接口不意味着每个阶段都能无限并行。

WOFF2 输出缓冲还取消了整块预先清零。但这不是简单换个 `Vec` 就结束：必须确保成功暴露的输出字节全部已经初始化。对应修改补上了 C++ 编码器的对齐 padding 写零，并检查返回长度不超过容量。少做初始化是一项优化，初始化边界正确则是它成立的前提。

压缩质量仍为 8，没有通过降低压缩等级换速度。哈希批量更新保持 `0.4.9` 的规范化码点字节格式；从昨日 `0.2.0` 到今天则涉及子集引擎和算法版本变化，不能宣称整个跨度都复用旧缓存。

## 0.4.10 性能实测：冷缓存与热缓存分开比较

正式发布的[性能比较 job](https://github.com/xingwangzhe/cjk-font-split-native/actions/runs/37597819146/job/112718056813)在同一台 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% 时间；这组测量不足以确认退步由哪个改动单独造成，我不替它编一个解释。

[测量脚本](https://github.com/xingwangzhe/cjk-font-split-native/blob/23933967626aeb3dfc5cfe5f363e9de1b8d96a87/scripts/compare-pgo.mjs)设置 `RAYON_NUM_THREADS=4`，先丢弃两个预热进程，再按 ABBAAB 顺序测量。每种候选每个场景收集 21 个样本，取中位数。普通场景校准到约 30 ms；构造场景为限制原生实例分配，每个样本固定三次迭代，仍可能受 GC 和 runner 波动影响。这个线程环境变量也不是对所有原生工作都使用四个线程的证明。

28 个场景的等权几何平均是 `1.634x`，其中包含四个构造后缓存命中场景。真实博客的工作负载权重不同，而且还有渲染、图片、搜索索引等工作。它不是“博客构建整体快了 63%”，更不是浏览器首屏测量。

## PGO 插桩崩溃：LLVM ABI 排查与发布选择

PGO 会先构建带插桩的版本，运行训练负载，合并 profile，再让编译器据此优化。今天初期的训练发生过 SIGSEGV。

一开始曾怀疑是模块加载阶段的问题；[诊断 CI](https://github.com/xingwangzhe/cjk-font-split-native/actions/runs/37590938237)的 GDB 回溯否定了这个猜测：调用进入 HarfBuzz 预处理，停在 Rust profiling runtime 的 `instrumentTargetValueImpl`，传入的却是 HarfBuzz C++ 的 `__profd` 元数据。

继续核对发现，`cc` 默认会继承 Rust 编译参数，把 PGO 标志映射给 C/C++。当时 Rust 与 Zig 使用的 LLVM 版本不同；上游 [LLVM 20 的数据结构](https://github.com/llvm/llvm-project/blob/llvmorg-20.1.0/llvm/include/llvm/ProfileData/InstrProfData.inc)与 [LLVM 23 的数据结构](https://github.com/llvm/llvm-project/blob/llvmorg-23.1.1/llvm/include/llvm/ProfileData/InstrProfData.inc)在 `CounterPtr` 后就有字段差异，后者增加了 `UniformCounterPtr`。结合调用栈和参数，问题指向不兼容的 profiling 数据布局混用。

[修复 `e39eff4`](https://github.com/xingwangzhe/cjk-font-split-native/commit/e39eff4)保留 Rust PGO，使用匹配的 `llvm-profdata`，同时在非 MSVC 原生 C/C++ 参数末尾禁用 profiling 标志，保留 sysroot。参数含义可对照 [Clang 官方文档](https://clang.llvm.org/docs/UsersManual.html#disabling-instrumentation)。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 | 基础版本 |

[选择规则](https://github.com/xingwangzhe/cjk-font-split-native/blob/23933967626aeb3dfc5cfe5f363e9de1b8d96a87/scripts/build-pgo.mjs#L222)同时要求几何平均至少提升 3%，以及基线不低于 20 微秒的场景没有加速比低于 `0.85` 的退步。这个 `0.85` 门槛按速度比写，换算成耗时增加约为 17.6%，不是严格的“耗时最多增加 15%”。

[macOS ARM job](https://github.com/xingwangzhe/cjk-font-split-native/actions/runs/37597819146/job/112714623973)的合成 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](https://github.com/xingwangzhe/cjk-font-split-native/actions/runs/37597819146)完成六个平台的构建和最终产物测试。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。[许可补全的提交](https://github.com/xingwangzhe/cjk-font-split-native/commit/407522a)和[发布日志](https://github.com/xingwangzhe/cjk-font-split-native/actions/runs/37597819146/job/112719092635)分别记录第三方许可文件与 npm provenance。实际下载的 npm 包也核验了 SHA-512，包含六个平台二进制和 Vite 入口。

对我而言，Vibe Coding 的价值体现在这里：我提出目标、追问结果和要求验证，AI 协作完成 Rust、C++、脚本与 CI 的修改；最终留下代码 diff、失败回溯、测试和正式产物。能解释缓存如何复用，能暴露训练失败，能在退步时不用某个候选，比“AI 给我生成了一段 Rust”更有用。

## Stalux 接入验证：myblog 本地升级，尚未部署

[Stalux 1.35.8](https://github.com/xingwangzhe/stalux/commit/02eb1772)已引入字体包 `0.4.10`，[主题发布 CI](https://github.com/xingwangzhe/stalux/actions/runs/37614457921)通过完整验证和 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 包，并在自己的博客构建里跑通。下一步若要声称整站收益，还需要在同一份内容、同样环境和明确的冷暖缓存条件下，做版本之间的对照。这个测量没有做，就先不把它写进成绩单。


---

**作者：**xingwangzhe

**本文链接：**[https://xingwangzhe.fun/posts/cjk-font-split-rust-vibe-optimization/](https://xingwangzhe.fun/posts/cjk-font-split-rust-vibe-optimization/)

本文采用[知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议](https://creativecommons.org/licenses/by-nc-sa/4.0/)进行许可。