Bolzplatzarena

Aber Moment mal - Performance Probleme mit Moment.js

Seit geraumer Zeit entwickelt unser Team eine Applikation, welches Daten zu visualisieren und manipulieren hat. Die meisten Entwickler nutzen dazu Testdaten im überschaubarem Rahm, um den Erfolg der Darstellung & Manipulation besser kontrollieren zu können. Wer will schon zu allen Stellen scrollen. Dies funktionierte super, alles läuft toll und schnell. 

Wenn es da nicht diesen einen Typen im Team gäbe, der immer sein Referenzprojekt nutzen würde, um den Erfolg von neuen Features zu messen. Beides - sowohl die Daten der Entwickler als auch die Daten des Testers - stellen valide Daten der Wirklichkeit da. Das eine sind Daten eines kleinen Projekte und das andere sind Daten eines großen Projektes. Leider verhält es sich bei der Skalierung der Darstellung & der Manipulation nicht um eine 1:1 Skalierung. Ein doppelt so großes Projekt, sorgt für eine vierfache Darstellung & verfache potenteille Durchlaufzeit bei Manipulationen.

Bei dem Test einer Neuentwicklung stellte sich raus, dass ein Teil der Manipulation plötzlich 17s dauerte. Die Aussage "sind halt viele Daten" war nicht gerade zufriedenstellend. Wir prüften, wie die Teillaufzeit vorher war. 8s. Immer noch ziemlich lahm, aber deutlich schneller. Der Entwickler war leider ratlos. Somit machten wir mit dem Profiler eine Prüfung und dieser zeigte eindeutig auf einer Methode (für diesen Anwendungsfall). Neben dem Overhead von Angular und seinem Lifecycle stach "isBefore" von Moment.js deutlich hervor.

Moment.js ist bequem zu nutzen und das wurde uns nun zum Verhängnis. Wir laden Daten aus der IndexDb, dort kann man natürlich keine Moment-Objekte speichern. Muss man auch nicht wenn man noch "isBefore" auch mit dem IsoString befüttern kann. Wir waren immer schon skeptisch und überlegten einen dexie Hook zu nutzen, aus dem IsoString Moments zu machen. Jetzt analysierten wir etwas mehr.

Was wir schnell rausgefunden haben - das ist aber wenig überraschend - war die Tatsache, dass isBefore mit zwei Moments einen wahnsinnigen Performanzschub bringt. Den string kurz vor der Verwendung zu einem Moment zu machen war jetzt nicht die schlechteste Idee, aber diese war zum Scheitern verurteilt auf Grund der Tatsache, wie Moment.js arbeitet. Dazu aber später mehr. Uns wurde also klar, dass wir uns Gedanken machen mussten, ob ein und der selbe String mehrmals in Methoden wie "isBefore", "isSame", "isBetween" etc. vorkam. Und in der Tat, es war wirklich so. Neben den Daten die offentsichtlich waren, speicherten wir natürlich auch so etwas wie Termine, Feiertage und Urlaube in der IndexDb und damit als IsoString hab. Wie besinnten uns also wieder auf die Idee mit dem dexie Hook und wandelten den IsoString beim Lesen aus der IndexDb um. Da es sich um wenige "Termine" handelte, war der Zusätzliche Aufwand überschaubar. Und das Ergebnis war enorm. Allein daurch konnten wir die Performance um 50% steigern. 

Aber reicht uns das?

Nein.

Den Rest auch umwandeln? Wir standen nun vor einem Dilemma. Bevor wir Manipulieren, zeigen wir die Daten an. Und genau die Daten werden ab und an manipuliert. Es ist also so, dass die Daten in 9 von 10 Fällen nur betrachten werden. Daher haben wir den Dexie Hook für uns erst einmal ausgeschlossen, denn wir müssten wir tausende Daten jeweils zwei Felder ins Moment überführen und hätten für 9 von 10 Fällen einen Performanzverlust hinnehmen müssen.

Daher haben wir uns anderen Bibliotheken angesehen und auch mal mit der Moment erzeugung selbst experimentiert. Ein Experiment bestand nur darin klassische Dates mit Moment zu vergleichen. Wir definierten zu erst zwei Szenarien.

1. Erzeuge 100.000 Moments aus einem String
2. Erzeuge 100.000 Dates aus einem String

Warum so hohe Zahlen? Zum ein sollen Zufälle ausgeschlossen werden, wenn die Stichprobe zu klein ist. Und zum anderen geht es bei uns um viele Daten. Wir haben eine 100 * 200 Matrix. Es sind zwar meist nicht einmal 50% der Felder der Matrix befüllt, aber ein Feld wird manchmal mehrmals durchlaufen und es werden mehrere Datumsfelder verglichen etc.

Unser Testrechner brauchte für

1. 1358 ms
2. 36 ms

Wir waren geschockt und überlegten Moment.js den Rücken zu kehren. Wir haben analysiert, was wir von Moment alles nutzen und uns war klar, dass wir gern die Bequemlichkeit behalten wollen würden. Viele Features ließen sich auch ohme Moment abbilden, manche sogar mit den blanken IsoStrings.

Ein "isBefore" lässt sich je nach Betrachtungsweise ohne Daten und Moment einfach mit einem normalen Vergleich der IsoString bewerkstelligen (ISO_STRING_1 < ISO_STRING_2).

Dennoch war die Nutzung von Moment für die einzelnen einfach und wir wollten nicht das ganze Projekt mit einem Big Bang ändern. Wir träumten immer noch von einer möglichst zentralen einfachen Möglichkeit.

Ich definierte ein drittes Szenarion inspiriert von eine Codestelle welche in days.js funden hatte. So komnbinierte ich Szenario 1 und 2. 

Statt

moment(ISO_STRING)

nutzte ich

moment(new Date(ISO_STRING))

Unser Testrechner brauchte für

3. 100ms

Klar das ist Faktor 3 langsamer als Date aber schaut euch den Unterschied zu Moment in der klassischen Nutzung an. Nun mussten wir rausfinden, ob das bei uns alle Fälle abdeckt. Und es war so. Wir stellten die zentrale Methode welche bei uns die Moments erzeugt um. Diese erlaubte ...arg: any als Eingangsparameter also mussten wir noch ein paar Checks einbauen, um es nicht einfach so zu machen und dann Laufzeitfehler zu bekommen. Dann stellten wir noch die neue Methode um, welche für die Kompfschmerzen gesorgt hatte. Es gab einige wenige Stellen, wo isBefore mit einem IsoString benutzt wurde, dort erstellten wir über den neuen Weg als dem IsoString ein Moment und übergaben das an isBefore.

Wir konnten die Performance weiter steigern und sind nun sogar bei 2s. Und sind somit sogar deutlich schneller als zuvor. Daher müssen wir sogar dem Feature danken, was unsere Software so langsam gemacht hat.

Aber warum ist das nun eigentlich so?

Das langsame an Moment (und auch an den anderen Frameworks) ist das Parsen eines Strings. In der Regel wird ein komplexer Regex angewendet und das Ergebnis weiterverarbeitet. In der einfachen Nutzung wird man den Overhead nicht merken. Muss man das 100er oder 1000e male ausführen, ist die Auswirkung enorm. Das Framework days.js prüft den Input ob es nicht einen schnelleren Weg gibt das Datum zu parsen und weicht in bestimmten Fällen dann einfach auf "new Date(ISO_STRING)" aus. Und das ist im Endeffekt genau das Feature, was wir nun bei uns eingebaut haben. Und wir vergleichen nur noch Moments mit Moments, damit wir die Erstellung des Moments unter Kontrolle haben.

Nachfolgend die "Last" erzeugenden Codeschnipsel für die unterschiedlichen Szenarien.

Szenario 1 - Erzeugen eines Moments aus dem String

createMoment.ts

typescript
1 2 3
for (let i = 0; i < this.loop; i++) {
    const data = moment('2011-01-01T12:12:12.123');
}

Szenario 2 - Erzeugen eines Dates aus einem String

createDate.ts

typescript
1 2 3
for (let i = 0; i < this.loop; i++) {
   const date = new Date('2011-01-01T12:12:12.123');
}

Szenario 3 - Erzeugen eines Moments aus einem Date was aus einem String erzeugt wurde

createMomentWithDate.ts

typescript
1 2 3
for (let i = 0; i < this.loop; i++) {
    const data = moment(new Date('2011-01-01T12:12:12.123'));
}