1 pontos por GN⁺ 2023-07-10 | 1 comentários | Compartilhar no WhatsApp

-.NET Framework 4.x, tomando como alvo um executável que apenas imprime "Hello World", o resultado normal da compilação em C# foi reduzido manualmente de 4,6 KB para 834 bytes com uma montagem artesanal de PE

  • O experimento adotou as condições de usar um ponto de entrada gerenciado, um único arquivo executável do Windows, apenas a BCL ou bibliotecas presentes em uma instalação comum do Windows, e desconsiderar bytes zero no fim do arquivo
  • A redução de tamanho foi feita removendo anotações de tipos de referência nullable e metadados desnecessários, além de migrar para PE de 64 bits para eliminar imports e base relocations
  • Em vez de Console.WriteLine, foi usado P/Invoke para chamar ucrtbase!puts, removendo assim o fluxo UTF-16 #US; a string ASCII foi movida para a área de padding entre o cabeçalho PE e a primeira seção
  • O executável final ficou em 834B (0x342) sem contar zeros no final, e cai para 476B quando compactado em ZIP, mostrando o limite mínimo da composição de um PE .NET e dos metadados do CLR

Condições do experimento e linha de base

  • O objetivo era colocar apenas o número mínimo de bytes necessários dentro de um executável .NET para que o CLR imprimisse "Hello World" na saída padrão
  • As regras do experimento foram as seguintes
    • A aplicação precisava executar um ponto de entrada gerenciado implementado em C# ou CIL
    • O runtime alvo era o .NET Framework 4.x.x
    • Dependências de terceiros eram proibidas; só mscorlib, por fazer parte da BCL, ou bibliotecas presentes em uma instalação comum do Windows eram permitidas
    • Por causa das restrições de alinhamento do arquivo PE e do CLR, um arquivo PE .NET teoricamente mínimo executável no Windows 10 ou superior não pode ter menos de 1 KB, mas os trailing zero bytes no fim do arquivo foram desconsiderados no cálculo do tamanho
  • Ao compilar um programa Hello World comum em C# com net472, C# 10 e nullable ativado, o executável fica com 4,6 KB
  • O código-fonte final está em Full Source Code

O que podia ser reduzido no resultado da compilação em C#

  • Os tipos de referência nullable do C# 8.0 são incluídos estaticamente no executável na forma de atributos customizados, e esses atributos aumentam bastante o tamanho
  • Ao desativar a checagem de tipos de referência nullable com <Nullable>disable</Nullable> no .csproj, os atributos somem, mas o tamanho total do arquivo continua em 4,6 KB por causa do alinhamento do arquivo PE
  • Pela descompilação, o compilador C# continua gerando metadados desnecessários mesmo com nullable desligado
    • assembly attributes como versão do arquivo da assembly e informações de copyright
    • Um tipo oculto <Module> além da classe Program
  • Em vez de procurar mais opções extremas no compilador C#, decidiu-se criar diretamente um executável .NET com AsmResolver
  • O módulo criado manualmente colocou a função main no tipo <Module> e montou um corpo CIL contendo apenas Ldstr "Hello World", Console.WriteLine e Ret
  • Com isso, o tamanho do arquivo caiu para cerca de metade do original

Removendo imports e base relocations com PE de 64 bits

  • O executável gerado tinha duas seções, .text e .reloc, e os diretórios de dados de Imports e Base Relocations ocupavam um espaço considerável
  • Um executável .NET AnyCPU ou de 32 bits precisa que o ponto de entrada não gerenciado chame mscoree!_CorExeMain, então o diretório de imports é necessário
  • Como executáveis .NET padrão são relocáveis, também é preciso base relocation para essa chamada de função importada
  • Base relocation normalmente fica em uma seção separada, e como cada seção precisa ser alinhada em blocos mínimos de 0x200 bytes, o arquivo incha para pelo menos 1 KB
  • Em um executável .NET de 64 bits, não é necessário ponto de entrada não gerenciado, então foi possível remover os dois diretórios e a seção .reloc com as seguintes configurações
    • module.PEKind = OptionalHeaderMagic.PE64
    • module.MachineType = MachineType.Amd64
  • Nessa etapa, chega-se ao mínimo teórico de 1 KB para um arquivo PE .NET válido

Removendo nomes de metadados, GUID e public key token

  • A partir daqui, a medição do tamanho não usa o tamanho mostrado no Windows Explorer, mas sim a posição logo após o último byte diferente de zero no editor hexadecimal
  • Com esse critério, o arquivo PE de 1 KB já tinha encolhido para 991 bytes (0x3DF)
  • O heap #Strings é o segundo maior fluxo de metadados em um binário .NET e armazena os nomes de tipos e métodos usados pelo fluxo de tabelas #~
  • Nomes irrelevantes em tempo de execução puderam ser definidos como null sem afetar a execução do programa, economizando 32 bytes
  • O fluxo #GUID guarda o GUID referenciado por Mvid na tabela Module, mas ele não era necessário no experimento, então foi removido
    • Os 16 bytes de Mvid foram removidos
    • O fluxo #GUID vazio e seu cabeçalho de 16 bytes também foram removidos
    • No total, foram economizados 32 bytes
  • O mscorlib referenciado para chamar Console::WriteLine normalmente traz um public key token de 8 bytes
  • Sem esse public key token, o CLR não valida a autenticidade desse assembly token, e no experimento ele foi removido
  • Depois dessa etapa, o arquivo passou a ter 918 bytes

Removendo Console.WriteLine e usando string ASCII

  • "Hello World" é armazenado no fluxo de metadados #US do .NET, como um array de caracteres de 16 bits com prefixo de tamanho e um byte zero adicional
  • Todos os caracteres da string a imprimir têm code point menor que 0xFF, então poderiam ser representados em ASCII de 1 byte, mas não há como fazer o CLR interpretar uma string do fluxo #US como ASCII
  • Em vez de Console::WriteLine, foi usada uma chamada P/Invoke para a função não gerenciada ucrtbase!puts
    • Em C#, a declaração seria [DllImport("ucrtbase")] static extern int puts(nint str);
    • puts recebe como argumento um ponteiro para uma string ASCII terminada em zero
  • Para conhecer o endereço de runtime da string no momento da compilação, o experimento desativou a flag DynamicBase em DllCharacteristics no optional header do PE
    • Foi definido um image base fixo
    • A string ASCII foi colocada dentro da seção .text
    • O endereço em runtime foi calculado como module_base_address + rva_ascii_string
  • Para que o CLR respeitasse essa flag, a flag ILOnly do diretório de dados .NET também foi desativada
  • Com essa mudança, foram removidos a string wide-character, o fluxo #US e a referência a System.Console::WriteLine, adicionando no lugar apenas a definição de puts e os metadados de P/Invoke
  • O resultado caiu para 889 bytes (0x379)

Últimas micro-otimizações

  • A definição padrão de puts retorna um int32 com a quantidade de caracteres impressos, mas o programa não usa esse valor
  • Em um PE de 64 bits, pela x64 calling convention da Microsoft, o valor de retorno vai para o registrador RAX, não para a pilha de avaliação do .NET
  • Ao mudar a definição P/Invoke para retorno void, foi possível remover a instrução pop usada para descartar o valor no método main
  • Como o tamanho do cabeçalho PE ficou menor que 0x200, surgiram dados de padding entre o cabeçalho e a primeira seção
  • O Windows PE Loader mapeia o cabeçalho PE para memória legível, e esse intervalo inclui também os dados de padding
  • Ao mover a string ASCII para essa área de padding, foi possível economizar mais 13 bytes
  • Como Console::WriteLine não era mais referenciado, a referência a mscorlib também foi removida
    • Uma tabela do fluxo #~ deixou de existir
    • O nome mscorlib também saiu do fluxo #Strings
  • O VersionString do diretório de metadados .NET é por padrão "v4.0.30319" e, com padding para múltiplos de 4 bytes, ocupa 12 bytes no total
  • Reduzir essa string para v4.0. ainda permite que o CLR 4.0 inicialize e execute o programa, economizando mais 4 bytes após o padding
  • O . final aparentemente é necessário; ao encurtar mais do que isso, o programa deixa de iniciar corretamente

Resultado final

  • O binário final tem 834 bytes (0x342), sem contar zeros no final do arquivo
  • Compactado em ZIP, ele fica com 476 bytes, menor que os 582 bytes obtidos ao parar as otimizações quando o limite de 1 KB foi alcançado
  • O experimento começou com um Hello World de 4,6 KB gerado pelo compilador C# e chegou a um arquivo PE montado manualmente com 834B
  • O programa final ainda imprime "Hello World" normalmente
  • O trabalho se parece menos com algo prático e mais com um experimento para explorar o limite mínimo de composição do formato de módulo .NET e dos metadados do CLR

1 comentários

 
GN⁺ 2023-07-10
Comentários do Hacker News
  • Eu realmente gosto desse tipo de experimento de programa mínimo executável
    Michal Strehovský criou um jogo da cobrinha com menos de 8 KB usando o framework hoje conhecido como .NET, sem dependência do runtime .NET
    Não gosto muito do Medium e o Twitter também não é útil hoje em dia, mas lembro que algo como o "hello world" até suportava o Windows 3.11
    https://medium.com/@MStrehovsky/building-a-self-contained-ga... [https://web.archive.org/web/20200103110836/https://medium.co... | https://archive.ph/b6qXE]
    https://news.ycombinator.com/item?id=22010159 (2020)
    https://news.ycombinator.com/item?id=22104734
    O trabalho mais recente e as formas de apoiar estão em https://flattened.net: "bflat - C# as you know it but with Go-inspired tooling"

    • Falando em C# "inspirado em Go", o .NET tem System.Threading.Channels
      https://learn.microsoft.com/en-us/dotnet/core/extensions/cha...
    • Se apps em C# puderem ficar pequenos o bastante para que o usuário perceba o tempo de download como instantâneo, fico pensando se isso poderia tornar C# WebAssembly mais viável
      Pelo que lembro, uma boa parte do tamanho do download era da própria biblioteca .NET
      Também fico curioso se existe algum framework/engine de jogos 2D voltado para navegador e usando C# que consiga gerar binários pequenos
    • Graças ao Michal e à equipe do CoreRT, voltei a olhar para C#, e hoje em dia acho que nunca consideraria C# sem o NativeAOT, especialmente existindo Go
  • Este post pode ser confuso. Esse executável precisa do runtime instalado no sistema e, na prática, usa .NET Framework 4.7.2 em 2023
    Para uma comparação justa com essa abordagem, talvez seja mais adequado comparar com arquivos .jar ou .py
    Como referência de executável real, o mais apropriado seriam binários Native AOT que rodam em Windows, Linux e macOS
    Mas aí é natural que fiquem bem maiores, porque precisam incluir código de suporte como GC, ThreadPool e Console

    • Não necessariamente. O exe gerado de fato praticamente não usa o .NET Framework além da chamada ao main
      A lógica de escrever bytes no console é feita por uma chamada pinvoke para código não gerenciado exposto por ucrt.dll
      O post sobre a minimização do Snake compartilhado em outro comentário faz a mesma coisa com o .NET Core, o .NET novo que não vem embutido no Windows, e de fato gera um binário não-.NET
      [0]: https://medium.com/@MStrehovsky/building-a-self-contained-ga...
    • Também é bastante injusto comparar um binário nativo totalmente com linkagem estática com um binário que carrega parte ou toda a biblioteca dinamicamente
      Comparar imagens de contêiner parece um bom ponto de partida. Use a menor imagem-base possível ou, se der, FROM scratch, mas compare apenas entre a mesma arquitetura
      Para eliminar variáveis como rodar 32 bits em 64 bits ou compressão de ponteiros, eu preferiria 32 bits
      Depois seria bom comparar o tamanho do tar exportado sem compressão. Não seria totalmente justo, mas reduziria as variáveis óbvias
      Pensando melhor, talvez fosse mais justo comparar o tamanho máximo da imagem comprimida, para refletir também a compressão dentro do contêiner. Claro, usando o mesmo algoritmo e os mesmos parâmetros, ou então, como em benchmarks de compressão, somando o tamanho do descompressor ao resultado final
    • Meu hello world precisa do framework "bash" e tem 0 bytes
      É só chamar assim: echo "hello world"
    • O .NET Framework vem embutido no Windows e, pelo menos desde o Vista, era um componente obrigatório
      Especificamente, o .NET Framework 4.7.2 deve vir por padrão nas versões suportadas do Windows
    • Não sei se o que devemos considerar é "justiça" ou "realidade"
      Uma instalação moderna do Windows não pode não ter .NET, enquanto no Linux e no macOS ele não vem por padrão, então parece estranho usar isso como critério de justiça
  • Além disso, fiquei curioso se alguém mais percebeu que a localização do autor é a McMurdo Station, na Antártida
    Não imaginava que haveria alguém naquela base com esse tipo de stack técnica: https://usscar.org/directory?combine=&field_usscar_nsf_progr...
    Isso pode render uma história bem interessante

    • Já fui a McMurdo em uma viagem de pesquisa
      O que surpreende quem vem de fora é que a maioria das pessoas lá é de equipe de apoio, e os cientistas são uma parcela pequena, talvez só uns 20%
      A equipe de apoio vem de todo tipo de profissão necessária para manter uma cidade moderna funcionando: bombeiros, carpinteiros, cozinheiros, mecânicos etc.
      Talvez por causa do processo seletivo, quase todo mundo é extremamente competente e está no topo da sua área. Havia uma quantidade surpreendente de gente no estilo canivete suíço com habilidades amplas
      A dificuldade de viver e trabalhar lá é única e atrai pessoas muito motivadas como mariposas para a luz, e muitas voltam ano após ano. É uma comunidade excelente
    • Nesse caso, não entendo a frase "usei meu sábado de forma idiota"
      O que mais daria para fazer aí?
    • Vale a pena ver https://brr.fyi/
    • Fiquei curioso sobre como ele descobriu isso
      Não aparece nos metadados do .NET nem no about
  • Foi dito que o arquivo fica inflado em pelo menos esse tanto porque todas as seções precisam ser alinhadas ao menor valor possível de alinhamento de seção, 0x200 bytes, mas 0x200 são 512 bytes, ou seja, 0,5 KB
    Já brinquei com esse nível de otimização de tamanho de PE há muito tempo, mas lembro que no Windows 9x o mínimo era 512, enquanto o NT aceitava alinhamentos tão pequenos quanto 1
    Também não vi o truque, que já foi estado da arte, de sobrepor os cabeçalhos DOS MZ e PE: http://www.phreedom.org/research/tinype/
    E aí a cena demoscene me faz perceber que conseguem fazer isso dentro de um binário de 1 KB:
    https://www.pouet.net/prodlist.php?type%5B%5D=1k&platform%5B...

  • Isso de forma alguma foi perder tempo de maneira idiota
    Quando você mergulha nos detalhes do binário, ganha uma compreensão profunda de como ele é construído, e os conceitos aprendidos ali enriquecem seu conhecimento
    Se é interessante e você aprendeu algo, sempre vale o tempo e o esforço

  • Isto aqui é muito mais relevante e interessante: https://twitter.com/MStrehovsky/status/1669502394827419648
    A ideia é que um Hello World em C# compilado nativamente, totalmente self-contained, incluindo GC e tudo mais, pode ser reduzido para cerca de 440 KB
    O .NET classic framework é um legado obsoleto do Windows, e novos apps não deveriam ser feitos com base nele

    • O exe resultante apenas depende de detalhes de implementação da biblioteca subjacente ucrt.dll, expostos por chamadas não gerenciadas de pinvoke, mas não realmente "usa" o framework
    • Pode haver uma exceção se você estiver criando um app que dependa de APIs do Office/COM
  • Eu esperava que isso fosse sobre um binário "de verdade", ou seja, um executável .NET compilado nativamente como .NET AOT
    https://learn.microsoft.com/en-us/dotnet/core/deploying/nati...

  • Parabéns. Você abriu mão do suporte a Unicode e basicamente criou uma chamada C para puts
    Não teria sido mais fácil simplesmente compilar com um compilador C?
    thats_just_c_with_extra_steps.jpg

    • Pular a compilação praticamente inteira é meio broxante
  • Acabei de testar no Oberon+ IDE e ele gera um Hello.dll de 2048 bytes com este módulo
    module Hello
    begin
    println("hello world")
    end Hello
    Usa o gerador de assembly https://github.com/rochus-keller/Pelib

    • Nicklaus Wirth estava certo
      Senti isso quando usei Lazarus/FreePascal pela primeira vez, e quanto mais leio a obra dele, mais acho que ele tinha razão
      Ultimamente penso que talvez o caminho para escapar dessa "reencarnação" dessa forma de pensar seja WASM, mas simplesmente não consigo abandonar o hardware desse jeito
      Sou primitivo demais para aceitar máquinas espirituais que não dá para destruir com um martelo em cima da mesa
      Um computador global indestrutível como o Ethereum, rodando no hardware dos outros, consumindo milhares de dólares em tempo de computação e ainda sendo mais lento que um RPi, me parece tabu de alguma forma fundamental
      Ainda quero colocar "Wirth was right" em uma camiseta com o rosto do Wirth
    • Sendo Oberon+, imagino que deveria ser self-hosting, não?
      Tem resquício demais do ecossistema C++
      Isso me lembra os projetos forge de hospedagem de software livre que são divulgados como alternativa ao GitHub e GitLab. Aí, quando você vai clonar o código ou abrir um bug, está tudo no GitHub
    • Fiquei curioso se o fim do arquivo está preenchido com bytes zero
      Parece que deveria haver uma opção para desativar isso
  • Foi revelador ver que dá para reduzir um .exe para menos de 1 KB
    Se alguém conseguisse reduzir o .exe de Hello World do Flutter para 1 MB, isso seria realmente impressionante

    • O primeiro passo provavelmente seria convencer a Microsoft a incluir o Flutter no Windows
    • Fico me perguntando se compilar apenas "Hello world" em Dart contaria