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