Легкий V8
В конце 2018 года мы начали проект под названием V8 Lite, целью которого было значительно уменьшить использование памяти V8. Изначально этот проект был задуман как отдельный легкий режим V8, специально предназначенный для мобильных устройств с низким объемом памяти или сценариев использования, где важнее снижение использования памяти, чем скорость выполнения. Однако в процессе этой работы мы поняли, что многие из сделанных нами оптимизаций памяти для этого легкого режима можно перенести в обычный V8, тем самым улучшив его для всех пользователей.
В этом посте мы выделяем некоторые из ключевых оптимизаций, которые мы разработали, и экономию памяти, которую они обеспечили в реальных условиях использования.
Примечание: Если вам больше нравится смотреть презентации, а не читать статьи, вам понравится видео ниже! Если нет, пропустите видео и продолжайте читать.
Легкий режим
Для того чтобы оптимизировать использование памяти V8, нам прежде всего нужно было понять, как используется память V8 и какие типы объектов составляют большую часть объема heap V8. Мы использовали инструменты визуализации памяти V8 для анализа состава heap на различных типичных веб-страницах.
В результате этого анализа мы выяснили, что значительная часть heap V8 была выделена под объекты, которые не являются необходимыми для выполнения JavaScript, но используются для его оптимизации и обработки исключительных ситуаций. Примеры включают: оптимизированный код; обратную связь типов, используемую для определения способов оптимизации кода; избыточные метаданные для связей между объектами C++ и JavaScript; метаданные, необходимые только в исключительных случаях, таких как символизация трассировки стека; и байткод для функций, которые выполняются лишь несколько раз при загрузке страницы.
В результате этого мы начали работу над легким режимом V8, который жертвует скоростью выполнения JavaScript ради значительной экономии памяти за счет существенного снижения выделения этих необязательных объектов.
Некоторые изменения легкого режима можно было реализовать путем настройки существующих параметров V8, например, отключения оптимизирующего компилятора TurboFan. Однако другие потребовали более сложных изменений в V8.
В частности, мы решили, что поскольку легкий режим не оптимизирует код, мы можем избежать сбора обратной связи типов, необходимой для оптимизирующего компилятора. При выполнении кода в интерпретаторе Ignition V8 собирает информацию о типах операндов, которые передаются в различные операции (например, +
или o.foo
), чтобы позже оптимизировать эти операции для данных типов. Эта информация хранится в векторах обратной связи, которые составляют значительную часть объема памяти heap V8. Легкий режим мог бы избежать выделения этих векторов обратной связи, однако интерпретатор и части инфраструктуры inline cache V8 ожидали наличие таких векторов, и поэтому потребовали значительных изменений для поддержки выполнения без обратной связи.
Легкий режим был введен в V8 v7.3 и обеспечивает уменьшение объема heap типичной веб-страницы на 22% по сравнению с V8 v7.1 за счет отключения оптимизации кода, отказа от выделения векторов обратной связи и выполнения устаревания редко выполняемого байткода (описано ниже). Это отличный результат для приложений, которые специально хотят обменять производительность на лучшее использование памяти. Однако в процессе работы мы поняли, что можем достичь большей части экономии памяти от легкого режима без потери производительности, сделав V8 более ленивым.
Ленивое выделение обратной связи
Полное отключение выделения векторов обратной связи не только предотвращает оптимизацию кода компилятором TurboFan от V8, но также препятствует выполнению V8 инлайн-кэширования общих операций, таких как загрузка свойств объекта в интерпретаторе Ignition. Таким образом, это приводило к значительному падению скорости выполнения V8, увеличивая время загрузки страниц на 12% и увеличивая использование процессора V8 на 120% в типичных сценариях интерактивных веб-страниц.
Чтобы обеспечить большинство этих улучшений для обычного V8 без указанных ограничений, мы перешли к подходу, в котором мы лениво выделяем векторы обратной связи после того, как функция выполнит определенный объем байткода (в настоящее время 1 КБ). Поскольку большинство функций не выполняются очень часто, мы предотвращаем выделение векторов обратной связи в большинстве случаев, но быстро выделяем их там, где это нужно, чтобы избежать падения производительности и при этом позволить оптимизировать код.
Еще одно осложнение с этим подходом связано с тем, что векторы обратной связи образуют дерево, в котором векторы обратной связи внутренних функций хранятся в виде записей в векторе обратной связи внешней функции. Это необходимо для того, чтобы вновь созданные замыкания функций получали тот же массив векторов обратной связи, что и все остальные замыкания, созданные для той же функции. При ленивом выделении векторов обратной связи мы не можем формировать это дерево с использованием векторов обратной связи, так как нет гарантии, что внешняя функция выделит свой вектор обратной связи к моменту, когда это сделает внутренняя функция. Чтобы решить эту проблему, мы создали новый ClosureFeedbackCellArray
для поддержания этого дерева, а затем заменяем ClosureFeedbackCellArray
функции на полноценный FeedbackVector
, когда функция становится горячей.
Наши лабораторные эксперименты и телеметрия на местах не показали падения производительности для ленивой обратной связи на настольных ПК, а на мобильных платформах мы даже наблюдали улучшение производительности на устройствах низкого уровня благодаря сокращению сборки мусора. Таким образом, мы включили ленивое выделение обратной связи во всех сборках V8, включая Lite mode, где небольшое падение использования памяти по сравнению с нашим первоначальным подходом без выделения обратной связи компенсируется улучшением производительности в реальном мире.
Ленивые позиции исходного кода
При компиляции байткода из JavaScript создаются таблицы с положениями исходного кода, которые связывают последовательности байткода с позициями символов в JavaScript-коде. Однако эта информация требуется только при символизации исключений или выполнении задач разработчика, таких как отладка, и поэтому используется редко.
Чтобы избежать этого перерасхода, мы теперь компилируем байткод без сбора позиций исходного кода (если отладчик или профайлер не подключен). Позиции исходного кода собираются только тогда, когда фактически генерируется трассировка стека, например при вызове Error.stack
или выводе трассировки стека исключения в консоль. Это имеет определенную стоимость, так как генерация позиций исходного кода требует повторного анализа и компиляции функции, однако большинство веб-сайтов не символизируют трассировки стека в продакшене, и поэтому не видят ощутимого влияния на производительность.
Одной из проблем, которую нам пришлось решить в рамках этой работы, было требование гарантированного воспроизводимого генерации байткода, что ранее не было гарантировано. Если V8 генерирует различный байткод при сборе позиций исходного кода по сравнению с исходным кодом, то позиции исходного кода не совпадают, и трассировки стека могут указывать на неправильное место в исходном коде.
При определенных обстоятельствах V8 мог генерировать различный байткод в зависимости от того, была ли функция заранее или лениво скомпилирована, из-за утраты некоторых данных парсера между первоначальным заранее выполненным парсингом функции и последующей ленивой компиляцией. Эти несоответствия в основном были безобидными, например потеря отслеживания того, что переменная неизменяема, и, следовательно, невозможность оптимизировать её. Однако некоторые из найденных в рамках этой работы несоответствий могли привести к неверному выполнению кода в определенных обстоятельствах. В результате мы исправили эти несоответствия и добавили проверки и стресс-режим, чтобы гарантировать, что заранее выполненная и ленивый компиляция функции всегда создают согласованный вывод, что позволило нам повысить уверенность в корректности и согласованности парсера и препарсера V8.
Очистка байткода
Байткод, скомпилированный из исходного JavaScript, занимает значительную часть пространства кучи V8, обычно около 15%, включая связанные метаданные. Существует множество функций, которые выполняются только во время инициализации или редко используются после компиляции.
В результате мы добавили поддержку очистки скомпилированного байткода из функций во время сборки мусора, если они не выполнялись недавно. Чтобы сделать это, мы отслеживаем возраст байткода функции, увеличивая возраст при каждом мажорном (mark-compact) сборке мусора и сбрасывая его до нуля, когда функция выполняется. Любой байткод, пересекший порог старения, может быть собран следующей сборкой мусора. Если он будет собран, а затем снова выполнен позже, он будет перекомпилирован.
Были технические сложности, чтобы гарантировать, что байткод сбрасывается только тогда, когда он больше не нужен. Например, если функция A
вызывает другую длительно выполняющуюся функцию B
, функция A
может устареть, находясь в стеке. Мы не хотим сбрасывать байткод функции A
, даже если она достигла порога устаревания, поскольку нам нужно будет вернуться к ней, когда завершится выполнение функции B
. Таким образом, байткод считается слабо связанным с функцией, когда она достигает порога устаревания, но сильно связанным любыми ссылками на него в стеке или в других местах. Мы сбрасываем код только тогда, когда не остается сильных ссылок.
В дополнение к сбросу байткода, мы также сбрасываем векторы обратной связи, связанные с этими сброшенными функциями. Однако мы не можем сбросить вектор обратной связи в рамках того же цикла сборки мусора, что и байткод, поскольку они не удерживаются одним и тем же объектом — байткод удерживается независимым от контекста SharedFunctionInfo
, тогда как вектор обратной связи удерживается зависимым от контекста JSFunction
. В результате мы сбрасываем векторы обратной связи в следующем цикле сборки мусора.
Дополнительные оптимизации
Помимо этих крупных проектов мы также обнаружили и устранили несколько неэффективностей.
Первое — уменьшение размера объектов FunctionTemplateInfo
. Эти объекты хранят внутренние метаданные о FunctionTemplate
s, которые используются для предоставления реализации функций на C++ для вызова их из JavaScript-кода. Chrome вводит множество FunctionTemplate
для реализации DOM API, поэтому объекты FunctionTemplateInfo
увеличивали размер кучи V8. Проанализировав типичное использование FunctionTemplate
, мы обнаружили, что из одиннадцати полей объекта FunctionTemplateInfo
всего три обычно устанавливаются на нестандартные значения. В результате мы разделили объект FunctionTemplateInfo
, и редкие поля теперь хранятся в побочной таблице, которая выделяется только при необходимости.
Вторая оптимизация касается метода отмены оптимизации из оптимизированного TurboFan кода. Поскольку TurboFan выполняет спекулятивные оптимизации, иногда может потребоваться перейти обратно к интерпретатору (отмена оптимизации), если определённые условия перестают выполняться. Каждая точка отмены оптимизации имеет идентификатор, который позволяет определить, куда в байткоде нужно вернуться для выполнения в интерпретаторе. Ранее этот идентификатор расчётно загружался при переходе оптимизированного кода к определенному смещению внутри большой таблицы переходов, которая загружала нужный идентификатор в регистр, а затем выполняла переход к функции выполнения отмены оптимизации. Это позволяло использовать только одну инструкцию перехода для каждой точки отмены оптимизации. Однако таблица переходов отмены оптимизации предвыделялась и должна была быть достаточно большой, чтобы охватывать весь диапазон идентификаторов отмены оптимизации. Вместо этого мы изменили TurboFan так, что точки отмены оптимизации в оптимизированном коде загружали идентификатор непосредственно перед вызовом функции выполнения отмены оптимизации. Это позволило полностью удалить большую таблицу переходов, с небольшим увеличением размера оптимизированного кода.
Результаты
Мы внедрили описанные выше оптимизации за последние семь версий V8. Обычно сначала они добавлялись в Lite mode, затем переносились в стандартную конфигурацию V8.
За это время мы уменьшили размер кучи V8 в среднем на 18% для ряда типичных веб-сайтов, что соответствует среднему уменьшению объема на 1,5 МБ для недорогих мобильных устройств AndroidGo. Это было возможно без значительного влияния на производительность JavaScript как на тестах, так и на взаимодействии с реальными веб-страницами.
Lite mode может обеспечить дополнительную экономию памяти с некоторым снижением пропускной способности выполнения JavaScript, отключив оптимизацию функций. В среднем Lite mode обеспечивает экономию памяти в 22%, а для некоторых страниц сокращение достигает 32%. Это соответствует уменьшению размера кучи V8 на 1,8 МБ на устройстве AndroidGo.
Если разделить по влиянию каждой отдельной оптимизации, становится ясно, что разные страницы получают разную долю выгоды от этих оптимизаций. В будущем мы будем продолжать находить потенциал для оптимизаций, которые помогут дополнительно снизить использование памяти в V8, оставаясь при этом невероятно быстрым при выполнении JavaScript.