На дворе 2023 год, и мы выпустили Node.js v20. Это значительное достижение, и цель этой статьи — использовать научную оценку состояния производительности Node.js.

Все результаты бенчмарков содержат воспроизводимый пример и описание аппаратного обеспечения. Чтобы уменьшить количество шума для постоянных читателей, воспроизводимые шаги будут свернуты в начале всех разделов.

Цель этой статьи - предоставить сравнительный анализ различных версий Node.js. Она подчеркивает улучшения и недостатки, а также дает представление о причинах этих изменений, не проводя никаких сравнений с другими рантаймами JavaScript.

Для проведения эксперимента мы использовали Node.js версий 16.20.0, 18.16.0 и 20.0.0 и разделили сьюты бенчмарков на три отдельные группы:

  1. Node.js Internal Benchmark (внутренний бенчмарк)

Учитывая значительный размер и отнимающий много времени характер сьюта бенчмарка Node.js, я выбрал образцы, которые, по моему мнению, оказывают большее влияние на разработчиков и конфигурации Node.js, например, чтение файла размером 16 МБ с помощью fs.readfile. Эти бенчмарки сгруппированы по модулям, таким как fs и streams. Для получения дополнительной информации о сюьте бенчмарков Node.js, пожалуйста, обратитесь к исходному коду Node.js.

  1. nodejs-bench-operations

Я занимаюсь сопровождением репозитория под названием nodejs-bench-operations, который содержит бенчмарки для всех основных версий Node.js, а также последние три выпуска каждой линейки версий. Это позволяет легко сравнивать результаты между различными версиями, например, Node.js v16.20.0 и v18.16.0, или v19.8.0 и v19.9.0, с целью выявления регрессий в кодовой базе Node.js. Если вы заинтересованы в сравнительном анализе Node.js, знакомство с этим репозиторием может оказаться весьма ценным (и не забудьте поставить ему звезду, если вы считаете его полезным).

  1. HTTP-серверы (фреймворки)

Этот практичный HTTP бенчмарк отправляет значительное количество запросов по различным маршрутам, возвращая JSON, обычный текст и ошибки, используя в качестве ссылок express и fastify. Основная цель - определить, применимы ли результаты, полученные с помощью Node.js Internal Benchmark и nodejs-bench-operations, к обычным HTTP-приложениям.

Окружающая среда

Для выполнения этого бенчмарка использовался выделенный хост AWS со следующим оптимизированным для вычислений экземпляром:

  • c6i.xlarge (Ice Lake) 3,5 ГГц - оптимизированный для вычислений

  • 4 vCPU

  • 8 ГБ памяти

  • Canonical, Ubuntu, 22.04 LTS, amd64 jammy

  • 1 Гб SSD тип диска

Внутренний бенчмарк Node.js

В этом бенчмарке были выбраны следующие модули/пространства имен:

  • fs — файловая система Node.js

  • events — классы событий Node.js EventEmitter / EventTarget

  • http — Node.js HTTP сервер + парсер

  • misc — время запуска Node.js с использованием child_processes и worker_threads + trace_events

  • module — Node.js module.require

  • streams — создание, удаление, чтение потоков Node.js и многое другое

  • url — парсер URL Node.js

  • buffers — операции с буферами Node.js

  • util — Node.js кодировщик/декодировщик текста

Использованные конфигурации доступны по адресу RafaelGSS/node#state-of-nodejs, а все результаты были опубликованы в основном репозитории: State of Node.js Performance 2023.

Методика проведения бенчмарка Node.js

Прежде чем представить результаты, важно объяснить статистический подход, использованный для определения достоверности результатов бенчмарков. Этот метод был подробно описан в предыдущей статье блога, с которой вы можете ознакомиться здесь: Подготовка и оценка бенчмарков.

Чтобы сравнить влияние новой версии Node.js, мы запустили каждый бенчмарк несколько раз (30) на каждой конфигурации и на Node.js 16, 18 и 20. В таблице результатов есть две колонки, которые требуют пристального внимания:

  1. улучшение (improvement) процент улучшения по сравнению с новой версией

  2. достоверность (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%, что fs.readfile не улучшился с Node.js 16 до Node.js 18 (достоверность ***). Следовательно, мы вполне уверены в результатах. Структуру таблицы можно представить следующим образом:

  • fs/readfile.js — бенчмарк файл

  • concurrent=1 len=16777216 encoding='ascii' duration=5 — опции бенчмарка. Каждый бенчмарк файл может иметь множество опций, в данном случае это чтение 1 параллельного файла с 16777216 байтами в течение 5 секунд с использованием ASCII в качестве метода кодировки.

Для любителей статистики, скрипт выполняет независимый/непарный 2-групповой t-тест с нулевой гипотезой, что производительность одинакова для обеих версий. Если p-значение меньше 0.05, в поле "Достоверность" появится звездочка. — Написание и запуск бенчмарков

Настройка бенчмарка

  1. Клонируйте репозиторий форка Node.js

  2. Проверьте ветку state-of-nodejs

  3. Создайте двоичные файлы Node.js 16, 18 и 20

  4. Запустите скрипт 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 наблюдалось улучшение на 67% при использовании API fs.readfile с кодировкой ascii и на 12% при использовании utf-8.

Результаты бенчмарка показали, что при апгрейде Node.js с версии 16 до 18 улучшение API fs.readfile с кодировкой ascii составило около 67%, а при использовании utf-8 - около 12%. Файл, использованный для теста, был создан с помощью следующего сниппета:

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

Однако при использовании fs.readfile с ascii на Node.js 20 наблюдалась регрессия в 27%. Об этой регрессии было сообщено команде Node.js Performance, и ожидается, что она будет исправлена. С другой стороны, 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%

События

Класс 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. Кроме того, API EventEmitter.prototype.emit также получил заметное улучшение примерно на 11,5% при сравнении Node.js 16 с Node.js 20. Полное сравнение приведено ниже для справки:

                                                                 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%. Это указывает на то, что предыдущий метод бенчмаркинга имел ограничения или ошибки. Однако фактическая производительность HTTP-сервера улучшилась, и нам необходимо тщательно оценить процент улучшения с помощью нового бенчмаркингового подхода, чтобы точно измерить достигнутый прогресс.

Следует ли мне ожидать улучшения производительности на 96%/9% в моем приложении Express/Fastify?

Безусловно, нет. Фреймворки могут не использовать внутренний HTTP API — это одна из причин, почему Fastify... быстрый! По этой причине в данном отчете был рассмотрен другой набор бенчмарков (3. HTTP-серверы).

Разное

Согласно нашим тестам, скрипт 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%

Этот бенчмарк довольно прост. Мы измеряем время, затраченное на создание нового [mode] (режим) с помощью заданного [script] (сценарий), где [mode] может быть:

  • process — новый процесс Node.js

  • worker — worker_thread (рабочий поток) Node.js.

А [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

???? Warmup (подготовка) необходима для учета влияния кэша файловой системы.

Модуль trace_events также претерпел заметный прирост производительности: при сравнении Node.js версии 16 с версией 20 улучшение составило 7%. Стоит отметить, что при сравнении Node.js версии 18 с версией 20 это улучшение было немного ниже — 2,39%.

Модуль

require() (или module.require) долгое время был виновником медленного времени запуска Node.js. Однако последние улучшения производительности позволяют предположить, что эта функция также была оптимизирована. Между версиями Node.js 18 и 20 мы наблюдали улучшение на 4,20% при запросе файлов .js, 6,58% для файлов .json и 9,50% при чтении каталогов — все это способствует более быстрому запуску.

Оптимизация require() крайне важна, поскольку данная функция активно используется в приложениях Node.js. Сократив время ее выполнения, мы можем значительно ускорить весь процесс запуска и улучшить пользовательский опыт.

Потоки

Потоки (streams) — это невероятно мощная и широко используемая функция Node.js. Однако между версиями Node.js 16 и 18 некоторые операции, связанные с потоками, стали выполняться медленнее. К ним относятся создание и удаление потоков Duplex, Readable, Transform и Writable, а также метод .pipe() для потоков Readable → Writable.

Приведенный ниже график иллюстрирует эту регрессию:

Однако в 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 в v18 и v20 такие медленные!". Это определенный бенчмарк, который может как повлиять, так и нет на вашу рабочую нагрузку. Например, если вы посмотрите на наивное сравнение в 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
  }
})

Дело в том, что методы instantiation (инстанцирование) и destroy (уничтожение) играют важную роль в экосистеме Node.js. Таким образом, вполне вероятно, что это окажет негативное влияние на некоторые библиотеки. Тем не менее, эта регрессия внимательно отслеживается в Node.js Performance WG.

Обратите внимание, что читаемый асинхронный итератор становится немного быстрее (~6,14%) на Node.js 20.

URL

Начиная с Node.js 18, в нее была добавлена новая зависимость парсера URL — Ada. Это дополнение подняло производительность Node.js при парсинге URL на новый уровень. Некоторые результаты могут достигать 400%. Как обычный пользователь, вы можете не использовать его напрямую. Но если вы применяете HTTP-сервер, то вполне вероятно, что на него повлияет это улучшение производительности.

Набор бенчмарков URL довольно велик. По этой причине будут рассмотрены только результаты бенчмарка URL WHATWG.

url.parse() и url.resolve() являются устаревшими и наследуемыми API. Несмотря на то, что их использование считается рискованным для любого приложения Node.js, разработчики все еще применяют их. Цитирую документацию Node.js:

url.parse() использует мягкий, нестандартный алгоритм для парсинга строк URL. Он подвержен таким проблемам безопасности, как подмена имени хоста и некорректная обработка имен пользователей и паролей. Не пользуйтесь им при ненадежных входных данных. CVE не выдаются для уязвимостей url.parse(). Вместо этого используйте WHATWG URL API.

Если вам интересно узнать об изменениях производительности url.parse и url.resolve, ознакомьтесь с репозиторием State of Node.js Performance 2023.

Тем не менее, очень интересно посмотреть на результаты работы нового whatwg-url-parse:

Ниже приведен список 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',
}

После недавнего апгрейда Ada 2.0 в Node.js 20 можно с уверенностью сказать, что при сравнении 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)). Посмотрите все результаты в расширенном виде в основном репозитории.

Буферы

В Node.js буферы используются для работы с двоичными данными. Буферы — это встроенная структура данных, которая может использоваться для хранения необработанных двоичных данных в памяти, что может быть полезно при работе с сетевыми протоколами, операциями файловой системы или другими низкоуровневыми операциями. В целом, буферы являются важной составляющей Node.js и широко используются в платформе для работы с двоичными данными.

Для тех из вас, кто прямо или косвенно использует буферы Node.js, у меня есть хорошие новости (в основном для первопроходцев Node.js 20).

Помимо улучшения производительности Buffer.from() Node.js 20 исправил две основные регрессии из Node.js 18:

  • Buffer.concat()

Node.js версии 20 продемонстрировал значительные улучшения по сравнению с версией 18, и эти улучшения остаются очевидными даже по сравнению с версией 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 — это два класса JavaScript, которые являются частью спецификации Web API и доступны в современных веб-браузерах и Node.js. Вместе TextDecoder и TextEncoder обеспечивают простой и эффективный способ работы с текстовыми данными в JavaScript, позволяя разработчикам выполнять различные операции со строками и кодировками символов.

Декодирование и кодирование становится значительно быстрее, чем в Node.js 18. С добавлением simdutf для парсинга UTF-8 в наблюдаемом бенчмарке результаты улучшились на 364% (чрезвычайно впечатляющий скачок) при декодировании по сравнению с Node.js 16.

Эти достижения стали еще более заметными в Node.js 20: производительность выросла на 25% по сравнению с Node.js 18. Полные результаты можно посмотреть в репозитории state-of-nodejs-performance-2023.

Повышение производительности также наблюдалось при сравнении методов кодирования на Node.js 18. От Node.js 16 до Node.js 18, TextEncoder.encodeInto достиг 93,67% улучшения в текущем наблюдении (использование ascii с длиной строки 256):

Операции бенчмаркинга в Node.js

Операции бенчмаркинга в Node.js всегда вызывали у меня любопытство. Как человек, которому нравится изучать тонкости Node.js и лежащих в его основе технологий, я нахожу увлекательным углубляться в детали этих операций, особенно тех, которые связаны с движком V8. На самом деле, я часто люблю делиться своими открытиями с другими людьми в рамках выступлений и семинаров, проводимых компанией NearForm, с которой я связан. Если вам интересно, то можно найти больше информации о моих презентациях на данную тему, перейдя по этой ссылке.

Кроме того, в этих бенчмарках будет использоваться метрика ops/sec, которая в основном означает количество операций, выполненных за одну секунду. Важно подчеркнуть, что это может означать лишь очень малую часть вашего машинного времени. Если вы читали мою предыдущую статью (Подготовка и оценка бенчмарков), то должны помнить раздел "Оценка результатов", где я рассматриваю проблему ops/sec в реальных приложениях — если нет, то вам стоит вернуться к нему.

Парсинг целых чисел

Парсинг строк в числа может быть выполнен с помощью + или parseInt(x, 10). Результаты предыдущих бенчмарков показали, что использование + было быстрее, чем parseInt(x, 10) в ранних версиях Node.js, как показано в таблице ниже:

name

ops/sec

samples

Using parseInt(x, 10) - small number (2 len)

283,768,532

91

Using parseInt(x, 10) - big number (10 len)

21,307,115

100

Using + - small number (2 len)

849,906,952

100

Using + - big number (10 len)

849,173,336

97

Источник

Однако с выходом Node.js 20 и новой версии V8 (11.4) обе операции по производительности оказались эквивалентными, как показано в обновленных результатах бенчмарков ниже:

name

ops/sec

samples

Using parseInt(x, 10) - small number (2 len)

856,413,575

98

Using parseInt(x, 10) - big number (10 len)

856,754,259

96

Using + - small number (2 len)

857,364,191

98

Using + - big number (10 len)

857,511,971

96

Источник

Super в сравнении с 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
  }
}

Сравнение между super и this в Node.js 18 давало следующие операции в секунду (ops/sec):

name

ops/sec

samples

Using super

159,426,608

96

Using this

160,092,440

91

Источник

Нет существенной разницы между обоими подходами и на Node.js 20. Это утверждение справедливо с небольшим различием:

name

ops/sec

samples

Using super

850,760,436

97

Using this

853,619,840

99

Источник

По результатам бенчмарков видно, что производительность при использовании this на Node.js 20 значительно увеличилась по сравнению с Node.js 18. Этот прирост весьма заметен: поскольку this на Node.js 20 достигает впечатляющих 853 619 840 оп/с по сравнению с 160 092 440 оп/с на Node.js 18, что на 433% лучше! По-видимому, у него такой же метод доступа к свойствам, как и у обычного объекта: obj.property1. Также отметим, что обе операции были протестированы в одном и том же выделенном окружении. Следовательно, это вряд ли произошло случайно.

Доступ к свойствам

В JavaScript существуют различные способы добавления свойств к объектам, каждый из которых имеет свое собственное назначение и иногда неоднозначен по своей природе. Как разработчик, вы можете заинтересоваться эффективностью доступа к свойствам в каждом из этих методов.

Хорошей новостью является то, что репозиторий nodejs-bench-operations включает сравнение этих методов, которое проливает свет на их характеристики производительности. На самом деле, данные бенчмарка показывают, что доступ к свойствам в Node.js 20 значительно улучшился, особенно при использовании объектов со свойствами writable: true и enumerable/configurable: false.

const myObj = {};

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

myObj.test // How fast is the property access?

На Node.js 18 доступ к свойству (myObj.test) продуцировал 166 422 265 оп/с. Однако при тех же обстоятельствах Node.js 20 производит 857 316 403 оп/с! Эту и другие особенности доступа к свойствам можно увидеть в следующих результатах бенчмарка:

  • Доступ к геттеру свойства v18 / v20

  • Доступ к сеттеру свойства v18 / v20

  • Доступ к свойству после перехода формы v18 / v20

Array.prototype.at

Array.prototype.at(-1) — это метод, который был введен в спецификации ECMAScript 2021. Он позволяет получить доступ к последнему элементу массива, не зная его длины и не используя отрицательные индексы, что может быть полезным в некоторых юзкейсах. Таким образом, метод at() обеспечивает более лаконичный и читабельный способ доступа к последнему элементу массива по сравнению с традиционными методами, такими как array[array.length - 1].

На Node.js 18 этот доступ был значительно медленнее по сравнению с Array[length-1]:

name

ops/sec

samples

Length = 100 - Array.at

26,652,680

99

Length = 10_000 - Array.at

26,317,564

97

Length = 1_000_000 - Array.at

27,187,821

98

Length = 100 - Array[length - 1]

848,118,011

98

Length = 10_000 - Array[length - 1]

847,958,319

100

Length = 1_000_000 - Array[length - 1]

847,796,498

101

Источник

Начиная с Node.js 19, Array.prototype.at эквивалентен старомодному Array[length-1], как показано в таблице ниже:

name

ops/sec

samples

Length = 100 - Array.at

852,980,778

99

Length = 10_000 - Array.at

854,299,272

99

Length = 1_000_000 - Array.at

853,374,694

98

Length = 100 - Array[length - 1]

854,589,197

95

Length = 10_000 - Array[length - 1]

856,122,244

95

Length = 1_000_000 - Array[length - 1]

856,557,974

99

Источник

String.prototype.includes

Большинство людей знают, что RegExp зачастую является источником многих узких мест в любом приложении. Например, вы захотите проверить, содержит ли определенная переменная application/json. И хотя вы можете сделать это несколькими способами, чаще всего используется либо:

  • /application/json/.test(text) — RegEx

или

  • text.includes('application/json') — String.prototype.includes

Некоторые из вас могут не знать, что String.prototype.includes практически такой же медленный, как RegExp на Node.js 16.

name

ops/sec

samples

Using includes

16,056,204

97

Using indexof

850,710,330

100

Using RegExp.test

15,227,370

98

Using RegExp.text with cached regex pattern

15,808,350

97

Using new RegExp.test

4,945,475

98

Using new RegExp.test with cached regex pattern

5,944,679

100

Источник

Однако, начиная с Node.js 18, такое поведение было исправлено.

name

ops/sec

samples

Using includes

856,127,951

101

Using indexof

856,709,023

98

Using RegExp.test

16,623,756

98

Using RegExp.text with cached regex pattern

16,952,701

99

Using new RegExp.test

4,704,351

95

Using new RegExp.test with cached regex pattern

5,660,755

95

Источник

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 была обнаружена регрессия, которая снизила скорость с 30 тыс. оп/с до 6~7 тыс. оп/с. Как я уже упоминал в твите, весьма вероятно, что это вызвано новой версией OpenSSL. Опять же, наша команда изучает данную проблему, и если у вас есть какие-либо соображения по этому поводу, не стесняйтесь ее комментировать: https://github.com/nodejs/performance/issues/72.

Инициативы по повышению производительности Node.js

Команда Node.js всегда тщательно следила за тем, чтобы ее API и основные функциональные возможности были оптимизированы для повышения скорости и использования ресурсов.

С целью дальнейшего повышения производительности Node.js команда недавно представила новую стратегическую инициативу под названием "Performance" ("Производительность"), которую возглавляет Ягиз Низипли (Yagiz Nizipli). Она направлена на выявление и устранение узких мест в рантайме Node.js и основных модулей, а также на повышение общей производительности и масштабируемости платформы.

Помимо инициативы Performance, в настоящее время реализуется еще несколько проектов, направленных на оптимизацию различных аспектов Node.js. Одной из них является инициатива 'Startup Snapshot', которую возглавляет Джойи Чунг (Joyee Cheung). Она направлена на снижение времени запуска приложений Node.js, что является критическим фактором для повышения общей производительности и удобства работы веб-приложений.

Поэтому, если вам интересна эта тема, подумайте о том, чтобы присоединиться к встречам каждые две недели, и не стесняйтесь отправлять сообщения в канале #nodejs-core-performance на OpenJS Foundation Slack.

То, за чем нужно следить

Помимо стратегических инициатив, существует несколько пулл-реквестов (PR), которые с большой вероятностью окажут существенное влияние на производительность Node.js — в данный момент я пишу нижеследующий пост (он еще не смерджен):

Возникающие погрешности в Node.js обходятся очень дорого. Это нередко является источником критических моментов в приложениях Node.js. В качестве примера я провел исследование имплементации fetch в Node.js (undici) и обнаружил, что одной из зол в реализации Node.js WebStreams является процесс создания ошибок. Следовательно, проведя оптимизацию ошибочных объектов в Node.js, мы можем повысить общую эффективность платформы и снизить риск возникновения узких мест.

Сжатие указателей — это техника, используемая в компьютерном программировании для уменьшения использования памяти программами, которые используют много указателей. Хотя это не улучшает производительность напрямую, но может косвенно ее повысить за счет уменьшения обращений к кэшу и ошибок страниц. Это, безусловно, может снизить некоторые затраты на инфраструктуру, как описано в данной теме.

В марте 2022 года был поднят вопрос, предлагающий увеличить V8 max_semi_space_size с целью уменьшить количество операций по сборке мусора (в частности, Scavenge) и увеличить общую пропускную способность в бенчмарке веб-инструментария. Мы пока оцениваем его влияние, и он может попасть или нет в Node.js 21.

Этот PR увеличивает значение по умолчанию для highWaterMark в потоках Node.js. Ожидается улучшение производительности при использовании потоков Node.js с параметрами по умолчанию. Однако данный PR является semver-major изменением и должен появиться в Node.js 21. Для получения подробных результатов бенчмарка, ждите: 'State of Node.js Performance 2023 — P2' в конце года.

Заключение

Несмотря на некоторые регрессии в потоках Node.js и криптомодуле, Node.js 20 может похвастаться значительным улучшением производительности по сравнению с предыдущими версиями. Заметные улучшения наблюдаются в таких операциях JavaScript, как доступ к свойствам, парсинг URL, кодирование и декодирование буферов/текста, время жизненного цикла запуска/процесса и EventTarget, среди прочих.

Команда разработчиков производительности Node.js (nodejs/performance) расширила сферу своей деятельности, что приводит к увеличению вклада в оптимизацию производительности с каждой новой версией. Эта тенденция указывает на то, что Node.js продолжит становиться быстрее с течением времени.

Стоит отметить, что бенчмарки фокусируются на определенных операциях, которые могут напрямую влиять или нет на ваш конкретный юзкейс. Поэтому я настоятельно рекомендую просмотреть все результаты бенчмарков в репозитории state-of-nodejs-performance и убедиться, что данные операции соответствуют вашим бизнес-требованиям.


В заключение материала рекомендуем два открытых урока, которые пройдут в ближайшие дни в рамках онлайн-курсов OTUS:

  • Построение Rest API с Express: займемся добавлением общих стандартов создания API; рассмотрим REST, принципы построения API; проанализируем технологии аутентификации в веб приложениях. Записаться на урок

  • Основы HTML: создадим разметку страницы авторизации — разберём основы семантики и атрибуты полей формы. Разберем типичные ошибки. Записаться на урок

Комментарии (0)