1 puntos por GN⁺ 2024-09-14 | 1 comentarios | Compartir por WhatsApp
  • Para ejecutar el motor de juegos Trial en Nintendo Switch, se estuvo portando durante 2 años el runtime de Common Lisp SBCL, y ya se llegó al punto de compilar y ejecutar código Lisp en la Switch e incluso enlazarlo con bibliotecas compartidas
  • La Switch tiene un entorno de ARM64 Cortex-A57 de 4 núcleos y 4 GB de RAM, pero no tiene shell, línea de comandos ni compilador, y además impide crear páginas ejecutables, así que no encaja con el método normal de bootstrap de SBCL
  • La compilación combina un host Linux ARM64 con el SDK de Nintendo, y usa fasteval, immobile-code y elfination para dividir el core en código y datos antes de empaquetarlo en el paquete final
  • El ejemplo de REPL de Trial ya funciona en el devkit de Switch, incluyendo contexto OpenGL, manejo de entrada y asignación de shaders, pero todavía se cae al ejecutar el recolector de basura y el audio sigue sin funcionar por problemas con los callbacks en C
  • Las tareas clave que faltan son estabilizar el safepoint para GC multihilo, restaurar los callbacks en C, evitar la compilación en tiempo de ejecución de CLOS, optimizar el rendimiento y, por el NDA de Nintendo, parte del código no puede publicarse

Hasta dónde funciona SBCL en Switch

  • Durante los últimos 2 años, junto con Charles Zhang, se avanzó en el port de SBCL para poder ejecutar el motor de juegos Trial en Nintendo Switch
  • El reto central fue adaptar a la plataforma Switch el runtime de Common Lisp que corre por debajo de Trial
  • Lo que ya es posible:
    • Se portaron el runtime y el compilador, así que en la Switch ya se puede compilar y ejecutar directamente código Lisp arbitrario
    • Se puede interactuar con bibliotecas compartidas
    • También se portaron varias bibliotecas de portabilidad del sistema operativo que Trial necesita en Switch
  • El ejemplo de REPL de Trial ya corre en el devkit de Switch
    • Como la Switch en sí no tiene terminal, Trial crea un contexto OpenGL, administra la entrada, asigna shaders y muestra texto en pantalla
  • Lo que aún bloquea:
    • En cuanto SBCL intenta ejecutar el recolector de basura, se cae casi de inmediato
    • Siguen quedando partes que no se pudieron sortear por las restricciones propias de Switch
    • El mecanismo de callbacks en C está roto, por lo que no es posible la salida de audio
    • Todavía puede haber más problemas de rendimiento que no se han revelado

Costos y sostenibilidad

  • El trabajo de porting ha costado hasta ahora alrededor de 17,000 dólares, y se le ha estado pagando a Charles Zhang de forma mensual
  • Los ingresos vienen de las ventas de Kandria y del apoyo en Patreon, GitHub Sponsors y Ko-Fi
    • En un buen mes, unos 1,200 dólares
    • En un mal mes, unos 600 dólares
  • En Zürich, Suiza, es difícil vivir solo con esos ingresos, y por ahora la situación es seguir aguantando viviendo con sus padres y reduciendo gastos personales
  • Como se duda que las ventas de Kandria para Switch alcancen para recuperar el costo del port, se vuelve difícil decidir en qué usar los recursos limitados
  • Por el momento, los ingresos por apoyo se seguirán usando para el port de SBCL a Switch y para un proyecto de juego no anunciado que está en desarrollo

Entorno de Switch y condiciones del port

  • Lo que se conoce públicamente del entorno de Switch:
    • El código de usuario corre sobre un chip ARM64 Cortex-A57
    • Usa 4 núcleos y 4 GB de RAM
    • Funciona sobre un sistema operativo de microkernel propietario desarrollado originalmente para Nintendo 3DS
  • SBCL ya tiene un port para ARM64 Linux, así que desde el lado de la generación de código esa parte ya estaba resuelta
  • Kandria entra sin problema en 4 GB de RAM, así que la capacidad de memoria en sí no es el problema
  • La dificultad real está en interactuar con el sistema operativo propietario de Switch
    • Tiene restricciones que no existen en un sistema operativo normal de PC
    • Eso resulta especialmente problemático para un sistema como Lisp
  • Switch es la única consola que soporta el renderizado con OpenGL sobre el que se basa Trial
    • Xbox solo soporta DirectX, aunque se sabe que existe una capa OpenGL → DirectX desarrollada por Microsoft, así que podría haber alguna posibilidad
    • Playstation se conoce por tener una API gráfica totalmente propietaria, así que sigue siendo una plataforma que no interesa considerar para este port
  • Para desarrollar, se obtuvo acceso a través de Nintendo of Europe y se compró un devkit de unos 400 dólares
  • El devkit y el SDK solo funcionan en Windows, lo que luego añade carga al proceso de build

El build normal de SBCL y su choque con Switch

  • SBCL está escrito principalmente en Lisp, e incluye también un pequeño runtime en C
  • Ese runtime en C se compila con un compilador C normal, pero necesita conocer información del entorno del sistema operativo de destino
  • Como el runtime por sí mismo no incluye compilador Lisp, para hacer bootstrap de SBCL se necesita otra implementación de Lisp, idealmente otra versión de SBCL
  • Build de 5 pasos en PC

    • build-config
      • Reúne las opciones de configuración de build para el destino y las saca en un formato que luego leen las demás etapas
    • make-host-1
      • Construye un compilador cruzado usando el compilador Lisp del host
      • También genera archivos header que describen como estructuras C la disposición en memoria de los objetos Lisp
    • make-target-1
      • Genera el runtime en C usando el compilador C del destino
      • El runtime en C incluye el recolector de basura y el código pegamento con el entorno del sistema operativo
      • También genera constantes desde los headers del sistema operativo que deben conocer el compilador Lisp y el runtime del destino
    • make-host-2
      • Con el compilador cruzado Lisp creado en make-host-1, construye el sistema Lisp del destino, o sea el compilador y la biblioteca estándar
      • Genera un cold core al que el runtime puede entrar
    • make-target-2
      • Carga el cold core en el runtime del destino y completa el bootstrap
      • Una vez cargado el sistema Lisp en memoria, se hace dump como warm core
      • A partir de ahí ya se puede cargar código nuevo y hacer dump libremente de nuevas imágenes
  • En qué falla esto en Switch

    • El build de SBCL necesita una etapa en la que se ejecuta código Lisp sobre la máquina de destino
    • El código Lisp del usuario no puede tratarse como una simple compilación batch como en C, porque al ejecutarse asume que está en el entorno de destino
    • El despliegue de aplicaciones también se parece a make-target-2: se va compilando código Lisp de forma incremental y luego se hace dump de un core con el runtime adjunto
    • Al arrancar, el runtime de SBCL mapea en memoria el blob del core, marca las páginas de código como ejecutables y salta a una función de entrada personalizada
    • En Switch, todo ese esquema resulta problemático

Estrategia de build para Switch

  • Switch no es un entorno de PC y no tiene shell, línea de comandos ni suite de compiladores
  • Como el sistema operativo no permite crear páginas ejecutables, incluso si se pudieran correr etapas de build en la propia Switch, la compilación incremental típica de Lisp seguiría siendo imposible
  • La mayor parte del código es independiente de la plataforma, así que puede compilarse para ARM64
  • La idea es hacer que solo las partes que tocan el entorno periférico del sistema operativo sepan que el destino es Switch, y construir el resto en otro entorno ARM64 como Linux
  • Etapas de build para Switch

    • build-config
      • Corre en el sistema host y usa flags especiales que representan un build para Switch
      • Activa el contrib fasteval
      • Como en Switch no se puede compilar en runtime, fasteval sustituye los puntos donde haría falta invocar el compilador
    • make-host-1
      • No cambia demasiado y genera headers para preparar la plataforma Switch
    • make-target-1
      • Hace cross-compile del runtime en C para Switch usando el compilador C provisto por el SDK de Nintendo
      • Como el OS de Switch no es compatible con POSIX, se crea un target de runtime personalizado en SBCL y se dejan con stubs o se envuelven diferencias del sistema operativo como el dynamic linking y el mapeo de páginas
    • Segundo build-config, make-host-1, make-target-1
      • Se crea un sistema ARM64 Linux normal con un conjunto de capacidades equivalente al de Switch
      • Algunos procesos Lisp usan flags especiales para saber que el destino final es Switch
    • make-host-2, make-target-2
      • Se obtiene un build algo especial de SBCL para Linux ARM64, y a partir de ahí se compila el código de usuario
    • Compilación del código de usuario
      • Se modifica *features* para hacerle creer que corre en Switch y no en Linux
      • Incluye :nx y excluye :linux, :unix, :posix
      • Luego se desactiva ASDF y se compilan programas como Trial de una forma cercana a la habitual antes de hacer dump de un nuevo core

Empaquetar el core con immobile-code y elfination

  • Como en Switch no se puede usar la estrategia normal de mapeo del core, no funciona adjuntar un core nuevo al runtime para Switch
  • La solución fue usar dos funciones relativamente poco conocidas de SBCL: immobile-code y elfination
  • En SBCL normal, el código compilado en runtime se coloca en alguna página y esa página se marca como ejecutable
    • Más adelante, si ese código ya no hace falta, puede ser recolectado por el GC
    • Así se recupera espacio y se compacta el resto del código
  • immobile-code usa otra estrategia
    • Coloca el código en páginas de código reservadas especialmente y lo deja allí
    • Ese código no puede ser recolectado por el GC
    • A cambio, se puede aprovechar el soporte tradicional de ejecutables del sistema operativo
    • Los ejecutables tienen secciones marcadas de antemano que el sistema operativo ya reconoce como código y mapea al iniciar el programa
  • La etapa de elfination convierte el core en secciones separadas de código y datos aptas para un ejecutable normal
    • El elfinator analiza el core
    • Reescribe el ensamblado para que se vuelva código independiente de posición, necesario para la aleatorización del espacio de direcciones
    • Lo separa en un archivo ensamblador de código puro y un payload de datos puros
  • Etapa final:
    • Generar el archivo ensamblador con el elfinator
    • Enlazar el binario final
    • Usar las herramientas de authoring del SDK de Nintendo para empaquetar metadata, bibliotecas compartidas, assets y el binario de la aplicación en un paquete final único

Infraestructura de build y hasta dónde se puede publicar

  • El build requiere al menos una máquina Linux ARM64 que haga la mayor parte del proceso
  • Para ejecutar el compilador del SDK de Nintendo y las herramientas de authoring, hace falta una máquina Windows AMD64 o una máquina Linux AMD64 usando Wine
  • En la práctica, la configuración se parece a casi tres máquinas
    • Un “driver” AMD64
    • Un host de build ARM64
    • Una VM Windows que se comunica con el devkit
  • Se automatizó todo escribiendo un sistema de build especial con caché y lógica de sincronización entre máquinas
    • Además tenía que poder correr también en entornos MSYS2/Windows, lo que trajo problemas de conversión de rutas
  • También se portaron el elfinator y immobile-code a ARM64, junto con bibliotecas de soporte como pathname-utils, libmixed y cl-gamepad
  • Por el NDA de Nintendo, muchos detalles no se pueden publicar
  • Todo lo que se puede publicar se ha enviado upstream, y las bibliotecas Lisp no mantienen forks privados
  • Para evitar enlazar directamente con el SDK de Nintendo, se creó una biblioteca C separada, y las bibliotecas Lisp acceden a las funciones del sistema operativo a través de esa interfaz personalizada
    • Así se puede dejar pública la parte Lisp y mantener privada solo la pequeña biblioteca en C

Punteros absolutos y reubicación al cargar

  • Elfination no fue diseñada desde el inicio para crear código Lisp ejecutable independiente de posición
  • El código Lisp suele incluir muchos punteros absolutos
  • Hizo falta trabajo en el compilador y en el runtime de SBCL para soportar la reubicación de punteros absolutos al momento de carga
  • Los objetos de código suelen incluir constantes de código, y había que lograr que dejaran de contener punteros absolutos
    • El GC no puede modificar secciones ejecutables
    • El cargador del OS tampoco puede modificar secciones ejecutables para reubicar punteros absolutos
  • La solución fue:
    • Mover punteros absolutos como las constantes de código a un espacio de lectura/escritura fuera del área de texto
    • Reescribir las referencias a constantes dentro del código para que carguen desde ese espacio de lectura/escritura
    • Así el cargador y el GC móvil pueden modificar los punteros en ese espacio

Problemas con el recolector de basura y los safepoints

  • El GC estándar de SBCL es gencgc, es decir, un recolector de basura generacional
  • gencgc separa generaciones de objetos para escanearlas con distinta frecuencia, y copia objetos entre generaciones para compactar espacio
  • Esa estructura en sí no es un problema esencial en Switch, pero el multithreading sí lo es
  • Si hay varios threads, uno de ellos podría estar accediendo a un objeto, así que no se lo puede mover libremente
  • La solución más simple es detener todos los threads antes de arrancar el GC
  • Diferencia entre Unix y Switch

    • En sistemas Unix se usa el mecanismo de signals para enviar una señal a otros threads y hacer que se estacionen
    • En Switch no existe ese mecanismo de signals, y tampoco se puede interrumpir un thread
    • Hay que hacer que cada thread se dé cuenta por sí mismo de que debe estacionarse, y la estrategia típica para eso es el safepoint
    • El safepoint inserta código extra desde el compilador para que el thread revise si tiene que estacionarse
    • Como agregar estos checks cuesta, conviene hacerlo lo menos posible
    • Si se revisa con muy poca frecuencia, el GC no puede empezar hasta que todos los threads se estacionen, así que otros threads siguen detenidos esperando
    • Si el check requiere muchas instrucciones, perjudica líneas de caché de CPU y optimizaciones de pipeline
  • Límites del safepoint actual de SBCL

    • El sistema actual de safepoints de SBCL fue escrito para Windows
    • Windows tampoco tiene signal handler entre procesos, pero a diferencia de Switch sí tiene signal handling para el thread actual
    • Método existente:
      • Cada thread mantiene una página donde el safepoint escribe una sola word
      • Cuando arranca el GC, esa página se marca como solo lectura
      • Si otro thread llega a su safepoint e intenta escribir en la página, ocurre un segmentation fault y ese thread se estaciona
      • Solo hace falta una instrucción de escritura, así que es eficiente
    • En Switch tampoco se puede usar esta técnica, por lo que hay que insertar checks más complejos
    • Como fuera de Windows no se necesitaban safepoints, esa parte no se había probado en otras plataformas y además de las modificaciones para Switch también es inestable por sí misma
    • Puede que esa parte del codebase necesite una limpieza importante, aunque se espera no tener que llegar a una reescritura completa

Evitar la compilación en runtime de CLOS

  • En CLOS, la compilación de la discriminating function necesaria para el despacho de métodos normalmente se difiere hasta la primera llamada a la función genérica
  • CLOS es muy dinámico y casi en cualquier momento se pueden agregar o quitar métodos, así que es difícil saber cuándo el sistema ya está “terminado”
  • En Switch no se puede invocar el compilador, así que no se puede conservar tal cual este modelo de compilación diferida
  • La estrategia actual es depender del fast evaluator
    • La función compile se deja con un stub
    • En vez de compilar, se genera una lambda que ejecuta el código con el evaluator
    • Eso permite que también funcione código de usuario que depende de compile
    • Es mucho más lento que la compilación real

Trabajo pendiente y riesgos de rendimiento

  • El enfoque con fasteval es sobre todo un fallback
  • Se quiere explorar cómo congelar la mayor cantidad posible de estado de CLOS justo antes de hacer dump de la imagen final y precompilar todo el código posible
  • También se planea investigar más el modo de block compilation que Charles restauró hace algunos años
  • Puede que hagan falta optimizaciones extra por el procesador relativamente débil de Switch
    • Del lado del motor Trial
    • Del lado del código de Kandria
  • Hasta ahora, incluso computadoras de hace 10 años podían manejar bien los requisitos del juego, así que no había sido tan necesario optimizar
  • Tareas de alta prioridad:
    • Lograr que el recolector de basura funcione por completo
      • Por ahora ya se puede arrancar y entrar al main loop de Trial
      • Falla al llegar a multi-generation compaction
    • Hacer que los callbacks que vienen de C vuelvan a funcionar
      • Esa parte del codebase de SBCL tiene muchas rutinas de ensamblador hechas a mano
      • Puede que necesiten ajustarse a immobile-code y elfination
      • Trial solo necesita callbacks para reproducir sonido mediante libmixed
    • Trabajar en funciones extra de portabilidad necesarias para que la suite de selftest de Trial pase por completo en Switch

Alcance de publicación limitado por el NDA

  • Se quisiera publicar todo el trabajo del port, pero no se puede por el NDA de Nintendo
  • Todo lo que sí se puede hacer público se publica o se manda upstream
  • Parte del código que enlaza directamente con el SDK de Nintendo no puede compartirse con personas que no hayan firmado el NDA
  • Si alguien quiere lanzar un juego en Common Lisp para Nintendo Switch, podrá recibir acceso al trabajo del port después de firmar el NDA
  • En las actualizaciones mensuales para patrons se piensa compartir el progreso con más detalle

1 comentarios

 
GN⁺ 2024-09-14
Opiniones de Hacker News
  • Durante las últimas semanas estuve probando Trial(https://github.com/Shirakumo/trial) para experimentar con el desarrollo de juegos en Common Lisp, y fue muy divertido.
    El hecho de poder cambiar casi cualquier elemento mientras el juego está en ejecución es una ventaja enorme, así que espero que este port tenga éxito.

    • Los lenguajes de la familia Lisp parecen encajar bien con la creación de juegos. Poder evaluar código de forma interactiva sin recompilar marca una gran diferencia en el desarrollo de funcionalidades, el desarrollo incremental y la corrección de bugs.
      Poder conservar el estado de la aplicación entre cambios de código también parece muy útil, y Common Lisp parece un lenguaje mucho más rápido de lo que imaginaba vagamente.
      Personalmente, no solo para desarrollo de juegos sino en general, creo que su mayor desventaja es lo tosco que resulta el uso de estructuras de datos, especialmente la forma de manejar mapas, pero ese compromiso parece aceptable.
  • Esto es realmente genial. SBCL es una implementación excelente del lenguaje, y tenía ganas de probar desarrollo en Common Lisp en una consola de juegos “de verdad”.
    También me sorprende gratamente que Shinmera esté haciendo este trabajo. La había visto algunas veces en #lispgames y en el Discord de Lisp, pero no sabía que también le interesaba este tipo de desarrollo de bajo nivel.
    Me parece aún más impresionante porque alguna vez miré un poco las entrañas de SBCL y salí corriendo asustado. También me da curiosidad si la combinación de SBCL con threading y SDL2 funciona hoy en una Raspberry Pi.

    • El trabajo del lado de SBCL no lo hago yo; eso es todo trabajo de Charles, a quien se lo encargué. Lo mío es la parte de portabilidad para que Trial funcione en varios entornos, la estructura general de build y los stubs iniciales del runtime.
      Y, como dije antes, *her :)
    • ¿No debería ser “her”?
  • Gracias al autor por escribir un artículo tan interesante y detallado. Siento que los detalles de este nivel sobre ports oficiales a consolas normalmente salen a la luz recién varios años después de que termina la vida de la consola.
    Leer trabajos con esta profundidad a veces me hace pensar en mi propio empleo, donde paso todo el día usando software repetitivo, y me da un poco de envidia.

    • Según mi experiencia de cuando trabajaba en eso, incluso esas herramientas oficiales solían ser cosas bastante improvisadas y pegadas con cinta, y salvo por el soporte de depuración, muchas veces las toolchains modernas de homebrew para plataformas antiguas eran mejores.
      Los devkits normalmente tenían hooks mejores, pero al mismo tiempo también estaba la ventaja de evitar un flujo centrado en GDB. Habiendo vivido ambos lados, no estoy tan seguro de que sea un mundo como para envidiarlo tanto.
    • Justo estaba por empezar otro día con Ruby on Rails, así que me puse a pensar qué hobby o proyecto open source podría merecer mi atención más adelante.
      Tal vez no sea un proyecto que necesite mi atención, sino un proyecto que mi atención vaya a necesitar después :D
  • Al ver la parte de “me gustaría publicar todo, pero no puedo por el NDA”, me pregunto por qué usaron el SDK oficial en lugar de un SDK homebrew sin restricciones (https://switchbrew.org/wiki/Setting_up_Development_Environment).
    Es pura especulación, pero tal vez sea porque Nintendo no permite lanzamientos oficiales de juegos compilados con SDKs de terceros.

    • No se pueden lanzar juegos hechos como homebrew; hay que usar obligatoriamente el SDK oficial. Además, casi nadie tiene una Switch con jailbreak, así que salvo en emuladores, ejecutar el juego se vuelve muy difícil.
  • Relacionado con esto está https://opengoal.dev.
    Como contexto, Naughty Dog usó GOAL, un lenguaje propio parecido a Lisp, al crear la serie Jak & Daxter para PS2. Como dejaron suficiente información de depuración, fue posible hacer ingeniería inversa, y el proyecto OpenGOAL lo logró.
    Ahora estos juegos pueden ejecutarse en cualquier plataforma a la que se haya portado el compilador de GOAL. Hasta donde sé, actualmente es más o menos x86, y sería genial que se portara a Switch.

  • Acabo de comprar Kandria. No soy de jugar mucho, así que probablemente no lo juegue demasiado, pero Shinmera claramente está ampliando los límites del mundo Lisp y creo que vale la pena apoyarla.

  • Su trabajo es realmente impresionante. Como alguien que usa Common Lisp de vez en cuando, este tipo de trabajo me alegra mucho.

  • Ojalá empresas como Nintendo o Sony apoyaran directamente estos esfuerzos. Al final es otra forma de crear juegos para consolas, es decir, IP, y no sé qué desventaja habría si a nivel de plataforma iniciaran algo como un Github Accelerator.

    • Ya se sabe bien que los desarrolladores de juegos pasan por cualquier proceso que exija el dueño de la plataforma, pagando ellos mismos el costo. Los desarrolladores no tienen mucho poder de negociación para ponerse exigentes con los detalles técnicos al decidir en qué plataforma lanzar.
      Nintendo no necesita crear nuevos incentivos para impulsar lanzamientos en Switch. Ya tiene el mayor incentivo: más de 140 millones de unidades vendidas y una alta tasa de compra de juegos.
      Aun así, hoy no hay tantos procesos como antes. Los sistemas actuales en su mayoría convergieron hacia arquitecturas de CPU y GPU de propósito general, y cuando hay diferencias suelen ser detalles menores.
    • En el pasado sí existía ese tipo de apoyo, pero lo que la gente hizo fue principalmente portar MAME y otros emuladores, o clonar juegos de la era de 8 y 16 bits.
      Por eso se vuelve difícil disfrutar de cosas buenas.
  • Vengo a HN a leer artículos como este. Aplausos para el autor original y su colega. Sé que es imposible, pero sería una gran bendición que Nintendo tratara sus sistemas de una forma un poco más abierta.

  • Como no se explica en ninguna parte, agrego que SBCL significa “Steel Bank Common Lisp”.
    “Steel Bank Common Lisp (SBCL) es un compilador de Common Lisp de alto rendimiento. Es software open source/libre con una licencia permisiva, y además de un compilador y un sistema de runtime para ANSI Common Lisp, ofrece un entorno interactivo con depurador, profiler estadístico, herramienta de cobertura de código y varias extensiones”.
    https://www.sbcl.org/