Prestazioni in Rust
Rust ha fama di essere veloce, e se l’è guadagnata. Ma «Rust è veloce» non significa che ogni programma Rust lo sia. Un algoritmo lento è lento in qualsiasi linguaggio, e alcune abitudini possono costarti parecchio senza che te ne accorga.
La buona notizia: la maggior parte dei grandi guadagni è semplice. Compila in modalità release. Misura prima di cambiare le cose. Non copiare dati che ti servono solo in lettura. Scegli la collezione giusta. Questa pagina li vede uno per uno, con misurazioni reali dove servono. Pensala come la messa a punto di una bici: prima controlli che le gomme siano gonfie (la modalità release), poi ti preoccupi dei componenti più leggeri.
Passo zero: misurare con Instant
Sezione intitolata “Passo zero: misurare con Instant”Prima di rendere qualcosa più veloce, devi sapere quanto è lento. std::time::Instant è un cronometro integrato nella libreria standard. Chiama Instant::now() prima del lavoro e .elapsed() dopo:
use std::time::Instant;
fn main() { let start = Instant::now();
// Genera 5 milioni di numeri pseudo-casuali con un semplice xorshift. let mut x: u64 = 12345; let mut numbers = Vec::with_capacity(5_000_000); for _ in 0..5_000_000 { x ^= x << 13; x ^= x >> 7; x ^= x << 17; numbers.push(x % 1_000_000); }
numbers.sort(); let sum: u64 = numbers.iter().take(1000).sum();
println!("smallest 1000 add up to {sum}"); println!("took {:?}", start.elapsed());}Il formato {:?} stampa una Duration in modo leggibile, per esempio 250.271745ms. Qui manca apposta il pulsante «Prova»: i tempi cambiano a ogni esecuzione e su ogni macchina, quindi non si possono confrontare con un output fisso. Eseguilo invece sul tuo computer con cargo run. (I testi stampati restano in inglese, come nelle misurazioni qui sotto.)
Build di debug e build release
Sezione intitolata “Build di debug e build release”Questo è in assoluto il consiglio più importante sulle prestazioni in Rust, ed è solo un flag.
cargo run # build di debug: veloce da compilare, lenta da eseguirecargo run --release # build release: più lenta da compilare, molto più veloce da eseguireUna build di debug salta la maggior parte delle ottimizzazioni, così compila in fretta ed è facile da debuggare. Controlla anche l’overflow degli interi. Una build release accende l’ottimizzatore al massimo.
Ecco una misurazione d’esempio, ottenuta eseguendo il programma qui sopra tre volte per tipo sulla macchina che abbiamo usato per scrivere questa pagina (un server cloud con CPU Intel Xeon). I tuoi numeri saranno diversi: conta solo la differenza tra i due:
debug build: took 5.02s, 4.27s, 4.83srelease build: took 250ms, 297ms, 265msÈ all’incirca 15–20 volte più veloce, con un solo flag. La differenza dipende molto dal codice: cicli e iteratori ci guadagnano di più. Ma non è mai così piccola da poterla ignorare.
Quindi la regola è semplice: non giudicare mai la velocità da una build di debug. Se qualcuno dice «Rust è lento» dopo aver cronometrato cargo run, il motivo è quasi sempre questo.
Prendere in prestito invece di clonare
Sezione intitolata “Prendere in prestito invece di clonare”.clone() fa una copia completa. Per una String o un Vec significa una nuova allocazione sullo heap e la copia di ogni byte. A volte una copia ti serve, ma spesso ti basta leggere i dati, e un prestito non costa niente.
Il caso più comune sono i parametri delle funzioni. Se una funzione legge soltanto una stringa, fai in modo che prenda &str, non String (qui il testo resta in inglese perché il risultato dipende dalla lunghezza in byte delle parole):
fn count_long_words(text: &str, min_len: usize) -> usize { text.split_whitespace().filter(|w| w.len() >= min_len).count()}
fn main() { let owned = String::from("the quick brown fox jumps over the lazy dog");
// &String diventa &str in automatico, nessuna copia necessaria. println!("{}", count_long_words(&owned, 5));
// Un letterale stringa è già una &str. println!("{}", count_long_words("borrowing is cheap and cloning is not", 6));
// Non abbiamo mai ceduto `owned`, quindi è ancora utilizzabile. println!("{owned}");}Output:
32the quick brown fox jumps over the lazy dogUn parametro &str accetta sia una String (presa in prestito con &) sia un letterale stringa. Un parametro String costringerebbe chi chiama a cedere la propria stringa o a clonarla. La stessa idea vale per le liste: prendi &[T] invece di Vec<T>. Vedi Prestito e Slice per i dettagli.
Attenzione anche ai clone nei cicli. for name in names.clone() copia l’intera lista solo per guardarla. for name in &names la prende in prestito gratis.
Allocare una volta sola, non di continuo
Sezione intitolata “Allocare una volta sola, non di continuo”Quando un Vec finisce lo spazio, alloca un blocco più grande e ci sposta dentro tutto. Se conosci la dimensione in anticipo, Vec::with_capacity la riserva in un colpo solo. Questo programma conta quante volte ogni Vec ha dovuto crescere mentre ci inserivamo 1000 numeri:
fn count_growths(mut v: Vec<u32>, n: u32) -> u32 { let mut growths = 0; let mut cap = v.capacity(); for i in 0..n { v.push(i); if v.capacity() != cap { growths += 1; cap = v.capacity(); } } growths}
fn main() { println!("Vec::new(): cresciuto {} volte", count_growths(Vec::new(), 1000)); println!("Vec::with_capacity(): cresciuto {} volte", count_growths(Vec::with_capacity(1000), 1000));}Output:
Vec::new(): cresciuto 9 volteVec::with_capacity(): cresciuto 0 volteNove ricrescite non sono un disastro: il Vec raddoppia ogni volta, quindi resta economico. Ma in un ciclo critico che costruisce milioni di vettori, il costo si accumula. (Lo schema di crescita esatto è un dettaglio interno della libreria standard e potrebbe cambiare.)
Ancora più semplice: lascia fare il lavoro a collect() e join(). Spesso conoscono la dimensione finale e allocano una volta sola:
fn main() { // collect() costruisce il Vec in un colpo solo. let squares: Vec<u64> = (1..=5).map(|n| n * n).collect(); println!("{squares:?}");
// join() calcola prima la lunghezza totale, poi copia una volta sola. let colors = ["rosso", "verde", "blu"]; println!("{}", colors.join(", "));}Output:
[1, 4, 9, 16, 25]rosso, verde, bluGli iteratori hanno costo zero
Sezione intitolata “Gli iteratori hanno costo zero”Potresti temere che .iter().filter().map().sum() sia più lento di un semplice ciclo, perché sembra fatto di tante chiamate di funzione. Non lo è. In una build release il compilatore mette inline (inlining) tutte quelle piccole closure, e ottieni lo stesso codice macchina compatto di un ciclo scritto a mano. Rust la chiama astrazione a costo zero (zero-cost abstraction): non paghi niente per lo stile più elegante.
fn sum_even_squares_loop(nums: &[u64]) -> u64 { let mut total = 0; for i in 0..nums.len() { if nums[i] % 2 == 0 { total += nums[i] * nums[i]; } } total}
fn sum_even_squares_iter(nums: &[u64]) -> u64 { nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * n).sum()}
fn main() { let nums: Vec<u64> = (1..=10).collect(); println!("ciclo: {}", sum_even_squares_loop(&nums)); println!("iteratore: {}", sum_even_squares_iter(&nums));}Output:
ciclo: 220iteratore: 220La versione con l’iteratore può persino essere un po’ più veloce. Con nums[i], Rust controlla a ogni accesso che i sia dentro la slice. L’iteratore sa già che non può uscire dai limiti, quindi quei controlli spariscono. Usa la forma che si legge meglio, di solito l’iteratore.
Scegliere la collezione giusta
Sezione intitolata “Scegliere la collezione giusta”Scegliere la struttura dati giusta spesso batte qualsiasi quantità di ritocchi. Un esempio classico: controllare tante volte «questo elemento è nella mia lista?».
Vec::containsguarda ogni elemento, uno per uno. Le ricerche rallentano man mano che la lista cresce.HashSet::contains(eHashMap::get) salta quasi direttamente alla risposta. Le ricerche restano veloci qualunque sia la dimensione.
use std::collections::HashSet;
fn main() { let banned: Vec<u32> = (0..20_000).map(|n| n * 3).collect(); let queries: Vec<u32> = (0..20_000).collect();
// Lento per liste grandi: ogni contains() scorre tutto il Vec. let hits_vec = queries.iter().filter(|q| banned.contains(q)).count();
// Veloce: costruisci un HashSet una volta, poi ogni ricerca è rapida. let banned_set: HashSet<u32> = banned.iter().copied().collect(); let hits_set = queries.iter().filter(|q| banned_set.contains(q)).count();
println!("Vec ne ha trovati {hits_vec}, HashSet ne ha trovati {hits_set}");}Output:
Vec ne ha trovati 6667, HashSet ne ha trovati 6667Stessa risposta, costo molto diverso. Quando abbiamo aggiunto dei timer Instant attorno a ogni parte ed eseguito una build release, questa è una misurazione d’esempio sulla nostra macchina:
Vec: 6667 hits in 27.97msHashSet: 6667 hits in 551.60µsCirca 50 volte più veloce, e questo compreso il tempo per costruire il set. Per una lista di 10 elementi, però, un Vec va benissimo e può persino vincere. Scopri di più nelle pagine su HashSet e HashMap.
Profilare prima di ottimizzare
Sezione intitolata “Profilare prima di ottimizzare”I programmatori sono notoriamente pessimi nell’indovinare dove se ne va il tempo. Quindi non tirare a indovinare: profila. Un profiler osserva il programma mentre gira e ti dice quali funzioni prendono più tempo. Di solito sono uno o due punti critici, e il resto del codice non conta per niente.
Due profiler popolari e gratuiti che funzionano con Rust:
# flamegraph: disegna un'immagine di dove viene speso il tempo (Linux, macOS)cargo install flamegraphcargo flamegraph
# samply: apre i risultati nel Firefox Profiler, nel tuo browsercargo install samplycargo build --releasesamply record ./target/release/my_appI profiler hanno bisogno dei simboli di debug per mostrare i nomi delle funzioni. Aggiungi questo a Cargo.toml perché le build release li conservino (non rende il codice più lento, solo il file più grande):
[profile.release]debug = trueBenchmark con criterion
Sezione intitolata “Benchmark con criterion”Cronometrare qualcosa una volta sola con Instant dà risultati ballerini. Un benchmark esegue il codice molte volte e usa la statistica per darti un numero affidabile. Il #[bench] integrato in Rust funziona solo su nightly, quindi quasi tutti usano il crate criterion (in inglese). Configuralo così:
[dev-dependencies]criterion = "0.8"
[[bench]]name = "fib_bench"harness = falseMetti il codice che vuoi misurare nella tua libreria (src/lib.rs), poi scrivi il benchmark in benches/fib_bench.rs:
use criterion::{criterion_group, criterion_main, Criterion};use my_crate::fibonacci;use std::hint::black_box;
fn bench_fib(c: &mut Criterion) { c.bench_function("fibonacci 20", |b| b.iter(|| fibonacci(black_box(20))));}
criterion_group!(benches, bench_fib);criterion_main!(benches);black_box impedisce al compilatore di fare il furbo e calcolare la risposta in anticipo, cosa che renderebbe il benchmark privo di senso. Eseguilo con:
cargo benchUn risultato d’esempio sulla nostra macchina (criterion mostra una stima bassa, la stima migliore e una stima alta):
fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]Eseguilo di nuovo dopo una modifica, e criterion ti dice se il codice è diventato più veloce o più lento, e se la differenza è reale o solo rumore.
Impostazioni del profilo release
Sezione intitolata “Impostazioni del profilo release”Le impostazioni release predefinite sono buone. Quando ti serve qualcosa in più, queste sono le manopole più comuni in Cargo.toml:
[profile.release]opt-level = 3 # il valore predefinito per release; "s" o "z" ottimizzano invece per la dimensionelto = true # ottimizzazione in fase di link: ottimizza tra tutti i crate (build più lenta)codegen-units = 1 # una sola unità = più occasioni di ottimizzare, ma compilazione più lentapanic = "abort" # niente codice di unwinding; binario più piccolo, ma i panic non si possono intercettareDi solito danno qualche punto percentuale, non miracoli, e rendono le build più lente. Provale una alla volta e misura ognuna. lto = "thin" è una via di mezzo molto usata, più veloce da compilare di lto = true.
Errori comuni
Sezione intitolata “Errori comuni”Cronometrare una build di debug. L’abbiamo già detto, ma è l’errore numero uno: misura sempre con --release.
Clonare per accontentare il compilatore. Diciamo che una funzione prende una String, e tu la chiami con un prestito:
fn shout(text: String) -> String { text.to_uppercase()}
fn main() { let name = String::from("ferris"); let loud = shout(&name); println!("{loud} {name}");}Il compilatore dice:
error[E0308]: mismatched types --> src/main.rs:7:22 |7 | let loud = shout(&name); | ----- ^^^^^ expected `String`, found `&String` | | | arguments to this function are incorrectCioè: i tipi non corrispondono, si aspettava una String e ha trovato una &String. Viene la tentazione di scrivere shout(name.clone()). Funziona, ma copia la stringa a ogni chiamata. La soluzione migliore è cambiare il parametro in text: &str: così shout(&name) compila e non si copia niente.
println! in un ciclo critico. Ogni println! blocca lo stdout e scrive subito la riga. Per poche righe va bene. Per un milione è lento. Avvolgi lo stdout in un BufWriter, così scrive a blocchi grandi:
use std::io::{self, BufWriter, Write};
fn main() { let mut out = BufWriter::new(io::stdout().lock()); for i in 1..=5 { writeln!(out, "riga {i}").unwrap(); }}Output:
riga 1riga 2riga 3riga 4riga 5Nella nostra misurazione d’esempio, stampare un milione di righe su un file ha richiesto circa 570–670ms con println! e circa 40–60ms con BufWriter (build release).
Riepilogo
Sezione intitolata “Riepilogo”- Misura sempre la velocità con
cargo run --release: le build di debug possono essere molte volte più lente. - Usa
Instant::now()e.elapsed()per cronometrare al volo, e criterion per benchmark affidabili. - Prendi in prestito (
&str,&[T]) invece di clonare quando ti serve solo leggere i dati. - Alloca una volta sola:
Vec::with_capacity,collect()ejoin()evitano le ricrescite ripetute. - Gli iteratori hanno costo zero; scegli un
HashSet/HashMapquando fai tante ricerche. - Prima profila, poi ottimizza i punti critici che il profiler ti mostra.
Verifica veloce
Sezione intitolata “Verifica veloce”Verifica veloce
1. Il tuo programma sembra lento quando lo esegui con cargo run. Cosa dovresti provare per prima cosa?
2. Una funzione legge soltanto una stringa. Quale tipo di parametro evita copie inutili?
3. Devi controllare se 50.000 ID sono in una lista di 50.000 ID vietati. Qual è la scelta migliore per la lista dei vietati?
4. Cosa significa «gli iteratori hanno costo zero»?
5. Perché un benchmark con criterion usa black_box?
Punteggio: 0 / 5
Per approfondire
Sezione intitolata “Per approfondire”- The Rust Performance Book (in inglese): una guida breve e pratica a impostazioni di build, profiling e accelerazioni comuni.
- Comparing Performance: Loops vs. Iterators — The Rust Book (in inglese): perché gli iteratori vengono compilati in codice veloce.
- Profiles — The Cargo Book (in inglese): ogni impostazione di
[profile], con il suo valore predefinito. std::time::Instant(in inglese): il cronometro della libreria standard.- Documentazione di criterion (in inglese): il crate per i benchmark usato qui sopra.