V8: За кулисами (февральское издание. История TurboFan)

Andrey Melikhov
devSchacht
Published in
11 min readMar 21, 2017

Перевод статьи Benedikt Meurer
V8: Behind the Scenes (February Edition feat. A tale of TurboFan)

Февраль был для меня захватывающим и очень, очень напряженным месяцем. Как вы, наверное, слышали, мы, наконец, объявили о запуске Ignition + TurboFan конвейере в Chrome 59. Несмотря на опоздания, не позволившие нам выполнить эти обещания в феврале, я всё же хотел бы взять немного времени, чтобы поразмыслить над историей TurboFan и донести ее до вас. Помните, все, что вы прочитаете здесь, является моим личным мнением и не отражает мнение V8, Chrome или Google.

Прошло почти три с половиной года с тех пор, как в 2013 году мы начали размышлять о TurboFan. С того момента мир сильно изменился, V8 сильно изменился. Я и сам сильно изменился, и моё восприятие JavaScript, Интернета и Node.js существенно изменились. История разработки TurboFan тесно связана с моим личным развитием, поэтому она, вероятно, сильно отражает мою собственную точку зрения и меня нельзя назвать беспристрастным.

В конце 2013 года, когда я присоединился к проекту TurboFan, мы твердо верили, что нам нужно решить проблемы Crankshaft и повысить эффективность оптимизации пиковой производительности JavaScript-кода. Мы основывали большинство этих выводов на JavaScript-коде, на который вышли в тестах производительности, таких как Octane, а также на исследованиях приложений на основе asm.js и результатах важных веб-страниц, таких как Google Maps. Они считались хорошими отражениями ситуации в реальном мире, поскольку вовсю нагружают оптимизирующий компилятор. Оглядываясь назад, хочу сказать, сложно было ошибаться сильнее. Действительно, различные тесты в Octane могли показать прирост от еще более умного компилятора, однако реальность заключалась в том, что для подавляющего большинства веб-сайтов оптимизирующий компилятор на самом деле не имеет значения и может даже навредить производительности — потому что упреждающая оптимизация имеет свою цену, особенно при загрузке страницы и, в частности, на мобильных устройствах.

Но в первый год развития TurboFan мы практически не знали о проблемах реального мира. Наша первоначальная цель состояла в создании оптимизирующего компилятора, который одновременно очень хорошо бы работал с asm.js кодом, в чём Crankshaft никогда не блистал. В Chrome 41 мы отправили TurboFan для asm.js кода. Эта первоначальная версия TurboFan уже была весьма умна. Мы в целом достигли уровня производительности Firefox в работе с asm.js. Большинство оптимизаций для быстрой арифметики, основанных на типизации, будут одинаково хорошо работать в стандартном JavaScript. С моей очень личной точки зрения, оптимизирующий компилятор TurboFan в то время был, вероятно, самой прекрасной версией, которую мы когда-либо имели, и единственной версией компилятора JavaScript, где, как мне представляется, концепция «sea of nodes» может иметь смысл (хотя в то время она уже показала свою слабость). В последующие месяцы мы попытались найти дополнительные способы превратить TurboFan в жизнеспособную, общую замену для Crankshaft. Но так же мы изо всех сил пытались создать еще одно подмножество JavaScript, который можно было бы развивать самостоятельно, подобно тому, как мы начали с asm.js.

В середине 2015 года мы начали понимать, что TurboFan решает проблемы, которых у нас нет, и что нам, возможно, придется вернуться к чертежной доске, чтобы выяснить, в чём V8 испытывает затруднения в «дикой природе». В то время мы не привлекали сообщество, и мой личный ответ разработчикам, которые слишком сильно мне досаждали, часто был негативным, в стиле «ваш JavaScript делает неправильные вещи», превратившийся со временем в «если ваш код медленно исполняется в V8, значит вы написали медленный код» в умах людей. Сделав шаг назад в попытке увидеть всю картину, я медленно осознал, что множество страданий возникло из-за того, что мы были слишком сосредоточены на случаях пиковой производительности, в то время как базовая производительность была для нас слепым пятном.

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

V8 походил на эту скалу. Если вы присмотритесь, то это потрясающе и красиво. Но если вы этого не сделаете, и упадете со скалы, вы будете измочаленны. Разница в производительности в 100 раз не была редкостью в прошлом. Будучи одной из этих скал, обработка объекта arguments в Crankshaft, вероятно, является наиболее часто встречаемой людьми и самой неприятной. Фундаментальное предположение в Crankshaft заключается в том, что объект arguments не уходит, и Crankshaft не нужно материализовать фактический объект argumentsJavaScript каждый раз, вместо этого он может просто взять параметры из записи активации. Другими словами, нет никакой страховки. Это все или ничего. Рассмотрим эту простую логику диспетчеризации:

var callbacks = [
function sloppy() {},
function strict() { "use strict"; }
];
function dispatch() {
for (var l = callbacks.length, i = 0; i < l; ++i) {
callbacks[i].apply(null, arguments);
}
}
for(var i = 0; i < 100000; ++i) {
dispatch(1, 2, 3, 4, 5);
}

Если взглянуть на нее наивно, кажется, что она следуют правилам объекта arguments в Crankshaft: в функции диспетчеризации мы используем только аргументы вместе с Function.prototype.apply. Тем не менее, запуск этого простого example.js в node.js говорит нам, что для функции диспетчеризации отключены все оптимизации:

$ node --trace-opt example.js
...
[marking 0x353f56bcd659 <JS Function dispatch (SharedFunctionInfo 0x187ffee58fc9)> for optimized recompilation, reason: small function, ICs with typeinfo: 6/7 (85%), generic ICs: 0/7 (0%)]
[compiling method 0x353f56bcd659 <JS Function dispatch (SharedFunctionInfo 0x187ffee58fc9)> using Crankshaft]
[disabled optimization for 0x167a24a58fc9 <SharedFunctionInfo dispatch>, reason: Bad value context for arguments value]
$

Причина в бесславном Bad value context for arguments value. Итак, в чем проблема? Несмотря на код, созданный по всем правилам объекта arguments, он падает со скалы производительности. Реальная причина довольно тонкая: Crankshaft может оптимизировать fn.apply(receiver, arguments), если только он знает, что fn.apply является Function.prototype.apply, и он знает это только для мономорфного доступа к свойству fn.apply. То есть, в терминологии V8, fn должен иметь точно такую же скрытую карту классов все время. Но коллбэки[0] и коллбэки[1] имеют разные карты, так как коллбэки[0] являются функцией в нестрогом режиме, тогда как коллбэки[1] - функции в строгом режиме:

$ cat example2.js
var callbacks = [
function sloppy() {},
function strict() { "use strict"; }
];
console.log(%HaveSameMap(callbacks[0], callbacks[1]));
$ node --allow-natives-syntax example2.js
false
$

TurboFan, с другой стороны, с радостью оптимизирует функцию диспетчеризации (используя последний Node.js LKGR):

$ node --trace-opt --future example.js
[marking 0x20fa7d04cee9 <JS Function dispatch (SharedFunctionInfo 0x27431e85d299)> for optimized recompilation, reason: small function, ICs with typeinfo: 6/6 (100%), generic ICs: 0/6 (0%)]
[compiling method 0x20fa7d04cee9 <JS Function dispatch (SharedFunctionInfo 0x27431e85d299)> using TurboFan]
[optimizing 0x1c22925834d9 <JS Function dispatch (SharedFunctionInfo 0x27431e85d299)> - took 0.526, 0.513, 0.069 ms]
[completed optimizing 0x1c22925834d9 <JS Function dispatch (SharedFunctionInfo 0x27431e85d299)>]
...
$

В этом тривиальном примере разница в производительности уже составляет 2.5x, и TurboFan ещё даже не генерирует впечатляющий код. Таким образом, вы получаете ускорение производительности только потому, что перед вами стоит выбор из двух крайностей: быстрый путь или медленный путь. А сосредоточенность V8 на быстром пути в прошлом часто приводила к ещё большему замедлению медленного пути, к примеру потому что вы платите больше за отслеживание отзывов, что нужно для создания почти идеального кода в некоторых быстрых случаях, или просто потому, что вам нужно провалиться сквозь множество проверок, чтобы добраться до медленного пути.

Делая шаг назад снова: если TurboFan должен был нам помочь, то он должен был что-то делать и по медленному пути. И мы договорились, что нам нужно будет решить две вещи, чтобы это произошло:

  1. Расширить возможности быстрого пути.
  2. Улучшить медленный путь.

Расширение быстрого пути имеет решающее значение для обеспечения того, чтобы ресурсы, которые движок JavaScript тратит на оптимизацию вашего кода, на самом деле окупались. Например, абсолютно бесполезна трата ресурсов для сбора обратной связи по типам и профилирование функции до того, пока она не станет горячей, просто чтобы затем понять, что она использует объект arguments неподдерживаемым способом. Заявленная цель оптимизирующего компилятора TurboFan - поддерживать полный язык и всегда платить за себя. В новом мире выбор уровней от Ignition до TurboFan всегда является победой с точки зрения скорости выполнения. В этом смысле TurboFan - это своего рода лучший Crankshaft.

Но это само по себе не помогло бы, особенно потому, что компиляция TurboFan дороже, чем у Crankshaft (вы действительно должны признать, что огромная инженерная работа, проведённая при создании Crankshaft, до сих пор сияет как основная часть движка Dart). Фактически производительность в реальном мире сильно пострадала бы от того, чтобы во многих случаях просто заменить Crankshaft на TurboFan. И в реальном мире производительность начинает серьезно проседать у V8 и Chrome, поскольку мы переходим в мир, где большая часть веб-трафика поступает с мобильных устройств, и все больше этих устройств являются устройствами Android с низким уровнем производительности. В этом мире время загрузки страницы и низкие накладные расходы, как памяти, так и исполнения, имеют решающее значение для успеха. Например, мы обнаружили, что 30% управляемой памяти в типичных веб-приложениях использовалось объектами Code:

Источник: V8: Hooking up the Ignition to the TurboFan, BlinkOn 7 conference, @rossmcilroy and @leszekswirski.

Это значит, что 30% памяти занято виртуальной машиной для поддержки внутреннего исполнения. Это много! Подавляющее большинство этих объектов Code получено из Full-Codegen и системы IC (inline caching). V8 традиционно использует для генерации машинного кода для каждой функции, которую он выполняет, компилятор Full-Codegen. Это означает, что даже если функция выполняется только один или два раза во время загрузки страницы, мы все равно создадим для нее объект Code. И эти объекты кода были действительно огромны, потому что Full-Codegen на самом деле не использует никаких серьезных оптимизаций (предполагается, что код будет сгенерирован как можно быстрее). В прошлом мы добавляли смягчающие меры, такие как механизм старения кода, в котором GC (сборщик мусора), в конечном счете, найдет объекты Code для функций, не выполнявшихся в течение определенного периода времени.

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

Источник: V8: Hooking up the Ignition to the TurboFan, BlinkOn 7 conference, @rossmcilroy and @leszekswirski.

Новый интерпретатор — большой выигрыш для V8. Но его влияние на время загрузки страницы и базовую производительность не является полностью положительным. Проблемы с медленными путями, особенно в системе IC (inline-caching), остаются даже при использовании Ignition (и TurboFan). Ключевым моментом здесь был традиционный подход, заключающийся в том, что выделенные кодовые заглушки, так называемые хэндлеры, для различных комбинаций карт скрытых классов и имен для ускорения доступа к свойствам не масштабируются. Например, для каждого доступа к свойствам o.x, исполняемого V8, он генерирует один объект Code для каждой карты o, проверяющий, присутствует ли o в этой карте, и, если да, загружает значение x в соответствии с этой картой. Таким образом, знания об объекте и способ, как добраться до значения свойства, были закодированы в крошечных объектах Code. Это внесло большой вклад в накладные расходы на общую память кода, а также было довольно дорогостоящим с точки зрения использования кэша команд. Но еще хуже, V8 должен был генерировать эти объекты Code для каждого доступа к свойствам, выполняющегося как минимум два раза (мы смягчили накладные расходы не делая этого при первом выполнении).

Некоторые веб-страницы тратят значительное количество времени только на создание этих обработчиков доступа к свойству во время загрузки страницы. Опять же, замена оптимизирующего компилятора не помогла бы вообще, но вместо этого мы смогли выделить основанную на TurboFan архитектуру генерации кода, которая была внедрена в Ignition чтобы иметь возможность использовать ее для заглушек кода. Это позволило нам реорганизовать систему IC, чтобы отойти от хэндлеров объектов Code к подходу, основанному на данных, где информация о том, как загружать или хранить свойство, кодируется через формат данных, а TurboFan на основе кодовых заглушек (таких как LoadIC, StoreIC, и т.д.) читает этот формат и выполняет соответствующие действия, используя новую структуру данных, так называемый FeedbackVector, который теперь привязан к каждой функции и отвечает за запись и управление всеми отзывами о выполнении, необходимыми для ускорения выполнения JavaScript.

Это значительно снижает нагрузку на выполнение во время загрузки страницы, а также значительно уменьшает количество крошечных объектов кода. Новый механизм абстракции, который мы создаем на основе архитектуры генерации кода TurboFan, называется CodeStubAssembler, который представляет из себя основанный на C++ DSL (domain specific language) предназначенный для генерации машинного кода в очень компактном режиме. С помощью этого компактного ассемблера мы сможем генерировать высокоэффективный код даже для медленного пути в JavaScript, не обращаясь к среде выполнения C++ (что является по-настоящему медленным путем).

В V8 была третья область, традиционно страдавшая от непредсказуемой базовой производительности: встроенные функции, определенные языком JavaScript. Это библиотечные функции, такие как Object.create, Function.prototype.bind или String.prototype.charCodeAt. Традиционно они были реализованы в громоздком сочетании самодостаточного JavaScript, рукописного машинного кода (по одному для каждой из девяти поддерживаемых архитектур V8), частичных быстрых путей в Crankshaft и рантайм фоллбеков на C++. Это было не только серьезным источником ошибок, связанных с корректностью, стабильностью и безопасностью, но и одним из основных факторов, способствовавших непредсказуемым результатам.

Например, использование Object.create в простых микротестах часто показывало довольно хорошую производительность, но как только он попадал в реальное приложение, где у вас есть несколько разных библиотек, использующих его и тем самым создающих пересекающуюся обратную связь, производительность значительно снижалась. И это загрязнение обратной связи приводило к падению производительности в функциях, которые будут использовать результирующие объекты. В настоящее время Object.create вызывает встроенный TurboFan, основанный на технологии CodeStubAssembler, и обеспечивает предсказуемую, достойную производительность, более или менее независимую от использования.

Другим ярким примером является Function.prototype.bind, которая была довольно популярной отправной точкой для обвинения V8 в плохой встроенной производительности (например, John-David Dalton сделал привычкой указывать на низкую производительность связанных функций в V8... и он был прав). Реализация Function.prototype.bind в V8 два года назад была в основном такой:

Обратите внимание, что %Foo является специальным внутренним синтаксисом и означает вызов функции Foo в среде выполнения C++, тогда как %_Bar - это специальный внутренний синтаксис для встраивания некоторой ассемблерной вставки, идентифицируемой Bar. Я предлагаю читателю самому понять, почему этот код будет медленным, учитывая, что переход в среду выполнения C++ довольно дорог (о нем вы также можете прочитать здесь). Простое переписывание этого встроенного кода в более понятный способ (изначально полностью основанный на единственной реализации на C++) и обеспечение более простой реализации для связанных функций дало 60 000% улучшение. Окончательный прирост производительности был достигнут, когда встроенная функция Function.prototype.bind была портирована в CodeStubAssembler.

Еще одним примером была реализация Promise в V8, которая сильно страдала, и люди предпочитали использовать полифилы, несмотря на то, что V8 обеспечивал нативную реализацию Promise. Портируя реализацию Promise на CodeStubAssembler, мы смогли ускорить промисы и async/await на 500%.

Источник: V8 release 5.7.

Поэтому, несмотря на то, что это самый известный компонент во всей истории TurboFan, фактически оптимизирующий компилятор — это только одна часть головоломки, и в зависимости от того, как вы смотрите на нее, это даже не самая важная часть. Ниже представлен приблизительный эскиз текущей архитектуры генерации кода TurboFan. Многие из этих компонентов уже поставляются в Chrome. Например, многие встроенные модули уже долгое время используют TurboFan, Ignition включен для младших Android-устройств, начиная с Chrome 53, и большая часть data-driven IC уже доступна. Таким образом, окончательный запуск полного конвейера, вероятно, является самым важным событием во всей истории TurboFan, но в некотором смысле это просто вишенка на торте.

Для меня лично это только начало, поскольку новая архитектура открывает совершенно новый мир возможностей оптимизаций для JavaScript. Будет интересно продвигать оптимизацию встроенных методов Array, таких как Array.prototype.map, Array.prototype.forEach и т.д., и, наконец, возможность встраивать их в оптимизированный TurboFan код, который более или менее принципиально невозможен в Crankshaft по нескольким причинам. И я также с нетерпением жду путей дальнейшего повышения производительности новых функций языка ES2015+.

Одна вещь, которая меня очень радует, это то, что ознакомление новых людей с кодом, написанным на TurboFan, производит гораздо более приятное впечатление, чем ознакомление со странным сочетанием Crankshaft, Full-Codegen, автономного JavaScript, рукописного машинного кода и C++, которое у нас было в прошлом.

Читайте нас на медиуме, контрибьютьте на гитхабе, общайтесь в группе телеграма, следите в твиттере и канале телеграма, скоро подъедет подкаст. Не теряйтесь.

--

--

Andrey Melikhov
devSchacht

Web-developer in big IT company Перевожу всё, до чего дотянусь. Иногда (но редко) пишу сам.