🗒️V8引擎如何让JSON.stringify速度提升两倍多?

发布于2024-07-02
在Web开发中,JSON.stringify作为JavaScript的核心函数,承担着数据序列化的重要任务,其性能直接影响着从网络请求数据序列化到localStorage数据存储等诸多常见操作。近期,V8引擎团队通过一系列技术优化,让JSON.stringify的速度提升了两倍多,接下来我们就来详细了解这些优化措施。

无副作用的快速路径

这一优化的基础是一条新的快速路径,其建立在一个简单的前提之上:如果能够保证序列化一个对象不会触发任何副作用,就可以使用一种更快的、专门的实现方式。这里的“副作用”不仅包括序列化过程中执行用户定义的代码等明显情况,还包括可能触发垃圾回收周期的更微妙的内部操作。
只要V8引擎能够确定序列化不会产生这些副作用,就可以保持在这条高度优化的路径上。这使得它能够绕过通用序列化器所需的许多昂贵检查和防御性逻辑,从而为最常见的表示纯数据的JavaScript对象类型带来显著的速度提升。
此外,与递归的通用序列化器不同,新的快速路径是迭代式的。这种架构选择不仅消除了对栈溢出检查的需求,允许在编码更改后快速恢复,还使开发人员能够序列化比以前更深层次的嵌套对象图。

处理不同的字符串表示形式

V8引擎中的字符串可以用单字节或双字节字符表示。如果一个字符串只包含ASCII字符,它们在V8中会以单字节字符串的形式存储,每个字符使用1字节。但如果字符串中包含一个非ASCII范围的字符,该字符串的所有字符都会使用2字节表示,这实际上使内存利用率翻倍。
为了避免统一实现中的常量分支和类型检查,现在整个字符串序列化器都根据字符类型进行了模板化。这意味着我们编译了两个不同的、专门的序列化器版本:一个完全针对单字节字符串优化,另一个针对双字节字符串优化。这虽然会对二进制大小产生影响,但团队认为性能的提升是非常值得的。
该实现能高效处理混合编码。在序列化过程中,我们必须检查每个字符串的实例类型,以检测在快速路径上无法处理的表示形式(如ConsString,在扁平化过程中可能触发GC),这些情况需要回退到慢速路径。这个必要的检查也会揭示字符串是使用单字节还是双字节编码。
因此,从乐观的单字节字符串序列化器切换到双字节版本的决定基本上是无成本的。当现有的检查发现一个双字节字符串时,会创建一个新的双字节字符串序列化器,并继承当前状态。最后,只需将初始单字节字符串序列化器的输出与双字节字符串序列化器的输出连接起来,就可以构建出最终结果。这种策略确保我们在常见情况下保持在高度优化的路径上,同时切换到处理双字节字符的过程是轻量且高效的。

利用SIMD优化字符串序列化

JavaScript中的任何字符串在序列化为JSON时都可能包含需要转义的字符(如"\\)。传统的逐字符循环查找这些字符的方式速度很慢。
为了加快这一过程,我们根据字符串的长度采用了两级策略:
  • 对于较长的字符串,我们切换到专用的硬件SIMD指令(如ARM64 Neon)。这允许我们将字符串的更大块加载到宽SIMD寄存器中,并在几条指令内同时检查多个字节是否有任何可转义的字符。
  • 对于较短的字符串,由于硬件指令的设置成本过高,我们使用一种称为SWAR(寄存器内的SIMD)的技术。这种方法在标准的通用寄存器上使用巧妙的位运算逻辑,以非常低的开销同时处理多个字符。
无论使用哪种方法,过程都非常高效:我们快速地逐块扫描字符串。如果没有块包含任何特殊字符(常见情况),我们可以简单地复制整个字符串。

快速路径上的“快车道”

即使在主快速路径中,我们也发现了另一个更快的“快车道”机会。默认情况下,快速路径仍然必须迭代对象的属性,并且对于每个键,执行一系列检查:确认键不是Symbol,确保它是可枚举的,最后扫描字符串以查找需要转义的字符(如"\\)。
为了消除这些检查,我们在对象的隐藏类上引入了一个标志。一旦我们序列化了一个对象的所有属性,如果没有属性键是Symbol,所有属性都是可枚举的,并且没有属性键包含需要转义的字符,我们就将其隐藏类标记为“快速JSON可迭代”。
当我们序列化一个与之前序列化过的对象具有相同隐藏类的对象(这很常见,例如一个包含所有具有相同形状的对象的数组),并且它是“快速JSON可迭代”的,我们可以简单地将所有键复制到字符串缓冲区,而无需任何进一步的检查。
我们还将此优化添加到了JSON.parse中,在解析数组时,假设数组中的对象通常具有相同的隐藏类,我们可以利用它进行快速的键比较。

更快的双精度到字符串算法

将数字转换为其字符串表示形式是一项令人惊讶的复杂且对性能至关重要的任务。作为我们在JSON.stringify上工作的一部分,我们发现了一个通过升级核心DoubleToString算法来显著加快这一过程的机会。我们现在已经用Dragonbox取代了长期使用的Grisu3算法,用于最短长度的数字到字符串的转换。
虽然这种优化是由我们对JSON.stringify的分析推动的,但新的Dragonbox实现有利于V8中所有对Number.prototype.toString()的调用。这意味着任何将数字转换为字符串的代码,不仅仅是JSON序列化,都将免费获得这种性能提升。

优化底层临时缓冲区

在任何字符串构建操作中,一个显著的开销来源是内存管理方式。以前,我们的字符串序列化器在C++堆上的一个连续缓冲区中构建输出。虽然简单,但这种方法有一个显著的缺点:每当缓冲区耗尽空间时,我们必须分配一个更大的缓冲区并复制整个现有内容。对于大型JSON对象,这种重新分配和复制的循环会产生主要的性能开销。
关键的见解是,强制这个临时缓冲区是连续的并没有真正的好处,因为最终结果只在最后才被组装成一个单一的字符串。
考虑到这一点,我们用分段缓冲区取代了旧系统。我们现在使用V8的Zone内存中分配的一系列较小的缓冲区(或“段”),而不是一个大的、不断增长的内存块。当一个段满了,我们只需分配一个新的段并继续在那里写入,完全消除了昂贵的复制操作。

局限性

新的快速路径通过专门针对常见的简单情况来实现其速度。如果正在序列化的数据不符合这些标准,V8会回退到通用序列化器以确保正确性。要获得全部性能优势,JSON.stringify调用必须满足以下条件:
  • 没有replacerspace参数:提供replacer函数或用于美化打印的space/gap参数是专门由通用路径处理的功能。快速路径设计用于紧凑的、非转换的序列化。
  • 纯数据对象和数组:正在序列化的对象应该是简单的数据容器。这意味着它们及其原型不能有自定义的.toJSON()方法。快速路径假设标准原型(如Object.prototypeArray.prototype)没有自定义的序列化逻辑。
  • 对象上没有索引属性:快速路径针对具有常规字符串键的对象进行了优化。如果一个对象包含类似数组的索引属性(如'0'、'1'等),它将由较慢的、更通用的序列化器处理。
  • 简单的字符串类型:某些内部V8字符串表示形式(如ConsString)在序列化之前可能需要内存分配来进行扁平化。快速路径避免任何可能触发此类分配的操作,并且最适合简单的、顺序的字符串。这是Web开发人员很难影响的事情。但不用担心,在大多数情况下它应该能正常工作。
对于绝大多数用例,例如为API响应序列化数据或缓存配置对象,这些条件自然满足,使开发人员能够自动受益于性能改进。

结论

通过从根本上重新思考JSON.stringify,从其高层逻辑到其核心内存和字符处理操作,我们在JetStream2的json-stringify-inspector基准测试中实现了超过2倍的性能提升。这些优化从V8版本13.8(Chrome 138)开始可用。
如何在PostgreSQL中高效处理数十亿行数据?——TimescaleDB功能实测解析前端性能优化—图片懒加载
Loading...
©2021-2025 Arterning.
All rights reserved.