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):
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
malloc
i free
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
:
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. jemalloc
i tcmalloc
. 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
:
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.
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.