1P by GN⁺ | ★ favorite | 댓글 1개
  • Rust 기반 AV1 디코더 rav1d는 같은 입력에서 C 기반 dav1d보다 약 6초, 9% 느렸고, 두 개의 작은 최적화로 실행 시간이 73.914초에서 72.182초로 줄어듦
  • 분석은 samply로 두 바이너리를 같은 조건에서 비교하고, 공통 Arm 어셈블리 함수를 앵커로 삼아 Rust 래퍼와 함수 구현의 차이를 추적함
  • 첫 번째 개선은 Arm 경로의 임시 버퍼 0 초기화를 MaybeUninit으로 피하고 lr_bak 초기화 위치를 옮겨, 전체 런타임을 약 1.6% 줄임
  • 두 번째 개선은 작은 수치 struct의 기본 PartialEq가 만든 비효율적 비교를 zerocopyas_bytes() 기반 비교로 바꿔 약 0.5초를 추가로 절감함
  • 두 PR은 새 unsafe 없이 총 2.3% 개선을 만들었지만, 측정은 macOS M3 칩, 단일 스레드, 특정 벤치마크 입력에 한정되며 dav1d와는 여전히 약 4.2초 차이가 남음

기준 성능과 측정 환경

  • rav1ddav1d의 Rust 포트임
    • c2rustdav1d를 변환
    • dav1d의 어셈블리 최적화 함수를 통합
    • 코드를 더 Rust답고 안전하게 바꾸는 작업을 포함
  • memorysafety.org는 rav1d 성능 개선 콘테스트를 열었고, 기준 상태에서는 Rust 기반 rav1d가 C 기반 dav1d보다 약 5% 느렸음
  • 로컬 측정은 MacBook Air M3, 8코어 환경에서 수행됨
    • rav1d: commit a654c1e82adb2d9a33ae50d2a82a7a747102cbb6
    • rustc 1.88.0-nightly, LLVM 20.1.2
    • dav1d: 1.5.1
    • Homebrew clang 20.1.4
    • 입력 파일: Chimera-AV1-8bit-1920x1080-6736kbps.ivf
    • 실행 옵션: --threads 1, 출력은 /dev/null
  • 초기 hyperfine 결과는 rav1d 73.914초, dav1d 67.912초였음
    • 같은 샘플 파일에서 rav1d가 약 6초, 9% 느림
    • clangrustc의 LLVM 버전은 패치 버전만 달랐음

프로파일링 접근법

  • 프로파일링에는 samply를 사용함
    • 기본 샘플링 속도는 1000Hz
    • 특정 함수에서 500샘플 차이는 대략 0.5초 실행 시간 차이에 해당
  • 두 바이너리가 유사하고 결정적으로 동작하기 때문에, 비디오 디코더 전체를 새로 이해하기보다 함수별 샘플 차이를 비교하는 방식이 유효했음
  • 공통으로 사용하는 최적화 어셈블리 호출을 앵커로 삼음
    • dav1dcdef_filter_8x8_neon, cdef_filter_4x4_neon을 호출하고 각각 관련 어셈블리 함수를 디스패치함
    • rav1dcdef_filter_neon_erased가 모든 어셈블리 함수 디스패치를 처리함
  • cdef_filter8_pri_sec_edged_8bpc_neon의 샘플 수는 두 스냅샷에서 거의 같아, 비교 방향이 맞는 것으로 확인됨
  • cdef_filter_neon_erasedrav1d_cdef_brow의 차이는 합쳐서 rav1d 전체 실행 시간의 약 1% 에 해당함
    • dav1dcdef_filter_{8x8,4x4}_neon Self 샘플 합계는 약 400
    • rav1dcdef_filter_neon_erased Self 샘플은 약 670
    • dav1d_cdef_brow_8bpc는 1790샘플, rav1d_cdef_brow는 2350샘플

개선 1: 임시 버퍼의 0 초기화 제거

  • cdef_filter_neon_erased는 임시 버퍼를 Align16([0u16; TMP_LEN])으로 생성함
    • TMP_LEN은 최악의 경우 12 * 16 + 8 = 200
    • 결과적으로 [u16; 200]에 해당하는 임시 버퍼를 0으로 채움
  • 대응되는 dav1d C 코드는 uint16_t tmp_buf[200] __attribute__((aligned(16))) 형태의 스택 버퍼를 만들지만 초기화하지 않음
    • 이 버퍼는 padding 어셈블리 함수의 쓰기 대상이 됨
    • 이후 filter 어셈블리 함수가 그 값을 그대로 사용함
  • rav1d의 LLVM IR에는 llvm.memset으로 400바이트를 0으로 채우는 코드가 나타남
    • Rust 컴파일러는 이 초기화를 제거해도 된다는 사실을 알 수 없었음
  • MaybeUninit을 사용해 임시 버퍼의 0 초기화를 피함
    • Align16([0u16; TMP_LEN])Align16([MaybeUninit::<u16>::uninit(); TMP_LEN])로 변경
    • 내부 함수 시그니처는 tmp: *mut MaybeUninit<u16>, tmp: &[MaybeUninit<u16>] 형태로 조정
    • 이미 unsafe인 코드 경로 안에서 처리되어 새 unsafe 블록은 추가되지 않음
  • 변경 후 cdef_filter_neon_erased의 Self 샘플은 670에서 274로 줄어듦
    • dav1dcdef_filter_{8x8,4x4}_neon Self 샘플 합계보다 약간 낮아짐

개선 1의 연장: 반복문 안 초기화 줄이기

  • Align16 버퍼를 더 찾는 과정에서 rav1d_cdef_brow 안의 lr_bak 초기화가 발견됨
    • 기존 코드는 반복문 안에서 lr_bak을 매번 0 초기화함
    • 대응되는 dav1d 코드는 이 버퍼를 초기화하지 않음
  • 여기서는 MaybeUninit 전환이 더 어려워, lr_bak 생성을 반복문 밖으로 옮김
    • 초기화를 매 반복마다 하지 않고 한 번만 수행함
    • 절감 폭은 작지만 같은 종류의 불필요한 작업을 줄임
  • 이 변경까지 포함한 전체 벤치마크에서 rav1d72.644초를 기록함
    • 기존 73.914초에서 1.2초 개선
    • 전체 런타임 기준 약 1.5% 개선
    • dav1d의 67.912초와는 아직 차이가 남음

개선 2: 작은 구조체 동등성 비교 최적화

  • inverted stack 보기로 다시 프로파일링하자 add_temporal_candidate에서 눈에 띄는 차이가 나타남
    • Rust와 C 버전 차이는 약 400샘플, 약 0.5초에 해당
    • 함수 자체는 약 50줄의 if, for, 짧은 유틸리티 호출로 구성됨
  • release-with-debug 프로파일로 다시 빌드해 줄 단위 샘플 분포를 확인함
    • if cand.mv.mv[0] == mv {
    • if cand.mv == mvp {
    • 두 줄이 합쳐 약 600샘플을 차지함
  • Rust의 Mv#[derive(PartialEq)]를 사용하는 작은 구조체임
    • #[repr(C)]
    • y: i16, x: i16
  • dav1dmvunion으로 정의되어 있음
    • struct { int16_t y, x; }
    • uint32_t n
    • 비교 시 mvstack[n].mv.n == mvp.n처럼 32비트 값으로 비교함
  • Rust에서 union을 쓰면 필드 접근이 unsafe가 되어 Mv 사용처 전체에 영향을 줄 수 있음
    • 대신 zerocopyAsBytes를 사용해 바이트 표현을 비교함
    • impl PartialEq for Mv에서 self.as_bytes() == other.as_bytes()를 사용
    • Godbolt 확인 결과 transmute 기반 방식과 같은 최적화된 어셈블리를 생성함
  • RefMvs{Mv,Ref}Pair에도 유사한 최적화를 적용함
    • 벤치마크 결과는 72.182초
    • 이전 결과 72.644초보다 약 0.5초 개선
    • 최초 기준 73.914초 대비 2.3% 개선

Rust 기본 PartialEq와 코드 생성 한계

  • 작은 구조체의 기본 PartialEq가 비효율적인 코드 생성을 낳는 이유는 Rust 이슈 #140167와 연결됨
  • C에서 struct { int16_t y, x; }y만 초기화하고 x는 초기화하지 않은 상태가 가능함
    • 비교가 this.y == other.y && this.x == other.x이고 모든 y가 다르면, x를 읽지 않아도 됨
    • 이런 경우를 고려하면 단일 메모리 로드로 최적화하는 것은 모든 필드가 항상 초기화된다는 보장이 있을 때만 유효함
  • 관련 논의에서는 LLVM이 “이 포인터를 통한 로드는 항상 초기화된 바이트를 읽는다”는 속성을 표현할 방법이 없다는 점을 다룸
  • zerocopy는 구조체를 바이트 슬라이스로 표현해도 되는 안전 조건을 정적으로 확인할 수 있어, 새 unsafe 없이 최적화된 비교를 구현할 수 있었음

최종 결과와 남은 성능 격차

  • 첫 번째 PR은 Arm 전용 고온 경로의 비싼 0 초기화를 피함
    • PR #1397
    • 실행 시간 1.2초 개선
    • 약 -1.6%
  • 두 번째 PR은 작은 수치 구조체의 기본 PartialEq 구현을 바이트 기반 비교로 바꿈
    • PR #1400
    • 실행 시간 0.5초 개선
    • 약 -0.7%
  • 두 변경은 합쳐 수십 줄 규모이며, 코드베이스에 새 unsafe를 도입하지 않음
  • 최종 rav1d 실행 시간은 72.182초로, 시작점보다 2.3% 빨라짐
    • dav1d의 67.912초와는 약 4.2초 차이
    • 시작 시 관측된 성능 차이의 약 30%를 줄임
  • 두 구현 사이에는 여전히 약 6% 격차가 남아 있으며, dav1drav1d의 프로파일러 스냅샷 비교가 추가 최적화 탐색에 계속 활용될 수 있음

댓글과 토론

Hacker News 의견들
  • u16 두 개 비교와 관련된 이슈가 흥미로움
    https://github.com/rust-lang/rust/issues/140167

    • 그 논의에서 저장소 포워딩(store forwarding) 이 언급되지 않은 게 의외임
      -O3 코드 생성은 이상하지만, -O2 출력은 합리적임. 구조체 하나가 막 계산된 상태라면 이를 단일 32비트 로드로 읽으려 할 때 저장소 포워딩 실패가 생겨 로드 병합의 이점이 사라질 수 있음. 인라인되지 않고 PGO도 없는 상황에서는 컴파일러가 그 최적화가 적절한지 판단할 정보가 부족함
    • 이 논의가 “나도 이 문제 있음”, “언제 고쳐짐?” 같은 댓글 14페이지가 아니라서 좋음
      웹 개발자로서는 GitHub 이슈가 꽤 별로일 때가 많음
    • 이 사례는 컴파일러 작성의 복잡성을 보여주는 듯함
      C 컴파일러도 일반적인 경우에 이 문제를 더 잘 처리할 수 있다고 확신하기는 어려움
  • 이런 내용 때문에 ffmpeg Twitter 계정이 Rust에 반대 입장을 내는 듯함
    https://x.com/ffmpeg/status/1924137645988356437?s=46

    • 보통 rbultje의 벤치마크는 신뢰하지만, rav1d 추적 이슈에는 여러 플랫폼의 다중 스레드 수치가 있고 그렇게 큰 차이를 보이지는 않음
      https://github.com/memorysafety/rav1d/issues/1294
      로그인하지 않아서 원 트윗만 보이는데, 답글에서 설명된 부분이 있는지 궁금함
    • ffmpeg Twitter 계정만 읽어도 ffmpeg를 쓰기 싫어질 정도임
      제대로 된 대안이 없다는 게 아쉽고, 개발자들이 꽤 공격적으로 보임. 파이프라인 전체를 통제한다면 최고 성능이 좋겠지만, 불특정 사용자의 신뢰할 수 없는 데이터를 받는다면 ffmpeg에는 매년 원격 악용 가능한 CVE가 적어도 대여섯 개는 나옴. 샌드박스를 단단히 해두는 게 좋음
      https://ffmpeg.org/security.html
      여기서 각자 고수하는 입장보다, 모두가 안전하면서 빠른 해법을 향해 가는 중간 지점이 있을 것 같음
    • 더 건강한 대응은 dav1d를 더 빠르게 만드는 작업이었을 듯함
      올림픽 기록 산정 기준을 다듬어서 Bolt의 100m 기록을 9.63초가 아니라 9.64초로 소급 수정해도 아무도 신경 안 씀. 하지만 실제로 9초에 100m를 달리면 관심을 받음. 단, 인간일 때의 이야기고 타조라면 인상적이지 않지만, 대체로 타조는 올림픽 100m에 출전하지 않음
  • 버퍼를 0으로 초기화하지 않아 얻는 성능 이점에 관한 글이, 이 글이 나온 지 이틀 뒤에 보인다는 점이 흥미로움
    https://news.ycombinator.com/item?id=44032680

  • 제목이 글을 과소평가함
    실제로는 좋은 최적화 두 개로 2.3% 더 빨라짐

    • 1.5%짜리 최적화는 aarch64 전용이라 전체 수치를 그대로 주장하기엔 조금 불공정해 보임
      Arm과 x86이 앞으로 배포의 대부분이라고 보면 절반 정도로 보는 편이 더 맞을 듯함
  • 좋은 글이고, 16비트 정수 쌍 비교에서 비효율적인 코드가 발견된 부분이 흥미로웠음

    • Rust/LLVM 쪽에서 가능할 때마다 이 최적화를 적용하도록 컴파일러를 개선할 수 있을지 궁금함
      Rust는 메모리 초기화 여부에 관해 훨씬 더 정확한 정보를 가질 수 있음
  • 조건이 같다면 코덱은 Rust보다는 WUFFS로 작성되는 게 맞다고 봄
    다만 dav1d처럼 복잡한 것을 WUFFS로 새로 쓰는 일은 c2rust 변환 결과를 정리하는 것보다 훨씬 클 수 있음. 천 배 어렵다고 해도 믿을 수 있음. 그래도 문명 전체로는 해볼 가치가 있다고 생각함
    WUFFS 또는 동등한 특수 목적 언어를 말하는 것이고, WUFFS가 이미 있음

    • WUFFS는 Matroska, webm, mp4 같은 컨테이너 파일 파싱에는 훌륭하겠지만, 비디오 디코더에는 전혀 적합해 보이지 않음
      동적 메모리 할당이 없으면 동적 데이터를 다루기 어려움. 비디오 코덱은 단순히 파일을 파싱해 데이터를 얻는 수준이 아니라, 상당히 동적인 상태를 많이 관리해야 함
  • 웃긴 밈으로 시작하는 글이면 좋은 글이라는 걸 알 수 있음
    최근 논의와도 관련 있어 보임: $20K Bounty Offered for Optimizing Rust Code in Rav1d AV1 Decoder (memorysafety.org) | 108 comments | https://news.ycombinator.com/item?id=43982238

  • 솔직히 첫 번째 최적화가 perf만 써도 꽤 명확히 보이는 것이었다는 점이 조금 놀라움
    첫 글에서 버퍼 0 초기화 문제를 이미 논의했던 것 같은데, 두 번째 최적화는 확실히 더 복잡하고 흥미로웠지만 그래도 perf가 가리켜 줬음. 이 도구를 과소평가하면 안 됨

    • 보기에는 단순히 perf만 쓴 게 아니라, C 버전과 Rust 버전의 차등 프로파일링을 하고 수동으로 대응시킨 것에 가까움
      perf diff가 있긴 하지만 서로 다른 심볼 이름을 매칭하지 못하고, 쓰는 사람도 많지 않아 보임
    • Apple 기기에서 aarch64 관점으로 접근했기 때문에 가능했던 듯함
      서로 다른 배경에서 온 사람이 “돌이켜보면 명확한” 빈틈을 발견하는 경우를 자주 봄
  • 이거 정말 재미있음
    rustc가 그 transmute 트릭을 수행하지 못하게 막는 게 있는지 궁금했는데, 다음 문단을 읽었더라면 댓글 달기 전에 이 이슈를 알았을 것임
    https://github.com/rust-lang/rust/issues/140167