Referencia: Este artículo es una tradución al español de Speed of Rust vs C por Kornel Lesiński.
Introducción#
La velocidad de ejecución y el uso de la memoria de los programas escritos en Rust debería ser más o menos la misma que la de los programas escritos en C, pero el estilo de programación general de estos lenguajes es tan diferente que es difícil generalizar su velocidad. Este es un resumen de dónde son iguales, dónde C es más rápido y dónde Rust lo es.
Descargo de responsabilidad: Esto no pretende ser un punto de referencia objetivo para descubrir verdades indiscutibles sobre estos lenguajes. Hay una diferencia significativa entre lo que estos lenguajes pueden lograr en teoría y cómo se utilizan en la práctica. Esta comparación particular se basa en mi propia experiencia subjetiva que incluye tener plazos establecidos, escribir errores y ser muy perezoso. He estado usando Rust como mi lenguaje principal por más de 4 años y C por una década antes de eso. Estoy comparando específicamente con sólo C aquí, ya que una comparación con C++ tendría muchos más “si’s” y “pero’s” en los que no quiero entrar.
En resumen:
- Las abstracciones que proporciona Rust son un arma de doble filo. Pueden ocultar código no óptimo, pero también facilitar mejoras algorítmicas y el aprovechamiento de bibliotecas altamente optimizadas.
- Nunca me preocupa llegar a un callejón sin salida en el rendimiento con Rust. Siempre está la insegura escotilla de escape que permite optimizaciones de muy bajo nivel (y que no se necesita a menudo).
- La concurrencia sin miedo es real. La torpeza ocasional del revisor de préstamos vale la pena para hacer práctica la programación paralela.
Mi sensación general es que si pudiera dedicar infinito tiempo y esfuerzo, mis programas en C serían tan rápidos o más rápidos que en Rust, porque teóricamente no hay nada que C no pueda hacer que Rust pueda. Pero en la práctica C tiene menos abstracciones, una biblioteca estándar primitiva, una terrible situación con dependencias, y no tengo tiempo para reinventar la rueda de manera óptima cada vez.
Ambos son “ensambladores portables”#
Tanto Rust como C dan control sobre la disposición de las estructuras de datos, los tamaños de enteros, la asignación de la memoria en el stack vs en el heap, la indirección de punteros y en general se traducen a código de máquina comprensible con poca “magia” insertada por el compilador. Rust incluso admite que los bytes tengan 8 bits y que ¡los números con signo puedan desbordarse!.
Aunque Rust tiene construcciones de alto nivel como iterators, traits y otras “abstracciones de costo cero” que están diseñadas para optimizar de manera predecible el código de máquina. La disposición de la memoria de los tipos de Rust es simple, por ejemplo, las cadenas (strings) y vectores (vectors) crecientes son exactamente {datos*, capacidad, longitud}
. Rust no tiene ningún concepto como mover o copiar constructors, así que el paso de objetos está garantizado para ser no más complicado que pasar un puntero o un memcpy.
La comprobación de préstamos es sólo un análisis estático en tiempo de compilación. No hace nada, y la información de vida útil es incluso completamente eliminada antes de la generación del código. No hay autoboxing ni nada inteligente como eso.
Un caso en el que Rust no llega a ser un generador de código “tonto” es en la desenvoltura (unwinding). Mientras que Rust no utiliza excepciones para el manejo normal de errores, un pánico (panic) o error fatal no manejado puede opcionalmente comportarse como una excepción en C++. Se puede desactivar en el momento de la compilación (panic = abort), pero incluso entonces Rust no quiere que otro código lance una excepción de C++ o use longjmp sobre los marcos de la pila de Rust.
El mismo viejo LLVM back-end#
Rust tiene una buena integración con LLVM, so que soporta Link-Time Optimization, incluyendo ThinLTO e incluso en línea a través de las límites del lenguaje (C/C++/Rust). También hay una optimización guiada por el perfil. A pesar de que rustc genera más verboso LLVM IR que clang, el optimizador todavía puede manejarlo bastante bien.
Parte de mi código en C es un poco más rápido cuando se compila con GCC que con LLVM, y no hay un Rust front-end para GCC, así que Rust se pierde eso.
En teoría, Rust permite optimizaciones aún mejores que C gracias a una inmutabilidad más estricta y a las reglas de aliasing, pero en la práctica esto todavía no sucede. Las optimizaciones más allá de las que hace C son poco probadas y poco desarrolladas en LLVM, por lo que Rust siempre espera a que una corrección de fallos más aterrice en LLVM para alcanzar su máximo potencial.
Ambos permiten el afinamiento a mano, con pequeñas excepciones#
El código en Rust es de bajo nivel y lo suficientemente predecible como para que pueda ajustar a mano a qué ensamblaje se optimizará. Rust soporta los intrínsecos de SIMD, da control sobre la línea, las convenciones de llamadas, etc. Rust es lo suficientemente similar a C que los perfiladores de C suelen trabajar con Rust desde el principio (por ejemplo, puedo usar los instrumentos de Xcode en un programa que sea un sándwich de Rust-C-Swift).
En general, cuando el rendimiento es absolutamente crítico y necesita ser optimizado a mano hasta el último bit, la optimización de Rust no es muy diferente de la de C.
Hay algunas características de bajo nivel para las que Rust no tiene un reemplazo adecuado:
- goto computado. Los usos sensatos de goto regular pueden ser emulados con
loop {break}
en Rust. Muchos usos degoto
en C son para la limpieza, lo que Rust no necesita. Sin embargo, hay una extensión degoto
no estándar que es muy útil para los intérpretes. Rust no puede hacerlo (puedes escribir una coincidencia y esperar que optimice), pero si necesitara un intérprete, trataría de aprovechar JIT de Cranelift en su lugar. alloca
y C99 arrays de longitud variable. Estos son controvertidos incluso en C, así que Rust se mantiene alejado de ellos.
Vale la pena señalar que Rust actualmente sólo soporta una arquitectura de 16 bits. El soporte de nivel 1 se centra en plataformas de 32 y 64 bits.
Pequeñas sobrecargas de Rust#
Sin embargo, donde Rust no se afina a mano, pueden aparecer algunas ineficiencias:
- Rust prefiere claramente el tamaño de registro
usize
en lugar de 32 bitsint
. Mientras que Rust puede usari32
al igual que C puede usarsize_t
, los valores por defecto afectan a la forma en que se escribe el código típico.usize
es más fácil de optimizar en plataformas de 64 bits sin depender de un comportamiento indefinido, pero los bits extra pueden ejercer más presión sobre los registros y la memoria. - Rust idiomático siempre pasa el puntero y el tamaño para strings y slices. No fue hasta que porté un par de bases de código de C a Rust, cuando me di cuenta de cuántas funciones en C sólo toman un puntero a la memoria, sin un tamaño, y espera lo mejor: el tamaño se conoce indirectamente por el contexto, o simplemente se asume que es lo suficientemente grande para la tarea.
- No todas las comprobaciones de límites están optimizadas.
for item in arr
oarr.iter().for_each(…)
son tan eficientes como pueden ser, pero si la formafor i in 0..len {arr[i]}
es necesaria, entonces el rendimiento depende de que el optimizador LLVM sea capaz de probar que la longitud coincide. A veces no puede, y las comprobaciones de los límites inhiben la autovectorización. Por supuesto, hay varias soluciones para esto, tanto seguras como inseguras. - El uso de la memoria “inteligente” está mal visto en Rust. En C, todo vale. Por ejemplo, en C estaría tentado de reutilizar un buffer asignado a un propósito para otro propósito más tarde (una técnica conocida como HEARTBLEED). Es conveniente disponer de buffers de tamaño fijo para datos de tamaño variable (por ejemplo,
PATH_MAX
) para evitar la reasignación de buffers crecientes. Rust idiomático sigue dando mucho control sobre la asignación de la memoria, y puede hacer cosas básicas como agrupaciones de memoria, combinar varias asignaciones en una sola, preasignar espacio, etc. Pero en general orienta a los usuarios hacia un uso “aburrido” de la memoria. - En los casos en que las reglas de verificación de préstamos dificultan las cosas, la salida más fácil es hacer copias adicionales o utilizar el recuento de referencias. Con el tiempo, he aprendido un montón de trucos de verificación de préstamos, y he ajustado mi estilo de codificación para que sea compatible con la verificación de préstamos, por lo que esto ya no aparece a menudo. Esto nunca se convierte en un gran problema, porque si es necesario, siempre hay una alternativa a los punteros “crudos”.
- El verificador de préstamos de Rust es infame por odiar las listas doblemente enlazadas, pero por suerte sucede que las listas enlazadas son lentas en el hardware del siglo XXI de todos modos (mala localización del caché, no hay vectorización). La biblioteca estándar de Rust tiene listas enlazadas, así como contenedores más rápidos y fáciles de usar para el corrector de préstamos para elegir.
Hay dos casos más que el verificador de préstamos no puede tolerar: archivos mapeados en memoria (los cambios mágicos desde fuera del proceso violan la semántica inmutable-exclusiva de las referencias) y las estructuras autorreferenciales (pasar la estructura por el valor haría que sus punteros internos cuelguen). Estos casos se resuelven ya sea con punteros crudos que son tan seguros como cada puntero en C, o con gimnasia mental para hacer abstracciones seguras alrededor de ellos. - Para Rust, los programas de un solo hilo no existen como concepto. Rust permite que estructuras de datos individuales sean “non-thread-safe” para el rendimiento, pero cualquier cosa que se permita compartir entre hilos (incluyendo variables globales) tiene que estar sincronizada o marcada como
unsafe
. - Sigo olvidando que los strings en Rust soportan algunas operaciones baratas en el lugar, como
make_ascii_lowercase()
(un equivalente directo de lo que yo haría en C), y utilizan innecesariamente Unicode, copiando.to_lowercase()
. Hablando de cadenas, la codificación UTF-8 no es un problema tan grande como parece, porque las cadenas tienen la vista.as_bytes()
, por lo que pueden ser procesadas de forma de Unicode-ignorante si es necesario. - libc se dobla hacia atrás para hacer
stdout
yputc
razonablemente rápido. libstd de Rust tiene menos magia, así que I/O no está amortiguada a menos que esté envuelta en unBufWriter
. He visto a gente quejarse de que su código en Rust es más lento que Python, y fue porque Rust pasó el 99% del tiempo limpiando el resultado byte por byte, exactamente como se dijo.
Tamaños de ejecutables#
Cada sistema operativo incluye una biblioteca estándar de C que tiene ~30MB
de código que los ejecutables de C obtienen “gratis”. Por ejemplo, un ejecutable C de “Hello World” no puede imprimir nada, sólo llama al printf
que viene con el sistema operativo.
Rust no puede contar con que los sistemas operativos tengan incorporada la biblioteca estándar de Rust, por lo que los ejecutables de Rust agrupan bits de la biblioteca estándar de Rust (300KB
o más). Fortunately, it’s a one-time overhead. For embedded development, the standard library can be turned off and Rust will generate “bare” code.
En base a cada función, el código escrito en Rust es aproximadamente del mismo tamaño como el escrito en C, pero hay un problema de “hinchazón de genéricos (generics)”. Las funciones genéricas obtienen versiones optimizadas para cada tipo con el que se usan, así que es posible terminar con 8 versiones de la misma función. cargo-bloat ayuda a encontrarlas.
Es muy fácil usar las dependencias en Rust. Al igual que en JS/npm, hay una cultura de hacer pequeñas bibliotecas de un solo uso, pero ellos suman. Eventualmente todos mis ejecutables terminan conteniendo tablas de normalización Unicode, 7 generadores de números aleatorios diferentes, y un cliente HTTP/2 con soporte Brotli. cargo-tree es útil para deduplicarlos y seleccionarlos.
Nota:
cargo-tree
fue integrado a Cargo desde la versión1.44.0
estable. Ver rust-lang/cargo#8062
Pequeñas victorias para Rust#
He hablado mucho de los gastos generales, pero el óxido también tiene lugares donde termina más eficiente y más rápido:
- Las bibliotecas C suelen devolver punteros opacos a sus estructuras de datos, para ocultar los detalles de implementación y asegurar que sólo hay una copia de cada instancia de la estructura. Esto cuesta un montón de asignaciones y punteros indirectos. La privacidad incorporada de Rust, las reglas de propiedad únicas y las convenciones de codificación permiten a las bibliotecas exponer sus objetos por valor, de modo que los usuarios de la biblioteca deciden si los colocan en el Heap o en el Stack. Los objetos en el Stack pueden ser optimizados muy agresivamente, e incluso optimizados por completo.
- Rust por defecto puede poner en linea (inlining) funciones de la biblioteca estándar, de las dependencias y de otras unidades de compilación. En C a veces soy reacio a dividir archivos o usar bibliotecas, porque afecta a el inlining y requiere una micro-gestión de los encabezados y la visibilidad de los símbolos.
- Los campos de estructura se reordenan para minimizar el acolchado (padding). La compilación en C con
-Wpadding
muestra la frecuencia con la que me olvido de este detalle. - Strings tienen su tamaño codificado en su puntero “gordo”. Esto hace que los controles de longitud sean rápidos, elimina el riesgo de
O(n²)
bucles de cadenas (strings), y permite hacer subcadenas en el lugar (por ejemplo, dividir una cadena en tokens) sin modificar la memoria o copiar para añadir el terminador\0
. - Como las plantillas de C++, Rust genera copias de código genérico para cada tipo con el que se usan, así que funciones como
sort()
y contenedores como las tablas hash están siempre optimizadas para su tipo. En C tengo que elegir entre hacks con macros o funciones menos eficientes que funcionan convoid*
y tamaños variables. - Los iteradores en Rust pueden ser combinados con cadenas que se optimizan juntas como una unidad. Así que en lugar de una serie de llamadas
buy(it); use(it); break(it); change(it); mail(upgrade(it));
que puede terminar reescribiendo el mismo buffer muchas veces, puedo llamarit.buy().use().break().change().upgrade().mail()
que se compila a unbuy_use_break_change_mail_upgrade(it)
optimizado para hacer todo eso en una sola pasada combinada.(0..1000).map(|x| x*2).sum()
compila areturn 999000.
- Del mismo modo, hay interfaces
Read
yWrite
que permiten a las funciones transmitir datos sin búferes. Se combinan muy bien, así que puedo escribir datos en un flujo que calcula el CRC de los datos sobre la marcha, añade framing/escapando si es necesario, lo comprime, y lo escribe en la red, todo en una sola llamada. Y puedo pasar tal flujo combinado como un flujo de salida a mi motor de plantillas HTML, así que ahora cada etiqueta HTML será lo suficientemente inteligente para enviarse comprimida. El mecanismo subyacente es sólo una pirámide de llamadas simplesnext_stream.write(bytes)
, así que técnicamente nada me impide hacer lo mismo en C, excepto que la falta de traits y generics en C significa que es muy difícil hacer eso en la práctica, aparte de con las llamadas de retorno configuradas en tiempo de ejecución, lo cual no es tan eficiente. - En C es perfectamente racional usar la búsqueda lineal la mayor parte del tiempo, porque ¿quién va a mantener una milmillonésima implementación a medias de la tabla de hash? No hay contenedores incorporados, las dependencias son una molestia, así que escribo listas enlazadas ad-hoc y recorto las distancias todo el tiempo. A menos que sea absolutamente necesario, no me molestaré en escribir una implementación sofisticada de un B-tree. Usaré
qsort
+bisect
y lo llamaré un día. Por otro lado en Rust, sólo se necesitan 1 o 2 líneas de código para obtener implementaciones de muy alta calidad en todo tipo de contenedores. Esto significa que mis programas en Rust pueden permitirse usar estructuras de datos adecuadas e increíblemente optimizadas cada vez. - En estos días todo parece requerir JSON. Rust Serde es uno de los analizadores de JSON más rápidos del mundo, y analiza directamente las estructuras en Rust, por lo que el uso de los datos analizados es muy rápido y eficiente también.
Gran victoria para Rust#
Rust refuerza la seguridad de los hilos (threads) de todo el código y datos, incluso en las bibliotecas de terceros, incluso si los autores de ese código no prestaron atención a la seguridad de los hilos. Todo mantiene garantías específicas de seguridad de los hilos, o no se permite su uso en los hilos. Si escribo cualquier código que no sea seguro con los hilos, el compilador señalará exactamente dónde es inseguro.
Esa es una situación dramáticamente diferente a la de C. Normalmente no se puede confiar en que ninguna función de la biblioteca sea segura a menos que esté claramente documentada de otra manera. Depende del programador asegurarse de que todo el código es correcto, y el compilador generalmente no puede ayudar con nada de esto. El código C multi-hilo conlleva mucha más responsabilidad, mucho más riesgo, así que es atractivo pretender que las CPUs multi-núcleo son sólo una moda, e imaginar que los usuarios tienen mejores cosas que hacer con los 7 o 15 núcleos restantes.
Rust garantiza libertad de cualquier carreras de datos (data races) y la inseguridad de la memoria (por ejemplo, errores use-after-free, incluso a través de los hilos). No sólo algunas carreras (races) que podrían ser encontradas con heurística o en tiempo de ejecución en construcciones instrumentales, sino todas las carreras de datos en todas partes. Esto salva vidas, porque las carreras de datos son el peor tipo de bichos de concurrencia. Ocurrirán en las máquinas de mis usuarios, pero no en mi depurador. Hay otros tipos de errores de concurrencia, como el mal uso de primitivos de bloqueo que causan condiciones de carreras lógicas (logic race conditions) de alto nivel o bloqueos, y Rust no puede eliminarlos, pero suelen ser más fáciles de diagnosticar y arreglar.
En C no me atreveré a hacer más que un par de pragmas de OpenMP en simple para bucles for
. He intentado ser más aventurero con las tareas y los hilos, y he acabado arrepintiéndome cada vez.
Rust tiene buenas librerías para paralelismo de datos (data parallelism), grupos de hilos (thread pools), colas (queues), tareas (tasks), estructuras de datos sin bloqueo (lock-free data structures), etc. Con la ayuda de tales bloques de construcción, y la fuerte red de seguridad del sistema tipos, puedo paralelizar los programas de Rust con bastante facilidad. En algunos casos es suficiente con reemplazar iter()
por par_iter()
, y si compila, ¡funciona! No siempre es un acelerador lineal (la ley de Amdahl es brutal), pero a menudo es un acelerador de hasta 2x o 3x para relativamente poco trabajo.
Hay una diferencia interesante en cómo las librerías Rust y C documentan la seguridad de los hilos. Rust tiene un vocabulario para aspectos específicos de la seguridad de los hilos, como Send
y Sync
, guardias (guards) y celdas (cells). En C, no hay una palabra para “puedes asignarlo a un hilo y liberarlo en otro hilo, pero no puedes usarlo de dos hilos a la vez”. Rust describe la seguridad de los hilos en términos de tipos de datos, lo cual se generaliza a todas las funciones que los usan. En C la seguridad de los hilos se habla en el contexto de las funciones y banderas (flags) individuales. Las garantías de Rust tienden a ser en tiempo de compilación, o al menos incondicionales. En C es común encontrar que “esto es seguro sólo cuando la opción del turboblub se establece en 7”.
En resumen#
Rust es de tal bajo nivel que, si es necesario, puede ser optimizado para un rendimiento máximo tan bien como en C. Las abstracciones de alto nivel, la fácil gestión de la memoria y la abundancia de bibliotecas disponibles tienden a hacer que los programas en Rust tengan más código, hagan más y, si se dejan sin revisar, pueden llegar a hincharse. Sin embargo, los programas en Rust también se optimizan bastante bien, a veces mejor que C. Mientras que C es bueno para escribir código mínimo a nivel de puntero byte a byte, Rust tiene características poderosas para combinar eficientemente múltiples funciones o incluso bibliotecas enteras juntas.
Pero el mayor potencial está en la capacidad de paralelizar sin miedo la mayoría del código en Rust, incluso cuando el código en C equivalente sería demasiado arriesgado de paralelizar. En este aspecto, Rust es un lenguaje mucho más maduro que C.