最近这段时间,在 Go Learning & Documentation v2 的开发里,我花了不少时间处理性能问题。
原因很现实。
随着工作流越来越严格,一条命令背后可能同时需要检查:
- 当前内容包;
- 历史证据;
- source identity;
- candidate identity;
- glossary;
- Snapshot;
- Independent QC;
- package closure;
- Integrated Surface;
- currentness;
- dependency;
- SHA-256;
- stale detection。
这些检查本身都有意义。
但如果同一份确定性数据在一次命令里被反复读取、解析和验证,执行时间就会越来越长。
有些低频命令最开始甚至需要几分钟。
于是自然会出现一个目标:
把它优化快一点。
问题是,性能优化做到后面,我逐渐发现另一个更难回答的问题:
到底应该优化到多快,才算够?
这次有一轮优化尤其典型。
同一条只读规划路径,基线实测大约是:
优化前耗时:164.03 秒经过一轮相对保守的修改以后,降到了:
优化后耗时:125.65 秒
耗时下降:约 23.4%这已经是一个不错的结果。
但后来继续推进更激进的方案时,我一度把同类路径压到了大约:
最快实测:35.93 秒从数字上看,这显然更漂亮。
问题是:
最后我还是把这部分优化撤销了。
因为性能数字虽然更好,但正确性证明没有一起成立。
这篇就整理为什么我最后选择:
保留没那么极致、但更简单和更容易证明正确的优化。
而不是把“35 秒”当成必须保住的成绩。
一开始,性能确实已经成为真实问题
这套项目早期很多命令的工作量还比较小。
例如只处理 Tour 时,正式内容范围比较稳定。
后来扩展到:
- Site shell;
- Learn;
- Docs;
- structured content;
- Integrated Surface;
- Preview;
- future content packages;
以后,一个看起来只是:
“重新计算当前范围”
的命令,背后已经可能需要穿过很多正式 authority。
如果每经过一层都重新:
- 读文件;
- 解 JSON;
- 构建 catalog;
- 计算 SHA;
- 验证 historical reference;
- 检查 current state;
耗时自然会不断叠加。
所以性能优化不是为了:
benchmark 好看。
而是因为维护工作已经开始受到实际影响。
一条低频但经常要人工等待几分钟的命令,会明显拖慢整个 workflow。
但性能优化不能先从“加缓存”开始
看到重复计算以后,最容易想到的办法通常是:
加 cache。
例如:
- memoization;
- LRU;
- persistent cache;
- global cache;
- decoded object cache;
- historical validation cache。
这些办法从纯性能角度看都很诱人。
因为如果:
第一次验证很贵那么最直接的想法就是:
第二次不要再验证
→ 直接复用第一次的结果问题在于,这套项目最核心的能力之一恰恰是:
发现“现在”和“刚才”已经不是同一个状态。
也就是:
- currentness;
- stale detection;
- dependency mutation detection;
- identity validation。
如果缓存设计不够严格,它最容易优化掉的,恰恰就是这些检查。
“上次 PASS”不能自动变成“这次仍然 PASS”
假设一个文件刚才验证通过:
10:00:文件 SHA 正确
10:00:validation PASS到了 10:03 再次执行命令。
如果缓存逻辑只是:
这个路径以前已经 PASS,直接复用。
那么中间只要文件发生了变化,就可能完全检测不到。
对于普通工具,这可能只是缓存不够新。
但对于当前工作流,这可能意味着:
stale 内容被当成 current。
或者:
已经变化的依赖继续复用旧授权。
所以正式 currentness 检查不能被简化成:
“我以前见过这个对象。”
真正需要证明的是:
我现在重新读取到的正式 authority,仍然和被证明过的 exact bytes 对应。
所以我后来把缓存分成了两类
第一类是相对安全的:
单次 invocation 内,对已经拿到的 exact bytes 做确定性转换复用。
例如同一次命令已经读取了一份完全相同的 bytes。
接下来需要:
- decode;
- parse;
- 构造一个纯确定性对象。
这种结果通常可以在当前 invocation 内复用。
因为它不会声称:
磁盘没有变化。
它只是说:
对同一份已经读取到的 exact bytes,不需要做两次相同的纯计算。
这是一个很重要的区别。
另一类缓存风险就高得多
另一种是:
缓存“磁盘现在是什么状态”。
例如缓存:
- 某路径已经 current;
- 某 closure 已经 PASS;
- 某 dependency 已经验证;
- 某 authority 上一次没有变化;
- 某 Snapshot 上一次仍然有效。
这种缓存一旦跨越 invocation,问题就复杂很多。
因为它必须回答:
缓存以后,哪些依赖可能发生变化?
变化以后,谁负责让缓存失效?
有没有间接依赖没有纳入 key?
文件内容没变,但 parser authority 变了怎么办?
source 没变,但 semantic inputs 变了怎么办?
前面 books.yaml 10→12 的案例其实已经证明:
依赖并不只是文件 bytes。
所以跨 invocation cache 的 invalidation,比最开始想象的难很多。
最先做的事情应该是 profile,而不是猜
这一轮性能优化里,我后来比较认可的一点是:
先测,再改。
不是看到某个函数名字很复杂,就猜它一定慢。
而是先获得:
- wall-clock;
- CPU profile;
- 实际 invocation 次数;
- 重复调用路径;
- real fixture。
然后再看:
时间到底花在哪里。
因为很多看起来“很重”的步骤,实际只执行一次。
反而一些看起来很小的 helper,可能在同一个 graph 里被重复调用几十次。
如果不 profile,很容易优化错方向。
第一个高价值目标:消除明显重复工作
真正值得优先处理的,通常不是“所有重复计算”。
而是:
明显、高频、占总时间比例很大的重复工作。
例如同一次 command invocation 里:
构建一次完整 validation graph
→ 后续多个阶段又重新构建相同 graph这种情况下,如果输入仍然是同一组已经验证的 current bytes,就很适合做:
invocation-local reuse。
因为收益大。
而 correctness contract 又比较容易说明。
这种优化的特点通常是:
实现简单,收益明显,失效边界清楚。
这是我后来最愿意保留的一类优化。
第二个高价值目标:不要重复解析确定不会变化的历史表示
工作流里保存了大量历史证据。
历史证据之所以存在,就是因为:
它们已经成为 immutable history。
但“历史不可改”不代表:
每经过一个 helper,都要重新用不同路径完整解析一次。
如果在同一次正式验证流程中:
- exact historical bytes 已经读取;
- SHA 已经核对;
- 引用 identity 已经确认;
那么后续纯转换就可以尽量复用已经得到的结果。
重点仍然是:
复用解析结果,而不是跳过 authority validation。
这个边界很关键。
第三个高价值目标:先做便宜的拒绝
还有一种很简单但很有效的优化:
调整 predicate 顺序。
假设一个对象最终需要检查:
先检查 cheap exact mismatch
→ 再检查昂贵历史图
→ 最后做完整重建如果第一步已经能够确定:
identity 不匹配。
后面的昂贵验证就没有必要继续做。
相比建立复杂 cache,这类调整通常有几个优势:
- 代码简单;
- correctness 容易证明;
- 不引入新的长期状态;
- 不增加 invalidation 问题。
所以我越来越倾向于:
先找这种简单收益。
为什么 35.93 秒最后还是被撤销
后来更激进的优化把时间压得非常低。
从:
164.03 秒到:
35.93 秒单看数字,已经接近:
快了将近一个数量级。
如果这是普通 benchmark,很容易觉得:
这肯定应该合并。
但正式 workflow 不能只看 benchmark。
还要回答:
输出是不是完全一样?
currentness 有没有削弱?
dependency mutation 是否仍然能发现?
stale input 是否仍然拒绝?
targeted negative tests 是否继续失败?
historical integrity 是否仍然完整?
只要其中任何一个关键问题回答不了:
速度再快也不能叫 PASS。
一次 targeted test FAILED,可能比快一分钟更重要
性能优化最危险的地方是:
happy path 往往最容易变快。
因为正常输入通常:
- current;
- 完整;
- 没有 mutation;
- 没有 stale;
- 没有并发变化。
缓存很容易在这种路径上表现得非常漂亮。
但真正重要的反而是 negative path。
例如:
dependency 已经变化。
source identity 不一致。
prior evidence stale。
historical bytes 被修改。
current file 在两次检查之间改变。
这些情况下,系统必须拒绝。
如果优化以后:
正常 fixture:PASS,而且特别快
异常 fixture:本来应该 FAIL,现在却 PASS那这不是:
一个带小 bug 的高性能实现。
而是:
正确性 contract 已经被破坏。
所以这一类优化必须撤销。
更快,但证明成本也可能高很多
还有一种情况比较微妙。
优化本身不一定已经证明错误。
但它会引入非常复杂的证明负担。
比如一个多层 cache 可能需要维护:
cache key
→ source identity
→ parser identity
→ glossary identity
→ authorization identity
→ dependency graph identity
→ historical reference identity
→ mutation-sensitive invalidation这时候真正需要评估的就不是:
它还能省多少秒?
而是:
为了证明这几十秒优化永远不会返回 stale state,需要增加多少代码和多少测试?
如果答案是:
要引入一套新的 cache architecture。
那对于一个低频 deterministic workflow 来说,很可能已经不划算。
性能收益也有边际递减
假设一个命令原来需要:
大约 10 分钟优化到:
大约 2 分钟这通常非常值得。
维护者每次执行都能明显感受到差别。
但如果再从:
2 分钟继续优化到:
1 分 30 秒价值就要重新评估。
如果这个 30 秒需要:
- 大量缓存代码;
- 复杂失效协议;
- 新的 concurrency 考虑;
- 更多测试;
- 更高维护成本;
那么:
不优化反而可能是更好的工程决定。
“还能优化”不等于“值得优化”
这是我后来给项目补的一条很重要的原则。
性能任务不应该以:
REMAINING_DUPLICATE_WORK=none作为结束条件。
因为现实里几乎永远都还能找到重复工作。
真正更合理的结束条件是:
REMAINING_HIGH_VALUE_OPTIMIZATION=none也就是说:
不是“已经没有任何重复计算”。
而是:
已经没有值得继续花工程成本处理的高价值瓶颈。
这两个标准完全不同。
有些低收益重复计算应该允许存在
这一点开始会有点反直觉。
既然已经知道:
某一步会重复执行。
为什么不把它消掉?
因为消除重复计算本身也是有成本的。
假设一个重复检查每次只需要:
100 毫秒但为了避免它,需要:
- 新增 cache;
- 管理生命周期;
- 增加 invalidation;
- 添加更多 identity;
- 写十几个测试。
那么保留这 100 毫秒,可能反而是最便宜、最安全的方案。
所以:
重复计算不是天然的 bug。
只有当它造成实际高成本,而且能够用简单方式安全消除时,才值得优化。
复杂 cache 还会增加未来修改成本
性能优化还有一个容易被低估的问题:
未来维护。
今天加一个 LRU,看起来只是几十行代码。
但以后只要新增一个正式 dependency,就必须继续回答:
它是否进入 cache key?
旧 cache 是否自动失效?
historical reader 是否受影响?
current checker 是否还会重新读 disk?
如果漏掉一个 dependency,系统可能不是立即报错。
而是:
静默返回旧结果。
这类错误往往比“执行慢一点”更难发现。
所以我后来越来越倾向于:
宁可多算一次,也不要多维护一套难以证明完整的长期状态。
invocation-local reuse 为什么更适合这类项目
相比 persistent cache,我更喜欢单次 invocation 范围内的 reuse。
原因很简单。
它的生命周期非常清楚:
命令启动
→ 读取当前 exact bytes
→ 完成正式 currentness 检查
→ 在这一次 invocation 内复用确定性结果
→ 命令结束
→ reuse state 消失下一次命令:
重新从 disk authority 开始。
这样不会出现:
昨天建立的缓存今天还认为自己是 current。
这和正式 workflow 的语义非常契合。
export 和 current-check 也不能为了快而变成同一次证明
还有一个非常重要的例子。
Generation Bundle 的:
export
和:
current-check
虽然都可能执行很多相同计算,但它们是两个独立 invocation。
current-check 的意义之一就是:
重新执行 canonical exporter,确认从 export 到现在输入没有变化。
如果为了性能把它改成:
export 时已经算过了,所以 check 直接复用。
那其实就把:
freshness proof
一起优化掉了。
所以正式 contract 明确要求:
两个 invocation 的 memo scope 必须分开。
性能测试也不能偷偷共享同一个 memo scope,然后得出:
current-check 很快。
因为这种 benchmark 并不对应真实正式流程。
benchmark 本身也可能“作弊”
性能测试还有一个很现实的问题。
如果 benchmark 使用:
- warm cache;
- 旧解析对象;
- 上一次 invocation 的 current result;
- 已经构建好的 graph;
那么数字可能非常漂亮。
但正式执行却不具备这些前提。
所以性能测试必须尽量回答:
这个测量真的代表用户会执行的那条命令吗?
而不是:
某个内部函数在理想缓存状态下有多快?
真实 fixture 很重要。
固定 targeted tests 也很重要。
为什么我没有继续追求 35 秒
当一条原本非常慢的 workflow 已经降到:
一两分钟左右的可接受范围。
继续往下优化时,收益性质已经变化了。
前面的收益是:
消除明显等待。
后面的收益越来越像:
为了漂亮数字继续压 benchmark。
如果这时候需要:
- typed memo;
- LRU;
- 多级 cache;
- persistent state;
- architecture refactor;
那么它已经开始触碰另一条边界:
性能复杂度可能超过性能收益。
这时候停止并不是:
优化失败。
反而可能说明:
工程目标已经完成。
Codex 配额本身也是性能成本的一部分
还有一个以前不太会考虑的问题:
实现优化也消耗资源。
特别是在大量使用 Codex 做 repository engineering 时,一个复杂 cache 方案可能需要:
- 设计;
- 修改;
- targeted tests;
- failure diagnosis;
- rollback;
- 再实现;
- 再验证。
这些都会消耗 5h quota。
如果剩余项目里还有:
- correctness blocker;
- Activation;
- Preview;
- Production;
那么继续花大量额度只为了:
再省几十秒。
机会成本就很高。
所以性能优化的性价比不能只看:
runtime saved还应该看:
runtime saved
对比
implementation cost
对比
verification cost
对比
regression risk
对比
Codex quota我后来给性能优化定了一个更明确的停止条件
现在我的思路更接近:
先问:
这是高频还是低频命令?
再问:
当前耗时是否已经影响实际工作?
再问:
profile 是否仍然存在明显大头?
再问:
下一项优化是不是简单、局部、容易证明?
如果答案逐渐变成:
低频;
已经可接受;
剩余瓶颈很分散;
需要复杂 cache 才能继续明显下降;
那就应该停。
也就是:
性能任务不需要把时间压到理论最低值。
撤销优化并不等于白做
这一轮让我感受很明显的一点是:
一项优化最后被 revert,不代表它完全没有价值。
因为它至少回答了:
这个方向能不能继续安全推进?
比如 35.93 秒这个结果证明:
性能上确实还有很大的理论空间。
但对应测试和证明又告诉我:
当前这个实现方式不值得保留。
这其实已经是一条很有价值的信息。
比起把有风险的优化硬留在仓库里,撤销以后还能保留:
- profile;
- benchmark;
- failed test evidence;
- 对 cache 边界的认识;
- 更明确的性能政策。
这比一个“很快但说不清是否安全”的实现更有价值。
每项优化都应该能够单独撤回
后来我也越来越喜欢事务式推进。
不要一次同时做:
优化 A
→ 优化 B
→ 优化 C
→ 最后统一测试因为如果最终失败,很难知道:
到底是哪一个改变破坏了 contract?
更好的方式是:
完成优化 A
→ 运行 targeted tests
→ PASS 后保留
完成优化 B
→ 运行 targeted tests
→ FAILED
→ 精确撤销 B 自己的改动这样不会因为追求速度,让工作树里堆积一串未经证明的优化。
“优化后输出一样”仍然不一定足够
性能优化里还有一个容易忽略的问题。
即使 happy-path 输出 JSON:
before == after也不一定足够。
因为还必须测试:
如果依赖在中间发生变化,新实现是否仍然会检测出来?
也就是说,既要证明:
正常输入结果没有变化。
还要证明:
应该失败的输入仍然失败。
这也是为什么 negative fixture 对性能优化特别重要。
correctness baseline 必须比 benchmark 更早建立
理想的优化顺序应该是:
先固定 correctness baseline
→ 再记录 before timing
→ profile 找瓶颈
→ 做一项修改
→ 跑 fixed targeted tests
→ PASS 后重新测 timing而不是:
先改很多代码
→ 看到速度很快
→ 再想办法证明没问题后者很容易产生心理偏差。
因为看到:
35 秒
以后,人会很自然地希望:
能不能调整一下测试把它保住?
这恰恰是最危险的方向。
正确顺序应该反过来:
测试 contract 不为优化让路。
更快,但测试 FAILED,就不能叫性能 PASS
所以我现在给性能任务的判断其实很简单。
例如:
执行时间:显著下降
targeted correctness tests:FAILED最终结论仍然只能是:
RESULT=FAILED不能写成:
性能优化成功,但还有少量测试需要处理。
因为如果测试覆盖的是正式 correctness invariant,那么这项优化就还没有成立。
这和整个翻译工作流其实是同一种思路
后来我发现,性能优化的原则和翻译 lifecycle 很像。
翻译里不能因为:
译文看起来不错。
就跳过 machine validation 和 Independent QC。
性能里也不能因为:
benchmark 看起来特别漂亮。
就跳过 correctness proof。
两者的共同原则其实都是:
不能拿结果表象替代正式证明。
从“最快”转向“高价值”
如果只追求最快,目标可能是:
找到所有 duplicate work
→ 全部消除
→ benchmark 尽量接近理论最小值但我现在更愿意把目标写成:
找出高成本瓶颈
→ 使用简单方案减少明显重复
→ 保持 correctness 与 freshness
→ 达到实际可接受耗时
→ 停止最终判断不是:
还能不能快。
而是:
下一秒性能收益是否仍然值得对应的工程成本和风险。
我现在更认可的几条原则
经过这几轮优化,我觉得可以把性能策略浓缩成几条规则。
第一:
先 profile,再优化;不要凭感觉猜瓶颈。
第二:
优先处理明显、高收益的重复计算,不追求清除所有 duplicate work。
第三:
优先使用 invocation-local reuse,谨慎引入 persistent cache、LRU 和复杂 memoization。
第四:
任何优化都不能降低 correctness、freshness、stale detection、identity、hash validation、dependency mutation detection 和 fail-closed。
第五:
export 和 current-check 之类本来就用于重新证明 freshness 的独立 invocation,不能因为性能优化而共享旧 PASS。
第六:
性能测试不仅要测 happy path,还必须保留 stale、mutation 和 invalid identity 等 negative fixtures。
第七:
一项优化一次提交思想上独立推进,targeted tests FAILED 就精确撤销这一项。
第八:
低频工作流已经从几分钟降低到一两分钟以后,继续节省几十秒通常不值得引入复杂缓存架构。
第九:
Codex 配额、实现时间、验证成本和未来维护负担,也属于性能优化成本。
第十:
性能任务真正的完成标准不是没有任何重复计算,而是没有剩余的高价值优化。
也就是:
REMAINING_HIGH_VALUE_OPTIMIZATION=none最终我现在最愿意保留的一句话是:
最快的实现,不一定是最便宜的实现;而最便宜的实现,也不只是看运行时间。
对于这类强调 currentness、历史证据和可恢复性的工作流来说:
能稳定地从 10 分钟降到 2 分钟,通常比冒着失效检测风险追到 30 秒更有价值。
Go 学习与文档多语言本地化项目
本系列完整记录 Go 学习与文档多语言本地化项目从 A Tour of Go 起步,逐步扩展到站点首页、项目说明页、学习内容、文档以及未来内容包的实际开发过程,包括架构设计、统一术语治理、整页翻译、结构保护、自动校验、独立质量审核、生产发布与长期维护。
项目源码:
✅ GitHub:shuijingwan/go-tour-i18n
语言版本:
当前跨项目目标语言池共包含 66 个语言版本。Go 项目本身目前提供 65 个社区 Production 语言版本,下面每一门 Go 语言版本都直接链接到其当前公开站点。语言列表沿用项目首页现有的展示顺序。
英语(美国)仅作为跨项目目标语言纳入列表,并不是单独的 Go Production 语言版本。当前 Go 的英文源内容仍为官方 generic English,因此英语(美国)链接到 Go 官方站点,而不是不存在的本地社区部署。
- 阿姆哈拉语
- 阿拉伯语
- 孟加拉语
- 葡萄牙语(巴西)
- 保加利亚语
- 加泰罗尼亚语
- 克罗地亚语
- 捷克语
- 丹麦语
- 荷兰语
- 英语(美国)(Go 官方英文站点)
- 英语(澳大利亚)
- 英语(加拿大)
- 英语(印度)
- 英语(新加坡)
- 英语(南非)
- 英语(英国)
- 爱沙尼亚语
- 菲律宾语
- 芬兰语
- 法语(法国)
- 法语(加拿大)
- 德语(德国)
- 德语(奥地利)
- 德语(瑞士)
- 希腊语
- 古吉拉特语
- 希伯来语
- 印地语
- 匈牙利语
- 印度尼西亚语
- 意大利语
- 日语
- 卡纳达语
- 哈萨克语
- 韩语
- 西班牙语(拉丁美洲)
- 拉脱维亚语
- 立陶宛语
- 马来语
- 马拉雅拉姆语
- 马拉地语
- 书面挪威语(Bokmål)
- 波斯语
- 波兰语
- 葡萄牙语(葡萄牙)
- 旁遮普语(果鲁穆奇文,印度)
- 罗马尼亚语
- 俄语
- 塞尔维亚语(西里尔文)
- 简体中文(中国大陆)
- 简体中文(新加坡)
- 斯洛伐克语
- 斯洛文尼亚语
- 西班牙语(西班牙)
- 斯瓦希里语(坦桑尼亚)
- 瑞典语
- 泰米尔语
- 泰卢固语
- 泰语
- 繁体中文(台湾)
- 繁体中文(香港)
- 土耳其语
- 乌克兰语
- 乌尔都语
- 越南语
项目正在持续扩展新的 Go 学习与文档内容,并长期维护各语言版本的术语一致性、翻译质量、独立审核、生产发布与后续更新。
本项目属于非官方社区多语言本地化项目,与 Go 官方无隶属、授权或背书关系。
