- Rust 기반 AV1 디코더 rav1d는 같은 입력에서 C 기반 dav1d보다 약 6초, 9% 느렸고, 두 개의 작은 최적화로 실행 시간이 73.914초에서 72.182초로 줄어듦
- 분석은
samply로 두 바이너리를 같은 조건에서 비교하고, 공통 Arm 어셈블리 함수를 앵커로 삼아 Rust 래퍼와 함수 구현의 차이를 추적함 - 첫 번째 개선은 Arm 경로의 임시 버퍼 0 초기화를
MaybeUninit으로 피하고lr_bak초기화 위치를 옮겨, 전체 런타임을 약 1.6% 줄임 - 두 번째 개선은 작은 수치
struct의 기본PartialEq가 만든 비효율적 비교를zerocopy의as_bytes()기반 비교로 바꿔 약 0.5초를 추가로 절감함 - 두 PR은 새
unsafe없이 총 2.3% 개선을 만들었지만, 측정은 macOS M3 칩, 단일 스레드, 특정 벤치마크 입력에 한정되며 dav1d와는 여전히 약 4.2초 차이가 남음
기준 성능과 측정 환경
rav1d는dav1d의 Rust 포트임c2rust로dav1d를 변환dav1d의 어셈블리 최적화 함수를 통합- 코드를 더 Rust답고 안전하게 바꾸는 작업을 포함
- memorysafety.org는
rav1d성능 개선 콘테스트를 열었고, 기준 상태에서는 Rust 기반rav1d가 C 기반dav1d보다 약 5% 느렸음 - 로컬 측정은 MacBook Air M3, 8코어 환경에서 수행됨
rav1d: commita654c1e82adb2d9a33ae50d2a82a7a747102cbb6rustc 1.88.0-nightly, LLVM20.1.2dav1d: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% 느림 clang과rustc의 LLVM 버전은 패치 버전만 달랐음
- 같은 샘플 파일에서
프로파일링 접근법
- 프로파일링에는
samply를 사용함- 기본 샘플링 속도는 1000Hz
- 특정 함수에서 500샘플 차이는 대략 0.5초 실행 시간 차이에 해당
- 두 바이너리가 유사하고 결정적으로 동작하기 때문에, 비디오 디코더 전체를 새로 이해하기보다 함수별 샘플 차이를 비교하는 방식이 유효했음
- 공통으로 사용하는 최적화 어셈블리 호출을 앵커로 삼음
dav1d는cdef_filter_8x8_neon,cdef_filter_4x4_neon을 호출하고 각각 관련 어셈블리 함수를 디스패치함rav1d는cdef_filter_neon_erased가 모든 어셈블리 함수 디스패치를 처리함
cdef_filter8_pri_sec_edged_8bpc_neon의 샘플 수는 두 스냅샷에서 거의 같아, 비교 방향이 맞는 것으로 확인됨cdef_filter_neon_erased와rav1d_cdef_brow의 차이는 합쳐서rav1d전체 실행 시간의 약 1% 에 해당함dav1d의cdef_filter_{8x8,4x4}_neonSelf 샘플 합계는 약 400rav1d의cdef_filter_neon_erasedSelf 샘플은 약 670dav1d_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으로 채움
- 대응되는
dav1dC 코드는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로 줄어듦dav1d의cdef_filter_{8x8,4x4}_neonSelf 샘플 합계보다 약간 낮아짐
개선 1의 연장: 반복문 안 초기화 줄이기
- 큰
Align16버퍼를 더 찾는 과정에서rav1d_cdef_brow안의lr_bak초기화가 발견됨- 기존 코드는 반복문 안에서
lr_bak을 매번 0 초기화함 - 대응되는
dav1d코드는 이 버퍼를 초기화하지 않음
- 기존 코드는 반복문 안에서
- 여기서는
MaybeUninit전환이 더 어려워,lr_bak생성을 반복문 밖으로 옮김- 초기화를 매 반복마다 하지 않고 한 번만 수행함
- 절감 폭은 작지만 같은 종류의 불필요한 작업을 줄임
- 이 변경까지 포함한 전체 벤치마크에서
rav1d는 72.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
dav1d의mv는union으로 정의되어 있음struct { int16_t y, x; }uint32_t n- 비교 시
mvstack[n].mv.n == mvp.n처럼 32비트 값으로 비교함
- Rust에서
union을 쓰면 필드 접근이unsafe가 되어Mv사용처 전체에 영향을 줄 수 있음- 대신
zerocopy의AsBytes를 사용해 바이트 표현을 비교함 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% 격차가 남아 있으며,
dav1d와rav1d의 프로파일러 스냅샷 비교가 추가 최적화 탐색에 계속 활용될 수 있음