Salta ai contenuti

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.

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.)

Questo è in assoluto il consiglio più importante sulle prestazioni in Rust, ed è solo un flag.

Terminal window
cargo run # build di debug: veloce da compilare, lenta da eseguire
cargo run --release # build release: più lenta da compilare, molto più veloce da eseguire

Una 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.83s
release 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.

.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:

3
2
the quick brown fox jumps over the lazy dog
▶ Provalo tu

Un 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.

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 volte
Vec::with_capacity(): cresciuto 0 volte
▶ Provalo tu

Nove 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, blu
▶ Provalo tu

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: 220
iteratore: 220
▶ Provalo tu

La 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 struttura dati giusta spesso batte qualsiasi quantità di ritocchi. Un esempio classico: controllare tante volte «questo elemento è nella mia lista?».

  • Vec::contains guarda ogni elemento, uno per uno. Le ricerche rallentano man mano che la lista cresce.
  • HashSet::contains (e HashMap::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 6667
▶ Provalo tu

Stessa 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.97ms
HashSet: 6667 hits in 551.60µs

Circa 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.

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:

Terminal window
# flamegraph: disegna un'immagine di dove viene speso il tempo (Linux, macOS)
cargo install flamegraph
cargo flamegraph
# samply: apre i risultati nel Firefox Profiler, nel tuo browser
cargo install samply
cargo build --release
samply record ./target/release/my_app

I 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 = true

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 = false

Metti 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:

Terminal window
cargo bench

Un 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.

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 dimensione
lto = 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ù lenta
panic = "abort" # niente codice di unwinding; binario più piccolo, ma i panic non si possono intercettare

Di 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.

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 incorrect

Cioè: 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 1
riga 2
riga 3
riga 4
riga 5
▶ Provalo tu

Nella 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).

  • 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() e join() evitano le ricrescite ripetute.
  • Gli iteratori hanno costo zero; scegli un HashSet/HashMap quando fai tante ricerche.
  • Prima profila, poi ottimizza i punti critici che il profiler ti mostra.

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