오늘 저녁, 제가 만들고 있는 runNburn v0.7.0 릴리스를 올렸습니다. Qwen3.6 비전 모델과 이미지가 들어간 대화 상태 재사용까지 붙이고 나니, 문득 이런 생각이 들더라고요.
“그래서 이걸 왜 만들기 시작했지?”
로컬 LLM 추론 엔진은 이미 많습니다. llama.cpp가 있고, Ollama가 있고, Apple 쪽에는 MLX도 있죠. 처음부터 잘 만들어진 선택지가 많은데 굳이 Rust로 엔진을 하나 더 만드는 건 꽤 무모한 일입니다.
그런데 제가 풀고 싶었던 문제는 조금 달랐어요. 빠른 메모리에 다 올라가지 않는 모델을 어떻게든 돌려보자는 것이었습니다.
RAM이나 VRAM보다 모델이 크다는 이유만으로 시작도 못 하는 상황을 줄이고 싶었습니다. 최고 속도 하나만 노리기보다, 하드웨어의 한계를 인정하면서 원래 GGUF 모델의 의미를 바꾸지 않고 끝까지 실행하는 엔진을 만들고 싶었어요.

빠른 메모리에 다 들어가지 않는 GGUF 모델도 제한된 하드웨어에서 끝까지 실행하자는 생각으로 만든 추론 엔진입니다.
핵심부터 세 가지로 정리하면
처음부터 기준은 이랬습니다.
- GGUF 가중치를 다른 제품용 포맷으로 변환하지 않고 직접 실행합니다.
- RAM과 VRAM 예산 안에서 필요한 가중치와 캐시만 올리고 내립니다.
- 하드웨어가 작아지면 느려질 수는 있어도, 모델의 라우팅이나 양자화 의미를 몰래 바꾸지는 않습니다.
runNburn은 가장 빠른 추론 엔진이 아니라, 메모리 한계 때문에 다른 경로가 포기하는 모델을 예측 가능한 방식으로 끝까지 실행하려는 엔진입니다.
여기엔 단서가 하나 붙습니다. “돌아간다"와 “빠르다"는 같은 말이 아니거든요. 저는 그 차이를 성능표에서 감추지 않으려고 했습니다.
시작은 갤럭시 Z 플립4였습니다
프로젝트 문서를 처음 쓴 날은 2026년 3월 24일입니다. 지금처럼 여러 backend를 아우르는 엔진은 아니었어요. Android NDK와 Qualcomm QNN부터 파고드는 모바일용 Rust 추론 엔진이 출발점이었습니다.
첫날부터 거창한 대형 모델을 돌린 건 아닙니다. TinyLlama 1.1B 모델로 의미 있는 첫 토큰을 얻는 것부터 시작했어요. 그 과정에서도 양자화 블록 해석, RoPE 위치, causal mask, KV cache 같은 기본기가 하나씩 틀어져 있었습니다.
특히 Q6_K dequant 구현이 잘못돼 출력이 완전히 망가졌던 기록을 보면 지금도 아찔합니다. 모델이 이상한 말을 하니까 처음에는 샘플러 문제인가 싶었는데, 실제 원인은 가중치를 읽는 가장 아래쪽 코드였어요.
“속도가 문제가 아니라 계산부터 틀렸네.”
이런 순간이 계속 나왔습니다.
며칠 뒤 갤럭시 Z 플립4에서 Qwen3.5 계열 모델이 한글 질문 네 개에 전부 답했습니다. decode는 약 21 tok/s. C API와 채팅 템플릿도 함께 붙였어요. 지금 보면 작은 출발이지만, 제가 만든 엔진이 휴대폰에서 문장을 이어 쓰던 그 화면은 아직 선명합니다.
진짜 목표는 속도가 아니라 실행 가능성이었습니다
초기에는 저도 계속 속도 숫자를 쫓았습니다. llama.cpp보다 몇 퍼센트 빠른지, MNN과 비교하면 어디가 병목인지, NEON GEMV를 얼마나 줄일 수 있는지 같은 것들이요.
그런데 개발을 이어갈수록 중요한 장면은 다른 데서 나왔습니다.
갤럭시 Z 플립4에서 21GB짜리 Qwen3.6 35B-A3B GGUF 모델이 실제로 답을 만들었습니다. 당시 측정은 짧은 프롬프트 기준 prefill 약 0.83 tok/s, decode 약 1.1 tok/s였습니다. 빠르다고 말하기는 어렵습니다. 하지만 같은 기기에서 기존 모바일 경로가 지원하지 못하던 모델이 “대한민국의 수도는 서울"이라는 문장을 이어 썼어요.
그때 방향이 선명해졌습니다. 최고 성능보다 오프로딩 능력을 먼저 보자.
작은 모델을 아주 빠르게 돌리는 경쟁은 이미 강자가 많습니다. 반면 모델 파일이 RAM보다 크고, 일부 가중치만 빠른 메모리에 둘 수 있고, 매 토큰마다 필요한 expert가 달라지는 환경은 여전히 까다롭습니다. runNburn은 바로 그 제약을 제품의 기본 조건으로 받아들이는 쪽을 택했습니다.
알리바바 공식 MNN보다도 빨라졌습니다
4월 개발 기록에는 이런 판단을 적어놨어요.
범용 ML 프레임워크로 MNN을 이기는 건 비현실적이다. LLM 전용 기능으로 차별화하자.
MNN은 알리바바가 만드는 범용 추론 엔진입니다. Qwen도 알리바바 계열 모델이니, 말하자면 알리바바 본진에서 만든 공식 실행 경로를 기준으로 삼은 셈이죠.
그런데 2026년 7월 30일 갤럭시 Z 플립4에서 Qwen3.6 35B-A3B를 다시 비교했을 때는 결과가 뒤집혔습니다. 양쪽을 한 번씩 warmup한 뒤 같은 원문과 greedy 15토큰 생성 조건으로 ABABAB를 반복했습니다.
MNN에는 공식 taobao-mnn/Qwen3.6-35B-A3B-MNN 4-bit 모델과 MNN 3.6.0을 썼습니다. 설정은 low+mmap, 4 thread였습니다. runNburn은 Q4_K_M GGUF를 직접 읽고, MTP를 끈 target-only 경로를 4 thread로 돌렸어요. 엔진별 chat template 차이 때문에 prompt 길이는 MNN 402토큰, runNburn 407토큰이었고, 그래서 raw prefill뿐 아니라 토큰당 시간도 함께 비교했습니다.
| 항목 | MNN | runNburn | 차이 |
|---|---|---|---|
| 전체 host wall 중앙값 | 198.782초 | 75.495초 | runNburn 2.63배 |
| 토큰당 prefill | 208.7ms | 131.2ms | runNburn 1.59배 |
| 15토큰 decode | 63.86초 | 13.47초 | runNburn 4.74배 |
세 지표 모두 같은 순번의 세 쌍에서 runNburn이 앞섰고 측정 범위도 겹치지 않았습니다. 짧은 수도 질문에서는 두 엔진 모두 같은 첫 10개 decode token을 냈고, 긴 표준 prompt도 의미 동등성 기준을 통과했습니다.
이건 같은 파일 형식을 맞춘 순수 kernel microbenchmark가 아닙니다. 공식 MNN 4-bit 자산과 runNburn Q4_K_M GGUF를 각 엔진의 실제 제품 경로로 끝까지 실행한 비교예요. 그래도 8GB 모바일에서 같은 Qwen3.6 35B-A3B급 모델을 정확하게 돌리면서 전체 wall과 prefill, decode가 모두 알리바바 공식 MNN 경로보다 빨랐습니다. 4월에 적은 “MNN을 이기는 건 비현실적"이라는 판단이 뒤집힌 순간이기도 했어요.
runNburn은 무엇이 다른가요?
GGUF를 그대로 진실의 원본으로 둡니다
runNburn의 제품 경로는 GGUF 파일을 mmap으로 직접 연결합니다. 별도 제품 포맷으로 변환하지 않고, F16이나 F32로 풀어놓은 가중치 사본도 기본값으로 만들지 않아요.
이 결정 때문에 구현은 더 까다로워집니다. 아키텍처마다 GGUF tensor layout과 quantization format을 직접 이해해야 하고, CPU와 CUDA, Metal이 같은 의미를 내도록 맞춰야 하거든요.
그 번거로움을 감수하는 이유는 분명합니다. 사용자가 고른 GGUF 파일이 끝까지 기준으로 남아요. 모델을 맞추려고 몰래 다시 양자화하거나, 생성한 sidecar가 없으면 실행할 수 없는 제품 경로도 만들지 않습니다.
메모리 예산이 배치 정책을 결정합니다
일반적인 설명은 “모델을 RAM이나 VRAM에 올린다"로 끝납니다. runNburn은 그다음 질문부터 시작합니다.
- 실제로 쓸 수 있는 RAM은 얼마인가?
- 현재 남은 VRAM은 얼마인가?
- 어떤 가중치를 계속 상주시킬 것인가?
- 어떤 sparse expert를 파일에서 가져올 것인가?
- KV와 SSM 상태, staging buffer, cache에 각각 얼마를 줄 것인가?
명시적인 --ram-budget이 없으면 물리 RAM의 4분의 1을 운영체제와 런타임용으로 남기고, 나머지 4분의 3을 엔진 working set 예산으로 사용합니다. 예산을 직접 주면 그 값이 자동 계산을 대신합니다.
이 예산은 엔진의 working set을 관리하는 기준이지, 프로세스 전체 RSS를 강제로 묶는 값은 아닙니다. mmap된 파일과 KV cache, 임시 버퍼, 런타임 라이브러리까지 더하면 실제 RSS는 달라질 수 있어요. runNburn은 그중 엔진이 소유한 residency와 cache를 예산에 맞춥니다.
하드웨어가 달라도 모델 의미는 바꾸지 않습니다
runNburn에서는 모델 아키텍처가 계산 의미를 결정하고, RAM과 VRAM, backend capability가 배치 위치를 결정합니다.
작은 기기에서는 더 많은 가중치를 파일에서 읽어야 하니 느려질 수 있습니다. 그래도 모델을 맞추려고 MoE router의 선택을 바꾸거나, 임의로 더 작은 양자화 형식으로 바꾸는 식의 동작은 제품 기본값에 넣지 않았습니다.
이 원칙 때문에 성능 최적화도 항상 correctness gate를 먼저 통과해야 합니다. 같은 모델, 같은 프롬프트, 같은 생성 길이에서 참조 엔진과 의미가 어긋나면 빠른 커널이어도 채택하지 않습니다.
222GiB 모델이 64GiB Mac에서 답을 냈습니다
runNburn의 방향을 가장 잘 보여주는 결과는 GLM-5.2 실험입니다.
GLM-5.2-UD-IQ2_M은 6개 shard를 합쳐 mapped weight가 222.18GiB입니다. 실행 장비는 통합 메모리 64GiB인 Apple M5 Pro였어요. 모델 전체를 메모리에 올리는 대신, 현재 필요한 expert weight를 제한된 cache와 파일 기반 paging으로 통과시켰습니다.
2026년 7월 30일에 같은 모델과 입력 조건으로 반복 측정한 결과는 아래와 같습니다.
| 엔진과 조건 | 외부 wall 측정 3회 | 중앙값 |
|---|---|---|
| runNburn Metal, bounded expert streaming | 28.468 / 28.563 / 28.658초 | 28.563초 |
| llama.cpp Metal build, GPU layer 1개 | 156.402 / 155.867 / 156.618초 | 156.402초 |
외부 wall로 계산하면 5.48배 차이입니다. 이 숫자를 “runNburn 커널이 llama.cpp보다 5.48배 빠르다"는 뜻으로 읽으면 안 됩니다.
llama.cpp의 full Metal과 auto-fit 경로는 Metal command-buffer OOM으로 완료되지 않았습니다. 반복 가능한 비교를 위해 GPU layer 1개를 사용한 partial-Metal 구성을 택했고, runNburn은 모델이 메모리보다 훨씬 큰 조건에서 전체 제품 경로를 끝냈습니다.
생성 길이는 8토큰으로 고정했습니다. 세 번의 runNburn 출력 hash는 같았지만, 이 정도 길이로 답변 품질까지 주장할 수는 없어요. 그래서 이 결과는 일반적인 속도 순위가 아니라 메모리 제약 환경의 실행 가능성으로만 봐야 합니다.
그렇다고 SSD를 RAM처럼 쓸 수 있는 건 아닙니다
여기서 바로 흥분하면 안 됩니다.
파일에서 필요한 가중치를 그때그때 가져오면 모델은 실행할 수 있지만, 저장장치 대역폭과 page fault 비용이 사라지는 건 아닙니다. GLM-5.2처럼 expert routing이 거의 균등한 대형 MoE는 hot expert cache의 이점이 작고, 매 토큰마다 큰 working set을 다시 읽게 됩니다.
실제로 parallel prefetch를 붙여보니 20토큰 cold run이 34.9초에서 70.0초로 두 배 느려진 적도 있습니다. 미리 읽은 데이터가 곧 다시 읽히고, 제한된 page cache에서는 필요한 페이지까지 밀어내며 cache thrashing을 만들었기 때문입니다. 그 최적화는 바로 폐기했습니다.
이 실패 이후 오프로딩을 보는 기준도 달라졌습니다. 저는 오프로딩을 “SSD를 확장 RAM으로 바꾸는 기술"이라고 설명하지 않습니다. 실행조차 못 하던 모델에 길을 열고, 그 안에서 cache와 backend 정책을 조정하는 쪽에 가깝거든요.
속도가 꼭 필요한 모델은 여전히 RAM이나 VRAM에 충분히 들어가는 구성이 유리합니다.
지금은 어디까지 왔나요?
현재 공개 버전은 v0.7.0이고, CPU를 기본 경로로 둡니다.
| 경로 | 현재 상태 |
|---|---|
| Linux와 macOS CPU | 기본 경로 |
| Android ARM64 CPU | 지원 |
| NVIDIA CUDA | 활성 개발 |
| Apple Metal | 활성 개발 |
| Vulkan | 실험적 |
| OpenCL과 MediaTek | 실험적 |
CLI로 한 번 실행하거나 모델을 한 번만 로드한 채 대화할 수 있습니다. OpenAI 호환 서버도 있어서 /v1/chat/completions와 /v1/responses를 중심으로 기존 클라이언트를 연결할 수 있어요.
v0.7.0에서는 Qwen3.6 vision projector와 PNG, JPEG 입력을 붙였습니다. 이미지가 들어간 첫 대화 뒤에는 KV, GDN, multimodal position과 image identity를 함께 보존해 다음 턴에서 이미지 전체를 다시 처리하지 않는 경로도 들어갔습니다.
다만 OpenAI API 전체를 구현한 건 아닙니다. 지원하는 endpoint와 parameter만 명시적으로 받고, 모르는 필드나 지원하지 않는 요청은 조용히 무시하지 않고 오류로 돌려줍니다.
직접 실행하려면
CPU CLI는 Rust와 Cargo가 있으면 빌드할 수 있습니다.
cargo build --release -p rnb-cli --no-default-features --features cpu
GGUF 모델을 직접 실행하는 가장 단순한 형태는 이렇습니다.
./target/release/runNburn /path/to/model.gguf \
"메모리 매핑이 대형 모델 추론에 유리한 이유를 설명해줘."
RAM 예산을 직접 정하려면 옵션을 GGUF 경로 앞에 둡니다.
./target/release/runNburn --ram-budget 16GiB \
/path/to/model.gguf "안녕"
OpenAI 호환 서버는 프로세스마다 모델 하나를 올립니다. 크기가 제한된 작업 대기열을 두고 추론 요청은 한 번에 하나씩 처리합니다.
./target/release/runNburn serve \
--host 127.0.0.1 \
--port 8000 \
--model-name local-model \
--ram-budget 16GiB \
/path/to/model.gguf
이 프로젝트는 개인용 단일 사용자 추론 서버를 목표로 합니다. continuous batching, 다중 tenant 격리, 초당 요청 수 경쟁, 분산 serving은 의도적인 비목표예요.
누구에게 맞을까요?
runNburn은 이런 분에게 잘 맞습니다.
- 가장 먼저 맞는 대상은 RAM이나 VRAM보다 큰 GGUF를 직접 실험하려는 분입니다.
- 원본 GGUF를 다른 형식으로 바꾸고 싶지 않은 경우도 잘 맞아요.
- CPU, CUDA, Metal, Android에서 같은 메모리 정책을 유지해야 하는 프로젝트
- OpenAI 호환 API가 필요한 개인용 로컬 모델 서버
- 속도 숫자뿐 아니라 메모리 사용과 correctness 근거까지 확인하는 분
반대로 이런 경우에는 다른 도구가 더 편할 수 있습니다.
- 모델을 내려받아 한 명령으로 바로 실행하는 편의성이 최우선인 경우
- 메모리에 충분히 들어가는 모델에서 최고 처리량만 노리는 작업
- 여러 사용자의 요청을 continuous batching으로 처리해야 한다면 목적부터 다릅니다.
- 모든 모델 아키텍처와 backend가 완성된 안정판이 필요한 경우
runNburn은 아직 pre-1.0입니다. 아키텍처를 인식한다고 해서 모든 community variant와 tensor layout을 완전히 지원한다는 뜻도 아닙니다. CPU는 기본 경로지만 CUDA와 Metal의 연산 커버리지는 모델마다 다르고, Vulkan과 OpenCL, MediaTek은 여전히 실험 영역입니다.
앞으로도 가장 어려운 건 경계를 지키는 일입니다
프로젝트가 커지면서 하고 싶은 것도 계속 늘어납니다. 더 많은 모델, 더 빠른 CUDA와 Metal, Android 가속, 비전 입력, MTP, OpenAI 호환 API까지 하나씩 붙고 있어요.
그럴수록 처음의 문제를 놓치기 쉽습니다.
“그래서 왜 또 하나의 추론 엔진을 만들고 있지?”
제 답은 지금도 같습니다. 빠른 메모리에 다 올라가지 않는 모델을, 모델의 원래 의미를 훼손하지 않고, 사용자가 정한 한계 안에서 끝까지 실행하기 위해서입니다.
222GiB 모델이 64GiB Mac에서 답을 만들었을 때 기뻤던 이유도 속도 숫자 하나 때문은 아니었습니다. 처음 갤럭시 Z 플립4에서 작은 모델이 문장을 만들던 순간부터 이어진 방향이 틀리지 않았다는 걸 확인한 느낌에 가까웠어요.
정리하면
runNburn은 Rust로 만든 memory-aware GGUF 추론 엔진입니다. GGUF 가중치를 file-backed 상태로 유지하고, RAM과 VRAM 예산에 맞춰 host residency와 accelerator cache를 조정합니다.
이 구조 덕분에 다른 경로가 OOM이나 모델 크기 때문에 포기하는 조건에서도 실행 가능성을 만들 수 있습니다. 별도 제품 포맷으로 바꾸거나 pretrained routing 의미를 수정하지 않는다는 원칙도 그대로예요. 대신 메모리를 넘는 순간 저장장치 I/O 비용을 피할 수 없고, 모든 backend와 모델이 같은 수준으로 완성된 것도 아닙니다. 가장 빠른 다중 사용자 서버를 찾는다면 목표부터 다릅니다.
그래도 계속 만드는 이유는 단순합니다. 지금 가진 하드웨어로는 엄두도 못 냈던 모델이 실제로 답을 내놓는 순간이 아직 재미있거든요.
댓글