2023 年 Node.js 性能状态

JavaScript/前端
195
0
0
2024-03-14
标签   NodeJs
原文:https://blog.rafaelgss.dev/state-of-nodejs-performance-2023 作者:rafaelgss 翻译/整理:五月君

下图为本篇主要内容:

2023 年,我们发布了 Node.js v20[1]。这是一项重要的成就,本文旨在使用科学数据评估 Node.js 性能的状况。

所有基准测试结果均包含可重现的示例和硬件详细信息。为了减少对普通读者的干扰,可重现的步骤将在所有部分的开头折叠。

本文旨在提供对不同版本的 Node.js 进行比较分析。它突出了改进和挫折,并深入探讨这些变化背后的原因,而不与其他 JavaScript 运行时进行比较。

为了进行这个实验,我们使用了 Node.js 版本 16.20.0、18.16.0 和 20.0.0,并将基准测试套件分为三个不同的组:

1. Node.js内部基准测试

考虑到 Node.js 基准测试套件的规模庞大且耗时,我选择了在我看来对 Node.js 开发人员和配置有更大影响的基准测试,比如使用 fs.readfile 读取 16MB 文件。这些基准测试按模块分组,例如 fs 和 streams。有关 Node.js 基准测试套件的更多详细信息,请参阅 Node.js 源代码[2]

2. nodejs-bench-operations

我维护一个名为 nodejs-bench-operations[3] 的仓库,其中包括所有主要版本的 Node.js 的基准测试操作,以及每个版本线的最后三个版本。这使得可以轻松地比较不同版本之间的结果,例如 Node.js v16.20.0 和 v18.16.0,或 v19.8.0 和 v19.9.0,目的是识别 Node.js 代码库中的退化。如果你对 Node.js 的比较感兴趣,关注这个仓库可能会有益处(如果你发现它有帮助,请别忘了给它点赞)。

3. HTTP 服务器(框架)

这个实用的 HTTP 基准测试向各种路由发送大量请求,返回 JSON、纯文本和错误,以 express 和 fastify 作为参考。主要目标是确定从 Node.js 内部基准测试和 nodejs-bench-operations 获得的结果是否适用于常见的 HTTP 应用程序。

💡 更新:由于本文涵盖了大量内容,第三和最后一步将在随后的文章中分享。为了保持更新并接收通知,我鼓励你在 Twitter[4]/LinkedIn[5]上关注我。

Environment

为了执行这个基准测试,使用了一个 AWS 专用主机,配备以下计算优化的实例:

  • c6i.xlarge (Ice Lake) 3,5 GHz - 计算优化
  • 4 vCPUs
  • 8GB 内存
  • Canonical,Ubuntu,22.04 LTS,amd64 jammy
  • 1GiB SSD Volume Type

Node.js内部基准测试

在这个基准测试中选择了以下模块/命名空间:

  • fs - Node.js 文件系统
  • events - Node.js 事件类 EventEmitter / EventTarget
  • http - Node.js HTTP服务器 + 解析器
  • misc - 使用 child_processes 和 worker_threads + trace_events 的 Node.js 启动时间
  • module - Node.js 模块.require
  • streams - Node.js 流的创建、销毁、可读等
  • url - Node.js URL 解析器
  • buffers - Node.js 缓冲区操作
  • util - Node.js 文本编码器/解码器

使用的配置可在 RafaelGSS/node#state-of-nodejs[6] 找到,所有结果都已发布在主存储库:State of Node.js Performance 2023。

Node.js 基准测试方法

在呈现结果之前,解释一下用于确定基准测试结果置信度的统计方法是至关重要的。这个方法已经在先前的博客文章中详细解释过,你可以在这里查看:准备和评估基准测试[7]

为了比较新的 Node.js 版本的影响,我们在每个配置和 Node.js 16、18 和 20 上多次运行了每个基准测试(30次)。当输出显示为表格时,有两列需要特别注意:

  • improvement - 相对于新版本的改进百分比
  • confidence - 告诉我们是否有足够的统计证据来验证改进

例如,考虑以下表格结果:

                                                                              confidence improvement accuracy (*)   (**)  (***)
fs/readfile.js concurrent=1 len=16777216 encoding='ascii' duration=5                 ***     67.59 %       ±3.80% ±5.12% ±6.79%
fs/readfile.js concurrent=1 len=16777216 encoding='utf-8' duration=5                 ***     11.97 %       ±1.09% ±1.46% ±1.93%
fs/writefile-promises.js concurrent=1 size=1024 encodingType='utf' duration=5                 0.36 %       ±0.56% ±0.75% ±0.97%

Be aware that when doing many comparisons the risk of a false-positive result increases.
In this case, there are 10 comparisons, you can thus expect the following amount of false-positive results:
  0.50 false positives, when considering a   5% risk acceptance (*, **, ***),
  0.10 false positives, when considering a   1% risk acceptance (**, ***),
  0.01 false positives, when considering a 0.1% risk acceptance (***)

有 0.1% 的风险,即从 Node.js 16 到 Node.js 18,fs.readfile 没有改进(置信度***)。因此,我们对结果相当有信心。表格结构可解读为:

  • fs/readfile.js - 基准测试文件
  • concurrent=1 len=16777216 encoding='ascii' duration=5 - 基准测试选项。每个基准测试文件可以有许多选项,在这种情况下,它使用ASCII编码,在5秒内读取1个并发文件,每个文件大小为16777216字节。
对于统计学上感兴趣的人,该脚本执行一个独立/不配对的双组t检验[8],零假设是两个版本的性能相同。如果 p 值小于 0.05,则置信度字段将显示星号。 — 编写和运行基准测试[9]

基准测试设置

  • 克隆 Node.js 仓库的分支
  • 切换到 state-of-nodejs 分支
  • 创建 Node.js 16、18 和 20 的二进制文件
  • 运行 benchmark.sh 脚本
#1
git clone git@github.com:RafaelGSS/node.git
#2
cd node && git checkout state-of-nodejs
#3
nvm install v20.0.0
cp $(which node) ./node20
nvm install v18.16.0
cp $(which node) ./node18
nvm install v16.20.0
cp $(which node) ./node16
#4
./benchmark.sh

文件系统

将 Node.js 从 16 升级到 18 时,使用 fs.readfile API 进行 ascii 编码时,观察到了 67% 的改进,而使用 utf-8 时大约有 12% 的改进。

readfile 比较 v16 和 v18

基准测试结果显示,将 Node.js 从版本 16 升级到 18 时,使用 fs.readfile API 进行 ascii 编码的改进约为 67%,而使用 utf-8 时大约有 12% 的改进。用于基准测试的文件是使用以下代码片段创建的:

const data = Buffer.alloc(16 * 1024 * 1024, 'x');
fs.writeFileSync(filename, data);

然而,在 Node.js 20 上,使用 ascii 进行 fs.readfile 时出现了 27% 的退化。这个退化已经报告给了 Node.js 性能团队,并且预计会得到修复。另一方面,fs.opendir、fs.realpath 和 fs.readdir 在从 Node.js 18 到 Node.js 20 时都表现出改进。以下是 Node.js 18 和 20 之间的比较基准测试结果:

                                                                              confidence improvement accuracy (*)   (**)  (***)
fs/bench-opendir.js bufferSize=1024 mode='async' dir='test/parallel' n=100           ***      3.48 %       ±0.22% ±0.30% ±0.39%
fs/bench-opendir.js bufferSize=32 mode='async' dir='test/parallel' n=100             ***      7.86 %       ±0.29% ±0.39% ±0.50%
fs/bench-readdir.js withFileTypes='false' dir='test/parallel' n=10                   ***      8.69 %       ±0.22% ±0.30% ±0.39%
fs/bench-realpath.js pathType='relative' n=10000                                     ***      5.13 %       ±0.97% ±1.29% ±1.69%
fs/readfile.js concurrent=1 len=16777216 encoding='ascii' duration=5                 ***    -27.30 %       ±4.27% ±5.75% ±7.63%
fs/readfile.js concurrent=1 len=16777216 encoding='utf-8' duration=5                 ***      3.25 %       ±0.61% ±0.81% ±1.06%

  0.10 false positives, when considering a   5% risk acceptance (*, **, ***),
  0.02 false positives, when considering a   1% risk acceptance (**, ***),
  0.00 false positives, when considering a 0.1% risk acceptance (***)

如果你正在使用 Node.js 16,你可以使用以下对比结果来比较 Node.js 16 和 Node.js 20。

                                                                              confidence improvement accuracy (*)    (**)   (***)
fs/bench-opendir.js bufferSize=1024 mode='async' dir='test/parallel' n=100           ***      2.79 %       ±0.26%  ±0.35%  ±0.46%
fs/bench-opendir.js bufferSize=32 mode='async' dir='test/parallel' n=100             ***      5.41 %       ±0.27%  ±0.35%  ±0.46%
fs/bench-readdir.js withFileTypes='false' dir='test/parallel' n=10                   ***      2.19 %       ±0.26%  ±0.35%  ±0.45%
fs/bench-realpath.js pathType='relative' n=10000                                     ***      6.86 %       ±0.94%  ±1.26%  ±1.64%
fs/readfile.js concurrent=1 len=16777216 encoding='ascii' duration=5                 ***     21.96 %       ±7.96% ±10.63% ±13.92%
fs/readfile.js concurrent=1 len=16777216 encoding='utf-8' duration=5                 ***     15.55 %       ±1.09%  ±1.46%  ±1.92%

Events

在事件方面,EventTarget 类显示出最显著的改进。该基准测试涉及使用EventTarget.prototype.dispatchEvent(new Event('foo')) 分派一百万个事件。

从 Node.js 16 升级到 Node.js 18 可以在事件分派性能上获得近 15% 的改进。但真正的飞跃发生在从 Node.js 18 升级到 Node.js 20 时,当只有一个侦听器时,性能可以提高高达 200%。

EventTarget 类是 Web API 的关键组件,被用于各种父特性,如 AbortSignal 和 worker_threads。因此,对该类的优化可能会对这些特性的性能产生影响,包括 fetch 和 AbortController。此外,与 Node.js 16 相比,EventEmitter.prototype.emit API 在与 Node.js 20 相比也实现了约 11.5% 的显著改进。以下是供您参考的综合比较:

                                                                 confidence improvement accuracy (*)   (**)  (***)
events/ee-emit.js listeners=5 argc=2 n=2000000                          ***     11.49 %       ±1.37% ±1.83% ±2.38%
events/ee-once.js argc=0 n=20000000                                     ***     -4.35 %       ±0.47% ±0.62% ±0.81%
events/eventtarget-add-remove.js nListener=10 n=1000000                 ***      3.80 %       ±0.83% ±1.11% ±1.46%
events/eventtarget-add-remove.js nListener=5 n=1000000                  ***      6.41 %       ±1.54% ±2.05% ±2.67%
events/eventtarget.js listeners=1 n=1000000                             ***    259.34 %       ±2.83% ±3.81% ±5.05%
events/eventtarget.js listeners=10 n=1000000                            ***    176.98 %       ±1.97% ±2.65% ±3.52%
events/eventtarget.js listeners=5 n=1000000                             ***    219.14 %       ±2.20% ±2.97% ±3.94%

HTTP

HTTP 服务器是 Node.js 中改进最显著的。毫不夸张地说,如今大多数 Node.js 应用程序都运行着一个 HTTP 服务器。因此,任何变化都可以轻松地被视为一个 semver-major,并增加对兼容性性能改进的努力。

因此,所使用的 HTTP 服务器是一个 http.Server,每个请求都回复包含 4 个 256 字节的块,每个块都包含 ‘C’,如下所示:

http.createServer((req, res) => {
    const n_chunks = 4;
    const body = 'C'.repeat();
    const len = body.length;
  res.writeHead(200, {
    'Content-Type': 'text/plain',
      'Content-Length': len.toString()
  });
    for (i = 0, n = (n_chunks - 1); i < n; ++i)
      res.write(body.slice(i * step, i * step + step));
    res.end(body.slice((n_chunks - 1) * step));
})
// See: https://github.com/nodejs/node/blob/main/benchmark/fixtures/simple-http-server.js

在比较 Node.js 16 和 Node.js 18 的性能时,可以观察到 8% 的显著改进。然而,从 Node.js 18 升级到 Node.js 20 导致了 96.13% 的显著改进。

这些基准测试结果是使用 test-double-http 的基准测试方法收集的。这是一个简单的 Node.js 脚本,用于发送 HTTP GET 请求:

function run() {
  if (http.get) { // HTTP or HTTPS
    if (options) {
      http.get(url, options, request);
    } else {
      http.get(url, request);
    }
  } else { // HTTP/2
    const client = http.connect(url);
    client.on('error', () => {});
    request(client.request(), client);
  }
}

run();

通过切换到更可靠的基准测试工具,如 autocannon 或 wrk,我们观察到报告的改进显著下降 — 从 96% 降至 9%。这表明先前的基准测试方法存在限制或错误[10]。然而,HTTP 服务器的实际性能已经提高,我们需要使用新的基准测试方法仔细评估改进的百分比,以准确评估所取得的进展。

那么,我是否应该期望在我的 Express/Fastify 应用程序中获得 96%/9% 的性能改进?

绝对不应该。框架可能选择不使用内部 HTTP API — 这就是 Fastify 之所以快的原因之一!因此,这份报告中考虑了另一个基准测试套件(3. HTTP Servers)。

其他

根据我们的测试,startup.js 脚本在 Node.js 进程生命周期中表现出显著的改进,从 Node.js 版本 18 到版本 20 观察到了 27% 的提升。当与 Node.js 版本 16 相比时,这种改进更为引人注目,启动时间减少了 34.75%!

随着现代应用越来越依赖于无服务器系统,减少启动时间已经成为提高整体性能的关键因素。值得注意的是,Node.js 团队一直在努力优化平台的这个方面,这也得到了我们的战略倡议的证明:https://github.com/nodejs/node/issues/35711。

这些启动时间的改进不仅有益于无服务器应用程序,而且还增强了依赖于快速启动时间的其他 Node.js 应用程序的性能。总体而言,这些更新展示了 Node.js 团队提升平台速度和效率的承诺,使其受益于所有用户。

$ node-benchmark-compare compare-misc-16-18.csv
                                                                                     confidence improvement accuracy (*)   (**)  (***)
misc/startup.js count=30 mode='process' script='benchmark/fixtures/require-builtins'        ***     12.99 %       ±0.14% ±0.19% ±0.25%
misc/startup.js count=30 mode='process' script='test/fixtures/semicolon'                    ***      5.88 %       ±0.15% ±0.20% ±0.26%
misc/startup.js count=30 mode='worker' script='benchmark/fixtures/require-builtins'         ***      5.26 %       ±0.14% ±0.19% ±0.25%
misc/startup.js count=30 mode='worker' script='test/fixtures/semicolon'                     ***      3.84 %       ±0.15% ±0.21% ±0.27%

$ node-benchmark-compare compare-misc-18-20.csv
                                                                                     confidence improvement accuracy (*)   (**)  (***)
misc/startup.js count=30 mode='process' script='benchmark/fixtures/require-builtins'        ***     -4.80 %       ±0.13% ±0.18% ±0.23%
misc/startup.js count=30 mode='process' script='test/fixtures/semicolon'                    ***     27.27 %       ±0.22% ±0.29% ±0.38%
misc/startup.js count=30 mode='worker' script='benchmark/fixtures/require-builtins'         ***      7.23 %       ±0.21% ±0.28% ±0.37%
misc/startup.js count=30 mode='worker' script='test/fixtures/semicolon'                     ***     31.26 %       ±0.33% ±0.44% ±0.58%

这个基准测试非常直观。我们测量了使用给定的 [script] 创建一个新的 [mode] 所经过的时间,其中 [mode] 可以是:

  • process - 一个新的 Node.js 进程
  • worker - 一个 Node.js worker_thread

而[script]则分为:

  • benchmark/fixtures/require-builtins - 一个需要所有Node.js模块的脚本
  • test/fixtures/semicolon - 一个空脚本,只包含一个;(分号) 这个实验可以使用 hyperfine 或 time 轻松复现:
$ hyperfine --warmup 3 './node16 ./nodejs-internal-benchmark/semicolon.js'
Benchmark 1: ./node16 ./nodejs-internal-benchmark/semicolon.js
  Time (mean ± σ):      24.7 ms ±   0.3 ms    [User: 19.7 ms, System: 5.2 ms]
  Range (min … max):    24.1 ms …  25.6 ms    121 runs

$ hyperfine --warmup 3 './node18 ./nodejs-internal-benchmark/semicolon.js'
Benchmark 1: ./node18 ./nodejs-internal-benchmark/semicolon.js
  Time (mean ± σ):      24.1 ms ±   0.3 ms    [User: 18.1 ms, System: 6.3 ms]
  Range (min … max):    23.6 ms …  25.3 ms    123 runs

$ hyperfine --warmup 3 './node20 ./nodejs-internal-benchmark/semicolon.js'
Benchmark 1: ./node20 ./nodejs-internal-benchmark/semicolon.js
  Time (mean ± σ):      18.4 ms ±   0.3 ms    [User: 13.0 ms, System: 5.9 ms]
  Range (min … max):    18.0 ms …  19.7 ms    160 runs
预热是必要的,以考虑文件系统缓存的影响。

trace_events 模块在性能方面也经历了显著的提升,将 Node.js 版本 16 与版本 20 进行比较时观察到了7%的改进。值得注意的是,当将 Node.js 版本 18 与版本 20 进行比较时,这一改进略低,为 2.39%。

Module(模块)

require()(或module.require)长期以来一直是导致 Node.js 启动时间缓慢的罪魁祸首。然而,最近的性能改进表明,这个函数也经过了优化。在 Node.js 版本 18 和 20 之间,我们观察到在 requiring .js 文件时改进了 4.20%,在 requiring .json 文件时改进了 6.58%,在读取目录时改进了 9.50% — 所有这些都有助于更快的启动时间。

优化 require() 是至关重要的,因为这是在 Node.js 应用程序中广泛使用的函数。通过减少这个函数执行所需的时间,我们可以显著加快整个启动过程并改善用户体验。

Streams(流)

流是 Node.js 一个非常强大且广泛使用的功能。然而,在 Node.js 版本 16 和 18 之间,与流相关的一些操作变得更慢。这包括创建和销毁 Duplex、Readable、Transform 和 Writable 流,以及从 Readable → Writable 流的 .pipe() 方法。

下面的图表说明了这种退化:

然而,在 Node.js 20 中,这个 .pipe() 的退化被减少了:

$ node-benchmark-compare compare-streams-18-20.csv
                                                       confidence improvement accuracy (*)   (**)  (***)
streams/creation.js kind='duplex' n=50000000                  ***     12.76 %       ±4.30% ±5.73% ±7.47%
streams/creation.js kind='readable' n=50000000                ***      3.48 %       ±1.16% ±1.55% ±2.05%
streams/creation.js kind='transform' n=50000000                **     -7.59 %       ±5.27% ±7.02% ±9.16%
streams/creation.js kind='writable' n=50000000                ***      4.20 %       ±0.87% ±1.16% ±1.53%
streams/destroy.js kind='duplex' n=1000000                    ***     -6.33 %       ±1.08% ±1.43% ±1.87%
streams/destroy.js kind='readable' n=1000000                  ***     -1.94 %       ±0.70% ±0.93% ±1.21%
streams/destroy.js kind='transform' n=1000000                 ***     -7.44 %       ±0.93% ±1.24% ±1.62%
streams/destroy.js kind='writable' n=1000000                           0.20 %       ±1.89% ±2.52% ±3.29%
streams/pipe.js n=5000000                                     ***     87.18 %       ±2.58% ±3.46% ±4.56%

正如你可能已经注意到的,某些类型的流(特别是 Transform)在 Node.js 20 中出现了退化。因此,Node.js 16 在这个具体的基准测试中仍然拥有最快的流 — 请不要将此基准测试结果解读为'Node.js 18和20中的流如此之慢!',这是一个可能或可能不影响你的工作负载的具体基准测试。例如,如果你在 nodejs-bench-operations 中进行一个简单的比较,你会发现以下代码片段在 Node.js 20 上的性能比其前身更好:

suite.add('streams.Writable writing 1e3 * "some data"', function () {
  const writable = new Writable({
    write (chunk, enc, cb) {
      cb()
    }
  })

  let i = 0
  while(i < 1e3) {
    writable.write('some data')
    ++i
  }
})

事实上,实例化和销毁方法在 Node.js 生态系统中扮演着重要的角色。因此,它很可能对一些库产生负面影响。然而,这种退化在 Node.js 性能工作组[11] 中受到密切关注[12]

请注意,Readable 异步迭代器在 Node.js 20 上变得稍微更快(约6.14%)。

URL

自从 Node.js 18 以来,Node.js 添加了一个新的 URL 解析器依赖项 — Ada[13]。这个添加将 Node.js 解析 URL 的性能提升到了一个新水平。一些结果的改进可达到 400%。作为普通用户,你可能不会直接使用它。但如果你使用HTTP服务器,那么你很可能会受到这种性能改进的影响。

URL 基准测试套件非常庞大。因此,只会涵盖 WHATWG URL 基准测试的结果。

url.parse()url.resolve() 都已被弃用,是传统的 API。尽管使用它们被认为对于任何 Node.js 应用程序都存在风险,开发人员仍在使用它们。引用Node.js文档:

url.parse() 使用一种宽松的、非标准的算法来解析 URL 字符串。它容易出现安全问题,如主机名欺骗和用户名密码的处理不正确。不要在不受信任的输入上使用。不会为 url.parse() 的漏洞发布 CVE。请改用 WHATWG URL API[14]

如果你对 url.parse 和 url.resolve 的性能变化感兴趣,请查看 State of Node.js Performance 2023[15] 仓库。

话虽如此,看到新的 whatwg-url-parse 的结果真的很有趣:

下面是用于基准测试的 URL 列表,这些 URL 是基于基准配置选择的:

const urls = {
  long: 'http://nodejs.org:89/docs/latest/api/foo/bar/qua/13949281/0f28b/' +
        '/5d49/b3020/url.html#test?payload1=true&payload2=false&test=1' +
        '&benchmark=3&foo=38.38.011.293&bar=1234834910480&test=19299&3992&' +
        'key=f5c65e1e98fe07e648249ad41e1cfdb0',
  short: 'https://nodejs.org/en/blog/',
  idn: 'http://你好你好.在线',
  auth: 'https://user:pass@example.com/path?search=1',
  file: 'file:///foo/bar/test/node.js',
  ws: 'ws://localhost:9229/f46db715-70df-43ad-a359-7f9949f39868',
  javascript: 'javascript:alert("node is awesome");',
  percent: 'https://%E4%BD%A0/foo',
  dot: 'https://example.org/./a/../b/./c',
}

随着 Node.js 20 中 Ada 2.0 的最新升级,可以公正地说,与 Node.js 18 相比,Node.js 20 也取得了显著的改进:

而且基准测试文件非常简单:

function useWHATWGWithoutBase(data) {
  const len = data.length;
  let result = new URL(data[0]);  // Avoid dead code elimination
  bench.start();
  for (let i = 0; i < len; ++i) {
    result = new URL(data[i]);
  }
  bench.end(len);
  return result;
}

function useWHATWGWithBase(data) {
  const len = data.length;
  let result = new URL(data[0][0], data[0][1]);  // Avoid dead code elimination
  bench.start();
  for (let i = 0; i < len; ++i) {
    const item = data[i];
    result = new URL(item[0], item[1]);
  }
  bench.end(len);
  return result;
}

唯一的区别是用作创建/解析 URL 时的第二个参数,它被用作基础。值得一提的是,当传递基础时(withBase='true'),它往往比常规用法(new URL(data))更快。在主要仓库[16] 中查看所有结果的详细信息。

Buffers(缓冲区)

在 Node.js 中,缓冲区用于处理二进制数据。缓冲区是一个内置数据结构,可用于在内存中存储原始二进制数据,当处理网络协议、文件系统操作或其他底层操作时非常有用。总体而言,缓冲区是 Node.js 的重要组成部分,在整个平台中广泛用于处理二进制数据。

对于那些直接或间接使用 Node.js 缓冲区的人来说,我有一个好消息(主要是给 Node.js 20 的早期采用者)。

除了改进 Buffer.from() 的性能之外,Node.js 20 还修复了 Node.js 18 中的两个主要回退:

  • Buffer.concat()

与版本 18 相比,Node.js 版本 20 在性能上取得了显著改进,而即使与版本 16 相比,这些改进仍然显而易见:

  • Buffer.toJSON()

从 Node.js 16 到 Node.js 18,Buffer.toJSON 的性能下降了 88%:

$ node-benchmark-compare compare-buffers-16-18.csv
                                                                            confidence improvement accuracy (*)    (**)   (***)
buffers/buffer-tojson.js len=256 n=10000                                           ***    -81.12 %       ±1.25%  ±1.69%  ±2.24%
buffers/buffer-tojson.js len=4096 n=10000                                          ***    -88.39 %       ±0.69%  ±0.93%  ±1.23%

但是,这种回退在 Node.js 20 中得到了数量级的修复和改进:

$ node-benchmark-compare compare-buffers-18-20.csv
                                                                            confidence improvement accuracy (*)    (**)   (***)
buffers/buffer-tojson.js len=256 n=10000                                           ***    482.81 %       ±7.02% ±9.42% ±12.42%
buffers/buffer-tojson.js len=4096 n=10000                                          ***    763.34 %       ±5.22% ±7.04%  ±9.34%

因此,可以肯定地说,Node.js 20 是处理缓冲区时最快的 Node.js 版本。

查看 Node.js 20 和 Node.js 18 之间的完整比较:

$ node-benchmark-compare compare-buffers-18-20.csv
                                                                            confidence improvement accuracy (*)   (**)   (***)
buffers/buffer-base64-decode.js size=8388608 n=32                                  ***      1.66 %       ±0.10% ±0.14%  ±0.18%
buffers/buffer-base64-encode.js n=32 len=67108864                                  ***     -0.44 %       ±0.17% ±0.23%  ±0.30%
buffers/buffer-compare.js n=1000000 size=16                                        ***     -3.14 %       ±0.82% ±1.09%  ±1.41%
buffers/buffer-compare.js n=1000000 size=16386                                     ***    -15.56 %       ±5.97% ±7.95% ±10.35%
buffers/buffer-compare.js n=1000000 size=4096                                              -2.63 %       ±3.09% ±4.11%  ±5.35%
buffers/buffer-compare.js n=1000000 size=512                                       ***     -6.15 %       ±1.28% ±1.71%  ±2.24%
buffers/buffer-concat.js n=800000 withTotalLength=0 pieceSize=1 pieces=16          ***    300.67 %       ±0.71% ±0.95%  ±1.24%
buffers/buffer-concat.js n=800000 withTotalLength=0 pieceSize=1 pieces=4           ***    212.56 %       ±4.81% ±6.47%  ±8.58%
buffers/buffer-concat.js n=800000 withTotalLength=0 pieceSize=16 pieces=16         ***    287.63 %       ±2.47% ±3.32%  ±4.40%
buffers/buffer-concat.js n=800000 withTotalLength=0 pieceSize=16 pieces=4          ***    216.54 %       ±1.24% ±1.66%  ±2.17%
buffers/buffer-concat.js n=800000 withTotalLength=0 pieceSize=256 pieces=16        ***     38.44 %       ±1.04% ±1.38%  ±1.80%
buffers/buffer-concat.js n=800000 withTotalLength=0 pieceSize=256 pieces=4         ***     91.52 %       ±3.26% ±4.38%  ±5.80%
buffers/buffer-concat.js n=800000 withTotalLength=1 pieceSize=1 pieces=16          ***    192.63 %       ±0.56% ±0.74%  ±0.97%
buffers/buffer-concat.js n=800000 withTotalLength=1 pieceSize=1 pieces=4           ***    157.80 %       ±1.52% ±2.02%  ±2.64%
buffers/buffer-concat.js n=800000 withTotalLength=1 pieceSize=16 pieces=16         ***    188.71 %       ±2.33% ±3.12%  ±4.10%
buffers/buffer-concat.js n=800000 withTotalLength=1 pieceSize=16 pieces=4          ***    151.18 %       ±1.13% ±1.50%  ±1.96%
buffers/buffer-concat.js n=800000 withTotalLength=1 pieceSize=256 pieces=16        ***     20.83 %       ±1.29% ±1.72%  ±2.25%
buffers/buffer-concat.js n=800000 withTotalLength=1 pieceSize=256 pieces=4         ***     59.13 %       ±3.18% ±4.28%  ±5.65%
buffers/buffer-from.js n=800000 len=100 source='array'                             ***      3.91 %       ±0.50% ±0.66%  ±0.87%
buffers/buffer-from.js n=800000 len=100 source='arraybuffer-middle'                ***     11.94 %       ±0.65% ±0.86%  ±1.13%
buffers/buffer-from.js n=800000 len=100 source='arraybuffer'                       ***     12.49 %       ±0.77% ±1.03%  ±1.36%
buffers/buffer-from.js n=800000 len=100 source='buffer'                            ***      7.46 %       ±1.21% ±1.62%  ±2.12%
buffers/buffer-from.js n=800000 len=100 source='object'                            ***     12.70 %       ±0.84% ±1.12%  ±1.47%
buffers/buffer-from.js n=800000 len=100 source='string-base64'                     ***      2.91 %       ±1.40% ±1.88%  ±2.46%
buffers/buffer-from.js n=800000 len=100 source='string-utf8'                       ***     12.97 %       ±0.77% ±1.02%  ±1.33%
buffers/buffer-from.js n=800000 len=100 source='string'                            ***     16.61 %       ±0.71% ±0.95%  ±1.25%
buffers/buffer-from.js n=800000 len=100 source='uint16array'                       ***      5.64 %       ±0.84% ±1.13%  ±1.48%
buffers/buffer-from.js n=800000 len=100 source='uint8array'                        ***      6.75 %       ±0.95% ±1.28%  ±1.68%
buffers/buffer-from.js n=800000 len=2048 source='array'                                     0.03 %       ±0.33% ±0.43%  ±0.56%
buffers/buffer-from.js n=800000 len=2048 source='arraybuffer-middle'               ***     11.73 %       ±0.55% ±0.74%  ±0.96%
buffers/buffer-from.js n=800000 len=2048 source='arraybuffer'                      ***     12.85 %       ±0.55% ±0.73%  ±0.96%
buffers/buffer-from.js n=800000 len=2048 source='buffer'                           ***      7.66 %       ±1.28% ±1.70%  ±2.21%
buffers/buffer-from.js n=800000 len=2048 source='object'                           ***     11.96 %       ±0.90% ±1.20%  ±1.57%
buffers/buffer-from.js n=800000 len=2048 source='string-base64'                    ***      4.10 %       ±0.46% ±0.61%  ±0.79%
buffers/buffer-from.js n=800000 len=2048 source='string-utf8'                      ***     -1.30 %       ±0.71% ±0.96%  ±1.27%
buffers/buffer-from.js n=800000 len=2048 source='string'                           ***     -2.23 %       ±0.93% ±1.25%  ±1.64%
buffers/buffer-from.js n=800000 len=2048 source='uint16array'                      ***      6.89 %       ±1.44% ±1.91%  ±2.49%
buffers/buffer-from.js n=800000 len=2048 source='uint8array'                       ***      7.74 %       ±1.36% ±1.81%  ±2.37%
buffers/buffer-tojson.js len=0 n=10000                                             ***    -11.63 %       ±2.34% ±3.11%  ±4.06%
buffers/buffer-tojson.js len=256 n=10000                                           ***    482.81 %       ±7.02% ±9.42% ±12.42%
buffers/buffer-tojson.js len=4096 n=10000                                          ***    763.34 %       ±5.22% ±7.04%  ±9.34%

文本编码和解码

TextDecoder 和 TextEncoder 是 Web API 规范的两个 JavaScript 类,在现代 Web 浏览器和 Node.js 中可用。TextDecoder 和 TextEncoder 一起为 JavaScript 中的文本数据提供了一种简单而高效的处理方式,使开发人员能够执行涉及字符串和字符编码的各种操作。

在 Node.js 18 中,解码和编码的速度显著提高。通过添加 simdutf 用于 UTF-8 解析,与 Node.js 16 相比,解码的观察基准结果提高了 364%(非常令人印象深刻的飞跃)。

这些改进在 Node.js 20 上进一步提升,与 Node.js 18 相比,性能提高了 25%。在 state-of-nodejs-performance-2023[17] 存储库中查看完整结果。

在比较 Node.js 18 上的编码方法时,还观察到了性能的提升。从 Node.js 16 到 Node.js 18,TextEncoder.encodeInto 在当前观察中(使用长度为256的 ascii 字符串)达到了 93.67% 的改进:

Node.js Bench Operations

Node.js 中的基准测试操作总是引起我的好奇心。作为一个喜欢探索 Node.js 及其底层技术细节的人,我发现深入了解这些操作的细节非常有趣,特别是与 V8 引擎相关的操作。事实上,我经常通过 NearForm(我隶属的公司)提供的演讲和研讨会与其他人分享我对这一主题的发现。如果你感兴趣,可以通过点击这个链接[18]找到我关于这个主题的演讲的更多信息。

此外,这些基准测试将使用 ops/sec 度量单位,它基本上表示在一秒钟内执行的操作次数。重要的是要强调,这只能占用你计算时间的极小一部分。如果你阅读过我的先前文章(准备和评估基准测试[19]),你应该记得 “评估结果” 部分,其中我用 ops/sec 在实际应用中处理了这个问题 - 如果没有,你应该考虑返回查看。

解析整数

将字符串解析为数字可以使用 + 或 parseInt(x, 10) 来完成。先前的基准测试结果显示,在 Node.js 的早期版本中,使用 + 比 parseInt(x, 10) 更快,如下表所示:

Source[20]

然而,随着 Node.js 2 0和新的 V8 版本(11.4)的发布,这两种操作在性能上变得相等,如下面更新的基准测试结果所示:

Source[21]

Super vs This

在 Node.js 20 中新增的一个有趣的基准测试是在类中使用 this 还是 super,如下面的示例所示:

class Base {
  foo () {
    return 10 * 1e2
  }
}

class SuperClass extends Base {
  bar () {
    const tmp = 20 * 23
    return super.foo() + tmp
  }
}

class ThisClass extends Base {
  bar () {
    const tmp = 20 * 23
    return this.foo() + tmp
  }
}

在 Node.js 18 中,使用 super 和 this 的每秒操作数(ops/sec)如下:

Source[22]

在两种方法之间并没有显著的差异,但在 Node.js 20 中,情况略有不同:

Source[23]

根据基准测试结果,似乎在 Node.js 20 中使用 this 的性能显著提高,相较于 Node.js 18,这个提高非常显著,使用 this 在 Node.js 20 上达到了惊人的 853,619,840 ops/sec,而在 Node.js 18 上仅为 160,092,440 ops/sec,提高了 433%!显然,它具有与常规对象相同的属性访问方法:obj.property1。此外,请注意,两种操作在相同的专用环境中进行了测试,因此不太可能是偶然发生的。

Property Access

在 JavaScript 中,有多种向对象添加属性的方法,每种方法都有其自身的目的,有时具有歧义的特性。作为开发者,您可能想知道每种方法中属性访问的效率。

好消息是,nodejs-bench-operations 存储库包含了这些方法的比较,揭示了它们的性能特性。事实上,这些基准测试数据显示,在 Node.js 20 中,特别是在使用具有 writable: trueenumerable/configurable: false 属性的对象时,属性访问得到了显著的改进。

const myObj = {};

Object.defineProperty(myObj, 'test', {
  writable: true,
  value: 'Hello',
  enumerable: false,
  configurable: false,
});

在 Node.js 18 中,属性访问(myObj.test)的操作数为 166,422,265 ops/sec。然而,在相同的环境下,Node.js 20 产生了 857,316,403 ops/sec! 关于属性访问的这一点以及其他细节可以在以下基准测试结果中找到:

  • Property getter access v18[24] / v20[25]
  • Property setter access v18[26] / v20[27]
  • Property access after shape transition v18[28] / v20[29]

Array.prototype.at

Array.prototype.at(-1) 是在 ECMAScript 2021 规范中引入的一种方法。它允许您访问数组的最后一个元素,而无需知道其长度或使用负索引,这在某些情况下可能是一种有用的功能。通过这种方式,与传统的方法如 array[array.length - 1] 相比,at() 方法提供了一种更简洁和可读的方式来访问数组的最后一个元素。

在 Node.js 18 中,与 Array[length-1] 相比,这种访问方式相对较慢:

Source[30]

自从 Node.js 19 以来,Array.prototype.at 等同于老式的 Array[length-1],如下表所示:

String.prototype.includes

大多数人都知道 RegExp 通常是任何应用程序的性能瓶颈之一。例如,您可能想要检查某个变量是否包含 application/json。虽然您可以用多种方式来实现,但大多数情况下您最终会使用:

/application\/json/.test(text) - 正则表达式

text.includes('application/json') - String.prototype.includes

但您可能不知道的是,在 Node.js 16 上,String.prototype.includes 几乎与 RegExp 一样慢。

Sourc[31]

然而,自从 Node.js 18 以来,这种行为已经得到了修复。

Source[32]

Crypto.verify

在 Node.js 中,crypto 模块提供了一组用于各种目的的加密功能,例如创建和验证数字签名、加密和解密数据以及生成安全随机数。该模块中提供的方法之一是 crypto.verify(),用于验证由crypto.sign() 方法生成的数字签名。

Node.js 14(已停止维护)使用的是 OpenSSL 1.x。在 Node.js 16中,我们添加了 QUIC 协议,但仍然使用 OpenSSL 版本 1。然而,在 Node.js 18 中,我们将 OpenSSL 更新到了 3.x 版本(覆盖 QUIC),并且在 Node.js 18 之后发现了一个回归,将 ops/sec 从 30,000 降低到了 6,000~7,000。正如我在推文中提到的,很可能是由新的OpenSSL版本引起的。再次强调,我们的团队正在调查此问题,如果您对此有任何见解,请随时在此问题上发表评论:https://github.com/nodejs/performance/issues/72。

Node.js 性能倡议

Node.js 团队一直致力于确保其 API 和核心功能在速度和资源使用方面得到优化。

为了进一步提升 Node.js 的性能,团队最近推出了一个名为 “性能(Performance)” 的新战略倡议,由 Yagiz Nizipli 主持。该倡议旨在识别和解决 Node.js 运行时和核心模块中的性能瓶颈,并提高平台的整体性能和可扩展性。

除了性能倡议之外,目前还有几个正在进行的其他倡议,专注于优化 Node.js 的不同方面。其中之一是 “启动快照” 倡议,由 Joyee 主持。该倡议旨在减少 Node.js 应用程序的启动时间,这是提高 Web 应用程序的整体性能和用户体验的关键因素。

因此,如果您对这个主题感兴趣,请考虑每两周参加一次会议,并随时在 OpenJS Foundation Slack 的 #nodejs-core-performance 频道中发送消息。

值得关注的事项

除了战略倡议之外,有一些拉取请求很可能对 Node.js 性能产生巨大影响——目前我正在编写以下帖子时尚未合并:

  • Node.js Errors - https://github.com/nodejs/node/pull/46648

在 Node.js 中创建错误非常昂贵。在Node.js应用程序中,这很容易成为性能瓶颈的来源。例如,我对 Node.js 中 fetch 的实现(undici)进行了研究,并发现 Node.js WebStreams 实现中错误对象是一个问题。因此,通过优化 Node.js 中的错误对象,我们可以提高平台的整体效率并降低性能瓶颈的风险。

  • 指针压缩构建 - https://github.com/nodejs/build/issues/3204

指针压缩是一种计算机编程中的技术,用于减少使用许多指针的程序的内存使用。虽然它不能直接提高性能,但通过减少缓存未命中和页面错误,它可以间接提高性能。这当然可以减少一些基础设施成本,正如问题线程中所描述的那样。

  • 增加默认 --max-semi-space-size - https://github.com/nodejs/node/pull/47277

在 2022 年 3 月创建了一个问题,建议增加 V8 max_semi_space_size,目标是减少垃圾回收(特别是 Scavenge)运行,并提高 Web 工具基准中的整体吞吐量。我们仍在评估其影响,可能会在 Node.js 21 中到达或不到达。

  • 提高 Node.js Readable/Writable 流上的默认 highWaterMark 值 - https://github.com/nodejs/node/pull/46608

这个 PR 增加了 Node.js 流中 highWaterMark 值的默认值。预计在使用默认选项的 Node.js 流中,会感知到性能的提升。然而,此 PR 是一个语义版本的主要更改,应该会在 Node.js 21 中到达。有关详细的基准结果,请等待 “Node.js性能现状2023 - P2”。

结论

尽管在 Node.js 流和加密模块中存在一些退化,但与先前版本相比,Node.js 20 在性能方面取得了显著的改进。在 JavaScript 操作中观察到了一些显著的增强,如属性访问、URL解析、缓冲区/文本编码和解码、启动/进程生命周期时间以及 EventTarget 等。

Node.js 性能团队(nodejs/performance[33])已经扩大了其范围,在每个新版本中对性能进行了更多的优化贡献。这一趋势表明,Node.js 将会随着时间的推移变得更加快速。

值得一提的是,基准测试侧重于特定操作,这些操作可能会或可能不会直接影响您的特定用例。因此,我强烈建议查看 state-of-nodejs-performance[34] 存储库中的所有基准测试结果,并确保这些操作与您的业务需求一致。

致谢

我要对所有花时间提供宝贵反馈的审阅者表示真诚的感谢。感谢你们的时间、专业知识和建设性的评论。

  • Vinicius Lourenço
  • Yagiz Nizipli
  • Debadree Chatterjee
  • Igor Savin
  • Paolo Insogna

参考资料

[1] Node.js v20: https://nodejs.org/en/blog/release/v20.0.0

[2] Node.js 源代码: https://github.com/nodejs/node/tree/main/benchmark

[3] nodejs-bench-operations: https://github.com/RafaelGSS/nodejs-bench-operations

[4] Twitter: https://twitter.com/_rafaelgss

[5] LinkedIn: https://www.linkedin.com/in/rafaelgss/

[6] RafaelGSS/node#state-of-nodejs: https://github.com/RafaelGSS/node/tree/state-of-nodejs

[7] 准备和评估基准测试: https://blog.rafaelgss.dev/preparing-and-evaluating-benchmarks

[8] 独立/不配对的双组t检验: https://en.wikipedia.org/wiki/Student%27s_t-test#Equal_or_unequal_sample_sizes%2C_unequal_variances_%28sX1_%3E_2sX2_or_sX2_%3E_2sX1%29

[9] 编写和运行基准测试: https://github.com/nodejs/node/blob/main/doc/contributing/writing-and-running-benchmarks.md#comparing-nodejs-versions

[10] 这表明先前的基准测试方法存在限制或错误: https://github.com/nodejs/performance/issues/80

[11] Node.js 性能工作组: https://github.com/nodejs/performance

[12] 密切关注: https://github.com/nodejs/performance/issues/79

[13] Ada: https://github.com/ada-url/ada

[14] WHATWG URL API: https://nodejs.org/api/url.html#the-whatwg-url-api

[15] State of Node.js Performance 2023: https://github.com/RafaelGSS/state-of-nodejs-performance-2023#url-results

[16] 主要仓库: https://github.com/RafaelGSS/state-of-nodejs-performance-2023#url-results

[17] state-of-nodejs-performance-2023: https://github.com/RafaelGSS/state-of-nodejs-performance-2023#util

[18] 这个链接: https://rafaelgss.dev/

[19] 准备和评估基准测试: https://blog.rafaelgss.dev/preparing-and-evaluating-benchmarks

[20] Source: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v18.md#parsing-integer

[21] Source: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v20.md#parsing-integer

[22] Source: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v18.md#super-vs-this

[23] Source: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v20.md#super-vs-this

[24] v18: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v18.md#property-getter-access

[25] v20: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v20.md#property-getter-access

[26] v18: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v18.md#property-setter-access

[27] v20: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v20.md#property-setter-access

[28] v18: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v18.md

[29] v20: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v20.md#property-access-after-shape-transition

[30] Source: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v18.md#get-the-last-item-of-an-array

[31] Sourc: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v16.md#string-searching

[32] Source: https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/RESULTS-v18.md#string-searching

[33] nodejs/performance: https://github.com/nodejs/performance

[34] state-of-nodejs-performance: https://github.com/RafaelGSS/state-of-nodejs-performance-2023

- END -