[Перевод] Правда о традиционных JavaScript-бенчмарках
Пожалуй, будет достаточно справедливо сказать, что JavaScript — самая важная современная технология в разработке программного обеспечения. Для многих из тех, кто изучал языки программирования, компиляторы и виртуальные машины, всё ещё удивителен тот факт, что при всей своей элегантности с точки зрения структуры языка JavaScript не слишком хорошо оптимизируем с точки зрения компилирования и не может похвастаться замечательной стандартной библиотекой. В зависимости от того, кто ваш собеседник, вы можете неделями перечислять недоработки в JavaScript и всё равно обнаружите какую-то странность, о которой ещё не слышали. Но несмотря на очевидные недостатки, сегодня JavaScript является ключевой технологией в вебе, доминирует в серверной/облачной сфере (благодаря Node.js), а также проникает в интернет вещей.
Возникает вопрос — почему JavaScript так популярен? Боюсь, у меня нет исчерпывающего ответа. Сегодня есть много причин для использования этого языка, важнейшими из которых, вероятно, являются огромная экосистема, выстроенная вокруг JavaScript, и несметное количество ресурсов. Но всё это в известной мере следствия. А почему же изначально язык стал популярен? Вы можете сказать: потому что долгое время он был лингва франка для веба. Но это было очень давно, и разработчики страстно ненавидели JavaScript. Если оглянуться назад, то рост популярности JavaScript начался во второй половине 2000-х. Как раз в те времена движки JavaScript начали гораздо быстрее работать с различными нагрузками, что, вероятно, повлияло на отношение многих к этому языку.
В те годы для измерения скорости применялись так называемые традиционные JavaScript-бенчмарки, начиная с Apple SunSpider, прародителя всех JS-микробенчмарков, затем были Mozilla Kraken и Google V8. Позднее гугловский бенчмарк был вытеснен Octane, а Apple выпустила JetStream. Эти традиционные бенчмарки приложили невероятные усилия для выведения производительности JavaScript на такую высоту, какой в начале века никто не ожидал. Отмечались тысячекратные ускорения, и внезапно использование перестало быть танцем с дьяволом, а выполнение вычислений на клиентской стороне стало не просто возможным, а даже поощряемым.
Источник: Advanced JS performance with V8 and Web Assembly, Chrome Developer Summit 2016, @s3ththompson.
В 2016 году все (значимые) JS-движки достигли невероятной производительности, и веб-приложения стали такими же шустрыми, как и нативные (или могут быть такими же шустрыми). Движки поставляются со сложными оптимизированными компиляторами, генерирующими короткие последовательности высокооптимизированного машинного кода. Достигается это за счёт вдумчивого выбора типа/формы (type/shape) для каждой операции (доступ к свойствам, двоичные операции, сравнения, вызовы и так далее) в зависимости от имеющейся статистики по различным типам/формам. Большинство этих оптимизаций диктовались микробенчмарками наподобие SunSpider и Kraken, а также статистическими пакетами вроде Octane и JetStream. Благодаря основанным на JavaScript технологиям вроде asm.js и Emscripten сегодня можно компилировать в JavaScript большие С++-приложения и выполнять их в браузере безо всякого скачивания и установки. Например, вы без труда прямо из коробки поиграете по сети в AngryBots, в то время как раньше для этого требовались специальные плагины вроде Adobe Flash или Chrome PNaCl.
Подавляющее большинство всех этих достижений стало возможным благодаря наличию микробенчмарков и пакетов измерения производительности, а также конкуренции, возникшей между традиционными JS-бенчмарками. Можете что угодно говорить о SunSpider, но очевидно, что без него производительность JavaScript вряд ли была бы такой, как сегодня.
Но довольно восхвалений, пришла пора взглянуть на обратную сторону монеты. Все измерительные тесты — будь то микробенчмарки или большие пакеты — обречены со временем становиться неактуальными! Почему? Потому что бенчмарк может вас чему-то научить только до тех пор, пока вы не начнёте с ним играться. Как только вы превысите (или не превысите) определённый порог, общая применимость оптимизаций, дающих выигрыш для данного бенчмарка, будет экспоненциально уменьшаться.
Например, мы использовали Octane в качестве прокси для измерения производительности реальных веб-приложений. И какое-то время он достаточно хорошо справлялся с этой задачей. Но сегодня распределение времени (distribution of time) в Octane и реальных приложениях сильно различается, поэтому дальнейшая оптимизация Octane вряд ли приведёт к каким-то значимым улучшениям в реальных приложениях (в том числе и для Node.js).
Источник: Real-World JavaScript Performance, конференция BlinkOn 6, @tverwaes.
По мере того как становилось всё более очевидным, что все традиционные бенчмарки для измерения производительности JavaScript, включая самые свежие версии JetStream и Octane, похоже, себя изжили, мы начали искать новые пути измерения реальных приложений, добавив в V8 и Chrome новые перехватчики для профилирования и трассировки. Также мы задействовали средства, позволяющие понять, на что у нас тратится больше времени при просмотре сайтов: на исполнение скрипта, сборку мусора, компилирование и так далее. Результаты исследований оказались очень интересными и неожиданными. Как видно из предыдущей иллюстрации, при запуске Octane более 70% времени тратится на исполнение JavaScript и сборку мусора, в то время как при просмотре сайтов на JavaScript всегда уходит меньше 30% времени, а на сборку мусора — не более 5%. Зато немало времени отнимают парсинг и компилирование, чего не скажешь об Octane. Так что значительные усилия по оптимизации исполнения JavaScript дадут вам хороший прирост попугаев в Octane, но сайты не станут грузиться заметно быстрее. Причём увлечение оптимизацией исполнения JavaScript может даже навредить производительности реальных приложений, потому что на компилирование начнёт уходить больше времени — или вам понадобится отслеживать дополнительные параметры, что удлинит компилирование, IC и Runtime.
Есть ещё один пакет бенчмарков, который пытается измерять общую производительность браузера, включая JavaScript и DOM: Speedometer. Он старается подходить к измерению более реалистично, запуская простое приложение TodoMVC, реализованное на разных популярных веб-фреймворках (на сегодняшний день оно немного устарело, но уже делается новая версия). В пакет включены новые тесты (angular, ember, react, vanilla, flight и backbone). На сегодняшний день Speedometer выглядит наиболее предпочтительным вариантом на роль прокси для измерения производительности реальных приложений. Но обратите внимание, что это данные по состоянию на середину 2016 года, и всё уже могло измениться по мере развития применяемых в вебе паттернов (например, мы рефакторим IC-систему для сильного снижения издержек, а также перепроектируем парсер). Хотя выглядит так, будто вышеописанная ситуация имеет отношение только к просмотру сайтов, мы получили очень убедительное доказательство того, что традиционные бенчмарки пиковой производительности не слишком хорошо подходят на роль прокси и в случае с реальными Node.js-приложениями.
Источник: Real-World JavaScript Performance, конференция BlinkOn 6, @tverwaes.
Возможно, всё это уже известно широкой аудитории, так что дальше я лишь остановлюсь на нескольких конкретных примерах, иллюстрирующих мысль, почему для JS-сообщества не просто полезно, но и критически важно с определённого момента прекратить обращать внимание на статические бенчмарки пиковой производительности. Начну с примеров того, как JS-движки могут проходить бенчмарки и как они это делают на самом деле.
Печально известные примеры с SunSpiderСтатья о традиционных JS-бенчмарках была бы неполной без упоминания очевидных проблем SunSpider. Начнём с теста производительности, чья применимость в реальных ситуациях ограничена: bitops-bitwise-and.js.
Здесь есть пара алгоритмов, которым требуется быстрая поразрядная операция И (bitwise AND), особенно в коде, транспилированном (transpile) из C/C++ в JavaScript. Однако вряд ли веб-страницам есть дело до того, может ли движок выполнять поразрядную операцию И в цикле вдвое быстрее другого движка. Вероятно, вы заметили, что после первой итерации цикла bitwiseAndValue становится равно 0 и остаётся таковым в течение следующих 599 999 итераций. Так что как только вы прогоните это с хорошей производительностью, то есть быстрее 5 мс на приличном железе, можете начать гонять этот бенчмарк в попытках понять, что нужна только первая итерация этого цикла, а все остальные — просто потеря времени (то есть мёртвый код после расщепления цикла). Для выполнения такого преобразования в JavaScript потребуется проверить:
- является ли
bitwiseAndValue
обычным свойством глобального объекта до исполнения скрипта, - чтобы не было перехватчика глобального объекта или его прототипов, и так далее.
Но если вы действительно хотите победить в бенчмарке и ради этого готовы на всё, то можете выполнить тест менее чем за 1 мс. Но применимость оптимизации ограничена лишь этим конкретным случаем, и небольшие изменения теста, вероятно, не приведут к её срабатыванию.
Короче, тест bitops-bitwise-and.js был худшим примером микробенчмарка. Перейдём к более практичному примеру — тесту string-tagcloud.js. По сути, он прогоняет очень раннюю версию полифилла json.js
. Пожалуй, этот тест выглядит куда разумнее предыдущего. Но если внимательнее посмотреть на профиль бенчмарка, то становится очевидно, что он тратит кучу времени на единственное выражение eval
(до 20% общего времени исполнения для парсинга и компилирования и ещё до 10% для реального исполнения скомпилированного кода):
Посмотрим ещё внимательнее: eval
исполняется лишь один раз и передаётся JSON-строке, содержащей массив из 2501 объекта с полями tag
и popularity
:
([
{
"tag": "titillation",
"popularity": 4294967296
},
{
"tag": "foamless",
"popularity": 1257718401
},
{
"tag": "snarler",
"popularity": 613166183
},
{
"tag": "multangularness",
"popularity": 368304452
},
{
"tag": "Fesapo unventurous",
"popularity": 248026512
},
{
"tag": "esthesioblast",
"popularity": 179556755
},
{
"tag": "echeneidoid",
"popularity": 136641578
},
{
"tag": "embryoctony",
"popularity": 107852576
},
...
])
Очевидно, что будет дорого парсить эти объектные литералы, генерировать нативный код и затем его исполнять. Гораздо дешевле просто парсить входную строку в виде JSON и генерировать соответствующий объектный граф. Чтобы улучшить результаты в бенчмарке, можно попробовать всегда изначально интерпретировать
eval
как JSON и реально выполнять парсинг, компилирование и исполнение только в том случае, если не получится прочитать в виде JSON (правда, для пропуска скобок потребуется дополнительная магия). В 2007 году такое не сошло бы даже за плохой хак, ведь ещё не существовало JSON.parse. А к 2017-му это превратилось просто в технический долг в JavaScript-движке, да ещё и потенциально может замедлить использование eval
. По сути, обновление бенчмарка до современного JavaScript--- string-tagcloud.js.ORIG 2016-12-14 09:00:52.869887104 +0100
+++ string-tagcloud.js 2016-12-14 09:01:01.033944051 +0100
@@ -198,7 +198,7 @@
replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(:?[eE][+\-]?\d+)?/g, ']').
replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) {
- j = eval('(' + this + ')');
+ j = JSON.parse(this);
return typeof filter === 'function' ? walk('', j) : j;
}
приводит к немедленному повышению производительности: на сегодняшний день runtime для V8 LKGR снижается с 36 до 26 мс, 30-процентное улучшение!
$ node string-tagcloud.js.ORIG
Time (string-tagcloud): 36 ms.
$ node string-tagcloud.js
Time (string-tagcloud): 26 ms.
$ node -v
v8.0.0-pre
$
Это обычная проблема статичных бенчмарков и пакетов тестирования производительности. Сегодня никто не будет всерьёз использовать
eval
для парсинга JSON-данных (по причине безопасности, а не только производительности). Вместо этого во всём коде, написанном за последние пять лет, используется JSON.parse. Более того, применение eval
для парсинга JSON в production может быть расценено как баг! Так что в этом древнем бенчмарке не учитываются усилия авторов движков по увеличению производительности относительно недавно написанного кода. Вместо этого было бы полезно сделать eval
излишне сложным, чтобы получить хороший результат в string-tagcloud.js
.Перейдём к другому примеру — 3d-cube.js. Этот бенчмарк выполняет много матричных операций, с которыми даже самые умные компиляторы не могут ничего поделать, кроме как просто исполнить. Бенчмарк тратит много времени на выполнение функции Loop и вызываемых ею функций.
Интересное наблюдение: функции RotateX
, RotateY
и RotateZ
всегда вызываются с параметром-константой Phi
.
Это означает, что мы всегда вычисляем они и те же значения для Math.sin и Math.cos, каждое по 204 раза. Есть только три разных входных значения:
- 0,017453292519943295
- 0,05235987755982989
- 0,08726646259971647
Чтобы избежать лишних вычислений одних и тех же значений синуса и косинуса, можно кешировать ранее вычисленные значения. Раньше V8 именно это и делал, а все остальные движки делают так до сих пор. Мы убрали из V8 так называемый трансцендентальный кеш, поскольку его избыточность была заметна при реальных нагрузках, когда ты не вычисляешь всегда одни и те же значения в строке. Мы сильно провалились в результатах бенчмарка SunSpider, убрав эту специфическую оптимизацию, но полностью уверены, что не имеет смысла оптимизировать под бенчмарк и в то же время ухудшать результаты на реальных проектах.
Источник: arewefastyet.com.
Очевидно, что лучший способ работы с такими константами — входными синусом/косинусом — нормальная эвристика замещения вызова (inlining heuristic), которая попытается сбалансировать замещение и учесть разные факторы вроде предпочтения замещения в точках вызова, когда может быть полезна свёртка констант (constant folding) (как в случае с RotateX
, RotateY
и RotateZ
). Но по ряду причин такое не подходило для компилятора Crankshaft. Зато это разумный вариант в случае с Ignition и TurboFan, и мы уже работаем над улучшением эвристики замещения.
Помимо специфических случаев, у SunSpider есть и другая фундаментальная проблема: общее время исполнения. Сейчас на приличном железе Intel движок V8 прогоняет весь бенчмарк примерно за 200 мс (в зависимости от живых объектов в новом пространстве и фрагментации старого пространства), в то время как основная пауза на сборку мусора легко может достигать 30 мс. И мы ещё не учитываем расходы на инкрементальную маркировку (incremental marking), а это более 10% общего времени исполнения пакета SunSpider! Так что если движок не хочет замедлиться на 10—20% из-за сборки мусора, то ему нужно как-то удостовериться, что она не будет инициирована во время выполнения SunSpider.
Для этого используются разные трюки, но все они, насколько мне известно, не оказывают положительного влияния на реальные задачи. V8 поступает просто: поскольку каждый тест SunSpider выполняется в новом , соответствующем новому нативному контексту, то мы просто регистрируем создание и размещение
(на каждый из тестов SunSpider тратится меньше 50 мс). И тогда сборка мусора выполняется между процедурами размещения и создания, а не во время теста. Эта уловка работает хорошо и в 99,99% случаев не влияет на реальные проекты. Но если V8 решит, что ваше приложение выглядит как тест SunSpider, то принудительно запустит сборщик мусора, и это негативно отразится на скорости работы. Так что не позволяйте приложению выглядеть как SunSpider!
Я мог бы привести и другие примеры, связанные с SunSpider, но не думаю, что это будет полезно. Надеюсь, вам уже ясно, что оптимизировать под SunSpider ради того, чтобы превзойти результаты хорошей производительности, не имеет смысла для реальных приложений. Думаю, мир выиграл бы от того, если бы SunSpider больше вообще не было, потому что движки могут применять странные хаки, полезные только для этого пакета и способные навредить в реальных ситуациях.
К сожалению, SunSpider всё ещё очень активно используется в прессе при сравнении того, что журналисты считают производительностью браузеров. Или, что ещё хуже, для сравнения смартфонов! Конечно, тут проявляется и интерес производителей. Лагерю Android важно, чтобы Chrome показывал хорошие результаты на SunSpider (и прочих ныне бессмысленных бенчмарках). Производителям смартфонов нужно зарабатывать, продавая свою продукцию, а для этого требуются хорошие обзоры. Некоторые компании даже поставляют в смартфонах старые версии V8, показывающие более высокие результаты в SunSpider. А в результате пользователи получают незакрытые дыры в безопасности, которые уже давно были пофиксены в более поздних версиях. Причём старые версии V8 по факту работают медленнее!
Источник: Galaxy S7 and S7 Edge review: Samsung«s finest get more polished, www.engadget.com.
Если JavaScript-сообщество действительно заинтересовано в получении объективных данных о производительности, то нам нужно заставить журналистов перестать использовать традиционные бенчмарки при сравнении браузеров и смартфонов. Я понимаю, что проще запустить бенчмарк в каждом браузере и сравнить полученные числа, но в таком случае — пожалуйста-пожалуйста! — обратите внимание на бенчмарки, которые хоть как-то соответствуют современному положению дел. То есть реальным веб-страницам. Если вам нужно сравнить два смартфона через браузерный бенчмарк, возьмите хотя бы Speedometer.
Менее очевидная ситуация с KrakenБенчмарк Kraken был выпущен Mozilla в сентябре 2010-го. Заявлялось, что он содержит фрагменты кода и ядра реальных приложений. Я не стану уделять Kraken слишком много времени, потому что он не оказал такого влияния на производительность JavaScript, как SunSpider и Octane. Опишу лишь пример с тестом audio-oscillator.js.
Тест 500 раз вызывает функцию calcOsc
. Она сначала вызывает generate
применительно к глобалу sine Oscillator
, затем создаёт новый Oscillator
, вызывает применительно к нему generate и добавляет к sine Oscillator
. Не углубляясь в детали, почему здесь так делается, давайте рассмотрим метод generate
в прототипе Oscillator
.
Глядя на код, можно предположить, что основную часть времени занимают доступы к массивам, или умножения, или циклические вызовы Math.round. Но на самом деле в runtime Oscillator.prototype.generate
доминирует выражение offset % this.waveTableLength
. Если запустить бенчмарк в профайлере на любой Intel-машине, то окажется, что более 20% процессорных циклов тратятся на инструкцию idiv
, которая генерируется для модуля (modulus). Интересное наблюдение: поле waveTableLength
экземпляра Oscillator всегда содержит значение 2048, единожды присвоенное в конструкторе Oscillator
.
Если мы знаем, что правая часть операции целочисленного модуля — это степень двойки, то мы можем сгенерировать куда лучший код и полностью избежать на Intel инструкции idiv
. Можно попробовать положиться на замещение вызова всего в функции calcOsc
и позволить подстановке констант исключить загрузку/хранение. Но это сработает для sine Oscillator
, помещённой вне функции calcOsc
.
Итак, мы добавили поддержку отслеживания значений определённых констант в качестве ответной реакции правой части оператора модуля. В V8 это имеет какой-то смысл, поскольку мы изучаем тип обратной связи для двоичных операций вроде +
, *
и %
, то есть оператор отслеживает типы входных данных, которые он видит, и типы полученных выходных данных (см. слайды с круглого стола Fast arithmetic for dynamic languages).
Было достаточно легко внедрить этот механизм в fullcodegen и Crankshaft, а BinaryOpIC
для MOD
также может проверять известную степень двойки для правой части. Запуск дефолтной конфигурации V8 (c Crankshaft и fullcodegen)
$ ~/Projects/v8/out/Release/d8 --trace-ic audio-oscillator.js
[...SNIP...]
[BinaryOpIC(MOD:None*None->None) => (MOD:Smi*2048->Smi) @ ~Oscillator.generate+598 at audio-oscillator.js:697]
[...SNIP...]
$
демонстрирует, что
BinaryOpIC
выбирает нужную обратную связь по константе (constant feedback) для правой части модуля, а также корректно отслеживает, чтобы левая часть всегда представляла собой маленькое целое число (Smi
в V8), и чтобы мы всегда получали маленький целочисленный результат. Если посмотреть на сгенерированный с помощью --print-opt-code --code-comments
код, то становится понятно, что Crankshaft использует обратную связь для создания эффективной кодовой последовательности для целочисленного модуля в Oscillator.prototype.generate
: [...SNIP...]
;;; <@80,#84> load-named-field
0x133a0bdacc4a 330 8b4343 movl rax,[rbx+0x43]
;;; <@83,#86> compare-numeric-and-branch
0x133a0bdacc4d 333 3d00080000 cmp rax,0x800
0x133a0bdacc52 338 0f85ff000000 jnz 599 (0x133a0bdacd57)
[...SNIP...]
;;; <@90,#94> mod-by-power-of-2-i
0x133a0bdacc5b 347 4585db testl r11,r11
0x133a0bdacc5e 350 790f jns 367 (0x133a0bdacc6f)
0x133a0bdacc60 352 41f7db negl r11
0x133a0bdacc63 355 4181e3ff070000 andl r11,0x7ff
0x133a0bdacc6a 362 41f7db negl r11
0x133a0bdacc6d 365 eb07 jmp 374 (0x133a0bdacc76)
0x133a0bdacc6f 367 4181e3ff070000 andl r11,0x7ff
[...SNIP...]
;;; <@127,#88> deoptimize
0x133a0bdacd57 599 e81273cdff call 0x133a0ba8406e
[...SNIP...]
Итак, мы загружаем значение
this.waveTableLength
(rbx
содержит ссылку this
), проверяем, чтобы оно было равно 2048 (десятичное 0×800). Если равно, то вместо использования функции idiv
мы просто выполняем поразрядную операцию И с соответствующей битовой маской (r11
содержит значение начинающей цикл переменной i
), уделяя внимание сохранению знака левой части. Проблема избыточной специализацииЭто классная уловка, но, как и в случае со многими уловками, предназначенными для получения хороших результатов в бенчмарках, тут есть одна главная проблема: избыточная специализация! Как только правая часть изменится, весь оптимизированный код должен быть деоптимизирован (больше неверно предположение, что правая сторона всегда представляет собой определённую степень двойки). Никакие последующие оптимизации не должны снова использовать
idiv
, поскольку в этом случае BinaryOpIC
наверняка зарепортит фидбек в форму Smi*Smi->Smi
. Предположим, что мы создали ещё один экземпляр Oscillator
, настроили на него другой waveTableLength
и применили generate
. Тогда будет потеряно 20% производительности, хотя мы не влияли на действительно интересные Oscillator
«ы; то есть движок здесь налагает нелокальный штраф (non-local penalization). --- audio-oscillator.js.ORIG 2016-12-15 22:01:43.897033156 +0100
+++ audio-oscillator.js 2016-12-15 22:02:26.397326067 +0100
@@ -1931,6 +1931,10 @@
var frequency = 344.53;
var sine = new Oscillator(Oscillator.Sine, frequency, 1, bufferSize, sampleRate);
+var unused = new Oscillator(Oscillator.Sine, frequency, 1, bufferSize, sampleRate);
+unused.waveTableLength = 1024;
+unused.generate();
+
var calcOsc = function() {
sine.generate();
Если сравнить время исполнения оригинального
audio-oscillator.js
и версии, содержащей дополнительный неиспользуемый экземпляр Oscillator
с модифицированным waveTableLength
, получим ожидаемые результаты: $ ~/Projects/v8/out/Release/d8 audio-oscillator.js.ORIG
Time (audio-oscillator-once): 64 ms.
$ ~/Projects/v8/out/Release/d8 audio-oscillator.js
Time (audio-oscillator-once): 81 ms.
$
Это пример ужасного падения производительности. Допустим, разработчик пишет код для библиотеки, осторожно настраивает и оптимизирует использование определённых входных значений, в результате получая приличную производительность. Потребитель обращается к библиотеке, но производительность оказывается гораздо ниже, потому что использует он её чуть иначе. Например, каким-то образом испортив обратную связь о типе для какого-то
BinaryOpIC
, он получил 20-процентное замедление работы (по сравнению с результатами, полученными автором библиотеки). И причину замедления не могут объяснить ни автор, ни пользователь, это выглядит непонятной случайностью.Сегодня такое не редкость в мире JavaScript. Пары подобных снижений производительности просто нельзя избежать, поскольку их причина в том, что производительность JavaScript основана на оптимистичных предположениях и спекуляциях. Мы потратили кучу времени и сил, пытаясь придумать способы избежать подобных падений, и до сих пор имеем (почти) такую же производительность. Похоже, стоит при любой возможности избегать idiv
, даже если вы не знаете, что правая часть всегда равна степени двойки (посредством динамической обратной связи). TurboFan, в отличие от Crankshaft, во время runtime всегда проверяет, равен ли входной параметр степени двойки, поэтому в общем случае код для целочисленного модуля со знаком и с оптимизацией правой части в виде (неизвестной) степени двойки выглядит так (псевдокод):
if 0 < rhs then
msk = rhs - 1
if rhs & msk != 0 then
lhs % rhs
else
if lhs < 0 then
-(-lhs & msk)
else
lhs & msk
else
if rhs < -1 then
lhs % rhs
else
zero
Это даёт нам гораздо более устойчивую и предсказуемую производительность (с TurboFan):
$ ~/Projects/v8/out/Release/d8 --turbo audio-oscillator.js.ORIG
Time (audio-oscillator-once): 69 ms.
$ ~/Projects/v8/out/Release/d8 --turbo audio-oscillator.js
Time (audio-oscillator-once): 69 ms.
$
Проблема с бенчмарками и избыточной специализацией заключается в том, что бенчмарк может подсказывать вам, куда смотреть и что делать, но не ответит, как далеко нужно зайти, и не защитит вашу оптимизацию. Например, все JS-движки используют бенчмарки для защиты от снижения производительности, но запуск Kraken не позволит нам защититься при общем подходе, используемом в TurboFan. То есть мы можем деградировать оптимизацию модуля в TurboFan до сверхспециализированной версии Crankshaft, и бенчмарк не сообщит нам о регрессе, потому что, с его точки зрения, всё прекрасно! Теперь вы можете расширить бенчмарк, предположим, в том же ключе, в каком я сделал выше, и попытаться всё покрыть бенчмарками. Именно это в определённой степени делают и разработчики движков. Но такой подход нельзя произвольно масштабировать. Даже если бенчмарки удобны и просты в использовании, нельзя забывать и о здравом смысле, иначе всё поглотит избыточная специализация, и от падения производительности вас будет отделять очень тонкая граница.
С тестами Kraken есть ряд других проблем, но давайте перейдём к наиболее влиятельному JS-бенчмарку за последние пять лет — Octane.
Взглянем на Octane повнимательнееБенчмарк Octane — это наследник бенчмарка V8. Он был анонсирован Google в середине 2012 года, а текущая версия Octane 2.0 — в конце 2013-го. В этой версии содержится 15 отдельных тестов, для двух из которых — Splay и Mandreel — мы измерили пропускную способность (throughput) и задержку. Для этого мы прогнали ряд задач, включая компилирование самого себя компилятором Microsofts TypeScript, чистое измерение производительности asm.js с помощью теста zlib, лучевую трассировку (ray tracer), двумерный физический движок и так далее. Подробности по каждому бенчмарку можете узнать из описания. Все эти задачи обдуманно выбрали для демонстрации определённых аспектов производительности JavaScript, которые считались важными в 2012 году или должны были обрести значение в ближайшем будущем.
По большому счёту, Octane прекрасно справился со своими целями и вывел производительность JavaScript на новый уровень в 2012—2013-м. Но за прошедшие годы мир очень изменился. Особенно сильно на полезность Octane влияет устарелость большинства тестов в пакете (например, древние версии TypeScript и zlib скомпилированы с помощью древней версии Emscripten, а Mandreel теперь и вовсе недоступен).
Мы наблюдали большое соперничество между фреймворками в вебе, особенно между Ember и AngularJS, использующими шаблоны исполнения JavaScript, которые вообще не отражены в Octane и зачастую страдают от (наших) специфических оптимизаций. Также мы наблюдали победу JavaScript на серверном и инструментальном фронтах, в результате которой масштабные JS-приложения зачастую работают в течение многих недель, если не лет, и это тоже никак не отражено в Octane. Как говорилось в начале, у нас есть серьёзные свидетельства того, что исполнение и профилирование памяти в Octane полностью отличается от текущего состояния дел в вебе.
Давайте посмотрим на конкретные примеры работы с Octane, чьи оптимизации больше не соответствуют современным задачам. Звучит несколько негативно, но на самом деле это не так! Как я уже упоминал пару раз, Octane — важная глава в истории производительности JavaScript, он сыграл очень заметную роль. Все оптимизации, внедрённые в JS-движки благодаря этому пакету бенчмарков, внедрялись с хорошей уверенностью в том, что Octane — хороший прокси для измерения производительности реальных приложений! У каждого времени свой бенчмарк, и для любого бенчмарка наступает момент, когда его нужно отпустить!
Рассмотрим тест Box2D, основанный на Box2DWeb, популярном двумерном физическом движке, портированном на JavaScript. Здесь выполняется большое количество вычислений с плавающей запятой, под которые в JS-движках внедрено много хороших оптимизаций. Но этот тест, судя по всему, содержит баг, и он может использоваться для некоторой манипуляции (я вставил в свой пример соответствующий эксплойт). В бенчмарке есть функция D.prototype.UpdatePairs
(деминифицировано):
D.prototype.UpdatePairs = function(b) {
var e = this;
var f = e.m_pairCount = 0,
m;
for (f = 0; f < e.m_moveBuffer.length; ++f) {
m = e.m_moveBuffer[f];
var r = e.m_tree.GetFatAABB(m);
e.m_tree.Query(function(t) {
if (t == m) return true;
if (e.m_pairCount == e.m_pairBuffer.length) e.m_pairBuffer[e.m_pairCount] = new O;
var x = e.m_pairBuffer[e.m_pairCount];
x.proxyA = t < m ? t : m;
x.proxyB = t >= m ? t : m;
++e.m_pairCount;
return true
},
r)
}
for (f = e.m_moveBuffer.length = 0; f < e.m_pairCount;) {
r = e.m_pairBuffer[f];
var s = e.m_tree.GetUserData(r.proxyA),
v = e.m_tree.GetUserData(r.proxyB);
b(s, v);
for (++f; f < e.m_pairCount;) {
s = e.m_pairBuffer[f];
if (s.proxyA != r.proxyA || s.proxyB != r.proxyB) break;
++f
}
}
};
Профилирование показывает, что много времени тратится на выполнение невинно выглядящей внутренней функции, передаваемой в первом цикле
e.m_tree.Query
: function(t) {
if (t == m) return true;
if (e.m_pairCount == e.m_pairBuffer.length) e.m_pairBuffer[e.m_pairCount] = new O;
var x = e.m_pairBuffer[e.m_pairCount];
x.proxyA = t < m ? t : m;
x.proxyB = t >= m ? t : m;
++e.m_pairCount;
return true
}
Точнее, время тратится не на саму функцию, а на запускаемые ею операции и функции встроенной библиотеки. 4—7% общего времени исполнения уходит на вызов бенчмарка в runtime-функции Compare, который реализует общий случай абстрактного относительного сравнения (abstract relational comparison).
Почти все вызовы runtime-функции идут из CompareICStub, используемого для двух относительных сравнений во внутренней функции:
x.proxyA = t < m ? t : m;
x.proxyB = t >= m ? t : m;
То есть на эти две безобидные строки приходится 99% времени выполнения функции! Как так? Ну, как и многие другие вещи в JavaScript, абстрактное относительное сравнение не всегда используется интуитивно правильно. В нашей функции
t
и m
— всегда экземпляры L
, центрального класса приложения. Но при этом не переопределяются свойства Symbol.toPrimitive
, "toString"
, "valueOf"
и Symbol.toStringTag
, относящиеся к абстрактному относительному сравнению. Если написать t < m
, то: - Вызывается ToPrimitive (
t
,hint Number
). - Запускается OrdinaryToPrimitive (
t
,"number"
), потому что нетSymbol.toPrimitive
. - Исполняется
t.valueOf()
, в результате получим самоt
, поскольку вызывается дефолтная Object.prototype.valueOf. - Затем идёт
t.toString()
, в результате получим"[object Object]"
, потому что используется дефолтная Object.prototype.toString, а Symbol.toStringTag дляL
не обнаружена. - Вызывается ToPrimitive (
m
,hint Number
). - Запускается OrdinaryToPrimitive (
m
,"number"
), потому что нет Symbol.toPrimitive. - Исполняется
m.valueOf()
, в результате получим само m, поскольку вызывается дефолтная Object.prototype.valueOf. - Затем идёт
m.toString()
, в результате получим"[object Object]"
, потому что используется дефолтная Object.prototype.toString, а Symbol.toStringTag дляL
не обнаружена. - Выполняется сравнение
"[object Object]" < "[object Object]"
, в результате получим
То же самое и при t >= m
, только в конце всегда будем получать true
. Баг в том, что нет смысла делать абстрактное относительное сравнение таким образом. Суть эксплойта: можно заставить компилятор выполнить свёртку констант, то есть применить к бенчмарку подобный патч:
--- octane-box2d.js.ORIG 2016-12-16 07:28:58.442977631 +0100
+++ octane-box2d.js 2016-12-16 07:29:05.615028272 +0100
@@ -2021,8 +2021,8 @@
if (t == m) return true;
if (e.m_pairCount == e.m_pairBuffer.length) e.m_pairBuffer[e.m_pairCount] = new O;
var x = e.m_pairBuffer[e.m_pairCount];
- x.proxyA = t < m ? t : m;
- x.proxyB = t >= m ? t : m;
+ x.proxyA = m;
+ x.proxyB = t;
++e.m_pairCount;
return true
},
Это позволит увеличить результат на 13%, отказавшись от сравнения всех запускаемых им поисков и вызовов встроенной функции:
$ ~/Projects/v8/out/Release/d8 octane-box2d.js.ORIG
Score (Box2D): 48063
$ ~/Projects/v8/out/Release/d8 octane-box2d.js
Score (Box2D): 55359
$
Как мы это сделали? Похоже, у нас уже был механизм отслеживания формы объектов, которые сравниваются в
CompareIC
: так называемое отслеживание отображения (map) известного получателя (known receiver map tracking) (в терминологии V8 map — это форма + прототип объекта). Но этот механизм применялся только в абстрактных сравнениях и сравнениях на строгое равенство. Однако я легко могу использовать отслеживание и для получения обратной связи для абстрактного относительного сравнения: $ ~/Projects/v8/out/Release/d8 --trace-ic octane-box2d.js
[...SNIP...]
[CompareIC in ~+557 at octane-box2d.js:2024 ((UNINITIALIZED+UNINITIALIZED=UNINITIALIZED)->(RECEIVER+RECEIVER=KNOWN_RECEIVER))#LT @ 0x1d5a860493a1]
[CompareIC in ~+649 at octane-box2d.js:2025 ((UNINITIALIZED+UNINITIALIZED=UNINITIALIZED)->(RECEIVER+RECEIVER=KNOWN_RECEIVER))#GTE @ 0x1d5a860496e1]
[...SNIP...]
$
Используемое в базовом коде
CompareIC
говорит нам, что для выполняемых в нашей функции сравнений «менее чем» и «больше либо равно» видит только RECEIVER
«ы (JavaScript-объекты в терминологии V8). И все эти получатели имеют одно и то же отображение (map) 0x1d5a860493a1
, соответствующее отображению экземпляров L
. В оптимизиров