53 – Creando el ejecutable más pequeño de Hola Mundo!

Posted on May 10, 2022 – - by Franco
Cover del post

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

  1. Tiene que ser un binario linkeado estaticamente, es decir que no tenga dependencias del SO o del ambiente de ejecución.
  2. 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":

1
2
3
4
5
6
7
package main

import "fmt"

func main(){
  fmt.Println("Hola, Mundo!")
}

Para compilar este programa usaremos el siguiente comando:

1
go build main.go

Para ver el tamaño en disco de este binario usaremos `ls` con las flags "lah" para listarlo y mostrarlo en el formato "humano":

1
2
ls -lah ./main # 1.7 MB
./main #> Hola, Mundo!

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:

1
2
3
4
// rust.rs
fn main() {
    println!("Hello, world!");
}

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:

1
rustc rust.rs

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:

1
2
ls -lah ./rust #> 3.6MB
./rust #> Hola, Mundo!

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:

1
rustc rust.rs  -C strip=symbols

Nota: En caso tengamos `strip` instalado también podemos usarlo (en lugar del flag):

1
strip rust

Veamos el tamaño del archivo:

1
2
ls -lah ./rust #> 307K
./rust #> Hola, Mundo!

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:

1
rustc rust.rs  -C strip=symbols -C opt-level="z" -C lto=no -C panic="abort" -C codegen-units=1

Verificamos el tamaño:

1
2
ls -lah ./rust #> 303K
./rust #> Hola, Mundo!

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:

1
2
3
4
5
#include<stdio.h>
int main(){
  printf("Hola, Mundo!")
  return 0;
}

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++:

1
g++ hello.c -o hello

Veamos el tamaño del ejecutable:

1
2
ls -lah ./hello #> 16K
./hello #> Hola, Mundo!

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":

1
g++ hello.c -o hello -static

Veamos el tamaño:

1
2
ls -lah ./hello #> 840K
./hello #> Hola, Mundo!

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:

1
zig cc -target x86_64-linux-musl -static -o hello-zig

Nota: El comando compila el programa para la arquitectura que estoy usando para escribir este post.

Veamos el tamanno:

1
2
ls -lah ./hello-zig #> 17K
./hello-zig #> Hola, Mundo!

Obtenemos un binario bastante pequeño, pero cabe destacar que esto es porque Zig por defecto corre "strip" en el binario.

1
2
file hello-zig
hello-zig: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped

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:

1
echo "Hola, mundo!"

Para compilarlo de manera estática (ya que por defecto es dinámico, como C), tendremos que instalar musl, y luego correr:

1
nim c --gcc.exe:musl-gcc --gcc.linkerexe:musl-gcc --passL:-static -o=hello-nim hello.nim

Veamos el tamaño:

1
2
ls -lh ./hello-nim #=> 115 KB
./hello-nim #=> Hola, mundo!

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":

1
nim c --gcc.exe:musl-gcc --gcc.linkerexe:musl-gcc --passL:-static -o=hello-nim -d:release hello.nim

Analizando el tamaño, esta vez usaremos "du -sh", qué es similar a "ls -lah", (es idéntico si le agregamos –aparent-size).

1
2
3
4
du -sh ./hello-nim #=> 96KB
ls -lh ./hello-nim #=> 94 KB
du -sh --apparent-size ./hello-nim #=> 94KB
./hello-nim #=> Hola, mundo!

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:

1
strip ./rust
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"

1
go build -ldflags "-w" main.go

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:

1
upx --best --lzma ./rust
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"

1
2
as -arch arm64 -o HelloWorld.o HelloWorld.s
ld -o HelloWorld HelloWorld.o -e _start -arch arm64

Tamaño:

1
du -sh --aparent-size ./HelloWorld #=> 17KB

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