Post #24
210
Ускоряем O(N + T), не меняя Big O. Часть 5. Зачем здесь WebAssembly
После реализации бакетов с показателем benchmark 0,922 секунды, остается два варианта:
1. продолжать бороться с огромным JavaScript файлом;
2. перенести самый горячий участок в WebAssembly.
Как уже можно было догадаться по заголовку, выбираем второй вариант.
Сразу обращаем внимание, что WebAssembly — не кнопка «сделать быстро/хорошо». Если просто переписать медленный алгоритм на C и собрать его в Wasm, плохая асимптотика и неудачный доступ к памяти никуда не исчезнут.
В нашем случае основная работа по структуре данных и алгоритму уже сделана, а WebAssembly понадобится поверх.
Что осталось в JavaScript
JavaScript превратился в небольшой wrapper:
На стороне JS осталось создать память, загрузить Wasm-модуль, прочитать stdin и вывести результат. Парсер, построение бакетов и поиск максимума выполняются уже внутри WebAssembly.
Почему типы имеют значение
В JavaScript одна переменная может в разное время содержать значения разных типов. V8 хорошо оптимизирует стабильный код, но для этого сначала должен собрать информацию о его поведении, построить предположения и предусмотреть откат на более общий путь. В WebAssembly тип каждой операции указан заранее, благодяря этому компилятор заранее знает форму данных и операций.
Это не отменяет проверок границ памяти и не делает запуск автоматически быстрее, но горячий цикл становится более предсказуемым, так как меньше вероятность деоптимизации из-за неожиданного типа.
Ручная раскладка памяти
Вместо отдельных аллокаций Wasm-версия заранее вычисляет адрес каждой структуры. Для упакованных событий используется
Упрощённо это можно представить так:
Нет объекта события, массива массивов и давления на garbage collector. Есть один непрерывный диапазон памяти и смещения внутри него. Но есть нюансы при пересечениях областей.
Почему ответ возвращается как BigInt
Экспортируемая функция возвращает
Максимальный ответ может выходить за диапазон
Преобразование в строку выполняется только один раз перед выводом результата.
Первый результат
Версия, которая читала весь вход непосредственно в
Однако у решения появился существенный недостаток, связанный с увеличенным потреблением памяти. Под вход заранее резервировалось около 270 МБ, а вся линейная память состояла из 6000 страниц WebAssembly (разберем страницы и почему они равны 64 КиБ подробнее в следующей статье. Сейчас можно вернуться к коду JS выше и увидеть выделение памяти initial: 6000):
Мы ускорили вычисления, но по-прежнему держали весь stdin в памяти.
---
#JavaScript #WebAssembly #WASM #MemoryLayout #Parser #Performance #CodeRun #8BitJS
После реализации бакетов с показателем benchmark 0,922 секунды, остается два варианта:
1. продолжать бороться с огромным JavaScript файлом;
2. перенести самый горячий участок в WebAssembly.
Как уже можно было догадаться по заголовку, выбираем второй вариант.
Сразу обращаем внимание, что WebAssembly — не кнопка «сделать быстро/хорошо». Если просто переписать медленный алгоритм на C и собрать его в Wasm, плохая асимптотика и неудачный доступ к памяти никуда не исчезнут.
В нашем случае основная работа по структуре данных и алгоритму уже сделана, а WebAssembly понадобится поверх.
Что осталось в JavaScript
JavaScript превратился в небольшой wrapper:
const fs = require('fs');
const memory = new WebAssembly.Memory({
initial: 6000,
maximum: 6000,
});
const module = new WebAssembly.Module(binary);
const instance = new WebAssembly.Instance(module, {
env: { memory },
});
const { solve } = instance.exports;
На стороне JS осталось создать память, загрузить Wasm-модуль, прочитать stdin и вывести результат. Парсер, построение бакетов и поиск максимума выполняются уже внутри WebAssembly.
Почему типы имеют значение
В JavaScript одна переменная может в разное время содержать значения разных типов. V8 хорошо оптимизирует стабильный код, но для этого сначала должен собрать информацию о его поведении, построить предположения и предусмотреть откат на более общий путь. В WebAssembly тип каждой операции указан заранее, благодяря этому компилятор заранее знает форму данных и операций.
Это не отменяет проверок границ памяти и не делает запуск автоматически быстрее, но горячий цикл становится более предсказуемым, так как меньше вероятность деоптимизации из-за неожиданного типа.
Ручная раскладка памяти
Вместо отдельных аллокаций Wasm-версия заранее вычисляет адрес каждой структуры. Для упакованных событий используется
uint32, для текущей суммы и ответа int64.Упрощённо это можно представить так:
uint32_t *pool = arena;
uint32_t *next = pool + pool_length;
uint32_t *head = next + chunk_count;
uint32_t *tail = head + bucket_count;
int64_t *local = align8(tail + bucket_count);
Нет объекта события, массива массивов и давления на garbage collector. Есть один непрерывный диапазон памяти и смещения внутри него. Но есть нюансы при пересечениях областей.
Почему ответ возвращается как BigInt
Экспортируемая функция возвращает
int64. В JavaScript такое значение представляется как BigInt:const answer = solve(offset, length);
process.stdout.write(answer.toString());
Максимальный ответ может выходить за диапазон
int32, а внутри Wasm текущая сумма остаётся настоящим 64-битным целым числом.Преобразование в строку выполняется только один раз перед выводом результата.
Первый результат
Версия, которая читала весь вход непосредственно в
WebAssembly.Memory, достигла примерно 0,710 секунды. По сравнению с предыдущими 0,922 секунды это уже заметный выигрыш.Однако у решения появился существенный недостаток, связанный с увеличенным потреблением памяти. Под вход заранее резервировалось около 270 МБ, а вся линейная память состояла из 6000 страниц WebAssembly (разберем страницы и почему они равны 64 КиБ подробнее в следующей статье. Сейчас можно вернуться к коду JS выше и увидеть выделение памяти initial: 6000):
6000 × 64 КиБ ≈ 375 МиБ
Мы ускорили вычисления, но по-прежнему держали весь stdin в памяти.
---
#JavaScript #WebAssembly #WASM #MemoryLayout #Parser #Performance #CodeRun #8BitJS
- 🔥 1