Optimización de bajo nivel y Zig
(alloc.dev)- La optimización de bajo nivel consiste en hacer que el compilador entienda mejor la intención y las restricciones del código, y Zig encaja bien con este objetivo porque facilita explicitar tipos, alineación, aliasing e información en tiempo de compilación
- Incluso los compiladores optimizadores como LLVM no siempre generan el mejor código, por lo que en las secciones críticas sigue siendo necesario revisar el código generado y ajustar el código fuente
- Zig puede generar código vectorizado más pequeño que el ejemplo en JavaScript al pasar en tiempo de compilación
noalias,align, tamaños fijos de arreglos y tipos de elementos comptimepermite ejecutar código Zig normal en tiempo de compilación y habilita metaprogramación como generación de constantes, implementación de genéricos, reflexión de tipos y optimización de comparaciones de cadenas- La fortaleza de Zig está en la ejecución en tiempo de compilación integrada al lenguaje, más que en macros que modifican directamente el AST; incluso algunos valores de runtime pueden despacharse a funciones especializadas en tiempo de compilación
Por qué hay que confiar en el compilador, pero verificar
- La optimización no es solo una técnica para crear programas rápidos: también está ligada a reducir costos, mejorar la escalabilidad y mantener la simplicidad del sistema
- Los compiladores modernos producen resultados impresionantes con backends como LLVM, pero en algunas situaciones siguen generando código subóptimo
- Los lenguajes de bajo nivel son rápidos no solo porque tienen menos sobrecarga de garbage collection o de intérprete, sino porque permiten expresar más información de intención que el compilador puede entender
- El compilador no puede cambiar el algoritmo ni el paradigma de programación en sí; por lo general realiza optimizaciones dentro de un alcance limitado, como los bucles
Ejemplo de máximo de arreglos en JavaScript y Zig
- El ejemplo en JavaScript guarda en
xel máximo elemento a elemento de dos arreglos con la formax[i] = y[i] > x[i] ? y[i] : x[i] - Para una persona es un código claro, pero el bytecode generado por V8 está inflado
- El ejemplo en Zig especifica con más detalle en los argumentos de la función la información necesaria para optimizar
noalias x:xno es un alias de otros punteros*align(64): alineación de 64 bytes[65536]f64: tamaño del arreglo y tipo de elementoconst: argumento de solo lectura
- Gracias a esta información, el compilador puede generar mejor código; en el ejemplo se genera ensamblador vectorizado
- Un código equivalente en Rust también genera un ensamblador casi idéntico
Puntos donde Zig favorece la optimización y sus límites
- Zig permite expresiones verbosas para pasarle mucha información de código a LLVM
- Los principales elementos que Zig ofrece en relación con la optimización son los siguientes
- El modelo de memoria de Rust permite que el compilador asuma siempre que los argumentos de una función no crean alias, pero en Zig hay que indicarlo explícitamente
- Si el compilador no puede saber si los argumentos de una función Zig no tienen alias, una función Zig sin anotaciones puede ser más lenta que una función Rust
- Incluso tomando como referencia solo LLVM IR bien anotado, Zig obtiene buenos resultados, pero su mayor fortaleza está en la ejecución en tiempo de compilación
El rol de comptime
comptimede Zig es una función para la generación de código en tiempo de compilación- Las tareas posibles en tiempo de compilación incluyen
- Generar constantes e incluirlas en el binario
- Evitar escribir repetidamente la misma estructura de hashmap para distintos tipos de datos
- Guiar optimizaciones para eliminar código innecesario a partir de datos conocidos en tiempo de compilación
- Inspeccionar, reflejar y generar tipos para implementar genéricos
- El código
comptimees código Zig normal que se ejecuta en tiempo de compilación y no puede tener efectos secundarios como IO de red - La máquina de emulación en tiempo de compilación coincide con el target de compilación
- Casi todo el código Zig puede ejecutarse en tiempo de compilación con
comptime, y todos los tipos pueden inspeccionarse, reflejarse y generarse en tiempo de compilación
Diferencias con las macros
- El propósito de
comptimees parecido al de las macros, pero su funcionamiento es distinto - Algunas macros reemplazan texto crudo y otras modifican directamente el AST del programa
comptimede Zig no modifica directamente el AST ni tiene funciones como macros de pegado de tokens- Como Zig busca ser un lenguaje legible, no encaja con un estilo de macros que crea o modifica variables en scopes no relacionados
- Tareas que las macros pueden hacer pero que
comptimede Zig no puede hacer directamente- Definir otras macros
- Modificar el AST
- Implementar directamente minilenguajes o DSL
- Aun así, en Zig se pueden crear DSL, y la función
printde Zig parsea la cadena de formato concomptimepara construir el grafo de funciones que serializará los datos - Algunos ejemplos son TigerBeetle account testing DSL, comath y zilliam
Optimización de comparación de cadenas con comptime
- Una comparación de cadenas común devuelve
falsesi las longitudes difieren y, si son iguales, compara cada byte en orden - Este método tiene que leer y comparar bytes de ambas cadenas
- Como muchas veces una de las cadenas ya se conoce en tiempo de compilación, en Zig se puede exigir que un argumento sea
comptimefn staticEql(comptime a: []const u8, b: []const u8) bool
- Al comparar con una cadena estática como
"Hello!\n", el compilador genera código compuesto por una comparación de longitud y comparaciones constantes de cada byte - El objetivo de esta sección no es solo mostrar una optimización que el compilador puede hacer automáticamente, sino demostrar que con
comptimese puede forzar la transformación y abrir oportunidades que el compilador no ve
Comparaciones en unidades más grandes y uso de SIMD
- Una comparación simple de cadenas con
comptimesigue realizando comparaciones byte por byte - La versión mejorada define el tamaño del bloque de comparación usando
std.simd.suggestVectorLength(u8)o@sizeOf(usize) - Primero verifica la longitud de la cadena y luego calcula cuántos bloques grandes se pueden comparar y cuántos bytes restantes quedan
- Cada bloque se convierte con
@bitCasta un tipo entero creado constd.meta.Int(.unsigned, block_len * 8)y se compara - Los bytes restantes también se comparan con un tipo entero separado
- El ensamblador generado para el ejemplo
"Hello, World!\n"usa registros más grandes y reduce la cantidad de saltos condicionales - En comparaciones de cadenas más largas se genera ensamblador que usa registros SIMD más grandes
Usar juntos valores de runtime y especialización en tiempo de compilación
comptimede Zig no se limita a datos conocidos en tiempo de compilación- En casos simples, se pueden generar varios procedimientos en tiempo de compilación y hacer despacho dinámico al procedimiento adecuado según el valor de runtime
- El código de ejemplo, en
switch (runtime_val), envía los valores del rangoinline 0...100astaticFn(comptime_val)y procesa el resto conruntimeFn(runtime_val) - Si no se quiere aumentar el tamaño del binario, se puede hacer fallback a una implementación completamente de runtime
Conclusión
comptimede Zig reemplaza plantillas, macros, genéricos y generación manual de código- En otros lenguajes se puede hacer algo similar, pero en Zig
comptimeestá integrado de forma más natural en el lenguaje - Zig facilita escribir código de alto rendimiento en situaciones realmente útiles, en contraste con el Turing tar-pit, donde todo es posible pero las tareas interesantes son difíciles
- Sobre las guerras de lenguajes, queda tanto la perspectiva amplia de que la completitud de Turing es suficiente como la postura de que, al mismo tiempo, las personas pueden tener lenguajes preferidos
- Frases que tratan al lenguaje en sí como objeto de benchmark, como “C es más rápido que Python”, pueden ser incorrectas; el verdadero objeto de benchmark no es el lenguaje, sino código e implementaciones específicos
1 comentarios
Opiniones de Hacker News
Lo que más me atrae de Zig es que apunta a la facilidad del sistema de build, la compilación cruzada y ciclos de iteración rápidos.
Como desarrollador de juegos tengo requisitos de rendimiento, pero creo que la mayoría de los lenguajes ofrecen rendimiento suficiente, así que no es mi principal criterio para elegir un lenguaje.
Se puede escribir código potente en cualquier lenguaje, pero lo clave es elegir un framework con alta preparación para el futuro que permita mantener código modular durante décadas.
C/C++ era la respuesta por defecto porque está soportado en todas partes, y siento que Zig podría llegar a ese nivel.
Zig es hostil a la encapsulación y no permite hacer privados los miembros de una estructura: https://github.com/ziglang/zig/issues/9909#issuecomment-9426...
La cita clave plantea que “los campos privados y los getters/setters son un antipatrón popularizado por Java; los campos son datos que existen, así que hay que nombrarlos con cuidado y documentarlos como parte de la API pública”.
Si no se puede ocultar la representación interna, es difícil crear correctamente un contrato de API, que es la base de la modularidad del software, y uno debería poder cambiar la representación interna sin romper el código de los usuarios.
La postura de Zig parece ser que no debería existir algo como una representación interna separada, sino que la representación misma debe ser pública, documentada y garantizada; espero que algún día reviertan esa decisión y soporten campos privados.
Muchas cosas funcionaron de inmediato, e incluso pude depurar bugs raros con un GDB antiguo.
A mí también me convenció Zig, y escribí sobre eso aquí: https://news.ycombinator.com/item?id=44211041
No entiendo muy bien por qué lo odian tanto.
Los genéricos feos también existen en C# o TypeScript, y el borrow checker es un concepto razonable si has hecho trabajo de bajo nivel.
Entre las herramientas creadas sobre Zig, la que de verdad me impresiona es bun, y mi vida se volvió mucho más simple desde que uso bun.
Algo parecido podría decirse de uv, hecho en Rust.
Normalmente a las consolas no les gusta nada que no sea C/C++, pero como Zig puede traducirse a C, quizá no quede completamente descartado.
Sobre la afirmación de que “incluso los compiladores modernos rompen la especificación del lenguaje (Clang asume que todos los bucles sin efectos secundarios terminan)”, no dudo que los compiladores a veces rompan la especificación, pero en ese caso Clang está en lo correcto, al menos desde C11.
C11 dice que una implementación puede asumir que termina un bucle cuya expresión de control no sea una expresión constante y que no realice entrada/salida, accesos volatile, sincronización ni operaciones atómicas.
Por eso, en C, un bucle infinito simple como
for (;;);debería compilarse realmente como un bucle infinito, y también debería hacerlo el menos opacoloop {}de Rust.Pero LLVM está hecho por gente que a veces olvida que no siempre están creando un compilador de C++, así que hubo un punto en el que, cuando Rust decía “dame un bucle infinito”, LLVM respondía “según C++ eso no existe, así que lo voy a optimizar”, y eso fue una aplicación incorrecta a otro lenguaje.
No hace falta comptime para inlinear y desplegar una comparación de strings.
También se puede en C: https://godbolt.org/z/6edWbqnfT
Corregí el typo.
Un mejor ejemplo es https://github.com/RetroDev256/comptime_suffix_automaton
De todos modos, el código de godbolt que enlazaste también muestra en la práctica uno de los dos ejemplos poco ideales.
No creo que comparar el ejemplo de JavaScript con los de Zig/Rust sea una buena comparación
A los compiladores de Zig y Rust se les pide elegir una CPU objetivo muy moderna, pero V8 no parece estar en las mismas condiciones
Un JIT optimizador también puede vectorizar si se dan las condiciones adecuadas
Como referencia, la mayoría de los lenguajes modernos realizan la misma optimización con strings, y aquí hay un ejemplo en C++: https://godbolt.org/z/TM5qdbTqh
El ejemplo de Zig usa arreglos de tamaño fijo y tipo conocido, mientras que el código JS es “genérico” en tiempo de ejecución, así que
xeypueden ser cualquier objetoEn JS es normal pagar ese costo, pero irónicamente, en este ejemplo concreto se le puede comunicar mejor la información de tipos al JIT
Si se hace que esta función se llame siempre con Float64Array del mismo tamaño, el JIT puede saberlo y generar un bucle más rápido. Aunque no sea vectorización, mejora mucho
Eso sí, los arreglos tipados tienen un costo alto de inicialización, así que en la práctica no se usan mucho salvo que asignes una vez un arreglo tipado grande y lo reutilices muchas veces
Además, el artículo decía que el bytecode de JS se infló bastante; probablemente buena parte sea por los guards que agrega el JIT al no poder garantizar que
65536sea igual al largo de los dos arreglosAun así, en la práctica nadie escribiría un bucle
forde esa forma, sino coni < x.length, y en ese caso el JIT al menos elimina una comprobación de arreglotargeta una CPU más antiguaPerdón por no haber considerado las limitaciones del objetivo de JS
El ejemplo de C++ que enlazaste es una buena muestra de lo que Clang puede hacer con C++, pero incluso considerando que Zig compila para una CPU específica, el ensamblador generado se ve algo decepcionante
Sería muy interesante ver un port a C++ de https://github.com/RetroDev256/comptime_suffix_automaton
Ese es un uso de comptime que un compilador de C++ no puede inferir limpiamente
No sé si la frase “a los lenguajes de alto nivel les falta la riqueza de intención de los lenguajes de bajo nivel” sea realmente correcta
La expresión de intención no parece ser un elemento del espectro alto nivel/bajo nivel; más bien, mientras más formas haya de expresar la intención con detalle, más cerca debería estar de un lenguaje de alto nivel
Es una intención sobre lo que quieres hacer que haga la máquina, más que sobre lo que intentas lograr
Código como
purchase.calculate_tax().await.map_err(|e| TaxCalculationError { source: e })?;está lleno de intención, pero no puedes saber qué código máquina saldrá al finalEsa sintaxis de bucle
fores horrible¿Hay dos listas una al lado de la otra, y la posición de un elemento en una lista corresponde a la posición de un elemento en la otra?
Me duelen los ojos con solo verlo
Siento que los lenguajes modernos tomaron el camino equivocado cuando empezaron a agregar toda clase de “magia” al parser y a esparcir pequeños símbolos por todo el código
No es algo que me den ganas de mirar durante horas
Por eso es natural que Zig ofrezca una sintaxis que hace exactamente eso de forma cómoda y a la vez deja claro qué está pasando
Personalmente creo que está bastante bien logrado, pero me da curiosidad por qué te lastima la vista
Me gusta mucho el modelo de asignadores de Zig
Ojalá en Go también se pudieran usar cosas como asignadores por request en lugar de garbage collection
Como el lenguaje en sí no tiene una forma de expresar y hacer cumplir reglas de ownership, al final terminas usando C con una sintaxis apenas distinta y esperando que todo salga bien
Sin garbage collection, incluso C++ es mucho más seguro que Go
El modelo de memoria de Rust permite que el compilador asuma siempre que los argumentos de una función nunca hacen referencia con alias, pero en Zig hay que indicarlo manualmente
Estoy evitando ese tipo de especificación de aliasing
Poca gente lo entiende, y si se usa mal puede introducir bugs difíciles de entender en el código
Sobre la afirmación de que la flexibilidad de comptime de Zig trajo buenas mejoras a otros lenguajes: la ejecución de funciones en tiempo de compilación y las funciones que reciben argumentos constantes fueron introducidas por D en 2007, e hicieron que varios lenguajes adoptaran algo parecido
https://dlang.org/spec/function.html#interpretation
Me gusta Zig, pero decir “me gusta lo verboso que es Zig” suena raro
Es cierto que C es demasiado laxo en varios rincones, pero Zig, según los criterios actuales, a veces se va un poco de más hacia el lado opuesto y termina generando demasiado ruido de anotaciones de tipo que parecen comentarios
En particular, pasa con los casteos explícitos de enteros en expresiones; escribí un poco sobre eso aquí: https://floooh.github.io/2024/08/24/zig-and-emulators.html
En términos de rendimiento, si el código Zig es más rápido que código C similar, por lo general se debe a la configuración más agresiva de optimización de LLVM de Zig
Por ejemplo, Zig usa por defecto
-march=nativey optimización de programa completo, y compila todo el código Zig del proyecto como una sola unidad de compilaciónCasi todos los “trucos”, como usar
unreachablecomo pista de optimización, también son posibles en C, aunque a veces requieren extensiones no estándar del lenguajeLos compiladores de C, especialmente Clang, también hacen constant folding de forma muy agresiva, y pueden reducir grandes regiones de código que se pueden plegar a constantes incluso con pilas de llamadas profundas
Así que, si solo se mira la generación de código, muchas veces no hay gran diferencia con el comptime de Zig
La ventaja de comptime es que no vuelve silenciosamente a código en tiempo de ejecución, y el código que no es comptime también queda sujeto a las mismas optimizaciones de constant folding que en C
Por ejemplo, si una función “pura” no comptime se llama con argumentos constantes, el compilador igual reemplaza la llamada a la función por su resultado
En resumen, si el código C es más lento que el código Zig, conviene revisar la configuración del compilador de C. Al final, todo el trabajo pesado de optimización ocurre debajo, en LLVM
fn signExtendCast(comptime T: type, x: anytype) T { const ST = std.meta.Int(.signed, @bitSizeOf(T)); const SX = std.meta.Int(.signed, @bitSizeOf(@TypeOf(x))); return @bitCast(@as(ST, @as(SX, @bitCast(x)))); }export fn addi8(addr: u16, offset: u8) u16 { return addr +% signExtendCast(u16, offset); }Compila al mismo ensamblador, es reutilizable y deja clara la intención
Estoy de acuerdo con eso
Virgil permitía usar todo el lenguaje en tiempo de compilación desde 2006 y también soportaba compilación de programa completo
Pero como Virgil no apunta a LLVM, la comparación de velocidad termina siendo una comparación entre los backends de los dos compiladores
Virgil se apoya mucho en el análisis de alcanzabilidad y en las optimizaciones de especialización que permite este modelo de compilación
Por ejemplo, desvirtualiza agresivamente las llamadas a métodos, elimina campos y objetos inalcanzables, promueve constantes a través de campos y objetos del heap, y monomorfiza por completo el código polimórfico
Simplemente porque eso los hace más fáciles de manejar para la IA
Si usar IA para programar es una buena idea, y hasta qué punto lo es, es otro tema; pero muchos desarrolladores lo creen, y los lenguajes van a intentar adaptarse a ellos