Optimisation de l'interopérabilité entre Lua et C++

Introduction
Le moteur Roblox est écrit en C++ et en Lua. Le code chargé des opérations de calcul intensif est écrit en C++ optimisé, tandis que la logique du jeu et les scripts sont écrits en Lua, pour faciliter le développement. Pour que ce modèle soit efficace, les transitions entre Lua et C++ doivent être aussi rapides que possible, car tout temps passé dans cette zone tampon n'est en réalité qu'une perte de millisecondes.
Au cours des deux derniers mois, nous avons déployé diverses améliorations sur cette partie du système. Un aspect en particulier — l’appel effectif des méthodes C++ depuis Lua — s’est avéré particulièrement intéressant, car il a permis des gains de vitesse considérables et a nécessité de fouiller dans les entrailles de Lua pour comprendre comment les choses fonctionnaient en coulisses.
Nous avons fini par modifier la machine virtuelle Lua elle-même, mais avant d'en arriver là, nous devons poser quelques bases.
Compilateurs, machine virtuelle et bytecode
Lorsque le code source Lua est compilé, il est converti en bytecode Lua, que la machine virtuelle Lua exécute ensuite. Le bytecode Lua comporte environ 35 instructions au total, pour des opérations telles que la lecture/écriture de tables, l'appel de fonctions, l'exécution d'opérations binaires, les sauts et les conditions, etc. La machine virtuelle Lua est basée sur des registres, contrairement à de nombreuses autres machines virtuelles qui sont basées sur une pile ; ainsi, une partie du travail du compilateur lors de la génération du bytecode consiste à déterminer quels registres chaque instruction doit utiliser.
Chaque instruction se présente sous la forme « OP_CODE A B » ou « OP_CODE A B C », où « OP_CODE » est le code d'opération (par exemple, CALL pour appeler une fonction) et A/B/C sont les arguments du code d'opération. Les arguments (ou registres) ne sont pas des valeurs réelles. Il s’agit plutôt d’indices qui pointent vers l’une des deux tables : la table des constantes (notée Kst(..)) ou la table des registres (notée R(..)).
Pour une description détaillée du bytecode Lua, consultez « Une introduction sans fioritures aux instructions de la machine virtuelle Lua 5.1 ». C'est bien plus passionnant que ça en a l'air, je vous le promets !
Pour vous donner une idée de ce à quoi ressemble le bytecode Lua, nous allons d'abord passer en revue quelques programmes simples, puis passer à des exemples plus pertinents.
À l'aide de l'utilitaire Chunkspy, nous pouvons désassembler le bytecode Lua en code assembleur Lua et obtenir une liste du code, ainsi que la table des constantes, ce qui nous permet de voir concrètement quel bytecode est généré pour un code source Lua donné.
Exemples de bytecode de base
Un programme simple comme « x = 10 » se compile en :
.const "x"; 0
.const 10; 1
[1] loadk 0 1 ; 10
[2] setglobal 0 0 ; x Les deux premières lignes montrent la table des constantes (avec la valeur de chaîne « x » dans l'emplacement 0 et la valeur entière 10 dans l'emplacement 1), et les deux lignes suivantes sont les opcodes désassemblés.
[Ligne 1] En consultant l'opcode LOADK dans « No Frills », on constate qu'il se présente sous la forme « LOADK A Bx --- R(A) := Kst(Bx) ». Ainsi, LOADK a deux arguments (les registres A et B) et son opération consiste à assigner la valeur trouvée dans la table des constantes à l'emplacement donné par le deuxième registre, Kst(Bx), au registre R(A) à l'emplacement donné par le premier argument. « Bx » signifie simplement que, comme l'opcode n'a que deux arguments, le registre B est étendu et se voit attribuer davantage de bits.
[Ligne 2] SETGLOBAL a la forme « SETGLOBAL A Bx --- Gbl[Kst(Bx)] := R(A) ». Elle attribue une valeur à la table globale en utilisant la clé fournie par la table des constantes à l'emplacement du deuxième argument. Comme le deuxième argument est 0 et que la valeur de la table des constantes à 0 est « x », elle écrit quelque chose dans la table globale en utilisant la clé « x ». Ce qui se trouve dans la table des registres à l'emplacement indiqué par le premier argument est ce qui est écrit, ce que l'instruction précédente a chargé avec la valeur 10.
Examinons un exemple un peu plus compliqué : « x = 10 ; y = x ». Je laisse au lecteur le soin d'exécuter manuellement le code. :)
.const "x"; 0
.const 10; 1
.const"y"; 2
[1] loadk 0 1 ; 10
[2] setglobal 0 0 ; x
[3] getglobal 0 0 ; x
[4] setglobal 0 2 ; y
Bytecode pour les appels de fonction
Examinons le code généré pour « foo(10) » :
.const "foo"; 0
.const 10; 1
[1] getglobal 0 0 ; foo // R(A) := Gbl[Kst(Bx)]
[2] loadk 1 1 ; 10 // R(A) := Kst(Bx)
[3] call 0 2 1
Pour exécuter des appels de fonction, la fonction doit être chargée dans le premier registre et les arguments dans les registres suivants. La sémantique de « CALL A B C » est telle que A contient la fonction, B est le nombre d’arguments (en réalité, c’est le nombre d’arguments +1, en raison de la manière dont « ... » est implémenté), et C est le nombre de valeurs de retour (là encore, c’est le nombre de valeurs de retour +1, pour gérer les valeurs de retour multiples).
Nous connaissons bien les deux premières lignes ; elles chargent une valeur dans l'emplacement 0 de la table des registres et la valeur 10 dans l'emplacement 1 de la table des registres. La troisième ligne est celle qui effectue l'appel de fonction, en utilisant la valeur du registre A (emplacement 0 de la table des registres, qui a été chargé avec « foo »), B spécifiant le nombre d'arguments et C le nombre de valeurs de retour (rappelons que 1 doit être ajouté aux valeurs de B et C). Avant que la fonction puisse être appelée, la VM vérifie également que la valeur dans R(A) est bien appelable.
Lua dispose d’un mécanisme permettant aux utilisateurs d’étendre les fonctionnalités des tables en associant une métatable à une table existante. La métatable contient des méthodes de secours qui sont invoquées si une certaine méthode ou opération ne peut pas être effectuée sur la table principale (voir https://www.lua.org/pil/13.html pour une description détaillée).
Dans notre cas, les entrées les plus pertinentes de la métatable sont les champs « __index » et « __call ». __index est utilisé lors de la recherche d’un élément dans une table ; ainsi, le code « local x = my_table[10] » appellerait d’abord la méthode __index sur my_table. En cas d'échec, il tenterait alors d'appeler __index sur la métatable de my_table. __call est utilisé de manière similaire lorsque vous essayez de traiter quelque chose comme une fonction et de l'appeler, par exemple « local x = foo(42) »
Pour que Lua et C++ puissent interagir, ils ont besoin d’un moyen de partager des fonctions et des données. Lua facilite cela en fournissant un type de données appelé UserData. Les objets UserData peuvent être créés en C++, et comme il s’agit de types de données natifs de Lua, ils peuvent être agrémentés de métatables qui permettent au code Lua d’interagir avec eux comme s’il s’agissait d’objets Lua classiques.
Appels de fonctions membres
Bon, revenons à l’examen du bytecode ! L’exemple suivant est un peu plus intéressant car il montre ce qui se passe lorsque vous avez un code tel que « foo:bar(10) », qui appelle la méthode bar sur l’instance foo (une instance de la classe Foo).
foo:bar(10)
.const "foo"; 0
.const "bar"; 1
.const 10; 2
[1] getglobal 0 0 ; foo
[2] self 0 0 257 ; "bar"
[3] loadk 2 2 ; 10
[4] call 0 3 1
La nouveauté ici est l’instruction self [ligne 2], que nous n’avons pas encore vue. Self a la syntaxe « SELF A B C --- R(A) := R(B)[RK(C)]; R(A+1) := R(B) », alors analysons cela. Dans la table des registres, à l'emplacement R(A), elle placera le résultat de la recherche dans la table à l'emplacement R(B) en utilisant la clé de l'emplacement RK(C). Elle copiera également le contenu de l'emplacement R(B) vers l'emplacement R(A+1), mais nous y reviendrons plus tard. Vous remarquerez peut-être que la valeur du registre C est 257. C'est valable car Lua utilise RK(C) pour rechercher la valeur, et RK utilisera soit la table des registres, soit la table des constantes, en fonction de la valeur du 9e bit. S'il s'agit d'un 1, ce qui est le cas ici, alors la table des constantes est utilisée ; sinon, la recherche se fait dans la table des registres (après avoir masqué le bit de poids fort).
La ligne 3 place 10 dans l'emplacement 2, et enfin la ligne 4 exécute l'appel de fonction.
L'instruction SELF a deux fonctions. Premièrement, elle recherche la méthode « bar » dans la classe Foo et la place dans R(A). Deuxièmement, comme foo est une méthode d'instance et que nous avons besoin de l'instance de la classe sur laquelle nous invoquons la méthode lors de l'appel, elle place cette instance dans R(A+1). Si vous connaissez les classes en Python, vous reconnaîtrez peut-être le concept : les méthodes s'écrivent généralement sous la forme « def my_method(self, arg1, arg2..) », où self est l'instance de la classe.
Nous allons devoir approfondir un peu ce sujet et examiner ce qui se passe lorsque l'instance foo est un objet C++, représenté en Lua sous la forme d'un objet UserData.
L'appel SELF peut être considéré comme une consultation de table, c'est-à-dire Foo[« bar »] (Foo en majuscules représente la classe Foo, par opposition à foo, l'instance), et nous savons que les consultations utilisent la méthode __index. Lorsque l'instance foo a été créée en C++, une métatable a été associée à l'instance, et le champ __index de cette métatable a été défini sur un morceau de code C++ qui sera appelé lorsque __index sera invoqué.
Lorsque C/C++ est appelé depuis Lua, la seule donnée transmise est un objet lua_State. Cet objet contient tout ce qui concerne le thread Lua en cours d'exécution. L'information la plus importante dans l'objet d'état est la pile Lua, qui contient les arguments de la fonction (accessibles via la famille de fonctions lua_tointeger/tostring, etc.) et qui est également utilisée pour renvoyer des valeurs vers Lua.
En pseudo-C++, notre fonction __index ressemble à ceci :
int metaIndex(lua_State* L)
{
// first argument is the userdata object
UserData* userdata = lua_touserdata(L, 1);
// get some kind of descriptor, that contains information
// about what methods the class exposes
ClassDescriptor* desc = getDescriptorForUserData(userdata);
// See if the class has the requested method
const char* methodName = lua_tostring(L, 2);
MemberFunctionPtr method = desc->hasMethod(methodName);
if (method)
{
// Upvalues are values that are available when a C
// function is invoked.
lua_pushupvalue(L, method);
lua_pushcfunction(L, methodInvoker);
return 1;
}
else
{
lua_pushnil(L);
return 0;
}
}
De nombreux détails internes sont passés sous silence, mais voici l'essentiel. Étant donné que l'objet UserData est passé comme premier argument sur la pile Lua, nous sommes en mesure de trouver un descripteur qui décrit la classe C++ réelle, et via ce descripteur, nous pouvons voir si cette classe possède une méthode portant le nom donné. Si c'est le cas, un pointeur de fonction vers un invocateur de méthode est poussé sur la pile Lua, et nous renvoyons un succès.
Après cet appel, la machine virtuelle Lua placera le reste des arguments dans la table des registres, puis appellera la fonction que nous avons renvoyée depuis la méthode metaIndex, qui appellera à nouveau le C++ et aboutira à la fonction d'invocation :
int methodInvoker(lua_State* L)
{ <br> // Get the userdata and the class descriptor
UserData* userdata = lua_touserdata(L, 1);
ClassDescriptor* desc = getDescriptorForUserData(userdata);
Class* instance = (Class*)userdata;
// Using Lua's upvalue mechanism, get the 'method'
// that was stored in metaIndex.
MemberFunctionPtr method = lua_getupvalue(L, 1);
// This is hand-wavey, but we have some mechanism of being
// able to invoke a member function via the class descriptor,
// and also pop arguments from the Lua stack, and push return values
return desc->invokeFunction(instance, method, L);
}
La méthode methodInvoker utilise également le ClassDescriptor, mais cette fois-ci, elle est capable d'invoquer la fonction membre et de retirer les bons arguments de la pile.
La dernière ligne droite !
Maintenant que nous voyons clairement les deux allers-retours entre Lua et C++, nous pouvons essayer de déterminer comment optimiser ce processus.
Notre objectif final est d'effectuer un seul appel de fonction de Lua vers C++ et d'avoir tous les éléments nécessaires sur la pile Lua pour pouvoir effectuer la recherche et l'invocation de la méthode en une seule fois. Le problème semble être qu'il nous manque un registre. Lorsque nous appelons notre fonction combinée de recherche et d'invocation, nous voulons que la pile Lua se présente comme suit : [self, nom de la méthode, arg1, arg2, ...], mais en examinant SELF, nous constatons qu'il utilise son premier emplacement pour le résultat de la recherche de la fonction de méthode et le deuxième emplacement pour stocker l'instance.
Une prise de conscience décisive s'est produite lorsque nous avons examiné le fonctionnement de la métaméthode __call. Si un objet dispose de la métaméthode __call, alors avant que la fonction _call ne soit invoquée, l'objet lui-même est poussé sur la pile et tous les arguments sont décalés vers le haut. En nous appuyant sur cette fonctionnalité, il était possible d'obtenir « self » sur la pile sans avoir à le stocker explicitement dans un registre.
La deuxième partie consistait à placer également le nom de la méthode sur la pile. Pour cela, nous avons dû faire preuve d’astuce et modifier le fonctionnement de l’opcode SELF.
Rappelons que, par défaut, SELF tente de trouver la fonction membre et de la stocker dans R(A) avec l’instance dans R(A+1). Nous avons fini par ignorer complètement cette recherche et avons stocké l’objet réel dans R(A) et le nom de la méthode dans R(A+1).
Si nous nous assurions maintenant que l'objet dans R(A) disposait d'une métaméthode __call, nous finirions également par pousser self sur la pile. Nous aurions donc une pile qui ressemblerait à [self, nom de la méthode, args…] et n'effectuerions qu'un seul appel vers C++. Parfait ! Enfin, presque. :)
Avant de considérer cela comme terminé, nous avons voulu y apporter quelques finitions. Nous ne voulions pas surcharger la sémantique de la métaméthode __call ; nous avons donc ajouté une métaméthode spécifique pour ce type d’invocation — appelée __namecall — qui n’était disponible que sur les objets UserData. Nous avons également modifié l’opcode SELF afin qu’il n’utilise la nouvelle sémantique que si l’objet dispose d’une métaméthode __namecall.
La deuxième chose que nous avons faite consistait principalement à permettre au nouveau chemin et à l'ancien chemin de partager facilement du code. Au lieu d'avoir le nom de la méthode comme deuxième argument, nous l'avons déplacé vers le dernier argument. Ainsi, après avoir été utilisé pour rechercher le pointeur de méthode, il pouvait facilement être retiré de la pile, et la pile se présentait comme si la fonction avait été invoquée via l'ancien chemin.
Conclusion
Quel est l’impact de cette optimisation ? Eh bien, comme pour la plupart des choses en programmation, la réponse est « ça dépend ». Pour les fonctions lourdes — que vous n’appelez pas souvent —, vous ne constaterez pas d’amélioration notable. Mais pour les petites fonctions que vous appelez souvent, les gains peuvent être considérables.
Les membres du forum des développeurs ont rapidement remarqué l'apparition de cette nouvelle métaméthode étrange, et un tableau a été présenté comparant la vitesse de __namecall à la fois à l'ancienne méthode d'appel des méthodes d'instance et à une solution de contournement que les développeurs utilisaient pour optimiser l'appel des méthodes :
local part = workspace.Baseplate
local count = 1000000
local start0 = tick()
for i=1,count do
part:IsA("BasePart")
end
local end0 = tick()
local start1 = tick()
for i=1,count do
local isa = part.IsA
isa(part, "BasePart")
end
local end1 = tick()
local start2 = tick()
local isa = part.IsA
for i=1,count do
isa(part, "Basepart")
end
local end2 = tick()
print("namecall", end0 - start0)
print("index+call", end1 - start1)
print("call", end2 - start2)
> namecall 0.49229717254639
> index+call 0.78510332107544
> call 0.49960780143738
La première boucle utilise le nouveau chemin d'exécution __namecall, mais comme toute la magie opère en arrière-plan, les développeurs n'ont pas besoin de modifier le code existant pour bénéficier de l'optimisation.
La deuxième boucle émule l'ancienne façon d'effectuer un appel de méthode d'instance : d'abord rechercher la méthode, puis l'invoquer.
Enfin, la troisième boucle illustre une optimisation courante pratiquée par les développeurs, consistant à rechercher d’abord la méthode, à la stocker dans une variable locale, puis à invoquer cette variable.
Ce qui est intéressant ici, c'est que cela montre qu'avec l'optimisation __namecall, il n'est plus nécessaire de mettre explicitement en cache les fonctions d'instance, car elle est tout aussi rapide que l'optimisation par mise en cache ; ainsi, le code le plus simple sera également le plus performant.
Maintenant que __namecall a été déployé et que nous sommes satisfaits des résultats obtenus, il est temps de nous concentrer sur l'utilisation de la mémoire et de voir ce que nous pouvons faire pour améliorer le client dans ce domaine !


