En este segundo post sobre la serie de optimizaciones en juegos en el que vamos a ver la segunda forma de optimizar el proceso de recolección de basura, evitando caídas de rendimiento en nuestros juegos.
Latencia adecuada.
El tiempo que consume el proceso de recolección de basura es directamente proporcional a lo complejo que sea nuestro heap. Si el heap está vacío, el recolector no tendrá nada que hacer.
Fijaos que hemos dicho “lo complejo que sea nuestro heap”. La complejidad del heap es una combinación del número de objetos vivos y el número de referencias a objetos que tengamos. En realidad no importa nada el tamaño en bytes que los objetos del heap tengan: lo que realmente importa es el número total de objetos (ya que el recolector debe examinar cada uno de ellos) y el número de referencias a objetos (el recolector debe seguir cada referencia para ver a qué está apuntando cada una).
Cuando medimos la complejidad del heap, un array de 100.000 enteros es poco costosa. Aunque ocupe mucha memoria, el recolector de basura sólo tiene que examinar el objeto una vez, y no tiene que mirar dentro.
100.000 arrays, cada una con un entero dentro será más costoso, ya que el recolector tiene más objetos que examinar.
Un array de 100.000 referencias a objetos es también más costoso, ya que aunque el array es sólo un objeto, el recolector tiene que recorrer todas las referencias para ver si cada objeto que contenga el array tiene que seguir vivo. Aunque el array sólo contenga nulls, el recolector los tiene que recorrer todos para asegurarse.
Aquí tenéis unos consejos para reducir la complejidad del heap:
- Es mejor algunos objetos grandes antes que muchos objetos pequeños.
- Mejor tener en el heap tipos por valor que referencias.
- Cuantas menos referencias a objetos mejor.
- Los arrays de tipos por valor son tus amigos!
- Considera reemplazar referencias a objetos por manejadores enteros, es decir, en lugar de guardar una referencia la nave que creó la bala, podrías guardar el “fui creado por la nave 23” como un entero directamente.
- Es prefeible un T[] o List<T> antes que un LinkedList<T> o un Dictionary<K,V>
Los discípulos del camino de la latencia no se preocupan de reservar memoria durante el juego. Pueden llamar a news, causar boxings, y usar delegados anónimos y métodos generadores. No les importa que pase el recolector de basura, ya que su heap es tan simple que el proceso termina muy rápido.
¿Cuál de los dos caminos elijo?
Podemos obtener un mejor rendimiento evitando las reservas de memoria, de manera que el recolector nunca pasará. Esto funciona sin importar lo complejo que sea nuestro heap.
También obtendremos un mejor rendimiento manteniendo nuestro heap simple, el recolector tardará muy rápidamente. Esto funciona aunque reservemos memoria durante el juego.
¡¡Lo que no podemos hacer es mejorar el rendimiento mezclando ambas soluciones!! Se consigue muy poco reservando memoria sólo para la mitad de memoria necesaria y tener un heap de complejidad media. Eso producirá un juego con un tamaño medio cada pocos segundos.
Si tu objetivo es evitar la recolección de basura, debes elegir sólo uno de los caminos y seguirlos hasta el final.
Reinicia tu cabeza
Los programadores nuevos en el mundo de evitar la recolección de basura piensan que pueden mejorar el rendimiento llamando a GC.Collect en momentos específicos.
Casi siempre se equivocan. Forzar la recolección de basura es una receta para confundir al recolector y dañar su rendimiento.
Pero…
En Xbox, el .NET Compact Framework realiza una recolección de basura cada vez que se ha reservado un megabyte.
Supongamos que estamos optimizando nuestro juego para evitar reservar de memoria. Después de un estudio cuidadoso con el CLR Profiler hemos conseguido reducir a 50 bytes por frame las reservas de memoria que hacemos, pero no conseguimos reducirlo más, no hay manera.
Además, digamos que nuestro juego se ejecuta a 60 frames por segundo, y que un nivel típico tarda 2 minutos en completarse. Al final del nivel tendremos reservados sólo 352k, no es suficiente para que el recolector se ejecute. En realidad, nuestro juego puede estar hasta 5 minutos sin tener que recolectar nada, el único momento en el que el jugador notará que está pasando el recolector de basura es si él mismo se dedica a recorrer el universo “perdiendo el tiempo”.
Suena razonable no? Seguramente podríamos vivir con ello.
Pero…
Estaremos reservando mucha memoria mientras se carga el nivel, y esto causará muchas recolecciones. En realidad no es un problema: no está mal que el recolector pase en este momento, ya que a la pantalla de carga no le importa el framerate.
Pero ¿qué pasa si durante la carga se reserva algo menos de un número de megabytes, por ejemplo 24,452k? Después de la última recolección en el mega 23, esta operación de carga reserva muy poco como para lanzar otra recolección, pero lo suficiente como para dejarnos sin espacio. Ahora nuestro juego sólo puede reservar 124k antes de lanzar otra recolección, así que el jugador notará esto sólo cuando lleve 42 segundos en el nivel.
La solución es llamar a GC.Collect al final de cada método de carga. Esto limpiará cualquier recolección que hiciese falta, reseteando el reloj para que tarde más en pasar el recolector.
Juan María Laó Ramos.