Garbage Collector verbessern/schneller machen
Michael_K
- javascript
Hallo,
kennt jemand eine Seite, in der anschaulich erklaert wird, welche Dinge helfen, dass Garbage Collector "schneller" zuschlagen kann?
Das Schoene an JS ist, dass der GC ziemlich gut funktioniert und man sich idR kaum Gedanken machen muss. Trotzdem habe ich gerade eine Situation, in der der GC relativ lange benoetigt, um Speicher wieder freizugeben. Konkret: in einem Browser wird eine Berechnung durchgefuehrt, die relativ viele Daten nacheinander benoetigt. Schaue ich mir das Ganze im Chrome mit den Entwicklungstools an, dann sehe ich, dass der Scheicher auf bis zu 500 bis 700 MB hochgeht, nach ca. 5 bis 15 Sekunden dann aber der Speicher wieder auf 45MB sinkt (dass fuer mich akzeptable ist .. gemessen an den Daten, die im BrwoserTab dann dargestellt bzw. vorgehalten werden).
Ich wuerde nun gerne wissen, ob ich den GC schneller machen kann, sodass nicht 700MB anfallen bis dass es keine 15 Sekunden bedarf, bis der GC zuschlaegt.
Zudem eine Frage, auf einer Seite wird dieser Code als unguenstige fuer GC bezeichnet bzw. als unnoetige Objekterstellung. Kann mir das jemand erklaeren bzw. wie sollte man es denn besser machen:
// Creating unnecessary objects in a loop
function processArray(array) {
const newArray = [];
for (let i = 0; i < array.length; i++) {
newArray.push(array[i] * 2);
}
return newArray;
}
Hallo Michael_K,
die push-Methode erweitert das Zielarray Stück für Stück um einen Eintrag. Ein dynamisches Array in Sprachen wie JavaScript ist kein klassisches Array, was aus einem zusammenhängenden Speicherbereich besteht, sondern ein Objekt, das numerische Schlüssel speziell behandelt. Also eigentlich: eine Map.
Wenn Du das neue Array als [] initialisierst, dann reserviert er Speicher für einige wenige Einträge. In .net passiert sowas beim ArrayList Typ, da sind es anfangs 8 Einträge. Wenn man dann einzeln Elemente anfügt, muss er den reservierten Speicher vergrößern. D.h. einen neuen Speicherblock bestellen, der mehr Platz hat, den alten Speicherblock dorthin kopieren, den alten Block freigeben. Das kostet Zeit und auch Speicher, denn der GC läuft nur, wenn es nötig ist. Unnötig ist es, wenn (a) das System noch genug Speicher hat und (b) der Browser eigentlich gerade was anderes tut. Und das tut er, denn du rechnest ja fleißig rum. Das laufende Programm anhalten und den Speicher reorganisieren ist etwas, was eine GC-Sprache nur im Notfall tut.
In .net hätte ich GC.collect(). Da ist es nötig, weil man mit .net auch Serversysteme bauen kann, die nahe an Realtime sind. In JavaScript kannst Du die Collection nicht erzwingen.
Du weißt, wie groß dein Zielarray ist, und kannst den nötigen Speicher gleich zu Beginn bestellen. Mit
const newArray = Array(array.length);
for (let i=0; i<array.length; i++) {
newArray[i] = array[i]*2;
}
halbierst Du locker die Laufzeit. Zumindest gerade in meinem Versuch mit 100000 Einträgen - von 19ms auf 8ms.
Du hast aber immer noch eine Map, die hypothetisch jeden Typ aufnehmen kann. Entsprechend muss JS dafür Speicher reservieren und kann den Code auch nicht wirklich optimieren. Du könntest auf ein TypedArray zurückgreifen. Das setzt aber voraus, dass Du deinen Wertebereich kennst. Reicht ein 16-bit Integer, mit oder ohne Vorzeichen? Es gibt Int8Array, Int16Array, Int32Array, Uint8Array, Uint16Array, Uint32Array, Float32Array und Float64Array. BigInt64Array ist etwas umständlich, weil es nur BigInt-Werte verarbeitet und die muss man immer manuell konvertieren.
Mit new UInt32Array(1000) bekommst Du einen ArrayBuffer mit 4000 Bytes, und die 1000 UInt32-Werte liegen darin dicht an dicht. Das schafft ein normales JavaScript Array nicht. Die Zugriffe darauf sind etwas schneller, nicht viel, aber auf jeden Fall ist der Speicher und ggf. auch der Transport zwischen Webworkern effizienter.
Was nichts bringt, ist a.length in einer Variablen zwischenzuspeichern. Sowas musst man in den 80ern tun, da gab's auch den Trick, bei array.length-1 zu beginnen und rückwärts zu zählen, weil ein Test auf i>=0 effizienter war als i<array.length. Ein besonderer Held meinte mal zu mir, ich sollte nicht >= oder <= verwenden, weil das ja zwei Tests und nicht nur einer wären. Gut, dass ich Assembler kannte und wusste, dass der Prozessor für < und <= eigene Instruktionen hat…
Die Grundregel ist eigentlich immer: Versuche, das Anlegen von Objekten zu vermeiden. Wenn Du Speicher allocierst, dann in einer bekannten Größe. Größenänderungen bedeuten Umkopieren.
Was relativ billig ist, ist das schnelle Anlegen und wieder Verwerfen kleiner Temp-Objekte. Ein Mark-and-Sweep Collector wie in GC ist für diese Art der Speichernutzung optimiert. Aber ein Objekt, das länger verwendet wird und dann auch noch ständig umkopiert werden muss, wie Dein newArray, ist ein ziemlicher Sparren im Speichergetriebe.
Im Detail hilft nur: Messen und Probieren. Mit performance.now() bekommst Du einen ziemlich präzisen Timestamp als Bezugspunkt (der nur leider von den Browsern künstlich abgestumpft wird, weil man damit zu genau messen konnte, was der PC tut und das zum Hacken genutzt hat). Man kann auf 5µs Genauigkeit kommen, dazu steht bei MDN was.
Rolf
Hallo Rolf,
vielen Dank. Sehr interessant zu lesen. Gut zu wissen, dass ich nichts übersehen habe und der GC sich in JS nicht direkt ansteuern lässt.
Der große Performancevorteil durch das Vordefinieren der Array-Länge scheint nur im Chrome realisierbar. Im aktuellen Firefox ist der Vorteil viel geringer. Hier mal getestet: https://jsperf.app/sotemu
Werde in Zukunft mal darauf achten, dort wo möglich, die Array-Länge vorzudefinieren.
Gruss Michael
Hallo Michael_K,
meine Timing-Tests gestern fanden auf meinem privaten Desktop PC statt. Ich habe das jetzt auf meinem Dienst-Laptop mit Edge 130 wiederholt, und finde einen Faktor 4 in der Laufzeit.
Dein Test ist aber nicht valide. Du füllst ein Array mit konstanten Werten, und überträgst nicht ein bestehendes Array in ein neues.
Wenn ich einen Test mache, der deinem Eingangsbeispiel entspricht, finde ich einen Faktor von 2 zwischen Push und Assign, und nochmal einen Faktor 2, wenn ich als Zielarray ein Uint32Array statt eines normalen Arrays verwende https://jsperf.app/kanayo/2.
Ein Vergleichstest mit Firefox war mir nur mit der ESR-128 Version auf einem System möglich, wo auch andere Benutzer drauf sind, und dort waren Push- und Zuweisungsmethode im Wesentlichen gleich schnell. Der Push war teils sogar schneller, aber das muss ich zu Hause nachtesten, um einen wirklich validen Vergleich zu haben. Entweder ist der FF so schlau, die Push-Schleife zu erkennen und das Zielarray entsprechend groß vorzubereiten, oder die push-Methode ist generell besser optimiert, oder er implementiert die Zuweisung intern so, als ob ein Push gemacht würde. Aber auch auf Firefox ist das Uint32Array doppelt so schnell wie das normale Array.
Einen direkten Vergleich Edge/Firefox habe ich nicht, weil ich sie nicht auf der gleichen Maschine habe.
Also - danke für den Hinweis: offenbar gibt es hier relevante Unterschiede zwischen V8 und Spidermonkey. Ein Test mit Nitro (-> Safari) wäre auch noch interessant.
Und danke für den Hinweis auf jsperf, das Tool war mir ganz entfallen!
Rolf
Hallo Rolf,
Ein Test mit Nitro (-> Safari) wäre auch noch interessant.
Safari 17.6 unter macOS 12.7.6 (MacBook Air von Anfang 2015)
Methode | Geschwingigkeit |
---|---|
const arr - push method | 1,231 ±4.21% 71% slower |
const arr - assign method | 3,016 ±1.72% 29% slower |
typed array | 4,267 ±5.60% fastest |
Gruß
Jürgen
Hallo Jürgen,
danke schön.
Aber, Michael - das ist jetzt vor allem für deine Massendaten relevant.
Das größte Performancerisiko im Browser ist nicht diese Form von Detailoptimierung, sondern ungeschickter Umgang mit dem DOM. Unerwartetes Re-Layout der Seite bremst viel mehr als JavaScript.
Und wenn's wirklich schnell gehen muss, ist JavaScript auch nicht unbedingt die Sprache der Wahl, dann landet man außerhalb des Browsers bei C++ oder C. Oder andere, vollcompilierende und strikt getypte Sprachen. In die Tiefen von Assembler abzusteigen sollte nur in Extremfällen nötig sein…
Rolf
Hallo Rolf,
der/das DOM ist eher nicht mein Problem. Es werden externe Inputdaten speicherschonend als WebStream abgearbeitet und erst das Endergebnis wird in den DOM/Webseite einghangen. Ich kann aber im Dev-Tool bei Chrome sehen, dass der Speicher ziemlich stark anwächst und erst nach einer kleinen Ewigkeit wieder freigegeben wird. Ich bin nun auf der Suche, welche Daten da unnötig lange gehalten werden und wie man diese schneller wieder freigaben kann.
Ein Speicher-Snapshot hat mich noch nicht auf die richtige Spur gebracht. Grob überschlagen hätte ich mit max ca. 250 MB gerechnet, aber definitiv keine Spitzen von 700MB. Da muss ich nochmal ran.
Gruss Michael
Hallo Michael,
vielleicht hilft Dir dieser Artikel ein wenig weiter?
Grüße
LL
und bei Stackoverflow (hier wohl eher Heapoverflow ;-p ) gibts auch noch eine Betrachtung dazu, wie man es einfach halten kann.
Hallo Lothar,
vielen Dank dafür. Es ist nur so, dass in meinem Fall der Speicher wieder freigegeben wird (also kein klassischer memory leak), aber die Freigabe erfolgt viel später(nach Berechnung und Ergebnisausgabe) und ich würde die Freigabe gere etwas beschleunigen. Ich werde mich melden, wenn ich Erfolg habe.
Gruss Michael
Hallo Michael,
hast Du denn mal versucht, ob der allokierte Speicher auf der Adresse (Variable, Struktur) wieder freigegeben wird, wenn Du der Variable NULL
zuweist, sowie Du sie nicht mehr benötigst?
Die Ankeradresse selber kannst/musst Du dann getrost dem GC überlassen. Die benötigt aber nicht so viel Platz.
Grüße
Lothar