Medir el rendimiento en Rust

Medir cuánto tarda un trozo de código en Rust parece trivial: Instant::now(), el cálculo, elapsed(). El problema es que la primera medición casi siempre miente, y conviene saber por qué antes de sacar conclusiones.

Medir, en principio

La herramienta está en la biblioteca estándar y no necesita dependencias:

use std::hint::black_box;
use std::time::Instant;

fn es_primo(n: u64) -> bool {
    if n < 2 { return false; }
    let mut d = 2;
    while d * d <= n {
        if n % d == 0 { return false; }
        d += 1;
    }
    true
}

fn main() {
    let limite = black_box(200_000u64);

    let t = Instant::now();
    let primos = black_box((2..limite).filter(|&n| es_primo(n)).count());
    let tardado = t.elapsed();

    println!("compilado en: {}", if cfg!(debug_assertions) { "depuración" } else { "release" });
    println!("primos hasta {limite}: {primos}");
    println!("tardó: {:.2?}", tardado);
}
cargo runcompilado en: depuración
primos hasta 200000: 17984
tardó: 21.52ms
cargo run --releasecompilado en: release
primos hasta 200000: 17984
tardó: 14.89ms

Repetido cinco veces cada uno, la depuración se mueve entre 20,87 y 21,79 ms, y el release entre 14,76 y 16,67. Es decir, unas 1,4 veces más rápido, no las diez o cincuenta que se suelen citar. En este cálculo manda la división entera, que el optimizador no puede acelerar mucho. La diferencia entre depuración y release depende enteramente de lo que haga tu código.

Por qué está ese black_box

Esta es la parte importante, y la descubrí equivocándome. La primera versión de este ejemplo sumaba cuadrados en vez de contar primos, y en release daba este resultado:

sumando cuadrados hasta 3.000.000, en releaseresultado    : 9000004500000500000
sin black_box: 120.00ns
con black_box: 40.00ns

Cuarenta nanosegundos para tres millones de iteraciones es imposible: no da tiempo ni a recorrer la memoria. Lo que ocurrió es que el optimizador reconoció la suma de cuadrados y la sustituyó por la fórmula cerrada. El resultado es correcto —coincide exactamente con n(n+1)(2n+1)/6— pero el bucle nunca se ejecutó, así que no había nada que medir.

black_box sirve para impedir ese tipo de optimización: le dice al compilador que no puede suponer nada sobre ese valor. Aun así no es infalible, como demuestra ese caso, donde ni con la barrera se evitó la sustitución por la fórmula. Si una medición da un número absurdamente bueno, la hipótesis correcta no es que tu código sea rapidísimo, sino que no se ejecutó.

Y el desbordamiento, de regalo. La primerísima versión sumaba hasta cincuenta millones, lo que desborda un u64. En depuración el programa moría con attempt to add with overflow; en release no se quejaba y devolvía 13919798230507451072, un número sencillamente falso. Es la diferencia entre los dos modos que explica la lección de tipos, vista en vivo: comprobar en depuración, dar la vuelta en release.

Reglas para medir sin engañarte

Regla Por qué
Siempre con --release En depuración mides las comprobaciones, no tu código
Usa el resultado Si nadie lo mira, el compilador borra el cálculo
Envuelve entradas y salidas en black_box Evita que se precalculen en tiempo de compilación
Repite varias veces Una sola medida recoge el ruido de la máquina
Desconfía de lo imposible Un millón de iteraciones no caben en nanosegundos
Para medir en serio, usa criterion Repite, descarta valores atípicos y da intervalos
Instant frente a las fechas. Instant es un reloj monótono: solo sirve para restar dos lecturas y nunca retrocede, aunque alguien cambie la hora del sistema. Por eso se usa para medir y no para registrar cuándo pasó algo. Para eso están las fechas con chrono, que es la distinción que hace Rust entre medir intervalos y manejar calendarios.

El código de esta página se compiló y ejecutó con rustc 1.96.1 antes de publicarla, en los dos modos y con cinco repeticiones de cada uno; todos los números están copiados de esas ejecuciones. Documentación oficial: std::time::Instant y std::hint::black_box.