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

Улучшение производительности `DataView` в V8

· 8 мин. чтения
Тэотим Гроенс, <i lang="fr">учёный Data-Vue</i>, и Бенедикт Мойрер ([@bmeurer](https://twitter.com/bmeurer)), профессиональный специалист по производительности

DataViews — это один из двух возможных способов низкоуровневого доступа к памяти в JavaScript, другой способ — TypedArrays. До сегодняшнего дня DataViews были гораздо менее оптимизированы, чем TypedArrays в V8, что приводило к снижению производительности при выполнении задач, таких как графически интенсивные рабочие нагрузки или при декодировании/кодировании бинарных данных. Причины этого в основном исторические, например, тот факт, что asm.js выбрал TypedArrays вместо DataViews, что стимулировало движки сосредоточиться на производительности TypedArray.

Из-за падения производительности разработчики JavaScript, такие как команда Google Maps, решили избегать DataViews и отказаться в пользу использования TypedArrays, несмотря на рост сложности кода. В этой статье объясняется, как мы сделали производительность DataView наравне, а в некоторых случаях превосходящей эквивалентный код TypedArray в V8 v6.9, эффективно делая DataView пригодным для критически важных реальных приложений.

Основы

С момента появления ES2015 JavaScript поддерживает чтение и запись данных в необработанные бинарные буферы, называемые ArrayBuffers. ArrayBuffers нельзя использовать напрямую, вместо этого программы должны использовать так называемый объект array buffer view, которым может быть либо DataView, либо TypedArray.

TypedArrays позволяют программам получать доступ к буферу как к массиву данных одного типа, например, 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]

С другой стороны, DataViews позволяют более детально управлять доступом к данным. Они дают программисту возможность выбирать типы данных для чтения из буфера и записи в него, предоставляя специализированные методы для каждого числового типа, что делает их удобными для сериализации структур данных.

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

Кроме того, DataViews также позволяют выбрать порядок байтов в хранящихся данных, что может быть полезным при получении данных от внешних источников, таких как сеть, файл или 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

Наш тест показал, что производительность встроенных методов чтения 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.

Производительность DataView на Torque

Оптимизация для TurboFan

Когда JavaScript-код становится горячим, мы компилируем его, используя наш оптимизирующий компилятор TurboFan, чтобы создать высокооптимизированный машиноориентированный код, который работает более эффективно, чем интерпретируемый байткод.

TurboFan работает, преобразуя входящий JavaScript-код во внутреннее графовое представление (точнее, «море узлов»). Он начинает с высокоуровневых узлов, которые соответствуют операциям и семантике JavaScript, и постепенно преобразует их в более низкоуровневые узлы, пока, наконец, не генерирует машинный код.

В частности, вызов функции, такой как вызов одного из методов DataView, внутренне представлен как узел JSCall, который в конечном итоге сводится к реальному вызову функции в созданном машинном коде.

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

Начальная производительность DataView на TurboFan

Реализация инлайнинга в TurboFan наконец позволила нам догнать и даже превзойти производительность нашей обертки на основе Uint8Array и быть в 8 раз быстрее, чем прежняя реализация на C++.

Дополнительные оптимизации TurboFan

Анализ машинного кода, созданного TurboFan после инлайнинга методов DataView, показал, что еще есть потенциал для улучшений. Первая реализация этих методов пыталась довольно точно следовать стандарту и вызывала ошибки, когда это указано спецификацией (например, при попытке чтения или записи за пределами базового ArrayBuffer).

Тем не менее код, который мы пишем в TurboFan, предназначен для оптимизации, чтобы быть как можно быстрее для обычных, часто используемых случаев — ему не нужно поддерживать все возможные крайние случаи. Устранив сложную обработку этих ошибок и просто деоптимизировавшись обратно к базовой реализации Torque, когда нам необходимо генерировать исключение, мы смогли уменьшить размер генерированного кода примерно на 35%, обеспечив заметное ускорение, а также значительно упростив код TurboFan.

Следуя этой идее максимальной специализации в TurboFan, мы также удалили поддержку индексов или смещений, которые слишком велики (выходят за пределы диапазона Smi) в коде, оптимизированном для TurboFan. Это позволило нам избавиться от обработки арифметики float64, которая необходима для смещений, не помещающихся в 32-разрядное значение, и избежать хранения больших целых чисел в куче.

По сравнению с первоначальной реализацией TurboFan, это более чем удвоило результат теста производительности DataView. DataView теперь работают до трех раз быстрее, чем оболочка Uint8Array, и примерно в 16 раз быстрее по сравнению с нашей первоначальной реализацией DataView!

Финальная производительность TurboFan DataView

Влияние

Мы оценили влияние новой реализации на производительность на некоторых примерах из реального мира, помимо нашего собственного теста.

DataView часто используется при декодировании данных, кодированных в бинарных форматах, из JavaScript. Одним из таких бинарных форматов является FBX — формат, используемый для обмена 3D-анимациями. Мы проинструментировали загрузчик FBX популярной библиотеки JavaScript для 3D-графики three.js и измерили снижение времени выполнения на 10% (около 80 мс).

Мы сравнили общую производительность DataView с TypedArray. Мы обнаружили, что новая реализация DataView обеспечивает практически такую же производительность, как и TypedArray, при доступе к данным, выровненным к естественной байтовой последовательности (little-endian на процессорах Intel), сокращая большую часть разрыва в производительности и делая DataView практическим выбором в V8.

DataView vs. TypedArray пиковая производительность

Мы надеемся, что теперь вы сможете начать использовать DataView там, где это имеет смысл, вместо того чтобы полагаться на имитации TypedArray. Пожалуйста, отправляйте нам свои отзывы о вашем использовании DataView! Вы можете связаться с нами через наш трекер ошибок, по электронной почте [email protected] или через @v8js в Twitter.