Benchmark ActiveRecord - SQL Partie 2

Publié le 19 septembre 2014 par Jonathan François | outils

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.

Exemple d’un benchmark

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 : Results

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 :

Results

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.

Code de notre benchmark

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 :

  • 5 000 posts et 15 000 ratings
  • 10 000 posts et 30 000 ratings
  • 20 000 posts et 60 000 ratings
  • 40 000 posts et 120 000 ratings

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: Graphique

Petit zoom sur la partie illisible (réalisons le benchmark avec un nombre max de posts à 100) : Graphique

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.

Conclusion

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.