Gestión de memoria en C++ en un motor de juego: ¿quién consume el frame?
Llamar a new dentro del frame generó picos de 13 ms en el profiler; después de introducir un arena de frame y un pool de objetos, el pico máximo descendió a 7,9 ms. Así fue como se resolvió.
Un pico de tres milisegundos en el profiler
A finales del invierno pasado estaba perfilando la escena de combate en nuestro propio motor. El tiempo de frame rondaba los 9 ms en promedio, pero cada pocos segundos aparecía un frame de 13 ms. Nada se tartamudeaba visiblemente en pantalla, sin embargo, con un objetivo de 60 FPS esos picos eran notables. Primero miramos el renderizado, porque todos miran allí primero, y el tiempo de renderizado resultó ser casi constante de frame a frame.
El culpable era una única línea dentro de DamageNumberSystem: un new DamageLabel() por cada golpe. Durante una ola con 400 golpes por segundo eso significaba seis o siete asignaciones en el heap por frame. La mayoría tardaba 60 ns, pero de vez en cuando una superaba los 40 µs y arruinaba todo el frame. Un gráfico de promedios nunca muestra esto, porque 399 de esas 400 asignaciones son baratas.
No todos los picos como este provienen de asignaciones, así que el primer trabajo fue separar las causas. Insertamos un contador sobre cada llamada a operator new dentro del frame, y los frames donde ese contador alcanzó su máximo coincidían uno a uno con los frames donde el tiempo de frame se disparó. Después de eso no quedó nada que discutir.
Esto es exactamente por lo que la gestión de memoria en C++ en un motor de juego es un tema propio: el costo promedio no importa, el peor caso sí. Dentro de un presupuesto de 16,6 ms, una única latencia de cola pierde el frame, y el jugador lo siente en sus manos antes de que lo veas en los números.
Por qué new/delete es peligroso dentro de un frame
Un asignador de propósito general no es determinista. malloc recorre listas libres buscando un bloque adecuado, toma un lock y a veces solicita al sistema operativo nuevas páginas. En ese último caso el costo salta de nanosegundos a microsegundos, y por mucho que leas el código no sabes en qué frame ocurrirá. En Windows la mayoría de los picos que medimos coincidieron exactamente con fallos de página.
El segundo problema es la fragmentación. En una sesión larga, miles de asignaciones de corta vida en tamaños mixtos perforan agujeros en el espacio de direcciones, y después de un tiempo una solicitud del mismo tamaño cuesta más que antes. En una prueba de 40 minutos el mismo camino de código terminó tardando el doble que en el primer minuto de juego. En consola es peor, porque no cuentas con la misma holgura de memoria virtual.
Tercero, en un motor multihilo cada sitio de asignación es un punto de sincronización oculto. Cuando cuatro instancias de WorkerThread asignan al mismo tiempo, el lock interno del asignador los serializa. No ves esto como un bloque claro de espera en el profiler; ves pequeños retrasos repartidos por todas partes, por eso se nota tan tarde.
El cuarto punto es la propia medición. El costo de un asignador de propósito general nunca se concentra en un solo lugar, se dispersa en cientos de sitios de llamada, y ninguno parece lo suficientemente grande por sí solo para llamar la atención. Por eso los problemas de gestión de memoria aparecen cuando mides el total, no cuando observas cualquier sistema individual.
Arena de frame: un reinicio por frame
Un asignador de arena, también llamado asignador lineal, es una idea simple: reservar un bloque grande al iniciar, avanzar un cursor en cada asignación y nunca liberar individualmente. Al final del frame vuelves el cursor a cero y listo. Nuestro FrameArena se abre con 8 MB, y una asignación cuesta un paso de alineación más una suma. Lo dimensionamos al doble del uso máximo medido y escribimos el recuento de bytes usados en telemetría al final de cada frame.
La regla es que solo las cosas que no sobreviven al frame van a la arena: la lista de visibilidad, el arreglo temporal de DrawCommand, la lista de pares del broadphase de física, la geometría de texto que la UI construye para ese frame. Ningún puntero que cruce el límite de un frame puede provenir de la arena, o lo sobrescribirás en el siguiente frame, y ese error es silencioso.
Reset() no ejecuta destructores. Por eso solo colocamos tipos trivialmente destructibles en la arena. Cualquier otra cosa necesita una lista de destructores separada, lo que consume parte de la ventaja de velocidad; en la práctica nunca tomamos ese camino. Un static_assert en el sitio de asignación obliga la regla en tiempo de compilación.
Cada hilo de trabajo obtuvo su propia arena. Eso eliminó incluso la última operación atómica del camino de asignación, y la contención oculta entre hilos desapareció por completo. Una arena que nunca se comparte tampoco necesita un lock. Cuatro arenas suman 32 MB, un precio trivial comparado con lo que ganamos en tiempo de frame.
Pool de objetos para objetos de tamaño fijo
El arena es inútil para objetos que sobreviven más de un frame. Los proyectiles, fuentes de audio y emisores de partículas viven varios segundos y mueren en orden arbitrario. Para esos usamos un asignador de pool: un arreglo de bloques de tamaño igual más una lista libre que apunta a los vacíos. Como el tamaño del bloque es fijo, la fragmentación deja de ser un problema por completo.
La parte agradable de un pool de memoria es que la lista libre puede vivir dentro de los propios bloques. Un bloque libre no está en uso, así que escribes la dirección del siguiente bloque libre en sus primeros ocho bytes; sin estructura de datos separada, sin asignación separada. Eso hace que Acquire() y Release() sean de tiempo constante y casi sin ramificaciones.
Nunca entregamos punteros crudos a objetos del pool. Cada proyectil recibe un índice de 32 bits más un contador de generación; cuando el proyectil muere la generación se incrementa y cualquier manejador obsoleto retenido en otro lugar se vuelve inválido por sí mismo. Eso convierte el uso después de liberar de un crash a una falla silenciosa que puedes asertar antes de que cause daño.
Para ProjectilePool elegimos una capacidad de 4096; el uso máximo en la ola más densa que medimos fue de 2 870. Cuando el pool se llena no lo expandimos, reciclamos el proyectil más antiguo. Crecer a mitad de frame derrotaría la razón por la que introdujimos el arena y el pool en primer lugar. Revisamos la capacidad una vez por release, usando el valor máximo que la telemetría reporta.
Localidad de caché, alineación y false sharing
La verdadera ganancia al cambiar asignadores no es el tiempo de asignación, sino que los objetos terminan junto a otros en memoria. Los 2 870 proyectiles del pool son contiguos, el bucle de actualización lee líneas de caché de 64 bytes en orden, y el prefetcher de hardware entra en acción. Cuando dispersamos la misma cantidad de proyectiles con new, la tasa de fallos de caché se triplicó.
Mantener los datos calientes pequeños es la otra mitad. Reducir la estructura Projectile de 96 bytes a 48 hizo que cabieran dos proyectiles por línea de caché y redujo el bucle de actualización de 0.9 ms a 0.6 ms. Mover campos fríos como el puntero de malla y el id de sonido a un arreglo paralelo fue suficiente. Lo único que cambió en esa medición fue la disposición de los campos; las matemáticas siguieron idénticas.
El false sharing, por otro lado, es invisible hasta que lo mides. Cada uno de nuestros cuatro hilos de trabajo incrementaba su propio contador, pero los contadores estaban en la misma línea de caché, de modo que cada escritura invalidaba la línea en los demás núcleos. Separarlos con alignas(64) ganó 1.2 ms en ese sistema, y fue un cambio de una sola línea.
En el lado SIMD la alineación no es opcional. La función Allocate del arena toma un parámetro de alineación; pasamos 16 para un buffer que contiene valores __m128 y 32 en la ruta AVX. Incluso en plataformas donde el acceso desalineado no causa crash, lo medimos ejecutándose notablemente más lento. Como el arena ya devuelve bloques alineados, esa verificación vive en exactamente un lugar.
¿Es std::pmr suficiente, o tu propio asignador?
Resumen de la medición: en la escena de combate el tiempo medio por frame pasó de 9.2 ms a 7.1 ms, pero la diferencia real está en el percentil 99. El pico bajó de 13.4 ms a 7.9 ms y los picos periódicos en el profiler desaparecieron por completo. Repetimos la misma medición en dos máquinas diferentes y la proporción se mantuvo. Todo el trabajo tomó tres semanas y cabe en dos archivos de encabezado.
La mayor parte de esto podrías hacerlo con std::pmr. std::pmr::monotonic_buffer_resource ya es un arena, y unsynchronized_pool_resource ya es un pool. Si trabajas con contenedores estándar, medir primero y luego conectar un recurso de memoria debajo de ellos cubre la mayor parte de lo que necesitas, con casi cero costo de mantenimiento. También ayuda que sea una interfaz estándar que todo el equipo ya reconoce.
Los casos donde debes escribir tu propio asignador personalizado son estrechos: bucles críticos donde ni siquiera quieres una llamada virtual, sistemas donde almacenas índices de 32 bits en lugar de punteros, regiones de memoria especiales en hardware de consola, y tu propia telemetría que informa el presupuesto de frame. Si ninguno de esos cuatro se aplica a ti, quédate con pmr.
Si solo haces una cosa, comienza contando las asignaciones dentro de un frame. Añadir un contador al motor e imprimir cuántas asignaciones hace cada frame es medio hora de trabajo, y el número que sale suele ser uno que nadie en el equipo había adivinado. Cuanto más se acerque ese número a cero, más fácil se vuelve todo lo demás.