Aller au contenu

Ruby Tumbler : préserver l’identité de l’implémentation

J’avance tranquillement sur Ruby Tumbler, mon compilateur expérimental de Ruby vers le natif.

Le but, c’est de prendre du code Ruby, sans toucher à sa syntaxe ni à son comportement, et de le faire tourner plus vite tout en consommant moins de mémoire.

À terme, j’aimerais compiler des applications Rails entières en binaires natifs autonomes.

J’ai travaillé avec Graphlient, une bibliothèque client GraphQL pour Ruby. J’ai compilé des parties de son code original, sans modification, et j’ai benchmarké la construction et la sérialisation complètes de requêtes.

Un code plus rapide ne fait pas forcément une app plus rapide

Nouveau problème : un code natif plus rapide ne signifie pas nécessairement une application plus rapide.

On peut compiler une méthode Ruby en code machine, supprimer quelques allocations, et quand même perdre l’avantage de performance dès que l’exécution bascule entre le code Ruby et le code natif.

Il y a des guards de méthode à vérifier. Les règles de dispatch dynamique de Ruby à préserver. Des chemins de fallback à maintenir.

Tout ça coûte du CPU.

Dans une expérience, j’ai supprimé des allocations via la réutilisation de bindings spécifiques au récepteur. Sauf que ça a ralenti les petites requêtes, sans amélioration constante de la consommation mémoire, donc j’ai désactivé le truc.

La dernière expérience a pris une autre direction : précalculer les identifiants de méthode, consolider plusieurs vérifications de guards en un seul lookup appelable et supprimer une dépendance inutile.

On a aussi débusqué un problème de correction subtil lié aux règles de recherche de méthodes de Ruby.

Supprimer Array#reject peut exposer Enumerable#reject, et les deux implémentations peuvent porter le même flag interne builtin.

Ce n’est pas suffisant pour établir qu’on appelle la même implémentation.

On a donc resserré nos guards pour vérifier l’identité de l’implémentation.

Le résultat mesuré

Le résultat ?

Sur 5,6 millions de requêtes chronométrées, la dernière version a réduit la consommation CPU médiane d’environ 8 % par rapport à notre précédente implémentation native sous YJIT.

On est maintenant à 2,3 % de Ruby classique sur les petites requêtes, et à 0,4 % sur les requêtes imbriquées.

Les plages de mesure se chevauchent, donc crier victoire serait prématuré.

Mais on a conservé les gains d’allocation précédents, et l’implémentation a passé 4 590 tests ciblés, 698 tests instrumentés, les suites de tests originales et les tests de régression de Rails.

Voilà ce que j’en retire :

L’opportunité n’est peut-être pas de compiler des méthodes Ruby individuelles en code machine plus rapide, mais plutôt de compiler des régions d’exécution plus larges, en supprimant le travail qui s’effectue _entre_ ces méthodes.

Au lieu d’optimiser des méthodes, qu’est-ce qui se passe si on compile une séquence entière de comportement d’application ?

Moins d’allers-retours dans la VM Ruby. Plus d’occasions de supprimer le travail intermédiaire.

On doit toujours préserver la sémantique de Ruby, y compris les cas complexes d’arguments par mot-clé, les blocs, les méthodes remplacées dynamiquement et les fallbacks qui ne doivent jamais répéter d’effets de bord.

C’est là que je me dirige maintenant.

J’ai lancé ce projet en me demandant combien de performance on pouvait presser de Ruby sans demander aux devs de réécrire leurs apps.

Maintenant, je soupçonne que la réponse intéressante réside dans la quantité de machinerie de runtime qu’on peut éviter d’exécuter, tout simplement.