Javascript Performance Benchmark mit jsperf
tl;dr; Lies Abschnitt "Ergebnis".
Ich liebe es ja Code-Poesie zu schreiben. Viel lieber mag ich es noch, über Code-Poesie zu diskutieren. Zur Zeit arbeiten @prop79 und ich an einem Shop. Im Backend kommt Rails mit ActiveAdmin zum Einsatz und im Frontend Backbone.JS (welches Underscore nutzt), Backbone.Marionette und Handlebars Templates.
Der Kunde möchte Kategorien anlegen können und diese unabhängig, aber in einer bestimmten Reihenfolge in Männer, Frauen und Unisex einteilen. Das bedeutet, dass eine Kategorie auch in allen drei Bereichen angezeigt werden kann, aber an völlig unterschiedlichen Positionen. Die Kategorien können also für jeden der drei Bereiche frei sortiert werden.
Die Kommunikation zwischen Rails und Backbone funktioniert über reines JSON. Mein erster Gedanke war, um es im Frontend einfach zu halten, alle Kategorien in der Index Action des Categories Controller in einen extra Hash zu packen und vorsortiert auszuliefern. Das klappte gut, der Aufwand (in Zeilen) war aber enorm und der Code eher unschick, zumal ich auch nicht auf das wundervolle Gem RABL verzichten wollte.
Nach nochmaligem Überlegen war mir dann aber klar, dass es architektonisch sinnvoll ist, diese reine Frontendlogik auch im Frontend zu lösen, damit kein anderer Bereich der Anwendung sich mit dem Sortierproblem beschäftigen muss und alles in einer einzigen Liste verarbeiten und weiterreichen kann. Das spart das Sortieren in der Action, Einpacken in JSON mit RABL, Versenden gedoppelter Daten und erneutes Verarbeiten im Frontend.
Stattdessen schrieb ich einen Handlebars Helper, der erst kurz vor dem Kompilieren des Templates, die Daten in die gewünschte Form umwandelt.
Code
Hier nun der komplette Stack in Code-Schnipseln:
#application_controller.rb
@categories = Category.all
# categories/_index.json.rabl
collection @categories, :object_root => false
attributes :name, :in_men, :in_women, :in_unisex
// Erzeugtes JSON
[
{
"name": "TROUSERS",
"in_men": 3,
"in_women": 2,
"in_unisex": null
},
{
"name": "HATS",
"in_men": null,
"in_women": 5,
"in_unisex": 2
},
...
]
<!-- handlebars template -->
<ul>
{{#nav items "in_men"}}
<li>{{name}}</li>
{{/nav}}
</ul>
// (1) erste Implementation des Handlebars Helpers
Handlebars.registerHelper('nav', function(array, gender, fn){
var buffer = '';
array = _.sortBy(array, function(item){ return item[gender] });
for (var i = 0, j = array.length; i < j; i++){
var item = array[i];
if(typeof(item[gender]) === 'number'){
buffer += fn(item);
}
}
return buffer;
});
Diskussion
René fand die Implementation nicht so optimal und das war sie auch nicht, also fingen wir an, sie lesbarer zu gestalten und fügten eine neue Variable ein. Damit sah der Code dann so aus:
// (2) erste Implementation (lesbar)
Handlebars.registerHelper('nav', function(array, gender, fn){
var buffer = '';
var sortedArray = _.sortBy(array, function(item){ return item[gender] });
for (var i = 0, j = sortedArray.length; i < j; i++){
var item = array[i];
if(typeof(item[gender]) === 'number'){
buffer += fn(item);
}
}
return buffer;
});
Am Ende war ich damit aber auch nicht so richtig zufrieden, weil die zusätzliche Deklaration einer Variable, nur um die Lesbarkeit des Codes zu erhöhen, nicht gerechtfertigt ist. Am nächsten Tag schaute ich mir nochmal die Underscore Bibliothek an, da wir diese
schon für Backbone einbinden und damit ohne weiteren Aufwand zur Verfügung steht. Als Ruby-Entwickler findet man dort viele bekannte Funktionen wieder. Dazu gibt es ein paar
wirklich durchdachte Sachen wie die _.reduce
Funktion (abgeleitet vom MapReduce Verfahren). Mit dieser konnte ich unseren Helper so umbauen:
// (3) Refactoring mit Underscore
Handlebars.registerHelper('nav', function(array, gender, fn){
array = _.filter(array, function(item) { return typeof(item[gender]) === 'number'});
array = _.sortBy(array, function(item) { return item[gender] });
return _.reduce(array, function(memo, item) { return memo + fn(item); }, '');
});
Oder um das ganze auf die Spitze zu treiben und die zwei Zuweisungen einzusparen, als Einzeiler:
// (4) Refactoring mit Underscore (one line)
Handlebars.registerHelper('nav', function(array, gender, fn){
return _.reduce(_.sortBy(_.filter(array, function(item){ return typeof(item[gender]) === 'number'}), function(item){ return item[gender] }), function(memo, item) { return memo + fn(item); }, '');
});
Benchmark
Am lesbarsten war am Ende die dritte Version unseres Helpers, aber welche war nun die performanteste? Nach kurzem Suchen fand ich die wirklich exzellente Seite jsperf.com. Dort kann man sehr schnell Javascript Benchmarks erstellen und dann in verschiedenen Browsern testen. Ich schrieb einen Test Case mit allen vier Varianten des Helpers.
Systeme
Ich testete auf allen Systemen, die ich gerade zur Verfügung hatte. Wie ich finde, ist eine ganz gute Auswahl zustande gekommen, um einigermaßen repräsentativ zu sein und die unterschiedlichen Javascript Engines testen zu können. Die fett geschriebenen Namen, sind die Namen, die unten in den Graphiken vor den Browserversionen stehen.
Ubuntu: Ubuntu 12.04 64bit, Intel i7-2600k, 3,8 GHz
OSX: Mac OS X Lion 10.7.4, Macbook Pro, 13 Zoll, Mitte 2010, Intel Core2Duo 2,4 GHz
Android: Android 4.1.1, Google Nexus S, ARM Cortex-A8, 1,0 GHz
Windows: Windows 7 64bit, in einer VM auf dem Ubuntu Rechner, halbe CPU Leistung (2Kerne/4Threads) zugeteilt
Leider erstellt jsperf Graphiken nur auf Basis der verschiedenen Browser. Also legte ich eine Tabelle an und trug per Hand die Durchschnittswerte von drei Testruns pro System und Browser ein, um die durchschnittliche Perfomance der einzelnen Versionen des Helpers auf allen Systemen ermitteln zu können. "1" steht für den Durchschnitt von drei Testruns der ersten Helper Version auf einem System. "2" für die zweite Version des Helpers, usw.
Ergebnis
Hier ist der jsperf.com Link zum selbst testen: Handlebars Nav Helper
Falls jemand Lust hat, könnte man den Helper noch mit reinem jQuery und ähnlichem implementieren. Einfach den Test Case bearbeiten. (Es wird eine neue Version angelegt)
nach Implementationen
- Die dritte und lesbarste Implementierung ist die durchschnittlich schnellste Implementierung, obwohl dort noch zwei Anweisungen mehr ausgeführt werden. Möglicherweise ist das (rekursive) Parsen von einem verschachtelten Einzeiler aufwändiger als drei einfache Zeilen.
- Die reine Anwendung von Underscore Funktionen ist so gut wie immer schneller als selbstgebaute for-Schleifen.
- Die größte Abweichung nach unten hat die zweite Implementierung auf einem alten Chrome in Windows mit 88,14% der Ausgangsgeschwindigkeit (ca. 8000 ops/s weniger)
- Die größte Abweichung nach oben hat die vierte Implementierung auf dem Internet Explorer 8 auf Windows mit 127,46% der Ausgangsgeschwindigkeit (ca. 800 ops/s mehr, trotzdem noch saulahm)
- Alle Abweichungen bewegen sich im Bereich des Unspürbaren beim Browsen (kommt natürlich darauf an wieviele solcher schnellen und lahmen Codeteile ich auf der Seite habe)
- Es gibt acht Benchmarks, bei denen die zusätzliche Variable in der zweiten Implementierung für eine höhere Geschwindigkeit sorgt
- Bei neun Benchmarks ist die vierte Implementierung (meist knapp) die Schnellste
nach Browsern
- mit Abstand schnellster Browser auf allen Plattformen ist Chrome
- Firefox ist auf allen Systemen relativ lahm
- die Geschwindigkeitsverbesserung von IE8 auf IE9 ist krass
- Firefox auf OSX und Chrome auf Windows werden mit steigender Versionsnummer langsamer
Google Docs Link: Handlebars Javascript Benchmark