Gestion de la mémoire C++ dans un moteur de jeu : qui mange la frame ?
Appeler new pendant la frame produisait des pics de 13 ms dans le profiler ; après un arena de frame et du pooling d'objets le pic est tombé à 7,9 ms. Voici comment cela s'est passé.
Un pic de trois millisecondes dans le profiler
Fin de l’hiver dernier, je profilais la scène de combat dans notre propre moteur. Le temps de frame était d’environ 9 ms en moyenne, mais toutes les quelques secondes, une frame de 13 ms apparaissait. Rien ne saccadait visiblement à l’écran, pourtant à 60 FPS ces pics étaient perceptibles. Nous avons d’abord regardé le rendu, car tout le monde y regarde en premier, et le temps de rendu s’est avéré presque constant d’une frame à l’autre.
Le coupable était une seule ligne dans DamageNumberSystem : un new DamageLabel() pour chaque coup. Lors d’une vague avec 400 coups par seconde, cela signifiait six ou sept allocations sur le tas par frame. La plupart ne prenaient que 60 ns, mais de temps en temps une dépassait 40 µs et ruinait toute la frame. Un graphe des moyennes ne montre jamais cela, car 399 de ces 400 allocations sont bon marché.
Tous les pics de ce type ne proviennent pas d’une allocation, donc la première tâche a été de séparer les causes. Nous avons ajouté un compteur sur chaque appel à operator new dans la frame, et les frames où ce compteur culminait correspondaient exactement aux frames où le temps de frame culminait. Après cela, il n’y avait plus rien à discuter.
C’est exactement pourquoi la gestion de la mémoire C++ dans un moteur de jeu est un sujet à part : le coût moyen n’a pas d’importance, le pire cas oui. Dans un budget de 16,6 ms, une seule latence de queue manque la frame, et le joueur le ressent avant que vous ne le voyiez dans les chiffres.
Pourquoi new/delete est dangereux à l'intérieur d'une frame
Un allocateur généraliste n’est pas déterministe. malloc parcourt les listes libres à la recherche d’un bloc adapté, prend un verrou, et parfois demande de nouvelles pages au système d’exploitation. Dans ce dernier cas, le coût passe de nanosecondes à microsecondes, et aucune lecture du code ne vous indique dans quelle frame cela se produira. Sous Windows, la plupart des pics que nous avons mesurés coïncidaient exactement avec des défauts de page.
Le deuxième problème est la fragmentation. Sur une longue session, des milliers d’allocations de courte durée de tailles variées perforent l’espace d’adresses, et après un moment une requête de même taille coûte plus cher qu’auparavant. Lors d’un test de 40 minutes, le même chemin de code a fini par prendre deux fois plus de temps qu’au cours de la première minute de jeu. Sur console c’est pire, car vous n’avez pas le même espace de mémoire virtuelle disponible.
Troisièmement, dans un moteur multithread chaque site d’allocation est un point de synchronisation caché. Quand quatre instances de WorkerThread allouent en même temps, le verrou interne de l’allocateur les sérialise. Vous ne voyez pas cela comme un bloc de blocage clair dans le profiler ; vous voyez de petites latences disséminées partout, ce qui explique pourquoi on ne le remarque que tard.
Le quatrième point est la mesure elle‑même. Le coût d’un allocateur généraliste ne se regroupe jamais en un seul endroit, il se répartit sur des centaines de sites d’appel, et aucun d’eux ne semble assez important pris isolément pour attirer l’attention. C’est pourquoi les problèmes de gestion de mémoire apparaissent quand on mesure le total, pas quand on observe un système isolé.
Arena de frame : une réinitialisation par frame
Un allocateur d’arène, aussi appelé allocateur linéaire, repose sur une idée simple : réserver un gros bloc au démarrage, avancer un curseur à chaque allocation, et ne jamais libérer individuellement. À la fin de la frame, on remet le curseur à zéro et c’est fini. Notre FrameArena s’ouvre avec 8 Mo, et une allocation coûte un pas d’alignement plus une addition. Nous l’avons dimensionné à deux fois l’utilisation maximale mesurée et nous écrivons le nombre d’octets utilisés dans la télémétrie à la fin de chaque frame.
La règle est que seules les choses qui ne survivent pas au-delà de la frame vont dans l’arène : la liste de visibilité, le tableau temporaire de DrawCommand, la liste de paires du broadphase physique, la géométrie texte que l’UI construit pour cette frame. Aucun pointeur traversant la frontière d’une frame ne doit provenir de l’arène, sinon il sera écrasé la frame suivante, et ce bug reste silencieux.
Reset() n’exécute pas les destructeurs. C’est pourquoi nous ne mettons que des types trivially destructibles dans l’arène. Tout autre type nécessite une liste de destructeurs séparée, ce qui récupère une partie de l’avantage de vitesse ; en pratique nous n’avons jamais emprunté cette voie. Un static_assert au site d’allocation impose la règle à la compilation.
Chaque thread de travail a reçu sa propre arène. Cela a éliminé même la dernière opération atomique du chemin d’allocation, et la contention cachée entre threads a complètement disparu. Une arène qui n’est jamais partagée n’a pas besoin de verrou non plus. Quatre arènes totalisent 32 Mo, un prix négligeable comparé à ce que nous avons gagné en temps de frame.
Pool d'objets pour des objets de taille fixe
L'arène est inutile pour les objets qui survivent au-delà d'une frame. Les projectiles, sources audio et émetteurs de particules vivent plusieurs secondes et meurent dans un ordre arbitraire. Pour ceux‑ci nous utilisons un allocateur de pool : un tableau de blocs de même taille plus une liste libre pointant vers les blocs vides. Comme la taille du bloc est fixe, la fragmentation ne pose plus de problème.
L'avantage d'un pool mémoire est que la liste libre peut résider à l'intérieur même des blocs. Un bloc libre n'est pas utilisé, donc vous écrivez l'adresse du prochain bloc libre dans ses huit premiers octets ; aucune structure de données séparée, aucune allocation séparée. Cela rend Acquire() et Release() en temps constant et presque sans branche.
Nous ne remettons jamais de pointeurs bruts aux objets du pool. Chaque projectile reçoit un indice de 32 bits plus un compteur de génération ; quand le projectile meurt la génération s'incrémente et tout handle périmé détenu ailleurs devient invalide automatiquement. Cela transforme l'utilisation après libération d'un plantage en une défaillance silencieuse que vous pouvez vérifier avec une assertion avant qu'elle ne cause des dégâts.
Pour ProjectilePool nous avons choisi une capacité de 4096 ; l'utilisation maximale lors de la vague la plus dense que nous avons mesurée était de 2 870. Lorsque le pool se remplit nous ne l'agrandissons pas, nous recyclons le projectile le plus ancien. Faire croître le pool en cours de frame annulerait la raison même d'avoir introduit l'arène et le pool. Nous réévaluons la capacité une fois par version, en utilisant la valeur maximale que les télémetries rapportent.
Localité du cache, alignement et faux partage
Le vrai gain en changeant d'allocateur n'est pas le temps d'allocation, c'est que les objets se retrouvent côte à côte en mémoire. Les 2 870 projectiles du pool sont contigus, la boucle de mise à jour lit les lignes de cache de 64 octets dans l'ordre, et le préfetch matériel s'active. Quand nous avions dispersé le même nombre de projectiles avec new, le taux de défauts de cache a triplé.
Garder les données chaudes petites constitue l'autre moitié. Réduire la structure Projectile de 96 octets à 48 a permis de placer deux projectiles par ligne de cache et a fait passer la boucle de mise à jour de 0,9 ms à 0,6 ms. Déplacer les champs froids comme le pointeur de maillage et l'identifiant du son dans un tableau parallèle suffisait. La seule chose qui a changé dans cette mesure était la disposition des champs ; les calculs sont restés identiques.
Le faux partage, en revanche, reste invisible tant qu'on ne le mesure pas. Chacun de nos quatre threads de travail incrémentait son propre compteur, mais les compteurs résidaient sur la même ligne de cache, si bien que chaque écriture invalidait la ligne sur les autres cœurs. Les séparer avec alignas(64) a gagné 1,2 ms sur ce système, et cela n’a nécessité qu’une ligne de code.
Du côté SIMD, l'alignement n'est pas optionnel. La fonction Allocate de l'arène prend un paramètre d'alignement ; nous passons 16 pour un tampon contenant des valeurs __m128 et 32 sur le chemin AVX. Même sur les plateformes où l'accès non aligné ne plante pas, nous avons mesuré qu'il était sensiblement plus lent. Puisque l'arène renvoie déjà des blocs alignés, ce contrôle ne vit qu'à un seul endroit.
std::pmr suffit‑il, ou faut‑il votre propre allocateur ?
Résumé des mesures : dans la scène de combat le temps moyen d'une frame est passé de 9,2 ms à 7,1 ms, mais la vraie différence se voit au 99ᵉ percentile. Le pic est passé de 13,4 ms à 7,9 ms et les pics périodiques dans le profileur ont complètement disparu. Nous avons répété la même mesure sur deux machines différentes et le ratio est resté stable. L’ensemble du travail a duré trois semaines et tient dans deux fichiers d’en‑tête.
La plupart de cela aurait pu être fait avec std::pmr. std::pmr::monotonic_buffer_resource est déjà une arène, et unsynchronized_pool_resource est déjà un pool. Si vous travaillez avec les conteneurs standards, mesurer d'abord puis brancher une ressource mémoire sous‑jacente couvre la plupart de vos besoins, à coût de maintenance quasi nul. Cela aide aussi le fait que c’est une interface standard reconnue par toute l’équipe.
Les cas où vous devez écrire votre propre allocateur personnalisé sont rares : boucles critiques où vous ne voulez même pas d’appel virtuel, systèmes où vous stockez des indices de 32 bits au lieu de pointeurs, régions mémoire spéciales sur le matériel console, et votre propre télémetrie rapportant le budget de la frame. Si aucun de ces quatre cas ne s’applique, restez avec pmr.
Si vous ne faites qu’une chose, commencez par compter les allocations à l’intérieur d’une frame. Ajouter un compteur au moteur et afficher le nombre d’allocations par frame représente une demi‑heure de travail, et le chiffre qui en ressort est généralement celui que personne dans l’équipe n’avait anticipé. Plus ce nombre se rapproche de zéro, plus tout le reste devient facile.