Введение
Недавно я работал над разработкой браузерной 3D-игры в качестве очередного pet-проекта с помощью движка BabylonJS. И в какой-то момент встал вопрос о необходимости процедурной генерации террейна — уверен, у каждого, кому приходилось сталкиваться с созданием естественных локаций в играх, возникала такая проблема. Вопрос состоит в подборе подходящего алгоритма генерации шума, который затем можно будет использовать в качестве карты глубины, нормалей и/или чего-нибудь еще. Да даже генерация движения воды, ветра и так далее — обычно производятся теми же алгоритмами!
Стоит сказать, что чаще всего используется один из нескольких популярных алгоритмов, вроде Шума Перлина, Симплекса, их кастомных модификаций и так далее. BabylonJS в примерах с генерацией террейна предлагает воспользоваться реализацией Шума Перлина, написанной на JavaScript. Зная то, что этот язык не лучший кандидат для таких низкоуровневых CPU-bound операций, я решил попробовать реализовать Шум Перлина с помощью WebAssembly и затем определить, удалось ли ускорить процесс генерации и если да, то насколько?
Немного о WebAssembly
Официальный сайт позиционирует технологию следующим образом:
WebAssembly (сокращенно Wasm) — это формат двоичных инструкций для стековой виртуальной машины. Wasm разработан как кроссплатформенный таргет компиляции для языков программирования, позволяющий развертывать в интернете клиентские и серверные приложения.
По своей сути, данный формат одновременно является уже скомпилированным набором инструкций в бинарном виде, что позволяет миновать стадии подготовки кода, интерпретации и JIT-компиляции, которые производят браузерные JS-движки, такие как V8 и SpiderMonkey (подробно об этом можно почитать, например, здесь).
Wasm появился не вчера и не год назад, так что уже на сегодняшний момент существуют игровые движки, платформы и редакторы, позволяющие компилировать проект, созданный в них, под Wasm для портирования его в браузеры. Но мы с вами попробуем написать программу и скомпилировать ее под Wasm вручную, без пап, мам и готовых движков. Это не сколько сложнее, сколько более чище и нагляднее в плане эксперимента.
В данном ридми представлен список языков программирования, которые могут быть скомпилированы в Wasm. Для удобства, они разделены на те, что уже активно используются в продакшене, стабильные для использования, нестабильные, в разработке и неподдерживаемые.
В качестве языка был выбран самый простой язык программирования в мире Rust. Он является type and memory safe и не требует Runtime-мусора вроде GC, хотя т.к. наша цель — ускорить процесс как можно сильнее, то ,при должной уверенности в том, что код не упадет и выполнится корретко без UB, можно получить такой же результат и на C/C++/Zig.
Данная статья не о тонкостях реализации конкретного алгоритма на конкретном языке. Так что опустим ревью каждой строчки кода и того, что в них происходит и перейдем сразу к самому интересному — циклу разработки:
Итак, приступим
Не мудрствуя лукаво, было решено просто переписать алгоритм с той самой версии, предлагаемой BabylonJS в вышеупомянутом примере. Тем более, таким образом мы сможем получить лучшее сравнение, чем если бы одна реализация была основана на одном алгоритме, а другая — на слегка измененном или вообще ином, что явно повлияло бы на результаты, исказив их!
Пример предлагал как Шум Перлина 2D, так и 3D. И, ради более показательных результатов, были переписаны оба алгоритма.
Оригинальная реализация алгоритма была сделана с соответствующими верхнеуровневыми функциями для генерации шумов так, что каждая из них принимает на вход координаты точки (x, y для 2D и x, y, z для 3D-версии) и возвращает число с плавающей точкой в случае 2D, и вектор (массив) таких чисел — для 3D-версии.
Первый блин — комом
Переписав оба алгоритма точь-в-точь, я принялся замерять время их выполнения. Для полной сходимости результатов, они оба были загружены на веб-страницу и испытаны в идентичных условиях: 2D-версия была испытана путем вложения алгоритма в двойной цикл, а 3D — в тройной, чтобы те получали все возможные координаты точек плоскости/пространства. Сам файл бенчмарков выглядел следующим образом:
import init, { Perlin } from './wasm-perlin-pkg/wasm_perlin.js'
await init()
const seed = Math.random()
noise.seed(seed)
const noiseWasm = new Perlin(seed)
const experiments = [
['perlinjs', noise],
['webperlin', noiseWasm],
]
experiments.forEach(([id, noise]) => {
const totalStart = Date.now()
for (let x = 0; x < 1e3; x++) {
for (let y = 0; y < 1e3; y++) {
noise.perlin2(x / 100, y / 100)
}
}
const totalEnd = Date.now()
console.log(`[${id}] Rendered in ` + (totalEnd - totalStart) + ' ms')
})
Стоит заметить, что рекомендуется использовать console.time вместо Date.now в случае попыток в профилирование! В нашем случае нужно не просто вывести результат в консоль, и по перфомансу разницы никакой
Код написан, остается лишь открыть веб-страницу и посмотреть в консольку. И что мы видим?
Похоже на ошибку! Разница в 3.5 раза и не в пользу Wasm-версии! Нужно запустить алгоритм, скажем, сотню раз, чтобы получить статистическую точность. Слегка меняем наш код запуска экспериментов:
experiments.forEach(([id, noise]) => {
const result = [...Array(100).keys()].map((i) => {
const totalStart = Date.now()
for (let x = 0; x < 1e3; x++) {
for (let y = 0; y < 1e3; y++) {
noise.perlin2(x / 100, y / 100)
}
}
const totalEnd = Date.now()
return totalEnd - totalStart
})
console.log(id, result)
})
И получаем результаты:
И если вы не всматривались в цифры, и вас не хватил инфаркт, то вот вам те же данные, но уже в виде графика:
Средние значения: 3.55 и 25.16, разница в 7 раз!
Выходит, никакой ошибки нет. Все ясно, результаты говорят сами за себя! JS превосходит Wasm даже в CPU-bound задачах, другого и быть не может!
Или все-таки может?
Рефлексия
Давайте на время отложим идею о том, что мы сравниваем 2 реализации алгоритма, и попытаемся проанализировать сам эксперимент. Нам кажется, что мы сравниваем их в чистом виде. Однако так ли это на самом деле?
Уверен, самые смышленные заметили подвох еще на моменте примеров кода самого эксперимента, но давайте всё же разберем, в чем причина подобных провальных результатов:
Итак мы имеем JS-версию и Wasm-версию алгоритма, написанного на Rust, так что в сборку Wasm-файла не должен попасть всякий рантайм-мусор вроде garbage collector, каких-то платформенных утилит и т.д. И там, и там код чисто алгоритма. Идем дальше.
Стоит вернуться к тому, что данные реализации представляют из себя на уровне их интерфейса функцию, принимающую координаты и возвращающую число. Обе реализации мы испытываем в JS-среде. Получается, при запуске JS-версии алгоритма в JS-среде функция получает JS values, с ними же работает и их же возвращает. А Wasm-версия, кроме самого алгоритма, должна сначала принять данные через WebAssembly ABI, а потом отправить выходные данные через него же. Также происходит вызов Wasm-функции, который, предположительно, происходит также весьма не бесплатно.
В общем-то похоже на выяснение того, кто сильнее: медведь или рыба-клоун, если сравнение мы будем производить в толще морской воды, в которой медведь не то, что не сможет нормально двигаться или дышать, а скорее всего сразу же погибнет из-за огромного давления.
К сожалению, мне не удалось найти достаточного кол-ва информации на просторах интернета, чтобы накидать ссылок в статью, поясняющих за подобные даунсайды при работе с Wasm. Надеюсь, среди читателей найдутся те, кто разбирается в данном вопросе и смогут разъяснить данный момент в комментариях, буду очень признателен.
Дубль два или попытка в great comeback
Чтож, наше предположение состоит в том, что код работает медленно из-за того, что мы вызываем Wasm-функцию, пихаем туда пару-тройку чисел и получаем оттуда одно число. И так много-много раз. И просадка происходит именно в моменте запуска функции + при пробрасывании входных данных и получении выходных.
Тогда, как вариант, можно просто передовать в функцию координаты, с которых начинать генерацию нашей матрицы (в случае 2D алгоритма) или 3D-матрицы (в случае 3D алгоритма), производить генерацию на стороне Wasm и возвращать уже готовые массивы данных. И все это единовременно!
Что же, для этого добавим функцию для генерации матрицы шума в нашу реализацию. Говорил, что не будет примеров кода Rust, а тут:
/// 2D Perlin Noise Matrix
#[wasm_bindgen(js_name = "perlin2Matrix")]
pub fn perlin2_matrix(&self, x: Float, y: Float, scale: Float) -> js_sys::Array {
js_sys::Array::from(&JsValue::from(
(0..(x as usize))
.map(|x| {
JsValue::from(js_sys::Float64Array::from(
&(0..(y as usize))
.map(|y| self.perlin2((x as Float) / scale, (y as Float) / scale))
.collect::<Vec<_>>()[..],
))
})
.collect::<Vec<_>>(),
))
}
И, конечно, приводем к этому же виду использование оригинального алгоритма:
const jsNoise = (x, y, scale) =>
[...Array(x).keys()].map((x) =>
[...Array(y).keys()].map((y) => noise.perlin2(x / scale, y / scale))
)
Реализации 3D-версии показывать, считаю, не имеет смысла — кода больше, а идея та же. Но если вы ценитель эстетики монструозных функциональных однострочников, как я, то держите ссылку.
В итоге, при запуске эксперимента, мы получаем следующие результаты:
Вот, другое дело! Wasm-версия 2D шума отрабатывает быстрее в среднем в 1,78 раз.
И 3D-версия:
Как ни странно, получаем ту же разницу в 1,78 раз. Сомнительно, ну Окэй.
Уверен, если не гнать в выходных данных вложенные друг в друга векторы, то можно добиться прироста производительности еще в несколько раз.
Визуализация шума
Наконец что-то интересное, а не эти ваши куски кода и какие-то графики
Для 2D-версии получаемая величина была нормализирована и отображена как оттенок серого (где 0 — черный, а 1 — белый).
В 3D-версии, напомню, мы получаем не число, а вектор чисел, потому я решил поступить интересным образом: разделить его на 3 части, получить среднее значение каждого, нормализовать, и точно также, как и с 2D-версией, получить значение оттенка, только уже красного, зеленого и синего соответственно для каждой из 3 частей, чтобы получить цветную точку.
Результат можно увидеть у себя в браузере, если перейти по ссылке (да-да, автор в верстку не могет).
Заключение
Подытожить данное приключение хочется тем, что Wasm, как более низкоуровневая технология, безусловно будет быстрее. Главное — правильно ее применять, иначе — можно получить похожий результат и остановиться где-то на первом блине комом.
Комментарии (10)
Cykooz
30.10.2024 06:43А вы пробовали для wasm делать вычисления в
f32
вместоf64
. Полагаю точности первого должно хватить для использования шума в играх.
codecity
30.10.2024 06:43Подытожить данное приключение хочется тем, что Wasm, как более низкоуровневая технология, безусловно будет быстрее.
WASM- это, скорее, не про ускорение JS а про смерть JS. На данном этапе WASM просто как некое дополнение к JS выглядит, но в перспективе туда добавят прямой доступ к DOM, сборщик мусора и пр. - и это уже будет не просто дополнение а автономно существующая и не зависимая от JS подсистема. Основной плюс - можно писать на любом ЯП, т.е. машина прогибается под человека а не наоборот.
Avangardio
30.10.2024 06:43Честно говоря, уже очень сомнительно такое будущее видится. Очень медленно все идет, а единственный, кто толкает жс+другие языки - bun.
Dominux Автор
30.10.2024 06:43Wasm, действительно, изначально задумывался как возможность ранить что-то более низкоуровневое, чем JS, с доступом к памяти, но прямо в веб-браузере. Однако, ее кроссплатформенность, очевидно, становится более предпочтительным плюсом.
Из-за чего лично я с нетерпением ожидаю взлет популярности раскрутки WASI и пакетного менеджера Wasmer, условная попытка раз и навсегда разобраться с необходимостью иметь все виды компиляторов C/C++, make, cmake и тд, чтобы не просто запускать код в брувзерах, а иметь возможность собирать проект на разных языках прямо на хост из исходников чисто засчет того, что Wasm будет выступать как именно кроссплатформенный унифицированный стандарт. Своеобразный доцкер, только без GC. Это ли не пушка?
Cykooz
30.10.2024 06:43Так ведь есть уже для этого "взлетевшее" решение, JVM называется.
Dominux Автор
30.10.2024 06:43JVM не подойдёт для C/C++/Rust/Go/Zig... А тут идея того, что на чем (почти что) не пиши - собираешь на любой комп без кучи симэйков и нужных версий компиляторов
Cykooz
30.10.2024 06:43Почему не подойдёт? Вроде тоже виртуальная машина с байткодом, как и WASM.
Dominux Автор
30.10.2024 06:43Wasm - не виртуальная машина
Действительно, не так мало языков могут быть запущены на JVM. Однако, JVM имеет GC и это платформа, она слишком тяжеловесная
Cykooz
30.10.2024 06:43Ну да, сам wasm это скорее спецификация. Виртульной машиной наверное можно назвать какой-то wasmtime. Но в общем-то смысл же тот же, что и в jvm - компилим один раз в байткод и запускаем везде.
Даже ограничения похожие - нет прямого доступа к окружению, только через интерфейсы "рантайма"
ALapinskas
Тоже потратил много времени, разбираясь с wasm, толи быстрее, толи нет. Прочитал книгу, написал тесты, попробовал в рендере графики, вывод получился такой: обертки для сложных структур могут сильно замедлить wasm, а в равных условиях производительность очень близка.