返回博客
GCJSONperformancebenchmarksmilestone

分代 GC、惰性 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.

上一篇博客发布时停在 v0.5.174,标题只有一个:Perry 终于在树内基准套件中击败了 Node 和 Bun,所有项目全胜。三天工作和一批积压的 GC + JSON 提交之后,Perry 来到了 v0.5.306——也就是发布了 132 个补丁版本——而这次的故事不一样了。标题不是 547 倍的加速,也不是又添了一栏胜利。它是让那些胜利经得起推敲的那些工作。

  • 分代 GC 作为默认行为发布。Phase A 到 D 落地于 v0.5.217–v0.5.237。
  • 小字符串优化(SSO) 作为默认行为发布。Steps 1.5 → 2 落地于 v0.5.213–v0.5.216。
  • JSON 流水线获得了基于 tape 的解析器、惰性 parse、惰性 stringify,以及按元素的稀疏物化。默认的 validate-and-roundtrip 现在是 75 ms 中位数——动态类型组里最快的。
  • 基准测试页面从头到尾被重写:RUNS=11 中位数 + p95 + σ + min + max,加入 simdjson 和 AssemblyScript+json-as 作为对比对象,把优化探针与真正的对比分开,并诚实地暴露 Perry 的每一个弱点。

配角是一连串稳定的正确性修复:Promise 微任务 FIFO、NaN 相等以及 ECMAScript 数字格式化、BigInt 二补码、AsyncLocalStorage 端到端、decimal.js + ioredis + commander runtime,以及一个一直藏在 tape 路径下、对纯 f64 做 JSON.stringify 时的段错误。再加上 Windows 工具链终于走向轻量:LLVM + xwin,无需安装 Visual Studio。

1. 分代 GC,默认开启

分代 GC 已经分阶段推进了两个月。本周期内关闭的阶段总结如下:

  • v0.5.217–v0.5.221——Phase A:shadow-stack runtime 脚手架、push/pop 发射、slot-map 串联、Let/LocalSet 影子镜像,以及根扫描器。
  • v0.5.222——Phase B:nursery + 老年代 arena 分割。
  • v0.5.223–v0.5.225——Phase C1–C2:写屏障 runtime 基础设施、codegen 发射屏障,每一次堆 store 都要走它。
  • v0.5.226–v0.5.228——Phase C3a–C4:remembered-set 根流入 mark + clear;minor GC 跟踪跳过老年代;非移动的 tenuring。
  • v0.5.229–v0.5.236——Phase C4b α/β/γ/δ:转发指针基础设施、pinning + evacuation pass、扫描器 + 传递性 pinning、引用重写、空闲 nursery 块归还给 OS、GC 触发上限锁定为初始阈值。
  • v0.5.237——Phase D part 1:PERRY_GEN_GC=1 默认开启。
  • v0.5.238——Phase D part 2:PERRY_SHADOW_STACK=1 默认开启。
  • v0.5.239–v0.5.240——收尾文档:路线图定稿,学术 + 工业血缘附录(Bartlett 1988、Ungar 1984、Cheney 1970)。

最重要的实测胜利:分代 GC 默认翻转的瞬间,test_memory_json_churn 的峰值 RSS 从 115 MB → 91 MB。计算端的回退很小,并且毫不掩饰地列了出来——nested_loops 8 → 18 ms、accumulate 24 → 34 ms、object_create 0 → 1 ms、array_read / array_write 各 +1 ms。逃生舱(PERRY_GEN_GC=0)能恢复到旧的数字;这个权衡是有意为之的,基准测试页面现在把两行并排列出来,读者可以自己挑选。

2. 小字符串优化,默认开启

SSO 是一个 22 字节的内联字符串表示,能让短字符串避免堆分配——典型的 JSON key(2–8 字节)和短 value 都落入内联形式。表面上的发布很小,背后的工作量很大:

  • v0.5.213:SSO 基础设施(表示 + 访问器)。
  • v0.5.214:Step 1 消费方武装 + PERRY_SSO_FORCE 测试开关。
  • v0.5.215:Step 1.5 codegen PropertyGet 三路分支——内联字符串快路径、堆字符串快路径、剩余情况慢路径。
  • v0.5.216:Step 2 翻转——默认发射 SSO。

v0.5.279 的后续修复关闭了 SSO 进入热路径后才暴露出来的最后一个属性读取 NaN bug;v0.5.272 中跨模块链式 getter 派发的修复又关闭了一个。两个 bug 在默认翻转之前就在待办列表上;两个修复都没有带来性能回退。

3. JSON:基于 tape 的 parse,默认惰性

JSON 流水线是这一时期改动最深入的重写。旧行为:JSON.parse 构建一棵完整物化的、由 NaN-boxed 值组成的树。新行为:JSON.parse 构建一条每值 12 字节的 tape 并按需物化——只有你真正读到的值才会付出物化成本。对未修改的 parse 结果做 stringify 现在是对原始输入的一次 memcpy,正是 simdjson 在 raw_json() 上用的同一招快速路径。

  • v0.5.200JSON.parse<T>(blob) 由 schema 驱动的 parse(Step 1)。编译期已知的形状让编译器能发射预解析过的 key 访问。
  • v0.5.203:基于 tape 的 parse 基础——Step 2 Phase 1。
  • v0.5.204:惰性 parse + 惰性 stringify——Step 2 Phases 2+4。
  • v0.5.206:lazy-safe 的索引访问 + 边界情况——Step 2 Phase 3。
  • v0.5.208:按元素的稀疏物化——Step 2 Phase 5b。
  • v0.5.209:游走游标 + 自适应物化阈值。
  • v0.5.210:对 ≥1 KB 的 blob 默认翻转为惰性 parse。

在惰性 tape 真正为之设计的负载(10k 条记录、约 1 MB blob、parse → stringify 中间不做迭代)上的结果:

实现中位数 (ms)p95 (ms)σ峰值 RSS
c++ -O3 -flto (simdjson)24281.28 MB
perry (gen-gc + lazy tape)75916.985 MB
rust serde_json (LTO)1851901.711 MB
bun25934226.182 MB
node39460260.1127 MB
kotlin (kotlinx.serialization)47353321.4606 MB
assemblyscript+json-as (wasmtime)59862110.558 MB

Perry 在 75 ms 中位数下,是这次对比中最快的动态类型 runtime——击败 Bun(259 ms)、击败 Node(394 ms)、击败 Kotlin 服务端 JIT(453 ms)。simdjson 在 24 ms 是 SIMD 加速的 C++ 天花板,故意留在页面上而不是被精挑细选地藏起来。Perry 没有击败它。重点是把这个差距摆出来,这样要去缩小它就有目标——已在 docs/json-typed-parse-plan.md 中跟踪。

诚实的搭档基准是 parse-and-iterate:同样的 blob,但每次迭代都对每条记录的 nested.x 求和,强制惰性 tape 物化。在这里 Perry 落到 466 ms——比 mark-sweep 逃生舱的 375 ms 还慢,因为 tape 付出的开销摊销不掉。这一行就在 TL;DR §B 里。当工作量躲不掉的时候,惰性 tape 也不会假装能躲。

4. 基准测试页面,已重写

关于 Perry 如何呈现性能数字,有三件事变了。

RUNS=11 中位数 + p95 + σ + min + max,不是 best-of-N。best-of-N 会悄无声息地丢掉尾延迟;在这台硬件上,它一直藏着 Python accumulate 9.4 秒级的离群点和 Swift JSON 5.3 秒级的 p95 尖峰。中位数把尾巴重新摆回了页面上。方法学的变更落在 v0.5.248;TL;DR §A 和 §B 中的每一个单元格都是 RUNS=11 重新跑出来的,截至 2026-04-25

优化探针和真实的 runtime 性能被分开了。那五个 Perry 12–34 ms 对 Rust/C++ 98 ms 的单元——loop_overheadmath_intensiveaccumulatearray_readarray_write——衡量的是编译器标志姿态,不是硅片本身。它们现在被放进自己的小节,上方有一段说明指出 clang++ -O3 -ffast-math 能把差距收到一毫秒以内。真正的 runtime 头牌内核是 loop_data_dependent:Perry 235 ms、Rust 229、Swift 233、Java 229、Bun 232——在这个编译器确实没法把工作折叠掉的内核上,Perry 稳稳地坐在 no-FMA-contract 那一组里。这才是诚实的对比。

新增对手。simdjson(4.3.0)现在出现在两张 JSON 表里——C++ parse 吞吐天花板,摆在页面上让读者看到差距。带 json-as 的 AssemblyScript(1.3.2)是最接近的、可安装的 TS-to-native 对手;porffor 在这个尺寸的负载上段错误,Static Hermes 在 macOS arm64 上装不上。带 kotlinx.serialization 的 Kotlin 在 v0.5.241–v0.5.242 加入了 JSON 多语言对比。每一行都是真实的,每一条免责声明都在页面上。

5. 多语言计算表

真正不可折叠的头牌内核,RUNS=11 中位数,2026-04-25 在 v0.5.249 刷新:

BenchmarkPerryRustC++JavaNodeBun
fibonacci3183303152821022589
loop_data_dependent235229129229322232
object_create1005116
nested_loops1888111821

fibonacci 上,Perry 与编译型那组只差 3–15 ms。Java 的 HotSpot JIT 因为内联了递归调用,要快约 11%。在 loop_data_dependent 上,这个内核分裂为两个 FP-contract 簇:约 128 ms 的 FMA-contract 组(Go 默认、Apple Clang 上的 g++ -O3——两者都把 sum * a + b 融合成一条 FMADDD),以及 229–235 ms 的 no-contract 组(Perry、Rust 默认、Swift、未带 -XX:+UseFMA 的 Java、Bun)跑标量的 FMUL + FADD。LLVM 在 -ffp-contract=fast 下能匹平 FMA 组;Perry 默认不开这个。nested_loops 是 cache-bound 而不是 compute-bound;所有人都落在 8–21 ms。

6. Windows 工具链,轻量化

Windows 用户不再需要安装 Visual Studio。v0.5.199 关闭了 #176perry setup windows + winget LLVM + xwin 替代了整棵 VS BuildTools 树。v0.5.201 去掉了 find_lld_link / find_perry_windows_sdk 上的 cfg 门控,这样路径发现在每一个面向 Windows 的平台上都能工作,而不只是 macOS 主机。

# Windows host
winget install LLVM.LLVM
perry setup windows
perry compile src/main.ts --target windows -o myapp.exe

7. Runtime 正确性扫荡

本周期的一个主题:从 V8/JSC 中悄悄分叉出来的 runtime 行为,要么变成修复,要么变成编译错误。值得一提的:

  • v0.5.255BigInt.fromTwos/toTwos 二补码。
  • v0.5.263Promise.all/race/any 对非 promise 类型的判别。
  • v0.5.281NaN==NaN + ECMAScript 数字格式化(3 → "3",不是 "3.0"-0 → "0";等等)。
  • v0.5.280(x) | 0NaN/Infinity 的 ToInt32 强制转换。
  • v0.5.284:Promise 微任务 FIFO + 抛出处理器的传播。
  • v0.5.286:对纯 f64 调用 JSON.stringify 在 tape 路径下会段错误。
  • v0.5.277:未传 encoding 时 fs.readFileSync 返回 Buffer(与 Node 一致)。
  • v0.5.272:跨模块的链式 getter 派发返回 undefined

围绕 issue #187 的标准库后续也补齐了:AsyncLocalStorage 端到端(v0.5.261)、commander runtime + codegen 真正调用 .action()(v0.5.250)、decimal.js 代码(v0.5.259)、Redis ioredis 端到端(v0.5.270)、pg + mongo 异步工厂模式(v0.5.275),以及 EE/LRU/WSS 上同样的异步工厂 bug(v0.5.252)。

perry/ui 一侧:通知点击回调(#97)在 Apple(v0.5.254)和 Android(v0.5.258)两端都接通;本地通知的 schedule + cancel(#96,v0.5.244);Android 上的 FCM 注册 + 接收(v0.5.262)。

8. 收尾

这段时间的模式不是头条数字。它是让现有的胜利经得起推敲的工作:一个能接住持续分配负载的分代 GC、一个能合上短字符串成本差距的 SSO、一个利用最常见负载“不修改”结构的 JSON 流水线,以及一个测中位数而不是 best-of-N、并把 simdjson 的 24 ms parse 天花板与 Perry 的 75 ms 摆在同一行的基准测试页面。读者得以看到差距——以及 Perry 相对于地板的位置。

来试试:

# npm (any platform)
npm install @perryts/perry
npx perry compile src/main.ts -o myapp && ./myapp

# Homebrew (macOS)
brew install PerryTS/perry/perry

# winget (Windows — no VS install needed)
winget install PerryTS.Perry

# Default benchmark suite
cd benchmarks/json_polyglot && ./run.sh
cd benchmarks/polyglot && ./run_all.sh

源码:github.com/PerryTS/perry —— 基准测试:benchmarks/README.md —— 更新日志:CHANGELOG.md

— Ralph

喜欢这篇文章?获取下一篇。

关于 Perry 发布和我们下一步开发内容的简短笔记。

Your email is sent to Resend for the Perry newsletter. You can request unsubscribe or deletion at any time. Privacy notice