Опубликовано: 30 января 2025 г.
Многие веб-приложения WebAssembly извлекают выгоду из многопоточности так же, как и собственные приложения. Несколько потоков позволяют выполнять больше работы параллельно и переносят тяжелую работу с основного потока, чтобы избежать проблем с задержками. До недавнего времени существовали некоторые общие болевые точки, которые могли возникнуть в таких многопоточных приложениях, связанные с распределением ресурсов и вводом-выводом. К счастью, последние функции Emscripten могут помочь в решении этих проблем. В этом руководстве показано, как эти функции могут в некоторых случаях привести к увеличению скорости в 10 и более раз.
Масштабирование
На следующем графике показано эффективное многопоточное масштабирование в чисто математической нагрузке (из теста, который мы будем использовать в этой статье ):
Это измерение чистых вычислений, которые каждое ядро ЦП может выполнять самостоятельно, поэтому производительность повышается при увеличении количества ядер. Такая нисходящая линия повышения производительности — это именно то, как выглядит хорошее масштабирование. И это показывает, что веб-платформа может очень хорошо выполнять многопоточный машинный код, несмотря на использование веб-воркеров в качестве основы для параллелизма, использование Wasm вместо настоящего машинного кода и другие детали, которые могут показаться менее оптимальными.
Управление кучей: malloc
/ free
malloc
и free
— критически важные функции стандартной библиотеки во всех языках с линейной памятью (например, C, C++, Rust и Zig), используемые для управления всей памятью, которая не является полностью статической или находится в стеке. Emscripten по умолчанию использует dlmalloc
, который является компактной, но эффективной реализацией (он также поддерживает emmalloc
, который еще более компактен, но в некоторых случаях медленнее). Однако многопоточная производительность dlmalloc
ограничена, поскольку он блокирует каждый malloc
/ free
(поскольку существует один глобальный распределитель). Таким образом, вы можете столкнуться с конфликтами и медлительностью, если у вас одновременно много выделений во многих потоках. Вот что происходит, когда вы запускаете невероятно тяжелый тест malloc
:
При увеличении количества ядер производительность не только не улучшается, но становится все хуже и хуже, поскольку каждый поток в течение длительного периода времени ожидает блокировки malloc
. Это наихудший случай, но он может произойти при реальных рабочих нагрузках, если выделено достаточно ресурсов.
mimalloc
Существуют версии dlmalloc
, оптимизированные для многопоточной работы, например ptmalloc3
, который реализует отдельный экземпляр распределителя для каждого потока, что позволяет избежать конфликтов. Существует несколько других распределителей с оптимизацией многопоточности, например jemalloc
и tcmalloc
. Компания Emscripten решила сосредоточиться на недавнем проекте mimalloc
, который представляет собой красиво спроектированный распределитель от Microsoft с очень хорошей переносимостью и производительностью. Используйте его следующим образом:
emcc -sMALLOC=mimalloc
Вот результаты теста malloc
с использованием mimalloc
:
Идеальный! Теперь производительность эффективно масштабируется, становясь все быстрее и быстрее с каждым ядром.
Если вы внимательно посмотрите на данные по одноядерной производительности на последних двух графиках, вы увидите, что dlmalloc
потребовалось 2660 мс, а mimalloc
всего 1466 мс, что означает улучшение скорости почти в 2 раза . Это показывает, что даже в однопоточном приложении вы можете увидеть преимущества более сложной оптимизации mimalloc
, хотя обратите внимание, что за это приходится платить размером кода и использованием памяти (по этой причине dlmalloc
остается значением по умолчанию).
Файлы и ввод-вывод
Многим приложениям необходимо использовать файлы по разным причинам. Например, для загрузки уровней в игре или шрифтов в графическом редакторе. Даже такая операция, как printf
использует файловую систему «под капотом», поскольку она печатает, записывая данные в stdout
.
В однопоточных приложениях это обычно не является проблемой, и Emscripten автоматически избегает связывания с полной поддержкой файловой системы, если все, что вам нужно, это printf
. Однако если вы используете файлы, то доступ к многопоточной файловой системе затруднен, поскольку доступ к файлам должен быть синхронизирован между потоками. Исходная реализация файловой системы в Emscripten, названная «JS FS», поскольку она была реализована на JavaScript, использовала простую модель реализации файловой системы только в основном потоке. Всякий раз, когда другой поток хочет получить доступ к файлу, он передает запрос основному потоку. Это означает, что другой поток блокирует межпоточный запрос, который в конечном итоге обрабатывается основным потоком.
Эта простая модель оптимальна, если к файлам обращается только основной поток, что является обычным шаблоном. Однако если другие потоки выполняют чтение и запись, возникают проблемы. Во-первых, основной поток выполняет работу за другие потоки, что приводит к заметной пользователю задержке. Затем фоновые потоки в конечном итоге ждут освобождения основного потока, чтобы выполнить необходимую им работу, поэтому работа становится медленнее (или, что еще хуже, вы можете оказаться в тупике, если основной поток в настоящее время ожидает этого рабочего потока).
ВасмФС
Чтобы решить эту проблему, у Emscripten есть новая реализация файловой системы WasmFS . WasmFS написана на C++ и скомпилирована в Wasm, в отличие от исходной файловой системы, которая была написана на JavaScript. WasmFS поддерживает доступ к файловой системе из нескольких потоков с минимальными издержками, сохраняя файлы в линейной памяти Wasm, которая используется всеми потоками. Все потоки теперь могут выполнять файловый ввод-вывод с одинаковой производительностью и часто даже могут избежать блокировки друг друга.
Простой тест файловой системы показывает огромное преимущество WasmFS по сравнению со старой JS FS.
При этом выполнение кода файловой системы непосредственно в основном потоке сравнивается с его выполнением в одном потоке pthread. В старой JS FS каждая операция файловой системы должна быть проксирована в основной поток, что делает ее на порядок медленнее в pthread! Это связано с тем, что вместо того, чтобы просто читать/записывать некоторые байты, JS FS осуществляет межпотоковую связь, которая включает в себя блокировки, очередь и ожидание. Напротив, WasmFS может одинаково обращаться к файлам из любого потока, поэтому диаграмма показывает, что между основным потоком и pthread практически нет разницы. В результате WasmFS в pthread работает в 32 раза быстрее, чем JS FS.
Обратите внимание, что есть также разница в основном потоке, где WasmFS работает в 2 раза быстрее. Это связано с тем, что JS FS вызывает JavaScript для каждой операции с файловой системой, чего WasmFS избегает. WasmFS использует JavaScript только при необходимости (например, для использования веб-API), в результате чего большинство файлов WasmFS остается в Wasm. Кроме того, даже если требуется JavaScript, WasmFS может использовать вспомогательный поток, а не основной поток, чтобы избежать видимой пользователем задержки. Из-за этого вы можете увидеть улучшение скорости при использовании WasmFS, даже если ваше приложение не является многопоточным (или если оно многопоточное, но использует файлы только в основном потоке).
Используйте WasmFS следующим образом:
emcc -sWASMFS
WasmFS используется в производстве и считается стабильной, но пока не поддерживает все функции старой JS FS. С другой стороны, он включает в себя некоторые важные новые функции, такие как поддержка исходной частной файловой системы (OPFS, которая настоятельно рекомендуется для постоянного хранения). Если вам не нужна функция, которая еще не была перенесена, команда Emscripten рекомендует использовать WasmFS.
Заключение
Если у вас есть многопоточное приложение, которое выполняет большое количество выделений или использует файлы, вы можете получить большую выгоду, используя WasmFS и/или mimalloc
. Оба варианта легко попробовать в проекте Emscripten, просто перекомпилировав их с флагами, описанными в этом посте.
Возможно, вы даже захотите попробовать эти функции, если вы не используете потоки: как упоминалось ранее, более современные реализации имеют оптимизации, которые в некоторых случаях заметны даже на одном ядре.