优化一切:一周、68 个版本,以及 547 倍的 JSON 加速
This dated article records the Perry version and measurements available when it was published. Use the current limitations, platform guide, and benchmark artifact for decisions today.
上一篇博客发布时,Perry 版本是 v0.5.12。今天我们已经到了 v0.5.80。也就是说七天内发布了 68 个补丁版本,几乎全部聚焦于一件事:把剩下的每一条慢路径都变成快路径。
v0.5.0 切换到 LLVM 后,到 v0.5.12 已经追平了 Cranelift。那是一个故事的结束,也是另一个故事的开始。LLVM 现在能看到一切。问题不再是“这为什么慢?”,而变成了“这为什么还没变快?”——后者是一个好处理得多的问题。
这篇文章带你回顾这一周。JSON 获得了 547 倍的加速。mimalloc 成为全局分配器。属性访问加上了单态内联缓存。Buffer 拥有了带 noalias 元数据的类型化指针槽。Fastify 和 WebSocket 服务器不再一分钟后就崩溃。基准数据又一次前进了。
1. JSON:弥合 547 倍的差距
在 v0.5.29 时,Perry 对 20 条记录数组的 JSON.parse 比 Node 慢 547 倍。到 v0.5.46 时只剩 1.3 倍。这是本周最大的单项差距变化,值得好好讲讲,因为这篇文章里其他每个优化都是同一主题的变奏:不要做不必要的工作。
原来的解析器为每个属性分配一个 Vec、为每个对象分配一个 key 的 Vec,还有一个 RefCell 保护的 thread-local 作为 key 缓存。它会复制每一个字符串。它会为每个字段名重新计算哈希。它会为每条记录都构建一个全新的对象 shape,哪怕这 20 条记录拥有完全相同的字段和完全相同的顺序。Node 的解析器能识别出这种模式,在所有记录之间共享一个 shape。Perry 的则不能。
修复分四步完成:
- 通过 thread-local 的
PARSE_KEY_CACHE实现 key 驻留(v0.5.45)。第一条记录分配 N 个 key 字符串;第 2 到第 20 条记录分配数为零。重复的 key 解析到同一个指针,这样它们就可以作为 shape 缓存的查找键使用,而无需 strcmp。 - 通过转移缓存实现 shape 共享(v0.5.45)。由
js_object_set_field_by_name构建的对象会走同一张转移图。当 schema 重复时,keys_array指针被共享,而这正是多态内联缓存命中所需要的。 - 零拷贝字符串解析 + 增量对象构建(v0.5.46)。
parse_string_bytes现在在没有反斜杠转义时返回ParsedStr::Borrowed(&[u8])——对每个 key 和大多数 value 来说这都是常见情况。parse_object直接写入字段,而不是先收集到 Vec 中。 - 解析期间抑制 GC(v0.5.60,关闭 #59)。解析大数组会在紧凑循环里分配数千个小对象。每一个都会触发 GC 阈值检查。设置一个“正在解析”的标志,把回收推迟到解析返回后——实际堆大小相同,但记账分支大幅减少。
然后是 stringify。对于同质数组——形状相同、百万次重复——JSON.stringify 会对每个对象做完整的属性迭代,而这对 shape 稳定的数组来说完全是浪费。五步修复也弥合了大部分差距:
- v0.5.62:数字的 itoa / ryu 快速路径,用基于深度的循环引用检查替代 HashSet。
- v0.5.63:
toJSON守卫 + 持久化 key 缓存 + 内联分派(这三项每次调用的开销累加起来不小)。 - v0.5.65:同质 shape 的 stringify 模板 + ASCII 转义快速路径。当每个元素拥有相同 shape 时,key/冒号/逗号这些脚手架被预计算一次。
- v0.5.70、v0.5.72、v0.5.75:每次调用的 shape 模板缓存,弥合 parse 遗留的 GC 缝隙,消除剩余的固定每次调用开销。
- v0.5.79:小值路径。数字、布尔值和短字符串走一条不设置任何对象机制的直接路径。
累积结果:本周开始时比 Node 慢 547 倍的 JSON 流水线,现在在现实工作负载上 parse 大约比 Node 慢 1.3 倍,stringify 基本持平。
2. 分配器的故事
Perry 会分配很多内存。每个对象字面量、每个数组字面量、每次字符串拼接、每个闭包。分配器是热点,而在 v0.5 的大部分时间里,它都是 Rust 默认的系统分配器加上用于短命值的 thread-local arena。
v0.5.67 把全局分配器换成了 mimalloc。这是 Cargo.toml 里的一行改动,对任何做大量小分配的工作负载都立竿见影——也就是每一个 TypeScript 程序。v0.5.66 为此做了铺垫:把所有 gc_malloc 的 thread-local 状态合并为每次调用单次 TLS 访问,让进入 mimalloc 的路径尽可能廉价。
v0.5.68 在此基础上更进一步,引入了 arena 分配的字符串。短命字符串(中间拼接结果、split() 的片段、解析器暂存)完全绕过全局分配器,落在一个会在自然边界重置的每线程 bump arena 中。仅此一项,在 JSON 解析上就带来了两位数百分比的收益。
还有两个根本不做分配的优化:
- 非逃逸对象的标量替换(v0.5.17,对象字面量在 v0.5.76)。如果一个对象从不离开其封闭函数,它就根本不必存在。它的字段变成普通的局部变量。一旦你不再把对象藏在不透明的分配器调用后面,LLVM 开箱即用地处理这个。
- 非逃逸数组的标量替换(v0.5.73)。同样的思路——如果数组不逃逸,它的元素就变成 SSA 值,整个分配消失。
具体到数组字面量路径,v0.5.69 添加了一条尺寸确定的快速路径(编译期已知大小时跳过容量增长机制),v0.5.74 为小数组字面量内联了 bump 分配器 IR,这样 LLVM 就能看到分配、折叠它、提升它或消除它。大量使用数组的基准测试又前进了一步。
收尾上,v0.5.25 修复了一个更安静的 bug:gc_malloc 在自己的路径上并没有触发回收,所以大量使用 malloc 的工作负载可能在任何检查之前无节制地增长堆。v0.5.61 为阈值加入了自适应步长——这才是你真正想要的:堆小时廉价地检查,堆大时检查得更少。
3. 属性访问拥有了真正的内联缓存
所有现代 JavaScript 引擎在属性访问上都有多态内联缓存(PIC)。在 Perry v0.5 的大部分时间里,PropertyGet 都要经过一个用 thread-local 哈希的 shape 表查找。对冷代码来说没问题。但当一个调用点 95% 的属性读取都看到同一个 shape(这几乎总是如此)时,就不太好了。
v0.5.44 为 PropertyGet 落地了单态内联缓存。每个 PropertyGet 点都获得一个每调用点缓存项:一个期望 shape 指针和一个字段偏移。命中路径是一次比较加一次索引加载。未命中路径退回到慢速辅助函数,由它更新缓存。
; Monomorphic IC fast path for obj.foo
%shape_ptr = load ptr, ptr %obj_shape_slot
%expected = load ptr, ptr @ic_expected_12
%hit = icmp eq ptr %shape_ptr, %expected
br i1 %hit, label %ic_hit, label %ic_miss
ic_hit:
%off = load i32, ptr @ic_offset_12
%addr = getelementptr i8, ptr %obj, i32 %off
%val = load i64, ptr %addr
; ... use val
br label %contv0.5.51 为动态属性写入加入了内容哈希的 shape 转移缓存。两个以相同顺序增长相同字段的对象会哈希到同一个转移,最终共享同一个 shape——这意味着 PIC 的读取侧真的能命中。
v0.5.55 从转移缓存中消除了最后一次 TLS 访问。v0.5.46 修复了一个 PIC miss 处理器的 bug:字段超过 8 个的对象会读到内联槽之外的未初始化内存(关闭 #55)。v0.5.78 加了一个守卫,防止 PropertyGet 的 PIC 把像裸数字这样的非指针接收者当作索引对象——过于乐观的类型细化可能导致这种情况,这是 IC 最后一批稳定性问题之一。
净效果:属性密集的代码——实际上就是大多数 TypeScript——仅仅靠 IC 就比一周前快了大约 2–3 倍。
4. 整数、位运算与 | 0 模式
NaN-boxing 让每个数字都是 f64。TypeScript 程序员用 x | 0 来强制整数语义。V8 花了十五年让它变得廉价。Perry 这一周都在追赶。
按顺序列出的改动栈:
- v0.5.48:为
(int / const) | 0生成sdiv。LLVM 会折叠为smulh + asr,约 2 个周期,而fdiv要约 10 个。 - v0.5.48:在 Uint8ArrayGet 边界上使用
@llvm.assume。用一个向量化器能推理的基本块替代边界检查的分支 + phi 菱形。 - v0.5.49:修复位运算在 NaN/Infinity 上按 ToInt32 规范产生 0。正确性优先。
- v0.5.50:在已知有限值时跳过 5 条 NaN/Inf 守卫指令的
toint32_fast。再加上对小辅助函数的alwaysinline和 clamp 检测。 - v0.5.52:用
smin/smaxintrinsic 直接处理 clamp 函数。clamp 是继自增之后最常见的整数模式。 - v0.5.53:在已知有限的值上,
x | 0和x >>> 0成为 noop——只剩fptosi + sitofp,完全不需要守卫。 - v0.5.56:i32 原生位运算;Uint8ArrayGet/Set 使用 i32 索引和值。
- v0.5.58、v0.5.60:
Math.imul降级为原生 i32 乘法,而不是 polyfill 路径。polyfill 检测识别出用户手写的Math.imul垫片并替换它。 - v0.5.59:纯函数 init 内联 + 整数局部变量播种。当被调用函数小且纯时,函数内整数分析可以跨越调用边界看清。
- v0.5.37–v0.5.40:累加器模式整数算术快速路径。经典的
for (...) acc += f(i)循环在类型允许时,从头到尾都保持在 i32 上。
v0.5.41 是一个微妙的改动。当 codegen 看到一个模块级的 const K: number[][] = [[...], ...] 时,它把整个结构降级为 .rodata 中的扁平 [N x i32] 常量。K[y][x] 变成一次 getelementptr + load i32。结合 v0.5.43 中整数分析的桥接,这使得 image_conv(对 4K RGB 帧做 5×5 高斯模糊)在一个版本里就获得了 3 倍加速。
5. Buffer 和 Uint8Array
二进制工作负载——加密、图像处理、解析、网络——都依赖 Buffer 和 Uint8Array。v0.5.64 给了它们类型化指针槽加 noalias 元数据。以前 Buffer 是存在 alloca double 中的 NaN-boxed double,现在它是存在 alloca i64 中的裸 i64 指针,并带有 LLVM 注释告诉优化器“这个指针不会和作用域内其他指针产生别名”。这解锁了优化器本来不敢做的 load/store 重排、向量化和寄存器分配。
v0.5.80 关闭了最后一个正确性问题:buffer 的 alias-scope 计数器原本是模块级的,却被按函数重置,在极少数情况下可能让 LLVM 跨越不应共享 scope ID 的作用域进行推理。现在计数器是模块级的,noalias 的故事无懈可击。
v0.5.53 让 Uint8ArraySet 变为无分支——用掩码存储替代在越界时写 0 的 if/else。v0.5.54 为较长的模式加入了 Two-Way indexOf,还有 arena 分配的 split,两者一起弥合了字符串密集的 Buffer 解析上的大部分差距。
6. 字符串:ASCII 是快速路径
JavaScript 字符串是 UTF-16,但大多数现实世界的字符串(key、标识符、HTTP 头、JSON 脚手架)都是 ASCII。v0.5.71 为 ASCII 字符串加入了 O(1) 的 charCodeAt 和 codePointAt——没有 UTF-16 扫描,只是一次字节加载。v0.5.20 已经让 indexOf、slice 和 charAt 在 ASCII 上绕过 UTF-16 扫描。
同一版本中还有一个正确性说明:String.length 现在返回 UTF-16 代码单元(ECMAScript 规范),而不是字节数。这修复了一个潜伏的 bug:"café".length 之前返回 5 而不是 4。
7. 服务器现在真的不宕机了
本周最不光彩却最能被用户感知的工作,是让长期运行的 Node 风格服务器——Fastify、ws、http、net——不再在几分钟后崩溃。
这些崩溃都有一个共同的根因:GC 不知道监听器闭包的存在。当你写 wss.on('message', handler) 时,闭包会捕获变量,这些变量作为字段存在于一个 GC 分配的 cell 中。如果 GC 根扫描器不知道去访问这些 cell,它们的捕获就会被回收,下一次 message 事件就会解引用已释放的内存。
- v0.5.26:对
net.Socket事件监听器闭包做根扫描(关闭 #35)。 - v0.5.27:扩展到
ws、http、events、fastify。 - v0.5.28:将模块级全局变量注册为 GC 根(关闭 #36)。上一层的生命周期 bug。
- v0.5.21:Fastify/WebSocket 请求处理器内
gc()的安全性——显式 GC 调用在请求处理器持有 arena 指针时运行(关闭 #31)。
和 GC 工作并行的是,v0.5.20 交付了一个主事件循环——真正的循环,不是占位符——让 WebSocket 和基于定时器的服务器持续存活,而不是在最后一次同步调用返回后就退出(参考 #28)。这是对任何试图把 Perry 作为生产 HTTP 服务器运行的用户来说影响最大的一个修复。Fastify 现在能持续运行。WebSocket 服务器现在能持续运行。
v0.5.19 修复了 JSValue FFI 参数/返回值的 SysV AMD64 ABI 不匹配——在 Linux 上这个问题会让原生 FFI 调用悄悄损坏参数。v0.5.18 为 axios(get/post/put/delete/patch)加入了原生分派,包括 response.status 和 response.data。v0.5.30 修复了 fastify request.header() 和 request.headers[] 的分派,之前它们对大小写不敏感的查找返回 undefined。
8. @perry/postgres:让这一切成为必要的驱动
本周的很多工作都由一个工作负载驱动:让一个完整的、与 Node 兼容的 Postgres 驱动在 Perry 原生上运行。这个驱动支持 TLS,拥有跨模块的 codec 注册表,支持 cancel/close/notify,现在可以与 pg、postgres.js 和 tokio-postgres 对标。
驱动侧的性能工作与编译器侧并行进行:
- 提升每列 codec,并去掉每单元 Buffer 拷贝。对 int8 使用 BigInt(string),避免中间分配。
- 对象形式行的动态每 shape Row 构造器。如果你的查询总是返回相同的列,驱动会在第一次时构建一个 shape 专用的行构造器并复用——这与编译器的 PIC 结合起来,使得行的字段访问和任何其他对象的字段访问一样快。
parseTypes: 'minimal'选项,供希望对 int8/numeric/date 获取原始字符串的调用方使用。
这正是编译器一直想要催生的正反馈循环。真实的驱动暴露真实的瓶颈。瓶颈变成一个可以作为 GitHub issue 提交的单行复现用例。一周的编译器修复之后,驱动更快了,编译器对所有人也更快了。这就是整个计划,被压缩到了七天里。
9. 值得一提的正确性修复
性能工作暴露正确性问题,就像清理河道会捞出购物车。列出部分:
- Promise.race 在 rejection 时读取了
.value而不是.reason,导致 rejection 被静默吞掉(v0.5.13–v0.5.14)。 - Promise.any 现在在所有输入 promise 都 reject 时正确抛出
AggregateError。增加了Promise.withResolvers,修复了queueMicrotask的顺序问题。 [..."hello"]现在生成字符数组,而不是损坏的对象(关闭 #16)。- BigInt 算术和
BigInt()强制转换(关闭 #33)。i64 bigint 快速路径(v0.5.29)让常见情况变得廉价。 - Buffer.indexOf / Buffer.includes 在传入数字字节参数时,原本比较的是 buffer 指针而不是字节值(关闭 #56)。
- NaN/Infinity 的位运算按 ToInt32 规范产生 0(关闭 #57)。
- Windows x86_64:五项平台特定修复——
localtime、clang发现、以及一批 codegen 调整——让 Windows x86_64 重新回到绿色(v0.5.72)。
10. 数字
上一篇文章的头条基准是 factorial 比 Node 快 24.6 倍。那个数字没变。本周变化的是围绕它的一切:
| Workload | v0.5.12 | v0.5.80 | Delta |
|---|---|---|---|
| JSON.parse(20 条记录的 schema) | 比 Node 慢 547 倍 | 比 Node 慢 1.3 倍 | ~420x |
| image_conv(4K 5×5 模糊) | 1,980ms | 457ms | 4.3x |
| 属性密集型代码(PIC 命中) | 基线 | 2–3x | 2–3x |
| Fibonacci(40) | 401ms | 309ms | 1.3x |
| 负载下 Fastify 的持续运行 | 约 60 秒后崩溃 | 无限期 | ∞ |
对 Node 的完整 15 项基准套件仍然是 14 胜 1 平——和上一篇文章同样的表格,每一项的数字都略有改善。本周真正的进展出现在那套基准之外的工作负载上:JSON、图像处理、长时间运行的服务器。差距曾经就在那里,如今被弥合的正是那里。
11. 接下来
我们仍在追赶的一项基准是 image_conv 对比 Zig。Perry 是 457ms;Zig 是 246ms。这个差距是架构层面的,不是单个优化 pass 级别的,它分布在三个地方:
- 类型化 buffer 局部变量。本周大多数 Buffer 工作已经落地,但 buffer 类型的函数参数和局部变量在每次访问时仍然要解箱。我们用于循环计数器的
i64槽方法需要扩展到 buffer。 - 内部/边界循环拆分。模糊循环对每个像素都做 clamp,包括 99.9% 不需要 clamp 的像素。把循环拆分为边界区域(需 clamp)和内部区域(无需 clamp),就能让 LLVM 用 NEON
ld3/st3向量化内部。 - 双 ABI 的 FNV-1a 哈希。哈希辅助函数是通过 NaN-box ABI 调用的。把它特化为热路径上的裸 i64 输入/输出是几个小时的工作,能在每个哈希密集的工作负载上得到回报。
这些都记录在 PERF_ROADMAP.md 里。期待在下一周期看到它们。
收尾
这一周的模式——68 个补丁版本,几乎全是性能,一个 JSON 差距从 547 倍降到 1.3 倍——正是你越过 LLVM 切换那座山进入好的一侧时会发生的事。优化器现在是盟友,而不是墙;剩下的大部分工作都是小而具体、可测量的:找一条慢路径,弄清优化器为什么穿不透它,暴露出结构,再次测量。这些 commit 没有哪个是奇技淫巧。它们只是被应用到了需要它们的地方。
如果你想试用其中任何一项:
brew install perryts/perry/perry
perry init my-app && cd my-app
perry compile src/main.ts -o my-app && ./my-app源码:github.com/PerryTS/perry —— 文档:docs.perryts.com —— 更新日志:CHANGELOG.md
issues、复现用例,以及跑得不够快的基准:请继续发来。这种节奏之所以可行,是因为 bug 报告足够具体,可以变成一行复现代码。这篇文章里的每个 commit 都带着一个 #N,是有原因的。
—— Ralph
喜欢这篇文章?获取下一篇。
关于 Perry 发布和我们下一步开发内容的简短笔记。
Your email is sent to Resend for the Perry newsletter. You can request unsubscribe or deletion at any time. Privacy notice