Skalowanie wielowątkowych aplikacji WebAssembly za pomocą mimalloc i WasmFS

Alon Zakai
Alon Zakai

Data publikacji: 30 stycznia 2025 r.

Wiele aplikacji WebAssembly w internecie korzysta z wielowątkowości w taki sam sposób jak aplikacje natywne. Wiele wątków pozwala na wykonywanie większej ilości pracy w tym samym czasie, a także przeniesienie wymagających zadań z wątku głównego, aby uniknąć problemów z opóźnieniami. Do niedawna w przypadku takich aplikacji wielowątkowych występowały pewne typowe problemy związane z przydziałem zasobów i wejściem/wyjściem. Na szczęście najnowsze funkcje Emscripten mogą znacznie pomóc w rozwiązaniu tych problemów. Z tego przewodnika dowiesz się, jak te funkcje mogą w niektórych przypadkach przyspieszyć działanie o co najmniej 10 razy.

Skalowanie

Na poniższym wykresie widać wydajne skalowanie wielowątkowe w czystym obciążeniu obliczeniowym (z testu porównawczego, którego użyjemy w tym artykule):

Wykres liniowy o nazwie „Skalowanie matematyczne” pokazuje zależność między liczbą rdzeni (oś X) a czasem wykonania w milisekundach (oś Y, o nazwie

Ten wskaźnik mierzy czystą moc obliczeniową, czyli to, co każde z rdzeni procesora może wykonać samodzielnie. Większa liczba rdzeni powoduje wzrost wydajności. Takie malejące się linie szybszej skuteczności to właśnie właściwe skalowanie. Pokazuje też, że platforma internetowa może bardzo dobrze wykonywać wielowątkowy kod natywny, mimo że używa web workerów jako podstawy dla równoległości, wykorzystuje Wasm zamiast prawdziwego kodu natywnego i innych szczegółów, które mogą wydawać się mniej optymalne.

Zarządzanie stosem: malloc/free

mallocfree to kluczowe standardowe funkcje biblioteki we wszystkich językach z pamięcią liniową (np. C, C++, Rust i Zig), które są używane do zarządzania całą pamięcią, która nie jest całkowicie statyczna lub nie znajduje się na stosie. Domyślnie Emscripten używa dlmalloc, co jest kompaktową, ale wydajną implementacją (obsługuje też emmalloc, która jest jeszcze bardziej zwarta, ale w niektórych przypadkach wolniejsza). Jednak wydajność wielowątkowa funkcji dlmalloc jest ograniczona, ponieważ blokuje ona każdy element malloc/free (ponieważ jest jeden globalny alokator). Dlatego, jeśli masz wiele alokacji w wielu wątkach naraz, możesz napotkać problemy z konkurencją i spowolnieniem. Oto co się dzieje, gdy przeprowadzasz test porównawczy z bardzo dużym obciążeniem malloc:

Wykres liniowy o nazwie dlmalloc scaling pokazuje zależność między liczbą rdzeni (oś X) a czasem wykonania w milisekundach (oś Y, im niższa wartość, tym lepiej). Z trendu wynika, że zwiększenie liczby rdzeni powoduje wydłużenie czasu wykonywania, przy czym wzrost jest stały i liniowy od 1 do 4 rdzeni.

Nie tylko wydajność nie poprawia się wraz z większą liczbą rdzeni, ale wręcz się pogarsza, ponieważ każdy wątek czeka przez długi czas na zablokowanie malloc. Jest to najgorszy możliwy scenariusz, ale może się zdarzyć w przypadku rzeczywistych obciążeń, jeśli jest wystarczająca liczba alokacji.

mimalloc

Istnieją wersje dlmalloc zoptymalizowane pod kątem wielowątkowego działania, np. ptmalloc3, która implementuje osobną instancję alokatora na każdy wątek, co zapobiega konfliktom. Istnieje kilka innych algorytmów przydziału z optymalizacją wielowątkowości, np. jemalloctcmalloc. Emscripten zdecydował się skupić na ostatnim projekcie mimalloc, który jest dobrze zaprojektowanym przydziałem zasobów od Microsoftu o bardzo dobrych parametrach przenośności i wydajności. Użyj go w ten sposób:

emcc -sMALLOC=mimalloc

Oto wyniki testu porównawczego malloc, w którym zastosowano mimalloc:

Wykres liniowy o nazwie mimalloc scaling pokazuje zależność między liczbą rdzeni (oś X) a czasem wykonania w milisekundach (oś Y, im niższa wartość, tym lepiej). Trend wskazuje, że zwiększanie liczby rdzeni skraca czas wykonywania, przy czym spadek jest znaczny w przypadku przejścia z 1 na 2 rdzenie, a łagodniejszy – z 2 na 4 rdzenie.

Super! Teraz wydajność jest skalowana efektywnie, a każdy dodatkowy rdzeń zwiększa szybkość.

Jeśli dokładnie przyjrzysz się danym o wydajności pojedynczego rdzenia w ostatnich 2 wykresach, zauważysz, że dlmalloc zajęło to 2660 ms, a mimalloc tylko 1466 ms, co oznacza prawie 2-krotne przyspieszenie. To pokazuje, że nawet w przypadku aplikacji jednowątkowej możesz odczuć korzyści wynikające z bardziej zaawansowanych optymalizacji mimalloc, choć pamiętaj, że wiąże się to z większym rozmiarem kodu i większym zużyciem pamięci (dlatego dlmalloc pozostaje domyślnym ustawieniem).

Pliki i wejścia-wyjścia

Wiele aplikacji musi używać plików z różnych powodów. Na przykład do wczytania poziomów w grze lub czcionek w edytorze obrazów. Nawet operacja printf korzysta z systemu plików, ponieważ drukuje dane do stdout.

W przypadku aplikacji jednowątkowych zwykle nie stanowi to problemu, a Emscripten automatycznie uniknie linkowania pełnego systemu plików, jeśli potrzebujesz tylko funkcjiprintf. Jeśli jednak używasz plików, dostęp do systemu plików w wielu wątkach jest trudny, ponieważ dostęp do plików musi być zsynchronizowany między wątkami. Pierwotna implementacja systemu plików w Emscripten, nazwana „JS FS”, ponieważ została zaimplementowana w JavaScript, używała prostego modelu implementacji systemu plików tylko w głównym wątku. Gdy inny wątek chce uzyskać dostęp do pliku, przekazuje prośbę do wątku głównego. Oznacza to, że inny wątek blokuje żądanie międzywątkowe, które w końcu jest obsługiwane przez wątek główny.

Ten prosty model jest optymalny, jeśli pliki są dostępne tylko dla wątku głównego, co jest częstym wzorcem. Jeśli jednak inne wątki wykonują operacje odczytu i zapisu, mogą wystąpić problemy. Po pierwsze, wątek główny wykonuje zadania dla innych wątków, co powoduje widoczne dla użytkownika opóźnienie. Następnie wątki w tle czekają, aż główny wątek będzie wolny, aby wykonać potrzebne zadania, przez co wszystko staje się wolniejsze (a co gorsza, może dojść do blokady, jeśli główny wątek czeka na ten roboczy).

WasmFS

Aby rozwiązać ten problem, Emscripten ma nową implementację systemu plików WasmFS. System plików WasmFS jest napisany w C++ i skompilowany do Wasm, w przeciwieństwie do oryginalnego systemu plików, który był w JavaScript. WasmFS obsługuje dostęp do systemu plików z wielu wątków z minimalnym obciążeniem, przechowując pliki w pamięci liniowej Wasm, która jest współdzielona między wszystkimi wątkami. Wszystkie wątki mogą teraz wykonywać operacje wejścia/wyjścia z pliku z równą wydajnością, a często nawet unikać blokowania się nawzajem.

Prosty test porównawczy systemu plików pokazuje ogromną przewagę WasmFS nad starym systemem plików JS.

Wykres słupkowy zatytułowany Wydajność systemu plików porównuje czas wykonywania w milisekundach (oś Y, im niższa wartość, tym lepiej) dla JS FS i WasmFS w 2 kategoriach: główny wątek i pthread (oś X). W przypadku pthread operacja JS FS trwa znacznie dłużej, natomiast w obu przypadkach WasmFS pozostaje na niskim poziomie.

Porównuje uruchamianie kodu systemu plików bezpośrednio na wątku głównym z uruchamianiem go na jednym wątku pthread. W starym systemie plików JS każda operacja systemu plików musi być przekazywana do wątku głównego, co powoduje, że jest ona o kilka rzędów wielkości wolniejsza na wątku pthread. Dzieje się tak, ponieważ zamiast tylko odczytywać i zapisywać poszczególne bajty, JS FS prowadzi komunikację między wątkami, która wymaga blokowania, kolejkowania i czekania. Z kolei WasmFS może równomiernie uzyskiwać dostęp do plików z dowolnego wątku, dlatego wykres pokazuje, że praktycznie nie ma różnicy między wątkiem głównym a wątkiem pomocniczym. W rezultacie WasmFS jest 32 razy szybszy niż JS FS w przypadku wątku pthread.

Zwróć uwagę, że różnica występuje również w głównym wątku, gdzie WasmFS jest 2 razy szybszy. Dzieje się tak, ponieważ JS FS wywołuje JavaScript w przypadku każdej operacji systemu plików, czego WasmFS unika. WasmFS używa JavaScriptu tylko wtedy, gdy jest to konieczne (np. do użycia interfejsu API internetowego), więc większość plików WasmFS jest w Wasm. Nawet wtedy, gdy wymagany jest JavaScript, WasmFS może używać wątku pomocniczego zamiast wątku głównego, aby uniknąć widocznej dla użytkownika zwłoki. Z tego powodu możesz zauważyć przyspieszenie działania dzięki WasmFS, nawet jeśli Twoja aplikacja nie jest wielowątkowa (lub jeśli jest wielowątkowa, ale używa plików tylko w głównym wątku).

Używaj WasmFS w ten sposób:

emcc -sWASMFS

WasmFS jest używany w wersji produkcyjnej i uważany za stabilny, ale nie obsługuje jeszcze wszystkich funkcji starego FS JS. Z drugiej strony zawiera ona ważne nowe funkcje, takie jak obsługa prywatnego systemu plików źródłowych (OPFS, który jest wysoce zalecany do trwałego magazynu). Jeśli nie potrzebujesz funkcji, która nie została jeszcze przeniesiona, zespół Emscripten zaleca użycie WasmFS.

Podsumowanie

Jeśli masz wielowątkową aplikację, która wykonuje wiele alokacji lub używa wielu plików, możesz znacznie skorzystać z WasmFS lub mimalloc. Oba te rozwiązania są proste do wypróbowania w projekcie Emscripten. Wystarczy ponownie skompilować projekt z flagami opisanymi w tym poście.

Możesz nawet wypróbować te funkcje, jeśli nie używasz wątków: jak już wspomnieliśmy, nowocześniejsze implementacje zawierają optymalizacje, które w niektórych przypadkach są zauważalne nawet na jednym rdzeniu.