죽은 CRC Crate를 되살렸더니 갑자기 입소문을 탔습니다
2015년 이후 방치된 Rust용 crc32 Crate를 현대적인 구현으로 재작성한 사례를 소개합니다. slicing-by-16, no_std, 빌드 타임 코드 생성, Python·Node.js 바인딩을 추가했으며, 순수 Safe Rust 경로에서 최대 약 1,282 MiB/s를 달성하면서 이식성과 안전성을 강조합니다.

이 글은 2015년 이후 업데이트되지 않은 Rust의 오래된 crc32 Crate를 되살려 crc32-v2로 확장한 개발 과정을 다룹니다. 원래 Crate는 변경 로그나 CI 없이 세 파일만으로 구성되어 있었지만, 당시부터 여러 프로젝트의 전이 의존성에 포함되어 있었고 글 작성자는 다운로드 수가 9,062건에 이르렀다고 설명합니다. 기존 구현은 바이트 단위로 CRC-32를 계산하는 단순하고 올바른 방식이었지만, 최신 환경에서 높은 처리량을 내기에는 한계가 있었습니다. 작성자는 이를 단순히 최신 Rust 문법으로 포팅하는 데 그치지 않고, zlib 소스를 참고해 구현을 다시 작성했습니다.
CRC-32와 기존 구현
CRC-32(Cyclic Redundancy Check, 32-bit variant)는 ZIP 파일 무결성 검증, Ethernet 프레임의 비트 오류 감지, FDDI, PKZIP, PNG, zlib 등 여러 포맷과 프로토콜에서 사용되는 체크섬 알고리즘입니다. 원래 crc32 Crate는 입력을 한 바이트씩 처리하면서 CRC 테이블을 조회하고 비트 단위 XOR을 수행했습니다. 구조는 단순하지만 1MiB 입력에서 약 2,916,562ns가 걸렸고, 처리량은 약 343MiB/s였습니다.
새 구현에는 바이트 단위 기준 경로인 crc32 외에도 slicing-by-4 방식의 crc32_little, slicing-by-8 방식의 crc32_little_8, slicing-by-16 방식의 crc32_little_16, 빅 엔디언 경로인 crc32_big을 추가했습니다. 또한 스트리밍 입력을 처리하는 Digest와, 서로 독립적으로 계산한 CRC를 결합하는 crc32_combine도 제공합니다. crc32_combine은 GF(2) 행렬 제곱을 사용해 O(log n) 시간에 두 체크섬을 결합하도록 구현했습니다.
slicing-by-N과 성능
slicing-by-N은 한 번에 여러 바이트를 처리하도록 서로 다른 바이트 오프셋에 대응하는 CRC 테이블을 미리 만들어 사용하는 방식입니다. slicing-by-4는 네 개의 테이블을 사용해 네 바이트를 한 번에 처리하고, slicing-by-8은 여덟 개, slicing-by-16은 열여섯 개의 테이블을 사용합니다. 각 테이블 조회 사이에 데이터 의존성이 적어 현대 CPU의 병렬 실행과 명령어 수준 병렬성을 활용할 수 있다는 설명입니다.
작성자의 벤치마크에서 1MiB 입력 기준 crc32는 2,916,562ns, crc32_little은 1,199,953ns, crc32_little_8은 1,004,746ns, crc32_little_16은 781,535ns였습니다. 이에 따른 처리량은 각각 약 343MiB/s, 833MiB/s, 1,004MiB/s, 1,282MiB/s입니다. 즉 slicing-by-16 경로는 바이트 단위 기준보다 약 3.7배 빠릅니다. 이 경로는 SIMD intrinsic이나 CPU 기능 감지 없이 구현되었으며, 글에서는 x86-64, ARM, RISC-V, WASM 등 Rust가 지원하는 여러 대상에서 동작하는 점을 강조합니다.
다만 1바이트처럼 입력이 매우 작을 때는 테이블 기반 경로의 정렬 및 준비 비용 때문에 기본 경로가 더 빠릅니다. 벤치마크에서 1바이트 입력은 crc32가 약 2ns, slicing-by-16이 약 3ns였습니다. 반면 crc32fast는 pclmulqdq 기반 SIMD를 사용해 1MiB 입력에서 약 89,204ns, 약 11,300MiB/s를 기록했습니다. zlib-rs도 약 10,390MiB/s로 더 빠릅니다. 글은 crc32-v2가 이들과 동일한 성능을 목표로 하지 않으며, SIMD와 unsafe 구현에 의존하지 않고 no_std 환경과 WASM 등에서 폭넓게 컴파일되는 별도의 사용 영역을 겨냥한다고 설명합니다.
컴파일 타임 테이블 생성
slicing-by-16에는 17개의 조회 테이블이 필요합니다. 256개 항목을 가진 4바이트 정수 테이블 17개이므로 런타임에 필요한 데이터 크기는 17 × 256 × 4 = 17,408바이트입니다. 글에서 제시한 방식은 이 상수를 소스 파일에 직접 기록하거나 프로그램 시작 시 생성하는 대신, 별도의 crc32-codegen 빌드 의존성 Crate를 두고 build.rs에서 호출하는 방식입니다.
crc32-codegen은 CRC-32 다항식 연산으로 17개 테이블을 컴파일 시점에 생성하고 $OUT_DIR/crc_tables.rs에 Rust 소스 코드로 기록합니다. 본체 Crate는 생성된 파일을 include!로 포함합니다. 따라서 런타임 초기화나 시작 시 테이블 생성 비용 없이 테이블이 바이너리에 포함됩니다. build.rs는 crc32_codegen::run()을 호출하는 한 줄에 가깝게 구성되어 있으며, 코드 생성, 포맷팅, 파일 기록과 검증을 별도 Crate가 담당합니다.
no_std와 안전성
라이브러리는 #![cfg_attr(not(feature = "std"), no_std)] 구성을 사용하고, 기본 기능에서는 std를 활성화하지만 default-features = false로 임베디드 환경에 포함할 수 있습니다. alloc을 사용하기 위한 extern crate alloc도 구성되어 있습니다. Python 바인딩은 pyo3와 std 기능에 의존하고, Node.js 바인딩 역시 std 기능이 필요하므로 bare-metal 사용 경로와 분리되어 있습니다.
no_std 동적 라이브러리를 빌드할 때는 패닉 처리기와 전역 할당자 문제도 다뤄야 합니다. 글에서는 링크 단계에서 필요한 계약을 만족시키기 위해 더미 전역 할당자 구현을 추가하고, 존재하지 않는 언와인딩 인프라를 사용하지 않도록 개발 프로필에서 panic = "abort"를 설정했다고 설명합니다. 그 결과 cargo build --no-default-features가 완료되도록 구성했습니다.
Crate 루트에는 #![forbid(unsafe_code)]가 선언되어 있어 일반적인 CRC 계산, GF(2) 행렬 연산, 스트리밍 Digest, 코드 생성 경로에 unsafe 코드가 들어가는 것을 컴파일러가 거부하도록 했습니다. 글은 Python FFI의 더미 할당자 스텁만 별도로 격리되고 조건부 컴파일된 unsafe 표면이라고 설명하면서, 전체 프로젝트를 소개할 때는 100% Safe Rust를 강조합니다. 따라서 실제 사용 시에는 FFI 및 해당 빌드 조건부 경로와 일반 라이브러리 경로를 구분해 살펴볼 필요가 있습니다.
Python·Node.js 바인딩과 작은 입력
Python 패키지는 maturin, pyo3, pyproject.toml, Python 패키지 디렉터리의 __init__.py, Rust의 #[pymodule] 이름을 함께 맞춰야 합니다. 글에서는 pyproject.toml의 모듈 이름이 crc32_v2._crc32_v2로 잘못 지정되었을 때 ModuleNotFoundError만 발생했고, 올바른 값인 crc32_rs._crc32_v2로 수정하는 데 세 글자 차이만 필요했지만 원인을 찾는 데 45분이 걸렸다고 설명합니다.
Python에서 crc32, crc32_little_16, crc32_bytes, crc32_hex, Digest를 사용할 수 있으며, Digest에 b"Hello, "와 b"world!"를 나누어 업데이트해도 한 번에 계산한 결과인 0xEBE6C6E6과 일치합니다. Node.js에는 crc32와 crc32Little16을 노출합니다. 작은 입력에서는 Python 함수 호출 자체의 인터프리터 오버헤드가 문제가 될 수 있습니다. 글의 측정값으로 Python zlib.crc32는 1바이트에서 약 297ns, Rust 함수는 약 2ns였고, 64바이트에서는 각각 약 301ns와 39ns였습니다. 이에 따라 1바이트 입력에서는 약 148배, 64바이트에서는 약 7.7배의 차이가 나타났다고 제시합니다. 다만 이 수치는 함수 호출 경로와 측정 환경을 포함한 벤치마크 결과로 제시된 값입니다.
사용 예와 향후 계획
Rust에서는 crc32(0, b"123456789")가 표준 CRC-32 결과인 0xCBF43926을 반환하도록 사용할 수 있습니다. crc32_little_16(0, b"Hello, world!")는 0xEBE6_C6E6을 반환하며, crc32_combine(c1, c2, 6)을 사용하면 b"Hello, "와 b"world!"를 각각 계산한 CRC를 길이 정보와 함께 결합해 전체 문자열의 CRC를 얻을 수 있습니다. Python은 pip install crc32-rs, Node.js는 npm install crc32-rs로 설치하며, Rust와 no_std Rust에는 각각 cargo add crc32-v2, cargo add crc32-v2 --no-default-features를 사용합니다.
현재 버전은 0.2.0이며, 로드맵에는 slicing-by-32, 스트리밍 Digest 상태를 직렬화·역직렬화하기 위한 serde 지원, WASM 대상 지원이 포함되어 있습니다. 글은 이 프로젝트를 오래된 의존성을 현대화하는 작업에서 출발해, 처리량 개선과 임베디드 호환성, 언어별 바인딩, 패키징까지 포함하는 라이브러리로 확장한 사례로 정리합니다.
원문: dev.to / 번역·요약: Trawling