如何让JSON.stringify性能提升两倍以上
JSON.stringify 是用于序列化数据的核心JavaScript函数。其性能直接影响Web上的常见操作,从序列化网络请求数据到将数据保存到localStorage。更快的JSON.stringify意味着更快的页面交互和响应更灵敏的应用程序。这就是为什么我们很兴奋地分享,最近的一项工程工作使得V8中的JSON.stringify性能提升了两倍以上。本文将详细解析实现这一改进的技术优化。
无副作用的快速路径
这项优化的基础是建立在一个简单前提上的新快速路径:如果我们能保证序列化对象不会触发任何副作用,我们就可以使用一个更快的、专门的实现。这里的“副作用”指的是任何破坏对象简单、流线型遍历的操作。
这不仅包括在序列化期间执行用户定义代码等明显情况,还包括可能触发垃圾回收周期等更微妙的内部操作。关于究竟什么会导致副作用以及如何避免它们的更多细节,请参阅限制部分。
只要V8能够确定序列化过程不会有这些影响,它就可以保持在这个高度优化的路径上。这允许它绕过通用序列化器所需的许多昂贵的检查和防御性逻辑,从而为代表纯数据的最常见类型的JavaScript对象带来显著的加速。
此外,新的快速路径是迭代式的,与递归的通用序列化器形成对比。这种架构选择不仅消除了对栈溢出检查的需要,并允许我们在编码更改后快速恢复,还允许开发者序列化比以前可能处理得更深的嵌套对象图。
处理不同的字符串表示
V8中的字符串可以用单字节或双字节字符表示。如果字符串只包含ASCII字符,它们将以单字节字符串的形式存储在V8中,每个字符使用1个字节。然而,如果字符串包含一个ASCII范围之外的字符,该字符串的所有字符都将使用2字节表示,这实质上使内存使用量翻倍。
为了避免统一实现中不断的条件分支和类型检查,整个字符串化器现在都基于字符类型进行了模板化。这意味着我们编译了两个不同的、专门的序列化器版本:一个完全针对单字节字符串优化,另一个针对双字节字符串优化。这会影响二进制大小,但我们认为性能的提升绝对值得。
该实现能高效处理混合编码。在序列化过程中,我们已经必须检查每个字符串的实例类型,以检测无法在快速路径上处理的表示(比如ConsString,它在扁平化过程中可能会触发GC),这些情况需要回退到慢速路径。这个必要的检查也揭示了字符串使用的是单字节还是双字节编码。
因此,从我们乐观的单字节字符串化器切换到双字节版本的决定基本上是零成本的。当这个现有检查显示一个双字节字符串时,会创建一个新的双字节字符串化器,继承当前状态。最后,通过简单地将初始单字节字符串化器的输出与双字节字符串化器的输出连接起来,构建最终结果。这种策略确保我们在常见情况下保持高度优化的路径,同时处理双字节字符的转换是轻量且高效的。
使用SIMD优化字符串序列化
JavaScript中的任何字符串都可能包含在序列化为JSON时需要转义的字符(例如" 或 \)。传统的逐个字符循环查找这些字符很慢。
为了加速这一过程,我们采用了基于字符串长度的两级策略:
- 对于较长的字符串,我们切换到专用的硬件SIMD指令(例如,ARM64 Neon)。这允许我们将更大块的字符串加载到宽的SIMD寄存器中,并用几条指令一次检查多个字节中是否有任何可转义字符。(来源)
- 对于较短的字符串,硬件指令的设置成本会太高,我们使用一种称为SWAR(寄存器内的SIMD)的技术。这种方法在标准的通用寄存器上使用巧妙的位运算逻辑,以非常低的开销一次处理多个字符。(来源)
无论采用哪种方法,该过程都非常高效:我们逐块快速扫描字符串。如果没有任何一个块包含特殊字符(这是常见情况),我们就可以直接复制整个字符串。
快速路径上的“快车道”
即使在主快速路径内部,我们也找到了创建另一个甚至更快的“快车道”的机会。默认情况下,快速路径仍然必须遍历对象的属性,并对每个键执行一系列检查:确认键不是Symbol,确保它是可枚举的,最后扫描字符串中需要转义的字符(例如" 或 \)。
为了消除这些检查,我们在对象的隐藏类上引入了一个标志。一旦我们序列化了一个对象的所有属性,如果没有任何属性键是Symbol,所有属性都是可枚举的,并且没有任何属性键包含需要转义的字符,我们就会将其隐藏类标记为fast-json-iterable。
当我们序列化一个与之前序列化的对象具有相同隐藏类的对象时(这很常见,例如,一个所有对象形状都相同的对象数组),并且它是fast-json-iterable,我们可以简单地将所有键复制到字符串缓冲区中,而无需任何进一步检查。
我们还将此优化添加到了JSON.parse中,假设数组中的对象通常具有相同的隐藏类,我们可以在解析数组时利用它进行快速键比较。
更快的双精度数到字符串的算法
将数字转换为字符串表示是一个出人意料复杂且对性能要求很高的任务。作为JSON.stringify工作的一部分,我们发现了通过升级核心的DoubleToString算法来显著加速这一过程的机会。我们现在已经用Dragonbox算法替换了长期使用的Grisu3算法,用于最短长度的数字到字符串转换。
虽然这项优化是由我们的JSON.stringify性能分析驱动的,但新的Dragonbox实现将使V8中所有对Number.prototype.toString()的调用受益。这意味着任何将数字转换为字符串的代码,不仅仅是JSON序列化,都将免费获得这种性能提升。
优化底层临时缓冲区
在任何字符串构建操作中,内存管理方式都是一个重要的开销来源。以前,我们的字符串化器在C++堆上的单个连续缓冲区中构建输出。虽然简单,但这种方法有一个明显的缺点:每当缓冲区空间不足时,我们不得不分配一个更大的缓冲区,并将所有现有内容复制过去。对于大型JSON对象,这种重新分配和复制的循环造成了巨大的性能开销。
关键的认识在于,强制这个临时缓冲区是连续的并没有带来真正的好处,因为最终结果只在最后才被组装成一个单一的字符串。
考虑到这一点,我们用分段缓冲区替换了旧系统。现在,我们不再使用一个不断增长的大内存块,而是使用一个在V8的Zone内存中分配的较小缓冲区(或“段”)列表。当一个段填满时,我们只需分配一个新的段并继续在那里写入,完全消除了昂贵的复制操作。
限制
新的快速路径通过对常见、简单的情况进行专门处理来实现其速度。如果要序列化的数据不符合这些条件,V8会回退到通用序列化器以确保正确性。要获得全部的性能优势,JSON.stringify调用必须遵守以下条件:
- 没有
replacer或space参数:提供用于美化输出的replacer函数或space/gap参数是由通用路径专门处理的功能。快速路径是为紧凑、无转换的序列化设计的。 - 纯数据对象和数组:被序列化的对象应该是简单的数据容器。这意味着它们及其原型不能有自定义的
.toJSON()方法。快速路径假定使用标准的原型(如Object.prototype或Array.prototype),这些原型没有自定义的序列化逻辑。 - 对象上没有索引属性:快速路径针对具有常规、基于字符串的键的对象进行了优化。如果对象包含类似数组的索引属性(例如,‘0’, ‘1’, …),它将被较慢、更通用的序列化器处理。
- 简单的字符串类型:某些内部的V8字符串表示(如ConsString)在序列化之前可能需要分配内存进行扁平化。快速路径避免任何可能触发此类分配的操作,最适合简单的顺序字符串。这是Web开发者很难影响的方面。但别担心,在大多数情况下它应该都能正常工作。
对于绝大多数用例,例如为API响应序列化数据或缓存配置对象,这些条件自然得到满足,从而使开发者能够自动受益于这些性能改进。
结论
通过从高层次逻辑到核心内存和字符处理操作,从头重新思考JSON.stringify,我们已经在JetStream2的json-stringify-inspector基准测试中交付了超过2倍的性能提升。下图显示了在不同平台上的结果。这些优化从V8 13.8版本(Chrome 138)开始可用。
