1 điểm bởi GN⁺ 2025-05-23 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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 MaybeUninit và dời vị trí khởi tạo lr_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 PartialEq mặc định sinh ra cho struct số nhỏ bằng phép so sánh dựa trên as_bytes() của zerocopy, 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 unsafe mớ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

  • rav1d là bản port sang Rust của dav1d
    • chuyển đổi dav1d bằng c2rust
    • 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
  • 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ì rav1d viết bằng Rust chậm hơn khoảng 5% so với dav1d viế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: commit a654c1e82adb2d9a33ae50d2a82a7a747102cbb6
    • rustc 1.88.0-nightly, LLVM 20.1.2
    • dav1d: 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ả hyperfine ban đầu là rav1d 73.914 giây, dav1d 67.912 giây
    • trên cùng tệp mẫu, rav1d chậm hơn khoảng 6 giây, tức 9%
    • phiên bản LLVM của clangrustc chỉ khác ở phiên bản vá

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
    • dav1d gọi cdef_filter_8x8_neon, cdef_filter_4x4_neon rồi dispatch tới các hàm assembly liên quan
    • rav1d dùng cdef_filter_neon_erased để xử lý mọi dispatch tới hàm assembly
  • Số mẫu của cdef_filter8_pri_sec_edged_8bpc_neon gầ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_erasedrav1d_cdef_brow cộng lại tương đương khoảng 1% tổng thời gian chạy của rav1d
    • tổng Self sample của cdef_filter_{8x8,4x4}_neon trong dav1d là khoảng 400
    • Self sample của cdef_filter_neon_erased trong rav1d là khoảng 670
    • dav1d_cdef_brow_8bpc có 1790 mẫu, còn rav1d_cdef_brow có 2350 mẫu

Cải tiến 1: bỏ khởi tạo 0 cho bộ đệm tạm

  • cdef_filter_neon_erased tạo bộ đệm tạm bằng Align16([0u16; TMP_LEN])
    • TMP_LEN trong 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 dav1d tương ứng tạo bộ đệm stack ở dạng uint16_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 filter dùng trực tiếp giá trị đó
  • Trong LLVM IR của rav1d xuất hiện mã dùng llvm.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ành Align16([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ối unsafe mới
  • Sau thay đổi, Self sample của cdef_filter_neon_erased giả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}_neon trong dav1d

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 Align16 lớn, người ta phát hiện phần khởi tạo lr_bak trong rav1d_cdef_brow
    • mã cũ khởi tạo 0 cho lr_bak ở mỗi vòng lặp
    • dav1d tương ứng không khởi tạo bộ đệm này
  • Ở đây việc chuyển sang MaybeUninit khó hơn, nên phần tạo lr_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, for và 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òng
    • if 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
  • Mv trong Rust là một struct nhỏ dùng #[derive(PartialEq)]
    • #[repr(C)]
    • y: i16, x: i16
  • mv trong dav1d được định nghĩa là union
    • struct { 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 union trong Rust thì truy cập field sẽ thành unsafe, có thể ảnh hưởng đến toàn bộ chỗ dùng Mv
    • thay vào đó, AsBytes của zerocopy được dùng để so sánh biểu diễn byte
    • trong impl PartialEq for Mv, dùng self.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
  • 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 PartialEq mặ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ạo y còn x thì chưa
    • nếu phép so sánh là this.y == other.y && this.x == other.x và mọi y đều khác nhau, thì không cần đọc x
    • 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
  • 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”
  • zerocopy có 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êm unsafe mớ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 PartialEq mặ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 unsafe mới vào codebase
  • Thời gian chạy cuối cùng của rav1d72.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
  • 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 dav1drav1d vẫn có thể tiếp tục được dùng để tìm thêm tối ưu hóa

1 bình luận

 
GN⁺ 2025-05-23
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

    • Tôi ngạc nhiên là trong cuộc thảo luận đó không nhắc đến store forwarding
      Mã sinh ra với -O3 thì kỳ lạ, nhưng output của -O2 thì 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ông
    • Tôi thích là cuộc thảo luận này không phải 14 trang bình luận kiểu “tôi cũng gặp lỗi này”, “bao giờ sửa?”
      Với tư cách web developer, GitHub issue nhiều khi khá tệ
    • Trường hợp này dường như cho thấy độ phức tạp của việc viết compiler
      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

    • Thường thì tôi tin các benchmark của rbultje, nhưng issue theo dõi rav1d có số liệu đa luồng trên nhiều nền tảng và không cho thấy chênh lệch lớn như vậy
      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
    • Chỉ đọc tài khoản Twitter của ffmpeg thôi cũng đủ khiến tôi không muốn dùng ffmpeg
      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ữ
    • Phản ứng lành mạnh hơn có lẽ là làm cho dav1d nhanh hơn
      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

    • Tối ưu hóa 1,5% là chỉ dành cho aarch64, nên có vẻ hơi không công bằng nếu dùng nguyên con số đó để nói cho tổng thể
      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ị

    • Tôi tự hỏi phía Rust/LLVM có thể cải thiện compiler để áp dụng tối ưu hóa này bất cứ khi nào có thể hay không
      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

    • WUFFS có lẽ rất tuyệt cho phân tích cú pháp file container như Matroska, webm, mp4, nhưng hoàn toàn không có vẻ phù hợp với video decoder
      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

    • Nhìn thì không chỉ đơn giản là dùng perf, mà gần với việc profiling đối chiếu giữa bản C và bản Rust rồi tự tay ghép tương ứng
      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ùng
    • Có lẽ điều này khả thi vì họ tiếp cận từ góc nhìn aarch64 trên thiết bị Apple
      Tô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