games / lua
Performance, profiling et bonnes pratiques
Explication
Ce que vous allez apprendre
- Capturer une fonction globale fréquente dans une variable locale pour accélérer sa résolution
- Remplacer une concaténation répétée en boucle par
table.concat - Repérer les allocations invisibles (tables/closures créées à chaque frame) qui chargent le garbage collector
- Mesurer un temps d'exécution réel avec
os.clock()avant d'optimiser quoi que ce soit - Choisir un
__indexde type table plutôt que fonction dans un chemin de code critique
Dans quel contexte ?
Un script de jeu recalcule la distance entre chaque joueur et plusieurs points d'intérêt à chaque frame, soixante fois par seconde. Sur un serveur avec des dizaines de joueurs et plusieurs scripts qui font ce genre de calcul en parallèle, une fonction anodine en apparence peut, une fois répétée à cette fréquence, devenir la cause principale de ralentissements perceptibles pour tout le monde.
Pourquoi la performance compte particulièrement en Lua
Lua est très souvent utilisé comme langage de script à l'intérieur d'un moteur temps réel (jeu vidéo, simulation) où du code s'exécute à chaque frame, potentiellement soixante fois par seconde. Un code qui semble anodin en soi peut, répété des milliers de fois par seconde, devenir un vrai goulot d'étranglement — d'où l'importance de connaître les pièges de performance propres au langage.
Locale vs globale : une différence de vitesse réelle
Accéder à une variable locale est plus rapide qu'accéder à une variable globale, à cause de la façon dont la machine virtuelle Lua résout ces deux types d'accès en interne. Capturer une fonction fréquemment utilisée (comme math.sqrt) dans une variable locale au début d'un fichier, une seule fois, évite de payer ce coût de résolution à chaque appel dans une boucle qui s'exécute souvent.
Le même piège de concaténation qu'ailleurs
Comme dans d'autres langages avec des chaînes immuables, concaténer avec .. en boucle recrée une nouvelle chaîne à chaque itération — un coût qui grandit avec la taille du résultat. table.concat, qui assemble tout en une seule fois à la fin, évite ce problème.
Le vrai enjeu en boucle "chaude" : les allocations invisibles
Créer une nouvelle table à chaque frame (même une petite table temporaire) met une pression constante sur le garbage collector, qui doit ensuite la libérer. Répété soixante fois par seconde pour chaque objet du jeu, ce coût invisible peut provoquer des saccades perceptibles. La solution consiste souvent à calculer directement le résultat sans passer par un objet intermédiaire temporaire.
La règle qui prime sur toutes les autres : mesurer avant d'agir
Comme dans tout domaine de performance, l'intuition humaine sur "ce qui est lent" se trompe souvent. Avant d'appliquer une seule de ces optimisations, il faut d'abord mesurer avec os.clock() ou un profiler dédié pour identifier où le temps est réellement passé — optimiser à l'aveugle gaspille de l'effort sur du code qui n'était peut-être pas le problème.
| Anti-pattern | Coût | Correction |
|---|---|---|
Concaténation .. en boucle | O(n²) | table.concat en une fois |
| Table créée à chaque frame | Pression GC constante | Calcul direct, pas d'objet temporaire |
Accès math.sqrt répété | Résolution globale à chaque appel | Capturer en local une fois |
Piège fréquent
Optimiser "à l'intuition" sans avoir mesuré peut faire perdre du temps sur du code qui n'était pas le vrai goulot d'étranglement, pendant que le vrai problème reste ignoré. Toujours profiler d'abord, même sommairement avec os.clock().
Commandes & code
Performance, profiling et bonnes pratiques
-- Localiser les fonctions globales fréquemment utilisées : l'accès à une locale est plus rapide
-- que l'accès à une globale (résolution différente dans la VM Lua)
local sqrt = math.sqrt -- capture locale, résolue une seule fois
local insert = table.insert
local function distance(x1, y1, x2, y2)
return sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2) -- utilise la locale "sqrt", pas math.sqrt à chaque appel
end-- Éviter la concaténation répétée de chaînes en boucle : O(n²) car chaque ".." crée une nouvelle chaîne
-- MAUVAIS
local function buildStringBad(items)
local result = ""
for _, item in ipairs(items) do
result = result .. item .. "," -- réallocation complète à CHAQUE itération
end
return result
end
-- BON : accumuler dans une table, joindre une seule fois à la fin avec table.concat (O(n))
local function buildStringGood(items)
local parts = {}
for i, item in ipairs(items) do
parts[i] = item
end
return table.concat(parts, ",")
end-- Pré-allouer une table quand la taille finale est connue évite les réallocations internes successives
-- table.create n'existe pas en Lua standard, mais on peut au moins éviter les insert() répétés
local function generateGrid(n)
local grid = {}
for i = 1, n do
grid[i] = 0 -- assignation directe par index, plus rapide que table.insert dans une boucle chaude
end
return grid
end-- Éviter la création d'objets/tables/closures dans les boucles "chaudes" (hot path, exécuté chaque frame)
-- MAUVAIS : crée une nouvelle table à chaque appel, pression constante sur le garbage collector
local function updateBad(entities)
for _, entity in ipairs(entities) do
local delta = { x = entity.vx * 0.016, y = entity.vy * 0.016 } -- allocation inutile à chaque frame
entity.x = entity.x + delta.x
entity.y = entity.y + delta.y
end
end
-- BON : pas d'allocation, calcul direct
local function updateGood(entities)
for _, entity in ipairs(entities) do
entity.x = entity.x + entity.vx * 0.016
entity.y = entity.y + entity.vy * 0.016
end
end-- Mesurer le temps d'exécution avec os.clock() (temps CPU) pour identifier les vrais points chauds
local function benchmark(fn, iterations, ...)
local start = os.clock()
for _ = 1, iterations do
fn(...)
end
local elapsed = os.clock() - start
print(string.format("%d itérations en %.4fs (%.6fs/itération)", iterations, elapsed, elapsed / iterations))
end
benchmark(buildStringGood, 10000, {"a", "b", "c", "d", "e"})-- Garbage collector : ajuster son comportement pour un jeu temps réel (éviter les pauses (stutter) perceptibles)
collectgarbage("collect") -- collecte complète immédiate (ex: à un point de chargement, pas en jeu)
print(collectgarbage("count")) -- mémoire utilisée en Ko, utile pour surveiller les fuites
-- Mode incrémental réglable : ajuster le pas de collecte pour lisser la charge sur plusieurs frames
collectgarbage("incremental", 200, 100, 12)
-- (pause, stepmul, stepsize) : paramètres avancés à ajuster selon le profil du jeu, à mesurer empiriquement-- Éviter les métatables __index de type fonction dans les hot paths : plus lent qu'un __index de type table
-- __index fonction = appel de fonction à CHAQUE accès manquant ; __index table = simple lookup, bien plus rapide
local SlowClass = setmetatable({}, {
__index = function(t, k) return rawget(SlowClass, k) end, -- indirection inutile et coûteuse
})
local FastClass = {}
FastClass.__index = FastClass -- lookup direct dans la table, le pattern standard vu en leçon POO-- Checklist de performance Lua avant optimisation "prématurée"
1. Toujours PROFILER avant d'optimiser (os.clock(), ou un profiler du moteur hôte) - l'intuition trompe souvent
2. Localiser les fonctions globales appelées fréquemment (math.*, string.*, table.*)
3. Éviter les allocations de table/closure dans les boucles exécutées chaque frame
4. Préférer table.concat à la concaténation répétée en boucle
5. Utiliser __index de type table plutôt que fonction quand c'est possible (accès direct plus rapide)Résumé
- Capturer les fonctions globales fréquentes en locales accélère leur résolution dans la boucle chaude.
table.concatremplace avantageusement la concaténation répétée (..) en boucle, qui est quadratique.- Éviter d'allouer tables/closures à chaque frame réduit la pression sur le garbage collector.
- Toujours mesurer (
os.clock(), profiler du moteur) avant d'optimiser : l'intuition sur la lenteur est souvent fausse.
Exercices pratiques
Mission : des saccades inexpliquées à 60 FPS
Objectif : Identifier les sources d'allocations et de coûts cachés dans une boucle de mise à jour exécutée chaque frame, et les corriger sans deviner à l'aveugle.
Contexte
Une boucle de mise à jour de jeu, exécutée 60 fois par seconde pour chaque entité, contient ce code :
local function updateEntities(entities)
local log = ""
for _, entity in ipairs(entities) do
local delta = { x = entity.vx * 0.016, y = entity.vy * 0.016 } -- nouvelle table à CHAQUE entité, CHAQUE frame
entity.x = entity.x + delta.x
entity.y = entity.y + delta.y
log = log .. entity.name .. " deplace; " -- concaténation en boucle
local d = math.sqrt(entity.x ^ 2 + entity.y ^ 2) -- math.sqrt résolu en globale à chaque appel
end
return log
endSur un serveur avec plusieurs centaines d'entités, les joueurs signalent des saccades perceptibles, mais personne n'a encore mesuré où le temps est réellement passé.