Улучшение производительности `DataView` в V8
DataView
s — это один из двух возможных способов низкоуровневого доступа к памяти в JavaScript, другой способ — TypedArray
s. До сегодняшнего дня DataView
s были гораздо менее оптимизированы, чем TypedArray
s в V8, что приводило к снижению производительности при выполнении задач, таких как графически интенсивные рабочие нагрузки или при декодировании/кодировании бинарных данных. Причины этого в основном исторические, например, тот факт, что asm.js выбрал TypedArray
s вместо DataView
s, что стимулировало движки сосредоточиться на производительности TypedArray
.
Из-за падения производительности разработчики JavaScript, такие как команда Google Maps, решили избегать DataView
s и отказаться в пользу использования TypedArray
s, несмотря на рост сложности кода. В этой статье объясняется, как мы сделали производительность DataView
наравне, а в некоторых случаях превосходящей эквивалентный код TypedArray
в V8 v6.9, эффективно делая DataView
пригодным для критически важных реальных приложений.
Основы
С момента появления ES2015 JavaScript поддерживает чтение и запись данных в необработанные бинарные буферы, называемые ArrayBuffer
s. ArrayBuffer
s нельзя использовать напрямую, вместо этого программы должны использовать так называемый объект array buffer view, которым может быть либо DataView
, либо TypedArray
.
TypedArray
s позволяют программам получать доступ к буферу как к массиву данных одного типа, например, Int16Array
или Float32Array
.
const buffer = new ArrayBuffer(32);
const array = new Int16Array(buffer);
for (let i = 0; i < array.length; i++) {
array[i] = i * i;
}
console.log(array);
// → [0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225]
С другой стороны, DataView
s позволяют более детально управлять доступом к данным. Они дают программисту возможность выбирать типы данных для чтения из буфера и записи в него, предоставляя специализированные методы для каждого числового типа, что делает их удобными для сериализации структур данных.
const buffer = new ArrayBuffer(32);
const view = new DataView(buffer);
const person = { age: 42, height: 1.76 };
view.setUint8(0, person.age);
view.setFloat64(1, person.height);
console.log(view.getUint8(0)); // Ожидаемый вывод: 42
console.log(view.getFloat64(1)); // Ожидаемый вывод: 1.76
Кроме того, DataView
s также позволяют выбрать порядок байтов в хранящихся данных, что может быть полезным при получении данных от внешних источников, таких как сеть, файл или GPU.
const buffer = new ArrayBuffer(32);
const view = new DataView(buffer);
view.setInt32(0, 0x8BADF00D, true); // Запись с little-endian.
console.log(view.getInt32(0, false)); // Чтение с big-endian.
// Ожидаемый вывод: 0x0DF0AD8B (233876875)
Эффективная реализация DataView
была запрошена давно (см. этот баг-репорт более чем 5 лет назад), и мы рады объявить, что производительность DataView
теперь соответствует ожиданиям!
Устаревшая реализация
До недавнего времени методы DataView
были реализованы как встроенные функции времени выполнения на C++ в V8. Это было очень дорого, так как каждый вызов требовал затратной перехода из JavaScript в C++ и обратно.
Чтобы исследовать фактическую стоимость производительности, вызванную этой реализацией, мы создали тест производительности, который сравнивает нативную реализацию метода получения данных DataView
с JavaScript-обёрткой, симулирующей поведение DataView
. Эта обёртка использует Uint8Array
для чтения данных байт за байтом из исходного буфера, а затем вычисляет возвращаемое значение из этих байтов. Вот, например, функция для чтения 32-битных беззнаковых чисел с использованием little-endian порядка:
function LittleEndian(buffer) { // Симуляция чтений little-endian DataView.
this.uint8View_ = new Uint8Array(buffer);
}
LittleEndian.prototype.getUint32 = function(byteOffset) {
return this.uint8View_[byteOffset] |
(this.uint8View_[byteOffset + 1] << 8) |
(this.uint8View_[byteOffset + 2] << 16) |
(this.uint8View_[byteOffset + 3] << 24);
};
TypedArray
уже сильно оптимизированы в V8, поэтому они представляют собой ту цель по производительности, которой мы хотели достичь.
Наш тест показал, что производительность встроенных методов чтения DataView
была до 4 раз медленнее, чем обертка на основе Uint8Array
, как для чтения с использованием big-endian, так и little-endian.
Улучшение базовой производительности
Нашим первым шагом в улучшении производительности объектов DataView
стала переноска их реализации из C++ рантайма в CodeStubAssembler
(также известный как CSA). CSA является портативным языком ассемблера, который позволяет нам писать код непосредственно в машинном промежуточном представлении TurboFan (IR), и мы используем его для реализации оптимизированных частей стандартной библиотеки JavaScript в V8. Переписывание кода в CSA полностью исключает обращение к C++, а также генерирует эффективный машинный код, используя бэкенд TurboFan.
Однако написание кода CSA вручную — это хлопотное дело. Контроль потока данных в CSA выражается подобно ассемблеру, используя явные метки и goto
, что делает код труднее для быстрого понимания.
Чтобы упростить разработчикам работу с оптимизированной стандартной библиотекой JavaScript в V8, а также улучшить читаемость и поддерживаемость, мы начали проектировать новый язык, названный V8 Torque, который компилируется в CSA. Цель Torque состоит в том, чтобы отвлечься от низкоуровневых деталей, которые усложняют написание и поддержку кода CSA, при сохранении аналогичного профиля производительности.
Переписывание кода DataView
стало отличной возможностью начать использовать Torque для нового кода и дало разработчикам Torque много обратной связи о языке. Вот как выглядит метод getUint32()
в DataView
, написанный на Torque:
macro LoadDataViewUint32(buffer: JSArrayBuffer, offset: intptr,
requested_little_endian: bool,
signed: constexpr bool): Number {
let data_pointer: RawPtr = buffer.backing_store;
let b0: uint32 = LoadUint8(data_pointer, offset);
let b1: uint32 = LoadUint8(data_pointer, offset + 1);
let b2: uint32 = LoadUint8(data_pointer, offset + 2);
let b3: uint32 = LoadUint8(data_pointer, offset + 3);
let result: uint32;
if (requested_little_endian) {
result = (b3 << 24) | (b2 << 16) | (b1 << 8) | b0;
} else {
result = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3;
}
return convert<Number>(result);
}
Перенос методов DataView
в Torque уже показал 3-кратное улучшение производительности, но все еще не достиг уровня производительности обертки на основе Uint8Array
.
Оптимизация для TurboFan
Когда JavaScript-код становится горячим, мы компилируем его, используя наш оптимизирующий компилятор TurboFan, чтобы создать высокооптимизированный машиноориентированный код, который работает более эффективно, чем интерпретируемый байткод.
TurboFan работает, преобразуя входящий JavaScript-код во внутреннее графовое представление (точнее, «море узлов»). Он начинает с высокоуровневых узлов, которые соответствуют операциям и семантике JavaScript, и постепенно преобразует их в более низкоуровневые узлы, пока, наконец, не генерирует машинный код.
В частности, вызов функции, такой как вызов одного из методов DataView
, внутренне представлен как узел JSCall
, который в конечном итоге сводится к реальному вызову функции в созданном машинном коде.
Однако TurboFan позволяет проверить, является ли узел JSCall
вызовом известной функции, например одной из встроенных функций, и встроить этот узел в IR. Это означает, что сложный узел JSCall
заменяется на этапе компиляции подграфом, который представляет функцию. Это позволяет TurboFan оптимизировать внутренности функции в следующих проходах как часть более широкого контекста, а не саму по себе, и, что наиболее важно, избавиться от затратного вызова функции.
Реализация инлайнинга в TurboFan наконец позволила нам догнать и даже превзойти производительность нашей обертки на основе Uint8Array
и быть в 8 раз быстрее, чем прежняя реализация на C++.
Дополнительные оптимизации TurboFan
Анализ машинного кода, созданного TurboFan после инлайнинга методов DataView
, показал, что еще есть потенциал для улучшений. Первая реализация этих методов пыталась довольно точно следовать стандарту и вызывала ошибки, когда это указано спецификацией (например, при попытке чтения или записи за пределами базового ArrayBuffer
).
Тем не менее код, который мы пишем в TurboFan, предназначен для оптимизации, чтобы быть как можно быстрее для обычных, часто используемых случаев — ему не нужно поддерживать все возможные крайние случаи. Устранив сложную обработку этих ошибок и просто деоптимизировавшись обратно к базовой реализации Torque, когда нам необходимо генерировать исключение, мы смогли уменьшить размер генерированного кода примерно на 35%, обеспечив заметное ускорение, а также значительно упростив код TurboFan.
Следуя этой идее максимальной специализации в TurboFan, мы также удалили поддержку индексов или смещений, которые слишком велики (выходят за пределы диапазона Smi) в коде, оптимизированном для TurboFan. Это позволило нам избавиться от обработки арифметики float64, которая необходима для смещений, не помещающихся в 32-разрядное значение, и избежать хранения больших целых чисел в куче.
По сравнению с первоначальной реализацией TurboFan, это более чем удвоило результат теста производительности DataView
. DataView
теперь работают до трех раз быстрее, чем оболочка Uint8Array
, и примерно в 16 раз быстрее по сравнению с нашей первоначальной реализацией DataView
!
Влияние
Мы оценили влияние новой реализации на производительность на некоторых примерах из реального мира, помимо нашего собственного теста.
DataView
часто используется при декодировании данных, кодированных в бинарных форматах, из JavaScript. Одним из таких бинарных форматов является FBX — формат, используемый для обмена 3D-анимациями. Мы проинструментировали загрузчик FBX популярной библиотеки JavaScript для 3D-графики three.js и измерили снижение времени выполнения на 10% (около 80 мс).
Мы сравнили общую производительность DataView
с TypedArray
. Мы обнаружили, что новая реализация DataView
обеспечивает практически такую же производительность, как и TypedArray
, при доступе к данным, выровненным к естественной байтовой последовательности (little-endian на процессорах Intel), сокращая большую часть разрыва в производительности и делая DataView
практическим выбором в V8.
Мы надеемся, что теперь вы сможете начать использовать DataView
там, где это имеет смысл, вместо того чтобы полагаться на имитации TypedArray
. Пожалуйста, отправляйте нам свои отзывы о вашем использовании DataView
! Вы можете связаться с нами через наш трекер ошибок, по электронной почте [email protected] или через @v8js в Twitter.