1 puntos por GN⁺ 2025-06-08 | 1 comentarios | Compartir por WhatsApp
  • La optimización de bajo nivel se puede implementar fácilmente en el lenguaje Zig
  • El compilador realiza bien las optimizaciones en la mayoría de los casos, pero a veces se obtiene mejor rendimiento cuando el programador comunica su intención con claridad
  • Zig admite generación de código de alto rendimiento y metaprogramación potente mediante la función de ejecución en tiempo de compilación (comptime)
  • En comparación con Rust, Zig permite optimizaciones más precisas gracias a las anotaciones y a una estructura de código explícita
  • En operaciones repetitivas como la comparación de cadenas, comptime puede generar mejor código ensamblador que una función convencional

Optimización y Zig

Como dice la famosa advertencia: "Todo es posible, pero lo interesante no se obtiene fácilmente", la optimización de programas siempre ha sido una de las principales preocupaciones de los desarrolladores. La optimización de código es indispensable para reducir costos de infraestructura en la nube, mejorar la latencia y simplificar sistemas. Este artículo se centra en explicar los conceptos de optimización de bajo nivel en Zig y los puntos fuertes del lenguaje.

¿Se puede confiar en el compilador?

  • En general, suele decirse "confía en el compilador", pero en la práctica hay casos en los que el compilador actúa distinto de lo esperado o incluso viola la especificación del lenguaje
  • Los lenguajes de alto nivel tienen limitaciones de rendimiento porque es difícil transmitir con claridad la intención (intent)
  • Los lenguajes de bajo nivel, por la explicitud del código, permiten que el compilador conozca la información necesaria para optimizar; por ejemplo, al comparar una función maxArray en JavaScript y en Zig, Zig transmite tipos claros, alineación y ausencia de alias en tiempo de compilación, no en tiempo de ejecución
  • Si se escribe la misma operación maxArray en Zig y Rust, se obtiene código ensamblador de alto rendimiento casi idéntico, pero cuanto mejor se exprese la intención, mejor será el resultado de la optimización
  • Aun así, no siempre se puede confiar en el rendimiento del compilador, por lo que en las secciones con cuellos de botella conviene revisar directamente el código y el resultado compilado para buscar formas de optimizar

El papel de Zig

  • Zig puede producir código optimizado sin información abstracta gracias a características como la explicitud precisa, funciones integradas abundantes, punteros y anotaciones, comptime y un Illegal Behavior bien definido
  • En Rust, el modelo de memoria garantiza por defecto que no haya alias entre argumentos, pero en Zig hace falta usar anotaciones como noalias de forma explícita
  • Si se toma como referencia únicamente LLVM IR, el nivel de optimización de Zig también es alto
  • Sobre todo, comptime (ejecución en tiempo de compilación) es una herramienta de optimización muy poderosa en Zig

¿Qué es comptime?

  • El comptime de Zig se usa para generación de código, inserción de valores constantes y creación de estructuras genéricas basadas en tipos, y cumple un papel importante en la mejora del rendimiento en tiempo de ejecución
  • Con comptime se puede implementar metaprogramación
  • A diferencia de los macros de C/C++ o del sistema de macros de Rust, comptime no tiene una sintaxis separada, sino que es código normal
  • El código comptime no modifica directamente el AST, sino que puede inspeccionar, reflejar y generar en tiempo de compilación para todos los tipos
  • La flexibilidad de comptime incluso ha influido en mejoras de otros lenguajes como Rust, y está integrada de forma natural en Zig

Límites de comptime

  • Algunas funciones de macros, como token-pasting, no pueden reemplazarse con comptime de Zig
  • Como Zig prioriza la legibilidad del código, no permite generar variables fuera de alcance ni definir macros de ese tipo
  • En cambio, el comptime de Zig ofrece una amplia variedad de usos de metaprogramación, como reflexión de tipos, implementación de DSL y optimización de parsing de cadenas

Optimización de comparación de cadenas con comptime

  • Una función común de comparación de cadenas puede implementarse en cualquier lenguaje, pero en Zig, cuando una de las dos cadenas es una constante conocida en comptime, se puede generar código ensamblador más eficiente
  • Por ejemplo, si una cadena siempre es "Hello!\n", se puede aprovechar una optimización que compare ese valor no byte por byte, sino en bloques más grandes
  • Para ello, usando comptime es posible generar en tiempo de compilación código de alto rendimiento con vectores SIMD, procesamiento por bloques y optimización de bytes restantes
  • Este enfoque permite implementar no solo comparaciones repetitivas de cadenas, sino también distintos mapeos basados en datos estáticos, tablas hash perfectas, parsers de AST y otros códigos orientados al rendimiento

Conclusión

  • Zig es muy adecuado para la optimización de bajo nivel y, gracias a su estructura de código explícita y a la potencia de comptime, permite implementar directamente el máximo rendimiento
  • Incluso frente a otros lenguajes como Rust, la capacidad de programación en tiempo de compilación y la explicitud de Zig representan una gran ventaja para el desarrollo de software de alto rendimiento
  • La capacidad de optimización de Zig seguirá siendo una ventaja competitiva cada vez más importante

1 comentarios

 
GN⁺ 2025-06-08
Comentarios en Hacker News
  • Lo que más me parece interesante de zig es la simplicidad de su sistema de build, la compilación cruzada y su búsqueda de una alta velocidad de iteración. Como soy desarrollador de juegos, el rendimiento importa, pero para la mayoría de los requisitos, la mayoría de los lenguajes ofrecen rendimiento suficiente. Así que no es el criterio principal al elegir un lenguaje. Se puede escribir código sólido en cualquier lenguaje, pero apunto a frameworks con visión de futuro que puedan mantenerse durante décadas. C/C++ ha sido la opción por defecto porque tiene soporte en todas partes, pero siento que zig podría llegar a estar a ese nivel
    • Por curiosidad probé ejecutar zig en un Kindle muy antiguo (Linux 4.1.15) y me sorprendió mucho lo pulido que está zig. Casi todo funcionó de inmediato, e incluso con un GDB viejo pude depurar bugs extraños. Yo también quedé fascinado con zig. Puedes ver la experiencia en detalle aquí
    • Siento que se puede escribir código sólido en la mayoría de los lenguajes, pero quiero código modular que siga siendo viable dentro de décadas. Me gusta Zig, pero creo que tiene desventajas en mantenimiento a largo plazo y modularidad. Zig es un lenguaje hostil al encapsulamiento. No es posible marcar como privados los miembros de un struct. Este comentario en un issue lo ejemplifica. La postura de Zig es que no debería existir una representación interna separada y que todos los usuarios deberían conocer la implementación interna, documentada y expuesta públicamente. Pero para mantener un contrato de API, es decir, el núcleo del software modular, deberías poder ocultar la implementación interna, y eso no se puede. Ojalá algún día Zig soporte campos private
    • Usé Rust por encima y me gustó. Pero escuché que era "malo", así que lo dejé un tiempo y ahora lo estoy usando otra vez. Me sigue gustando. No entiendo bien por qué tanta gente lo odia. La sintaxis fea de genéricos también existe en C# y Typescript. Y el Borrow Checker es fácil de entender si ya tienes experiencia con lenguajes de bajo nivel
    • Zig se siente como un Rust más simple y un Go mejor. Por otro lado, entre las herramientas construidas sobre zig, me encanta bun, al punto de que me impresiona de verdad. bun me ha simplificado muchísimo la vida. uv, basado en Rust, da una experiencia parecida
    • Estoy de acuerdo en que C/C++ es la base. Casi todo lo que ha intentado crear algo mejor que C ha terminado convirtiéndose en C++. Aun así, no hay que dejar de intentarlo. Rust y Zig son prueba de que todavía podemos esperar algo mejor. Yo voy a aprender más C++ a partir de ahora
  • Aunque a veces los compiladores de punta rompen la especificación del lenguaje, la suposición de Clang de que un bucle infinito termina sí es correcta según el estándar desde C11. En C11 se especifica lo siguiente: "si la expresión de control no es una expresión constante, y el bucle no realiza operaciones de entrada/salida, volatile, sincronización o atómicas, el compilador puede asumir que termina"
    • En C++ (hasta antes de C++26) esa regla aplica a todos los bucles, pero como dices, en C solo aplica a "bucles cuya expresión de control no sea una expresión constante". O sea, un bucle obviamente infinito como for(;;); sí debería ser realmente infinito, y loop {} en Rust también. Pero los desarrolladores de LLVM a veces actúan como si solo hicieran un compilador de C++, así que cuando Rust pide "por favor, un bucle infinito", LLVM aplica la lógica de "en C++ eso no pasa, ¡a optimizar!" y causa problemas. En otras palabras, se aplicó la optimización equivocada al lenguaje equivocado
  • Aunque no exista la función comptime, comparar cadenas inline y desenrollarlas sigue siendo perfectamente posible en C. Aquí hay un ejemplo relacionado
    • ¡Buena observación! El primer ejemplo era demasiado simple. Un mejor ejemplo sería el autómata de sufijos en tiempo de compilación. Además, el código de godbolt enlazado arriba en realidad muestra uno de dos casos que no deberían hacerse
  • No creo que la parte donde se dice que el bytecode generado por V8 para el código JavaScript de ejemplo es ineficiente sea una buena comparación. A Zig y Rust se les pide compilar apuntando a un entorno muy moderno, mientras que a V8 no se le fuerza ese tipo de optimizaciones. De hecho, los JIT modernos también pueden vectorizar si las condiciones lo permiten. Y la mayoría de los lenguajes modernos manejan optimizaciones de strings de forma parecida. Como referencia, también hay un ejemplo en C++
    • Comparar JS con Zig es básicamente como comparar manzanas con ensalada de frutas. El ejemplo de Zig usa arreglos con tipos y tamaños fijos, mientras que JS usa código "genérico" donde pueden entrar distintos tipos en tiempo de ejecución. Por eso, si le das bien la información de tipos a JS, el JIT puede generar bucles mucho más rápidos, aunque no necesariamente vectorizados. En la práctica no se usan tanto TypedArray, porque el costo de inicialización es alto y solo valen la pena si se van a reutilizar bastante. Además, el artículo dice que el código JS quedó inflado, pero gran parte de eso viene de que el JIT no puede confiar en las comprobaciones de longitud del arreglo y mete guards; en realidad casi todo el mundo escribe bucles como i < x.length, lo que sí permite optimización por parte del JIT. En ese sentido es un poco quisquilloso, aunque la diferencia sea pequeña
    • También se puede cambiar el target de los ejemplos de godbolt de Rust y Zig a CPUs más antiguas. No había pensado en esa limitación del lado de JS. Y el ejemplo de C++ muestra qué tan buen código genera clang. Aun así, por ahora el assembly no me deja del todo satisfecho, incluso considerando que zig se construye para una CPU específica. Si hubiera un ejemplo de port del Autómata de Sufijos en tiempo de compilación a C++, sería realmente interesante. Ese es un caso de uso real de comptime que un compilador de C++ no puede predecir
  • Me hace ruido la frase "los lenguajes de alto nivel carecen del 'intent' que sí tienen los de bajo nivel". Yo diría más bien que una ventaja de los lenguajes de alto nivel es justamente poder expresar la intención de forma más detallada y de más maneras
    • También estoy de acuerdo. En esencia, la diferencia entre un lenguaje de alto nivel y uno de bajo nivel es que en uno de alto nivel expresas intención, mientras que en uno de bajo nivel tienes que exponer el mecanismo mismo de implementación
    • Aquí, por "intención" no se habla de intención de negocio como "calcular el impuesto de esta compra", sino de algo más cercano a qué le estás diciendo a la computadora que haga, como "desplaza este byte tres posiciones a la izquierda". Por ejemplo, un código como purchase.calculate_tax().await.map_err(|e| TaxCalculationError { source: e })?; está lleno de intención, pero es imposible predecir cómo se verá finalmente en código máquina
  • Me gusta muchísimo el modelo de allocator de Zig. Ojalá en Go se pudiera usar algo como un allocator por request en vez de GC
    • En Go no es imposible usar allocators personalizados y arenas, pero la usabilidad es muy mala y es difícil emplearlos correctamente. Tampoco hay una forma a nivel de lenguaje de expresar o hacer cumplir reglas de ownership. Al final terminas escribiendo algo que es apenas C con una sintaxis un poco distinta, y sin GC sería incluso más peligroso que C++
  • Entiendo eso de "me gusta la verbosidad de Zig", pero honestamente suena un poco raro. Mientras que C es descuidado en varias partes, Zig a veces exige demasiado "ruido de anotaciones" (sobre todo en casts explícitos de enteros dentro de fórmulas matemáticas). Ver este artículo. En rendimiento, cuando zig supera a c, normalmente se debe a que Zig usa configuraciones más agresivas de optimización en LLVM (-march=native, optimización de programa completo, etc.). De hecho, en C también se pueden usar pistas de optimización como unreachable mediante extensiones del lenguaje, y Clang también es muy agresivo con el constant folding. O sea, muchas veces la diferencia entre comptime en Zig y la generación de código en C viene de la configuración de optimización del compilador. TL;DR: si C va lento, primero revisa los flags del compilador. Al final, el núcleo de la optimización sigue siendo LLVM
    • Si el ejemplo es el de los casts, incluso se podría crear una función para encapsular el cast y así mejorar la reutilización y dejar más clara la intenció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);
      }
      
      De esta forma también sale exactamente el mismo assembly, pero es más reutilizable y claro
    • Las ideas de Zig son interesantes, y en comparación con lo que esperaba del artículo original, el énfasis estaba más en comptime y en la compilación de programa completo. Coincido con eso. Como referencia, Virgil ya ofrecía desde 2006 uso del lenguaje completo en tiempo de compilación y soporte para compilación de programa completo. Virgil no apunta a LLVM, así que la comparación de velocidad al final es una comparación de backend. Gracias a este enfoque, Virgil puede hacer optimizaciones muy potentes como devirtualizar llamadas a métodos por adelantado, eliminar al máximo campos y objetos no usados, propagar constantes incluso a objetos de heap asociados a campos, y especializar completamente
    • Pensando en el uso futuro de la IA, creo que los lenguajes cada vez más explícitos y verbosos se van a volver más comunes. Más allá de si uno programa con IA o si eso está bien, muchos desarrolladores van a preferir ayuda de IA y los lenguajes van a cambiar en esa dirección
    • Si entra un nuevo backend de x86, creo que en adelante sí podríamos ver casos donde la diferencia de rendimiento entre C y Zig provenga del proyecto Zig en sí
    • En cuanto a los casts explícitos de enteros, pronto debería llegar una mejora para hacer eso más limpio. Ver esta discusión relacionada
  • Hacer benchmarks como si "C es más rápido que Python" a nivel de lenguaje completo no tiene mucho sentido, pero sí hay funciones de un lenguaje que se vuelven grandes barreras para la optimización. Si usas el lenguaje adecuado, tanto el desarrollador como el compilador pueden expresar la intención de forma natural y eficiente
  • La sintaxis del for en Zig me parece demasiado desordenada. Eso de poner dos listas en paralelo y alinear posiciones ya de solo verlo me lastima la vista. Creo que los lenguajes recientes se están equivocando al meter demasiada sintaxis "mágica" y símbolos especiales. No sé si podría pasar horas mirándolo
    • Este patrón de recorrer dos arreglos a la vez es muy común en código de bajo nivel, y recorrer en paralelo también. Por eso me parece adecuado que Zig lo soporte de forma clara y natural. Me da curiosidad por qué te lastima la vista
  • La optimización es muy importante. Su efecto se vuelve aún mayor con el paso del tiempo
    • Aunque claro, eso solo aplica si el software realmente llega a usarse