
우리가 직접 C 및 C++ 추론 엔진을 작성하는 이유
요약
LocalAI가 Python 의존성 없이 C/C++로 직접 추론 엔진을 작성하는 이유와 그 이점을 설명합니다. vllm.cpp 사례를 통해 경량화된 바이너리가 성능 저하 없이 메모리 점유율을 획기적으로 줄일 수 있음을 증명합니다.
핵심 포인트
- Python 의존성 제거를 통해 배포 크기를 수 GB에서 수십 MB로 축소
- vllm.cpp는 vLLM과 대등한 처리량(throughput)을 유지하며 메모리 효율성 확보
- 단일 파일 및 공유 라이브러리 형태로 이식성 극대화
- CUDA 및 glibc 환경에 구애받지 않는 예측 가능한 메모리 관리 가능
대부분의 LocalAI 백엔드(backends)는 다른 사람의 엔진을 래핑(wrap)하고 있으며, 이는 올바른 기본 설정입니다. llama.cpp, vLLM, whisper.cpp, stable-diffusion, MLX 등은 저희보다 해당 모델들에 더 능숙한 사람들이 유지 관리하고 있으며, 이를 래핑하는 데는 Dockerfile과 gRPC shim만 있으면 됩니다.
저희 백엔드 중 18개는 아무것도 래핑하지 않습니다. 이것들은 저희가 처음부터 직접 작성한 C 또는 C++ 포트(ports)이며, 각각의 포트가 존재하는 이유는 업스트림(upstream) 엔진을 래핑할 경우 저희가 배포할 수 없는 것들, 즉 수 기가바이트(GB)에 달하는 Python 설치 파일, 이식 불가능한 CUDA 전용 스택, 또는 C++ 구현체가 전혀 없는 모델 등을 함께 배포해야 했기 때문입니다. 이 포스트는 이러한 포트들이 얻는 이점과 측정된 가치, 그리고 그 비용에 대해 다룹니다.
얻을 수 있는 것: 단 하나의 파일, 그리고 예측 가능한 메모리
Python 추론 스택을 배포한다는 것은 대상 머신에 설치된 CUDA 및 glibc 환경에 맞춰 설치 시점에 의존성 트리(dependency tree)를 해결해야 함을 의미합니다. ggml 포트를 배포하는 것은 공유 라이브러리(shared library)와 GGUF 파일 하나를 복사하는 것을 의미합니다.
이 차이를 가장 명확하게 보여주는 측정치는 vLLM의 V1 서빙 아키텍처를 C++20으로 포팅한 저희의 vllm.cpp입니다. vLLM을 설치하면 9.1 GiB의 가상 환경(virtualenv)이 생성됩니다. vllm.cpp를 설치하면 66 MiB 크기의 바이너리(binary)가 생성됩니다. 이 엔진은 paged KV cache, continuous batching, prefix caching, 스케줄러(scheduler) 및 샘플러(sampler)를 포함하여 원본 Python 버전과 동일한 기능들을 구현하며, 추론 시 Python, PyTorch, ggml을 전혀 사용하지 않습니다.
당연히 드는 의문은 이것이 처리량(throughput) 측면에서 어떤 비용을 치르는가 하는 점입니다. NVIDIA GB10에서 Qwen3.6-27B를 NVFP4 형식으로, greedy, closed loop 방식으로 실행했을 때, --enforce-eager 대신 프로덕션 그래프 설정(production graphed configuration)을 사용한 vLLM과 비교한 결과입니다:
| 동시성 (Concurrency) | 1 | 2 | 4 | 8 | 16 | 32 |
|---|---|---|---|---|---|---|
| vllm.cpp tok/s | 86.05 | 159.68 | 292.34 | 508.77 | 801.76 | 1095.01 |
| vLLM tok/s | 82.32 | 158.03 | 290.31 | 505.46 | 789.16 | 1076.25 |
| 비율 (Ratio) | 1.045x | 1.011x | 1.007x | 1.007x | 1.016x | 1.017x |
우리는 6개 지점 모두에서 앞서고 있으며, 그중 5개 지점은 동등한 수준(ties)입니다. 우리의 실행 간 노이즈 대역(run-to-run noise band)은 0.5%이며, 병렬 처리(concurrency) 2에서 32 사이의 값은 0.7%에서 1.7% 사이에 위치하므로, 정직한 해석은 단일 스트림(single-stream) 케이스(4.5%)만이 노이즈 범위를 명확히 벗어난다는 것입니다. 출력값은 해당 곡선의 모든 지점에서 vLLM과 토큰 단위로 동일(token-for-token identical)합니다. 피크 호스트 메모리(Peak host memory)는 28.18 GiB 대비 24.88 GiB입니다.
성숙한 CUDA 스택과 대등한 결과를 냈다는 것은 66 MiB 크기의 바이너리로서 좋은 결과이며, 이는 메모리 점유율(footprint) 절감이 처리량(throughput)의 손실로 이어지지 않음을 의미합니다. 동일한 GGUF 파일로 CPU에서 실행되는 llama.cpp와 비교했을 때, 프리필(prefill)은 1.18배 더 빠르며(177.3 tok/s 대비 223.8 tok/s), 디코드(decode)는 llama.cpp 자체 오차 범위 내에서 동등하며, 생성된 토큰은 해당 모델의 그리디 디코드(greedy decode)와 바이트 단위로 일치(byte-identical)합니다. Apple M4에서 실행되는 MLX-LM과 비교하면, 첫 번째 토큰까지의 프리필 시간(prefill time to first token)은 1.5% 앞서며, 웜 상태의 총 처리량(warm total throughput)은 MLX-LM의 97.6%로, 순수하게 디코드 단계에서 발생하는 2.4%의 격차가 존재합니다.
때로는 포팅(port) 자체가 단순히 더 빠르기도 합니다
depth-anything.cpp는 ByteDance의 Depth Anything 3를 포팅한 것으로, 일반 사진 한 장으로부터 미터 단위의 미터법 깊이(metric depth)와 더불어 픽셀당 신뢰도(per-pixel confidence), 카메라 내부 파라미터(intrinsics) 및 외부 파라미터(extrinsics), 그리고 역투영된 포인트 클라우드(back-projected point cloud)를 제공합니다. CPU에서 실행할 경우 동일한 모델을 실행하는 PyTorch보다 더 빠릅니다.
| 엔진 (Engine) | 양자화 (Quant) | 모델 MB (Model MB) | 로드 ms (Load ms) | 추론 ms (Infer ms) | 피크 RAM MB (Peak RAM MB) | vs PyTorch |
|---|---|---|---|---|---|---|
| PyTorch | f32 | 516 | 749 | 416.9 | 1328 | 1.00x |
| C++/ggml | q8_0 | 142 | 40 | 319.4 | 363 | 1.31x |
Ryzen 9 9950X3D 환경에서 504x336 해상도, 16개 스레드를 사용할 때, 동일한 모델임에도 속도는 1.31배 빠르고 메모리는 27%만 사용하며, 로딩은 749 ms 대신 40 ms 만에 완료됩니다. 양자화된 q4_k 빌드는 99 MB 파일이며 거의 손실이 없는(near-lossless) 상태를 유지합니다. 출력값은 37개의 패리티 테스트(parity tests)를 통해 구성 요소별로 참조 포워드 패스(reference forward pass)와 1.0의 상관관계를 보입니다.
더 빠른 이유는 PyTorch보다 더 나은 matmul (행렬 곱셈) 커널을 작성했기 때문과는 아무런 관련이 없습니다. 두 개의 위치 임베딩 (positional embeddings), 즉 DPT 헤드의 UV 임베딩과 백본의 bicubic 위치 임베딩은 입력 기하학 (geometry)에만 의존하며 호출할 때마다 동일함에도 불구하고, 매 포워드 패스 (forward pass)마다 단일 스레드 스칼라 (single-threaded scalar) sin, cos 및 bicubic 루프를 통해 재계산되고 있었습니다. 이를 캐싱 (caching)함으로써 포워드당 약 95ms의 호스트 측 오버헤드 (host-side overhead)를 제거했으며, 이것이 격차의 대부분을 차지합니다. PyTorch는 벡터화된 연산 (vectorized operations)으로 동일한 임베딩을 구축하므로 이러한 비용을 지불하지 않았습니다.
이것이 이러한 승리들의 일반적인 형태입니다. 무거운 GEMM (General Matrix Multiply)은 모두 동일한 클래스의 BLAS 커널을 호출하기 때문에 차이가 거의 없습니다. 차이는 Python 참조 구현 (reference implementation)이 최적화하려 하지 않았던 호스트 측 작업과, 추론 (inference)을 수행하기 위해 인터프리터 (interpreter)와 프레임워크 (framework)를 로드하지 않는다는 점에 있습니다. GPU에서는 상황이 다시 동일한 수준으로 돌아갑니다. GB10에서 ggml CUDA 백엔드와 flash attention을 사용할 경우, depth-anything.cpp는 포워드당 47.3ms로 PyTorch의 튜닝된 cuDNN과 대등한 성능을 보이며, 콜드 스타트 (cold start) 시 로딩 속도에서만 1.75배에서 2.9배 더 빠를 뿐입니다.
동일한 성능은 관문이며, 속도는 그 다음 단계이다
face-detect.cpp와 voice-detect.cpp는 LocalAI의 Python insightface 및 speaker-recognition 백엔드를 대체했습니다.
두 경우 모두 우리가 CPU 속도에서의 승리를 주장하지는 않지만, 그럼에도 불구하고 모두 출시되었습니다.
face-detect.cpp는 Python이나 onnxruntime 없이 단 하나의 독립적인 GGUF 파일만으로 SCRFD 탐지(detection), 112x112로의 5개 랜드마크 유사 변환 정렬(similarity-transform alignment), 그리고 ArcFace 임베딩(embedding)을 포함한 전체 insightface buffalo 체인을 실행합니다. 탐지 박스(Detector boxes)와 랜드마크(landmarks)는 insightface와 1픽셀 이내로 일치하며, 인식 임베딩(recognition embedding)은 어떤 스레드 수에서도 코사인 유사도(cosine similarity) 1.000000을 유지합니다. CPU에서는 onnxruntime보다 느립니다. SCRFD 탐지는 1개 스레드에서 약 0.83배, 8개 스레드에서 0.69배 속도로 실행되며, ArcFace 임베딩은 약 0.61배 및 0.84배 속도로 실행됩니다. onnxruntime의 MLAS 컨볼루션 커널(convolution kernels)은 FMA-port 정점에 도달해 있으며, 커스텀 AVX2 Winograd 경로가 격차를 좁혔으나 완전히 메우지는 못했습니다. GPU에서는 동일한 컨볼루션(convolutions)을 cuDNN을 통해 라우팅할 경우 SCRFD는 14.8ms에서 6.4ms로 단축되어 torch-cuDNN과 동등한 수준에 도달합니다.
voice-detect.cpp 역시 메모리 결과가 첨부된 동일한 사례입니다. WeSpeaker 검증(verification)은 CPU 전용 Python, torch 및 onnxruntime 경로의 약 334MB와 비교했을 때, 저희 바이너리에서는 약 62MB로 정점(peak)을 찍어 대략 5.4배 더 낮으며, 판정 결과와 임베딩 코사인 유사도 1.000000은 동일합니다. CPU에서의 엔드 투 엔드(End to end) 성능은 모델과 스레드 수에 따라 우위가 바뀌며 서로 10~15% 이내의 차이를 보이고, GPU에서는 컨볼루션 인코더(conv encoders)가 레퍼런스(reference)와 일치합니다.
생체 인식 파이프라인(biometric pipeline)에서는 레퍼런스보다 더 빠른 것보다 레퍼런스와 정확히 일치하는 것이 더 중요합니다. 소수점 네 번째 자리에서 차이가 나는 임베딩은 임계값(threshold)에서의 검증 결정을 변화시키며, 배포 시 등록된 모든 템플릿을 다시 계산해야 할 수도 있습니다. 동등성(Parity)이야말로 교체를 단순한 마이그레이션(migration)이 아닌 즉시 교체 가능한(drop-in) 방식으로 만드는 핵심입니다.
방법론 (The method)
모든 포팅(port)은 동일한 순서를 따르며, 그 순서가 중요한 부분입니다.
먼저 가중치(weights)를 토크나이저(tokenizer), 어휘집(vocabulary), 그리고 임베딩된 모든 보조 모델(auxiliary model)이 포함된 하나의 GGUF로 변환합니다. 이렇게 하면 모델을 배포하는 것이 단순히 파일을 복사하는 일이 됩니다.
두 번째로 그래프를 포팅(Port)하고, 원래 구현에서 덤프(dump)된 참조 텐서(reference tensors)와 비교하여 구성 요소별로 게이팅(gate)합니다. depth-anything.cpp는 전처리(preprocessing), 백본(backbone), 어텐션(attention), DPT 헤드(DPT head), 깊이(depth), 포즈(pose), 레이 헤드(ray head), 레이-투-포즈 솔버(ray to pose solver) 및 익스포터(exporters)를 다루는 37개의 ctest 케이스를 포함하고 있습니다. parakeet.cpp는 NeMo와 비교하여 WER(Word Error Rate) 0에서의 전사 일치 여부로 게이팅합니다. face-detect.cpp는 픽셀 단위의 박스(box) 및 랜드마크(landmark) 거리와 임베딩 코사인(embedding cosine) 유사도로 게이팅합니다. 빠르기만 하고 약간이라도 틀린 포팅은 가치가 없으며, 구성 요소별 게이팅이 없다면 몇 달이 지나서야 그것이 틀렸음을 알게 됩니다.
세 번째로, 패리티(parity, 일치성)가 유지된 후에만 프로파일러(profiler)를 사용하여 최적화(Optimize)합니다. parakeet.cpp에서 결정적인 승리는 트랜스듀서 디코드(transducer decode) 시간의 97%를 차지하면서도 대부분 중복되었던 예측 네트워크(prediction-network) LSTM 포워드 패스(forward pass)를 캐싱(caching)한 것이었습니다. depth-anything.cpp에서는 두 개의 캐싱된 위치 임베딩(positional embeddings)이 핵심이었습니다. 둘 다 커널 재작성(kernel rewrite)은 아니었으며, 프로파일링할 수 있는 작동하는 베이스라인(baseline)이 없었다면 둘 다 찾아낼 수 없었을 것입니다.
마지막으로 평탄한 C ABI(flat C ABI)를 노출합니다. LocalAI는 purego를 통해 공유 라이브러리(shared library)를 dlopen하고 해당 ABI를 직접 호출하므로, 서빙 경로(serving path)에 서브프로세스(subprocess)도, Python 서버로의 gRPC 홉(hop)도, 인터프리터(interpreter)도 존재하지 않습니다.
비용 (What it costs)
주로 유지보수 비용입니다. 각 엔진은 자체 CI, 자체 벤치마크 스위트(benchmark suite), 자체 GGUF 변환 스크립트 및 자체 패리티 베이스라인을 가진 하나의 리포지토리(repository)이며, 업스트림(upstream)에서 컨버터(converter) 작업이 필요한 새로운 체크포인트(checkpoints)를 계속 출시합니다.
GPU 커널(kernels)이 취약점입니다. ggml의 범용 CUDA 컨볼루션(convolution) 및 어텐션(attention) 커널은 컨볼루션 비중이 높은 모델에서 NVIDIA의 튜닝된 cuDNN보다 뒤처지는데, 이것이 face-detect.cpp가 패리티에 도달하기 위해 명시적인 cuDNN 경로를 필요로 하는 이유이며, parakeet.cpp의 GPU 성능 마진이 NeMo 대비 중앙값 1.25배인 반면 CPU 마진은 더 넓은 이유입니다.
포팅(Porting) 또한 모든 것에 적용될 수는 없습니다. llama.cpp, vLLM, whisper.cpp, MLX, 그리고 diffusers는 래퍼(wrapped) 상태로 유지되는데, 이는 해당 프로젝트들이 규모가 크고 빠르게 변화하며 이미 각자의 분야에서 매우 뛰어나기 때문입니다. 우리는 모델에 C++ 구현체가 없거나, Python 의존성(dependency)이 모델 자체보다 더 무겁거나, 혹은 우리가 필요로 하는 기능이 아직 존재하지 않을 때 엔진을 직접 작성합니다. 그 외의 모든 것은 타인이 만든 것을 설치하여 사용합니다.
위에 나열된 모든 엔진은 실패한 실행 기록을 포함하여, 자체적인 벤치마크 스위트(benchmark suite), 패리티 게이트(parity gates), 그리고 방법론을 각자의 저장소(repository)에 유지합니다. 전체 목록은 LocalAI README의 “Backends built by us” 표에서 확인할 수 있습니다.
직접 시도해보기
이 글을 읽고 있는 바로 그 기기에서 실행해 보세요.
LocalAI는 컨테이너(container), 바이너리(binary), macOS DMG 또는 Helm 차트(Helm chart)로 설치되며, 모델이 백엔드(backend)를 처음 요청할 때 이를 가져옵니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Lobste.rs AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기