1 pontos por GN⁺ 2024-09-14 | 1 comentários | Compartilhar no WhatsApp
  • Para rodar o motor de jogos Trial no Nintendo Switch, foi feito ao longo de 2 anos o port do runtime Common Lisp SBCL, e agora já se chegou ao ponto de compilar e executar código Lisp no Switch e até integrar bibliotecas compartilhadas
  • O Switch tem um ambiente com ARM64 Cortex-A57 de 4 núcleos e 4 GB de RAM, mas não possui shell, linha de comando nem compilador, e também bloqueia a criação de páginas executáveis, o que entra em conflito com o método normal de bootstrap do SBCL
  • O build combina um host Linux ARM64 com o SDK da Nintendo, e usa fasteval, immobile-code e elfination para dividir o core em código e dados antes de empacotar tudo no pacote final
  • O exemplo de REPL do Trial já executa no devkit do Switch, incluindo contexto OpenGL, tratamento de entrada e alocação de shaders, mas ainda trava quando o coletor de lixo roda, e o áudio continua indisponível por causa de problemas com callbacks em C
  • As principais tarefas restantes são estabilizar o safepoint para GC multithread, restaurar callbacks em C, evitar compilação em tempo de execução no CLOS, otimizar desempenho, e parte do código não pode ser aberta por causa do NDA da Nintendo

Até onde o SBCL funciona no Switch

  • Para executar o motor de jogos Trial no Nintendo Switch, o port do SBCL foi desenvolvido ao longo dos últimos 2 anos junto com Charles Zhang
  • O principal desafio era adaptar ao Switch o runtime Common Lisp sobre o qual o Trial roda
  • O que já é possível hoje:
    • O runtime e o compilador foram portados, então é possível compilar e executar diretamente no Switch código Lisp arbitrário
    • É possível fazer interface com bibliotecas compartilhadas
    • Também foram portadas várias bibliotecas de portabilidade de sistema operacional necessárias para o Trial no Switch
  • O exemplo de REPL do Trial roda no devkit do Switch
    • Como o próprio Switch não tem terminal, o Trial cria um contexto OpenGL, gerencia entrada, aloca shaders e mostra texto na tela
  • O que ainda bloqueia o avanço:
    • O SBCL trava logo que tenta acionar o coletor de lixo
    • Ainda restam limitações específicas do Switch que não puderam ser contornadas
    • O mecanismo de callback em C está quebrado, então não é possível saída de áudio
    • Ainda pode haver mais problemas de desempenho que não apareceram

Custos e sustentabilidade

  • O trabalho de port até agora custou cerca de 17 mil dólares, pagos a Charles Zhang em base mensal
  • A renda vem das vendas de Kandria e de apoios no Patreon, GitHub Sponsors e Ko-Fi
    • Em meses bons, cerca de 1.200 dólares
    • Em meses ruins, cerca de 600 dólares
  • É difícil viver em Zürich, Switzerland só com essa renda, e no momento a situação é de morar com os pais e reduzir gastos pessoais para aguentar
  • Como há dúvidas se as vendas de Kandria para Switch conseguiriam recuperar o custo do port, fica difícil decidir onde usar recursos limitados
  • A renda de apoios deve continuar sendo usada por enquanto no port do SBCL para Switch e em um projeto de jogo ainda não anunciado

Ambiente do Switch e condições do port

  • Informações publicamente conhecidas sobre o ambiente do Switch:
    • O código de usuário roda em um chip ARM64 Cortex-A57
    • Usa 4 núcleos e 4 GB de RAM
    • Funciona sobre um sistema operacional de microkernel proprietário originalmente desenvolvido para o Nintendo 3DS
  • O SBCL já tem um port para ARM64 Linux, então a parte de geração de código já estava resolvida
  • O Kandria cabe com folga em 4 GB de RAM, então a capacidade de memória em si não é o problema
  • A dificuldade real está em fazer interface com o sistema operacional proprietário do Switch
    • Existem restrições que não aparecem em sistemas operacionais comuns de PC
    • Para um sistema como Lisp isso é especialmente problemático
  • O Switch é o único console com suporte ao renderizador OpenGL em que o Trial se baseia
    • O Xbox só suporta DirectX, mas há relatos de que existe uma camada OpenGL → DirectX desenvolvida pela Microsoft, então talvez haja alguma possibilidade
    • O Playstation é conhecido por usar uma API gráfica totalmente proprietária, então continua sendo uma plataforma para a qual não há interesse em portar
  • Para desenvolver, foi obtido acesso pela Nintendo of Europe e foi comprado um devkit de cerca de 400 dólares
  • O devkit e o SDK só rodam em Windows, o que depois aumenta o peso do processo de build

O build normal do SBCL e o conflito com o Switch

  • O SBCL é escrito principalmente em Lisp, com um pequeno runtime em C incluído
  • O runtime em C é compilado com um compilador C comum, mas precisa conhecer informações sobre o ambiente do sistema operacional de destino
  • Como o runtime em si não inclui o compilador Lisp, para fazer o bootstrap do SBCL é necessário usar outra implementação Lisp, idealmente outra versão do próprio SBCL
  • Build em 5 etapas no PC

    • build-config
      • Reúne opções de configuração de build para o alvo e gera uma saída em um formato que as etapas seguintes possam ler
    • make-host-1
      • Constrói um compilador cruzado usando o compilador Lisp do host
      • Também gera arquivos de cabeçalho que descrevem como os objetos Lisp ficam organizados na memória como estruturas C
    • make-target-1
      • Cria o runtime em C usando o compilador C do alvo
      • O runtime em C inclui o coletor de lixo e o código de integração com o ambiente do sistema operacional
      • Também gera constantes vindas dos headers do sistema operacional que o compilador Lisp e o runtime do alvo precisam conhecer
    • make-host-2
      • Usa o compilador cruzado Lisp criado em make-host-1 para construir o sistema Lisp do alvo, ou seja, o compilador e a biblioteca padrão
      • Gera um cold core no qual o runtime pode entrar
    • make-target-2
      • Carrega o cold core no runtime de destino e finaliza o bootstrap
      • Depois que o sistema Lisp é carregado na memória, ele é despejado como um warm core
      • A partir daí, é possível carregar novo código e despejar novas imagens livremente
  • Onde o Switch causa problema

    • O build do SBCL exige uma etapa em que código Lisp precisa ser executado na máquina de destino
    • Código Lisp de usuário não pode ser tratado como C com simples compilação em lote, porque ele assume que está no ambiente de destino quando executa
    • A distribuição da aplicação também se parece com make-target-2: compila gradualmente o código Lisp e depois despeja um core acoplado ao runtime
    • O runtime do SBCL, ao iniciar, mapeia um blob de core na memória, marca páginas de código como executáveis e salta para uma função de entrada personalizada
    • No Switch, esse fluxo inteiro dá problema

Estratégia de build para o Switch

  • O Switch não é um ambiente de PC e não tem shell, linha de comando nem conjunto de compiladores
  • Como o sistema operacional não permite criar páginas executáveis, mesmo que fosse possível rodar as etapas de build no Switch, a compilação incremental normal de Lisp seria inviável
  • A maior parte do código é independente de plataforma, então pode ser compilada para ARM64
  • Basta fazer com que apenas as partes que tocam o ambiente do sistema operacional saibam que o alvo é o Switch, e construir a implementação em outro ambiente ARM64 como Linux
  • Etapas de build para o Switch

    • build-config
      • Roda no sistema host com flags especiais que representam o build para Switch
      • Ativa a contrib fasteval
      • Como no Switch não é possível compilar em tempo de execução, o fasteval substitui os pontos em que seria necessário chamar o compilador
    • make-host-1
      • Não muda muito e gera headers para preparar a plataforma Switch
    • make-target-1
      • Faz a compilação cruzada do runtime em C para o Switch com o compilador C fornecido pelo SDK da Nintendo
      • Como o sistema operacional do Switch não é compatível com POSIX, foi criado um alvo de runtime customizado no SBCL, fazendo stubs ou wrappers para diferenças de SO como linkedição dinâmica e mapeamento de páginas
    • Segundo build-config, make-host-1, make-target-1
      • Cria um sistema ARM64 Linux comum com um conjunto de recursos equivalente ao do Switch
      • Em alguns processos Lisp, usa flags especiais para informar que o alvo final é o Switch
    • make-host-2, make-target-2
      • Produz um build um pouco especial de SBCL para Linux ARM64, e então compila o código do usuário
    • Compilação do código do usuário
      • Modifica *features* para fazer o sistema acreditar que está rodando no Switch e não no Linux
      • Inclui :nx e exclui :linux, :unix, :posix
      • Desativa o ASDF, compila programas como o Trial de um jeito próximo do normal e então despeja um novo core

Empacotando o core com immobile-code e elfination

  • Como a estratégia normal de mapeamento de core não funciona no Switch, não dá para anexar um novo core ao runtime do Switch do jeito tradicional
  • A solução foi usar recursos menos conhecidos do SBCL: immobile-code e elfination
  • No SBCL comum, o código compilado em tempo de execução vai para alguma página e essa página é marcada como executável
    • Se esse código deixar de ser necessário depois, ele pode virar alvo de coleta de lixo
    • É possível recuperar espaço e compactar o restante do código
  • O immobile-code usa outra estratégia
    • Coloca o código em páginas especiais reservadas e o mantém ali
    • O código não pode ser coletado pelo GC
    • Em compensação, isso permite usar o suporte tradicional do sistema operacional a executáveis
    • Executáveis têm seções pré-marcadas que o sistema operacional já sabe que são código, e o mapeamento é feito na inicialização do programa
  • A etapa de elfination converte o core em seções separadas de código e dados adequadas para um executável comum
    • O elfinator analisa o core
    • Reescreve a assembly para virar código independente de posição, necessário por causa da randomização de layout do espaço de endereços
    • Separa em um arquivo de assembly só de código e um arquivo de payload só de dados
  • Etapa final:
    • Gerar o arquivo de assembly com o elfinator
    • Fazer o link do binário final
    • Usar a ferramenta de authoring do SDK da Nintendo para empacotar metadados, bibliotecas compartilhadas, assets e o binário da aplicação em um único pacote final

Infraestrutura de build e o que pode ser publicado

  • O build exige no mínimo uma máquina Linux ARM64 para executar a maior parte do processo
  • Para rodar o compilador do SDK da Nintendo e as ferramentas de authoring, é necessária uma máquina Windows AMD64 ou uma máquina Linux AMD64 com Wine
  • Na prática, a configuração chega perto de três máquinas
    • Um “driver” AMD64
    • Um host de build ARM64
    • Uma VM Windows que se comunica com o devkit
  • Para automatizar isso, foi criado um sistema de build especial com cache e lógica de sincronização entre máquinas
    • Como ele também precisava rodar em ambiente MSYS2/Windows, houve problemas com conversão de caminhos
  • Também foram portadas para ARM64 ferramentas e bibliotecas de apoio como elfinator, immobile-code, pathname-utils, libmixed e cl-gamepad
  • Muitos detalhes não podem ser divulgados por causa do NDA da Nintendo
  • O que podia ser tornado público foi enviado para upstream, e as bibliotecas Lisp não usam forks privados
  • Para evitar ligação direta com o SDK da Nintendo, foi criada uma biblioteca C separada, e as bibliotecas Lisp acessam funções do sistema operacional por meio dessa interface customizada
    • A ideia é manter a parte Lisp pública e só a pequena biblioteca em C privada

Ponteiros absolutos e relocação no carregamento

  • O elfination não foi projetado originalmente para produzir desde o início código Lisp executável independente de posição
  • Código Lisp normalmente contém muitos ponteiros absolutos
  • Foi necessário trabalhar no compilador e no runtime do SBCL para permitir relocação de ponteiros absolutos no carregamento
  • Objetos de código normalmente carregam constantes de código, e isso precisou deixar de conter ponteiros absolutos
    • O GC não pode modificar a seção executável
    • O loader do SO também não pode modificar a seção executável para relocar ponteiros absolutos
  • Solução adotada:
    • Mover ponteiros absolutos, como constantes de código, para uma área legível e gravável fora da região de texto
    • Reescrever as referências às constantes no código para carregá-las dessa área legível e gravável
    • O loader e o GC móvel podem então corrigir os ponteiros nesse espaço

Problemas de coletor de lixo e safepoint

  • O GC padrão do SBCL é o gencgc, um coletor de lixo geracional
  • O gencgc separa gerações de objetos para escanear cada uma com frequências diferentes e copia objetos para outras regiões geracionais para compactar espaço
  • Essa estrutura em si não é o problema fundamental no Switch, mas o multithreading é
  • Com várias threads, uma thread pode estar acessando um objeto, então não dá para movê-lo arbitrariamente
  • A solução mais simples é parar todas as threads antes de iniciar o GC
  • Diferença entre Unix e Switch

    • Em sistemas Unix, o mecanismo de signal permite enviar sinais para outras threads para que entrem em park
    • O Switch não tem mecanismo de signal e também não permite interromper threads
    • É preciso fazer cada thread perceber sozinha que deve entrar em park, e a estratégia mais comum é o safepoint
    • Safepoint insere código extra pelo compilador para verificar se a thread precisa entrar em park
    • Como há custo em adicionar essas checagens, elas precisam ser o mais raras possível
    • Se forem raras demais, o GC não consegue começar até que todas as threads parem, fazendo outras threads aguardarem
    • Se exigirem muitas instruções, atrapalham linhas de cache da CPU e otimizações de pipeline
  • Limites do safepoint atual do SBCL

    • O sistema de safepoint atual do SBCL foi escrito para Windows
    • O Windows também não tem signal handler entre processos, mas ao contrário do Switch ainda tem signal handling para a thread atual
    • Método atual:
      • Cada thread mantém uma página na qual o safepoint escreve uma palavra
      • Quando o GC começa, essa página é marcada como somente leitura
      • Quando outra thread chega ao safepoint e tenta escrever na página, ocorre uma segmentation fault e essa thread entra em park
      • Isso exige apenas uma única instrução de escrita, então é eficiente
    • No Switch, essa técnica também não pode ser usada, então é preciso inserir uma checagem mais complexa
    • Como safepoint não era necessário fora do Windows, essa parte não foi testada em outras plataformas e é instável independentemente das mudanças para Switch
    • Talvez seja necessário reorganizar bastante essa parte do código, embora a esperança seja evitar uma reescrita completa

Evitando compilação em tempo de execução no CLOS

  • O CLOS normalmente adia a compilação da discriminating function necessária para despacho de métodos até a primeira chamada da função genérica
  • Isso acontece porque o CLOS é muito dinâmico e métodos podem ser adicionados ou removidos quase a qualquer momento, então é difícil saber quando o sistema está “pronto”
  • No Switch, não é possível chamar o compilador, então esse modelo de compilação tardia não funciona como está
  • A estratégia atual é depender do fast evaluator
    • A função compile vira um stub
    • Em vez de compilar, ela gera uma lambda que executa o código pelo evaluator
    • Isso também funciona para código de usuário que depende de compile
    • A execução fica muito mais lenta do que com compilação real

Trabalho restante e riscos de desempenho

  • A abordagem com fasteval é principalmente um fallback
  • A intenção é explorar maneiras de congelar o máximo possível do estado do CLOS imediatamente antes de despejar a imagem final e pré-compilar o máximo de código possível
  • Também deve ser investigado com mais profundidade o modo de block compilation restaurado por Charles alguns anos atrás
  • O processador relativamente fraco do Switch pode exigir otimizações adicionais
    • No motor Trial
    • No código de Kandria
  • Até agora, como até computadores de 10 anos atrás davam conta dos requisitos do jogo, foi possível conviver com relativamente pouca otimização
  • Tarefas de alta prioridade:
    • Fazer o coletor de lixo funcionar por completo
      • Hoje já é possível chegar do boot até o loop principal do Trial
      • A falha aparece ao chegar à compactação multigeracional
    • Fazer callbacks vindos de C voltarem a funcionar
      • Essa parte do código do SBCL tem muitas rotinas de assembly escritas à mão
      • Pode ser necessário ajustá-las para immobile-code e elfination
      • O Trial só precisa de callbacks para reprodução de som via libmixed
    • Implementar mais recursos de portabilidade necessários para que a suíte de selftest do Trial passe completamente no Switch

Alcance de divulgação limitado pelo NDA

  • Haveria vontade de publicar o trabalho inteiro do port, mas isso não é possível por causa do NDA da Nintendo
  • O que pode ser público foi enviado para upstream ou publicado
  • Parte do código que se conecta diretamente ao SDK da Nintendo não pode ser compartilhada com quem não assinou o NDA
  • Se alguém quiser lançar um jogo em Common Lisp no Nintendo Switch, poderá ter acesso ao trabalho de port depois de assinar o NDA
  • Atualizações mensais para apoiadores no Patreon devem compartilhar o progresso com mais detalhes

1 comentários

 
GN⁺ 2024-09-14
Comentários do Hacker News
  • Nas últimas semanas experimentei Trial(https://github.com/Shirakumo/trial) para testar desenvolvimento de jogos em Common Lisp e foi muito divertido
    O grande diferencial é que dá para mudar quase tudo enquanto o jogo está rodando, então espero que esse port seja bem-sucedido

    • Linguagens da família Lisp parecem combinar bem com desenvolvimento de jogos. Poder avaliar código interativamente sem recompilar faz uma grande diferença no desenvolvimento de funcionalidades, no desenvolvimento incremental e na correção de bugs
      Também parece muito útil poder manter o estado da aplicação entre mudanças no código, e Common Lisp também parece ser uma linguagem bem mais rápida do que eu imaginava vagamente
      Pessoalmente, mais do que em desenvolvimento de jogos, o maior ponto fraco no geral é o uso de estruturas de dados, especialmente a forma de lidar com mapas, que é meio tosca, mas esse compromisso parece aceitável
  • Isso é realmente muito legal. SBCL é uma implementação excelente da linguagem, e eu queria experimentar desenvolvimento em Common Lisp em um console de videogame “de verdade”
    Também é uma surpresa positiva ver a Shinmera fazendo esse trabalho. Eu já a tinha visto algumas vezes no #lispgames e no Discord de Lisp, mas não sabia que ela também se interessava por esse tipo de desenvolvimento de baixo nível
    Eu já dei uma olhada rápida por dentro do SBCL e recuei assustado, então isso parece ainda mais impressionante. Fico curioso também se essa combinação de SBCL com threading e SDL2 hoje funciona em Raspberry Pi

    • O trabalho do lado do SBCL não sou eu que estou fazendo, isso é tudo trabalho do Charles que contratei. O que ficou comigo foi a parte de portabilidade para fazer o Trial funcionar em vários ambientes, a estrutura geral de build e o stub inicial de runtime
      E, como eu disse antes, *ela :)
    • Não seria “ela” o certo?
  • Obrigado ao autor por escrever um texto tão interessante e detalhado. Esse nível de detalhes sobre um port oficial para console normalmente só vem à tona anos depois de o console já ter encerrado seu ciclo de vida
    Ler um trabalho tão profundo assim às vezes até dá inveja, pensando no meu emprego, onde passo o dia usando software repetitivo

    • Na época em que eu trabalhava com isso, essas ferramentas oficiais também costumavam ser bem improvisadas e remendadas, e, tirando o suporte a depuração, muitas vezes as toolchains homebrew modernas para plataformas antigas eram melhores
      Os devkits geralmente tinham hooks melhores, mas ao mesmo tempo havia a vantagem de evitar um fluxo centrado em GDB. Tendo passado pelos dois lados, não sei se é um mundo tão invejável assim
    • Eu estava justamente prestes a começar mais um dia com Ruby on Rails e pensando em que tipo de hobby ou projeto open source eu poderia querer dedicar minha atenção no futuro
      Talvez não seja um projeto que precisa da minha atenção agora, mas um projeto do qual a minha atenção vai precisar no futuro :D
  • Ao ver a parte “eu queria tornar tudo público, mas não posso por causa de NDA”, fiquei curioso sobre por que usar o SDK oficial em vez do SDK homebrew sem restrições (https://switchbrew.org/wiki/Setting_up_Development_Environment)
    Chutando totalmente, talvez seja porque a Nintendo não permite o lançamento oficial de jogos compilados com SDKs de terceiros

    • Jogos feitos com homebrew não podem ser lançados, e é obrigatório usar o SDK oficial. Além disso, quase ninguém tem um Switch desbloqueado, então também fica muito difícil rodar o jogo fora de um emulador
  • Relacionado a isso, existe https://opengoal.dev
    Pelo contexto, a Naughty Dog usava GOAL, uma linguagem própria parecida com Lisp, para fazer a série Jak & Daxter no PS2. Como eles deixaram informação de depuração suficiente, foi possível fazer engenharia reversa, e o projeto OpenGOAL conseguiu isso
    Agora esses jogos podem rodar em todas as plataformas para as quais o compilador de GOAL foi portado. Pelo que eu sei, no momento é algo como x86, e seria legal ver um port para Switch

  • Acabei de comprar Kandria. Não sou de jogar muito, então provavelmente não vou jogar tanto, mas acho que vale apoiar a Shinmera, que claramente está ampliando as fronteiras do mundo Lisp

  • O trabalho dela é realmente incrível. Como alguém que usa Common Lisp de vez em quando, fico muito feliz de ver esse tipo de coisa

  • Seria ótimo se empresas como Nintendo ou Sony apoiassem esse tipo de esforço diretamente. No fim das contas, é só mais uma forma de criar jogos para console, ou seja, IP, então não vejo qual seria o lado ruim de iniciar algo como um Github Accelerator no nível da plataforma

    • Já é bem conhecido que os desenvolvedores de jogos acabam passando, com o próprio dinheiro, por qualquer processo exigido pelos donos da plataforma. Na hora de decidir em que plataforma lançar, os desenvolvedores não têm muito poder de barganha para implicar com detalhes técnicos
      A Nintendo não precisa criar novos incentivos para levar lançamentos ao Switch. Ela já tem o maior incentivo de todos: mais de 140 milhões de unidades vendidas e uma alta taxa de compra de jogos
      Ainda assim, hoje não há tantos processos quanto antes. A maioria dos sistemas atuais convergiu para arquiteturas genéricas de CPU e GPU, e mesmo quando há diferenças, muitas vezes são pequenos enfeites
    • No passado esse tipo de apoio existia, mas o que as pessoas faziam na maior parte do tempo era portar MAME e outros emuladores, ou clonar jogos da era 8-bit e 16-bit
      É por isso que fica difícil aproveitar coisas boas
  • É por esse tipo de post que venho ao HN. Palmas para o autor original e seu colega. Eu sei que é impossível, mas seria uma bênção enorme se a Nintendo tratasse seus sistemas de forma só um pouco mais aberta

  • Como isso não foi explicado em lugar nenhum, vale acrescentar que SBCL significa “Steel Bank Common Lisp”
    “Steel Bank Common Lisp (SBCL) é um compilador Common Lisp de alto desempenho. É um software livre/open source sob licença permissiva e, além de um compilador e sistema de runtime para ANSI Common Lisp, oferece um ambiente interativo que inclui depurador, profiler estatístico, ferramenta de cobertura de código e várias extensões”
    https://www.sbcl.org/