Cómo construir un arnés de evaluación comparativa personalizado en Rust

Everett Pompeii

Everett Pompeii


¿Qué es la Evaluación Comparativa?

La evaluación comparativa es la práctica de probar el rendimiento de tu código para ver qué tan rápido (latencia) o cuánto (rendimiento) trabajo puede hacer. Este paso, a menudo pasado por alto en el desarrollo de software, es crucial para crear y mantener un código rápido y de alto rendimiento. La evaluación comparativa proporciona las métricas necesarias para que los desarrolladores comprendan cómo se comporta su código bajo diversas cargas de trabajo y condiciones. Por las mismas razones por las cuales escribes pruebas unitarias y de integración para prevenir regresiones de características, debes escribir evaluaciones comparativas para prevenir regresiones de rendimiento. ¡Los errores de rendimiento son errores!

Evaluación de rendimiento en Rust

Las tres opciones populares para la evaluación de rendimiento en Rust son: libtest bench, Criterion, e Iai.

libtest es el marco de evaluación de pruebas unitarias y rendimiento integrado de Rust. Aunque forma parte de la biblioteca estándar de Rust, libtest bench todavía se considera inestable, por lo que solo está disponible en versiones del compilador nightly. Para trabajar en el compilador estable de Rust, se necesita un arnés de evaluación de rendimiento separado. Sin embargo, ninguno está siendo desarrollado activamente.

El arnés de evaluación de rendimiento más popular dentro del ecosistema de Rust es Criterion. Funciona tanto en versiones del compilador Rust estable como nightly, y se ha convertido en el estándar de facto dentro de la comunidad Rust. Criterion también es mucho más rico en funcionalidades en comparación con libtest bench.

Una alternativa experimental a Criterion es Iai, del mismo creador de Criterion. Sin embargo, utiliza el conteo de instrucciones en lugar del tiempo de pared: instrucciones de CPU, accesos a L1, accesos a L2 y accesos a RAM. Esto permite evaluaciones de rendimiento de una sola ejecución, ya que estas métricas deberían mantenerse casi idénticas entre ejecuciones.

Con todo eso en mente, si estás buscando medir el tiempo de reloj de tu código, entonces probablemente deberías usar Criterion. Si estás buscando medir el rendimiento de tu código en CI con runners compartidos, entonces puede valer la pena echarle un vistazo a Iai. Sin embargo, ten en cuenta que Iai no se ha actualizado en más de 3 años. Así que podrías considerar usar Iai-Callgrind en su lugar.

Pero, ¿qué pasa si no quieres medir el tiempo de reloj de pared o el conteo de instrucciones? ¿Qué pasa si quieres seguir un benchmark completamente diferente‽ Afortunadamente, Rust facilita increíblemente la creación de un arnés de pruebas personalizado.

Cómo Funciona cargo bench

Antes de construir un arnés de evaluación personalizado, necesitamos entender cómo funcionan los benchmarks en Rust. Para la mayoría de los desarrolladores de Rust, esto significa ejecutar el comando cargo bench. El comando cargo bench compila y ejecuta tus benchmarks. Por defecto, cargo bench intentará usar el arnés de bench libtest incorporado (pero inestable). libtest bench luego recorrerá tu código y ejecutará todas las funciones anotadas con el atributo #[bench]. Para usar un arnés de evaluación personalizado, necesitamos decirle a cargo bench que no use libtest bench.

Usar un Harness de Benchmarking Personalizado con cargo bench

Para que cargo bench no use libtest bench, necesitamos añadir lo siguiente a nuestro archivo Cargo.toml:

Cargo.toml
[[bench]]
harness = false

Desafortunadamente, no podemos usar el atributo #[bench] con nuestro harness de benchmarking personalizado. Quizás algún día pronto, pero no hoy. En su lugar, tenemos que crear un directorio separado benches para guardar nuestros benchmarks. El directorio benches es para benchmarks lo que el directorio tests es para pruebas de integración. Cada archivo dentro del directorio benches se trata como un crate separado. El crate que está siendo evaluado debe ser por lo tanto un crate de biblioteca. Es decir, debe tener un archivo lib.rs.

Por ejemplo, si tuviéramos un crate de biblioteca básico llamado game entonces podríamos añadir un archivo de benchmark personalizado llamado play_game al directorio benches. Nuestra estructura de directorios se vería así:

game
├── Cargo.lock
├── Cargo.toml
└── benches
└── play_game.rs
└── src
└── lib.rs

A continuación, necesitamos informar a cargo bench sobre nuestro crate de benchmark personalizado play_game. Así que actualizamos nuestro archivo Cargo.toml:

Cargo.toml
[[bench]]
name = "play_game"
harness = false

Escribir código para medir el rendimiento

Antes de que podamos escribir una prueba de rendimiento, necesitamos tener algún código de biblioteca para evaluar. Para nuestro ejemplo, vamos a jugar al juego FizzBuzzFibonacci.

Las reglas para FizzBuzzFibonacci son las siguientes:

Escribe un programa que imprima los enteros del 1 al 100 (inclusive):

  • Para múltiplos de tres, imprime Fizz
  • Para múltiplos de cinco, imprime Buzz
  • Para múltiplos de ambos tres y cinco, imprime FizzBuzz
  • Para números que son parte de la secuencia de Fibonacci, solo imprime Fibonacci
  • Para todos los demás, imprime el número

Así es como se ve nuestra implementación en src/lib.rs:

src/lib.rs
pub fn play_game(n: u32, print: bool) {
let result = fizz_buzz_fibonacci(n);
if print {
println!("{result}");
}
}
fn fizz_buzz_fibonacci(n: u32) -> String {
if is_fibonacci_number(n) {
"Fibonacci".to_string()
} else {
match (n % 3, n % 5) {
(0, 0) => "FizzBuzz".to_string(),
(0, _) => "Fizz".to_string(),
(_, 0) => "Buzz".to_string(),
(_, _) => n.to_string(),
}
}
}
fn is_fibonacci_number(n: u32) -> bool {
let (mut previous, mut current) = (0, 1);
while current < n {
let next = previous + current;
previous = current;
current = next;
}
current == n
}

Crear un Arnés de Benchmarking Personalizado

Vamos a crear un arnés de benchmarking personalizado dentro de benches/play_game.rs. Este arnés de benchmarking personalizado va a medir las asignaciones en el heap usando el crate dhat-rs. dhat-rs es una herramienta fantástica para rastrear las asignaciones en el heap en programas de Rust creada por el experto en rendimiento de Rust Nicholas Nethercote. Para ayudarnos a gestionar nuestras funciones de benchmark, usaremos el crate inventory del asombrosamente prolífico David Tolnay.

Agreguemos dhat-rs e inventory a nuestro archivo Cargo.toml como dev-dependencies:

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"

Crear un Asignador Personalizado

Dado que nuestro arnés de evaluación personalizado medirá las asignaciones en el montón, necesitaremos usar un asignador de montón personalizado. Rust permite configurar un asignador de montón global personalizado usando el atributo #[global_allocator]. Añade lo siguiente al principio de benches/play_game.rs:

benches/play_game.rs
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;

Esto le dice a Rust que use dhat::Alloc como nuestro asignador de montón global.

🐰 Solo puedes establecer un asignador de montón global a la vez. Si deseas cambiar entre varios asignadores globales, tienen que ser administrados a través de la compilación condicional con [características de Rust].

Crear un Colector de Benchmarks Personalizado

Para crear un arnés de evaluación comparativa personalizado, necesitamos una forma de identificar y almacenar nuestras funciones de benchmark. Usaremos una estructura, acertadamente llamada CustomBenchmark para encapsular cada función de benchmark.

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}

Un CustomBenchmark tiene un nombre y una función de benchmark que devuelve dhat::HeapStats como resultado.

Luego usaremos el crate inventory para crear una colección de todos nuestros CustomBenchmarks:

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}
inventory::collect!(CustomBenchmark);

Crear una Función de Benchmark

Ahora, podemos crear una función de benchmark que juegue el juego FizzBuzzFibonacci:

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}

Línea por línea:

  • Crear una función de benchmark que coincida con la firma utilizada en CustomBenchmark.
  • Crear un dhat::Profiler en modo de prueba, para recopilar resultados de nuestro dhat::Alloc asignador global personalizado.
  • Ejecutar nuestra función play_game dentro de una “caja negra” para que el compilador no optimice nuestro código.
  • Iterar de 1 a 100 inclusivamente.
  • Para cada número, llamar a play_game, con print establecido en false.
  • Devolver nuestras estadísticas de asignación de heap como dhat::HeapStats.

🐰 Establecemos print en false para la función play_game. Esto evita que play_game imprima en la salida estándar. Parametrizar las funciones de tu librería de esta manera puede hacerlas más adecuadas para el benchmarking. Sin embargo, esto significa que puede que no estemos evaluando la librería de la misma manera que se usa en producción.

En este caso, debemos preguntarnos:

  1. ¿Nos importan los recursos que se necesitan para imprimir en la salida estándar?
  2. ¿Es la impresión en la salida estándar una posible fuente de ruido?

Para nuestro ejemplo, hemos decidido:

  1. No, no nos importa imprimir en la salida estándar.
  2. Sí, es una fuente de ruido muy probable.

Por lo tanto, hemos omitido la impresión en la salida estándar como parte de este benchmark. El benchmarking es difícil y, a menudo, no hay una respuesta correcta a preguntas como estas. Depende.

Registrar la Función de Referencia

Con nuestra función de referencia escrita, necesitamos crear un CustomBenchmark y registrarlo en nuestra colección de referencias usando inventory.

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}
inventory::submit!(CustomBenchmark {
name: "bench_play_game",
benchmark_fn: bench_play_game
});

Si tuviéramos más de una referencia, repetiríamos este mismo proceso:

  1. Crear una función de referencia.
  2. Crear un CustomBenchmark para la función de referencia.
  3. Registrar el CustomBenchmark en la colección inventory.

Crear un Ejecutador de Benchmark Personalizado

Finalmente, necesitamos crear un ejecutor para nuestro marco de benchmarks personalizado. Un marco de benchmarks personalizado es realmente solo una aplicación binaria que ejecuta todos nuestros benchmarks y reporta sus resultados. El ejecutor de benchmarks es lo que orquesta todo eso.

Queremos que nuestros resultados se impriman en Formato de Métrica Bencher (BMF) JSON. Para lograr esto, necesitamos añadir una última dependencia, el serde_json crate por… adivinaste, ¡David Tolnay!

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"
serde_json = "1.0"

A continuación, implementaremos un método para CustomBenchmark para ejecutar su función de benchmark y luego devolver los resultados en BMF JSON.

benches/play_game.rs
impl CustomBenchmark {
fn run(&self) -> serde_json::Value {
let heap_stats = (self.benchmark_fn)();
let measures = serde_json::json!({
"Final Blocks": {
"value": heap_stats.curr_blocks,
},
"Final Bytes": {
"value": heap_stats.curr_bytes,
},
"Max Blocks": {
"value": heap_stats.max_blocks,
},
"Max Bytes": {
"value": heap_stats.max_bytes,
},
"Total Blocks": {
"value": heap_stats.total_blocks,
},
"Total Bytes": {
"value": heap_stats.total_bytes,
},
});
let mut benchmark_map = serde_json::Map::new();
benchmark_map.insert(self.name.to_string(), measures);
benchmark_map.into()
}
}

Los resultados en BMF JSON contienen seis Medidas para cada benchmark:

  • Bloques Finales: Número final de bloques asignados cuando el benchmark terminó.
  • Bytes Finales: Número final de bytes asignados cuando el benchmark terminó.
  • Bloques Máximos: Máximo número de bloques asignados en un momento dado durante la ejecución del benchmark.
  • Bytes Máximos: Máximo número de bytes asignados en un momento dado durante la ejecución del benchmark.
  • Bloques Totales: Número total de bloques asignados durante la ejecución del benchmark.
  • Bytes Totales: Número total de bytes asignados durante la ejecución del benchmark.

Finalmente, podemos crear una función main para ejecutar todos los benchmarks en nuestra colección inventory y imprimir los resultados en BMF JSON.

benches/play_game.rs
fn main() {
let mut bmf = serde_json::Map::new();
for benchmark in inventory::iter::<CustomBenchmark> {
let mut results = benchmark.run();
bmf.append(results.as_object_mut().unwrap());
}
let bmf_str = serde_json::to_string_pretty(&bmf).unwrap();
std::fs::write("results.json", &bmf_str).unwrap();
println!("{bmf_str}");
}

Ejecutar el Arnés de Benchmark Personalizado

Todo está ahora en su lugar. Finalmente podemos ejecutar nuestro arnés de benchmark personalizado.

Terminal window
cargo bench

La salida tanto a la salida estándar como a un archivo llamado results.json debería verse así:

{
"bench_play_game": {
"Current Blocks": {
"value": 0
},
"Current Bytes": {
"value": 0
},
"Max Blocks": {
"value": 1
},
"Max Bytes": {
"value": 9
},
"Total Blocks": {
"value": 100
},
"Total Bytes": {
"value": 662
}
}
}

Los números exactos que veas pueden ser un poco diferentes según la arquitectura de tu computadora. Pero lo importante es que al menos tengas algunos valores para las últimas cuatro métricas.

Rastrea Resultados de Benchmarks Personalizados

La mayoría de los resultados de benchmarks son efímeros. Desaparecen tan pronto como el límite de retroceso en tu terminal se alcanza. Algunos arneses de benchmarks te permiten almacenar en caché los resultados, pero eso requiere mucho trabajo para implementar. E incluso entonces, solo podríamos almacenar nuestros resultados localmente. ¡Por suerte para nosotros, nuestro arnés de benchmarking personalizado funcionará con Bencher! Bencher es un conjunto de herramientas de benchmarking continuo que nos permite rastrear los resultados de nuestros benchmarks a lo largo del tiempo y detectar regresiones de rendimiento antes de que lleguen a producción.

Una vez que estés configurado utilizando Bencher Cloud o Bencher Self-Hosted, puedes rastrear los resultados de nuestro arnés de benchmarking personalizado ejecutando:

Terminal window
bencher run --file results.json "cargo bench"

También puedes leer más sobre cómo rastrear benchmarks personalizados con Bencher y el adaptador de benchmarks JSON.

Conclusión

Comenzamos este artículo examinando los tres arneses de evaluación comparativa más populares en el ecosistema de Rust: libtest bench, Criterion, y Iai. Aunque pueden cubrir la mayoría de los casos de uso, a veces es posible que necesites medir algo más que el tiempo de reloj de pared o el recuento de instrucciones. Esto nos llevó a crear un arnés de evaluación comparativa personalizado.

Nuestro arnés de evaluación comparativa personalizado mide las asignaciones en el montón utilizando dhat-rs. Las funciones de evaluación se recopilaron utilizando inventory. Al ejecutarse, nuestras evaluaciones producen resultados en formato JSON de Bencher Metric Format (BMF). Luego podríamos usar Bencher para rastrear nuestros resultados personalizados de evaluación comparativa a lo largo del tiempo y detectar regresiones de rendimiento en CI.

Todo el código fuente de esta guía está disponible en GitHub.

Bencher: Benchmarking continuo

🐰 Bencher

Bencher es un conjunto de herramientas de benchmarking continuo. ¿Alguna vez has tenido un impacto de regresión de rendimiento en tus usuarios? Bencher podría haber evitado que eso sucediera. Bencher te permite detectar y prevenir las regresiones de rendimiento antes de que lleguen a producción.

  • Ejecutar: Ejecute sus benchmarks localmente o en CI usando sus herramientas de benchmarking favoritas. La CLI bencher simplemente envuelve su arnés de benchmarks existente y almacena sus resultados.
  • Seguir: Sigue los resultados de tus benchmarks con el tiempo. Monitoriza, realiza consultas y representa gráficamente los resultados utilizando la consola web de Bencher basándose en la rama de origen, el banco de pruebas y la medida.
  • Capturar: Captura las regresiones de rendimiento en CI. Bencher utiliza analíticas de vanguardia y personalizables para detectar regresiones de rendimiento antes de que lleguen a producción.

Por las mismas razones que las pruebas unitarias se ejecutan en CI para prevenir regresiones funcionales, los benchmarks deberían ejecutarse en CI con Bencher para prevenir regresiones de rendimiento. ¡Los errores de rendimiento son errores!

Empiece a capturar regresiones de rendimiento en CI — prueba Bencher Cloud gratis.

🤖 Este documento fue generado automáticamente por OpenAI GPT-4. Puede que no sea exacto y contenga errores. Si encuentra algún error, abra un problema en GitHub.