Can safe Rust ever beat Google's C Brotli?
안전한 Rust가 Google의 C Brotli를 앞설 수 있을까요?
안전한 Rust로 작성한 Brotli 구현 mbrotli의 최적화 과정과 Google의 C 구현을 비교합니다. 일부 압축 품질에서는 처리량이 앞서지만 디코딩은 모든 측정 품질에서 뒤졌으며, 측정 조건과 FFI의 안전성 수정 때문에 결과를 그대로 일반화하기 어렵다고 설명합니다.
- 주제
AI 요약
Rust Brotli 인코더·디코더 mbrotli를 만든 저자는 Google의 C 구현과 성능을 비교하고, 최적화 과정에서 얻은 교훈을 소개합니다. 제작 라이브러리는 테스트 빌드를 제외하고 unsafe 코드를 금지하며, SIMD에는 안전한 fearless_simd API를 씁니다. 다만 의존성까지 unsafe 코드가 전혀 없다는 뜻은 아니며, C 포인터 경계는 별도 mbrotli-ffi 크레이트가 맡습니다.
공개 벤치마크의 범위와 한계
lzbench 2.4에 게시된 결과는 Google Brotli 1.2.0과 mbrotli 0.5.2를 AMD EPYC 9555P 한 스레드에서 비교했습니다. 입력은 211,947,520바이트인 silesia.tar이며, 터보를 끄고 Ubuntu 26.04에서 측정했습니다. 압축 처리량은 품질 0에서 두 구현 모두 341MB/s로 같았습니다. 품질 2와 5에서는 mbrotli가 각각 8.6%, 6.2% 느렸고, 품질 8과 11에서는 각각 14.3%, 약 20% 빨랐습니다. 디코딩은 측정한 모든 품질에서 mbrotli가 느렸습니다. 처리량이 20% 늘었다고 실행 시간이 20% 줄어드는 것은 아니라고 저자는 덧붙입니다.
이 수치는 수정 전 0.5.2의 결과입니다. 당시 C ABI 래퍼가 초기화하지 않은 C 메모리를 &mut [u8]로 취급하는 안전성 버그가 있었습니다. 0.5.3에서 이를 고쳤지만, 수정 과정에서 일부 압축·압축 해제 경로도 달라졌습니다. 따라서 공개 수치를 수정된 버전의 성능으로 간주할 수 없습니다. 또한 mbrotli는 런타임에 AVX2를 선택할 수 있지만 기준 C 빌드에는 같은 런타임 특화 커널이 없습니다. 이 비교는 Rust와 C 언어만 떼어 놓은 시험도 아닙니다.
품질 10과 11의 출력이 다른 문제도 분석합니다. lzbench의 C 빌드에 들어간 -ffast-math가 부동소수점 계산 순서와 반올림을 바꿔 매치 선택을 달리했습니다. 양쪽을 fast-math 없이 빌드하면 시험한 Silesia 입력에서 출력 바이트가 같았고, lzbench의 플래그를 적용하면 차이가 재현됐습니다. 품질 11의 출력 크기 차이는 약 0.005%였습니다. 따라서 출력 크기만 같다고 바이트 단위로 같은 결과라고 볼 수 없으며, 설정과 부동소수점 동작을 맞춰야 합니다.
SIMD와 매치 탐색 최적화
한 최적화에서는 AVX2 토큰을 전달했는데도 탐색 함수가 기준 CPU 기능으로 컴파일됐습니다. 올바른 토큰만 전달하면 호출 함수까지 AVX2 코드가 된다고 생각한 것이 원인이었습니다. 생성된 어셈블리에는 SIMD 보조 함수 호출이 18번 있었고 네이티브 벡터 비교는 없었습니다. 함수 본문을 vectorize가 제공하는 기능 활성화 영역 안으로 옮기자 호출이 6개의 바이트 비교와 6개의 마스크 추출로 바뀌었습니다. 출력은 유지됐고, 11개 코퍼스의 기하평균 기준으로 품질 5에서 7.4%, 품질 7에서 4.6%, 품질 9에서 17.4% 처리량이 개선됐습니다. 저자는 이 수치가 개인 장비에서의 전후 측정이지 EPYC 결과를 분해한 값은 아니라고 선을 긋습니다.
품질 10·11의 파서는 후보 매치를 모은 뒤 최단 경로를 찾습니다. 기존 스칼라 탐색은 후보를 하나씩 검사했지만, 새 경로는 32개 후보의 첫 두 바이트를 SIMD 비교로 한꺼번에 거릅니다. 단, 가장 가까운 후보부터 보는 기존 순서를 유지하도록 마스크의 비트를 높은 쪽부터 방문하고, 링 버퍼 경계를 넘거나 연속 데이터가 부족한 경우에는 스칼라 경로로 돌아갑니다. alice29.txt의 품질 11에서는 find_all_matches에 잡힌 명령어 수가 2억 7,800만에서 1억 2,500만으로 줄었습니다. 전체 압축에서 mbrotli의 명령어 수는 해당 최적화 묶음 적용 전 20억 9,700만, 적용 후 17억 1,400만이었고 출력 크기는 46,487바이트로 같았습니다.
안전성 검사와 코드 생성
품질 11 파서의 거리 탐색에서는 backward.wrapping_sub(1) < max_distance로 0을 제외하면서 허용 범위의 상한도 검사했습니다. 링 버퍼를 유효한 슬라이스로 한 번 제한해, 알고리즘의 경계 검사와 Rust 슬라이스의 범위 검사가 같은 조건을 확인하게 했습니다. 반복해서 노드 인덱스를 만들고 검사하는 작업도 줄였습니다. 이 변경 묶음은 Alice 입력에서 약 2억 5천만 개의 명령어를 없앴습니다. 저자는 안전성을 없애는 대신 중복 검사를 피하는 방식으로 LLVM이 할 일을 줄였다고 설명합니다.
고품질 블록 분할기는 히스토그램별 비용의 최솟값과 동률일 때 가장 작은 ID를 찾습니다. AVX2에서 정수 64비트 최솟값을 다루는 대신, 작은 ID를 정확히 표현할 수 있는 f64 벡터에 담아 비용 비교와 ID 선택을 처리했습니다. i7-13700KF의 개인 측정에서는 품질 11 압축 시간이 mapsdatazrh에서 205.8ms에서 190.8ms로, Alice에서 102.1ms에서 97.2ms로 줄었습니다. 이는 lzbench 결과에 더해 계산할 수 있는 개선율이 아니라 별도 커널 실험입니다.
입력 크기와 출력 버퍼가 바꾸는 결과
품질 8은 측정 구성에 따라 결과가 뒤집힙니다. 큰 Silesia 입력을 쓴 공개 벤치마크에서는 mbrotli의 압축 처리량이 C의 약 1.14배였지만, i7-13700KF와 WSL2에서 빈 입력부터 1MiB까지 여덟 입력을 각각 새 인코더로 처리한 개인 측정에서는 약 0.90배였습니다. 품질 5~9에서 쓰는 해시 버킷 테이블은 최대 16MiB이며, C는 사용하지 않는 슬롯을 초기화하지 않습니다. mbrotli는 안전한 Rust 표현을 유지하려고 희소 저장으로 시작한 뒤, 남은 작업량이 충분할 때 조밀한 테이블로 바꿉니다. 초기화 비용이 짧은 입력에서 더 두드러질 수 있지만, 두 벤치마크는 CPU·컴파일러·코퍼스도 달라 테이블 초기화만 원인이라고 단정하지 않습니다.
0.5.3의 FFI 수정은 디코딩 경로에도 영향을 줍니다. 초기화되지 않은 출력 버퍼를 &mut [MaybeUninit<u8>]로 표현하면 안전성을 지킬 수 있지만, 디코딩한 바이트를 LZ77 이력으로 바로 읽는 기존 경로를 그대로 쓸 수 없습니다. 현재 경로는 초기화된 링 버퍼를 거쳐 결과를 복사합니다. 버퍼를 미리 초기화해 선형 경로를 쓰는 조건도 있으며, 압축 입력 크기와 출력 용량에 따라 선택이 달라집니다. 개인 측정에서는 링 버퍼 경로가 텍스트·바이너리 입력에서 2~7% 느렸지만, 저자는 이를 수정된 FFI의 공개 벤치마크 성능으로 간주하지 않습니다.
저자는 unsafe로 워드 로드를 처리한 실험이 특정 핫 루프에서 약 17% 빨랐지만, 프로젝트 제약에 따라 되돌렸다고 설명합니다. 다른 벡터화·반복자 변경도 실제 시간 개선이 없으면 유지하지 않았습니다. 다음 비교에서는 수정된 FFI, 같은 코퍼스와 도구 체인, 같은 부동소수점 설정을 사용하고, SIMD 런타임 디스패치와 기본 경로, 새 작업 공간과 재사용 작업 공간, 초기화 버퍼와 비초기화 버퍼를 나눠 측정해야 한다고 제안합니다.
Reddit 반응
- @Shnatsel — 수동으로 vectorize()를 호출해야 하는 문제를 피하려고 fearless_simd v1.0에 #[simd] 매크로를 추가했습니다. 안전하고 빠른 디코더에서 범위 검사를 없애는 방법을 다룬 상세 글도 썼으니, 관심 있는 분은 읽어보셔도 좋습니다.
- @walksinsmallcircles — 정말 교육적인 글이었습니다. 감사합니다.
- @Mnwamnowich — 그 매크로는 꽤 최근에 나왔나요? 확인해보겠습니다. 범위 검사를 피하려고 여러 방법을 시도했지만 아직 남아 있습니다.
- @Shnatsel — 네, 매크로는 일주일 전에 나왔습니다. 사용해도 벤치마크 개선이 없다면 바꾸지 않는 편이 좋습니다. syn 때문에 컴파일이 느려진다며 의존성 깊숙한 곳에 proc macro가 들어가는 걸 꺼리는 사람도 있습니다.
- @Mnwamnowich — 제 의존성 트리에는 thiserror를 거쳐 이미 syn이 들어와 있습니다. 내일 x86 머신에서 벤치마크를 확인하고 결과를 다시 공유하겠습니다.
원문: Hashnode / 번역·요약: Trawling