Cải thiện hiệu năng bộ giải mã video rav1d
(ohadravid.github.io)- Bộ giải mã AV1 rav1d viết bằng Rust chậm hơn khoảng 6 giây, tương đương 9%, so với dav1d viết bằng C trên cùng đầu vào, và hai tối ưu hóa nhỏ đã giảm thời gian chạy từ 73.914 giây xuống 72.182 giây
- Phân tích dùng
samplyđể so sánh hai binary trong cùng điều kiện, lấy các hàm assembly Arm dùng chung làm điểm neo để lần theo khác biệt giữa lớp bọc Rust và phần cài đặt hàm - Cải tiến đầu tiên tránh việc khởi tạo 0 cho bộ đệm tạm ở nhánh Arm bằng
MaybeUninitvà dời vị trí khởi tạolr_bak, giúp giảm khoảng 1.6% tổng thời gian chạy - Cải tiến thứ hai thay phép so sánh kém hiệu quả do
PartialEqmặc định sinh ra chostructsố nhỏ bằng phép so sánh dựa trênas_bytes()củazerocopy, tiết kiệm thêm khoảng 0.5 giây - Hai PR này mang lại tổng cộng 2.3% cải thiện mà không thêm
unsafemới, nhưng phép đo chỉ giới hạn ở benchmark đầu vào cụ thể, đơn luồng, trên macOS với chip M3, và vẫn còn chênh khoảng 4.2 giây so với dav1d
Hiệu năng gốc và môi trường đo
rav1dlà bản port sang Rust củadav1d- chuyển đổi
dav1dbằngc2rust - tích hợp các hàm tối ưu hóa assembly của
dav1d - bao gồm công việc biến mã nguồn trở nên đúng chất Rust hơn và an toàn hơn
- chuyển đổi
- memorysafety.org đã tổ chức cuộc thi cải thiện hiệu năng
rav1d, và ở trạng thái chuẩn thìrav1dviết bằng Rust chậm hơn khoảng 5% so vớidav1dviết bằng C - Phép đo cục bộ được thực hiện trên MacBook Air M3, môi trường 8 lõi
rav1d: commita654c1e82adb2d9a33ae50d2a82a7a747102cbb6rustc 1.88.0-nightly, LLVM20.1.2dav1d:1.5.1- Homebrew clang
20.1.4 - tệp đầu vào:
Chimera-AV1-8bit-1920x1080-6736kbps.ivf - tùy chọn chạy:
--threads 1, đầu ra là/dev/null
- Kết quả
hyperfineban đầu là rav1d 73.914 giây, dav1d 67.912 giây- trên cùng tệp mẫu,
rav1dchậm hơn khoảng 6 giây, tức 9% - phiên bản LLVM của
clangvàrustcchỉ khác ở phiên bản vá
- trên cùng tệp mẫu,
Cách tiếp cận profiling
- Việc profiling sử dụng
samply- tốc độ lấy mẫu mặc định là 1000Hz
- chênh lệch 500 mẫu ở một hàm cụ thể tương đương xấp xỉ 0.5 giây thời gian chạy
- Vì hai binary tương tự nhau và hoạt động một cách xác định, cách so sánh chênh lệch số mẫu theo từng hàm hiệu quả hơn là phải hiểu lại toàn bộ bộ giải mã video
- Các lệnh gọi assembly tối ưu hóa dùng chung được lấy làm điểm neo
dav1dgọicdef_filter_8x8_neon,cdef_filter_4x4_neonrồi dispatch tới các hàm assembly liên quanrav1ddùngcdef_filter_neon_erasedđể xử lý mọi dispatch tới hàm assembly
- Số mẫu của
cdef_filter8_pri_sec_edged_8bpc_neongần như giống nhau trong hai snapshot, xác nhận rằng hướng so sánh là đúng - Chênh lệch ở
cdef_filter_neon_erasedvàrav1d_cdef_browcộng lại tương đương khoảng 1% tổng thời gian chạy củarav1d- tổng Self sample của
cdef_filter_{8x8,4x4}_neontrongdav1dlà khoảng 400 - Self sample của
cdef_filter_neon_erasedtrongrav1dlà khoảng 670 dav1d_cdef_brow_8bpccó 1790 mẫu, cònrav1d_cdef_browcó 2350 mẫu
- tổng Self sample của
Cải tiến 1: bỏ khởi tạo 0 cho bộ đệm tạm
cdef_filter_neon_erasedtạo bộ đệm tạm bằngAlign16([0u16; TMP_LEN])TMP_LENtrong trường hợp xấu nhất là12 * 16 + 8 = 200- kết quả là một bộ đệm tạm tương đương
[u16; 200]được điền 0
- Mã C
dav1dtương ứng tạo bộ đệm stack ở dạnguint16_t tmp_buf[200] __attribute__((aligned(16)))nhưng không khởi tạo- bộ đệm này là đích ghi của hàm assembly
padding - sau đó hàm assembly
filterdùng trực tiếp giá trị đó
- bộ đệm này là đích ghi của hàm assembly
- Trong LLVM IR của
rav1dxuất hiện mã dùngllvm.memsetđể điền 0 400 byte- trình biên dịch Rust không thể biết rằng có thể loại bỏ phần khởi tạo này
- Dùng
MaybeUninitđể tránh khởi tạo 0 cho bộ đệm tạm- đổi
Align16([0u16; TMP_LEN])thànhAlign16([MaybeUninit::<u16>::uninit(); TMP_LEN]) - điều chỉnh chữ ký hàm nội bộ thành dạng
tmp: *mut MaybeUninit<u16>,tmp: &[MaybeUninit<u16>] - vì được xử lý trong đường mã vốn đã là
unsafe, không cần thêm khốiunsafemới
- đổi
- Sau thay đổi, Self sample của
cdef_filter_neon_erasedgiảm từ 670 xuống 274- thấp hơn một chút so với tổng Self sample của
cdef_filter_{8x8,4x4}_neontrongdav1d
- thấp hơn một chút so với tổng Self sample của
Phần mở rộng của cải tiến 1: giảm khởi tạo bên trong vòng lặp
- Trong quá trình tìm thêm các bộ đệm
Align16lớn, người ta phát hiện phần khởi tạolr_baktrongrav1d_cdef_brow- mã cũ khởi tạo 0 cho
lr_bakở mỗi vòng lặp - mã
dav1dtương ứng không khởi tạo bộ đệm này
- mã cũ khởi tạo 0 cho
- Ở đây việc chuyển sang
MaybeUninitkhó hơn, nên phần tạolr_bakđược đưa ra ngoài vòng lặp- thay vì khởi tạo ở mỗi vòng lặp, nó chỉ được thực hiện một lần
- mức tiết kiệm nhỏ, nhưng vẫn loại bỏ cùng một kiểu công việc thừa
- Trong benchmark tổng thể sau khi gồm cả thay đổi này,
rav1dđạt 72.644 giây- cải thiện 1.2 giây so với 73.914 giây trước đó
- tương đương khoảng 1.5% theo tổng runtime
- vẫn còn cách biệt so với 67.912 giây của
dav1d
Cải tiến 2: tối ưu hóa so sánh bằng nhau của struct nhỏ
- Khi profiling lại bằng chế độ inverted stack, một khác biệt đáng chú ý xuất hiện ở
add_temporal_candidate- chênh lệch giữa bản Rust và C là khoảng 400 mẫu, tương đương khoảng 0.5 giây
- bản thân hàm chỉ gồm khoảng 50 dòng
if,forvà các lời gọi utility ngắn
- Build lại với profile
release-with-debugđể xem phân bố mẫu theo từng dòngif cand.mv.mv[0] == mv {if cand.mv == mvp {- hai dòng này cộng lại chiếm khoảng 600 mẫu
Mvtrong Rust là một struct nhỏ dùng#[derive(PartialEq)]#[repr(C)]y: i16,x: i16
mvtrongdav1dđược định nghĩa làunionstruct { int16_t y, x; }uint32_t n- khi so sánh, nó dùng kiểu
mvstack[n].mv.n == mvp.nđể so sánh dưới dạng giá trị 32 bit
- Nếu dùng
uniontrong Rust thì truy cập field sẽ thànhunsafe, có thể ảnh hưởng đến toàn bộ chỗ dùngMv- thay vào đó,
AsBytescủazerocopyđược dùng để so sánh biểu diễn byte - trong
impl PartialEq for Mv, dùngself.as_bytes() == other.as_bytes() - kiểm tra trên Godbolt cho thấy nó sinh ra assembly tối ưu tương tự cách dựa trên
transmute
- thay vào đó,
- Tối ưu hóa tương tự cũng được áp dụng cho
RefMvs{Mv,Ref}Pair- kết quả benchmark là 72.182 giây
- cải thiện khoảng 0.5 giây so với 72.644 giây trước đó
- nhanh hơn 2.3% so với mốc ban đầu 73.914 giây
Giới hạn của PartialEq mặc định trong Rust và sinh mã
- Lý do
PartialEqmặc định của struct nhỏ sinh mã kém hiệu quả có liên quan đến issue Rust#140167 - Trong C, với
struct { int16_t y, x; }, có thể tồn tại trạng thái chỉ khởi tạoycònxthì chưa- nếu phép so sánh là
this.y == other.y && this.x == other.xvà mọiyđều khác nhau, thì không cần đọcx - xét các trường hợp như vậy, tối ưu hóa thành một lần nạp bộ nhớ duy nhất chỉ hợp lệ khi có đảm bảo rằng mọi field luôn được khởi tạo
- nếu phép so sánh là
- Thảo luận liên quan đề cập rằng LLVM không có cách biểu diễn thuộc tính kiểu “mọi lần load qua con trỏ này luôn đọc các byte đã được khởi tạo”
zerocopycó thể kiểm tra tĩnh các điều kiện an toàn để biểu diễn struct như lát byte, nên có thể hiện thực phép so sánh tối ưu mà không cần thêmunsafemới
Kết quả cuối cùng và khoảng cách hiệu năng còn lại
- PR đầu tiên tránh khởi tạo 0 tốn kém trên đường nóng chỉ dành cho Arm
- PR #1397
- cải thiện thời gian chạy 1.2 giây
- khoảng -1.6%
- PR thứ hai đổi cách cài đặt
PartialEqmặc định cho các struct số nhỏ sang so sánh dựa trên byte- PR #1400
- cải thiện thời gian chạy 0.5 giây
- khoảng -0.7%
- Hai thay đổi này cộng lại chỉ ở quy mô vài chục dòng mã và không đưa thêm
unsafemới vào codebase - Thời gian chạy cuối cùng của
rav1dlà 72.182 giây, nhanh hơn 2.3% so với điểm xuất phát- vẫn chậm hơn khoảng 4.2 giây so với 67.912 giây của
dav1d - đã thu hẹp khoảng 30% chênh lệch hiệu năng quan sát được lúc đầu
- vẫn chậm hơn khoảng 4.2 giây so với 67.912 giây của
- Giữa hai bản cài đặt vẫn còn khoảng 6% chênh lệch, và việc so sánh snapshot profiler giữa
dav1dvàrav1dvẫn có thể tiếp tục được dùng để tìm thêm tối ưu hóa
1 bình luận
Các ý kiến trên Hacker News
Vấn đề liên quan đến so sánh hai u16 khá thú vị
https://github.com/rust-lang/rust/issues/140167
Mã sinh ra với
-O3thì kỳ lạ, nhưng output của-O2thì hợp lý. Nếu một struct vừa được tính xong, việc cố đọc nó bằng một lần load 32-bit duy nhất có thể gây lỗi store forwarding, làm mất lợi ích của việc gộp load. Trong tình huống không inline và không có PGO, compiler thiếu thông tin để xác định tối ưu hóa đó có phù hợp hay khôngVới tư cách web developer, GitHub issue nhiều khi khá tệ
Cũng khó chắc rằng compiler C có thể xử lý vấn đề này tốt hơn trong trường hợp tổng quát
Có vẻ vì những nội dung như thế này mà tài khoản Twitter của ffmpeg có lập trường phản đối Rust
https://x.com/ffmpeg/status/1924137645988356437?s=46
https://github.com/memorysafety/rav1d/issues/1294
Vì không đăng nhập nên tôi chỉ thấy tweet gốc, không biết phần trả lời có giải thích gì thêm không
Tiếc là không có lựa chọn thay thế đúng nghĩa, và các developer trông khá hiếu chiến. Nếu kiểm soát toàn bộ pipeline thì hiệu năng tối đa sẽ tốt, nhưng nếu phải nhận dữ liệu không đáng tin từ người dùng bất kỳ, ffmpeg mỗi năm có ít nhất năm sáu CVE có thể khai thác từ xa. Tốt nhất nên sandbox thật chặt
https://ffmpeg.org/security.html
Tôi nghĩ có một điểm trung gian để mọi người cùng tiến tới một giải pháp vừa an toàn vừa nhanh, thay vì lập trường mà mỗi bên đang khăng khăng giữ
Dù có tinh chỉnh tiêu chí tính thành tích Olympic để hồi tố sửa kỷ lục 100m của Bolt từ 9,63 giây thành 9,64 giây thì cũng chẳng ai quan tâm. Nhưng nếu thực sự chạy 100m trong 9 giây thì sẽ được chú ý. Tất nhiên đó là khi là con người; nếu là đà điểu thì không ấn tượng lắm, nhưng nhìn chung đà điểu không thi 100m Olympic
Thú vị là một bài viết về lợi ích hiệu năng từ việc không khởi tạo buffer về 0 lại xuất hiện hai ngày sau bài này
https://news.ycombinator.com/item?id=44032680
Tiêu đề đánh giá thấp bài viết
Thực ra nó nhanh hơn 2,3% nhờ hai tối ưu hóa tốt
Nếu xem Arm và x86 sẽ chiếm phần lớn phân phối trong tương lai, có lẽ tính khoảng một nửa sẽ hợp lý hơn
Bài viết hay, và phần phát hiện code kém hiệu quả trong so sánh cặp số nguyên 16-bit rất thú vị
Rust có thể có thông tin chính xác hơn nhiều về việc bộ nhớ đã được khởi tạo hay chưa
Nếu các điều kiện như nhau, tôi nghĩ codec nên được viết bằng WUFFS hơn là Rust
Tuy nhiên việc viết lại thứ phức tạp như dav1d bằng WUFFS có thể lớn hơn rất nhiều so với việc dọn dẹp kết quả chuyển đổi c2rust. Nói khó hơn cả nghìn lần tôi cũng tin. Dù vậy, xét trên toàn bộ nền văn minh thì tôi nghĩ việc đó đáng làm
Tôi đang nói đến WUFFS hoặc một ngôn ngữ chuyên dụng tương đương, và WUFFS thì đã có sẵn
Không có cấp phát bộ nhớ động thì khó xử lý dữ liệu động. Video codec không chỉ đơn giản là parse file để lấy dữ liệu, mà còn phải quản lý rất nhiều trạng thái khá động
Nếu bài viết mở đầu bằng một meme hài hước thì có thể biết đó là bài hay
Có vẻ cũng liên quan đến cuộc thảo luận gần đây: $20K Bounty Offered for Optimizing Rust Code in Rav1d AV1 Decoder (memorysafety.org) | 108 comments | https://news.ycombinator.com/item?id=43982238
Thành thật mà nói, tôi hơi ngạc nhiên là tối ưu hóa đầu tiên là thứ khá rõ ràng chỉ với perf
Hình như bài đầu tiên đã bàn về vấn đề khởi tạo buffer về 0, còn tối ưu hóa thứ hai thì chắc chắn phức tạp và thú vị hơn, nhưng dù sao perf cũng đã chỉ ra. Không nên đánh giá thấp công cụ này
Có
perf diff, nhưng nó không khớp được các tên symbol khác nhau, và có vẻ cũng không nhiều người dùngTôi thường thấy những người đến từ bối cảnh khác phát hiện ra các khoảng trống mà “nhìn lại thì thấy rõ”
Cái này thật sự rất thú vị
Tôi đã tự hỏi liệu có gì ngăn rustc thực hiện mẹo transmute đó không; nếu đọc đoạn tiếp theo thì tôi đã biết issue này trước khi bình luận
https://github.com/rust-lang/rust/issues/140167