Publié le 30 janvier 2025
De nombreuses applications WebAssembly sur le Web bénéficient du multithreading, comme les applications natives. Plusieurs threads permettent d'effectuer plus de tâches en parallèle et de déplacer les tâches lourdes du thread principal pour éviter les problèmes de latence. Jusqu'à récemment, des problèmes courants pouvaient survenir avec de telles applications multithreads, en lien avec les allocations et les E/S. Heureusement, les fonctionnalités récentes d'Emscripten peuvent vous aider à résoudre ces problèmes. Ce guide montre comment ces fonctionnalités peuvent entraîner une amélioration de la vitesse de 10 fois ou plus dans certains cas.
Scaling
Le graphique suivant montre une mise à l'échelle multithread efficace dans une charge de travail mathématique pure (à partir du benchmark que nous utiliserons dans cet article):
Cette métrique mesure le calcul pur, que chaque cœur de processeur peut effectuer seul. Les performances s'améliorent donc avec un plus grand nombre de cœurs. Une telle ligne descendante de performances plus rapides correspond exactement à ce qu'est une bonne mise à l'échelle. Il montre également que la plate-forme Web peut très bien exécuter du code natif multithread, malgré l'utilisation de workers Web comme base du parallélisme, de Wasm plutôt que de code natif et d'autres détails qui peuvent sembler moins optimaux.
Gestion de la mémoire tampon: malloc
/free
malloc
et free
sont des fonctions de bibliothèque standard critiques dans tous les langages de mémoire linéaire (par exemple, C, C++, Rust et Zig) qui permettent de gérer toute la mémoire qui n'est pas entièrement statique ou sur la pile. Emscripten utilise dlmalloc
par défaut, qui est une implémentation compacte mais efficace (il est également compatible avec emmalloc
, qui est encore plus compact, mais plus lent dans certains cas). Toutefois, les performances multithread de dlmalloc
sont limitées, car elles nécessitent un verrouillage sur chaque malloc
/free
(car il n'y a qu'un seul administrateur global). Par conséquent, vous pouvez rencontrer des conflits et des ralentissements si vous effectuez de nombreuses allocations dans de nombreux threads à la fois. Voici ce qui se passe lorsque vous exécutez un benchmark extrêmement lourd pour malloc
:
Non seulement les performances ne s'améliorent pas avec un plus grand nombre de cœurs, mais elles s'aggravent de plus en plus, car chaque thread finit par attendre de longues périodes pour le verrouillage malloc
. Il s'agit du pire cas possible, mais cela peut se produire dans des charges de travail réelles si suffisamment d'allocations sont effectuées.
mimalloc
Il existe des versions optimisées pour plusieurs threads de dlmalloc
, comme ptmalloc3
, qui implémente une instance d'allocateur distincte par thread, ce qui évite les conflits.
Il existe plusieurs autres alloueurs avec des optimisations multithread, comme jemalloc
et tcmalloc
. Emscripten a décidé de se concentrer sur le projet mimalloc
récent, qui est un alloueur bien conçu par Microsoft, offrant une très bonne portabilité et des performances exceptionnelles. Utilisez-la comme suit:
emcc -sMALLOC=mimalloc
Voici les résultats de l'analyse comparative malloc
à l'aide de mimalloc
:
Parfait ! Les performances sont désormais évolutives de manière efficace, et s'accélèrent de plus en plus avec chaque cœur.
Si vous examinez attentivement les données sur les performances d'un cœur dans les deux derniers graphiques, vous constaterez que dlmalloc
a pris 2 660 ms et mimalloc
seulement 1 466, soit une amélioration de la vitesse de près de deux fois. Cela montre que même sur une application monothread, vous pouvez bénéficier des optimisations plus sophistiquées de mimalloc
. Notez toutefois que cela a un coût en termes de taille de code et d'utilisation de la mémoire (c'est pourquoi dlmalloc
reste l'option par défaut).
Fichiers et E/S
De nombreuses applications doivent utiliser des fichiers pour diverses raisons. Par exemple, pour charger des niveaux dans un jeu ou des polices dans un éditeur d'images. Même une opération telle que printf
utilise le système de fichiers en arrière-plan, car elle imprime en écrivant des données dans stdout
.
Dans les applications monothread, ce n'est généralement pas un problème, et Emscripten évite automatiquement d'associer la prise en charge complète du système de fichiers si tout ce dont vous avez besoin est printf
. Toutefois, si vous utilisez des fichiers, l'accès au système de fichiers multithread est délicat, car l'accès aux fichiers doit être synchronisé entre les threads. L'implémentation originale du système de fichiers dans Emscripten, appelée "JS FS", car implémentée en JavaScript, utilisait le modèle simple d'implémentation du système de fichiers uniquement sur le thread principal. Chaque fois qu'un autre thread souhaite accéder à un fichier, il met en proxy une requête au thread principal. Cela signifie que l'autre thread se bloque sur une requête interthread, que le thread principal gère finalement.
Ce modèle simple est optimal si seul le thread principal accède aux fichiers, ce qui est un modèle courant. Toutefois, si d'autres threads effectuent des lectures et des écritures, des problèmes surviennent. Tout d'abord, le thread principal finit par effectuer des tâches pour d'autres threads, ce qui entraîne une latence visible par l'utilisateur. Ensuite, les threads d'arrière-plan finissent par attendre que le thread principal soit libre pour effectuer le travail dont ils ont besoin. Les choses ralentissent donc (ou pire, vous pouvez vous retrouver dans une impasse si le thread principal attend actuellement ce thread de travail).
WasmFS
Pour résoudre ce problème, Emscripten propose une nouvelle implémentation de système de fichiers, WasmFS. WasmFS est écrit en C++ et compilé en Wasm, contrairement au système de fichiers d'origine qui était en JavaScript. WasmFS prend en charge l'accès au système de fichiers à partir de plusieurs threads avec un coût minimal, en stockant les fichiers dans la mémoire linéaire Wasm, qui est partagée entre tous les threads. Tous les threads peuvent désormais effectuer des E/S de fichiers avec des performances égales, et ils peuvent souvent même éviter de se bloquer les uns les autres.
Un benchmark simple du système de fichiers montre l'énorme avantage de WasmFS par rapport à l'ancien système de fichiers JS.
Cela compare l'exécution du code du système de fichiers directement sur le thread principal à son exécution sur un seul pthread. Dans l'ancien système de fichiers JS, chaque opération de système de fichiers doit être mise en proxy sur le thread principal, ce qui le rend plus lent d'un ordre de grandeur sur un pthread. En effet, au lieu de simplement lire/écrire des octets, le FS JS effectue une communication entre les threads, ce qui implique des verrouillages, une file d'attente et des temps d'attente. En revanche, WasmFS peut accéder aux fichiers de n'importe quel thread de manière égale. Le graphique montre donc qu'il n'y a pratiquement aucune différence entre le thread principal et un pthread. Par conséquent, WasmFS est 32 fois plus rapide que le FS JS sur un pthread.
Notez qu'il existe également une différence sur le thread principal, où WasmFS est deux fois plus rapide. En effet, le FS JS appelle JavaScript pour chaque opération de système de fichiers, ce que WasmFS évite. WasmFS n'utilise JavaScript que si nécessaire (par exemple, pour utiliser une API Web), ce qui laisse la plupart des fichiers WasmFS dans Wasm. De plus, même lorsque JavaScript est requis, WasmFS peut utiliser un thread d'assistance plutôt que le thread principal pour éviter les latences visibles par l'utilisateur. Par conséquent, vous pouvez constater une amélioration de la vitesse en utilisant WasmFS, même si votre application n'est pas multithread (ou si elle l'est, mais n'utilise des fichiers que sur le thread principal).
Utilisez WasmFS comme suit:
emcc -sWASMFS
WasmFS est utilisé en production et considéré comme stable, mais il n'est pas encore compatible avec toutes les fonctionnalités de l'ancien FS JS. En revanche, il inclut certaines nouvelles fonctionnalités importantes, comme la prise en charge du système de fichiers privé d'origine (OPFS, qui est fortement recommandé pour le stockage persistant). Sauf si vous avez besoin d'une fonctionnalité qui n'a pas encore été portée, l'équipe Emscripten vous recommande d'utiliser WasmFS.
Conclusion
Si vous disposez d'une application multithread qui effectue de nombreuses allocations ou utilise des fichiers, vous pouvez tirer de grands avantages de l'utilisation de WasmFS et/ou de mimalloc
. Il est facile de les essayer dans un projet Emscripten en les recompilant avec les indicateurs décrits dans cet article.
Vous pouvez même essayer ces fonctionnalités si vous n'utilisez pas de threads: comme indiqué précédemment, les implémentations les plus modernes sont accompagnées d'optimisations visibles même sur un seul cœur dans certains cas.