Перейти к основному содержимому

Ускорение V8 с помощью изменяемых чисел в куче

· 5 мин. чтения
[Виктор Гомес](https://twitter.com/VictorBFG), манипулятор битами

В V8 мы постоянно стремимся улучшать производительность JavaScript. В рамках этих усилий мы недавно пересмотрели набор бенчмарков JetStream2, чтобы устранить узкие места производительности. В этом посте описывается конкретная оптимизация, которую мы внедрили, что привело к значительному улучшению результата async-fs в 2.5x, способствуя заметному улучшению общего счета. Оптимизация была вдохновлена этим бенчмарком, но подобные шаблоны действительно встречаются в реальном коде.

Бенчмарк async-fs, как следует из названия, представляет собой реализацию файловой системы на JavaScript, сфокусированную на асинхронных операциях. Однако существует неожиданное узкое место производительности: реализация Math.random. Для обеспечения консистентных результатов за несколько прогонов используется собственная, детерминированная реализация Math.random. Вот как она выглядит:

let seed;
Math.random = (function() {
return function () {
seed = ((seed + 0x7ed55d16) + (seed << 12)) & 0xffffffff;
seed = ((seed ^ 0xc761c23c) ^ (seed >>> 19)) & 0xffffffff;
seed = ((seed + 0x165667b1) + (seed << 5)) & 0xffffffff;
seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xffffffff;
seed = ((seed + 0xfd7046c5) + (seed << 3)) & 0xffffffff;
seed = ((seed ^ 0xb55a4f09) ^ (seed >>> 16)) & 0xffffffff;
return (seed & 0xfffffff) / 0x10000000;
};
})();

Ключевой переменной здесь является seed. Она обновляется при каждом вызове Math.random, генерируя псевдослучайную последовательность. Важно, что seed хранится в ScriptContext.

ScriptContext служит местом хранения значений, доступных в определенном скрипте. Внутренне этот контекст представлен как массив тегированных значений V8. По умолчанию в конфигурации 64-битных систем V8 каждое из этих значений занимает 32 бита. Младший значащий бит каждого значения действует как тег. Значение 0 указывает на 31-битное малое целое число (SMI). Само целое значение хранится прямо, сдвинутое влево на один бит. Значение 1 указывает на сжатый указатель на объект в куче, где значение сжатого указателя увеличивается на единицу.

Структура ScriptContext: синие слоты — указатели на метаданные контекста и глобальный объект (NativeContext). Желтый слот указывает на неотмеченное число двойной точности с плавающей запятой.

Это маркирование определяет, как хранятся числа. SMI находятся непосредственно в ScriptContext. Более крупные числа или числа с десятичными частями хранятся косвенно как неизменяемые объекты HeapNumber в куче (64-битный double), а в ScriptContext хранится сжатый указатель на них. Такой подход эффективно обрабатывает различные типы чисел, оптимизируя общий случай SMI.

Узкое место

Профилирование Math.random выявило два основных узких места производительности:

  • Выделение HeapNumber: Слот, выделенный для переменной seed в контексте скрипта, указывает на стандартное неизменяемое значение HeapNumber. Каждый раз, когда функция Math.random обновляет seed, в куче должен выделяться новый объект HeapNumber, что приводит к значительной нагрузке на выделение памяти и сборку мусора.

  • Вычисления с плавающей запятой: Хотя вычисления внутри Math.random в основном являются целочисленными операциями (с использованием побитовых сдвигов и сложений), компилятор не может в полной мере использовать это. Поскольку seed хранится как общий HeapNumber, сгенерированный код использует более медленные инструкции для чисел с плавающей запятой. Компилятор не может доказать, что seed всегда будет содержать значение, представимое как целое число. Хотя компилятор мог бы потенциально предположить 32-битные целочисленные диапазоны, V8 в основном фокусируется на SMI. Даже при предположении 32-битного целого числа потребовалась бы потенциально дорогая конверсия из 64-битного числа с плавающей запятой в 32-битное целое число, а также проверка на отсутствие потерь.

Решение

Для решения этих проблем мы внедрили двухчастную оптимизацию:

  • Отслеживание типов слотов / изменяемые слоты для HeapNumber: Мы расширили отслеживание неизменяемых значений в контексте скрипта (переменные 'let', которые были инициализированы, но никогда не изменялись), включив информацию о типах. Мы отслеживаем, является ли значение слота константным, SMI, HeapNumber или общим тегированным значением. Также мы ввели концепцию изменяемых слотов для HeapNumber в контексте скриптов, аналогично изменяемым полям HeapNumber для JSObjects. Вместо указывания на неизменяемый HeapNumber, слот контекста скрипта владеет HeapNumber, и его адрес не должен быть утечкой. Это устраняет необходимость выделять новый HeapNumber при каждом обновлении для оптимизированного кода. Сам HeapNumber изменяется на месте.

  • Изменяемый 'Heap Int32': Мы улучшаем типы слотов в контексте скрипта, чтобы отслеживать, находится ли числовое значение в диапазоне Int32. Если да, изменяемый HeapNumber хранит значение как необработанный Int32. При необходимости переход на double имеет дополнительное преимущество – отсутствует необходимость в перераспределении HeapNumber. В случае Math.random компилятор теперь может наблюдать, что seed постоянно обновляется с помощью операций с целыми числами, и маркировать слот как содержащий изменяемый Int32.

Состояния машины слотов. Зелёная стрелка указывает на переход, вызванный сохранением значения SMI. Синие стрелки обозначают переходы при сохранении значения Int32, а красные стрелки – значения с плавающей запятой двойной точности. Состояние Other действует как поглощающее, предотвращающее дальнейшие переходы.

Важно отметить, что эти оптимизации создают зависимость кода от типа значения, хранимого в слоте контекста. Оптимизированный код, созданный JIT-компилятором, зависит от наличия в слоте определенного типа (здесь - Int32). Если какой-либо код пишет значение в слот seed, изменяющий его тип (например, число с плавающей точкой или строку), оптимизированный код должен будет деоптимизироваться. Эта деоптимизация необходима для обеспечения правильности выполнения. Таким образом, стабильность типа, хранимого в слоте, является ключом к поддержанию максимальной производительности. В случае Math.random побитовая маскировка в алгоритме гарантирует, что переменная seed всегда содержит значение Int32.

Результаты

Эти изменения значительно ускоряют работу функции Math.random:

  • Отсутствие аллокации / быстрые обновления на месте: Значение seed обновляется непосредственно в своем изменяемом слоте в контексте скрипта. Новые объекты не создаются во время выполнения Math.random.

  • Операции с целыми числами: Компилятор, зная, что слот содержит Int32, может генерировать высокооптимизированные инструкции для работы с целыми числами (сдвиги, сложения и т. д.). Это исключает издержки арифметики с плавающей точкой.

Результаты бенчмарка async-fs на Mac M1. Чем выше показатели, тем лучше.

Совокупный эффект этих оптимизаций приводит к впечатляющему ускорению примерно в 2.5x на бенчмарке async-fs. В свою очередь, это улучшает общий результат теста JetStream2 на ~1.6%. Это демонстрирует, что кажущийся простым код может создавать неожиданно значительные узкие места производительности и что маленькие, целевые оптимизации могут оказывать большое влияние не только на тест.