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.
Bước số không: đo bằng Instant
Phần tiêu đề “Bước số không: đo bằng Instant”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”.)
Build debug và build release
Phần tiêu đề “Build debug và build release”Đây là mẹo hiệu năng lớn nhất trong Rust, và nó chỉ là một cờ (flag).
cargo run # debug build: fast to compile, slow to runcargo run --release # release build: slower to compile, much faster to runBuild 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.83srelease build: took 250ms, 297ms, 265msTứ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.
Mượn thay vì clone
Phần tiêu đề “Mượn thay vì clone”.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ả:
32the quick brown fox jumps over the lazy dogCá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ầnVec::with_capacity(): tăng kích thước 0 lầnChí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ươngIterator không tốn chi phí
Phần tiêu đề “Iterator không tốn chi phí”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: 220iterator: 220Phiê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 collection
Phần tiêu đề “Chọn đúng collection”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::containsxem 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 6667Cù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.97msHashSet: 6667 hits in 551.60µsNhanh 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.
Profile trước khi tối ưu
Phần tiêu đề “Profile trước khi tối ưu”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:
# flamegraph: draws a picture of where time is spent (Linux, macOS)cargo install flamegraphcargo flamegraph
# samply: opens the results in the Firefox Profiler in your browsercargo install samplycargo build --releasesamply record ./target/release/my_appflamegraph 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 = trueBenchmark với criterion
Phần tiêu đề “Benchmark với criterion”Đ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:
cargo benchMộ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 profile release
Phần tiêu đề “Thiết lập profile release”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ướclto = 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ơnpanic = "abort" # bỏ code unwinding; file nhỏ hơn, nhưng không bắt được panicNhữ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.
Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”Đ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 incorrectNó 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 1dòng 2dòng 3dòng 4dòng 5Trong 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).
Tóm tắt
Phần tiêu đề “Tóm tắt”- 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/HashMapkhi 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
Phần tiêu đề “Kiểm tra nhanh”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
Đọc thêm
Phần tiêu đề “Đọc thêm”- The Rust Performance Book (tiếng Anh): hướng dẫn ngắn, thực tế về thiết lập build, profiling và các cách tăng tốc thường gặp.
- Comparing Performance: Loops vs. Iterators — The Rust Book (tiếng Anh): vì sao iterator được biên dịch thành code nhanh.
- Profiles — The Cargo Book (tiếng Anh): mọi thiết lập
[profile], kèm giá trị mặc định. std::time::Instant(tiếng Anh): chiếc đồng hồ bấm giờ của thư viện chuẩn.- Tài liệu criterion (tiếng Anh): crate benchmark được dùng ở trên.