Короткие встроенные вызовы
В V8 версии 9.1 мы временно отключили встроенные функции на настольных компьютерах. Хотя использование встроенных функций значительно улучшает использование памяти, мы поняли, что вызовы функций между встроенными функциями и кодом, скомпилированным JIT, могут привести к значительным потерям в производительности. Эта стоимость зависит от микроархитектуры процессора. В этом посте мы объясним, почему это происходит, как это влияет на производительность и что мы планируем сделать для долгосрочного решения проблемы.
Распределение кода
Машинный код, генерируемый JIT-компиляторами V8, выделяется динамически на страницах памяти, принадлежащих виртуальной машине. V8 выделяет страницы памяти в непрерывной области адресного пространства, которая сама либо находится в случайном месте памяти (по причинам рандомизации адресного пространства), либо внутри 4-ГиБ виртуальной памяти, которую мы выделяем для сжатия указателей.
Код JIT V8 очень часто вызывает встроенные функции. Встроенные функции — это, по сути, отрывки машинного кода, которые поставляются в составе виртуальной машины. Существуют встроенные функции, которые реализуют полные функции стандартной библиотеки JavaScript, такие как Function.prototype.bind
, но многие встроенные функции представляют собой вспомогательные отрывки машинного кода, которые заполняют пробел между высокоуровневой семантикой JS и низкоуровневыми возможностями процессора. Например, если функция JavaScript хочет вызвать другую функцию JavaScript, обычно реализация функции вызывает встроенную функцию CallFunction
, которая выясняет, как должна быть вызвана целевая функция JavaScript; например, является ли она прокси или обычной функцией, сколько аргументов она ожидает и т.д. Поскольку эти отрывки известны при сборке виртуальной машины, они «встраиваются» в бинарный файл Chrome, что означает, что они находятся внутри области кодов бинарного файла Chrome.
Прямые и косвенные вызовы
На 64-битных архитектурах бинарный файл Chrome, включающий эти встроенные функции, находится на произвольно удаленном расстоянии от кода JIT. С набором инструкций x86-64 это означает, что мы не можем использовать прямые вызовы: они требуют 32-битного знакового значения, которое используется в качестве смещения для адреса вызова, а целевой адрес может находиться более чем за 2 ГиБ. Вместо этого мы должны полагаться на косвенные вызовы через регистр или операнд памяти. Такие вызовы больше зависят от предсказаний, поскольку из самой инструкции вызова сразу не видно, что является целью вызова. На ARM64 мы вообще не можем использовать прямые вызовы, так как диапазон ограничен 128 МБ. Это означает, что в обоих случаях мы полагаемся на точность косвенного предсказателя ветвлений процессора.
Ограничения косвенных предсказаний ветвей
При использовании x86-64 было бы хорошо полагаться на прямые вызовы. Это должно уменьшить нагрузку на косвенный предсказатель ветвлений, так как цель известна после декодирования инструкции, а также не требуется загружать цель в регистр из константы или памяти. Но это не только очевидные различия, видимые на уровне машинного кода.
Из-за Spectre v2 различные комбинации устройств/ОС отключили косвенное предсказание ветвлений. Это означает, что в таких конфигурациях мы столкнемся с очень дорогостоящими задержками при вызове функций JIT, которые зависят от встроенной функции CallFunction
.
Более важно, хотя архитектуры наборов инструкций на 64-бита («высокоуровневый язык процессора») поддерживают косвенные вызовы на удаленные адреса, микроархитектура свободна в реализации оптимизаций с произвольными ограничениями. Похоже, что косвенные предсказатели ветвлений предполагают, что расстояние вызовов не превышает определенное значение (например, 4 ГиБ), требуя меньше памяти для предсказания. Например, руководство по оптимизации Intel прямо указывает:
Для 64-битных приложений производительность предсказания ветвлений может быть негативно затронута, если цель ветвления находится более чем в 4 ГБ от ветвления.
Хотя на ARM64 архитектурный диапазон переходов для прямых вызовов ограничен 128 МБ, оказывается, что у чипа Apple M1 есть такая же микроархитектурная ограничение на диапазон 4 ГБ для предсказания косвенных вызовов. Косвенные вызовы с целью вызова, находящейся дальше, чем 4 ГБ, всегда, как представляется, неправильно предсказываются. Из-за особенно большого буфера перестановки на M1, компонента ЦП, который позволяет выполнять предсказанные инструкции спекулятивно вне очереди, частое неправильное предсказание приводит к исключительно большим штрафам производительности.
Временное решение: копирование встроенных функций
Чтобы избежать затрат на частые неправильные предсказания и по возможности избежать ненужной зависимости от предсказания ветвления на x86-64, мы решили временно скопировать встроенные функции в область компрессии указателей V8 на настольных компьютерах с достаточным объемом памяти. Это размещает скопированный встроенный код рядом с динамически генерируемым кодом. Результаты производительности сильно зависят от конфигурации устройства, но вот некоторые результаты, полученные нашими продуктивными ботами:
Раскладывание встроенных функций увеличивает использование памяти на затронутых устройствах на 1.2-1.4 МБ на каждый экземпляр V8. В качестве более долговременного решения мы ищем возможность выделения JIT-кода ближе к бинарному файлу Chrome. Таким образом, мы сможем повторно встроить встроенные функции для восстановления преимуществ памяти, одновременно улучшив производительность вызовов из кода, сгенерированного V8, в C++ код.