- {fmt} es una biblioteca de formateo para C++ que ha reducido la expansión de templates mediante type erasure; en este experimento, un ejecutable simple con
fmt::printse redujo de 75 kB a 14 kB - La estructura clave consiste en que
formatdelega envformat, que no es un template, y en ocultar también el tipo de salida detrás de una API de buffers, lo que permite reducir tanto el tamaño del binario como los tiempos de compilación - En aarch64 Ubuntu 22.04 con GCC 11.4.0, el ejecutable stripped de {fmt} 11.0.2 medía 75 kB; al desactivar locale, reducir los tipos incorporados y usar macros de optimización de tamaño, bajó de 71 kB → 31 kB → 27 kB → 23 kB
- La eliminación del runtime de C++ fue posible al hacer que las excepciones usen
abortmedianteFMT_THROW, compilar con-fno-exceptions,-nodefaultlibsy-lc, y cambiar el asignador predeterminado debasic_memory_buffera uno basado en malloc/free - El ejecutable final mide 14 kB; considerando que un
mainvacío en C en el mismo sistema pesa 6 kB, el tamaño que agrega {fmt} es inferior a 10 kB, ylddtampoco muestra dependencias del runtime de C++
Cómo {fmt} crea binarios pequeños
- {fmt} formatting library suele generar varias veces menos código por llamada de función que alternativas como IOStreams, Boost Format o tinyformat
- La clave está en una arquitectura que aplica type erasure en varias capas para reducir la expansión de templates
- Los argumentos de formateo tienen type erasure mediante
format_args- La función template
formatdelega el trabajo real envformat, que no es un template - Los iteradores de salida y otros tipos de salida también tienen type erasure mediante una API de buffers separada
- La función template
- El uso de templates queda limitado a una capa superior delgada, y esta estructura contribuye a binarios más pequeños y a tiempos de compilación de C++ más rápidos
Tamaño de código cercano a printf y mayor seguridad
- El programa de ejemplo solo llama a
fmt::print("The answer is {}.", 42); - El resultado de compilación es mucho más pequeño que con IOStreams y está en un nivel similar al ejemplo con
printf - A diferencia de
printf, {fmt} ofrece seguridad de tipos en runtime- Los errores en la cadena de formato pueden detectarse en tiempo de compilación
- Incluso si la cadena de formato se decide en runtime, los errores se manejan con excepciones, evitando comportamiento indefinido, corrupción de memoria y posibles caídas
- Al usar argumentos posicionales (positional arguments), que no encajan bien con los argumentos variádicos de C, una llamada a {fmt} suele ser más eficiente
Tamaño base y eliminación de locale
- En la optimización del tamaño de la biblioteca de 2020, {fmt} se redujo a menos de 100 kB y a unos 57 kB con
-Os -flto - Desde entonces, {fmt} pasó a usar el algoritmo Dragonbox, contribuido por Junekey Jeon, para el formateo de números de punto flotante
- Esta medición toma como referencia el tamaño del ejecutable percibido por el usuario final y se realizó en aarch64 Ubuntu 22.04 con GCC 11.4.0
- La compilación base de {fmt} 11.0.2 pesa 75 kB después de
-Os -flto -DNDEBUGystrip- Aunque hubo varios cambios en los últimos 4 años, el tamaño no retrocedió de forma significativa
- Al desactivar el soporte de locale con
FMT_STATIC_THOUSANDS_SEPARATOR, el tamaño del binario baja a 71 kB- El formateo de {fmt} es independiente de locale por defecto
- Locale se puede usar opcionalmente con el especificador de formato
L
Reducción de tipos incorporados y modelo de “no pagar por lo que no se usa”
- El análisis con Bloaty muestra que el formateo numérico, en especial el formateo de punto flotante, ocupa una gran parte del tamaño del binario
- El formateo de punto flotante también usa tablas, y esas tablas no aparecen en la salida de Bloaty
- La carga fundamental surge de que la función de formateo debe conocer todos los tipos formateables
- Este enfoque encaja con el
printfestándar de C, pero no es un requisito indispensable para {fmt} - {fmt} admite una API de extensión que permite formatear tipos arbitrarios sin conocer de antemano el conjunto completo de tipos
- Este enfoque encaja con el
- En la implementación experimental, se configura
FMT_BUILTIN_TYPES=0para tratar de forma especial solo ainty enviar el resto de los tipos a la API de extensión generalintes necesario para manejar ancho y precisión dinámicos- Ejemplo:
fmt::print("{:{}}\n", "hello", 10);imprime"hello "
- Este enfoque ofrece un modelo en el que no se paga por los tipos que no se usan, aunque el tamaño del binario por llamada aumenta ligeramente
- Si realmente se formatean números de punto flotante u otros tipos, el código relacionado sigue incluyéndose en la compilación
- Tras aplicar
FMT_BUILTIN_TYPES=0, el binario de ejemplo baja a 31 kB - Luego se eliminaron restos relacionados con locale en e582d37 y b3ccc2d, y al permitir desactivarlo con mayor claridad mediante la macro
FMT_USE_LOCALE, el tamaño quedó en 27 kB
Elegir entre velocidad y tamaño, y eliminar el runtime de C++
- Dentro de la biblioteca hay varias partes que sacrifican tamaño para ganar velocidad
do_count_digits, que calcula la cantidad de dígitos decimales, usa una tabla de 256 bytes- Cambiar esta implementación de forma incondicional podría afectar negativamente a otros casos de uso
- Ya existe una implementación fallback para casos como
constexpr, donde no se puede usar__builtin_clz
- Se agregó la macro
FMT_OPTIMIZE_SIZEpara que el usuario pueda controlar si usar la implementación fallback- Con este ajuste y algunos cambios similares, el tamaño del binario queda en 23 kB
- Para eliminar la dependencia de la biblioteca estándar de C++, las excepciones pueden desactivarse con
FMT_THROW- El ejemplo usa
FMT_THROW(s)=abort()y-fno-exceptions - En general no se recomienda, pero puede ser aceptable en algunos casos de uso donde la mayoría de los errores se detectan en tiempo de compilación
- El ejemplo usa
- Al compilar con
-nodefaultlibs -lc, la dependencia restante del runtime de C++ proviene defmt::basic_memory_buffer- Este buffer es un pequeño buffer asignado en la stack que se expande a memoria dinámica si hace falta
fmt::printnormalmente puede escribir directamente en un bufferFILE, por lo que no requiere asignación dinámica
- Como solución más general, se reemplazó el asignador predeterminado basado en
new/deletepor uno basado en malloc/free- Después de este cambio, el tamaño final del binario es 14 kB
- Como un programa C con
mainvacío en el mismo sistema pesa 6 kB, el tamaño que agrega {fmt} es inferior a 10 kB
- El resultado de
ldd a.outmuestra sololibc.so.6y el loader; no aparece ninguna dependencia del runtime de C++ - El resultado final muestra que {fmt} puede usarse con un tamaño menor en entornos embebidos y con restricciones de memoria
1 comentarios
Opiniones en Hacker News
En realidad, esto es más bien un problema de tendencia de comité, así que no esperaría que fmt, al ser una biblioteca de terceros, tenga necesariamente valores predeterminados incorrectos.
Sorprendentemente, cuando esta funcionalidad se estandarizó como std::format en C++20, el comité no volvió a introducir este error que existe en muchas otras partes del estándar.
Así que también hay algo de esperanza para quienes proponen no empeorar innecesariamente C++ con el pretexto de hacerlo “consistente”.
Es bastante impactante ver la cantidad de código necesaria para el formateo de punto flotante.
También vale la pena leer el proyecto Dragonbox [1] enlazado, y está bastante optimizado incluso en ramas que casi no se usan.
[1] https://github.com/jk-jeon/dragonbox
Normalmente el compilador de Zig no depende del runtime de C en Windows, por lo que puede producir binarios más pequeños que MSVC, pero esta vez el binario era extrañamente grande para lo que hacía la herramienta.
Lo abrí con Binary Ninja y la mayor parte del código era para soportar formateo de punto flotante; al convertir los números de punto flotante a enteros antes de imprimirlos, el tamaño bajó a lo esperado.
Estoy haciendo experimentos de optimización de tamaño, y por ahora se puede reducir a unos 3k en AVR de 8 bits.
Eso incluye solo la implementación y las tablas para binary32 de precisión simple; la doble precisión requiere bastante más, pero al mismo tiempo buena parte del aumento de tamaño se debe a las limitaciones de AVR.
En plataformas como x64 podría ser mucho más pequeño, aunque se puede decir que 3k sigue siendo grande.
La implementación de referencia al final también es una implementación de aritmética de precisión arbitraria, pero no es tan mala.
[1] https://research.swtch.com/ftoa
[2] https://go.dev/src/strconv/ftoa.go
Me pregunto si sería más eficiente multiplicar por la cantidad de posiciones decimales, convertir a entero, pasarlo por itoa() y luego insertar el punto decimal en la posición correcta.
Como principiante en C++, me da curiosidad: ¿el asignador predeterminado de libc++, es decir, la implementación predeterminada de new/delete, hace internamente algo realmente distinto de llamar a malloc/free de libc? Si es así, ¿por qué?
delete[] intenta ejecutar el destructor de cada elemento antes de liberar la memoria.
Para que delete[] funcione, C++ tiene que llevar registro del tamaño de la asignación en algún lugar; esa información puede estar cerca de la región asignada o en una estructura separada.
Si se usa una estructura separada, es menos probable que la información se sobrescriba cuando se escribe mal memoria después del objeto, pero requiere costo de búsqueda y código adicional.
Una biblioteca de C++ bien hecha hará más cosas, pero con eso uno se puede dar una idea de que new/delete no son lo mismo que malloc/free.
Muchas implementaciones lo hacen simplemente porque ya existe y es fácil de usar.
Sin embargo, la aplicación puede reemplazar el operator new predeterminado de la biblioteca estándar por una implementación propia incluso en plataformas que no tienen una funcionalidad equivalente a la interposición de símbolos de ELF.
Yo habría esperado que una biblioteca de formateo diseñada para ser pequeña y poder imprimir cadenas y enteros fuera de alrededor de 50 bytes.
Para una cadena bastan unas 4 instrucciones: comprobar el terminador nulo, imprimir el carácter y saltar dos pasos hacia atrás.
Para un entero serían unas 20 instrucciones: verificar si es negativo, imprimir '-' e invertir el signo; poner 1000000000 en R1; dividir y guardar el residuo; sumar el ASCII '0'; imprimir el carácter; dividir R1 por 10; usar el residuo como entrada; repetir hasta que R1=0.
El punto flotante no se usa en muchos programas, así que debería compilarse solo cuando se necesita, y lo mismo para hexadecimal, punteros y relleno con ceros a la izquierda.
Cuando escribes código para un microcontrolador con 2KB de espacio de código, no metes una biblioteca de formateo de cadenas de 14KB.
No se puede hacer una biblioteca que al mismo tiempo tenga muchas funciones, sea rápida y además sea pequeña.
No veo muy bien en qué se diferencia esto de una queja general hecha en público, más que algo específico de fmt.
Solo el código de algoritmos como Dragonbox o Dragon4 ya supera el presupuesto de tamaño, así que que las funciones sean “opcionales” no importa demasiado.
Y eso es solo una de las aproximadamente 20 funcionalidades que la gente quiere.
Así otras personas podrían encontrar formas más inteligentes de meter más funcionalidades.
Si no, no entiendo bien el punto.
Esos requisitos son válidos, pero deberían resolverlos los compiladores para microcontroladores de especificación mínima, no la especificación del lenguaje.
Si necesitas que sea extremadamente pequeña a cambio de no soportar ni funciones básicas, claramente hay mejores opciones.
Si solo tienes 2KB de espacio de código, no deberías usar esto.
Por suerte, la mayoría de los microcontroladores modernos son mucho más grandes; por ejemplo, el esp32 empieza en 1MB, así que usar una biblioteca de formateo de 14KB es perfectamente razonable.
Haciendo un poco de promoción: incluso incluyendo una libc con búfer de salida, se puede tener
printf(Hello, World!\n");en un ejecutable de 1008 bytes: https://github.com/pts/minilibc686Claro que compararlo directamente sería comparar peras con manzanas.
Me parece interesante la parte que dice: “si un programa en C con una función main vacía ocupa 6kB en este sistema, {fmt} ahora solo agrega menos de 10kB al binario”.
Nunca he hecho una prueba así.
También importa qué biblioteca de C uses, y en menor medida si usas ELF u otro contenedor.
Siempre el problema es fmt.
Es muy gracioso que ahora pase exactamente lo mismo en .NET: si tocas suficientes números, especialmente formateo/parsing de punto flotante y decimal, el linker arrastra un montón de código relacionado con punto flotante y BigInt, y el tamaño del binario crece.
Muy interesante.
Me gustan estas optimizaciones que cambian la forma de pensar.
No sé si soy lento, pero me tomó un rato darme cuenta de que el “14k” del título significa 14kB.
Al menos históricamente, k es una abreviatura común de kB.