Cet article est publié sous licence CC BY-NC-SA
Le code permettant de faire tourner les exemples est sur notre github.
Dans la première partie de notre benchmark nous avons vu un exemple dans lequel écrire ses requêtes manuellement était plus adapté qu’utiliser ActiveRecord.
En effet, lorsque nous n’avons pas besoin de manipuler le résultat d’une requête sous forme d’objets héritant d’ActiveRecord::Base, autant lancer une requête écrite à la main. C’était le cas de notre génération de données.
Avec ActiveRecord::Base, chaque tour de boucle crée un objet et joue les callbacks. Or c’est la partie initialisation d’objet qui est la plus coûteuse.
Dès lors que nous manipulons les résultats de sortie, l’impact d’ActiveRecord est négligeable puisque notre coût sera majoritairement celui de l’initialisation, chose que nous devrons également faire avec notre requête en SQL pur.
Repartons donc de notre morceau de code suivant:
def self.find_with_ar_and_sort_with_ruby
includes(:ratings).group('posts.id').sort_by(&:avg_rating)
end
def avg_rating
ratings.average(:score) || 0
end
Cette approche est une approche assez classique et naïve de développement. Le code est compréhensible et fait ce qu’on lui demande.
Mais prenons un peu de recul, est ce que cette solution tiens la route quand nos volumes de données explosent ?
Voyons le comportement de cette solution selon les volumes. Pour cela nous allons réalisé des benchmarks des deux méthodes vu dans le premier article.
Pour rappel, voici la même requête au format SQL
def self.order_by_rating_sql
find_by_sql("
SELECT posts.*, AVG(ratings.score) AS average FROM posts
LEFT OUTER JOIN ratings ON posts.id = ratings.post_id
GROUP BY posts.id, posts.created_at
ORDER BY average DESC NULLS LAST, posts.created_at DESC")
end
Expliquons brièvement un benchmark par le biais d’un exemple basique.
Prenons l’exemple d’une insertion d’objets dans un tableau. En ruby, vous pouvez utiliser soit l’opérateur <<
, soit la méthode push
. Les deux font le même travail, à l’exception de l’insertion multiple d’objet qui est supporté par la méthode push
mais pas par l’opérateur <<
.
Dans notre exemple, nous voulons déterminer quelle méthode utiliser pour insérer des données issues d’une boucle (donc insertion un à un).
#notre boucle qui être tout autre chose
1000000.times{
#avec l'opérateur `<<`, insertion de 15 pour l'exemple
array=[]; array << 15
#avec la méthode `push`
array=[]; array.push(15)
}
Le module Benchmark fait partie intégrante de la bibliothèque standard, de sorte que vous n’avez pas besoin d’installer gem pour l’obtenir. Voici la documentation de la bibliothèque standard.
Le code de ce benchmark est à exécuter au sein d’une console ruby.
Benchmark.bm do |performance|
performance.report("Insert"){ 1000000.times{ array=[]; array << 15}}
performance.report("Push"){ 1000000.times{ array=[]; array.push(15)}}
end
Nous avons choisi un nombre d’itération d’un million afin de pouvoir distinguer une différence significative entre les deux méthodes.
Voici le résultat obtenu :
Le benchmark nous renvoit 4 valeurs par test.
Voici à quoi correspondent ces paramétres :
- user
: User CPU Time - Temps passé a éxécuter le code au sein de l’espace utilisateur
- system
: System CPU Time - Temps passé a éxécuter le code au sein du noyau système
- total
: User CPU Time + System CPU Time -
- real
: Le temps réel qu’il a fallu pour éxécuter le code (temps système, temps à attendre l’utilisateur, réseau, disque etc… )
Vous l’aurez compris, ce qui nous intéresse ici c’est bien le paramètre real
, soit le temps réel qu’il a fallu pour exécuter le code.
Nous voyons que l’opérateur <<
est légèrement plus rapide que la méthode push
.
L’objectif ici est uniquement de vous présenter le déroulé d’un benchmark, nous sommes dans
de la micro-optimisation et les différences ne sont pas significatives.
Lorsque nous réalisons des benchmarks sur un grand nombre d’objets instanciés, les résultats peuvent être biaisés par les interactions avec la mémoire système, l’initialisation de ruby ou d’autres dépendances.
C’est pour cela qu’existe la méthode Benchmark#bmbm
qui permet de jouer deux fois notre code pour le Benchmark.
En effet, cette méthode va comparer réellement deux fois le code. La première fois sera pour lui comme une “répétition” / initialisation et la seconde permettra de réaliser le vrai Benchmark sans effet de bord.
Réalisons le même benchmark que précédemment mais cette fois-ci avec la méthode #bmbm
:
Benchmark.bmbm do |performance|
performance.report("Insert"){ 1000000.times{ array=[]; array << 15}}
performance.report("Push"){ 1000000.times{ array=[]; array.push(15)}}
end
Observons le résultat :
Dans le cas de notre exemple, nous ne voyons pas de différence entre la méthode #bm
et #bmbm
car le test est très basique.
Il reste néanmoins conseillé d’utiliser la méthode #bmbm
plutôt que #bm
.
Nous voulons donc voir l’évolution du temps d’éxécution de notre méthode find_with_ar_and_sort_with_ruby
en fonction du nombre de données à traiter. Le but étant d’analyser son comportement quand le volume
de données tend à croître.
Benchmark.bmbm do |performance|
performance.report("OrderByRatingSql:") { Post.order_by_rating_sql }
performance.report("OrderByRatingAr:") { Post.find_with_ar_and_sort_with_ruby }
end
Avant de voir comment enregistrer toutes les données et générer le graphique (qui fera l’objet du prochain article), générerons 4 cas manuellement :
Dans le premier article nous avions mis en place une tâche rake (sample:populate) afin de réinitialiser notre base de données et de générer le nombre de donnée voulu. Nous utilisons donc cette tâche rake après chaque benchmark.
Voici les résultats obtenus :
5000P-15000R | 10 000P-30 000R | 20 000P-60 000R | 40 000P-120 000R | |
---|---|---|---|---|
SQL | 110.248 | 174.69 | 391.472 | 879.621 |
Ruby | 2956.505 | 6013.68 | 12428.89 | 24923.189 |
x | 26.81 | 34.42 | 31.74 | 28.33 |
La dernière ligne nous indique combien de fois la méthode avec SQL est plus rapide que celle avec le tri en ruby.
Voici un graphique représentant ces valeurs:
Petit zoom sur la partie illisible (réalisons le benchmark avec un nombre max de posts à 100) :
Ces résultats nous permettent déjà d’observer que courbe de la méthode find_with_ar_and_sort_with_ruby
s’accélére beaucoup plus rapidement que la méthode utilisant le SQL. D’ailleurs cette dernière semble relativement linéaire lors de la montée en charge.
Nous pouvons également dire que le point limite d’utilisation de la méthode find_with_ar_and_sort_with_ruby
se situe à environ une dizaine de posts.
Au delà de ce point, il n’est vraiment plus conseillé d’utiliser cette méthode.
Le fait de trier tous les objets avec la fonction ruby sort_by après les avoir récupéré en SQL rend cette méthode beaucoup plus lente.
Dans le prochain article, on automatisera tout cela pour générer nos graphiques en fonction du type de requête, des quantités de données, de la précision du graphique (nombre de points) etc…
En attendant si vous le souhaitez, vous pouvez retrouvez le code de cet article sur notre repo Github.
L’équipe Synbioz.
Libres d’être ensemble.
Nos conseils et ressources pour vos développements produit.