1 puntos por GN⁺ 2025-06-08 | 1 comentarios | Compartir por WhatsApp
  • 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
  • comptime permite 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 x el máximo elemento a elemento de dos arreglos con la forma x[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: x no es un alias de otros punteros
    • *align(64): alineación de 64 bytes
    • [65536]f64: tamaño del arreglo y tipo de elemento
    • const: 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

  • comptime de 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 comptime es 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 comptime es parecido al de las macros, pero su funcionamiento es distinto
  • Algunas macros reemplazan texto crudo y otras modifican directamente el AST del programa
  • comptime de 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 comptime de 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 print de Zig parsea la cadena de formato con comptime para 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 false si 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 comptime
    • fn 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 comptime se 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 comptime sigue 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 @bitCast a un tipo entero creado con std.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

  • comptime de 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 rango inline 0...100 a staticFn(comptime_val) y procesa el resto con runtimeFn(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

  • comptime de Zig reemplaza plantillas, macros, genéricos y generación manual de código
  • En otros lenguajes se puede hacer algo similar, pero en Zig comptime está 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

 
GN⁺ 2025-06-08
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.

    • Me gusta Zig, pero creo que la mantenibilidad a largo plazo y la modularidad son, de hecho, uno de sus puntos más débiles.
      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.
    • Por diversión probé correr Zig en un viejo Kindle sobre un Linux 4.1.15 recortado, y fue una experiencia bastante interesante; quedé pleasantly surprised con la madurez de Zig.
      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
    • Había usado un poco Rust y me había gustado, pero escuché que tenía mala fama y lo dejé un tiempo; al volver a probarlo, todavía me gusta.
      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.
    • Zig parece un Rust más simple y un Go mejor.
      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.
    • Me da curiosidad cómo funcionaría Zig en consolas.
      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.

    • C++ dice eso para todos los bucles hasta que salga C++26, pero, como señalas, C en sí no lo hace; solo aplica cuando “la expresión de control no es una expresión constante”.
      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 opaco loop {} 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.

  • 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

    • En general se siente como comparar manzanas con ensalada de frutas, pero sirve para mostrar la diferencia entre los usos de JS y Zig
      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 x e y pueden ser cualquier objeto
      En 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 65536 sea igual al largo de los dos arreglos
      Aun así, en la práctica nadie escribiría un bucle for de esa forma, sino con i < x.length, y en ese caso el JIT al menos elimina una comprobación de arreglo
    • En los ejemplos de Godbolt de Rust y Zig se puede cambiar el target a una CPU más antigua
      Perdó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

    • Estoy de acuerdo y, más aún, creo que la diferencia fundamental entre los lenguajes de alto nivel y los de bajo nivel es que en los de alto nivel expresas la intención, mientras que en los de bajo nivel no tienes más remedio que expresar el mecanismo subyacente
    • Creo que aquí la intención de la que se habla no es “calcula la tasa de impuestos de esta compra”, sino algo más parecido a “desplaza este byte tres posiciones a la izquierda”
      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 final
  • Esa sintaxis de bucle for es 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

    • Esos arreglos son un patrón muy común en código de bajo nivel, sin importar el lenguaje, y recorrerlos en paralelo también lo es
      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

    • En Go también son posibles los asignadores personalizados y las arenas, y de hecho existen, pero su usabilidad es muy mala y son difíciles de usar correctamente
      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=native y optimización de programa completo, y compila todo el código Zig del proyecto como una sola unidad de compilación
    Casi todos los “trucos”, como usar unreachable como pista de optimización, también son posibles en C, aunque a veces requieren extensiones no estándar del lenguaje
    Los 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

    • En cuanto al ejemplo de casteo, se puede envolver el cast en una función
      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
    • Zig tiene ideas interesantes, y pensé que el artículo se enfocaría más en la optimización de bajo nivel, pero al final el punto fue que “comptime y la compilación de programa completo son excelentes”
      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
    • Cuando salga el nuevo backend x86, quizá podamos ver qué parte de la diferencia de rendimiento entre C y Zig puede atribuirse solo al propio proyecto Zig
    • En cuanto a los casteos explícitos de enteros, parece que pronto habrá una limpieza: https://ziggit.dev/t/short-math-notation-casting-clarity-of-...
    • En los próximos años, creo que la inclinación adicional en los lenguajes en general se moverá con bastante fuerza hacia lenguajes más verbosos y explícitos
      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