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 ar­chi­tek­to­nisch 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

Speed Change

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

nach Browsern

Google Docs Link: Handlebars Javascript Benchmark

Tabelle Operations per second Speed Change