干掉 `Cow` 让我的 JSON 格式化器提速 42%
摘要
作者详细介绍了如何通过移除写时复制(Copy-on-Write, Cow)数据结构,使其 JSON 格式化器 JJPWRGEM 的性能提升了 42%,使其速度显著优于 Prettier 和 Oxfmt。
<p><a href="https://lobste.rs/s/9nngwi/killing_cow_made_my_json_formatter_42">评论</a></p>
查看缓存全文
缓存时间: 2026/05/13 00:22
# 杀死 `Cow` 让我的 JSON 格式化器快 42%
来源: https://jacobasper.com/blog/killing-a-cow-made-my-json-formatter-42-percent-faster/
破折号: 12
2026年5月5日
格式化器(formatter)可以一致地美化代码,通常能提高人类的可读性。如果你对格式化器、词法分析器(tokenizer)或抽象语法树(AST)不熟悉,请阅读我关于 [格式化器工作原理](https://jacobasper.com/blog/how-do-formatters-work) 的文章。
JavaScript 生态系统存在一个缺口——大多数格式化器要么功能强大*要么*速度快。Prettier 是 JavaScript 生态系统的标准,但它并不是最快的。Oxfmt 取得了重大进展——在支持 Prettier 的 JavaScript 格式化对等性的同时,性能有了显著提升。有一份报告指出,迁移到 Oxfmt 后,他们的仓库格式化速度提高了 6.5 倍——从 13.9 秒降至 2.1 秒 ([链接](https://www.udaynayak.com/blog/migrating-from-eslint-prettier-to-oxlint-oxfmt-in-an-nx-monorepo))。
然而,这些增益并不适用于 JSON。对于 JSON,Oxfmt 比 Prettier 慢 10–20%。Oxfmt 拥有原生的 JavaScript 格式化器,但对于没有原生实现的文件类型,它合理地委托给 Prettier 处理[^1]。Oxfmt 计划在今年推出原生 JSON 实现!([链接](https://github.com/oxc-project/oxc/issues/19704))
Prettier 和 Oxfmt 格式化一个 2MB 的 JSON 文件都需要超过 1 秒,而我的格式化器 [JJPWRGEM](https://github.com/20jasper/JJPWRGEM) 大约需要 30 毫秒。
> **稠密浮点数基准测试**
> 执行时间:解析并序列化一个包含大量轻度嵌套数组的 2.2MB JSON 文件
> 查看完整基准测试以获取更多细节 ([链接](https://github.com/20jasper/JJPWRGEM/blob/7ae6fd4f24aac289b8bdbb6b5c7f45b6b37d90c8/benches/json.md))
| 命令 | 中位时间 |
| :--- | :--- |
| jsonxf | 14.0ms |
| jjp | 31.0ms |
| bun | 70.0ms |
| node | 89.0ms |
| jq | 112.0ms |
| dprint | 665.0ms |
| prettier | 1149.0ms |
| oxfmt | 1218.0ms |
公平地说,JJPWRGEM 并不具备与 Prettier 完全对等的功能——但如果它有了呢?这是一个很好的基础,我有信心在实现功能的过程中发现性能增益。我从一个范围明确且影响巨大的改变开始——像 Prettier 一样规范化数字——结果在这个过程中使我的格式化器速度提高了 42%。如果你对如何计算这个比例感兴趣,可以跳到最后部分的 [最终基准测试](https://jacobasper.com/blog/killing-a-cow-made-my-json-formatter-42-percent-faster/#final-benchmarks)。
## 数字规范化
为了简单起见,让我们从与科学计数法相关的 3 个规范化操作开始。它们彼此之间很好地衔接:
1. 指数符号小写化(从 `1E5` 变为 `1e5`)
2. 去除 `+` 号(从 `1e+5` 变为 `1e5`)
3. 去除指数为 0 的情况(从 `1e0` 变为 `1`)
### 基线
目前,JJPWRGEM 保持数字原样不变,并将其表示为单个 `Cow`。随着内容的推进,我会解释更多关于 `Cow` 的内容。
我对几种具有不同数据形状的文件进行基准测试。以下是一些样本:
**稠密浮点数 (dense floats)**
```json
{
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"properties": {
"name": "Canada"
},
"geometry": {
"type": "Polygon",
"coordinates": [
[[-65.613616999999977, 43.420273000000009], [-65.619720000000029, 43.418052999999986], [-65.625, 43.421379000000059], ...]
]
}
}]
}
```
**大量对象和整数 (many objects and integers)**
```json
{
"events": {
"138586341": {
"description": null,
"id": 138586341,
"name": "30th Anniversary Tour",
"subTopicIds": [337184269, 337184283],
"topicIds": [324846099, 107888604]
}
},
"areaNames": {
"205705993": "Arrière-scène central",
"205705994": "1er balcon central"
}
}
```
**字符串和深层嵌套 (strings and deep nesting)**
```json
{
"statuses": [{
"metadata": {
"result_type": "recent",
"iso_language_code": "ja"
},
"created_at": "Sun Aug 31 00:29:15 +0000 2014",
"id": 505874924095815700,
"text": "@aym0566x \n\n名前:前田あゆみ\n第...",
...
}]
}
```
“Deser”指的是反序列化为 [抽象语法树 (AST)](https://en.wikipedia.org/wiki/Abstract_syntax_tree)。性能以吞吐量表示,以避免基于大小的差异。1MB 的文件比 3MB 的文件格式化得更快,但两者的 `MB/s` 是可比的!
| 数据集 | 基线 (MB/s) |
| :--- | :--- |
| deser/dense floats | 115.6 |
| deser/many objects and integers | 385.5 |
| deser/strings and deep nesting | 257.8 |
### 迭代 0:构建新的规范化字符串
最简单的方法是为格式不正确的数字构建新字符串。
不幸的是,每个数字都需要遍历两次:一次查找范围,再次验证。即使输入中没有数字,它也需要 [慢速堆分配](https://stackoverflow.com/a/2264981)。
我们需要一种更复杂的方法,该方法引用输入字符串的块。
### 迭代 1:2 个 `Cow`
每个数字只有两个部分重要。`e` 左边的尾数(mantissa)和右边的指数(exponent)。你可能认出这是科学计数法——`1e5` 代表 $1 \times 10^5$。
假设我们有 `1E+5`,我们可以存储 `1` 和 `5`,然后重构 `1e5`。如果有指数,`e` 或 `E` 会被小写化。任何 `+` 号都可以跳过,因为 `1e5` 和 `1e+5` 是等价的。
```rust
pub enum Token<'a> {
Number {
mantissa: Cow<'a, str>,
exponent: Cow<'a, str>
},
}
```
这种方法有效,但降低了整体性能。`Cow` 带来了原始 `str` 所避免的开销,对没有太多数字的数据集产生负面影响。
| 数据集 | 基线 (MB/s) | 当前 (MB/s) | 变化 |
| :--- | :--- | :--- | :--- |
| deser/dense floats | 115.6 | 101.1 | -12.5% |
| deser/many objects and integers | 385.5 | 289.3 | -25.0% |
| deser/strings and deep nesting | 257.8 | 214.8 | -16.7% |
#### 什么是 `Cow`?
`Cow` 代表 Clone On Write(写时复制)。它引用数据,直到需要修改时才复制数据。当大多数值保持不变时,这非常划算——只有需要修改的数据才会被克隆。
`to_lowercase` 函数是一个很好的用例——如果输入已经是小写,你可以返回对同一字符串的引用!在 Rust 术语中,数据是“借用”的。
```rust
fn to_lowercase(string: &str) -> Cow<'_, str> {
if string.is_lowercase() {
// 引用输入字符串
Cow::Borrowed(string)
} else {
// 创建一个新字符串
Cow::Owned(string.to_lowercase())
}
}
```
> 图示:调用 `to_lowercase` 且输入为已小写的 `&str` 时,返回一个指向同一内存的 `Cow::Borrowed`,避免分配。
现在想象一个包含大写字母的输入。为了避免更改被引用的字符串,会创建一个新的字符串。`Cow` “拥有”新数据,并且可以在不重新分配的情况下更新分配的容量,但它必须在作用域结束时清理这些数据。
> 图示:调用 `to_lowercase` 且输入为大写的 `&str` 时,返回一个指向新分配的 `Cow::Owned`。
由于 `Cow` 可能需要清理其数据,它在作用域结束时会有轻微的性能惩罚。这最终导致程序中 20% 的指令变得无用!`Cow` 的灵活性从未被使用过。
更糟糕的是,每个 `Token` 都可能是一个 `Number`,因此清理检查会在像 `OpenCurlyBrace` 这样不携带数据的令牌上运行。
让我们去掉 `Cow`!
### 迭代 2:使用 `&str` 代替 `Cow`
```rust
pub enum Token<'a> {
Number {
mantissa: &'a str,
exponent: &'a str
},
}
```
编译器得以移除所有那些清理指令和分支,这对于每个数据集来说都是一个巨大的胜利。
然而,“稠密浮点数”数据集低于基线,因为它没有指数——支付了检查永远不存在指数的成本。让我们尝试将令牌分为 2 类,以避免支付该成本。
| 数据集 | 基线 (MB/s) | 当前 (MB/s) | 变化 |
| :--- | :--- | :--- | :--- |
| deser/dense floats | 115.6 | 111.0 | -3.9% |
| deser/many objects and integers | 385.6 | 442.9 | +14.9% |
| deser/strings and deep nesting | 258.7 | 341.4 | +32.0% |
### 迭代 3:分离 `Mantissa` 和 `Exponent` 变体
而不是由 1 个变体持有完整数字,我们将其分为 2 个,以便 CPU 可以一次读取 2 个令牌,节省一条指令。
```rust
pub enum Token<'a> {
Mantissa(&'a str),
Exponent(&'a str),
}
```
CPU 以块的形式读取内存——就像一次读取一个词而不是整句话。
> 一个专注的 CPU,每次只读取一个 JSON Token。
CPU 一次将 64 字节的 RAM 获取到缓存中进行处理,这意味着每个令牌包含 2 个 `&str` 或 `Cow` 会将读取限制为一次一个 `Token`。
> 一个难过的 CPU 说:“我一次只能读取一个令牌,这太悲伤了。请让令牌变为 32 字节或更低,这样我就可以一次读取两个。”
包含 1 个 `&str` 或 `Cow` 的变体均为 24 字节,因此这 2 个可以装入一次读取中。
> 一个开心的 CPU 说:“我可以一次读取两个令牌,因为令牌是 24 字节。非常感谢,享受更高的吞吐量吧。”
“稠密浮点数”的反序列化现在优于基线,但“大量对象和整数”由于更高的数字解析开销,相比上一迭代有所倒退。
| 数据集 | 上一迭代 (MB/s) | 当前 (MB/s) | 变化 |
| :--- | :--- | :--- | :--- |
| deser/dense floats | 111.0 | 122.6 | +10.5% |
| deser/many objects and integers | 442.9 | 401.7 | -9.3% |
| deser/strings and deep nesting | 341.4 | 356.8 | +4.5% |
### 迭代 4:拆分状态机
解析尾数和指数使得数字状态机膨胀到 88 字节。就像较大的令牌一样,CPU 每次状态变更需要两次读取。
状态机就像一份顺序检查清单。为了找到数字的开始和结束,我们可以遵循这份(缩写)清单:
1. 以数字或减号开始
2. 然后是另一个数字或 `.` 表示浮点数
3. ...
4. 结束
起初,拥有一个完整的数字状态机是有意义的,但现在我们关心这个数字的两个独立块。
拆分不仅允许 CPU 在一次读取中获取完整状态,编译器也能通过减少需要推理的可能性来更好地优化。
```rust
enum MantissaState {
...
}
enum ExponentState {
MinusOrPlusOrDigit,
AfterSign,
Digits,
Zero,
End
}
```
现在,“大量对象和整数”数据集甚至比基线更快!不幸的是,“稠密浮点数”退回到略低于基线的水平,但这是一个可接受的权衡,因为典型的 JSON 包含更多的混合数据。
| 数据集 | 上一迭代 (MB/s) | 当前 (MB/s) | 变化 |
| :--- | :--- | :--- | :--- |
| deser/dense floats | 122.6 | 111.3 | -9.2% |
| deser/many objects and integers | 401.7 | 456.9 | +13.7% |
| deser/strings and deep nesting | 356.8 | 385.1 | +7.9% |
## 最终基准测试
从 `Cow` 切换到 `str` 以及拆分 `Number` 令牌和状态机都有助于提高性能。
标题中提到的“42%”改进指的是字符串和深层嵌套数据集的反序列化 + 美化。较慢的阶段会拉低较快的阶段,因此我们将它们的时间相加并转换回 MB/s 吞吐量:
$$
\text{throughput}_{\text{combined}} = \frac{1}{\frac{1}{\text{throughput}_{\text{deser}}} + \frac{1}{\text{throughput}_{\text{prettify}}}}
$$
对于基线情况:
$$
\frac{1}{\frac{1}{257.8} + \frac{1}{2091.1}} \approx 229.5 \text{ MB/s}
$$
对于新实现:
$$
\frac{1}{\frac{1}{385.1} + \frac{1}{2105.0}} \approx 325.5 \text{ MB/s}
$$
由此产生了约 42% 的增益:
$$
\frac{325.5 - 229.5}{229.5} \approx 0.418 \approx 41.8\%
$$
### 反序列化为 AST
将字符串转换为 AST 的基准测试。“稠密浮点数”为每个数字支付的额外未使用令牌买单,但“大量对象和整数”和“字符串和深层嵌套”由于将 `Cow` 转换为 `str` 而有了巨大改进。
| 数据集 | 基线 (MB/s) | 新 (MB/s) | 变化 |
| :--- | :--- | :--- | :--- |
| dense floats | 115.6 | 111.3 | -3.7% |
| many objects and integers | 385.5 | 456.9 | +18.5% |
| strings and deep nesting | 257.8 | 385.1 | +49.4% |
### 从 AST 美化
将 AST 序列化为人类可读字符串的基准测试。“稠密浮点数”受到的影响最大,因为每个 `Number` 都需要一个分支来检查是否有指数可供打印。其他数据集持平。
| 数据集 | 基线 (MB/s) | 新 (MB/s) | 变化 |
| :--- | :--- | :--- | :--- |
| dense floats | 1424.7 | 1332.0 | -6.5% |
| many objects and integers | 1853.2 | 1845.3 | -0.4% |
| strings and deep nesting | 2091.1 | 2105.0 | +0.7% |
### 从令牌流压缩 (Uglify)
将字符串转换为令牌并序列化为紧凑字符串。全面大幅改进。避免构建语法树,因此序列化避免了像 AST 到字符串路径那样带有格式化的分支。
| 数据集 | 基线 (MB/s) | 新 (MB/s) | 变化 |
| :--- | :--- | :--- | :--- |
| dense floats | 198.9 | 251.2 | +26.3% |
| many objects and integers | 449.8 | 630.4 | +40.2% |
| strings and deep nesting | 269.9 | 418.2 | +54.9% |
---
基准测试证明,格式化器可以同时具备速度快和功能强大——我做了更多工作,并在大多数类别中提高了性能。当我实现下一个 Prettier 功能时,我们再见!
请考虑订阅我的 [RSS 源](https://jacobasper.com/blog/rss.xml),否则你的程序会让你的 CPU 抑郁。
> 一个电容器给 CPU 做治疗。他们说:“我听说你最近感觉缓存未命中(cache miss)很糟糕……”
> CPU 躺在沙发上,电容器戴着眼镜,拿着剪贴板。
[^1]: 我非常喜欢 Oxfmt 迄今为止所做的一切,所以这并不是为了贬低 Oxfmt。这是我找到的唯一一个在格式化较大的 JSON 文件时不会崩溃的与 Prettier 兼容的工具。我看过他们做出的一些令人惊叹的架构决策。开源需要做很多工作↩
相似文章
@reach_vb: 用于自主优化工作的 Codex:
Charlie Marsh 分享称,OpenAI 的 Codex 在他忙于其他任务时,自主发现了一个格式化工具可以提速 5-10%。
我梳理了本地模型导致 JSON 输出失效的每一种情况并构建了修复库,以下是我在 288 次模型调用中的发现
一位开发者梳理了 288 次本地模型运行中的 JSON 输出失败案例,发现了如 Markdown 代码块包裹和尾随逗号等常见问题,并开发了 outputguard,这是一个采用 15 种策略修复无效 JSON 的 Python 库。
@tom_doerr: 将 Claude Code 和 Cursor 的 token 成本降低 60-95% https://github.com/yvgude/lean-ctx
lean-ctx 是一个基于 Rust 的开源上下文运行时,通过文件读取压缩和 Shell 输出优化,将 Claude Code、Cursor、Copilot 等 AI 编程助手的 token 成本降低 60–95%。它以 Shell Hook 和 MCP Server 的形式运行,提供 56 个工具及多种读取模式。
一夜之间格式化2500万行代码库:rubyfmt的故事
Stripe工程师分享了他们使用rubyfmt在一夜之间格式化2500万行Ruby代码库的经验,重点介绍了开发者生产力的提升。
@shubh6200: 要了解如何处理海量文件,请阅读 @geofflangdale 和 @lemir 的《Parsing Gigabytes of JSON per Second》…
该论文介绍了 simdjson,这是第一个能够在单核上使用 SIMD 指令每秒处理数 GB 数据的验证性 JSON 解析器,相比 RapidJSON 等现有解析器实现了显著的加速。