返回博客
GCJSONperformancebenchmarksmilestone

分代 GC、惰性 JSON,以及经得起推敲的基准

上一篇博客发布时停在 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