Test de performance Ruby

1 Réponses • 552 Vues

Nuri Yuri

HostMaster

Salut, dans ce sujet je vais montrer des tests de performance de certaines manières d'écrire du code Ruby.
Ces tests sont fait à l'aide du module benchmark de Ruby, je donnerai deux résultats et je commenterai ceux-ci.

Le format des différents tests sera le suivant :
Nom du test
Code
Résultat
Commentaire


Temps d'exécution brûte de for/time/upto dans une boucle de 0 à n
def test1(n)
Benchmark.bm(10) do |x|
x.report('for') { for i in 0...n do end }
x.report('times') { n.times { |i| } }
x.report('upto') { 0.upto(n-1) { |i| } }
end
nil
end

                user    system      total        real
for          3.953000  0.000000  3.953000 (  3.945528)
times        3.625000  0.000000  3.625000 (  3.674161)
upto        3.609000  0.000000  3.609000 (  3.610492)
                user    system      total        real
for          4.000000  0.016000  4.016000 (  4.018443)
times        3.718000  0.000000  3.718000 (  3.727272)
upto        3.579000  0.000000  3.579000 (  3.583954)

Les méthodes upto et times bien qu'elles utilisent un bloc sont plus rapide que le mot clé for. La différence de temps entre times et upto est assez minime la plupart du temps donc ils sont équivalent.


Temps d'exécution de boucles imbriqués
def test2(n, n2 = 1000)
Benchmark.bm(10) do |x|
x.report('for') { for i in 0...n do for j in 0...n2 do end end }
x.report('times') { n.times { |i| n2.times { |j| } } }
x.report('upto') { 0.upto(n-1) { |i| 0.upto(n2 - 1) { |j| } } }
end
nil
end

                user    system      total        real
for          4.063000  0.000000  4.063000 (  4.073870)
times        3.531000  0.000000  3.531000 (  3.555437)
upto        3.609000  0.000000  3.609000 (  3.642794)
                user    system      total        real
for          4.094000  0.000000  4.094000 (  4.095330)
times        3.563000  0.000000  3.563000 (  3.560964)
upto        3.546000  0.000000  3.546000 (  3.551975)

Sur ce test, on commence à voir que l'écart entre times/upto et for se creuse un peu. Imaginez donc du code qui itère à 3 niveaux et qui est très souvent appelé (60 fois au moins) en une seconde.


Même tests que les deux précédent mais en évitant la génération d'objets lors du test
def test3(n)
range = 0...n
Benchmark.bm(10) do |x|
x.report('for') { for i in range do end }
x.report('times') { n.times { |i| } }
x.report('upto') { 0.upto(n-1) { |i| } }
end
nil
end
def test4(n, n2 = 1000)
range = 0...n
rang2 = 0...n2
Benchmark.bm(10) do |x|
x.report('for') { for i in range do for j in rang2 do end end }
x.report('times') { n.times { |i| n2.times { |j| } } }
x.report('upto') { 0.upto(n-1) { |i| 0.upto(n2 - 1) { |j| } } }
end
nil
end

#test3
                user    system      total        real
for          4.094000  0.000000  4.094000 (  4.085594)
times        3.672000  0.000000  3.672000 (  3.687970)
upto        3.656000  0.000000  3.656000 (  3.678156)
                user    system      total        real
for          4.219000  0.000000  4.219000 (  4.232484)
times        3.859000  0.000000  3.859000 (  3.928990)
upto        3.750000  0.000000  3.750000 (  3.730622)

#test4
                user    system      total        real
for          4.312000  0.000000  4.312000 (  4.332343)
times        3.750000  0.000000  3.750000 (  3.761662)
upto        3.797000  0.000000  3.797000 (  3.798667)
                user    system      total        real
for          4.454000  0.000000  4.454000 (  4.513141)
times        3.718000  0.000000  3.718000 (  3.798507)
upto        3.735000  0.000000  3.735000 (  3.800130)

On constate que même en stockant le range dans une variable, la boucle for reste plus lente que time et upto.


Conditions unless contre les conditions if ! et if not
def test5(n)
tr = true
fl = false
Benchmark.bm(20) do |x|
x.report('unless true') { n.times { 0 unless tr } }
x.report('unless false') { n.times { 0 unless fl } }
x.report('if not true') { n.times { 0 if not tr } }
x.report('if not false') { n.times { 0 if not fl } }
x.report('if !true') { n.times { 0 if !tr } }
x.report('if !false') { n.times { 0 if !fl } }
end
nil
end

                          user    system      total        real
unless true            0.437000  0.000000  0.437000 (  0.426894)
unless false          0.422000  0.000000  0.422000 (  0.435318)
if not true            0.516000  0.000000  0.516000 (  0.512847)
if not false          0.484000  0.015000  0.499000 (  0.501301)
if !true              0.516000  0.000000  0.516000 (  0.517258)
if !false              0.484000  0.000000  0.484000 (  0.498909)
                          user    system      total        real
unless true            0.437000  0.000000  0.437000 (  0.439280)
unless false          0.438000  0.000000  0.438000 (  0.440027)
if not true            0.516000  0.000000  0.516000 (  0.529585)
if not false          0.515000  0.000000  0.515000 (  0.518547)
if !true              0.500000  0.000000  0.500000 (  0.524461)
if !false              0.531000  0.000000  0.531000 (  0.525847)

On constate que unless est plus rapide que if ! ou if not, ce qui est normal puis-ce que unless fait économiser un appel de fonction.


Condition de sortie de fonction sur une propriété avec test de l'existence de l'objet (if vs unless)
def test6(n)
nilobj = nil
trobj = Object.new
def trobj.prop() true end
flobj = Object.new
def flobj.prop() false end
Benchmark.bm(20) do |x|
x.report('unless nil') { n.times { 0 unless nilobj && !nilobj.prop } }
x.report('unless true') { n.times { 0 unless trobj && !trobj.prop } }
x.report('unless false') { n.times { 0 unless flobj && !flobj.prop } }
x.report('if nil') { n.times { 0 if !nilobj || nilobj.prop } }
x.report('if true') { n.times { 0 if !trobj || trobj.prop } }
x.report('if false') { n.times { 0 if !flobj || flobj.prop } }
end
nil
end

                          user    system      total        real
unless nil            0.422000  0.000000  0.422000 (  0.414901)
unless true            0.734000  0.000000  0.734000 (  0.759905)
unless false          0.750000  0.000000  0.750000 (  0.756782)
if nil                0.485000  0.000000  0.485000 (  0.483935)
if true                0.765000  0.000000  0.765000 (  0.760565)
if false              0.782000  0.000000  0.782000 (  0.777518)
                          user    system      total        real
unless nil            0.453000  0.000000  0.453000 (  0.461495)
unless true            0.812000  0.000000  0.812000 (  0.811154)
unless false          0.781000  0.000000  0.781000 (  0.780396)
if nil                0.516000  0.000000  0.516000 (  0.509829)
if true                0.797000  0.000000  0.797000 (  0.796470)
if false              0.828000  0.000000  0.828000 (  0.849900)

On constate que les deux cas (if/unless) sont à peu près équivalent même si unless bénéficie d'une plus grande rapidité dans le cas où l'objet n'est pas défini.


Condition d'exécution de code sur une propriété avec test de l'existence de l'objet (if vs unless)
def test7(n)
nilobj = nil
trobj = Object.new
def trobj.prop() true end
flobj = Object.new
def flobj.prop() false end
Benchmark.bm(20) do |x|
x.report('unless nil') { n.times { 0 unless !nilobj || !nilobj.prop } }
x.report('unless true') { n.times { 0 unless !trobj || !trobj.prop } }
x.report('unless false') { n.times { 0 unless !flobj || !flobj.prop } }
x.report('if nil') { n.times { 0 if nilobj && nilobj.prop } }
x.report('if true') { n.times { 0 if trobj && trobj.prop } }
x.report('if false') { n.times { 0 if flobj && flobj.prop } }
end
nil
end

                          user    system      total        real
unless nil            0.515000  0.000000  0.515000 (  0.516317)
unless true            0.860000  0.000000  0.860000 (  0.872909)
unless false          0.859000  0.000000  0.859000 (  0.867979)
if nil                0.437000  0.000000  0.437000 (  0.433577)
if true                0.704000  0.016000  0.720000 (  0.718310)
if false              0.718000  0.000000  0.718000 (  0.724716)
                          user    system      total        real
unless nil            0.531000  0.000000  0.531000 (  0.536457)
unless true            0.891000  0.000000  0.891000 (  0.896096)
unless false          0.859000  0.000000  0.859000 (  0.866521)
if nil                0.453000  0.000000  0.453000 (  0.441448)
if true                0.703000  0.000000  0.703000 (  0.702902)
if false              0.719000  0.000000  0.719000 (  0.716738)

Dans un tel test, on constate que if est plus rapide que unless, ceci s'explique assez facilement par le fait qu'il y a deux inversion dans unless contre zéro dans le if. Inverser une booléen coute du temps de calcul.


Condition d'execution avec autant d'inversion pour le cas if que le cas unless
def test8(n)
a = true
b = false
Benchmark.bm(20) do |x|
x.report('unless !a or b') { n.times { 0 unless !a or b } }
x.report('if a and !b') { n.times { 0 if a and !b } }
x.report('unless a and !b') { n.times { 0 unless a and !b } }
x.report('if !a or b') { n.times { 0 if !a or b } }
end
nil
end

                          user    system      total        real
unless !a or b        0.484000  0.000000  0.484000 (  0.504805)
if a and !b            0.500000  0.000000  0.500000 (  0.507417)
unless a and !b        0.500000  0.000000  0.500000 (  0.496798)
if !a or b            0.516000  0.000000  0.516000 (  0.510257)
                          user    system      total        real
unless !a or b        0.516000  0.000000  0.516000 (  0.514401)
if a and !b            0.515000  0.000000  0.515000 (  0.512519)
unless a and !b        0.516000  0.000000  0.516000 (  0.514878)
if !a or b            0.515000  0.000000  0.515000 (  0.514458)

Ici, nous constatons que les résultats sont très proches et qu'il n'y a aucune raison de privilégier l'un ou l'autre.


Condition avec accès sur variable d'instance
def test9(n)
@obj = Object.new
def @obj.prop() true end
Benchmark.bm(20) do |x|
x.report('ivar access') { n.times { 0 if @obj && @obj.prop } }
x.report('local asignment') do
n.times do
obj = @obj
0 if obj && obj.prop
end
end
x.report('if asignment') { n.times { 0 if (obj = @obj) && obj.prop } }
end
nil
end

                          user    system      total        real
ivar access            0.719000  0.000000  0.719000 (  0.712001)
local asignment        0.718000  0.000000  0.718000 (  0.715696)
if asignment          0.704000  0.000000  0.704000 (  0.705806)
                          user    system      total        real
ivar access            0.703000  0.000000  0.703000 (  0.704716)
local asignment        0.719000  0.000000  0.719000 (  0.720434)
if asignment          0.719000  0.000000  0.719000 (  0.710304)

Nous constatons qu'il n'y a pas de très grosse différence mais que l'allocation de la variable d'instance peut être légèrement couteuse si elle ne sert que pour la condition.



Test d'accès multiple aux propriétés d'un objet par la variable d'instance ou par une variable locale
def test10(n)
@obj = Object.new
def @obj.prop1() end
def @obj.prop2() end
def @obj.prop3() end
def @obj.prop4() end
def @obj.prop5() end
Benchmark.bm(20) do |x|
x.report('ivar access') do
      n.times do
        @obj.prop1
        @obj.prop2
        @obj.prop3
        @obj.prop4
        @obj.prop5
      end
    end
x.report('local access') do
      n.times do
        (obj = @obj).prop1
        obj.prop2
        obj.prop3
        obj.prop4
        obj.prop5
      end
end
end
nil
end

                          user    system      total        real
ivar access            1.828000  0.000000  1.828000 (  1.834955)
local access          1.625000  0.000000  1.625000 (  1.628531)
                          user    system      total        real
ivar access            1.890000  0.000000  1.890000 (  1.908691)
local access          1.782000  0.000000  1.782000 (  1.779801)

On constate une vitesse légèrement plus grande pour l'utilisation de variable locale (7%) mais cela dépend beaucoup de la taille du cache des processeurs.
Lors de mes tests sur un Xeon (assez récent) les deux étaient assez équivalent (peu de différence, après tout le monde n'a pas un Xeon dans son PC :v).


Test de l'appel de méthode sur la variable d'instance ou en chaine à la Java
def test11(n)
@obj = Object.new
def @obj.prop1() self end
def @obj.prop2() self end
def @obj.prop3() self end
def @obj.prop4() self end
def @obj.prop5() self end
Benchmark.bm(20) do |x|
x.report('no chain') do
      n.times do
        @obj.prop1
        @obj.prop2
        @obj.prop3
        @obj.prop4
        @obj.prop5
      end
    end
x.report('chain') do
      n.times do
        @obj.prop1.prop2.prop3
          .prop4
          .prop5
      end
end
end
nil
end

                          user    system      total        real
no chain              1.781000  0.000000  1.781000 (  1.792772)
chain                  1.641000  0.000000  1.641000 (  1.645065)
                          user    system      total        real
no chain              1.765000  0.000000  1.765000 (  1.775226)
chain                  1.625000  0.000000  1.625000 (  1.622949)

On constate que la chaine est plus rapide (en plus d'éviter l'écriture de @ qui est pas pratique à écrire sur un clavier AZERTY).
Comparé aux variable locales c'est kiff kiff à l'exception que ça économise une variable.


Accès à une constante contre accès à une constante par la racine
class Test
def test12(n)
Benchmark.bm(10) do |x|
x.report('outside') do
      n.times do
        Math
      end
    end
x.report('root') do
      n.times do
        ::Math
      end
end
end
nil
end
end

                user    system      total        real
outside      0.375000  0.000000  0.375000 (  0.374896)
root        0.375000  0.000000  0.375000 (  0.372688)
                user    system      total        real
outside      0.375000  0.000000  0.375000 (  0.376898)
root        0.360000  0.000000  0.360000 (  0.373211)

Les résultats sont à peu près identique, la différence majeure est la précision. Si une classe Math est définie dans la classe Test, on ne trouvera pas nécessairement Math::PI depuis test si on utilise pas l'écriture qui explicite à Ruby de partir de la racine (::Math::PI).


Initialisation de Hash à String contre initialisation de Hash à Symbols
def test13(n)
Benchmark.bm(10) do |x|
x.report('String') do
      n.times do
        { "a" => 0, "b" => 1, "c" => 2 }
      end
    end
x.report('Symbol') do
      n.times do
        { a: 0, b: 1, c: 2 }
      end
end
end
nil
end

                user    system      total        real
String      5.860000  0.000000  5.860000 (  5.864220)
Symbol      4.281000  0.000000  4.281000 (  4.280487)
                user    system      total        real
String      5.891000  0.000000  5.891000 (  5.897124)
Symbol      4.297000  0.000000  4.297000 (  4.291657)

On constate que le gain de temps est de 27% (ce qui est significatif) en utilisant des Symboles pour les clé de Hash.
Il y a deux raisons à ça.
1. Les symboles sont immuable, contrairement aux string qui provoquent la création d'un objet à chaque fois qu'on écrit l'expression littérale.
2. Les symboles sont utilisés dans le fonctionnement interne de Ruby, de ce fait, Ruby gère les symboles plus efficacement que les strings. (Dans les Hash qui ont la même structure que la table de variable d'instance d'un objet).


Déserialiser un Hash à String contre déserialiser un Hash à Symbols
def test14(n)
  d = Marshal.dump({ "a" => 0, "b" => 1, "c" => 2 })
  e = Marshal.dump({ a: 0, b: 1, c: 2 })
Benchmark.bm(10) do |x|
x.report('String') do
      n.times do
        Marshal.load(d)
      end
    end
x.report('Symbol') do
      n.times do
        Marshal.load(e)
      end
end
end
nil
end

                user    system      total        real
String      3.093000  0.000000  3.093000 (  3.088905)
Symbol      2.032000  0.000000  2.032000 (  2.031451)
                user    system      total        real
String      3.093000  0.000000  3.093000 (  3.092860)
Symbol      2.047000  0.000000  2.047000 (  2.044098)

La déserialisation est encore plus frappante (pas d'effet de cache), on a un gain de temps de 33% en déserialisant des Hash utilisant des Symbols.


Test d'accès de Hash, frozen String vs String vs Symbol
def test15(n)
  a = { "a" => 0, "b" => 1, "c" => 2 }
  b = { "a".freeze => 0, "b".freeze => 1, "c".freeze => 2 }
  c = "c".freeze
  d = { a: 0, b: 1, c: 2 }
Benchmark.bm(20) do |x|
x.report('String') do
      n.times do
        a["c"]
      end
    end
x.report('Frozen String') do
      n.times do
        b["c"]
      end
    end
x.report('Frozen String w fs') do
      n.times do
        b[c]
      end
    end
x.report('Symbol') do
      n.times do
        d[:c]
      end
end
end
nil
end

                          user    system      total        real
String                1.047000  0.000000  1.047000 (  1.058712)
Frozen String          1.062000  0.000000  1.062000 (  1.059156)
Frozen String w fs    1.094000  0.000000  1.094000 (  1.106889)
Symbol                0.562000  0.000000  0.562000 (  0.570313)
                          user    system      total        real
String                1.063000  0.000000  1.063000 (  1.067675)
Frozen String          1.078000  0.000000  1.078000 (  1.067470)
Frozen String w fs    1.094000  0.000000  1.094000 (  1.104055)
Symbol                0.562000  0.000000  0.562000 (  0.586306)

J'ai lu quelque part sur Internet qu'utiliser des frozen String rendait l'accès plus rapide aux valeurs d'un Hash, ce test prouve que cela est pas totalement vrai.
D'ailleurs, l'accès à l'aide de Symbol est largement plus rapide que celui à l'aide de String (45% de gain).


Tester respond_to? à l'aide de Symbol contre à l'aide de String
def test16(n)
Benchmark.bm(20) do |x|
x.report('String') do
      n.times do
        nil.respond_to?('to_s')
      end
    end
x.report('Symbol') do
      n.times do
        nil.respond_to?(:to_s)
      end
end
end
nil
end

                          user    system      total        real
String                1.953000  0.000000  1.953000 (  1.956250)
Symbol                0.719000  0.000000  0.719000 (  0.722352)
                          user    system      total        real
String                1.953000  0.000000  1.953000 (  1.961752)
Symbol                0.719000  0.000000  0.719000 (  0.724839)

On constate encore une fois que Ruby est bien plus efficace avec des Symboles qu'avec des Strings (63% de gain en vitesse).
ln(yo) = <3

Script

Nuri Yuri

HostMaster

Envoyer un message (appel de fonction), String vs Symbol
def test17(n)
Benchmark.bm(20) do |x|
x.report('String') do
      n.times do
        nil.send('to_i')
      end
    end
x.report('Symbol') do
      n.times do
        nil.send(:to_i)
      end
end
end
nil
end

                          user    system      total        real
String                2.140000  0.000000  2.140000 (  2.143785)
Symbol                0.875000  0.000000  0.875000 (  0.882647)
                          user    system      total        real
String                2.203000  0.000000  2.203000 (  2.197432)
Symbol                0.875000  0.000000  0.875000 (  0.884064)

Encore une fois, utiliser un String pour quelque chose de natif à Ruby est une mauvaise idée, utiliser un symbol apporte un gain de 59%.


Test de vitesse entre send et public_send
def test18(n)
Benchmark.bm(20) do |x|
x.report('send') do
      n.times do
        nil.send(:to_i)
      end
    end
x.report('public_send') do
      n.times do
        nil.public_send(:to_i)
      end
end
end
nil
end

                          user    system      total        real
send                  0.875000  0.000000  0.875000 (  0.878696)
public_send            0.984000  0.000000  0.984000 (  1.004569)
                          user    system      total        real
send                  0.875000  0.000000  0.875000 (  0.883909)
public_send            0.985000  0.000000  0.985000 (  0.982040)

public_send est un peu plus lent que send, en effet, public_send vérifie si la méthode invoquée est publique de ce fait il y a une légère perte.


Appel d'une méthode à arguments fixe vs méthode à argument dynamiques (splat)
def a(a, b, c)

end

def b(*args)

end
def test19(n)
Benchmark.bm(20) do |x|
x.report('arg list') do
      n.times do
        a(0, 1, 2)
      end
    end
x.report('splat') do
      n.times do
        b(0, 1, 2)
      end
end
end
nil
end

                          user    system      total        real
arg list              0.610000  0.000000  0.610000 (  0.613610)
splat                  1.453000  0.000000  1.453000 (  1.460436)
                          user    system      total        real
arg list              0.641000  0.000000  0.641000 (  0.642666)
splat                  1.453000  0.000000  1.453000 (  1.457832)

On constate que les méthodes ayant des arguments dynamique sont plus lente à appeler que des méthodes à argument fixe. Le gain avec les arguments fixe est de 55%.


Appel de méthode utilisant des keyword argument fixes vs méthode utilisant un double splat (keyword argument dynamique)
def c(a: , b: , c: )
end
def d(**kwarg)
end
def e(kwarg = {})
end
def test19(n)
  hash = { a: 1, b: 2, c: 3 }
Benchmark.bm(20) do |x|
x.report('kwarg') do
      n.times do
        c(a: 1, b: 2, c: 3)
      end
    end
x.report('double splat') do
      n.times do
        d(a: 1, b: 2, c: 3)
      end
end
x.report('passed hash') do
      n.times do
        d(**hash)
      end
end
x.report('passed hash w/ splats') do
      n.times do
        d(hash)
      end
end
x.report('kwarg old way') do
      n.times do
        e(a: 1, b: 2, c: 3)
      end
end
x.report('passed hash w/ splats old way') do
      n.times do
        e(hash)
      end
end
end
nil
end

                          user    system      total        real
kwarg                  1.125000  0.000000  1.125000 (  1.134334)
double splat          12.531000  0.000000  12.531000 ( 12.521757)
passed hash          19.906000  0.000000  19.906000 ( 19.916297)
passed hash w/ splats  8.516000  0.000000  8.516000 (  8.513546)
kwarg old way          4.813000  0.000000  4.813000 (  4.804575)
passed hash w/ splats old way  0.734000  0.000000  0.734000 (  0.737668)

Les résultats sont frappants, utiliser le double splat (**kwarg) est une catastrophe (10/20x plus lent).
Notez que quand vous utilisez l'ancienne méthode (ruby 1.9) pour les kwarg c'est plus rapide que la nouvelle (double splat), cela est du au fait que le double splat crée un nouveau Hash lors de l'appel de la méthode et créer un Hash est beaucoup plus punitif que créer un Array.

Quand vous créez des méthode utilisant des kwarg, faites donc bien gaffe à ce que votre méthode soit très peu appelée (genre uniquement dans des initialisation ocassionelles comme pour les éléments d'interface) et faites en sorte à ce que vous évitiez le plus possible les doubles splat (**kwarg).


Wala, c'est tout pour ce petit sujet :q
ln(yo) = <3

There was an error while thanking
Thanking...