Avances en la reescritura del compilador Roc de Rust a Zig
(rtfeldman.com)- Para resolver fallas estructurales de la implementación existente, reescribieron 300.000 líneas de Rust en Zig y, tras 487 días, alcanzaron paridad funcional; apuntan a la primera versión numerada estable, 0.1.0, para más adelante este año
- El nuevo compilador admite carga de código en caliente y compilación cruzada reproducible, interpolación de cadenas dentro de pattern matching, y elimina las asignaciones en heap en el ruteo HTTP; además redujo el tamaño wasm de
Rocci Birda 31 KB, menos de la mitad - Las razones clave para elegir Zig fueron los tiempos de compilación, los allocators granulares y el control de disposición de datos, un ecosistema adecuado para desarrollar compiladores y la verificación de código con memoria no segura; la compilación incremental de Zig 0.17.0 recompila unas 460.000 líneas en 35 ms
- En la clasificación real de bugs, el compilador en Rust tuvo 21 casos de corrupción de memoria y el compilador en Zig tuvo 10, aunque la mayoría se debió a generación de código incorrecta; los errores de seguridad de memoria del propio compilador Zig fueron 2 use-after-free que corrompían nombres de archivo
- Zig encajó bien con estructuras de datos sin punteros, deserialización sin parseo y reutilización del serializador de LLVM bitcode, pero la experiencia de desarrollo de Rust fue mejor en liberación automática de memoria en tests, polimorfismo, campos privados de structs, detección de código muerto y compatibilidad entre releases
Reescritura que alcanzó paridad funcional
- El equipo del compilador de Roc reescribió durante alrededor de un año y medio 300.000 líneas de Rust en Zig y alcanzó paridad funcional con el compilador existente
- La paridad funcional es un hito importante, pero no es una versión estable; la 0.1.0 del nuevo compilador está prevista para más adelante este año
- Ahora se puede compilar el juego de WASM-4 de 2024 Rocci Bird con el nuevo compilador
- El juego completo tiene menos de 1.000 líneas de código Roc
- El código fuente actualizado es más conciso que el original
- El binario wasm que genera
roc build --opt=sizepesa 31 KB, menos de la mitad del resultado del compilador anterior
- En la página de inicio, con un compilador WebAssembly de 2,5 MB, se pueden escribir y ejecutar programas básicos de Roc en el navegador
- La reescritura tomó 487 días, 476 días más que los 11 días que le llevó a Bun trasladar unas 500.000 líneas de Zig a Rust
- Bun hizo un port directo, mientras que Roc fue más bien una reescritura que cambió de forma importante varias estructuras y funcionalidades del compilador
- Por eso es difícil comparar directamente el tamaño o la duración de ambos codebases solo en función de Rust y Zig
Experiencia de desarrollo que ofrece el nuevo compilador
-
Carga de código en caliente y compilación cruzada
- Después de ejecutar el servidor con
roc server.roc, si se modifica el código, el nuevo código se aplica automáticamente a partir de la siguiente solicitud - Es un comportamiento común en lenguajes interpretados como Python, pero poco habitual en lenguajes compilados de alto rendimiento como Roc
- Funciona de la misma manera no solo en servidores web, sino también en juegos 2D sencillos
- Al desplegar,
roc build server.rocgenera un binario autónomo con optimizaciones de LLVM aplicadas - Con
roc build --target=x64muslse puede compilar de forma cruzada un binario estático para Alpine Linux - Si se ingresan los mismos bytes de código fuente, cualquier sistema de build, incluido Mac, genera los mismos bytes de salida
- Después de ejecutar el servidor con
-
Interpolación de cadenas dentro de pattern matching
- Admite interpolación de cadenas como
"/users/${id}"dentro de pattern matching - A diferencia del ruteo estilo Express, no parsea templates de cadenas en tiempo de ejecución, sino que el compilador lo procesa directamente
- Puede hacer match a la vez sobre el método HTTP y la ruta, y también ramificar rutas anidadas o valores por defecto mediante patrones
- El código completo de manejo de solicitudes HTTP del ejemplo verifica la seguridad de tipos en compilación y no realiza ninguna asignación en heap
- Implementó ruteo de solicitudes HTTP sin asignaciones usando ejecución en tiempo de compilación de funciones puras, y la sintaxis se puede probar en el compilador WebAssembly de la página de inicio
- Admite interpolación de cadenas como
Por qué lo reescribieron desde cero
- A diferencia de Rust, C o Zig, Roc no es un lenguaje de sistemas y usa gestión automática de memoria basada en conteo de referencias
- Evita las pausas de un garbage collector de rastreo
- Aprovecha la optimización Perceus y las modificaciones oportunistas al estilo Koka
- A diferencia de los lenguajes no sistémicos típicos, no asigna heap por cada captura de closure, sino que usa defuncionalización polimórfica mediante especialización de conjuntos lambda
- La defuncionalización permite varias optimizaciones posteriores, como el inlining en lenguajes funcionales, pero hacer correcta la implementación existente resultó extremadamente difícil
- Mediante el prototipo en OCaml de Ayaz Hafiz comprobaron que el problema era una falla estructural que atravesaba varias etapas del compilador y que, para resolverlo, había que reescribir la mayor parte del compilador
- Otros colaboradores también planeaban reescribir varias partes por distintos motivos, así que pasaron a evaluar una reescritura completa en vez de un enfoque tipo barco de Teseo, reemplazando gradualmente casi todo
- En proyectos de compiladores exitosos es común reescribir desde cero, por ejemplo para self-hosting; también existe el caso de la reescritura de TypeScript trasladada a Go, un lenguaje distinto del propio
- Aunque la política del compilador de Roc es no hacer self-hosting, en esta ocasión consideraron que los beneficios de la reescritura superaban los costos conocidos
Criterios para elegir Zig y no volver a Rust
- El equipo ya usaba Zig para varias funcionalidades básicas de la biblioteca estándar y los únicos lenguajes de sistemas que conocían lo suficiente eran Rust y Zig, así que solo evaluaron seriamente esos dos
- Cada proyecto tiene un lenguaje más adecuado; seguir usando Rust en otros trabajos y elegir Zig para Roc no es contradictorio
-
Tiempos de compilación
- Los builds de
cargoen Rust tomaban mucho tiempo incluso con compilación incremental y seguían siendo una molestia importante a medida que crecía el codebase - Esperaban que al pasarse a Zig los builds fueran mucho más rápidos
- Los builds de
-
Control de memoria y disposición de datos
- En cada etapa de compilación usan distintos allocators, en particular arena allocators, y aprovechan ampliamente la disposición de array of structs (SoA)
- El ecosistema de Rust en general asume un único allocator global, mientras que el ecosistema de Zig toma como base pasar allocators granulares y también ofrece soporte SoA de forma estándar
-
Alcance del ecosistema necesario
- El ecosistema completo de Rust es más grande que el de Zig, pero en ambos ecosistemas no había muchos paquetes relacionados con las necesidades específicas de Roc
- Había más funcionalidades de nicho necesarias del lado de Zig, como código que genera bitcode rápidamente en vez de envolver la biblioteca C++ de LLVM
-
Soporte para código con memoria no segura
- Las 300.000 líneas del compilador existente en Rust tenían alrededor de 1.200 usos de
unsafe - Como comparación, el propio compilador de Rust usa alrededor de 40.000
unsafeen 3,5 millones de líneas - En un compilador que genera código máquina, las operaciones de memoria no segura son una parte importante del trabajo
- Rust sigue el modelo de aislar bloques
unsafepoco frecuentes y verificarlos con Miri o Valgrind, pero en Rocunsafeno era poco frecuente - Zig ofrecía más funcionalidades para hacer que el código con memoria no segura funcionara correctamente, y el equipo quería el mayor apoyo precisamente en esa área
- Las 300.000 líneas del compilador existente en Rust tenían alrededor de 1.200 usos de
Seguridad de memoria obtenida sin borrow checker
-
Alcance de las comprobaciones de Rust y Zig
- Según material de Microsoft de 2019, cerca del 70% de las vulnerabilidades tratadas cada año mediante actualizaciones de seguridad eran problemas de seguridad de memoria
- Si se divide la clasificación de 2018 de ese material desde la perspectiva de Rust y Zig, queda así
- El 83.6% corresponde a lecturas/escrituras fuera de rango, casts no seguros, lecturas sin inicializar, desbordamientos de pila y problemas no relacionados con seguridad de memoria; son categorías que no dependen de elegir Rust o Zig
- El 16.4% corresponde a use-after-free, una categoría que puede detectarse con el borrow checker de Rust,
ReleaseSafede Zig o comprobaciones al estilo Fil-C ReleaseSafede Zig provoca un pánico en tiempo de ejecución si se usa memoria liberada- Sus comprobaciones son menos exhaustivas que el subconjunto seguro de Rust
- Tiene costo de ejecución y el programa puede entrar en pánico
ReleaseFastomite las comprobaciones en producción, pero las mantiene en builds de depuración y pruebas- Si se probaran todas las rutas reales de ejecución, se podría obtener una seguridad similar a
ReleaseSafe, pero ese nivel de cobertura de pruebas generalmente no es realista
-
Otros proyectos en Zig y el
unsafede Rust- TigerBeetle usa
ReleaseSafe, y en una verificación Jepsen minuciosa se encontraron 2 bugs de seguridad, pero ninguno estaba relacionado con seguridad de memoria - Ghostty y el compilador de Zig, que usan
ReleaseFast, tampoco tienen CVE causados por inseguridad de memoria en código Zig - Los programas en Rust también pueden tener huecos de seguridad de memoria a través de
unsafedentro de sus dependencias - Unsafe Rust tiene riesgos similares a Zig
ReleaseFast, pero no cuenta con una función equivalente a las comprobaciones en ejecución de Zig que detectan problemas durante el desarrollo - Miri y Valgrind pueden ayudar, pero no muchos proyectos Rust los usan
- En cambio, la cultura de usar
unsaferara vez y revisarlo con mayor rigor es lo que en la práctica ha construido la sólida reputación de Rust en seguridad de memoria - También hubo casos de vulnerabilidades relacionadas con
unsafeen proyectos basados en Rust- Deno tuvo CVE de lectura fuera de rango y use-after-free
- Rocket tuvo un CVE de use-after-free
- Actix tuvo varios CVE de inseguridad de memoria durante una etapa en la que su uso de
unsafeera anormalmente alto
- Roc consideró que use-after-free no era un gran riesgo, porque la mayoría de las asignaciones ocurren en arenas con tiempos de vida simples, y juzgó que las comprobaciones adicionales de Zig eran más útiles para código inherentemente no seguro
- TigerBeetle usa
Bugs de corrupción de memoria confirmados tras la reescritura
- El resultado de clasificar el issue tracker de Roc con Claude Opus 4.8 fue el siguiente
| Compilador | Hubo corrupción de memoria | Sin corrupción de memoria | Total |
|---|---|---|---|
| Rust | 21 | 2,575 | 2,596 |
| Zig | 10 | 421 | 431 |
- Los 21 casos de corrupción de memoria del compilador Rust no fueron corrupción de la lógica interna del compilador
- El borrow checker cumplió su función prevista
- Fueron bugs de miscompilation en los que el código máquina generado incorrectamente corrompía memoria en el programa compilado
- 8 de los 10 casos de corrupción de memoria del compilador Zig también fueron miscompilations
- Los 2 restantes fueron use-after-free en el código de reporte de errores
- En los mensajes de error de
roc checkyroc bundle, el nombre del archivo aparecía corrupto como el carácter de reemplazo U+FFFD - El borrow checker de Rust habría podido detectar ambos bugs
- En los mensajes de error de
- El efecto que habría tenido para los usuarios reales elegir otra herramienta fue el siguiente
| Elección de herramienta | Impacto real en seguridad de memoria |
|---|---|
Zig ReleaseFast |
2 bugs en los que el nombre de archivo no se renderizaba en algunos mensajes de error |
Zig ReleaseSafe |
2 bugs en los que algunos errores entraban en pánico y no podían renderizar el nombre de archivo |
| Rust borrow checker | Habría prevenido ambos bugs |
- Considerando 18 meses, cientos de miles de líneas y cientos de reportes de bugs, la diferencia entre las tres opciones no fue sustancial para el proyecto
- Bun maneja juntos valores de JavaScript con garbage collection por rastreo y valores gestionados manualmente, por lo que use-after-free, double-free y fugas por no liberar memoria tuvieron un peso importante
- El compilador de Roc no interactúa con JavaScript ni con otros garbage collectors por rastreo, así que no sufre los mismos problemas de gestión de tiempos de vida
- Roc necesita más herramientas para encontrar errores de memoria en el código de salida generado que errores de memoria internos del compilador, y lo segundo queda fuera del alcance del borrow checker
Resultados reales en tiempos de build
zig build --watch -fincrementalactualmente recompila cambios en unas 35 ms sobre aproximadamente 450,000 a 460,000 líneas de código Zig- La versión estable Zig 0.16.0 tiene un bug que rompe
-fincrementalen la base de código de Roc- La corrección ya fue incorporada, pero para usarla habría que pasar a un prebuild de Zig 0.17.0 con cambios incompatibles
- Como también habría que vendorizar las dependencias relacionadas y actualizarlas a 0.17.0, se decidió esperar al próximo release estable
- Los resultados medidos en una desktop Intel y Ubuntu 26 fueron los siguientes
| Compilador Roc | Tamaño del código | Build en frío | Build incremental |
|---|---|---|---|
| Original basado en Rust 1.85.0 | 354K | 32.4 s | 10.0 s |
| Original basado en Rust 1.97.0 | 354K | 25.4 s | 3.4 s |
| Punto de paridad funcional con Zig 0.16.0 | 320K | 39.6 s | 8.6 s |
| Reescritura actual con Zig 0.17.0 | 464K | 32.1 s | 0.035 s |
- En el punto de paridad funcional, incluso los artefactos que casi no cambiaban se recompilaban siempre, pero ahora solo se generan cuando hace falta
- Con este cambio, aunque el código creció alrededor de 50%, el build en frío se volvió más rápido
- Al pasar de Rust 1.85.0 a 1.97.0, el build incremental bajó de 10 s a 3.4 s, una mejora de alrededor de dos tercios en 18 meses
- Los 35 ms de Zig son aproximadamente una centésima parte de 3.4 s, y el resultado proviene de una base de código alrededor de 50% más grande que la del código medido en Rust
- Actualmente
-fincrementalsolo funciona en x86-64, y muchos colaboradores usan Mac basadas en ARM, por lo que todavía no se puede aprovechar por completo la ventaja en tiempos de build
Estructura sin punteros y deserialización sin parseo
- La nueva caché en disco usa como formato en disco la misma disposición de memoria eficiente para la ejecución, como en enfoques usados en el compilador de Zig y en el desarrollo de juegos
- Todas las estructuras de datos del compilador se representan con arreglos de índices de 32 bits en lugar de punteros, y en varios lugares se usa un formato de arreglos de structs
- Reduce el uso de memoria y aumenta la velocidad de ejecución
- Permite escribir las estructuras de datos directamente en disco sin convertirlas a un formato de serialización separado
- Al deserializar, no se parsean los bytes del disco
- Se leen los bytes en memoria
- Se reubican algunas partes para que las estructuras de datos existentes apunten a los nuevos arreglos
- Luego pueden usarse de inmediato
- En la práctica, la velocidad queda limitada por la velocidad de I/O al leer bytes del disco a memoria
- Si los datos están en la caché de disco del sistema operativo, los resultados de una compilación anterior se cargan aproximadamente a velocidad de
memcpy
- Si los datos están en la caché de disco del sistema operativo, los resultados de una compilación anterior se cargan aproximadamente a velocidad de
roc checkguarda en disco, en la primera ejecución, resultados como el parseo y la verificación de tipos- Si el código fuente de entrada no cambió, en la segunda ejecución mueve directamente las estructuras de datos del disco a memoria
roc testtambién cachea los resultados de pruebas deterministas de funciones puras- La caché funciona por archivo, por lo que, si se cambia un archivo, solo se reprocesan ese archivo y los archivos dependientes
- Este enfoque es posible porque todo el compilador usa índices en lugar de punteros; en estructuras típicas centradas en punteros, la deserialización sin parseo no es posible
-
Límites de seguridad de las estructuras basadas en índices
- Así como un puntero puede apuntar a una dirección incorrecta, un índice también puede consultar el arreglo equivocado y leer bytes arbitrarios
- El borrow checker de Rust gestiona la vida útil de los punteros, pero no verifica qué índice pertenece a qué arreglo
- Si se conoce de antemano la cantidad de arreglos necesarios,
compact_arenade Rust puede crear etiquetas de tipo con macros para impedir consultas al arreglo equivocado - Cuando la cantidad de arreglos varía según el número de módulos, como en Roc, esta técnica no se puede aplicar, por lo que
compact_arenatambién marcaSmallArena::newcomounsafe - Crear una arena vacía en sí no genera peligro; el riesgo real está en la indexación de arreglos, que se realiza con mucha frecuencia
- Safe Rust es eficaz bajo la suposición de que
unsafees pequeño y está aislado, pero en un caso como Roc, dondeunsafeestá muy extendido, esa suposición no se cumple
El ecosistema de Zig encajaba con Roc
- Para Bun, el
Dropde Rust, que ejecuta el código de limpieza una sola vez, era útil por la interoperabilidad entre JavaScript y la memoria manual - Roc, en cambio, quería usar arenas separadas por módulo y por etapa de compilación, por lo que los paquetes de Rust basados en un asignador global y
Dropimplícito resultaban incómodos - En el ecosistema de Zig es habitual que las API reciban asignadores de forma explícita, lo que encaja bien con el modelo de gestión de memoria de Roc
- El ecosistema de Rust era más adecuado para la estructura que quería Bun, mientras que el de Zig era más adecuado para la estructura que quería Roc
-
Reutilización del serializador de bitcode de LLVM
- LLVM es una dependencia central del optimizador de Roc y realiza optimizaciones adicionales después de las propias optimizaciones de Roc
- LLVM rompe con frecuencia la compatibilidad de sus API principales, por lo que actualizar versiones requería bastante tiempo y costo
- El formato de bitcode serializado de LLVM es estable y retrocompatible, así que usar un serializador propio permite independizarse de los cambios en la API de C++
- Para ello se necesita un serializador de bitcode escrito a mano y separado de las bibliotecas C++ de LLVM
- La implementación conocida existente estaba en el compilador de Zig, y el nuevo compilador de Roc reutiliza ese código Zig
- La mayor fuente de dependencias que Roc obtiene del ecosistema de Zig no son tanto los paquetes generales, sino el propio compilador de Zig
Funciones de Rust que se extrañan en Zig
- En la implementación de un compilador se necesita control explícito de la asignación, pero en las pruebas la asignación y liberación automáticas de Rust resultaban más cómodas
- El asignador de pruebas de Zig detecta fugas de memoria y también puede detectar fugas en el código Roc compilado
- En contrapartida, en cada prueba hay que escribir correctamente
initydefer deinit; si uno solo está mal, la prueba falla por una fuga
- Aunque
comptimede Zig se superpone con el polimorfismo paramétrico y ad hoc, se extrañan ambas formas de polimorfismo- El trait
Allocatorde Rust puede recibirself - Implementaciones como
ArenaAllocatorde Zig deben recibir un punteroanyopaquey luego castearlo a su propio tipo
- El trait
- Al no haber campos privados en structs, no se puede impedir con un error de compilación el acceso directo a campos que no deberían tocarse
- En una revisión de diferencias de código solo se ve el acceso al campo, no la documentación del struct original, así que hay que comprobarlo por separado cada vez
- A veces se extraña la consistencia de Rust, donde funciones, variables y constantes usan todas
snake_case unsafey el borrow checker también tenían costos, pero daban la tranquilidad de que ciertos problemas solo debían preocupar dentro de bloquesunsafe- Esto no significa que deban agregarse las mismas funciones a Zig
- En Zig era más común descubrir código muerto más tarde que en Rust
- Las herramientas integradas de Zig y
tidy.zigde TigerBeetle tampoco detectan parte del código muerto - El código muerto no se genera en el binario, por lo que no afecta a los usuarios, pero es una desventaja para el mantenimiento del codebase
- Las herramientas integradas de Zig y
- En Rust, las actualizaciones de versiones menores y los cambios de edición fueron en su mayoría sencillos
- Actualmente Zig no busca retrocompatibilidad, y como era una condición prevista no fue un gran problema, pero la experiencia de actualización simple de Rust fue mejor
Aspectos satisfactorios de Zig
- Zig tiene el atractivo de un diseño sustractivo, que reduce herramientas conocidas, como en la programación funcional, para obtener otras propiedades
- No tiene macros, y muchos problemas, incluido el polimorfismo paramétrico, se pueden resolver con
comptimeo con funciones comunes - Permite controlar con precisión la disposición de los datos
- Usa tipos enteros que no son potencias de 2, como
u7yu5, sin manipulación de bits adicional - Ofrece soporte nativo para packed struct
- Permite inlinear funciones en el sitio de llamada, no en el sitio de declaración
- Funcionalidades que en Rust requerirían crates basados en macros se pueden usar sin dependencias adicionales
- Usa tipos enteros que no son potencias de 2, como
- La cadena de herramientas de build de Zig encajó bien para generar binarios autónomos para Alpine Linux y WebAssembly
- También maneja builds especiales que compilan los builtins equivalentes a la biblioteca estándar de Roc como blobs binarios opacos y los incluyen en el ejecutable final
- Uber también usa la cadena de herramientas de Zig en su infraestructura de build sin usar el lenguaje Zig
- En el manejo de errores de Zig, una asignación fallida en el heap también se trata como un error común de espacio de usuario
- Roc usa una estrategia similar, donde los errores se acumulan de forma natural mediante tipos suma anónimos y payloads
- Se prefiere el enfoque de Zig y Roc frente a
anyhow,thiserrory el manejo básico basado enResultde Rust - Para la sintaxis de propagación de errores se prefiere el
?posfijo de Rust antes que eltryde Zig, por lo que Roc también adoptó un operador?posfijo - En conjunto, incluyendo las API de asignadores granulares y el código reutilizable para compiladores de alto rendimiento, hay mucha satisfacción con la elección de Zig para el proyecto
Próximos pasos de Roc
- Se planea lanzar la 0.1.0 del nuevo compilador en la segunda mitad de este año, y será el primer lanzamiento numerado de Roc
- Antes del lanzamiento también se pueden probar las builds nightly, pero actualmente aún quedan varios bugs, funciones incompletas y documentación incompleta
- La Roc Programming Language Foundation es una organización sin fines de lucro 501(c)(3) de Estados Unidos, y las donaciones se usan principalmente para compensar a los contribuidores
- El avance futuro del desarrollo y las preguntas se pueden seguir en Roc Zulip
2 comentarios
Opiniones en Lobste.rs
En números, los 35 ms de Zig son casi 100 veces más rápidos que los 3.4 segundos de Rust, y aunque el código también es alrededor de un 50% mayor, en el desarrollo real la diferencia parece algo exagerada.
Si no recompilas el compilador cada minuto sino cada 10 minutos, el tiempo ahorrado es de unos 3 segundos, y 3.4 segundos ya es suficientemente rápido.
A medida que crece la base de código, Rust puede volverse más lento, pero el tiempo de compilación en frío de hecho empeoró; además, si se considera que se cambia la laptop cada dos años y se suman las mejoras del propio compilador, es difícil afirmar que los tiempos de compilación de Rust seguirán aumentando a largo plazo.
El modo
checkde Rust y el LSP son más rápidos que una recompilación completa, pero no llegan al mismo nivel.Con la combinación
--watch -fincremental, recompilas cada vez que guardas y recibes feedback muy rápido y frecuente.Migré un proyecto personal de Rust a Zig, y también usé Rust durante años en el trabajo; la velocidad de compilación de Zig fue realmente un cambio que me permitió respirar.
35 ms es sorprendentemente rápido.
Solo volver a enlazar parece que tomaría más que eso; me da curiosidad qué está haciendo realmente el compilador.
Si cambias la implementación de una función, basta con insertar el nuevo ensamblador en el binario existente, pero el inlining, los cambios de firma de funciones, las dependencias y las reubicaciones cuando falta espacio requieren análisis adicional.
Si modificas o agregas una función, busca un área libre suficientemente grande en la sección
.textdel ejecutable de salida y escribe ahí el nuevo código máquina.Si no hay espacio suficiente, puede que tenga que ampliar
.texty mover otros datos, pero expande la sección de forma exponencial para amortizar el costo.Si hace falta, también agrega entradas a la tabla de símbolos y elementos de reubicación; como también deja espacio libre de antemano para ellos, al terminar solo queda cerrar el archivo.
Estimando con base en los resultados de Tracy que vi recientemente, del total de 35 ms el linker usó apenas alrededor de 1 ms.
La cifra de que en 300 mil líneas de código Rust se usó
unsafeunas 1,200 veces es mayor de lo que esperaba.Por ejemplo, Inko solo tiene 162 expresiones
unsafe { ... }y 87 funcionesunsafeexpuestas al código generado mediante la ABI de C, y todo el código Rust tiene unas 88 mil líneas.Sin embargo, por la forma en que funciona Inkwell, todo el backend de LLVM es en la práctica una enorme zona
unsafe, así que es difícil sacar conclusiones con una comparación simple.Yo solo marco como
unsafelas funciones que realmente comprometen la seguridad de memoria, como manejar punteros directamente, pero otros desarrolladores también lo usan como una pista de que hay que hacer cierta operación antes para evitar un panic.La cifra de que
unsafeaparece 40 mil veces en la biblioteca estándar y el compilador de Rust es inexacta.Es el resultado de contar incluso la palabra en tests y comentarios, y la mayoría está en la biblioteca estándar; en el compilador en sí, incluso incluyendo comentarios y tests, hay menos de 2 mil apariciones.
Durante mis contribuciones a rustc, la proporción de mis PR que contenían
unsafeni siquiera llegó al 1%, así de raro es el código inseguro dentro del compilador.Es natural que la biblioteca estándar, que implementa el runtime básico del que depende todo Rust, tenga mucho
unsafe; ese tipo de código es inseguro en cualquier lenguaje, ya sea de forma explícita o implícita.Ese compilador es unas 10 veces más pequeño que rustc y usa una cantidad similar de
unsafe, así que no diría que esté generalizado, pero sí se encuentra con frecuencia, y me da curiosidad por qué lo necesitan mucho más que rustc.No analizamos por separado el
unsaferelacionado con la biblioteca estándar en ambas bases de código, pero si hubiéramos implementado el nuevo compilador en Rust, esperamos que hubiera tenido másunsafeque rustc debido a la estructura que usa cachés e índices en lugar de punteros.No era nuestra intención menospreciar el trabajo del equipo de Rust; queríamos explicar nuestra elección y el estado del avance respetando los logros de otros proyectos.
Es un texto que evalúa de forma equilibrada las ventajas y desventajas de Rust y Zig sin ocultarlas, y resulta especialmente honesta la conclusión de que, incluso después de escribir cientos de miles de líneas durante 18 meses y resolver cientos de bugs, el resultado del proyecto probablemente no habría cambiado mucho si se hubiera elegido otro lenguaje entre las opciones
Sin embargo, cuesta entender la explicación de que, en compiladores que generan código máquina como Roc o rustc, las operaciones no seguras de memoria sean una gran parte del trabajo
También hay muchos compiladores escritos en OCaml o Haskell, y generar código máquina en sí consiste en armar bytes en un vector y escribirlos en un archivo, así que no hay motivo para que sea inseguro
Lo entendería si se tratara de interpretación o compilación JIT, pero me intriga por qué sería necesario también en la compilación común
El riesgo aparece al ejecutar el código máquina generado, y como en muchos casos los compiladores reales generan código máquina y lo ejecutan al mismo tiempo, se lo describió como una gran parte del trabajo
Además de la interpretación y el JIT, esto incluye evaluar código de usuario en tiempo de compilación, como
const fnde Rust o expresiones que en Roc pueden elevarse al nivel superior, o ejecutar pruebas y luego inspeccionar la salida para decidir qué mostrarle al usuarioEl criterio es que, ya sea que la memoria dañada esté en el proceso del compilador o en el programa generado, la causa de que el procesador se comporte mal son las instrucciones producidas por el compilador, y el lugar que debe corregirse también es el código del compilador
Componentes como el código de enlace externo y el garbage collector requieren mucho
unsafeincluso en Rust, lo que neutraliza buena parte de las ventajas del Rust seguroEs posible escribir solo el compilador en un lenguaje seguro, pero el rendimiento es un problema, y Zig y el compilador de Roc usan ampliamente una estructura de arreglos (SoA) e índices de arreglos en lugar de punteros
Si eso se implementa en Rust, se termina evitando el borrow checker y se pierden los beneficios de seguridad relacionados
Las relaciones de vida útil en un compilador son sorprendentemente simples: en una etapa se pueden asignar, crear y modificar datos en un arena; pasarlos como solo lectura a la siguiente etapa; y luego descartar todo el arena
Las partes más complejas son la compilación incremental y el enlazado, donde se lee estado desde el disco y se modifica un binario in situ; en ese proceso pueden surgir corrupción de estado, bugs, compilaciones incorrectas y problemas de memoria, pero eso es independiente de la seguridad de memoria del proceso del compilador en sí
La seguridad y la corrección son conceptos mucho más amplios que la seguridad de memoria interna de un programa, especialmente cuando se trata de realizar de forma segura y correcta tareas difíciles de expresar en Rust
Como creador de
compact_arena, me alegra que se lo haya presentado, pero se describió mal la forma en que esta biblioteca usaunsafeEl objetivo de
compact_arenaes garantizar quenewno pueda usarse mal desde Rust seguro, para hacer seguro el indexadoEl macro
mk_arenapuede llamarse desde código Rust seguro incluso dentro de bucles en los que no se conoce la cantidad necesaria de arenasnewse usa de forma segura, pero en la sección de seguridad de https://docs.rs/compact_arena/0.5.0/… se indica que la etiqueta que se pasa al constructor es la base del mecanismo de indexado, y que si ese valor se usa en otra arena, los índices de las dos arenas pueden mezclarse, lo que puede provocar accesos fuera de rango y comportamiento indefinidoMi punto es que el riesgo no surge de llamar a
new()en sí, sino al indexar con el valor creado pornew(), y considero que el lugar que debería auditarse y marcarse comounsafees la llamada real de indexadoAunque es posible que haya entendido mal el diseño
Tengo entendido que Python requiere reiniciar el programa para reflejar código nuevo, y que la recarga en caliente necesita extensiones no estándar y escribir el código con cuidado
La recarga en caliente es más común en entornos de desarrollo basados en imagen, como Lisp o Smalltalk, o en Erlang
importlib.reload()también es posible hacer recarga en caliente en Python, pero no sé qué tan extendido sea su usoMe pregunto si se puede reducir el tiempo de compilación evitando usar
cargo buildNo está claro si configurar Bazel sería más o menos trabajo que portar a Zig, y por supuesto el tiempo de compilación no fue la única razón para cambiar de lenguaje
Comentarios de Hacker News
En general el texto está bien, pero cuesta estar de acuerdo con la idea de que en compiladores que generan código máquina, como Roc o
rustc, las operaciones inseguras de memoria sean una gran parte del trabajoPara cosas como parcheo de binarios en ejecución o recarga de código sí haría falta código inseguro, pero en el proceso normal de generar un ejecutable no hay razón para que la generación de código máquina en sí sea insegura
Más bien, es más probable encontrar código inseguro en el runtime del lenguaje
Dicho eso, en la práctica muchos compiladores no solo generan código máquina sino que también lo ejecutan directamente, así que por eso se habló de que era “una gran parte del trabajo”, aunque un compilador no necesariamente tiene que hacer ambas cosas
También se pensaba en cosas como el parcheo de binarios, la recarga de código, el runtime, así como evaluar código de usuario en tiempo de compilación, por ejemplo con
const fnen Rust o expresiones que en Roc pueden elevarse al nivel superior, además de ejecutar tests y revisar su salida para decidir qué mostrarEs una lógica parecida a decir que, como igual puedes golpearte la cabeza usando cinturón de seguridad, entonces el cinturón solo estorba y no debería usarse
En código de rendimiento extremo cambian desde las estructuras de datos y los algoritmos hasta las estrategias de asignación de memoria, y TigerBeetle es conocido por asignar toda la memoria de una sola vez al arrancar
Como el compilador de Roc también busca un compromiso parecido al de Zig, es natural encontrar muchos patrones en común
No se ve en qué se basa la afirmación de que
ReleaseSafedetecta en runtime un use-after-freeRevisé la documentación de Zig sobre verificaciones de seguridad de memoria en runtime, pero no encontré nada relacionado buscando
use-after-free,UaFosafety-checked, y aunque usesDebugAllocatoren builds de release, eso tampoco lo detecta de forma confiableEl tema está resumido en https://landaire.net/memory-safety-by-default-is-non-negotia...
Es una opinión formada escribiendo código directamente desde antes del auge de la IA, y aunque con la combinación de Zig y LLMs algo podría cambiar, mientras más leo el texto más afirmaciones raras veo, así que cuesta confiar en él
Se lee menos como una evaluación técnica honesta y más como un texto escrito para justificar una discusión previa, aunque me gustan los textos y lenguajes raros y también me genera rechazo el exceso de entusiasmo por la IA, así que por ahora intento darle el beneficio de la duda
ReleaseSafemás bien agrega bounds checks y hace panic en código inalcanzableZig no parece ofrecer seguridad temporal de memoria (temporal memory safety)
Es interesante que OCaml, aunque maduro, flexible y suficientemente expresivo como para validar prototipos, no haya sido elegido como lenguaje de implementación final
También queda la duda de si el incremental build de Zig realmente será significativamente más rápido que dune, y aunque la compilación cruzada es una ventaja, eso tampoco se abordó en “por qué Zig”
Da curiosidad si el control fino de memoria realmente es tan importante en un compilador, y en qué hito los mantenedores deciden pasarse a otro lenguaje, como ocurrió con Rust y WASM, que empezaron en OCaml
El incremental build de Zig claramente es una función decisiva, y se entiende elegir cambiar de lenguaje para obtener eso en el corto plazo
Pero a mediano plazo esperaría que Rust también incorpore algo parecido en un futuro cercano
Quiero velocidad, pero no quiero avanzar rápido para terminar disparándome en el pie, y estoy creando mi propio lenguaje que combine la seguridad de Rust, las capacidades de Zig y un runtime de Go sin recolección de basura
Eso parece más viable, e incluso da la impresión de que podría implementarse en espacio de usuario
Habría sido mucho más convincente si hubieran hecho una comparación científica real al elegir el lenguaje de implementación del compilador
Parece que partieron de la suposición no verificada de que un compilador de alto rendimiento necesita un lenguaje de sistemas de bajo nivel (https://www.roc-lang.org/faq#self-hosted-compiler) y concluyeron que Zig era la única alternativa aparte de Rust
El rendimiento de un compilador está dominado por los algoritmos; incluso los lenguajes administrados rápidos, con el mismo algoritmo, por lo general quedan dentro de un factor de dos en tiempo de ejecución, mientras que la brecha de rendimiento causada por diferencias algorítmicas no tiene ese límite
Zig en sí mismo es un contraejemplo a la teoría de que escribir un compilador en un lenguaje de bajo nivel lo vuelve más rápido, y las aproximadamente 15 mil líneas por segundo de Roc no son rápidas. Hay material que indica que ya en 1998 un compilador de ML procesaba 3 mil líneas por segundo (https://flint.cs.yale.edu/cs421/case-for-ml.html)
Podría ser más útil a futuro detener el trabajo actual del compilador y hacer un compilador autoalojado para un subconjunto pequeño de Roc que pueda hacerse en menos de 10 mil líneas
Así, en vez de una implementación de 300 mil líneas, se podrían probar varias implementaciones a escala de 10 mil líneas y verificar si un lenguaje de bajo nivel realmente es necesario para cumplir los objetivos de rendimiento
En el proceso de autoalojado aparecerían las funciones de Roc que de verdad importan, también se escribiría más código en Roc, y al mejorar las capacidades generales necesarias para el compilador también mejorarían las aplicaciones construidas encima
El hecho de que en los años 90 se compilara ML rápidamente no basta para juzgar la velocidad de compilación actual de Roc. El diseño del lenguaje impone fuertes restricciones sobre los algoritmos necesarios, y el hardware moderno también es mucho más complejo
Roc parece tener cierto grado de sobrecarga y algoritmos sofisticados para evitar asignar cierres en el heap, y esas exigencias podrían introducir una complejidad algorítmica inevitable
Cuando se llega al límite de la optimización algorítmica, lo que queda es reducir factores constantes y, en particular, los lenguajes de alto nivel con manejo de memoria claramente ponen un piso a cuánto puede reducirse eso
He visto casos en código real donde controlar directamente la disposición de la memoria mejoró el rendimiento en más de 10 veces, y en la industria de videojuegos ese tipo de trabajo incluso ocupa gran parte de una carrera profesional. La idea de que un solo algoritmo ingenioso hace desaparecer todos los problemas de rendimiento está lejos de la realidad
Las compilaciones de Rust son una de las causas de mayor desperdicio de espacio de almacenamiento en casi cualquier computadora, y al compilar varias librerías se acumulan decenas de GB muy rápido
Se puede configurar una carpeta de compilación global para reutilizar dependencias entre proyectos, pero cualquier solución así sería mejor si se ofreciera como comportamiento predeterminado
Aun así, hace falta recolección de basura del caché, y un nuevo trabajo de reorganización de artefactos intermedios de compilación para facilitar eso ya está en su etapa final
node_modulesen el ecosistema de JavaScriptIncluso en un proyecto de Tauri donde el código de backend es mucho más pequeño que el de frontend, los artefactos de compilación de Rust ocupan 9GB, mientras que
node_modulesapenas llega a 550MBEntiendo Roc como un lenguaje de scripting para incrustarse en un ABI de C, y me da curiosidad cuál sería su caso de uso real
Quisiera saber si apunta a competir con WASM en entornos de plugins que ofrecen una gran plataforma Roc, y por qué el desarrollador de una aplicación querría exponer una capa de Roc en vez de WASM, donde quien crea el plugin puede usar el lenguaje que quiera
Si se trata de un lenguaje a nivel aplicación que usa una plataforma Roc pequeña, también me pregunto si busca competir con Gleam para código HTTP del lado del servidor y con Elm para código cliente
El tiempo de compilación es un factor gravemente subestimado. Mi mayor queja es esperar 10 minutos por una compilación de C++, porque eso rompe por completo el flujo de trabajo del desarrollo
El cambio que se siente al pasar de un archivo
.rsa uno.tses casi como si hubieras cambiado de computadoraNo uso Zig directamente, pero me entusiasman varias posibilidades
Me interesan especialmente nuevos juegos hechos en Zig, el software distribuido cuya promesa TigerBeetle ya mostró, y el campo de la robótica, que en lo personal me interesa mucho