53 – Creando el ejecutable más pequeño de Hola Mundo!
Motivación
Hace ya un buen tiempo me he dado cuenta que los ejecutables de Go son un poco grandes. 3mb por aqui, 10mb por alla, en programas simples.
Si bien es cierto el espacio hoy en dia hablar de MBs es como no hablar de nada por curiosidad se me ocurrio saber cual seria el programa mas ligero que podria hacer en ejecutable. Los contendientes naturales incluyen los lenguages de Go, Rust, C y porque no, Nim.
¿Por qué no Python, Ruby, Javascript y otros? Por que no son lenguajes hechos para ser compilados, son mas scripting languages. Eso definitivamente no le quita mérito, solo que para esta comparacion nos interesa que el ejecutable pueda sostenerse por si mismo en la mayoría de sistemas operativos.
Criterios
- Tiene que ser un binario linkeado estaticamente, es decir que no tenga dependencias del SO o del ambiente de ejecución.
- El codigo fuente tiene que ser "estandar", sin ningun "trucos" para reducir el tamaño.
Metodo de medición
Mediremos usando la utilidad du con los flags -sh para solo considerar el binario y ademas imprimirlo en un lenguaje entendido por el usuario solamente en caso que los tamanos esten muy cerca entre si usaremos el flag de –aparent-size para poder verificar el tamano
Sin mas preambulos empecemos las pruebas.
Benchmark
Go
Empezamos con la manera más sencilla de implementar un Hola Mundo en Go. Para esto, usaremos la biblioteca estandar de Go `fmt`. Guardamos este código en un archivo llamado "main.go":
|
|
Para compilar este programa usaremos el siguiente comando:
|
|
Para ver el tamaño en disco de este binario usaremos `ls` con las flags "lah" para listarlo y mostrarlo en el formato "humano":
|
|
Este pequeño programa de Go, de tan solo 7 SLOC (lineas de código) pesa cerca 1.7MB. Si no estan muy familiarizados con Go, es necesario explicarles que Go es un lenguaje desarrollado por Google y uno de los pilares de Go es estar linkeado "estáticamente" es decir, que el binario incluye todas las bibliotecas necesarias para correr en la arquitectura (y sistema operativo) elegido en la compilación. Esto significa que nuestro ejecutable de 1.7MB puede correr incluso en computadoras que no tienen instalado Go, lo cual es una gran ventaja.
Rust
En segundo lugar veremos uno de los lenguajes del momento: Rust. Para ello, primero crearemos un nuevo archivo con el nombre rust.rs y escribimos las siguientes 3 líneas:
|
|
Como ven, Rust nuestro Hola Mundo es bastante elegante y sencillo. Esto, gracias a que println es un macro incluido en el lenguaje asi que no necesitamos importar ninguna biblioteca manualmente (internamente Rust SI incluye la biblioteca, por si acaso.)
Para compilarlo usamos:
|
|
Nota: tambien podemos compilarlo con cargo build si es que usamos cargo new para crear nuestro proyecto pero para este post vamos a tratar de minimizar la cantidad de archivos que generamos
Esto produce un ejecutable en la carpeta targets/debug con el numbre de "rust", otra vez usaremos `ls` para ver cuanto pesa:
|
|
Soprendentemente (al menos para mi), este ejecutable en Rust es casi 2 veces el tamaño del ejecutable de GO (incluso antes de optimizar Go). Si bien es cierto no estoy muy versado en Rust, pensé que al haber optado por tener un "Compilador lento" (Slow compiler), Rust tendria un binario más reducido.
Optimizando Rust
Veamos que podemos hacer para reducir el tamaño de este binario. Primero, por defecto Rust compila el binario en modo "debug", esto quiere decir que tiene cosas como símbolos de depuración, y otras cosas añadidas que nos sirven para poder investigar a profundidad los binarios y otras cosas útiles, pero en este caso no las necesitamos, para quitarlas podemos usar strip:
|
|
Nota: En caso tengamos `strip` instalado también podemos usarlo (en lugar del flag):
|
|
Veamos el tamaño del archivo:
|
|
Esto claramente produce un ejecutable mucho más pequeño, tán solo 307KB, es decir cerca del 10% del tamaño del binario original.
Además, podemos jugar un poco con los flags mencionados en este articulo de Rust. Utilizándolos, el comando se convierte en:
|
|
Verificamos el tamaño:
|
|
Ok, no hay una gran reducción esta vez, pero no podemos decir que no redujo un poco el binario. Sin lugar a dudas en proyectos más grandes este comando sería de mayor ayuda.
C
Para C, crearemos un archivo llamado hello.c y escribiremos el siguiente código:
|
|
Nota para los amantes de C: yo sé que, en teoría, podemos usar tan solo "puts", pero hoy en dia los compiladores convierten nuestro código en "puts" automáticamente, asi que realmente no tiene sentido complicarnos de esa manera como desarolladores.
Para compilarlo usaremos g++:
|
|
Veamos el tamaño del ejecutable:
|
|
16KB. ¿Cómo es posible que sea tan pequeño? ¿Tendrá que ver que C es un lenguaje "antiguo" y por eso es más eficiente? La respuesta es que no (en este caso). El tema principal es que g++ usa el linkeado dinámico de las bibliotecas que C necesita (a diferencia de Go y Rust), es decir asume que tenemos instalado en nuestra computadora C (la gran mayoría de sistemas operativos lo tienen). Ya que realmente no es una pelea justa, tratemos de compilarlo con bibliotecas "estáticas":
|
|
Veamos el tamaño:
|
|
Como podemos ver, si creamos el ejecutable con bibliotecas estáticas tenemos un archivo de 840KB. ¡Significativamente más grande que el de Rust! Incluso luego de correrlo por strip, ¡el binario sigue pesando 768KB!
Zig
Pasemos a Zig. Zig es un lenguaje que intenta ser el sucesor de C, pero más que reemplazarlo por completo intenta tener una relacion simbiotica con C. La sintaxis es bastante simple e incluso Zig tiene un compilador para C por lo que incluso podemos usar el mismo codigo fuente de C.
Para compilarlo con Zig usaremos el siguiente comando:
|
|
Nota: El comando compila el programa para la arquitectura que estoy usando para escribir este post.
Veamos el tamanno:
|
|
Obtenemos un binario bastante pequeño, pero cabe destacar que esto es porque Zig por defecto corre "strip" en el binario.
|
|
Nim
Por ultimo veremos Nim. Nim, es un otro lenguaje relativamente joven, este se "compila" a C lo cual es bastante parecido a Zig. Sin lugar a dudas el lenguaje mas simple para el Hola Mundo:
|
|
Para compilarlo de manera estática (ya que por defecto es dinámico, como C), tendremos que instalar musl, y luego correr:
|
|
Veamos el tamaño:
|
|
Tán solo 115KB, sorprendentemente pequeño en realidad, siendo hasta ahora el binario más pequeño que hemos generado (con bibliotecas estáticas), ganandole a su hermano mayor, C.
Incluso podemos sacarle un poco más de jugo al compilador con el Flag "-d:release":
|
|
Analizando el tamaño, esta vez usaremos "du -sh", qué es similar a "ls -lah", (es idéntico si le agregamos –aparent-size).
|
|
Se reduce un poco más, llegando a un ejecutable estático con menos de 100KB.
Strip
Si han leído todo el artículo, habrán visto que ya hemos usado strip, (cuando optimizamos Rust). strip es una gran herramienta, que "remueve" todas las tablas de símbolos de depuración, que, en general no necesitamos hacer pero que reduce significamente el tamaño de los binarios:
Sintáxis:
|
|
Lenguaje: Binario estático | Original | Strip | Reducción (%) |
---|---|---|---|
Go | 1.7MB | 1.2MB | 29% |
Rust | 3.6MB | 307KB | 91.5% |
C | 840KB | 761KB | 9.4% |
Zig (optimizado) | 17KB | 17KB | 0% |
Nim | 94KB | 78KB | 6.4% |
Cabe destacar que en Go podemos usar en el paso de compilacion el flag: -ldflags "-w"
|
|
UPX
UPX es una herramienta que comprime binarios y promete reducirlos en 50-60% más. Lamentablemente muchos de los virus de computadoras usan UPX, lo cual causa que los antivirus marquen los programas de UPX como virus. Por lo tanto, no recomendamos usarlo si van a distribuir los binarios. De todas formas, veamos cuanto puede reducir nuestros binarios:
Sintáxis:
|
|
Lenguaje: Binario estático | Original | UPX+Strip | Reducción (%) |
---|---|---|---|
Go | 1.2MB | 414KB | 36% |
Rust | 307KB | 118KB | 39% |
C | 761KB | 250KB | 33% |
Zig (no se puede comprimir) | 17KB | 17KB | 0% |
Nim | 78KB | 31KB | 39% |
Conclusión
El ganador de nuestra compmaracion es Zig, con un sorprendente resultado de 17KB. Este binario es tan pequeño que incluso vence a Nim y a Rust luego de pasar por upx. Probablemente sea el compilable mas pequeño que podamos hacer con un lenguaje "estandar".
Bonus
No iba a incluirlo en el benchmark original, pero ya que estamos aquí, veamos como como hacerlo en Assembly (x86) para MacOS M1:
.global _start // Provide program starting address to linker
.align 2
_start: mov x0, #1 // 1 = StdOut
adr x1, helloworld // string to print
mov x2, #13 // length of our string
mov x16, #4 // MacOS write system call
svc 0 // Call linux to output the string
mov x0, #0 // Use 0 return code
mov x16, #1 // Service command code 1 terminates this program
svc 0 // Call MacOS to terminate the program
helloworld: .ascii "Hello World!\n"
Para "compilarlo"
|
|
Tamaño:
|
|
17KB, igual que Zig. Queda como tarea para el lector optimizarlo.
Código fuente
Todo el código fuente para este post está a continuación: Descargar benchmark.zip