干掉 `Cow` 让我的 JSON 格式化器提速 42%

Lobsters Hottest 工具

摘要

作者详细介绍了如何通过移除写时复制(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 兼容的工具。我看过他们做出的一些令人惊叹的架构决策。开源需要做很多工作↩

相似文章