20 Millionen Partikel mit JavaScript simulieren
(dgerrells.com)- Ausgehend von einer JavaScript-Partikelsimulation, die nur die CPU nutzt, wurde das Ziel von 1 Million Partikeln bei 60 fps auf dem Smartphone erreicht, und das finale Beispiel wurde bis auf 20 Millionen Partikel skaliert
- Der Schlüssel zur Performance war, Arrays von Objekten zu vermeiden und die Partikeldaten mit TypedArray und SharedArrayBuffer in zusammenhängendem Speicher zu halten sowie die Arbeit mit Web Workern auf CPU-Kerne zu verteilen
- Der eigentliche Flaschenhals zeigte sich stärker beim Zeichnen auf dem Bildschirm und beim Speicherzugriff als bei der Berechnung der Partikelpositionen; zufällige Schreibzugriffe in den Pixelpuffer verursachten CPU-Cache-Misses
- Mit Pixelpuffern pro Worker, Synchronisierung per
postMessage, Double Buffering, einem Grid für Partikel pro Pixel und GPU-Textur-Rendering wurden die Engpässe schrittweise reduziert - Die finale Architektur lässt die CPU simulieren und die GPU ein Grid-Texture in Bildschirmauflösung rendern; auf einem M1 Mac werden 20 Millionen Partikel mit etwa 20 fps verarbeitet
Ziel und grundlegender Ansatz
- Die Herausforderung bestand darin, ohne WebAssembly nur mit reinem JavaScript und der CPU 1 Million Partikel auf einem Smartphone mit 60 fps zu simulieren
- Da einfache JavaScript-Objektarrays sowohl bei der Single-Core-Leistung auf Mobilgeräten als auch beim Speicherlayout Grenzen haben, fiel die Wahl auf einen Ansatz mit zusammenhängenden Arrays
- Bei großen Rechenmengen sind dichte, zusammenhängende Speicherarrays wichtig, damit möglichst viele Daten im CPU-Cache liegen können
- Bei normalen JavaScript-Arrays und -Objekten ist schwer zu garantieren, dass sie so dicht angeordnet sind wie Arrays oder
structin Low-Level-Sprachen TypedArraykann ein zusammenhängendes Byte-Array fester Größe erzeugen und ermöglicht damit in JavaScript eine vergleichsweise Low-Level-nahe Speicherverwaltung
Erste Implementierung: SharedArrayBuffer und Web Worker
- Die Partikeldaten wurden in einer
Float32Array-View auf einemSharedArrayBuffergespeichert- Ein Partikel wird durch vier 32-Bit-Gleitkommazahlen dargestellt:
x,y,dx,dy - Ein flacher Puffer wird mit
stride = 4undbyte_stride = stride * 4indiziert
- Ein Partikel wird durch vier 32-Bit-Gleitkommazahlen dargestellt:
- Die Simulation begann mit einem einfachen Positionsupdate pro Frame:
x += dx,y += dy - Multithreading wurde mit Web Workern und
SharedArrayBufferumgesetzt- Der Main Thread teilt den Workern jeweils Chunks von Partikeln zu
- Jeder Worker liest und schreibt nur den ihm zugewiesenen Chunk, damit nicht mehrere Threads dieselbe Speicherstelle beschreiben
- Um
SharedArrayBufferim Browser zu verwenden, sind folgende Header nötigCross-Origin-Embedder-Policy: require-corpCross-Origin-Opener-Policy: same-origin
- Die anfängliche Synchronisierung nutzte einen separaten SharedArrayBuffer für Signale
- Der Main Thread schreibt ein Startsignal für die Worker
- Die Worker prüfen das Signal mit
setInterval(..., 1)und markieren nach Abschluss ihre Bereitschaft
- Das Rendering wurde mit
ImageDatawie ein CPU-basierter Rasterizer umgesetzt- Jedes Partikel wird als ein Pixel auf dem Bildschirm gezeichnet
- Partikel außerhalb des Bildschirms werden übersprungen, um Zugriffe außerhalb der Array-Grenzen zu vermeiden
- Mit
putImageDatawird das Ergebnis auf das Canvas gezeichnet
- Frühes Profiling zeigte, dass die Worker-Arbeit nur wenige ms dauerte und der Großteil der Zeit für das Rendering im Main Thread draufging
- Der Speicher wurde nur einmal beim Start allokiert, und in der Simulationsschleife wurden keine neuen Objekte erzeugt, wodurch kaum Garbage entstand
Zweite Implementierung: Eingabe und gravitationsähnliche Interaktion
- Die Eingabedaten wurden in den bestehenden
SharedArrayBufferfür den Simulationszustand integriert- Gespeichert wurden Delta-Zeit, Maus-x/y, Touch-Status sowie Bildschirmbreite/-höhe
- Der Touch-Status ist zwar boolean-ähnlich, belegt aber 4 Byte
- Beim Tippen auf den Bildschirm wurde ein Effekt hinzugefügt, bei dem die Partikel zum Berührungspunkt gezogen werden
- Als Vorlage diente die reale Gravitationsformel
1 / r², sie wurde aber für eine interessante Interaktion statt für physikalische Genauigkeit angepasst - Zusätzlich wurde eine Dämpfung als Reibung eingebaut, damit die Partikel nicht unendlich beschleunigen und aus dem Bildschirm fliegen
- Als Vorlage diente die reale Gravitationsformel
- Die Renderfarbe wurde so geändert, dass sie sich je nach Partikelposition verändert
- Die RGB-Kanalwerte werden über
x / widthundy / heightangepasst - Würde man Farben pro Partikel speichern, würden die Partikeldaten größer; deshalb wurde positionsbasierte Färbung gewählt
- Die RGB-Kanalwerte werden über
- Es konnte mit bis zu 2 Millionen Partikeln experimentiert werden, und über den URL-Query-Parameter
countließ sich die Partikelzahl verändern - Bei 10 Millionen Partikeln fiel die Framerate deutlich ab, und Profiling zeigte, dass ImageData-Pixelschreiben langsamer war als die Worker
- Statt vollständigem RGB wurden nur noch Blau- und Alpha-Kanal geschrieben; dadurch sank die Renderzeit in manchen Umgebungen von etwa 80–90 ms auf 16–18 ms
- Dieser Unterschied hing von der CPU ab und war auf einem Desktop-Ryzen größer als auf einem M1 MacBook Air
Dritte Implementierung: Pixelpuffer in Workern schreiben
- Um mehr CPU-Kerne zu nutzen, übernahmen die Worker nicht nur die Simulation, sondern auch das Zeichnen der Pixel
- Damit nicht mehrere Worker gleichzeitig in denselben Pixelpuffer schreiben, bekam jeder Worker einen eigenen Pixelpuffer
- Verwendet wurde ein
SharedArrayBufferder GrößeCPU_CORES * window.innerWidth * window.innerHeight * 3 - Jeder Worker besitzt einen vollständigen RGB-Puffer für den gesamten Bildschirm
- Verwendet wurde ein
- Der Main Thread summiert anschließend alle Pixelpuffer der Worker und schreibt sie in
ImageData - In dieser Version trat Bildschirmflackern auf
- Während ein Worker seinen Pixelpuffer mit
fill(0)löschte, las der Main Thread denselben Puffer - Auch das frühere Rendering konnte veraltete Daten lesen, aber die Unterschiede zwischen Frames fielen kaum auf; hier wurde das Löschen der Worker als partielles Flackern sichtbar
- Während ein Worker seinen Pixelpuffer mit
- Als mögliche Lösungen kamen Synchronisierung per
Atomics, Warten auf Worker-Abschluss perpostMessageoder Double Buffering infrage
Vierte Implementierung: Synchronisierung per postMessage
- Um das Flackern zu beseitigen, wurde das Signal-Array verworfen und stattdessen per
postMessagegemeldet, wenn ein Worker fertig ist - Der Main Thread verwaltet dabei einen Wert
activeWorkers- Jeder Worker sendet nach Abschluss eine Nachricht, wodurch
activeWorkersdekrementiert wird - Wenn alle Worker fertig sind, wird gerendert und das nächste
requestAnimationFrameangefordert
- Jeder Worker sendet nach Abschluss eine Nachricht, wodurch
- Damit war das Flackern gelöst
- Die Frametimes verbesserten sich aber nicht deutlich und konnten sogar schlechter werden
- Der Grund ist, dass der Main Thread während des Wartens auf die Worker nichts tut
- Bei 2 Millionen Partikeln gab es Situationen, in denen sich etwa 7 ms sparen ließen, wenn die Worker nicht auf das Rendering des Main Threads warten müssten
- Um diesen Flaschenhals zu reduzieren, wurde das in Grafiktreibern übliche Double Buffering eingeführt
Fünfte Implementierung: Double Buffering und Cache-Flaschenhälse
- Double Buffering bedeutet, zwei Pixelpuffer anzulegen und pro Frame zwischen ihnen zu wechseln
- Während der Main Thread einen Puffer rendert, bereiten die Worker im anderen den nächsten Frame vor
- Danach mussten die Worker nicht mehr auf das Rendering des Main Threads warten, und manche Frames, die zuvor über 50 ms brauchten, lagen nun näher bei 16 ms
- Der weiterhin langsame Code waren drei Zeilen, die Werte zu den RGB-Kanälen des Pixelpuffers addierten
- Die Ursache war, dass die Daten zwar zusammenhängend gespeichert sind, aber nicht in zusammenhängender Reihenfolge angesprochen werden
- Der aus
xundyberechnetepixelIndexist nicht an die Reihenfolge des Partikelarrays gebunden - Dadurch springt die Schleife fast zufällig zu verschiedenen Positionen im Pixelpuffer, also in einem Muster nahe Zufallszugriff
- Der aus
- Dieses Zugriffsmuster führt zu Cache-Misses, wenn Partikeldaten und Pixeldaten nicht gleichzeitig in den Cache passen
- Die Beispielrechnung für ein iPhone lautet
- Formel:
screen width * screen height * bytes per pixel + bytes per particle * total particles / number of workers 2532 * 1170 * 3 + 16 * 2,000,000 / 5- Das Ergebnis liegt über 15 MB und ist damit viel zu groß für den L1-Cache
- Formel:
- In dieser Version dauerte auf dem M1-Chip die Simulation von 2 Millionen Partikeln etwa 4 ms, der Zugriff auf Pixeldaten etwa 3,5 ms und das Schreiben in den Pixelpuffer etwa 7 ms
- Auf Smartphones ließen sich 1 Million Partikel mit 60 fps halten, aber auf Desktops mit mehr als 20 Threads verbrachte der Main Thread 30 ms pro Frame damit, über 20 Pixelpuffer zusammenzurechnen
Sechste Implementierung: Rückstellkraft zur Startposition
- Der neue Effekt sorgt dafür, dass Partikel umso stärker zurückgezogen werden, je weiter sie sich von ihrer Startposition entfernen
- Dafür wurden jedem Partikel zwei Startpositionswerte
sxundsyhinzugefügt- Ein Partikel besitzt nun sechs Floats:
x,y,dx,dy,sx,sy particleStridesteigt auf 6
- Ein Partikel besitzt nun sechs Floats:
- Im Worker-Code wurde eine Funktion zur Kraftberechnung ergänzt, und um Garbage zu vermeiden, kam ein Cache-Objekt zum Einsatz
- Das Ergebnis erzeugte visuelle Effekte wie Gel oder Flüssigkeit; die Partikel bildeten Wirbel, die an zerknittertes Papier oder Stoff erinnerten
- Dabei entstand die Idee, statt Pixelpuffern pro Worker nur noch ein Grid zu speichern, das die Anzahl der Partikel pro Pixel zählt
Siebte Implementierung: Grid für Partikelanzahl pro Pixel
- Wenn statt RGB-Werten nur die Anzahl der Partikel an jeder Pixelposition gespeichert wird, sinkt der Speicherbedarf auf ein Drittel
- Da die Farbe nicht aus Partikeldaten, sondern aus der Position
x,yberechnet wird, reicht für das Rendering ein Zähler pro Pixel aus - Alle Worker teilen sich nun ein gemeinsames Grid für die Partikelanzahl
- Die Arbeit des Main Threads zum Aufsummieren der Worker-Puffer entfällt
- Das Problem wachsender Renderkosten bei steigender Worker-Zahl ist damit gelöst
- Die neue Cache-Formel lautet
screen width * screen height + bytes per particle * total particles / number of workers- Für das iPhone:
2532 * 1170 + 16 * 2,000,000 / 5 - Das ergibt etwa 9,3 MB, also rund 30 % weniger als zuvor, aber immer noch zu groß für den L1-Cache
- Beim Rendering wird über alle Pixel iteriert, die Partikelzahl an dieser Stelle gelesen, daraus RGB berechnet und das Grid anschließend mit
fill(0)zurückgesetzt - Die Worker berechnen nach der Simulation für sichtbare Partikel den
pCountIndexund führenactiveGrid[pCountIndex]++aus - Diese Änderung half besonders bei vielen Workern und ermöglichte auf dem Desktop die Nutzung aller 24 Threads
- Trotzdem war ein M1 MacBook Air in manchen Fällen schneller als ein Ryzen 9 3900X Desktop
- Der Ryzen 9 3900X hat zwar 64 MB L3-Cache, aber nur 64 KB L1
- Die erste M1-Generation hat 320 KB L1-Cache, davon 128 KB für Daten und 192 KB für Instruktionen
- Wahrscheinlich wirkte sich der größere L1-Datencache positiv auf die Speicherlatenz aus
Grenzen der CPU-only-Optimierung
- Nach mehreren Optimierungen wurde gegenüber der ersten Multithreading-Version ungefähr eine Verdopplung der Geschwindigkeit erreicht
- Das Ziel, auf einem Smartphone rein CPU-basiert 1 Million Partikel in JavaScript mit 60 fps zu verarbeiten, wurde erreicht
- Insgesamt ist das Ergebnis nicht außergewöhnlich beeindruckend; mit einer kompilierten Sprache wäre vermutlich die 10-fache Geschwindigkeit möglich
- Wenn SIMD-Instruktionen in engen Loops nutzbar wären, gäbe es weiteres Beschleunigungspotenzial
- Im nächsten Schritt blieb die Simulation auf der CPU, während nur das Rendering in eine klassische GPU-Pipeline verlagert wurde, um den Unterschied zu vergleichen
20 Millionen Partikel und GPU-Rendering
- Der erste GPU-Rendering-Vergleich nutzte in threejs GPU Instancing, bei dem für jedes Partikel eine Plane/ein Quad gezeichnet wird
- Dieser Ansatz war langsamer als erwartet
- Bei threejs-Instancing müssen Matrizen pro Partikelposition aktualisiert werden, und diese Arbeit läuft auf einem einzelnen CPU-Thread
- Außerdem müssen die Transformationsdaten der Partikel in jedem Frame zur GPU hochgeladen werden, was langsam ist
- Auf einem M1-Chip gingen bei der Simulation von 4 Millionen Partikeln 80 % der Framezeit für das Senden der Daten an die GPU drauf
- Auf dem Desktop lag dieser Anteil bei 30 %, war aber immer noch der größte Flaschenhals
- Die Alternative war, das zuvor gebaute Partikelanzahl-Grid als Textur zu verwenden und ein Fullscreen-Quad zu rendern
- Die zur GPU gesendete Datenmenge ist dann nicht mehr von der Partikelzahl abhängig, sondern fix an die Bildschirmauflösung gebunden
- Bei großen Simulationen reduziert das die Transfermenge, bei kleinen kann es jedoch ein Trade-off sein und sogar mehr Daten bedeuten
- Mit threejs und einem benutzerdefinierten Pixel-Shader ließ sich das schnell umsetzen; das Rendering im Main Thread blieb unabhängig von der Simulationsgröße bei wenigen ms
- In dieser Architektur laufen die Worker so schnell wie möglich, und die Simulation hängt sowohl bei kleinen als auch großen Szenarien direkt von der Zahl der CPU-Kerne ab
- Jeder Worker verbringt etwa 50 % der Zeit mit numerischer Berechnung und etwa 50 % mit der Aktualisierung der Partikelzähler im Grid
- Die Performance der finalen Version sieht so aus
- Ein M1 Mac verarbeitet im Akkubetrieb rund 20 Millionen Partikel mit etwa 20 fps
- Ein Desktop schafft bei ähnlicher fps-Zahl etwa 30 Millionen Partikel
- In einem Test eines Freundes mit einer 32-Core-CPU wurden etwa 40 Millionen Partikel erreicht
- Zugehörige Demos:
Fazit
- CPU und GPU können numerische Berechnungen sehr schnell ausführen
- Langsam sind das Verschieben von Daten und insbesondere zufällige Speicherzugriffe
- Wer schnellen Code schreiben will, muss verstehen, wie Hardware Daten cached und bewegt
SharedArrayBufferund Web Worker waren nützliche Werkzeuge für Experimente mit Mehrkernnutzung in JavaScript- Sobald WebGPU breiter verfügbar ist, soll als Nächstes ein Compute Shader ausprobiert werden
1 Kommentare
Meinungen auf Hacker News
Es wäre schön, die Simulation direkt in den Blog einzubetten
Ich musste ziemlich lange nach oben und unten scrollen, bis ich den Link zur eigentlichen Simulation gefunden hatte
Man könnte einen Wert wählen, der auch auf älteren Smartphones ordentlich läuft, oder ihn je nach Framerate anpassen lassen; alternativ könnte man einfach ein paar Links oben im Artikel platzieren
https://ciechanow.ski/ ist ein Weltklasse-Beispiel dafür, wie großartig es sein kann, Simulationen in einen Artikel einzubauen
Früher haben alle Websites solche coolen interaktiven Elemente eingebettet
Außerdem denke ich, dass man Partikelsimulationen auch ohne WebGPU auf der GPU laufen lassen kann
Beispiel: https://news.ycombinator.com/item?id=19963640
Ich wünschte, das wäre der Standard für den Informationsaustausch. Wenn etwas es wert ist, geteilt zu werden, ist es auch wert, verständlich aufbereitet zu werden
Ich hatte überlegt, jede Version einzubetten, hatte aber Sorge, dass dann zu viele Worker gleichzeitig laufen
Ich werde den Artikel aktualisieren und am Ende die finale Version einbetten
Dieser Blog ist wirklich gut, und jedes Beispiel ist unglaublich sauber ausgearbeitet
Edit: Ich habe versucht, eine eingebettete Version hinzuzufügen, aber die benötigten Header passten nicht gut zu anderen Embeds, und die alten Versionen hängen weiterhin an codesandbox
Ich habe die Mobile-Demo [0] selbst ausprobiert, und sie ist ziemlich beeindruckend
[0] https://dgerrells.com/sabby
Erstaunlich, dass das mit reinem JavaScript möglich ist; weil die Verzögerung mit mehr Fingern zunimmt, scheint mehr Datenverkehr zwischen Threads stattzufinden
Aus reiner Neugier: Ich frage mich, ob man diese Daten in eine einzige JS-Zahl codieren könnte, statt jedes Partikel mit vier 32-Bit-Gleitkommazahlen x, y, dx, dy darzustellen
Der sichere Integer-Bereich von JS ist
2^53 - 1 = 9,007,199,254,740,991, und der im Artikel verwendete Bereich vonFloat32Arrayreicht von-3.4e38bis3.4e38Wenn die Bildschirmposition zum Beispiel eine 1000x1000-Canvas ist, lässt sie sich mit Zahlen von 0 bis 1.000.000 ausdrücken; selbst mit zusätzlich 10 Subpixel-Stufen wären das 100.000.000 Werte und läge locker im JS-Bereich
Die Geschwindigkeiten
dxunddywerden zwar ebenfalls als zufällige Dezimalzahlen zwischen -10 und +10 mit(Math.random()*2-1)*10berechnet, aber wenn eine Nachkommastelle reicht, könnte man sie in einen Integer-Bereich von -100 bis +100 umwandeln, und 10.000 mögliche Werte würden genügenKombiniert ergibt das
10,000 * 100,000,000 = 1,000,000,000,000, also eine Billion Zahlen, mit denen sich ein Partikel darstellen ließe, und das liegt innerhalb von JS’MAX_SAFE_INTEGERDaher scheint es möglich, die Daten eines einzelnen Partikels in einem einzigen
MAX_SAFE_INTEGERoder einem einzigenFloat32Array-Element unterzubringen; dann bräuchte man keinen Stride, und die Datenkonsistenz könnte zuverlässiger seinAllerdings könnten die Kosten fürs Codieren/Decodieren größer sein als die Speicherersparnis, sodass es sich aus Performance-Sicht überhaupt nicht lohnt
Im
@thi.ng-Ökosystem gibt es nützliche Implementierungen, außerdem eine WASM-SIMD-Implementierung[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...
Es gibt auch Structura, das einige performante Datenstrukturen implementiert
[4]: https://github.com/zandaqo/structurae/blob/master/README.md#...
Mit
Float16Arrayließe sich der Speicherbedarf sofort halbierenEine weitere Möglichkeit wäre, die Arrays nach Präzision aufzuteilen
Zum Beispiel könnte man für x und y
Float16Arrayund für dx/dyInt8Arrayverwenden; in beiden Fällen können jedoch Bewegungsartefakte entstehen, und besonders beiInt8Arraykönnten sie wegen Clamping und Aliasing von dx/dy auffallenWirklich beeindruckend, aber an dieser Stelle frage ich mich:
Javascript does support an Atomics API but it uses promises which are gross. Eww sick.Abgesehen von
waitAsync[1] scheint die Atomics API keine Promises zu verwenden.Ich habe früher schon Atomics benutzt, musste dabei aber keinen asynchronen/Promise-Code anfassen.
Ich frage mich, ob sie intern Promises verwendet oder ob ich etwas übersehen habe.
[1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
Es gibt auch Simulationen mit fast 20 Millionen Partikeln.
Man kann sie ausführen und programmieren: https://tinlizzie.org/~ohshima/shadama2/
https://tinlizzie.org/~ohshima/shadama2/live2017/
https://www.youtube.com/watch?v=tqBO_5GMfWw
Das Video ist klasse, aber die CodeSandbox-Links unter
try it out herefunktionieren auf macOS Chrome Desktop nicht.Es erscheinen CORS-Fehler wie
Uncaught ReferenceError: SharedArrayBuffer is not definedundERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep.Bei Browsern mit Fokus auf Datenschutz kann sie ebenfalls blockiert werden.
Künftig werde ich versuchen, eingebettete Beispiele einzubauen.
Die finale Version kann man hier ausprobieren: https://dgerrells.com/sabby
Hervorragend.
Ich habe eine Stunde damit verbracht, den Code zu verstehen und damit herumzuspielen, und hier eine Live-Implementierung gebaut: https://particules.kapochamo.com/index.html
Sie ist wirklich schön, und es ist schwer zu glauben, dass man in JS so viele Partikel simulieren kann.
Beeindruckend, dass du dich so tief in den Code eingegraben hast.
Es könnte sich lohnen zu prüfen, ob
chrome://tracingmehr Einblicke liefert.Habe ich hier erfahren: https://youtu.be/easvMCCBFkQ?t=114
Ich möchte das sofort an das UI-Team schicken, das langsame React-Apps baut.
JS ist wirklich schnell, und besonders wenn es gut geschrieben ist, extrem schnell.
Schnelles JS sieht aus wie ein schlechter C-Dialekt, und man darf keine Garbage-Collection-Objektallokationen machen, aber der Sprache fehlen gute Alternativen ohne Allokationen.
In einer komplexen JS-App werden nur wenige sämtliche Speicherallokationen als Pointer in einem riesigen
ArrayBufferbehandeln; dann ist es einfacher, gleich zu WebAssembly zu wechseln.Schade ist, dass man sich nicht allein auf JS verlassen kann und viel DOM-Manipulation dazumischen muss; und in den UIs, die wir bauen, ist genau diese DOM-Manipulation normalerweise der langsame Teil.