Retour au cours

games / lua

LuaJIT et FFI : concepts avancés

Leçon 141 exercice

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.cdef et 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 FFIRôle
ffi.cdefDéclare une signature C (types, fonctions)
ffi.new/ffi.typeofAlloue une struct/tableau C typé
ffi.gcAttache un destructeur automatique à une ressource
ffi.castRé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.

lua
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
lua
-- 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
lua
-- 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é
lua
-- 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 faite

Résumé

  • FFI déclare des signatures C via ffi.cdef et appelle ffi.C.<fonction> directement, sans binding manuel.
  • Les structs et tableaux C alloués via ffi.new offrent un accès mémoire quasi natif, bien plus rapide que des tables Lua imbriquées.
  • ffi.gc attache un destructeur à une ressource C : sans lui, une allocation manuelle fuit silencieusement.
  • ffi.cast réinterprète de la mémoire brute sans aucune vérification : à réserver aux données déjà validées.

Exercices pratiques

1 disponible
1

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 :

lua
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
end

Aprè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.

Résoudre l’exercice →