Benchmark vs. Fragezeichen

Benchmarken in Ruby

Tests sind für erfolgreiche Refactorings notwendig. Immerhin soll sich das System nach dem Refactoring auch noch genauso verhalten, wie vorher.
Aber auch die Performance wird durch Refactorings unter Umständen beeinflußt. Wenn ein Refactoring aus Sicht der Architektur erfolgreich war oder den Code verbessert hat, dann sollte es möglichst nicht zu Überraschungen bei der Performance kommen.
Benchmarks liefern die nötigen Informationen. Das heißt die gemessenen Werte geben die notwendige Sicherheit aus Sicht der Performance. Außerdem können Benchmarks helfen, Performanceprobleme zu lokalisieren. Ob ein mutmaßliches Performanceproblem beseitigt werden muß oder nicht, sollte auch von Benchmarkresultaten abhängen.
Fakten helfen, die richtigen Entscheidungen zu treffen.
So zum Beispiel lautet ein Argument gegen das Extrahieren von Logik in eine eigenständige Methode, daß es Performance kosten würde.
Anhand des folgenden Moduld AgeCalculator soll gemessen werden, wie teuer die Auslagerung der temporären Variable (years_since_birthday) in eine Query Methode ist:

# age_calculator.rb
module AgeCalculator
  ADULT_AGE = 18

  def self.adult? birthday
    years_since_birthday = Date.today.year - birthday.year
    years_since_birthday >= ADULT_AGE
  end
end

Dafür wird zunächst der IST-Zustand in einem Benchmark Script gemessen:

# benchmarks.rb
require 'benchmark'

Benchmark.bm do |bm|
  bm.report('adult?') do
    AgeCalculator.adult? Date.new(2000)
  end
end

Alles, was sich innerhalb des Benchmark Blocks befindet, wird in der Zeitmessung berücksichtigt. Mit Benchmark#bm können auch mehrere Benchmarks miteinander verglichen werden.
Wenn das Script durchgelaufen ist:

$ ruby benchmarks.rb

könnten dabei folgende Zahlen herauskommen:

        user       system     total        real
adult?  0.000000   0.000000   0.000000 (  0.000034)

Die Zahlen entsprechen den typischen Resultaten von UNIX Benchmarks:

  1. user: die CPU Zeit, die verbraucht wurde, um den User Code auszuführen (die adult? Implementierung),
  2. system: die CPU Zeit, die verbraucht wurde, um den Kernel Code auszuführen (alles, was in der Schicht unter Ruby ausgeführt wird)
  3. total: die CPU Gesamtzeit aus user und system
  4. real: die real verbrauchte Zeit, die auch andere Prozesse, die paralellel auf dem System laufen, beinhaltet (als wenn jemand mit der Stoppuhr messen würde)

Die total Zeit ist so gering, dass diese Zahlen fast wertlos sind. Außerdem sollen für eine exakte Aussage Vorher/ Nachher Vergleiche gemacht werden.

Benchmark Vergleiche

Zum Vergleich, wird die alternative Implementierung (mit einer Query Methode) eingeführt:

# age_calculator.rb
module AgeCalculator
  ADULT_AGE = 18

  def self.original_adult? birthday
    years_since_birthday = Date.today.year - birthday.year
    years_since_birthday >= ADULT_AGE
  end

  def self.alternative_adult? birthday
    years_since_birthday(birthday) >= ADULT_AGE
  end

  private

  def self.years_since_birthday birthday
    Date.today.year - birthday.year
  end
end

In dem Benchmark Script werden nun beide Implementierungen miteinander verglichen:

# benchmarks.rb
require 'benchmark'

birthday = Date.new(2000)

Benchmark.bm do |bm|                                                          
  bm.report('original') do                                                      
    AgeCalculator.original_adult? birthday                                      
  end                                                                           
                                                                                
  bm.report('alternative') do                                                   
    AgeCalculator.alternative_adult? birthday                                   
  end                                                                           
end

und ergeben folgendes Resultat:

                    user          system      total           real
original       0.000000   0.000000   0.000000 (  0.000055)
alternative  0.000000   0.000000   0.000000 (  0.000011)

Abgesehen von der immer noch geringen Qualität der Zahlen (user und system), war nicht zu erwarten, dass der alternative Ansatz um den Faktor 5 performanter ist. Der Benchmark ist offensichtlich noch nicht realistisch.

Realistische Benchmarks

Der Ruby Garbage Collection, Speicherallozierungen und Cachingmechanismen beinflussen ebenfalls den Benchmark.
Mit Benchmark#bmbm können diese Einflüsse ausgeschaltet werden. Dabei wird der Code nämlich zweimal Mal durchlaufen:

# benchmarks.rb
require 'benchmark'

birthday = Date.new(2000)

Benchmark.bmbm do |bm|
  bm.report('original') do
    AgeCalculator.original_adult? birthday
  end

  bm.report('alternative') do
    AgeCalculator.alternative_adult? birthday
  end
end

und der zweite Durchlauf ergibt dann ein sehr viel realistischeres Bild:

Rehearsal -----------------------------------------------
original        0.000000   0.000000   0.000000 (  0.000036)
alternative   0.000000   0.000000   0.000000 (  0.000009)
-------------------------------------- total: 0.000000sec

                     user          system      total            real
original        0.000000   0.000000   0.000000 (  0.000027)
alternative   0.000000   0.000000   0.000000 (  0.000027)

Allerdings laufen beide Implementierungen immer noch gegen 0 Sekunden.

Benchmarks für Last und Skalierung

Für Lasttests und Messung von kleinen Codesegementen macht es häufig Sinn, den Code sehr oft zu durchlaufen. Damit ergibt sich ein Mittelwert,der Varianzen ausschaltet.
In der Funktion scale_benchmark wird der Block eine Million mal ausgeführt:

# benchmarks.rb
require 'benchmark'

def scale_benchmark &block                                                      
  1000000.times &block                                                          
end

birthday = Date.new(2000)

Benchmark.bm do |bm|
  bm.report('original') do
    scale_benchmark { AgeCalculator.original_adult? birthday }
  end

  bm.report('alternative') do
    scale_benchmark { AgeCalculator.alternative_adult? birthday }
  end
end

Das Ergebnis entspricht den Erwartungen:

original        1.380000   0.580000   1.960000 (  1.958297)
alternative   1.410000   0.580000   1.990000 (  1.990055)

Tatsächlich kostet das Extrahieren der Logik in eine Query Methode Performance. Auf dem Testsystem (Intel , 8 GB RAM) beträgt der Unterscheid bei 1.000.000 Durchläufen 0,03 Sekunden.
Aber wer solche Performanceprobleme hat, hat eigentlich ein ganz anderes Problem.