[Перевод] Бенчмарки JavaScript — это полный хаос

eb087ddf2b409c7e6de3995f1196b12f.png

Я ненавижу код бенчмаркинга, как и любой другой человек. Гораздо веселее притвориться, что твоё кэширование значения увеличило производительность на 1000%, чем проверять это тестами. Увы, бенчмаркинг JavaScript по-прежнему необходим, особенно потому, что JavaScript используется (когда не должен?) во всё более чувствительных к производительности приложениях. К сожалению, из-за множества базовых архитектурных решений языка, JavaScript никак не упрощает выполнение бенчмаркинга.

Что не так с JavaScript?  

JIT-компилятор снижает точность (?) 

Для тех, кто не знаком с магией современных скриптовых языков наподобие JavaScript, их архитектура может быть довольно сложной. Большинство современных движков JavaScript не просто пропускают код через интерпретатор, который мгновенно выдаёт команды, а используют архитектуру, больше похожую на компилируемые языки наподобие C — они интегрируют несколько уровней «компиляторов».

Каждый из этих компиляторов обеспечивает разный баланс между временем компиляции и производительностью в среде выполнения, чтобы пользователю не нужно было тратить вычислительные ресурсы на оптимизацию редко выполняемого кода и в то же время чтобы он мог пользоваться преимуществами производительности продвинутых компиляторов для кода, выполняемого наиболее часто (для «горячих путей» выполнения кода). Существуют и другие тонкости, всплывающие при использовании оптимизирующих компиляторов со сложной программистской терминологией наподобие «мономорфизма функций», но я пожалею вас и не буду в неё углубляться.

Но почему это важно для бенчмаркинга? Ну, как вы могли догадаться, поскольку бенчмаркинг измеряет производительность кода, на него достаточно сильно влияет JIT-компилятор. После полной оптимизации малые блоки кода при бенчмаркинге часто обеспечивают более чем десятикратный рост производительности, привнося в результаты большие погрешности. Возьмём для примера самую простую схему бенчмаркинга (по множеству причин вам не стоит использовать ничего из представленного ниже):

for (int i = 0; i<1000; i++) {
    console.time()
    // выполняем какую-то затратную работу
    console.timeEnd()
}

(Не беспокойтесь, мы поговорим и о console.time)

Спустя несколько прогонов большая часть вашего кода будет кэширована, что существенно снизит время на каждую операцию. Программы бенчмаркинга часто максимально стараются устранить это кэширование/оптимизацию, потому что из-за них тестируемые программы в дальнейшем процессе бенчмаркинга могут казаться относительно быстрее. Однако в конечном итоге нам надо задаться вопросом, действительно ли бенчмарки без оптимизации отражают производительность программ в реальном сценарии использования. Да, в некоторых случаях, например, для редко посещаемых веб-страниц, оптимизация маловероятна, но в средах наподобие серверов, где производительность важнее всего, следует ожидать оптимизацию. Если у вас выполняется код в качестве middleware для тысяч запросов в секунду, то вам лучше надеяться, что V8 его оптимизирует.

То есть, по сути, даже в рамках одного движка существует 2–4 разных способа выполнения кода с варьирующимися уровнями производительности. И, кстати, в некоторых случаях очень сложно бывает гарантировать включение конкретных уровней оптимизации. В общем, развлекайтесь :).

Движки всеми силами стараются не позволить вам точно замерять тайминги 

Знаете, что такое fingerprinting? Это методика, позволявшая использовать Do Not Track на пользу слежению. Движки JavaScript всеми силами стараются устранить эту проблему. Эти усилия, наряду с действиями по предотвращению тайминг-атак, привели к тому, что движки JavaScript намеренно делают тайминги неточными, чтобы хакеры не могли выполнять точные измерения текущей производительности компьютеров и затратности конкретной операции. К сожалению, это означает, что если никак на это не реагировать, то у бенчмарков будет та же проблема.

Пример из предыдущего раздела был бы неточным, потому что он выполняет измерения в миллисекундах. Давайте перейдём на performance.now(). Отлично, теперь метки времени измеряются в микросекундах!

// Плохо
console.time();
// работа
console.timeEnd();

// Лучше?
const t = performance.now();
// работа
console.log(performance.now() - t);

Но только… все они добавляются инкрементами по 100 мкс. Теперь давайте добавим заголовки, чтобы снизить риск тайминг-атак. Упс, мы всё равно можем получать инкременты по 5 мкс. Вероятно, 5 мкс — это приемлемая точность для большинства ситуаций, но если нам нужна более высокая дробность, нужно поискать её где-нибудь ещё. Насколько я знаю, ни один браузер не позволяет использовать более точные таймеры. Node.js позволяет, но, разумеется, у него есть собственные проблемы.

Даже если вы решите запустить код в браузере, чтобы компилятор выполнил свою работу, то, очевидно, вам всё равно предстоит много мучений, если вам нужны более точные тайминги. К тому же, не все браузеры одинаковы.

Каждая среда уникальна 

Мне нравится Bun, он сделал большой шаг вперёд для серверного JavaScript;, но как же он усложняет бенчмаркинг для JavaScript для серверов! Ещё несколько лет назад единственными важными средами серверного JavaScript были Node.js и Deno, и обе они использовали движок JavaScript V8 (такой же, как в Chrome). Bun использует JavaScriptCore (движок Safari), обладающий совершенно иными характеристиками производительности.

Проблема нескольких сред JavaScript со своими характеристиками производительности относительно нова для серверного JavaScript, но клиенты страдали от неё уже очень давно. Три популярных движка JavaScript — V8, JSC и SpiderMonkey (соответственно, в Chrome, Safari и Firefox) могут иметь сильно различающуюся производительность для эквивалентного фрагмента кода.

Одним из примеров таких различий может служить Tail Call Optimization (TCO). TCO оптимизирует функции, имеющие рекурсии в конце своего тела, например:

function factorial(i, num = 1) {
	if (i == 1) return num;
	num *= i;
	i--;
	return factorial(i, num);
}

Попробуйте выполнить бенчмаркинг factorial(100000) в Bun. А теперь попробуйте сделать то же самое в Node.js или Deno. У вас должна возникнуть примерно такая ошибка:

function factorial(i, num = 1) {
 ^

RangeError: Maximum call stack size exceeded

V8 (а значит, и в Node.js с Deno) при каждом вызове factorial() в конце функции создаёт совершенно новый контекст функции для вложенной функции, который в конечном итоге будет ограничен стеком вызовов. Но почему этого не происходит в Bun? JavaScriptCore, используемый Bun, реализует TCO, которая оптимизирует подобные типы функций, превращая их в цикл for, больше похожий на такой код:

function factorial(i, num = 1) {
	while (i != 1) {
		num *= i;
		i--;
	}
	return i;
}

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

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

Что ещё стоит упомянуть 

  • Сборщик мусора и его тенденцию произвольным образом устраивать паузы

  • Способность JIT-компилятора удалять весь ваш код, потому что «он необязателен»

  • Ужасно широкие flame-графики в большинстве инструментов разработчика JavaScript

  • Я думаю, смысл вы поняли

Но… как же решить эту проблему?  

Хотелось бы мне сказать, что есть пакет npm, решающий все эти проблемы, но, к сожалению, его нет.

На сервере ситуация чуть получше. Можно использовать d8, чтобы вручную контролировать уровни оптимизации, управлять сборщиком мусора и получать точные тайминги. Разумеется, для качественного проектирования конвейера бенчмаркинга вам понадобится хорошее владение магией Bash, поскольку d8 плохо интегрирован (или вообще не интегрирован) с Node.js. Также для получения похожих результатов можно включить в Node.js некоторые флаги, но так вы не сможете использовать определённые фичи, например, включение конкретных уровней оптимизации.

v8 --sparkplug --always-sparkplug --no-opt [file]

Пример D8 со включенным конкретным уровнем компиляции (sparkplug). По умолчанию D8 использует больше контроля за GC и в целом больше отладочной информации.

Можно ли использовать подобные фичи в JavaScriptCore? Честно говоря, я не особо много работал с CLI JavaScriptCore, к тому же у него очень мало документации. В нём можно включать конкретные уровни при помощи флагов командной строки, но я не знаю точно, какой объём отладочной информации можно получить. В Bun также есть полезные утилиты бенчмаркинга, но по сравнению с Node.js они сильно ограничены.

К сожалению, для всего этого нужен базовый движок/тестовая версия движка, которую получить может быть достаточно сложно. Я выяснил, что простейший способ управления движками заключается в использовании esvu в паре с eshost-cli, так как они сильно упрощают управление движками и выполнение в них кода. Разумеется, при этом всё равно требуется достаточно большой объём ручного труда, поскольку эти инструменты просто управляют выполнением кода в разных движках, а код бенчмаркинга вам придётся писать самостоятельно.

Если вы просто хотите как можно точнее выполнить на сервере бенчмарк в движке с опциями по умолчанию, то есть готовые инструменты Node.js наподобие mitata, помогающие повысить точность таймингов и снизить погрешности, связанные с GC. Многие из таких инструментов, например, ту же Mitata, можно также применять для нескольких движков; разумеется, при этом вам всё равно придётся настраивать конвейер аналогично описанному выше.

В браузере же всё намного сложнее. Я не знаю, существуют ли решения для получения более точных таймингов, а контроль движка гораздо больше ограничен. Больше всего информации, связанной с производительностью JavaScript в среде выполнения браузера, можно получить в Chrome devtools, в которых есть простой flame-график и утилиты симуляции замедления CPU.

Заключение

Многие из тех архитектурных решений, которые сделали JavaScript производительными и портируемым (относительно), существенно усложняют бенчмаркинг по сравнению с другими языками. Целевых платформ для бенчмаркинга гораздо больше, и у вас меньше контроля за каждой платформой.

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

Надеюсь, что если вы выбираете бенчмарк, то моя статья показала вам часть проблем, присутствующих в бенчмаркинге JavaScript.

© Habrahabr.ru