Bỏ qua để đến nội dung

Hiệu năng trong Rust

Rust nổi tiếng là nhanh, và tiếng tăm đó hoàn toàn xứng đáng. Nhưng “Rust nhanh” không có nghĩa là mọi chương trình Rust đều nhanh. Một thuật toán chậm thì chậm ở bất kỳ ngôn ngữ nào, và vài thói quen nhỏ có thể âm thầm khiến bạn mất rất nhiều thời gian.

Tin vui là phần lớn những cải thiện lớn đều đơn giản. Build ở chế độ release. Đo trước khi thay đổi. Đừng sao chép dữ liệu mà bạn chỉ cần đọc. Chọn đúng collection. Trang này đi qua từng điều một, kèm số đo thật ở những chỗ cần. Hãy nghĩ như khi chỉnh một chiếc xe đạp: trước hết kiểm tra lốp đã bơm căng chưa (chế độ release), sau đó mới lo tới chuyện thay phụ tùng nhẹ hơn.

Trước khi làm cho thứ gì đó nhanh hơn, bạn cần biết nó chậm tới mức nào. std::time::Instant là một chiếc đồng hồ bấm giờ có sẵn trong thư viện chuẩn. Gọi Instant::now() trước khi làm việc, và .elapsed() sau đó:

use std::time::Instant;
fn main() {
let start = Instant::now();
// Tạo 5 triệu số giả ngẫu nhiên bằng một thuật toán xorshift đơn giản.
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());
}

Định dạng {:?} in một Duration theo cách dễ đọc, ví dụ 250.271745ms. Ở đây cố ý không có nút “Try it”: thời gian đo thay đổi mỗi lần chạy và trên mỗi máy, nên không thể so với một kết quả cố định. Hãy chạy nó trên máy của bạn bằng cargo run. (Các chuỗi được giữ bằng tiếng Anh để khớp với số đo mẫu bên dưới; “took” nghĩa là “mất”.)

Đây là mẹo hiệu năng lớn nhất trong Rust, và nó chỉ là một cờ (flag).

Terminal window
cargo run # debug build: fast to compile, slow to run
cargo run --release # release build: slower to compile, much faster to run

Build debug biên dịch nhanh nhưng chạy chậm; build release biên dịch chậm hơn nhưng chạy nhanh hơn nhiều. Build debug bỏ qua phần lớn tối ưu hóa để biên dịch nhanh và dễ gỡ lỗi. Nó cũng kiểm tra tràn số nguyên. Build release bật trình tối ưu hóa ở mức tối đa.

Đây là một số đo mẫu khi chạy chương trình ở trên ba lần với mỗi chế độ, trên máy chúng tôi dùng khi viết trang này (một máy chủ đám mây với CPU Intel Xeon). Số của bạn sẽ khác; chỉ khoảng cách giữa chúng là đáng quan tâm:

debug build: took 5.02s, 4.27s, 4.83s
release build: took 250ms, 297ms, 265ms

Tức là nhanh hơn khoảng 15–20 lần, chỉ nhờ một cờ. Khoảng cách phụ thuộc nhiều vào code: vòng lặp và iterator được lợi nhiều nhất. Nhưng nó không bao giờ nhỏ tới mức có thể bỏ qua.

Vì vậy quy tắc rất đơn giản: đừng bao giờ đánh giá tốc độ từ một build debug. Nếu ai đó nói “Rust chậm” sau khi đo bằng cargo run, thì gần như chắc chắn đây là lý do.

.clone() tạo một bản sao đầy đủ. Với String hay Vec, điều đó có nghĩa là một lần cấp phát mới trên heap và sao chép từng byte. Đôi khi bạn cần một bản sao, nhưng thường thì bạn chỉ cần đọc dữ liệu, và mượn (borrowing) thì miễn phí.

Trường hợp phổ biến nhất là tham số của hàm. Nếu một hàm chỉ đọc một chuỗi, hãy nhận &str, đừng nhận String:

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 tự động chuyển thành &str, không cần sao chép.
println!("{}", count_long_words(&owned, 5));
// Một string literal vốn đã là &str.
println!("{}", count_long_words("borrowing is cheap and cloning is not", 6));
// Ta chưa bao giờ trao `owned` đi, nên vẫn dùng được nó.
println!("{owned}");
}

Kết quả:

3
2
the quick brown fox jumps over the lazy dog
▶ Tự chạy thử

Các câu ví dụ được giữ bằng tiếng Anh vì w.len() đếm byte, và chữ tiếng Việt có dấu chiếm nhiều byte hơn. Một tham số &str nhận được cả String (mượn bằng &) lẫn string literal. Một tham số String sẽ buộc người gọi phải trao chuỗi của họ đi hoặc clone nó. Ý tưởng tương tự dùng cho danh sách: nhận &[T] thay vì Vec<T>. Xem Mượn và Slice để biết chi tiết.

Cũng hãy để ý clone trong vòng lặp. for name in names.clone() sao chép cả danh sách chỉ để nhìn qua nó. for name in &names mượn nó miễn phí.

Cấp phát một lần, đừng cấp phát đi cấp phát lại

Phần tiêu đề “Cấp phát một lần, đừng cấp phát đi cấp phát lại”

Khi một Vec hết chỗ, nó cấp phát một khối lớn hơn và chuyển mọi thứ sang. Nếu bạn biết trước kích thước, Vec::with_capacity giữ chỗ một lần là xong. Chương trình này đếm số lần mỗi Vec phải tăng kích thước khi đẩy vào 1000 số:

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(): tăng kích thước {} lần", count_growths(Vec::new(), 1000));
println!("Vec::with_capacity(): tăng kích thước {} lần", count_growths(Vec::with_capacity(1000), 1000));
}

Kết quả:

Vec::new(): tăng kích thước 9 lần
Vec::with_capacity(): tăng kích thước 0 lần
▶ Tự chạy thử

Chín lần tăng kích thước không phải thảm họa: mỗi lần Vec tăng gấp đôi, nên chi phí vẫn rẻ. Nhưng trong một vòng lặp nóng tạo ra hàng triệu vector, nó sẽ cộng dồn lại. (Cách tăng kích thước chính xác là chi tiết nội bộ của thư viện chuẩn và có thể thay đổi.)

Còn dễ hơn nữa: để collect() và join() làm việc đó. Chúng thường biết kích thước cuối cùng và chỉ cấp phát một lần:

fn main() {
// collect() tạo Vec một lần là xong.
let squares: Vec<u64> = (1..=5).map(|n| n * n).collect();
println!("{squares:?}");
// join() tính tổng độ dài trước, rồi sao chép một lần.
let colors = ["đỏ", "xanh lá", "xanh dương"];
println!("{}", colors.join(", "));
}

Kết quả:

[1, 4, 9, 16, 25]
đỏ, xanh lá, xanh dương
▶ Tự chạy thử

Có thể bạn lo rằng .iter().filter().map().sum() chậm hơn một vòng lặp thường, vì trông nó như rất nhiều lời gọi hàm. Không phải vậy. Trong build release, trình biên dịch nhúng (inline) tất cả các closure nhỏ đó, và bạn nhận được mã máy gọn y như một vòng lặp viết tay. Rust gọi điều này là trừu tượng không tốn chi phí (zero-cost abstraction): bạn không phải trả giá cho phong cách viết đẹp hơn.

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!("vòng lặp: {}", sum_even_squares_loop(&nums));
println!("iterator: {}", sum_even_squares_iter(&nums));
}

Kết quả:

vòng lặp: 220
iterator: 220
▶ Tự chạy thử

Phiên bản iterator thậm chí có thể nhanh hơn một chút. Với nums[i], Rust kiểm tra i có nằm trong slice hay không ở mỗi lần truy cập. Iterator vốn đã biết nó không thể vượt ra ngoài giới hạn, nên những lần kiểm tra đó biến mất. Hãy dùng cách nào dễ đọc hơn, thường là iterator.

Chọn đúng cấu trúc dữ liệu thường hiệu quả hơn mọi kiểu tinh chỉnh. Một ví dụ kinh điển: kiểm tra “phần tử này có trong danh sách của tôi không?” rất nhiều lần.

  • Vec::contains xem từng phần tử một. Danh sách càng dài, tra cứu càng chậm.
  • HashSet::contains (và HashMap::get) nhảy gần như thẳng tới câu trả lời. Tra cứu vẫn nhanh dù kích thước lớn đến đâu.
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();
// Chậm với danh sách lớn: mỗi lần contains() quét cả Vec.
let hits_vec = queries.iter().filter(|q| banned.contains(q)).count();
// Nhanh: tạo HashSet một lần, sau đó mỗi lần tra cứu đều nhanh.
let banned_set: HashSet<u32> = banned.iter().copied().collect();
let hits_set = queries.iter().filter(|q| banned_set.contains(q)).count();
println!("Vec tìm thấy {hits_vec}, HashSet tìm thấy {hits_set}");
}

Kết quả:

Vec tìm thấy 6667, HashSet tìm thấy 6667
▶ Tự chạy thử

Cùng một kết quả, chi phí rất khác nhau. Khi chúng tôi thêm bộ đếm Instant quanh từng phần và chạy bản build release, đây là một số đo mẫu trên máy của chúng tôi:

Vec: 6667 hits in 27.97ms
HashSet: 6667 hits in 551.60µs

Nhanh hơn khoảng 50 lần, và đó là đã tính cả thời gian tạo set. Tuy vậy, với một danh sách 10 phần tử, Vec hoàn toàn ổn và thậm chí có thể thắng. Tìm hiểu thêm ở các trang HashSet và HashMap.

Lập trình viên nổi tiếng là đoán rất tệ chuyện thời gian đi đâu. Vậy nên đừng đoán: hãy profile. Một profiler quan sát chương trình của bạn chạy và cho biết hàm nào tốn nhiều thời gian nhất. Thường chỉ có một hai điểm nóng, và phần còn lại của code hoàn toàn không đáng kể.

Hai profiler miễn phí, phổ biến, dùng được với Rust:

Terminal window
# flamegraph: draws a picture of where time is spent (Linux, macOS)
cargo install flamegraph
cargo flamegraph
# samply: opens the results in the Firefox Profiler in your browser
cargo install samply
cargo build --release
samply record ./target/release/my_app

flamegraph vẽ một hình ảnh cho thấy thời gian được dùng ở đâu (Linux, macOS); samply mở kết quả trong Firefox Profiler ngay trên trình duyệt. Profiler cần debug symbol để hiện tên hàm. Thêm đoạn này vào Cargo.toml để build release giữ lại chúng (nó không làm code chậm hơn, chỉ làm file lớn hơn):

[profile.release]
debug = true

Đo một lần bằng Instant thì nhiễu. Một benchmark chạy code nhiều lần và dùng thống kê để cho ra một con số đáng tin. #[bench] có sẵn của Rust chỉ chạy trên nightly, nên phần lớn mọi người dùng crate criterion (tiếng Anh). Cài đặt như sau:

[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "fib_bench"
harness = false

Đặt code bạn muốn kiểm tra vào thư viện của bạn (src/lib.rs), rồi viết benchmark trong 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 ngăn trình biên dịch “khôn quá” mà tính sẵn kết quả từ trước, điều sẽ khiến benchmark trở nên vô nghĩa. Chạy nó bằng:

Terminal window
cargo bench

Một kết quả mẫu trên máy của chúng tôi (criterion hiển thị ước lượng thấp, ước lượng tốt nhất và ước lượng cao):

fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]

Chạy lại sau khi thay đổi, và criterion sẽ cho bạn biết code nhanh hơn hay chậm hơn, và sự khác biệt đó là thật hay chỉ là nhiễu.

Thiết lập release mặc định đã tốt. Khi bạn cần thêm một chút, đây là những nút chỉnh thường gặp trong Cargo.toml:

[profile.release]
opt-level = 3 # mặc định cho release; "s" hoặc "z" tối ưu cho kích thước
lto = true # tối ưu lúc liên kết: tối ưu xuyên qua mọi crate (build chậm hơn)
codegen-units = 1 # một unit = nhiều cơ hội tối ưu hơn, nhưng biên dịch chậm hơn
panic = "abort" # bỏ code unwinding; file nhỏ hơn, nhưng không bắt được panic

Những thiết lập này thường chỉ cho thêm vài phần trăm, không phải phép màu, và chúng làm build chậm hơn. Hãy thử từng cái một, và đo mỗi lần. lto = "thin" là lựa chọn trung gian phổ biến, build nhanh hơn lto = true.

Đo thời gian trên build debug. Đã nói ở trên, nhưng đây là lỗi số một: hãy luôn đo với --release.

Clone cho trình biên dịch vừa lòng. Giả sử một hàm nhận String, và bạn gọi nó với một tham chiếu mượn:

fn shout(text: String) -> String {
text.to_uppercase()
}
fn main() {
let name = String::from("ferris");
let loud = shout(&name);
println!("{loud} {name}");
}

Trình biên dịch báo:

error[E0308]: mismatched types
--> src/main.rs:7:22
|
7 | let loud = shout(&name);
| ----- ^^^^^ expected `String`, found `&String`
| |
| arguments to this function are incorrect

Nó mong đợi một String nhưng lại nhận được &String. Rất dễ bị cám dỗ viết shout(name.clone()). Cách đó chạy được, nhưng mỗi lần gọi lại sao chép chuỗi. Cách sửa tốt hơn là đổi tham số thành text: &str; khi đó shout(&name) biên dịch được và không có gì bị sao chép.

println! trong một vòng lặp nóng. Mỗi println! khóa stdout và ghi dòng đó ra ngay. Vài dòng thì không sao. Một triệu dòng thì chậm. Hãy bọc stdout trong một BufWriter để nó ghi theo từng khối lớn:

use std::io::{self, BufWriter, Write};
fn main() {
let mut out = BufWriter::new(io::stdout().lock());
for i in 1..=5 {
writeln!(out, "dòng {i}").unwrap();
}
}

Kết quả:

dòng 1
dòng 2
dòng 3
dòng 4
dòng 5
▶ Tự chạy thử

Trong số đo mẫu của chúng tôi, in một triệu dòng ra file mất khoảng 570–670ms với println! và khoảng 40–60ms với BufWriter (build release).

  • Luôn đo tốc độ với cargo run --release; build debug có thể chậm hơn nhiều lần.
  • Dùng Instant::now() và .elapsed() để đo nhanh, và criterion cho benchmark đáng tin cậy.
  • Mượn (&str, &[T]) thay vì clone khi bạn chỉ cần đọc dữ liệu.
  • Cấp phát một lần: Vec::with_capacity, collect() và join() tránh việc tăng kích thước lặp đi lặp lại.
  • Iterator không tốn chi phí; hãy chọn HashSet/HashMap khi cần tra cứu nhiều lần.
  • Profile trước, rồi tối ưu những điểm nóng mà profiler chỉ ra.

Kiểm tra nhanh

1. Chương trình của bạn có vẻ chậm khi chạy bằng cargo run. Bạn nên thử gì trước tiên?

2. Một hàm chỉ đọc một chuỗi. Kiểu tham số nào tránh được việc sao chép không cần thiết?

3. Bạn cần kiểm tra 50.000 ID có nằm trong danh sách 50.000 ID bị cấm hay không. Đâu là lựa chọn tốt hơn cho danh sách bị cấm?

4. “Iterator không tốn chi phí” có nghĩa là gì?

5. Vì sao benchmark criterion dùng black_box?

Điểm: 0 / 5