Simuler 20 millions de particules en JavaScript
(dgerrells.com)- En partant d’une simulation de particules JavaScript utilisant uniquement le CPU, l’auteur a atteint l’objectif de 1 million de particules à 60 fps sur mobile et a finalement poussé l’exemple jusqu’à 20 millions de particules
- La clé des performances a été d’éviter les tableaux d’objets et de placer les données des particules en mémoire contiguë avec TypedArray et SharedArrayBuffer, tout en répartissant le travail entre les cœurs CPU via des Web Workers
- Le véritable goulot d’étranglement s’est davantage manifesté dans le rendu à l’écran et l’accès mémoire que dans le calcul des positions, les écritures aléatoires dans le buffer de pixels provoquant des défauts de cache CPU
- Les goulots ont été réduits progressivement via des buffers de pixels par Worker, la synchronisation avec
postMessage, le double buffering, une grille du nombre de particules par pixel et un rendu GPU par texture - L’architecture finale fait tourner la simulation sur CPU et le rendu d’une texture de grille à la résolution de l’écran sur GPU, avec environ 20 fps pour 20 millions de particules sur un Mac M1
Objectif et approche de base
- Le défi consistait à simuler 1 million de particules à 60 fps sur mobile avec du JavaScript pur et uniquement le CPU, sans WebAssembly
- Considérant qu’un simple tableau d’objets JavaScript atteignait ses limites en performances mono-cœur sur mobile et en disposition mémoire, l’auteur a choisi de manipuler les données sous forme de tableaux contigus
- Pour les calculs massifs, il est important d’avoir des tableaux en mémoire contiguë et dense afin de placer un maximum de données dans le cache CPU
- Il est difficile de garantir que les tableaux et objets JavaScript classiques soient organisés de manière aussi compacte que des tableaux ou des
structdans des langages de bas niveau TypedArraypermet de créer des tableaux contigus de taille fixe en octets, ce qui rend possible une gestion mémoire relativement bas niveau en JavaScript
Première implémentation : SharedArrayBuffer et Web Worker
- Les données des particules sont stockées dans une vue
Float32Arrayposée sur unSharedArrayBuffer- Une particule est représentée par quatre valeurs flottantes 32 bits :
x,y,dx,dy - Le buffer plat est indexé avec
stride = 4etbyte_stride = stride * 4
- Une particule est représentée par quatre valeurs flottantes 32 bits :
- La simulation commence par une simple mise à jour de position à chaque frame :
x += dx,y += dy - Le multithreading repose sur des Web Workers et
SharedArrayBuffer- Le thread principal découpe les particules en chunks et les distribue aux Workers
- Chaque Worker ne lit et n’écrit que son chunk, ce qui évite que plusieurs threads écrivent au même endroit en mémoire
- Pour utiliser
SharedArrayBufferdans le navigateur, les en-têtes suivants sont nécessairesCross-Origin-Embedder-Policy: require-corpCross-Origin-Opener-Policy: same-origin
- La synchronisation initiale utilise un SharedArrayBuffer dédié au signalement
- Le thread principal écrit un signal d’exécution pour les Workers
- Les Workers vérifient ce signal avec
setInterval(..., 1)et, une fois le travail terminé, marquent leur état comme prêt
- Le rendu s’appuie sur
ImageData, comme dans un rasterizer CPU- Chaque particule est dessinée comme un pixel à l’écran
- Les particules hors écran sont ignorées afin d’éviter les accès hors limites
- Le résultat est affiché dans le Canvas avec
putImageData
- Le profilage initial a montré que le travail des Workers prenait quelques ms, tandis que la majeure partie du temps était consacrée au rendu sur le thread principal
- La mémoire était allouée une seule fois au démarrage et aucun nouvel objet n’était créé dans la boucle de simulation, donc presque aucun garbage n’était produit
Deuxième implémentation : entrées et interaction de type gravité
- Les données d’entrée ont été ajoutées au
SharedArrayBufferdéjà utilisé pour l’état de la simulation- On y stocke le delta de temps, les coordonnées x/y de la souris, l’état du toucher et la largeur/hauteur de l’écran
- L’état du toucher se comporte comme un booléen, mais utilise 4 octets
- En appuyant sur l’écran, les particules sont attirées vers le point de contact
- L’auteur s’est inspiré de la forme
1 / r²d’une vraie formule gravitationnelle, mais l’a adaptée pour privilégier une interaction amusante plutôt qu’une simulation physique exacte - Un amortissement assimilable à de la friction a été ajouté pour éviter que les particules accélèrent indéfiniment et sortent de l’écran
- L’auteur s’est inspiré de la forme
- La couleur de rendu a été modifiée pour varier selon la position des particules
- Les valeurs des canaux RGB sont ajustées à partir de
x / widthety / height - Stocker une couleur par particule aurait augmenté leur taille, donc une coloration basée sur la position a été choisie pour réduire le volume de données
- Les valeurs des canaux RGB sont ajustées à partir de
- Il a été possible d’expérimenter jusqu’à 2 millions de particules, et le paramètre de requête
countdans l’URL permet de modifier leur nombre - À 10 millions de particules, le framerate chutait, et le profilage montrait que l’écriture des pixels dans ImageData était plus lente que le travail des Workers
- En n’écrivant plus que le canal bleu et l’alpha au lieu du RGB complet, le temps de rendu est tombé d’environ 80–90 ms à 16–18 ms sur certains environnements
- Cet écart dépendait du CPU et était plus marqué sur un Ryzen desktop que sur un MacBook Air M1
Troisième implémentation : écriture du buffer de pixels dans les Workers
- Pour exploiter davantage de cœurs CPU, les Workers ont pris en charge non seulement la simulation, mais aussi le dessin des pixels
- Un buffer de pixels par Worker a été mis en place pour éviter que plusieurs Workers écrivent simultanément dans le même buffer
- Un
SharedArrayBufferde tailleCPU_CORES * window.innerWidth * window.innerHeight * 3est utilisé - Chaque Worker dispose de son propre buffer RGB pour l’ensemble de l’écran
- Un
- Le thread principal additionne ensuite tous les buffers de pixels des Workers avant d’écrire dans
ImageData - Cette version provoquait un scintillement à l’écran
- Le thread principal lisait un buffer pendant qu’un Worker le remettait à zéro avec
fill(0) - Les rendus précédents pouvaient déjà lire des données obsolètes, mais la différence d’une frame à l’autre était peu visible ; ici, l’effacement par le Worker se traduisait par un scintillement partiel
- Le thread principal lisait un buffer pendant qu’un Worker le remettait à zéro avec
- Les pistes envisagées étaient une synchronisation via
Atomics, l’attente de la fin des Workers avecpostMessage, ou le double buffering
Quatrième implémentation : synchronisation via postMessage
- Pour supprimer le scintillement, le tableau de signaux a été abandonné au profit de
postMessagepour notifier la fin du travail des Workers - Le thread principal gère une valeur
activeWorkers- Chaque Worker envoie un message lorsqu’il a terminé, ce qui décrémente
activeWorkers - Une fois tous les Workers terminés, le rendu est effectué et le prochain
requestAnimationFrameest demandé
- Chaque Worker envoie un message lorsqu’il a terminé, ce qui décrémente
- Cette approche a supprimé le scintillement
- Le temps par frame ne s’est pas vraiment amélioré, et pouvait même empirer
- Le thread principal ne fait rien pendant qu’il attend la fin des Workers
- Pour 2 millions de particules, il existait des cas où laisser les Workers ne pas attendre le rendu du thread principal permettait d’économiser environ 7 ms
- Pour réduire ce goulot, l’auteur a appliqué le double buffering, fréquent dans les pilotes graphiques
Cinquième implémentation : double buffering et goulot d’étranglement du cache
- Le double buffering consiste à créer deux buffers de pixels et à alterner le buffer actif à chaque frame
- Pendant que le thread principal affiche un buffer, les Workers préparent la frame suivante dans l’autre
- Après cette modification, les Workers n’avaient plus besoin d’attendre le rendu du thread principal, et certaines frames qui dépassaient auparavant 50 ms se rapprochaient de 16 ms
- Le code qui restait lent correspondait aux trois lignes ajoutant des valeurs aux canaux RGB du buffer de pixels
- La cause tenait au fait que, même si les données sont stockées de façon contiguë, l’ordre d’accès ne l’est pas
- Le
pixelIndexcalculé à partir dexetyn’est pas aligné avec l’ordre du tableau de particules - À chaque boucle, le code saute vers un emplacement arbitraire du buffer de pixels, produisant un motif proche de l’accès aléatoire
- Le
- Ce schéma d’accès provoque des défauts de cache lorsque les données des particules et celles des pixels ne tiennent pas toutes dans le cache
- L’exemple de calcul pour un iPhone est le suivant
- Formule :
screen width * screen height * bytes per pixel + bytes per particle * total particles / number of workers 2532 * 1170 * 3 + 16 * 2,000,000 / 5- Le résultat dépasse 15 Mo, bien trop pour tenir dans le cache L1
- Formule :
- Dans cette version, sur une puce M1, la simulation de 2 millions de particules prenait environ 4 ms, l’accès aux données de pixels environ 3,5 ms et l’écriture dans le buffer de pixels environ 7 ms
- Sur mobile, 1 million de particules pouvait être maintenu à 60 fps, mais sur un desktop disposant de plus de 20 threads, le thread principal passait 30 ms par frame à additionner plus de 20 buffers de pixels
Sixième implémentation : force de rappel vers la position de départ
- Le nouvel effet consistait à faire revenir les particules d’autant plus fortement qu’elles s’éloignaient de leur position initiale
- Pour cela, deux valeurs supplémentaires
sxetsyont été ajoutées à chaque particule- Une particule possède désormais six floats :
x,y,dx,dy,sx,sy particleStridepasse à6
- Une particule possède désormais six floats :
- Le code des Workers a reçu une fonction de calcul des forces, avec un objet de cache pour éviter la production de garbage
- Le résultat produisait des effets visuels rappelant de la gelée ou un fluide, avec des tourbillons évoquant du papier froissé ou du tissu
- C’est au cours de cette étape qu’est née l’idée de ne plus stocker un buffer de pixels par Worker, mais une grille ne comptant que le nombre de particules par pixel
Septième implémentation : grille du nombre de particules par pixel
- En stockant uniquement le nombre de particules à chaque pixel au lieu de valeurs RGB, la mémoire peut être réduite d’un tiers
- Comme la couleur est calculée à partir de la position
x,yet non des données de chaque particule, ce simple compteur par pixel suffit pour le rendu - Tous les Workers ont été modifiés pour partager une seule grille du nombre de particules
- Le travail d’agrégation des buffers par Worker sur le thread principal disparaît
- Le problème de l’augmentation du coût d’agrégation avec le nombre de Workers est ainsi résolu
- La nouvelle formule de calcul du cache est la suivante
screen width * screen height + bytes per particle * total particles / number of workers- Pour un iPhone :
2532 * 1170 + 16 * 2,000,000 / 5 - On obtient environ 9,3 Mo, soit environ 30 % de moins qu’avant, mais toujours trop pour le cache L1
- Le rendu parcourt tous les pixels, lit le nombre de particules du pixel concerné, calcule les valeurs RGB, puis réinitialise la grille avec
fill(0) - Après la simulation, chaque Worker calcule le
pCountIndexdes particules visibles et exécuteactiveGrid[pCountIndex]++ - Ce changement a été particulièrement utile quand le nombre de Workers était élevé, et a permis d’utiliser les 24 threads complets sur desktop
- Malgré cela, il arrivait que le MacBook Air M1 soit plus rapide qu’un desktop Ryzen 9 3900X
- Le Ryzen 9 3900X dispose de 64 Mo de cache L3, mais seulement 64 Ko de L1
- Le M1 de première génération possède 320 Ko de cache L1, dont 128 Ko pour les données et 192 Ko pour les instructions
- L’auteur estime qu’un cache de données L1 plus grand a contribué à réduire la latence mémoire
Limites des optimisations CPU-only
- L’ensemble des optimisations a permis d’obtenir environ 2x de gain par rapport à la première version multithread
- L’objectif consistant à traiter 1 million de particules sur mobile à 60 fps avec un rendu CPU en JavaScript a été atteint
- Le résultat global n’a rien de particulièrement impressionnant, et l’auteur estime qu’un langage compilé irait 10x plus vite
- Il resterait encore de la marge si des instructions SIMD pouvaient être utilisées dans les tight loops
- L’étape suivante a consisté à conserver la simulation sur CPU tout en déplaçant uniquement le rendu vers un pipeline GPU traditionnel pour comparer
20 millions de particules et rendu GPU
- La première comparaison côté GPU utilisait l’instancing GPU de threejs pour dessiner un plane/quad par particule
- Cette approche s’est révélée moins performante qu’attendu
- L’instancing de threejs impose de mettre à jour une matrice pour chaque position de particule, et ce travail se fait sur un seul thread CPU
- Les données de transformation des particules doivent être envoyées au GPU à chaque frame, et ce transfert est lent
- Sur une puce M1, lors de la simulation de 4 millions de particules, 80 % du temps par frame était consacré à l’envoi des données au GPU
- Sur desktop, cette part tombait à 30 %, mais restait malgré tout le plus gros goulot d’étranglement
- L’alternative a été d’utiliser comme texture la grille du nombre de particules créée précédemment et de rendre un quad plein écran
- Le volume de données envoyé au GPU devient alors fixe, lié à la résolution de l’écran et non au nombre de particules
- C’est un compromis : les grosses simulations transfèrent moins de données, mais les petites peuvent en transférer davantage
- Une implémentation rapide avec threejs et un pixel shader personnalisé a montré que le rendu sur le thread principal restait de l’ordre de quelques ms, indépendamment de la taille de la simulation
- Dans cette architecture, les Workers tournent aussi vite que possible, et la simulation dépend directement du nombre de cœurs CPU, quelle que soit l’échelle
- Chaque Worker passe environ 50 % de son temps à faire des calculs numériques et 50 % à mettre à jour les compteurs de la grille de particules
- Les performances de la version finale sont les suivantes
- Sur Mac M1 sur batterie, 20 millions de particules tournent à environ 20 fps
- Sur desktop, environ 30 millions de particules sont traitées pour un fps similaire
- D’après un test réalisé par un ami équipé d’un CPU 32 cœurs, le système monte à environ 40 millions de particules
- Démonstrations associées :
En résumé
- Les CPU et GPU peuvent traiter très rapidement les calculs numériques
- Les parties lentes sont le déplacement des données et surtout les accès mémoire aléatoires
- Pour écrire du code rapide, il faut comprendre comment le matériel met les données en cache et les déplace
SharedArrayBufferet les Web Workers se sont révélés utiles pour expérimenter l’utilisation de plusieurs cœurs en JavaScript- L’auteur prévoit d’essayer des compute shaders lorsque WebGPU sera plus largement adopté
1 commentaires
Avis sur Hacker News
Ce serait bien d’intégrer directement la simulation dans le blog
J’ai dû faire défiler longtemps vers le haut et le bas avant de trouver le lien vers la simulation elle-même
On pourrait choisir des valeurs qui tournent correctement même sur de vieux téléphones, ou les ajuster en fonction du framerate, ou simplement mettre quelques liens en haut de l’article
https://ciechanow.ski/ est un exemple de tout premier ordre de ce que peut donner l’intégration de simulations dans un article
Avant, tous les sites web intégraient ce genre d’éléments interactifs géniaux
Et je pense qu’on peut faire tourner une simulation de particules sur le GPU même sans WebGPU
Exemple : https://news.ycombinator.com/item?id=19963640
J’aimerais que cela devienne la norme pour partager de l’information. Si quelque chose vaut la peine d’être partagé, cela vaut aussi la peine de le rendre facile à comprendre
J’ai envisagé d’intégrer chaque version, mais je craignais qu’il y ait trop de workers qui tournent en même temps
Je prévois de mettre à jour l’article pour intégrer la version finale à la fin
Ce blog est excellent, avec un niveau de finition incroyable pour chaque exemple
Édit : j’ai essayé d’ajouter une version intégrée, mais les en-têtes nécessaires ne cohabitaient pas bien avec les autres intégrations, et les anciennes versions restent liées à codesandbox
Je viens de tester la démo mobile [0], c’est assez impressionnant
[0] https://dgerrells.com/sabby
C’est étonnant d’obtenir ça en pur JavaScript, et comme la latence augmente avec le nombre de doigts, j’imagine qu’il y a davantage de données qui transitent entre les threads
Simple curiosité : je me demande si, au lieu de représenter chaque particule avec quatre nombres flottants 32 bits x, y, dx, dy, on pourrait encoder ces données dans un seul nombre JS
La plage des entiers sûrs en JS est
2^53 - 1 = 9,007,199,254,740,991, tandis que la plage duFloat32Arrayutilisé dans l’article va de-3.4e38à3.4e38Par exemple, si la position à l’écran est sur un canvas 1000x1000, on peut l’exprimer avec des nombres de 0 à 1 000 000 ; même en ajoutant 10 niveaux de sous-pixels, on arrive à 100 000 000, largement dans la plage de JS
Les vitesses
dxetdysont aussi calculées comme des décimaux aléatoires entre -10 et +10 avec(Math.random()*2-1)*10, mais si les limiter à une décimale suffit, on peut les convertir en entiers de -100 à +100, ce qui ne nécessite que 10 000 valeurs possiblesEn combinant les deux,
10,000 * 100,000,000 = 1,000,000,000,000, soit mille milliards de nombres pour représenter une particule, ce qui reste dansMAX_SAFE_INTEGERde JSOn pourrait donc mettre les données d’une particule entière dans un seul
MAX_SAFE_INTEGERou un seul élément deFloat32Array, ce qui supprimerait le besoin de stride et pourrait rendre la cohérence des données plus solideCela dit, le coût d’encodage/décodage peut dépasser les économies de mémoire et ne présenter aucun intérêt côté performances
L’écosystème
@thi.ngcontient des implémentations utiles, et il existe aussi une implémentation WASM SIMD[0]: https://github.com/thi-ng/umbrella/tree/develop/packages/vec...
[1]: https://github.com/thi-ng/umbrella/tree/develop/packages/vec...
[2]: https://github.com/thi-ng/umbrella/tree/develop/packages/bit...
[3]: https://github.com/thi-ng/umbrella/tree/develop/packages/sim...
Il y a aussi Structura, qui implémente quelques structures de données performantes
[4]: https://github.com/zandaqo/structurae/blob/master/README.md#...
Utiliser
Float16Arraypermettrait de diviser immédiatement par deux les besoins mémoireUne autre approche consiste à séparer les tableaux selon la précision
Par exemple, x et y pourraient utiliser
Float16Array, et dx/dyInt8Array, mais dans les deux cas cela peut provoquer des artefacts de mouvement, et ce serait particulièrement visible avecInt8Arrayà cause du clamp et de l’aliasing sur dx/dyVraiment génial, mais je m’interroge sur ce passage
Javascript does support an Atomics API but it uses promises which are gross. Eww sick.À part
waitAsync[1], l’API Atomics ne semble pas utiliser de promessesJ’ai déjà utilisé Atomics par le passé, mais je n’ai jamais eu besoin de gérer du code asynchrone/des promesses
Je me demande si elle utilise des promesses en interne, ou si quelque chose m’a échappé
[1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
Il existe aussi une simulation qui gère près de 20 millions de particules
On peut l’exécuter et la programmer : https://tinlizzie.org/~ohshima/shadama2/
https://tinlizzie.org/~ohshima/shadama2/live2017/
https://www.youtube.com/watch?v=tqBO_5GMfWw
La vidéo est chouette, mais les liens CodeSandbox de
try it out herene fonctionnent pas sur Chrome desktop sous macOSJ’obtiens des erreurs CORS comme
Uncaught ReferenceError: SharedArrayBuffer is not definedetERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoepÇa peut aussi être bloqué si vous utilisez un navigateur axé sur la protection de la vie privée
À l’avenir, j’essaierai d’inclure des exemples intégrés
Vous pouvez essayer la version finale ici : https://dgerrells.com/sabby
Excellent
J’ai passé une heure à comprendre le code et à jouer avec, et j’ai créé ici une implémentation en temps réel : https://particules.kapochamo.com/index.html
C’est vraiment beau, et il est difficile de croire qu’autant de particules soient simulées en JS
C’est impressionnant que tu sois allé aussi loin dans le code
Ça vaut le coup de vérifier si
chrome://tracingapporte davantage d’enseignementsJe l’ai appris ici : https://youtu.be/easvMCCBFkQ?t=114
J’ai très envie d’envoyer ça à notre équipe UI qui fabrique une appli React lente
JS est vraiment rapide, et surtout, quand il est bien écrit, il est extrêmement rapide
Le JS rapide ressemble à un mauvais dialecte de C, et il ne faut pas faire d’allocations d’objets soumises au garbage collector, alors que le langage manque de bonnes alternatives sans allocation
Dans une appli JS complexe, presque personne ne va gérer toutes les allocations mémoire avec des pointeurs dans un énorme
ArrayBuffer, et à ce compte-là il est plus simple de passer à WebAssemblyCe qui est dommage, c’est qu’on ne peut pas miser uniquement sur JS et qu’il faut beaucoup y mêler des manipulations du DOM ; or, dans les UI que nous construisons, c’est généralement cette manipulation du DOM qui est lente