miércoles, 28 de septiembre de 2016

Games aside #0: Ensamblador en Game Boy, ¿es necesario?


Izquierda: prototipo presentado aquí; derecha: nuevo prototipo en ensamblador.

Recientemente he terminado de portar todo el código del prototipo de Last Crown Warriors mostrado en este blog a ensamblador. Sentía que el programa original, realizado con una combinación de C y ASM, no garantizaba un rendimiento óptimo, y que no sería la mejor base sobre la que fundamentar el resto del programa: tarde o temprano iba a terminar recurriendo a las virtudes de la programación de bajo nivel, y las herramientas que estaba usando, a pesar de ofrecer la posibilidad de añadir rutinas en ensamblador al programa, no presentaban las mismas ventajas que ofrece un programa realizado íntegramente en lenguaje ensamblador.

Por eso, aprovechando la ocasión de tener dos programas de resultado casi idéntico (uno en ASM, otro en C+ASM) para Game Boy, me he decidido a realizar una sencilla comparativa de rendimiento en ambos.

No es mi objetivo hacer un análisis en profundidad y concienzudo, ni mucho menos, pero sí sacar a la luz cuál es el resultado real que se obtiene cuando se desarrolla haciendo uso de las principales herramientas de las que la escena de Game Boy actualmente dispone.

C+ASM versus ASM

Antes de comenzar, creo conveniente aclarar por qué en este caso no se encuentra representada la opción de sólo C. Sencillamente, la desestimé. El deseo de tener un gran número de enemigos en pantalla con un rendimiento óptimo se contradecía con mi experiencia previa en desarrollos basados únicamente en C y orientados al sistema de marras.

Para empezar, toca concretar qué herramientas se han usado para la elaboración de la ROM en cada uno de los casos: Para el programa de C y ensamblador usé GBDK (Game Boy Development Kit), una versión modificada del SDCC (Small Device C Compiler) orientada a la Game Boy con librerías dedicadas y ejemplos. Decir que lleva sin actualizarse desde mediados de 2002.

En este programa el código en C, si bien no busca la optimización más absoluta, si se encuentra realizado siguiendo las directrices recomendadas por la herramienta. Se delegan además la operaciones matemáticas complejas y las funciones grandes recurrentes o relativamente simples a rutinas en ASM. Concretamente en este lenguaje de bajo nivel se cumplen los siguientes procesos:
  • actualización y posprocesado de sprites;
  • inteligencia artificial de los enemigos;
  • detección de entorno y procesado de hierba;
  • multiplicaciones y divisiones con resto;
  • generación de números aleatorios;
  • actualización gráfica durante el VBLANK.
Por lo tanto queda en manos de C el control del héroe, su sistema de colisión avanzada, la actualización de la interfaz de usuario, la animación del fondo, y el control general del programa.

Cabe destacar que el rendimiento de las rutinas mencionadas en el programa basado en C es menor que el de sus homónimas en el programa completamente en ensamblador. La razón es simple, en bajo nivel cuando queremos acceder a un dato lo hacemos directamente a través de su dirección, en C la memoria se maneja de manera dinámica y por lo tanto no es viable acceder a un dato de esta forma. A menos, claro está, que también decidamos controlar las direcciones de cada una de las variables, perdiendo así una de las ventajas que C nos ofrece. En el caso del prototipo se pasan todos los parámetros en forma de variables a través de una pila, apilando todos los datos de referencia que necesitamos primero para luego desapilarlos en la rutina de ASM antes de gestionarlos. Este proceso adicional perjudica ligeramente a la rapidez de dichas rutinas.

Resultado, hasta cinco enemigos moviéndose simultáneamente en pantalla sin ralentizaciones de ningún tipo. La barra verde a la derecha de la imágen indica la carga del procesador, si el verde claro sobrepasase el alto de la barra significaría que existe una carga de trabajo por frame mayor a la que el procesador puede hacer frente y surgirían ralentizaciones.


Programa en C+ASM.

Y aquí tenemos ese mismo resultado con el programa elaborado enteramente en ASM con RGBDS (Rednex Game Boy Development System), un ensamblador que hoy día sigue recibiendo actualizaciones. En este programa se ha realizado una transición de la lógica original a este lenguaje, priorizando en todo momento la optimización de recursos y procesamiento.

 Programa en ASM puro.

Como se puede observar la carga del procesador es notablemente menor. Y aquí tenemos el programa de nuevo esta vez con nueve enemigos en movimiento, algo inviable en el prototipo anterior, llegando así a cubrir el número máximo de sprites en pantalla a nivel de hardware (si contamos los sprites de hierba asociados a cada personaje).

 Programa en ASM puro, con número de enemigos máximizado.

Así que, ensamblador en Game Boy, ¿es necesario? Creo que depende del proyecto que se quiera desarrollar. Para Last Crown Warriors, un juego que pretende mostrar de manera simultánea el mayor número posible de personajes distintos en movimiento, definitivamente sí.

8 comentarios:

  1. Muy interesante!!

    Supongo que teniendo un buen control de ensamblador ya deja de ser una ventaja el usar un lenguaje como C para programar.

    ResponderEliminar
    Respuestas
    1. El mayor problema de hacer uso exclusivo de ensamblador a la hora de programar es la inversión de tiempo que requiere. El código que se realiza es más difícil de adaptar a otros proyectos y cualquier refactorización lleva mucho más tiempo.
      Eso sí, en rendimiento y control no tiene rival como es lógico. Para sistemas limitados creo que es lo más recomendable.

      Eliminar
    2. Cierto, pero quien te quita lo ensamblado :)

      Eliminar
  2. Muchas gracias por compartir!
    La verdad ds que las pruebas son bastante concluyentes si se quiere exprimir un hardware tan limitado como el de la pobre gameboy

    ResponderEliminar
  3. Enhorabuena por el proyecto y por la interesante entrada.

    Vaya por delante que nunca he programado nada para Gameboy, pero aún así me gustaría hacer una pequeña corrección a la entrada. Comentas que en ASM puedes acceder directamente a las variables, mientras que en C tienes que hacerlo a través de la pila. Esto no es del todo cierto. En C tienes que acceder a través de la pila si las variables son pasadas como parámetros de funciones. Si declaras las variables como globales en lugar de pasarlas como parámetros, el acceso es directo (igual que en ASM). Del mismo modo, en determinadas circunstancias, las variables se pasan a través de registros, sin ocupar la pila.

    El problema de declarar las variables globales es que si bien ganas en velocidad por un lado, por otro lado el programa se hace más difícil de seguir y se tiende a gastar más RAM. Pero esto ocurre igual en ASM (que también puedes pasar parámetros a las subrutinas a través de la pila si quieres).

    Los problemas de rendimiento en C probablemente se deban también al SDCC, que yo no lo he usado nunca, pero constantemente leo que es un compilador bastante regulero...

    ResponderEliminar
    Respuestas
    1. Muchas gracias doragasu por las aclaraciones, lo último que quiero con estos artículos es inducir a error.

      Creo que por la forma en la que está escrito puede parecer que estoy diciendo que es imposible acceder a datos en la rutina de ASM que no hayan sido pasados como parámetro con la pila (hablo lógicamente en el caso del programa de C), voy a modificar ligeramente es parte.

      Decir también que se me pasó por la cabeza declarar algunas variables como globales, especialmente en los casos en los que necesitaba una gran cantidad de parámetros, pero precisamente quería evitar ese tipo de declaración al usar C como base en lugar de ensamblador. Lo de poder pasar parámetros a través de registros del hardware en C no lo conocía, sería interesante ver cómo está implementado en el SDCC adaptado de GBDK.

      Y, como apuntas, estoy seguro de que gran parte de los problemas de rendimiento se deben al compilador, teniendo en cuenta que en 2002 el SDCC aún no tenía soporte nativo de Game Boy.

      Hay un proyecto en marcha para actualizar el GBDK a la última versión del SDCC (ya con soporte nativo), aunque aún tiene algún problema con la gestión de bancos de memoria: https://github.com/rotmoset/gbdk-n

      Como he dicho, gracias por perfilar la información del artículo y por los ánimos. Aunque soy el primero que aún está aprendiendo, espero poder aportar algo de mi experiencia con Game Boy a través de estos artículos.

      Eliminar
  4. Lo de pasar parámetros por los registros depende de la arquitectura y del propio compilador (es lo que llaman "calling convention"). Por lo general el valor de retorno de la función se deja en un registro, y los parámetros de entrada, dependiendo del número de registros de la máquina, y del número de parámetros y su tamaño, cuando se puede se pasan por registro, y si no por la pila. En el caso del Z80 y SDCC no se cómo será, tendrías que buscar el "calling convention" de este compilador.

    Supongo que no te estaré diciendo nada que no conozcas ya, pero cuando tienes que pasar muchos parámetros a una función, lo recomendable a efectos de rendimiento, suele ser empaquetarlos en una estructura y pasar un puntero a la misma.

    Por otro lado, aunque generalmente se desaconseja usar en C variables globales salvo cuando sea estrictamente necesario (p.e. en arrays muy grandes que puedan desbordar la pila), en sistemas empotrados con recursos limitados no hay nada malo en hacer una o varias estructuras con las variables que más se usan, y mantenerlas globales. Yo así lo hago en bastantes proyectos (si bien no son juegos).

    Hay un viejo mantra que escribió Mike Abrash en su libro "Zen of Assembly Language", que es bastante aceptado: "El 10% del código se ejecuta el 90% del tiempo". Eso viene a decir que generalmente es más provechoso identificar ese 10% y optimizarlo a saco, que escribir todo en ASM. Aunque en arquitecturas tan limitadas como un Z80 y compiladores regulares como SDCC, tal vez sí que merezca la pena dar el salto a 100% de ASM... tendría que hacer algo para poder pronunciarme ;-)

    ¡Ánimo y a seguir con tus proyectos, que hay ganas de jugarlos!

    ResponderEliminar
    Respuestas
    1. En principio sólo uso una estructura, para los personajes. En el resto de datos, creo que no se da el caso de que haya varios de ellos que se usen de forma conjunta recurrentemente. Revisando el código te puedo decir que los parámetros de la rutina que más datos requiere ocupan catorce bytes (la de la IA de los enemigos).

      El famoso mantra que comentas lo conocía, aunque no sabía su origen (tendré que echar un ojo a ese libro, gracias por la información). De hecho, lo tuve en la cabeza cuando empecé a meter rutinas de ASM en el prototipo en C. Aunque acabo siendo bastante más que el 10% en el prototipo de C+ASM.

      Vaya por delante que C es un lenguaje que me encanta, considero que se pueden abordar un montón de proyectos distintos en Game Boy con él. Y estoy seguro de que conociendo a fondo el compilador se pueden hacer cosas interesantes también. Pero también creo, que en mayor o menor medida, el ensamblador se hace imprescindible para los proyectos medianamente ambiciosos a nivel técnico en este sistema.

      ¡Gracias por los ánimos! Programar 100% ASM lleva un poco más de tiempo, pero me gusta y creo que merecerá la pena tanto en Rocket Man como en Last Crown Warriors.

      Eliminar