1 puntos por GN⁺ 2023-07-10 | 1 comentarios | Compartir por WhatsApp

-. Para un ejecutable que solo imprime "Hello World" en .NET Framework 4.x, el resultado normal de compilación en C# se redujo manualmente de 4.6 KB a 834 bytes armando el PE a mano

  • El experimento impuso estas condiciones: punto de entrada administrado, archivo ejecutable único de Windows, uso exclusivo de la BCL o bibliotecas incluidas normalmente en Windows, y sin contar los 0 bytes del final del archivo
  • La reducción de tamaño se logró eliminando anotaciones de tipos de referencia anulables y metadatos innecesarios, y cambiando a un PE de 64 bits para quitar imports y base relocations
  • En lugar de Console.WriteLine, se llamó a ucrtbase!puts por P/Invoke para eliminar el stream UTF-16 #US, y la cadena ASCII se movió al área de padding entre el encabezado PE y la primera sección
  • El ejecutable final quedó en 834 B (0x342) sin contar los ceros finales, y en 476 B comprimido en ZIP, mostrando el límite mínimo de configuración de un PE de .NET y los metadatos del CLR

Condiciones del experimento y línea base

  • El objetivo era meter la menor cantidad posible de bytes dentro de un ejecutable .NET para que el CLR imprimiera "Hello World" en la salida estándar
  • Las reglas del experimento fueron las siguientes
    • La aplicación debía ejecutar un punto de entrada administrado implementado en C# o CIL
    • El runtime objetivo era .NET Framework 4.x.x
    • Se prohibían dependencias de terceros; solo se permitían mscorlib, como parte de la BCL, o bibliotecas incluidas normalmente en Windows
    • Por las restricciones de alineación del archivo PE y del CLR, el archivo PE teóricamente mínimo de .NET que puede ejecutarse en Windows 10 o superior no puede ser menor a 1 KB, pero los trailing zero bytes no se contaron en el cálculo del tamaño
  • Si se compila un programa Hello World normal de C# con net472, C# 10 y nullable habilitado, el ejecutable pesa 4.6 KB
  • El código fuente final está en Full Source Code

Qué se puede recortar del resultado compilado de C#

  • Los tipos de referencia anulables de C# 8.0 se incluyen estáticamente en el ejecutable en forma de atributos personalizados, y esos atributos aumentan bastante el tamaño
  • Si en el .csproj se desactiva la comprobación de tipos de referencia anulables con <Nullable>disable</Nullable>, los atributos desaparecen, pero por la alineación del archivo PE el tamaño total sigue siendo de 4.6 KB
  • Al descompilar se ve que el compilador de C# sigue generando metadatos innecesarios incluso con nullable desactivado
    • assembly attributes como la versión del archivo del ensamblado o la información de copyright
    • El tipo oculto <Module> además de la clase Program
  • En vez de buscar más opciones extremas del compilador de C#, se decidió crear directamente un ejecutable .NET con AsmResolver
  • El módulo creado a mano coloca la función main en el tipo <Module> y construye un cuerpo CIL que solo contiene Ldstr "Hello World", Console.WriteLine y Ret
  • Con este método, el tamaño del archivo se reduce aproximadamente a la mitad frente al original

Eliminar imports y base relocations con un PE de 64 bits

  • El ejecutable generado tiene dos secciones, .text y .reloc, y los directorios de datos de Imports y Base Relocations ocupan mucho espacio
  • En un ejecutable .NET AnyCPU o de 32 bits, el punto de entrada no administrado debe llamar a mscoree!_CorExeMain, por lo que se necesita el directorio de imports
  • Como el ejecutable .NET predeterminado es relocatable, también se necesita una base relocation para esa llamada a la función importada
  • Las base relocations normalmente van en una sección aparte, y cada sección debe alinearse al menos a bloques de 0x200 bytes, por lo que el archivo se infla a un mínimo de 1 KB
  • En un ejecutable .NET de 64 bits no hace falta punto de entrada no administrado, así que con la siguiente configuración se pueden eliminar ambos directorios y la sección .reloc
    • module.PEKind = OptionalHeaderMagic.PE64
    • module.MachineType = MachineType.Amd64
  • En esta etapa se alcanza el mínimo teórico de 1 KB para un archivo PE válido de .NET

Eliminar nombres de metadatos, GUID y public key token

  • A partir de aquí, la medición del tamaño ya no se toma del tamaño de archivo que muestra Windows Explorer, sino de la posición inmediatamente después del último byte distinto de cero en un editor hexadecimal
  • Con ese criterio, el archivo PE de 1 KB ya se había reducido a 991 bytes (0x3DF)
  • El heap #Strings es el segundo stream de metadatos más grande en un binario .NET y almacena los nombres de tipos y métodos usados por el stream de tablas #~
  • Si se asignan null a nombres que no son importantes en tiempo de ejecución, el programa sigue funcionando normalmente, y eso ahorra 32 bytes
  • El stream #GUID contiene el GUID al que apunta el Mvid de la tabla Module, pero en este experimento no hacía falta identificar una versión, así que se eliminó
    • Se eliminaron los 16 bytes del Mvid
    • También se eliminaron el stream vacío #GUID completo y su encabezado de 16 bytes
    • En total se ahorraron 32 bytes
  • mscorlib, referenciado para llamar a Console::WriteLine, normalmente lleva un public key token de 8 bytes
  • Si no hay public key token, el CLR no verifica la autenticidad de ese assembly token, y en el experimento se eliminó
  • Después de esta etapa, el archivo quedó en 918 bytes

Eliminar Console.WriteLine y usar una cadena ASCII

  • "Hello World" se guarda en el stream de metadatos #US de .NET como un arreglo de caracteres de 16 bits con prefijo de longitud y un byte 0 adicional
  • Todos los caracteres de la cadena a imprimir tienen puntos de código menores a 0xFF, así que podrían representarse como ASCII de 1 byte, pero no hay forma de hacer que el CLR interprete como ASCII una cadena del stream #US
  • En lugar de Console::WriteLine, se llama a la función no administrada ucrtbase!puts mediante P/Invoke
    • La declaración equivalente en C# es [DllImport("ucrtbase")] static extern int puts(nint str);
    • puts recibe como argumento un puntero a una cadena ASCII terminada en cero
  • Para conocer en tiempo de compilación la dirección en runtime de la cadena, se desactiva el flag DynamicBase en DllCharacteristics del encabezado opcional PE
    • Se fija una image base estática
    • La cadena ASCII se coloca dentro de la sección .text
    • La dirección en runtime se calcula como module_base_address + rva_ascii_string
  • Para que el CLR respete este flag, también se desactiva el flag ILOnly del directorio de datos de .NET
  • Con este cambio se eliminan la cadena wide-character, el stream #US y la referencia a System.Console::WriteLine, y en su lugar solo se agregan la definición de puts y los metadatos de P/Invoke
  • El resultado baja a 889 bytes (0x379)

Últimas microoptimizaciones

  • La definición estándar de puts devuelve un int32 con la cantidad de caracteres impresos, pero el programa no usa ese valor
  • En un PE de 64 bits, según la x64 calling convention de Microsoft, el valor de retorno queda en el registro RAX, no en la pila de evaluación de .NET
  • Si se cambia la definición P/Invoke a retorno void, se puede eliminar la instrucción pop del método main que descartaba el valor de retorno
  • Como el tamaño del encabezado PE es menor a 0x200, se generan padding data entre el encabezado y la primera sección
  • El Windows PE Loader mapea el encabezado PE en memoria legible, y ese rango incluye también los padding data
  • Si la cadena ASCII se mueve a esa zona de padding, se pueden ahorrar 13 bytes más
  • Como ya no se referencia Console::WriteLine, también se elimina la referencia a mscorlib
    • Se reduce una tabla del stream de tablas #~
    • También se elimina el nombre mscorlib del stream #Strings
  • El VersionString del directorio de metadatos .NET es por defecto "v4.0.30319", y con el padding a múltiplos de 4 bytes ocupa en total 12 bytes
  • Si se acorta a v4.0., el CLR 4.0 sigue arrancando y el programa se ejecuta, con lo que se ahorran 4 bytes adicionales después del padding
  • El . final aparentemente es necesario; si se reduce más, el programa ya no arranca correctamente

Resultado final

  • El tamaño final del binario, sin contar los trailing zero, es de 834 bytes (0x342)
  • Comprimido en ZIP queda en 476 bytes, menos que los 582 bytes del punto donde se alcanzó el límite de 1 KB sin optimizaciones adicionales
  • El experimento comenzó con un Hello World de 4.6 KB generado por el compilador de C# y terminó en un archivo PE armado manualmente de 834 B
  • El programa final sigue imprimiendo correctamente "Hello World"
  • Más que algo práctico, este trabajo se parece a un experimento para explorar los límites mínimos de configuración del formato de módulos .NET y los metadatos del CLR

1 comentarios

 
GN⁺ 2023-07-10
Comentarios en Hacker News
  • Este tipo de experimentos de programa ejecutable mínimo me encantan
    Michal Strehovský creó un juego de Snake de menos de 8 KB usando el framework ahora conocido como .NET, sin depender del runtime de .NET
    Medium no me gusta mucho y Twitter tampoco sirve demasiado ahora, pero recuerdo que más o menos el "hello world" incluso soportaba 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
    El trabajo más reciente y el patrocinio se pueden ver en https://flattened.net: "bflat - C# as you know it but with Go-inspired tooling"

    • Siguiendo con lo de C# "Go-inspired", .NET tiene System.Threading.Channels
      https://learn.microsoft.com/en-us/dotnet/core/extensions/cha...
    • Si las apps en C# pudieran hacerse lo bastante pequeñas como para que los usuarios sintieran que el tiempo de descarga es instantáneo, me pregunto si eso podría servir para hacer más viable C# WebAssembly
      Según recuerdo, buena parte del tamaño de descarga era la propia biblioteca de .NET
      También me pregunto si existe algún framework o motor de juegos 2D orientado al navegador que use C# y produzca binarios pequeños
    • Gracias a Michal y al equipo de CoreRT volví a fijarme en C#, y hoy en día creo que jamás consideraría C# —especialmente existiendo Go— si no fuera por NativeAOT
  • Este artículo puede resultar confuso. Ese ejecutable requiere un runtime instalado en el sistema y, de hecho, usa .NET Framework 4.7.2 en 2023
    Para compararlo de forma justa con este enfoque, probablemente tendría más sentido usar archivos .jar o .py
    Si hablamos de ejecutables reales, un binario Native AOT que pueda correr en Windows, Linux y macOS sería un criterio más adecuado
    Aun así, es natural que sea mucho más grande porque también tiene que incluir código de soporte como GC, ThreadPool y Console

    • No necesariamente. En la práctica, el exe de salida casi no usa .NET Framework más allá de llamar a main
      La lógica para escribir bytes en la consola se resuelve con una llamada pinvoke a código no administrado expuesto por ucrt.dll
      El artículo sobre minimizar Snake que compartieron en otro comentario hace lo mismo sobre .NET Core, que es el .NET nuevo no incluido en Windows, y de hecho produce un binario no-.NET
      [0]: https://medium.com/@MStrehovsky/building-a-self-contained-ga...
    • También es bastante injusto comparar un binario nativo completamente enlazado de forma estática con otro que carga parte o todas sus bibliotecas de forma dinámica
      Comparar imágenes de contenedor parece un buen punto de partida. Usar la imagen base más pequeña posible o, si se puede, "FROM scratch", pero comparando solo dentro de la misma arquitectura
      Preferiría 32 bits para eliminar variables como correr 32 bits sobre 64 bits o la compresión de punteros
      Después estaría bien comparar el tamaño del tar exportado sin comprimir. No sería totalmente justo, pero al menos reduciría las variables obvias
      Pensándolo más, quizá sería más justo comparar el tamaño máximo de imagen comprimida, para reflejar también la compresión dentro del contenedor. Claro, usando el mismo algoritmo y parámetros, o bien, como en los benchmarks de compresión, sumando el tamaño del descompresor al resultado final
    • Mi hello world requiere el framework "bash" y mide 0 bytes
      Se invoca así: echo "hello world"
    • .NET Framework viene integrado en Windows y, al menos desde Vista, era un componente obligatorio
      En concreto, .NET Framework 4.7.2 debería venir incluido por defecto en las versiones compatibles de Windows
    • No sé si lo que debería importarnos es la "justicia" o la "realidad"
      Una instalación reciente de Windows no puede no tener .NET, mientras que Linux y macOS no lo traen por defecto, así que usar eso como criterio de justicia me parece raro
  • Además, me pregunto si alguien notó que la ubicación del autor es McMurdo Station, Antártida
    No esperaba que hubiera alguien con ese stack tecnológico en esa base: https://usscar.org/directory?combine=&field_usscar_nsf_progr...
    Podría ser una historia bastante interesante

    • Fui a McMurdo en un viaje de investigación
      Lo que sorprende a los externos es que la mayoría de la gente allí es personal de apoyo; los científicos son una fracción pequeña, quizá solo alrededor del 20%
      El personal de apoyo viene de todo tipo de oficios necesarios para hacer funcionar una ciudad moderna: bomberos, carpinteros, cocineros, mecánicos, etc.
      Quizá por el proceso de selección, casi todos son extremadamente capaces y están entre los mejores en lo suyo. Había una cantidad sorprendente de personas tipo navaja suiza con habilidades muy amplias
      Las dificultades particulares de vivir y trabajar allí atraen, como polillas a la luz, a personas únicas y muy motivadas, y mucha gente regresa año tras año. Es una comunidad excelente
    • En este caso no entiendo la frase "gasté el sábado de forma tonta"
      ¿Qué otra cosa podrías hacer allá?
    • Vale la pena ver https://brr.fyi/
    • Me pregunto cómo lo encontró
      No aparece en los metadatos de .NET ni en about
  • Dijeron que el archivo se infla al menos esa cantidad porque todas las secciones deben alinearse al valor mínimo posible de alineación de sección, 0x200 bytes, pero 0x200 son 512 bytes, o sea 0.5 KB
    Hice este nivel de optimización del tamaño de PE hace mucho tiempo, pero recuerdo que en Windows 9x el mínimo era 512, mientras que NT aceptaba alineaciones tan pequeñas como 1
    Tampoco vi el viejo truco, que antes era de última generación, de superponer los encabezados DOS MZ y PE: http://www.phreedom.org/research/tinype/
    Y luego está darse cuenta de que en la demoscene logran hacer esto dentro de un binario de 1 KB:
    https://www.pouet.net/prodlist.php?type%5B%5D=1k&platform%5B...

  • Esto de ninguna manera fue una pérdida tonta de tiempo
    Meterse en los detalles de los binarios da una comprensión profunda de cómo se construyen, y los conceptos que aprendes ahí enriquecen tu conocimiento
    Si te parece interesante y aprendiste algo, siempre vale la pena dedicarle tiempo y esfuerzo

  • Esto es mucho más relevante e interesante: https://twitter.com/MStrehovsky/status/1669502394827419648
    Dice que un Hello World de C# compilado nativamente y completamente autocontenido, "incluyendo GC y todo lo demás", puede reducirse a unos 440 KB
    El .NET classic framework es un legado obsoleto de Windows que ya no se recomienda, y las apps nuevas no deberían construirse sobre eso

    • El exe resultante solo depende de detalles de implementación de la biblioteca subyacente ucrt.dll expuesta mediante llamadas no administradas de pinvoke; en realidad no "usa" el framework
    • Una excepción podrían ser las apps que dependen de APIs de Office/COM
  • Esperaba que se hablara de binarios "reales", o sea ejecutables .NET compilados nativamente, como .NET AOT
    https://learn.microsoft.com/en-us/dotnet/core/deploying/nati...

  • Felicidades. Básicamente descartaste el soporte Unicode e hiciste una llamada en C a puts
    ¿No habría sido más fácil simplemente compilarlo con un compilador de C?
    thats_just_c_with_extra_steps.jpg

    • Saltarse la compilación casi por completo le quita un poco la gracia
  • Acabo de probarlo con el IDE de Oberon+ y este módulo produce un Hello.dll de 2048 bytes
    module Hello
    begin
    println("hello world")
    end Hello
    Usa el generador de ensamblado https://github.com/rochus-keller/Pelib

    • Nicklaus Wirth tenía razón
      Eso sentí cuando usé Lazarus/FreePascal por primera vez, y mientras más leo su obra, más creo que estaba en lo correcto
      Últimamente pienso que WASM podría ser la salida de esta "reencarnación" de esa forma de pensar, pero no puedo rendirme así con el hardware
      Soy demasiado primitivo para aceptar una máquina espiritual que no pueda romper con un martillo sobre mi escritorio
      Una computadora global indestructible como Ethereum, que corre usando miles de dólares en tiempo de cómputo en hardware ajeno y es más lenta que una RPi, me resulta tabú de alguna manera fundamental
      Todavía quiero poner "Wirth was right" en una camiseta con la cara de Wirth
    • Con Oberon+, uno pensaría que debería ser self-hosting
      Hay demasiado residuo del ecosistema de C++
      Me recuerda a los proyectos de forja de hosting de software open source que se promocionan como alternativas a GitHub y GitLab. Pero cuando vas a clonar el código o reportar un bug, todo está en GitHub
    • Me pregunto si el final del archivo está rellenado con bytes cero
      Parece que debería haber una opción para desactivar eso
  • Fue revelador ver que se puede reducir un .exe a menos de 1 KB
    Si alguien pudiera reducir el .exe de Hello World de Flutter hasta 1 MB, sería realmente impresionante

    • El primer paso probablemente sería convencer a Microsoft de incluir Flutter en Windows
    • Me pregunto si compilar solo "Hello world" con Dart cuenta como válido