Retour au cours

games / lua

Performance, profiling et bonnes pratiques

Leçon 121 exercice

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 __index de 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-patternCoûtCorrection
Concaténation .. en boucleO(n²)table.concat en une fois
Table créée à chaque framePression GC constanteCalcul direct, pas d'objet temporaire
Accès math.sqrt répétéRésolution globale à chaque appelCapturer 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

lua
-- 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
lua
-- É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
lua
-- 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
lua
-- É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
lua
-- 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"})
lua
-- 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
lua
-- É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
text
-- 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.concat remplace 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

1 disponible
1

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 :

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

Sur 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é.

Résoudre l’exercice →