games / lua
LuaJIT et FFI : concepts avancés
Explication
Ce que vous allez apprendre
- Comprendre la différence entre l'interpréteur PUC-Lua et le compilateur JIT de LuaJIT
- Déclarer une signature C avec
ffi.cdefet appeler directement une fonction C - Créer et manipuler des structs C typées via
ffi.typeof/ffi.new - Éviter les fuites mémoire d'une ressource C allouée manuellement avec
ffi.gc - Identifier les dangers de
ffi.cast, qui contourne toute vérification de sécurité
Dans quel contexte ?
Un moteur de simulation doit manipuler des dizaines de milliers de positions 3D par frame pour des calculs physiques, un volume de données pour lequel des tables Lua imbriquées deviennent trop lentes et trop gourmandes en mémoire. LuaJIT et sa FFI permettent de représenter ces données comme de vrais tableaux C compacts, directement depuis un script Lua, sans écrire une seule ligne de code C.
LuaJIT : un autre moteur d'exécution, bien plus rapide
Le Lua "standard" (PUC-Lua) interprète le code ligne par ligne à chaque exécution. LuaJIT est une implémentation alternative qui compile le code à la volée en code machine natif (JIT, "Just-In-Time") pendant l'exécution — un peu comme un moteur de navigateur web compile du JavaScript. Le résultat est un gain de performance très important, souvent décisif pour du code de jeu vidéo ou de simulation intensif.
FFI : parler directement au code C, sans intermédiaire
Normalement, faire communiquer Lua avec une bibliothèque écrite en C demande d'écrire un "binding" — du code C spécifique qui traduit entre les deux mondes. LuaJIT propose la FFI (Foreign Function Interface), qui élimine ce travail : on décrit simplement la signature de la fonction C qu'on veut appeler (avec une syntaxe C classique, dans ffi.cdef), et LuaJIT se charge de l'appeler directement, sans code de conversion intermédiaire à écrire.
Pourquoi c'est aussi rapide
L'accès aux structures via FFI se fait quasiment comme en C natif : pas de conversion coûteuse entre représentation Lua et représentation C, pas d'indirection supplémentaire par une table Lua classique. Pour manipuler de grandes quantités de données structurées (des milliers de positions 3D, par exemple), un tableau FFI de structs C est bien plus compact et rapide qu'un tableau Lua de tables imbriquées.
Le prix de cette rapidité : la responsabilité manuelle
FFI donne accès à de la mémoire brute, avec les risques que cela implique : un pointeur C n'est pas géré par le ramasse-miettes Lua de la même façon qu'un objet Lua ordinaire. Une ressource allouée manuellement (via malloc) doit être explicitement libérée, sinon elle fuit silencieusement — d'où l'importance de ffi.gc, qui attache un destructeur automatique à une ressource, redonnant au garbage collector Lua la responsabilité de sa libération.
Un outil puissant, à réserver à un besoin réel
FFI est délibérément une échappatoire aux garanties de sécurité normales de Lua (ffi.cast ne vérifie ni taille ni alignement). Ce n'est pas un outil du quotidien, mais une réponse ciblée à un vrai besoin de performance ou d'interopérabilité avec du code natif existant.
| Fonction FFI | Rôle |
|---|---|
ffi.cdef | Déclare une signature C (types, fonctions) |
ffi.new/ffi.typeof | Alloue une struct/tableau C typé |
ffi.gc | Attache un destructeur automatique à une ressource |
ffi.cast | Réinterprète de la mémoire brute, sans aucune vérification |
Piège fréquent
Une ressource allouée manuellement avec ffi.C.malloc n'est PAS gérée par le garbage collector Lua tant qu'on ne l'a pas explicitement attachée avec ffi.gc(ptr, ffi.C.free). L'oublier crée une fuite mémoire silencieuse, invisible tant que le processus ne manque pas encore de mémoire.
Commandes & code
LuaJIT et FFI : concepts avancés
LuaJIT compile à la volée (JIT) et expose la Foreign Function Interface (FFI) pour appeler du code C sans écrire un seul binding en C.
local ffi = require("ffi")
-- Déclarer la signature C exacte (syntaxe C standard, pas du Lua) : LuaJIT parse ceci lui-même
ffi.cdef[[
typedef struct { double x, y; } Point;
double sqrt(double x);
int printf(const char *fmt, ...);
]]
-- Appel direct d'une fonction de la libc, sans binding intermédiaire ni conversion manuelle
local result = ffi.C.sqrt(2.0)
print(result) -- 1.4142135623731
-- Création d'une struct C typée, allouée et gérée comme un objet Lua normal (GC automatique)
local Point = ffi.typeof("Point")
local p = Point(3.0, 4.0)
print(p.x, p.y) -- 3.0 4.0-- Pourquoi FFI est rapide : pas de conversion Lua<->C via la pile, accès mémoire quasi natif
local ffi = require("ffi")
ffi.cdef[[ typedef struct { float x, y, z; } Vector3; ]]
local Vector3 = ffi.typeof("Vector3")
local N = 1000000
local points = ffi.new("Vector3[?]", N) -- tableau C contigu de N structs, alloué en une fois (VLA - variable length array)
for i = 0, N - 1 do
points[i].x = i * 1.0
points[i].y = i * 2.0
points[i].z = i * 3.0
end
-- Accès en O(1) sans indirection de table Lua ni boxing : bien plus rapide qu'une table de N tables imbriquées-- Piège classique : la durée de vie des chaînes/pointeurs C n'est PAS gérée par le GC Lua de la même façon
local ffi = require("ffi")
ffi.cdef[[ const char *getenv(const char *name); ]]
local path = ffi.C.getenv("PATH") -- retourne un const char* : pointeur brut, PAS une string Lua
local lua_string = ffi.string(path) -- OBLIGATOIRE pour convertir en vraie string Lua gérée par le GC
-- Danger : garder une struct C pointant vers de la mémoire libérée côté C = comportement indéfini (pas d'erreur Lua propre)
-- Règle d'or : ffi.gc() pour attacher un destructeur à une ressource C allouée manuellement
local ptr = ffi.C.malloc(1024)
ptr = ffi.gc(ptr, ffi.C.free) -- le GC Lua appelle free() automatiquement quand ptr n'est plus référencé-- Cas d'usage typique : parser un buffer binaire réseau/fichier avec un typage C explicite (pas string.byte en boucle)
local ffi = require("ffi")
ffi.cdef[[
#pragma pack(1)
typedef struct {
uint32_t magic;
uint16_t version;
uint8_t flags;
} PacketHeader;
]]
local function parseHeader(rawBytes)
local header = ffi.cast("PacketHeader*", rawBytes) -- réinterprète la mémoire brute SANS copie
return {
magic = header.magic,
version = header.version,
flags = header.flags,
}
end
-- ffi.cast est une opération dangereuse par nature : aucune vérification de longueur/alignement n'est faiteRésumé
- FFI déclare des signatures C via
ffi.cdefet appelleffi.C.<fonction>directement, sans binding manuel. - Les structs et tableaux C alloués via
ffi.newoffrent un accès mémoire quasi natif, bien plus rapide que des tables Lua imbriquées. ffi.gcattache un destructeur à une ressource C : sans lui, une allocation manuelle fuit silencieusement.ffi.castréinterprète de la mémoire brute sans aucune vérification : à réserver aux données déjà validées.
Exercices pratiques
Mission : le serveur de simulation qui fuit sa mémoire
Objectif : Diagnostiquer une fuite mémoire causée par des ressources C non attachées au garbage collector Lua, et sécuriser un cast FFI dangereux.
Contexte
Un moteur de simulation physique utilise LuaJIT et sa FFI pour allouer des buffers de calcul :
local ffi = require("ffi")
ffi.cdef[[
void *malloc(size_t size);
void free(void *ptr);
]]
local function allocateBuffer(size)
local ptr = ffi.C.malloc(size) -- allocation manuelle, jamais libérée explicitement
return ptr
end
for i = 1, 10000 do
local buf = allocateBuffer(1024) -- appelé à chaque frame de simulation
endAprès quelques minutes de simulation continue, le processus consomme des gigaoctets de mémoire et finit par planter, alors que collectgarbage("count") ne montre presque aucune mémoire Lua utilisée.