초당 1.4 토큰에서 36 토큰까지: 의존성 없는 C 언어 LLM 엔진을 구축하며 배운 DRAM 한계에 대하여
요약
C 언어만을 사용하여 의존성 없는 LLM 추론 엔진을 구축하며 성능을 1.4 tok/s에서 36 tok/s까지 최적화한 과정을 다룹니다. CPU 환경에서 BitNet b1.58 모델을 실행할 때 발생하는 DRAM 대역폭의 한계와 최적화 전략을 설명합니다.
핵심 포인트
- C99 기반의 제로 의존성 엔진 구축으로 오버헤드 최소화
- BitNet b1.58의 삼진 가중치를 활용한 연산 최적화
- CPU 추론 성능의 핵심 병목 지점인 DRAM 대역폭 분석
- AVX-512 및 SIMD 활용을 통한 성능 도약
초당 1.4 토큰에서 36 토큰까지: 의존성 없는 C 언어 LLM 엔진을 구축하며 배운 DRAM 한계에 대하여
저는 단 하나의 질문으로 Project Zero를 시작했습니다: 모든 것을 C 언어로 작성하고 모든 ML 프레임워크를 건너뛴다면, CPU에서 BitNet b1.58 추론(inference)을 얼마나 빠르게 실행할 수 있을까?
첫 번째 답변은 겸허해질 수밖에 없었습니다: 초당 1.4 토큰 (1.4 tokens/second). 디버그 빌드(Debug build), 스칼라 산술(scalar arithmetic), SIMD 미사용 상태였습니다. CPU는 삼진법(ternary) 값에 대해 수학적으로는 단순히 부정(negations)과 no-ops(no-operation)에 불과한 연산을 수행하기 위해 8192바이트의 FP32 가중치(weights)를 로드하는 데 대부분의 시간을 소비하고 있었습니다.
9개월 후, 동일한 모델이 4코어 Xeon에서 36.25 tok/s로 실행됩니다. 그리고 이 수치는 추정치가 아닙니다. 이는 해당 하드웨어의 **분석적 DRAM 대역폭 한계(analytical DRAM bandwidth ceiling)의 95%**에 달하는 수치이며, OpenBenchmarking.org를 통해 제3자 검증을 마쳤습니다. 제 개발용 노트북(i5-11300H)에서는 INT4 분류기 경로(classifier path)를 통해 42.83 tok/s를 기록합니다.
이것은 우리가 어떻게 그 단계에 도달했는지, 그리고 CPU LLM 추론을 지배하는 단 하나의 제약 조건에 대해 제가 무엇을 배웠는지에 대한 이야기입니다.
왜 C99와 제로 의존성(Zero Dependencies)인가?
성능 이야기에 앞서, 왜 굳이 이렇게 하는 걸까요?
대부분의 LLM 추론은 Python과 CUDA 환경에서 실행됩니다. 이러한 스택은 실제 프로덕션 GPU 워크로드에는 진정으로 훌륭합니다. 하지만 실질적인 비용이 따릅니다: 약 2GB의 Python 런타임, 프레임워크 라이브러리, CUDA 툴킷, 모델 변환 도구 등입니다. 오래된 서버, 엣지 장치(edge device), 또는 임베디드 시스템(embedded system)에서 추론을 실행하는 사람에게는 이러한 오버헤드가 병목 현상(bottleneck)이 됩니다.
Project Zero는 gcc -O3로 컴파일됩니다. 그 외의 Makefile 마법은 없습니다. 바이너리는 단 하나의 실행 파일입니다. .gguf 파일과 프롬프트(prompt)만 제공하면 됩니다. 그게 전부입니다.
이는 또한 무엇이 실제로 느린지를 이해하는 데 유용한 제약 조건이 된다는 사실이 밝혀졌습니다. 프레임워크를 탓할 수 없을 때, 당신은 수학을 이해해야만 합니다.
최적화 여정
BitNet b1.58 가중치(weights)는 삼진(ternary) 방식입니다: 각 가중치는 {−1, 0, +1} 중 하나입니다. 삼진 가중치를 사용한 밀집 행렬-벡터 곱셈(Dense matrix-vector multiply)은 실제로는 곱셈이 아닙니다. 이는 부호 반전(negation), 아무 작업도 하지 않음(no-op), 또는 누적(accumulation)입니다. 단순한 접근 방식(FP32로 역양자화(dequantize)한 후 FMA 실행)은 1.58비트를 인코딩하는 부동 소수점(floats)을 로드하는 데 메모리 대역폭(memory bandwidth)의 98%를 낭비합니다.
각 단계별 도약의 구체적인 원인과 함께 그 여정을 소개합니다:
1.4 tok/s — 기준점(baseline). 스칼라(Scalar), 디버그 모드(debug mode), FP32 역양자화(dequantization).
5.5 tok/s — AVX-512 활성화, CPU 거버너(governor)를 성능(performance) 모드로 설정, 스핀락(spinlock) 스레드 풀(thread pool), 디버그 플래그(debug flags) 제거. 순수하게 기계적인 정리 작업입니다.
10.5 tok/s — 개발 장비에서 earlyoom 비활성화. 설정 변경 하나만으로 91% 향상되었습니다. OOM 킬러(OOM killer)가 추론(inference) 중에 워커 스레드(worker threads)를 조용히 일시 중단시키고 있었습니다. 이는 꽤나 당혹스러웠던 디버깅 세션 중 하나였습니다.
13.0 tok/s — HT 스케줄링(scheduling) 수정, 토크나이저(tokenizer) 재작성, 핫 패스(hot path)에서 top_p 샘플링(sampling) 제거. 이 시점에서 우리는 개발용 노트북의 싱글 채널 DDR4 대역폭 한계치의 97%에 도달했습니다. 코드가 아닌 하드웨어가 한계였습니다.
15.5 tok/s — RAM을 싱글 채널에서 듀얼 채널 DDR4로 업그레이드. 한계치가 두 배로 늘어났고, 우리는 그 대부분을 확보했습니다.
16.1 tok/s — T=6 스레드가 최적의 지점(sweet spot)임을 확인, KV 캐시(KV cache) 전략 수정. i5에서 T=4를 넘어서는 하이퍼스레딩(Hyperthreading)은 스로틀링(throttling)을 유발하는 열 압박(thermal pressure)을 가하여, 오히려 한계치가 떨어지기 시작합니다.
이 시점에서 우리는 Xeon(Emerald Rapids, AVX-512 VNNI) 작업을 시작했습니다. i5의 정체는 하드웨어 한계였습니다.
21.2 tok/s (Xeon) — INT8 VNNI 분류기(classifier) 경로. 부동 소수점 누적 대신, vpdpbusd를 사용하여 int8 내적(dot products)을 int32 누산기(accumulators)에 누적합니다. 여기서 연산(compute)의 양상이 바뀝니다.
32.7 tok/s — VBMI 3-명령어 언팩(unpack). 이는 자세히 설명할 가치가 있는 도약입니다.
36.25 tok/s — INT4 VBMI 분류기 + PGO/LTO. DRAM 한계치의 95%에 도달했습니다. 이것이 현재 우리의 위치입니다.
중요한 세 가지 명령어
AVX-512 VBMI 머신에서 삼진 행렬 곱셈(ternary matmul)의 핫 패스(hot path)는 64개 요소 블록당 세 개의 명령어로 축소됩니다:
vpermi2b — 테이블 룩업 디코딩 (table lookup decode). 패킹된 가중치 스트림(packed weight stream)의 각 바이트는 2비트 쌍으로 4개의 삼진 값(ternary values)을 인코딩합니다. vpermi2b는 두 개의 512비트 레지스터에 걸쳐 64-way 바이트 단위 룩업(byte-granularity lookup)을 수행하며, 3 사이클의 지연 시간(latency) 내에 32바이트의 패킹된 삼진 값을 64개의 부호 있는 바이트(signed bytes)로 디코딩합니다. 이는 언팩(unpack)→시프트(shift)→마스크(mask) 시퀀스를 완전히 대체합니다.
vpternlogd — 3-입력 비트 연산 (3-input bitwise). 8비트 진리표(truth table)를 사용하여 세 입력의 모든 불리언 함수(boolean function)를 표현할 수 있는, 다소 과소 사용되는 AVX-512 명령어입니다. 우리는 이를 사용하여 분기(branching) 없이 디코딩된 삼진 값으로부터 부호 마스크(sign masks)와 제로 마스크(zero masks)를 계산합니다.
vpdpbusd — INT8 VNNI 누적 (INT8 VNNI accumulation). 이것은 누적 작업의 핵심(workhorse)입니다: 명령어당 64개의 int8 MAC 연산을 수행하며, 16개의 int32 레인(lane)으로 누적됩니다. 삼진 가중치는 부호 없는 제약 조건(unsigned constraint)을 충족하기 위해 {−1, 0, +1}이 아닌 {0, 1, 2}로 저장되며, 최종 결과에 편향 보정(bias correction)이 적용됩니다.
4개 코어를 사용하는 Sapphire Rapids에서: FP32 FMA는 사이클당 128 MAC을 제공합니다. INT8 VNNI는 사이클당 512 MAC을 제공합니다. 하지만 더 큰 이점은 메모리에 있습니다: 동일한 가중치 행렬에 대해 8192바이트의 FP32 대비 512바이트의 패킹된 삼진 값을 사용합니다. 36 tok/s에서 우리는 약 11.7 GB/s의 DRAM을 소비하고 있으며, 해당 하드웨어에서 측정된 한계치(ceiling)는 약 12.3 GB/s입니다.
코드를 읽고 싶다면 src/math/ternary_matmul_packed_vbmi.c에 있습니다.
"한계치의 95%"가 실제로 의미하는 것
최적화 과정 중에 알고리즘과 싸우는 것을 멈추고 물리 법칙과 싸우기 시작했다는 것을 깨닫는 순간이 있습니다.
BitNet 추론(inference)에 대한 분석적 DRAM 대역폭 한계치(DRAM bandwidth ceiling)는 다음과 같습니다:
ceiling = DRAM_bandwidth / bytes_per_weight / model_size_weights
16.0 GB/s DRAM과 행당 512바이트의 패킹된 삼진 표현(packed ternary representation)을 사용하는 4코어 Xeon의 경우:
ceiling ≈ 16.0 GB/s ÷ (512 bytes / 2048 weights) = ~38 tok/s
우리는 36.25에 도달했습니다. 남은 5%는 캐시 미스 오버헤드(cache miss overhead), 스레드 동기화(thread synchronization), 그리고 트랜스포머(transformer)의 비-matmul 부분(어텐션(attention), 정규화(normalization), 샘플링(sampling))입니다.
메모리 레이아웃 (memory layout)을 변경하지 않고는 이 한계를 뛰어넘을 수 있는 알고리즘적 트릭은 없습니다. 더 많은 DRAM 대역폭 (DRAM bandwidth, 즉 다른 하드웨어)이 필요하거나, 근본적으로 다른 표현 방식 (speculative decoding, batching, sparse attention)이 필요할 것입니다. 고정된 모델에 대한 단일 스트림 추론 (single-stream inference)의 경우, 36.25는 이 하드웨어가 도달할 수 있는 거의 최대 속도입니다.
솔직한 격차: DeepSeek MoE
엔진이 어디에서 느린지에 대해 솔직하게 말씀드리고 싶습니다.
밀집형 (dense) GGUF 모델 (SmolLM2-135M F16)의 경우, Project Zero는 i5-11300H에서 100 tok/s로 동작하며, 이는 1~2개 스레드에서의 llama.cpp보다 약간 빠르고, 최대 스레드 사용 시 5% 이내의 차이를 보입니다.
DeepSeek-V2 Q4_K_S의 경우: 1.9 tok/s vs. llama.cpp의 13.8 tok/s. 저희가 7배 더 느립니다.
근본적인 원인은 제가 아직 해결하지 못한 메모리 액세스 패턴 (memory access pattern) 문제입니다. MoE 라우팅 (routing)은 토큰당 64개의 전문가 (experts) 중 2개를 선택하며, 각 전문가의 가중치 (weights)는 메모리에 비연속적으로 흩어져 있습니다. 밀집형 모델에서는 가중치 스트림 (weight stream)이 순차적이기 때문에 프리페칭 (prefetching)이 작동하고 DRAM 처리량 (throughput)이 높습니다. 반면 MoE에서는 각 전문가 선택 시 가중치 행렬의 서로 다른 영역에서 콜드 페치 (cold fetch)를 트리거하여, L3 미스율 (L3 miss rate)이 80% 이상 발생합니다.
lama.cpp는 제가 재현하지 못한 메모리 레이아웃 최적화를 통해 이를 처리합니다. 이것이 현재의 미해결 과제입니다.
향후 계획
현재 이 엔진은 단일 바이너리 내에서 BitNet ternary 및 밀집형 F16/Q4_K GGUF 모델을 지원하며, OpenAI 호환 HTTP API를 제공합니다. 미리 빌드된 x86-64 Linux 바이너리는 GitHub releases에 있습니다 (컴파일러가 필요하지 않습니다).
현재 제가 명확한 답을 내놓지 못하고 활발히 작업 중인 두 가지 사항은 다음과 같습니다:
-
Fused Q4_K matmul 커널 (kernel) — 밀집형 모델도 ternary와 동일한 처리가 필요합니다. 현재 방식은 누적 (accumulation) 전에 역양자화 (dequantization)를 수행하지만, 퓨즈드 커널 (fused kernel)을 사용하면 메모리 대역폭의 약 30%를 절감할 수 있습니다.
-
MoE 전문가 프리페칭 (expert prefetching) — 만약 2~3개 레이어 앞서 어떤 전문가가 선택될지 예측할 수 있다면, 흩어지기 전에 해당 가중치를 프리페칭할 수 있습니다. 라우팅 결정은 은닉 상태 (hidden state)가 주어지면 결정론적 (deterministic)이므로 예측이 가능합니다. 이것이 충분히 빠를지는 아직 검증되지 않았습니다.
만약 당신이 CPU 추론 (CPU inference), 메모리 대역폭 제한 커널 최적화 (memory-bound kernel optimization) 분야에서 작업하거나, MoE 문제에 대한 아이디어가 있다면 — 리포지토리는 github.com/shifulegend/project-zero에 있으며, README에 'Help Wanted' 섹션이 마련되어 있습니다.
OpenBenchmarking 결과는 공개되어 있으며 재현 가능합니다: Xeon 결과 · i5-11300H 결과.
직접 시도해 보다가 이상한 점을 발견하거나 — 혹은 어떤 하드웨어에서든 36.25 tok/s보다 더 나은 결과를 얻으신다면 — 진심으로 그 내용을 듣고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기