맥미니 M4로 LLM 서빙 시작하기 — 스펙 판단부터 첫 벤치마크까지
LLM을 처음 다뤄보는 웹 개발자가 맥미니 M4에서 Qwen2.5 7B를 띄우고, 잘못 알던 것 4가지를 계산으로 걸러내고, TTFT와 prefill 곡선을 측정하기까지의 기록.
이 글의 독자: LLM을 처음 다뤄보는 개발자.
KV cache,TTFT,quantization같은 단어를 오늘 처음 듣는 분을 기준으로 썼습니다. 모든 용어는 처음 나올 때 설명하고, 모든 숫자는 계산 과정을 같이 적었습니다.글의 형식: 시간 순서(timeline)입니다. 결과만 정리한 글이 아니라, 어떤 결정을 왜 했고, 무엇을 잘못 알고 있었고, 몇 번 만에 제대로 된 숫자를 얻었는지를 그대로 적었습니다. 틀렸던 부분을 지우지 않은 이유는, 그게 이 글에서 제일 쓸모 있는 부분이기 때문입니다.
들어가며 — 왜 이걸 시작했나
저는 웹 개발자입니다. AI 모델을 학습시키는 사람이 되고 싶은 게 아니라, 오픈소스 LLM을 실제 트래픽 아래에서 버티게 만드는 백엔드 엔지니어가 되고 싶습니다.
이 둘은 완전히 다른 일입니다. 전자는 모델을 만드는 일(research)이고, 후자는 이미 있는 모델을 빠르고 싸고 안 죽게 서비스하는 일(serving/inference)입니다. 웹 개발자가 가진 기술 — 동시성 처리, 큐잉, 캐싱, 모니터링 — 이 그대로 먹히는 쪽은 후자입니다.
그래서 목표를 한 문장으로 정했습니다.
“LLM 추론의 병목(latency, GPU 비효율, 동시성)을 백엔드 엔지니어링으로 해결하고, 그 차이를 숫자로 증명한다.”
이 글은 그 프로젝트의 1일차 기록입니다. 아직 아무것도 만들지 않았고, 대신 내 하드웨어가 뭘 할 수 있는지를 측정했습니다. 그리고 그 과정에서 처음에 알고 있던 것 중 상당수가 틀렸다는 걸 알게 됐습니다.
Step 0 — 시작 전에 알아야 할 최소한의 용어
본문에 들어가기 전에, 앞으로 계속 나올 단어 5개만 먼저 정리하겠습니다. 이것만 알면 나머지는 따라올 수 있습니다.
| 용어 | 뜻 | 비유 |
|---|---|---|
| Parameter (파라미터) | 모델이 학습으로 얻은 숫자들. “7B 모델” = 파라미터 70억 개 | 모델의 “지식”이 저장된 값들 |
| Quantization (양자화) | 파라미터 하나를 몇 bit로 저장할지 줄이는 것 | 사진을 JPEG로 압축하는 것. 용량이 줄고, 화질이 아주 조금 나빠짐 |
| VRAM | GPU가 쓸 수 있는 메모리. 모델은 여기 통째로 올라가야 함 | 작업대 크기. 작으면 큰 모델을 아예 못 올림 |
| Memory bandwidth (메모리 대역폭) | GPU가 메모리에서 초당 몇 GB를 읽을 수 있는지 | 작업대에서 손을 얼마나 빨리 움직일 수 있는지 |
| TTFT (Time To First Token) | 요청을 보낸 뒤 첫 글자가 화면에 뜨기까지 걸린 시간 | 사용자가 “느리다”고 느끼는 바로 그 시간 |
특히 VRAM과 memory bandwidth는 다른 개념이라는 점이 이 글 전체의 핵심입니다. 대부분의 글이 VRAM만 이야기하는데, 실제로는 둘 다 봐야 합니다.
용량(VRAM)은 “올릴 수 있냐 없냐”를 결정하고, 대역폭(bandwidth)은 “빠르냐 느리냐”를 결정합니다.
Step 1 — 하드웨어 스펙 판단 (결정 1)
저에게는 두 대의 컴퓨터가 있습니다.
- Mac mini M4, 통합 메모리 32GB
- 데스크탑, RTX 4060 Ti (VRAM 8GB)
처음엔 당연히 “NVIDIA가 AI용이니까 데스크탑이 메인”이라고 생각했습니다. 그런데 스펙을 실제로 찾아보니 판단이 뒤집혔습니다.
두 기계의 실제 스펙
| 항목 | Mac mini M4 (32GB) | RTX 4060 Ti (8GB) |
|---|---|---|
| GPU가 쓸 수 있는 메모리 | 약 21.3 GB (설정으로 ~28GB까지) | 8 GB (변경 불가) |
| 메모리 대역폭 | 120 GB/s | 288 GB/s |
| CUDA 사용 | 불가 | 가능 |
| 소비 전력 (idle) | 매우 낮음 | 높음 |
용량은 맥이 약 2.7배 이기고, 속도는 데스크탑이 2.4배 이깁니다. 어느 쪽이 “더 좋다”가 아니라, 서로 반대 방향으로 부족한 기계입니다.
여기서 처음 알게 된 것 ①: 맥의 “통합 메모리”는 32GB 전부를 GPU가 쓰지 못한다
애플 실리콘은 통합 메모리(Unified Memory) 구조라서, CPU와 GPU가 같은 메모리를 나눠 씁니다. 그래서 “32GB니까 VRAM 32GB”라고 생각하기 쉬운데, macOS가 시스템 몫을 떼어갑니다.
36GB 이하 기기에서는 대략 2/3만 GPU에 할당됩니다.
\[32768\ \text{MB} \times \frac{2}{3} \approx 21845\ \text{MB} \approx \mathbf{21.3\ GB}\]이 값은 터미널에서 강제로 올릴 수 있습니다.
1
2
# GPU에 28GB를 할당. 재부팅하면 원래대로 돌아감
sudo sysctl iogpu.wired_limit_mb=28672
다만 OS 몫을 4GB 아래로 남기면 macOS가 SSD로 swap을 시작하고, 그 순간 속도가 한 자릿수 배가 아니라 십수 배로 느려집니다. 이때 증상이 특이한데, 성능이 서서히 나빠지는 게 아니라 절벽처럼 떨어집니다. 이걸 모르면 “모델이 이상하다”고 엉뚱한 데서 원인을 찾게 됩니다.
여기서 처음 알게 된 것 ②: 32GB 맥미니는 대역폭이 낮은 쪽이다
이게 제가 제일 크게 잘못 알고 있던 부분입니다. “애플 실리콘은 메모리 대역폭이 높다”는 말을 여기저기서 봤는데, 칩별로 확인해보니 이렇습니다.
| 칩 | 메모리 대역폭 | 맥미니 메모리 옵션 |
|---|---|---|
| M4 (base) | 120 GB/s | 16 / 24 / 32GB |
| M4 Pro | 273 GB/s | 24 / 48 / 64GB |
| M4 Max | 410–546 GB/s | (맥미니에 없음) |
맥미니 M4 Pro는 24GB에서 시작해서 48GB로 건너뜁니다. 즉 “맥미니 32GB”라는 조합 자체가 base M4 칩이라는 뜻이고, 대역폭은 120 GB/s입니다. RTX 4060 Ti(288 GB/s)보다 오히려 낮습니다.
“애플 실리콘 = 고대역폭”은 M4 Pro/Max 이야기였습니다. 제 기계는 용량은 넉넉하고 속도는 느린 기계입니다.
결정 1 — 두 기계의 역할 분담
| 기계 | 역할 | 이유 |
|---|---|---|
| Mac mini M4 | 서비스 서버 — 모델 + Spring Boot + Kafka + Redis + Grafana 전부 상시 구동 | 21.3GB 용량과 낮은 전력. 8GB로는 스택 전체를 못 올림 |
| RTX 4060 Ti | CUDA 벤치마크 장비 — vLLM, TensorRT, Triton | CUDA는 맥에서 아예 안 됨. 7B 4-bit(4.5GB)는 8GB에 충분히 들어감 |
“둘 중 뭐가 낫냐”가 아니라 “서로 못 하는 걸 시킨다”로 정리했습니다. 그리고 이 구성이 오히려 포트폴리오로는 낫다고 판단했습니다. 같은 모델을 서로 다른 아키텍처 두 대에서 돌려서 비교한 데이터는 흔치 않으니까요.
Step 2 — 모델이 메모리에 얼마나 들어가는지 직접 계산하기
모델 카드는 누구나 읽을 수 있습니다. 엔지니어의 능력은 “이거 내 기계에 들어가? 얼마나 빨라?”를 다운로드 전에 답할 수 있느냐입니다. 계산은 초등학교 산수 수준입니다.
메모리는 세 덩어리입니다.
\[\text{Total} = \underbrace{\text{Weights}}_{\text{fixed}} + \underbrace{\text{KV cache}}_{\text{users}\,\times\,\text{context}} + \underbrace{\text{Activations}}_{\text{small}}\]- Weights — 모델 자체. 고정된 크기
- KV cache — 동시 사용자 수 × 문맥 길이에 비례해서 커짐
- Activations — 작아서 일단 무시
“OOM(메모리 부족)이 났는데 이유를 모르겠다”의 거의 전부는 첫 번째만 계산하고 두 번째를 잊은 경우입니다.
① Weights — 모델 자체의 크기
\[\text{Weights (GB)} = \frac{\text{params} \times \text{bits per param}}{8 \times 10^{9}}\]8B(80억) 모델로 계산해보면:
| 정밀도 | bit/param | 크기 | 설명 |
|---|---|---|---|
| FP16 | 16 | 16 GB | 압축 안 한 원본 |
| INT8 | 8 | 8 GB | 거의 손실 없음 |
| INT4 | 약 4.5 | 약 4.5 GB | 실무 기본값 |
왜 4가 아니라 4.5인가? 양자화는 파라미터를 32개나 64개씩 묶어서 저장하는데, 묶음마다 scale(배율)과 zero-point(기준점) 값을 원래 정밀도로 따로 저장합니다. 그 부가 정보가 약 0.5 bit입니다. 그래서 “4-bit 모델”의 실제 파일은 항상 파라미터 ÷ 2보다 큽니다.
면접에서 쓸 문장: “quantization은 무손실이다”라고 말하면 안 됩니다. 4-bit는 벤치마크 점수를 몇 % 깎고, 특히 긴 계산과 코드에서 티가 납니다. 대신 이렇게 말하는 게 정확합니다 — “4-bit는 정확도를 조금 내주고 메모리를 4배, 대역폭 병목 기계에서는 속도까지 약 4배 얻는 거래입니다.”
② KV cache — 진짜로 발목 잡는 쪽
LLM은 글자를 하나씩 만들어냅니다. 새 글자를 만들 때마다 앞의 모든 글자를 다시 계산하면 너무 느리기 때문에, 이미 처리한 글자들의 중간 결과(Key, Value 벡터)를 메모리에 저장해둡니다. 이게 KV cache입니다.
문제는 이게 문맥 길이 × 동시 사용자 수에 비례해서 커진다는 점입니다.
\[\text{bytes per token} = 2 \times L \times H_{kv} \times d_{head} \times \text{bytes per value}\]L= 레이어 수H_kv= key/value head 개수 (attention head 개수와 다릅니다 — 아래 설명)d_head= head 차원- 맨 앞 2 = Key와 Value 두 개
맨 뒤의 “값당 bytes”는 런타임마다 다릅니다
대부분의 글이 FP16(2 bytes)을 가정합니다. 그런데 내 서버가 실제로 뭘 쓰는지 확인해야 합니다. pgrep -fl ollama로 보면 Ollama는 기본값이 --cache-type-k q8_0 --cache-type-v q8_0, 즉 8-bit 캐시입니다.
| 캐시 타입 | 값당 bytes | 비고 |
|---|---|---|
| FP16 | 2.0 | 교과서적 가정. vLLM 기본값 |
| q8_0 | 약 1.06 | 8bit + 32개 블록마다 FP16 scale → 8.5bit. Ollama 기본값 |
| q4_0 | 약 0.56 | 공격적. 긴 문맥에서 품질 손해가 보임 |
Llama 3.1 8B (L=32, H_kv=8, d_head=128):
즉 실제로 돌아가는 환경에서는 토큰당 68 KB입니다. 이걸 확인 안 하고 FP16으로 계산했으면 메모리 예산을 2배 비관적으로 잡을 뻔했습니다.
이제 이게 왜 무서운지 보여주는 표입니다.
q8_0 기준 (실제로 돌아가는 환경):
| 문맥 길이 | 1명 | 10명 | 50명 |
|---|---|---|---|
| 4K 토큰 | 0.27 GB | 2.7 GB | 13.6 GB |
| 8K 토큰 | 0.53 GB | 5.3 GB | 26.6 GB — 불가능 |
| 32K 토큰 | 2.1 GB | 21.3 GB — 불가능 | 불가능 |
(FP16이면 전부 2배입니다. 50명이 8K를 쓰면 50GB가 필요합니다.)
제 기계는 21.3GB입니다. 모델(4.5GB)이 문제가 아니라, 50명이 8K 문맥을 쓰면 캐시만 50GB가 필요합니다. 모델보다 캐시가 10배 큽니다.
GQA — 파라미터 수보다 중요할 수 있는 값
옛날 모델은 attention head 하나마다 KV head가 하나씩 있었습니다(MHA). 요즘 모델은 GQA(Grouped Query Attention) 를 씁니다. Llama 3.1 8B는 attention head가 32개인데 KV head는 8개입니다. 4개의 head가 KV 한 쌍을 공유합니다.
결과: KV cache가 1/4로 줄어듭니다. 품질 손해는 거의 없습니다.
그래서 모델을 고를 때 파라미터 수만 보면 안 되고, config.json의 num_key_value_heads를 봐야 합니다. GQA가 잘 된 14B가 GQA 없는 7B보다 동시접속에서 더 쌀 수 있습니다.
실제로 쓸 모델로 계산 — Qwen2.5 7B
제가 고른 모델입니다. L=28, H_kv=4 (GQA가 아주 공격적), d_head=128:
토큰 하나당 30 KB. GQA가 공격적(KV head 4개)인 데다 캐시까지 8-bit이라, Llama 3.1 8B의 절반 이하입니다.
| 항목 | 값 | 계산 |
|---|---|---|
| GPU 가용 메모리 | 21.3 GB | 32GB × 2/3 |
| Weights | −4.2 GB | 7.6B × 4.5bit ÷ 8 |
| 여유분(overhead) | −1.5 GB | 경험값 |
| KV cache 예산 | 15.6 GB | |
| 저장 가능 토큰 수 | 약 550,000 | 15.6GB ÷ 30KB |
| 4K 문맥 기준 동시 요청 | 약 135건 | 550,000 ÷ 4,096 |
135라는 숫자가 나왔습니다. 이게 부하 테스트의 목표치가 됩니다. 단, 이 숫자는 캐시를 빈틈없이 채울 수 있을 때 이야기입니다. 순진하게 짠 서버는 이걸 못 합니다 (뒤에서 설명).
Step 3 — 서빙 스택 결정 (결정 2)
여기서 불편한 사실을 마주쳤습니다.
“LLM 서빙 포트폴리오”의 표준 구성은 vLLM + NVIDIA Triton Inference Server입니다. 채용 공고에 그 이름이 적혀 있고, 면접에서 그걸 묻습니다. 그런데 맥미니에서는:
| 런타임 | 맥 GPU에서 동작? | 실제 상황 |
|---|---|---|
| NVIDIA Triton Server | 아예 안 됨 | macOS 빌드가 존재하지 않음. “느리다”가 아니라 바이너리가 없음 |
| vLLM | CPU만 | macOS에서는 소스 빌드 + CPU backend. Metal(맥 GPU) 지원은 아직 확정된 이야기가 아님 |
| MLX / mlx-lm | 정식 지원 | 애플이 직접 만든 프레임워크. QLoRA 파인튜닝도 가능 |
| Ollama (llama.cpp) | 정식 지원 | Metal 사용. 설치 10분 |
아키텍처 다이어그램에 Triton이 들어 있다면, 그건 제 기계의 다이어그램이 아닙니다. 이걸 3주 뒤에 알았으면 최악이었을 겁니다.
여기서 배운 것: 아키텍처를 그리기 전에 각 컴포넌트가 내 하드웨어에서 실제로 실행되는지부터 확인해야 합니다. 유명한 구성이라고 해서 내 기계에서 도는 건 아닙니다.
결정 2 — Ollama로 시작
가장 좋아서가 아니라, 기준선(baseline)이 필요했고 Ollama가 10분이면 뜨기 때문입니다.
1
2
3
4
5
6
brew install ollama
ollama serve
ollama pull qwen2.5:7b # 약 4.7GB, 기본이 4-bit(Q4_K_M)
ollama list # 실제 받은 용량 확인
ollama show qwen2.5:7b # 아키텍처, 양자화, 문맥 길이 확인
모델 태그에 양자화 종류가 들어 있습니다. qwen2.5:7b는 qwen2.5:7b-instruct-q4_K_M의 별칭이고, q8_0으로 바꾸면 8-bit짜리(2배 크기)를 받습니다.
Qwen3가 아니라 Qwen2.5로 시작한 이유: Qwen3에는 thinking mode가 있어서 답을 하기 전에 추론 토큰을 먼저 뱉습니다. 그러면 “첫 토큰”이 뭔지 애매해져서 TTFT 측정이 오염됩니다. 깨끗한 기준선을 먼저 잡고, 비교는 나중에 하기로 했습니다.
Step 4 — 첫 측정, 그리고 첫 함정
측정할 지표 4개
Latency는 숫자 하나가 아닙니다. 이 4개를 처음부터 다 재야 합니다. 나중에 붙이려면 훨씬 고생합니다.
| 지표 | 뜻 | 무엇에 좌우되나 |
|---|---|---|
| TTFT (Time To First Token) | 첫 글자가 뜨기까지 | 프롬프트 처리(prefill). 사용자가 체감하는 “느림” |
| TPOT (Time Per Output Token) | 첫 글자 이후 글자당 시간 | 메모리 대역폭 |
| Throughput | 전체 요청 합쳐 초당 토큰 수 | batching. 높이면 개별 TTFT는 약간 나빠짐 |
| P99 | 상위 1% 느린 요청 | 평균은 꼬리를 숨김. 새벽에 알람 울리게 만드는 건 P99 |
마지막이 중요합니다. 평균 latency만 보고하면 안 됩니다. P50과 P99를 항상 같이 봅니다.
함정 ① — 첫 실행은 기준선이 아니다
처음 돌린 결과입니다.
1
2
3
4
5
6
7
8
{
"load_ms": 3010.01,
"prefill_ms": 296.66,
"prompt_tokens": 40,
"gen_tokens": 71,
"gen_ms": 3456.12,
"tok_per_sec": 20.54
}
load_ms가 3초입니다. 이건 SSD에서 4.7GB를 메모리로 읽어들인 시간이지 모델 성능이 아닙니다. Ollama는 5분간 요청이 없으면 모델을 내리기 때문에, 커피 마시고 와서 돌리면 다시 cold 상태가 됩니다.
규칙: 측정 전에 warm-up 요청을 한 번 보내고, 매 측정마다
load_duration이 0에 가까운지 확인합니다.
함정 ② — 같은 프롬프트를 반복하면 캐시를 측정하게 된다
warm-up을 넣고 다시 쟀더니 이런 결과가 나왔습니다.
| 프롬프트 길이 | prefill 시간 | prefill 속도 |
|---|---|---|
| 40 토큰 | 51 ms | 778 tok/s |
| 3,473 토큰 | 58 ms | 59,646 tok/s |
3,473개 토큰을 58ms에 처리했다? 물리적으로 불가능합니다.
원인은 prefix caching이었습니다. llama.cpp는 이전 요청과 앞부분이 같으면 그때 계산해둔 KV cache를 그대로 재사용합니다. 저는 똑같은 긴 프롬프트를 6번 보냈으니, 2번째부터는 prefill을 거의 통째로 건너뛴 것입니다. 벤치마크가 측정한 건 성능이 아니라 캐시 적중이었습니다.
고치는 방법은 간단합니다. 매 요청마다 프롬프트 맨 앞에 고유한 문자열(UUID)을 붙여서 캐시를 무효화합니다. 캐시는 앞부분(prefix)이 일치해야 먹히기 때문에, 앞을 바꾸면 전체가 무효가 됩니다.
규칙: 프롬프트를 반복하는 벤치마크는 캐시를 재는 것입니다. prefix를 매번 바꾸지 않으면 자기 자신을 속이게 됩니다.
그런데 이 버그가 이 프로젝트에서 제일 중요한 발견이 됐습니다. 뒤에서 다시 나옵니다.
제대로 잰 기준선
캐시를 무효화하고, warm 상태에서, 5회 측정 후 중앙값입니다.
짧은 프롬프트 (74 토큰)
| 회차 | TTFT (ms) | 전체 (ms) | prefill (ms) | 생성 토큰 | tok/s |
|---|---|---|---|---|---|
| 1 | 384 | 3659 | 286 | 69 | 21.1 |
| 2 | 394 | 3754 | 293 | 72 | 21.4 |
| 3 | 403 | 3566 | 302 | 68 | 21.5 |
| 4 | 379 | 3617 | 282 | 69 | 21.3 |
| 5 | 394 | 3627 | 291 | 69 | 21.3 |
| 중앙값 | 394 | 3627 | 291 | 21.3 |
긴 프롬프트 (3,505 토큰)
| 회차 | TTFT (ms) | 전체 (ms) | prefill (ms) | 생성 토큰 | tok/s |
|---|---|---|---|---|---|
| 1 | 17611 | 21022 | 17499 | 65 | 19.1 |
| 2 | 17960 | 21235 | 17846 | 65 | 19.9 |
| 3 | 17927 | 21267 | 17809 | 65 | 19.5 |
| 4 | 18184 | 21833 | 18067 | 69 | 19.0 |
| 5 | 18070 | 21645 | 17905 | 69 | 19.3 |
| 중앙값 | 17960 | 21267 | 17846 | 19.3 |
긴 프롬프트에서 첫 글자가 나오기까지 18초. 짧은 프롬프트의 45배입니다.
Step 5 — 이 숫자들이 하드웨어 한계인지 확인하기
“18초? 설정을 잘못했나?” 싶었습니다. 그래서 이론값과 비교했습니다. 이게 이 글에서 제일 중요한 습관입니다 — 측정값은 항상 이론 한계와 대조해야 합니다.
생성 속도는 대역폭 한계
토큰 하나를 만들 때 모델은 자기 가중치 전체를 메모리에서 한 번 읽습니다. 그래서 상한은:
\[\text{tok/s}_{\max} = \frac{\text{bandwidth}}{\text{model size}} = \frac{120\ \text{GB/s}}{4.68\ \text{GB}} = \mathbf{25.6}\]실측 21.3 tok/s = 이론값의 83%.
Prefill은 연산 한계
프롬프트 처리는 계산량이 정해져 있습니다. Transformer forward pass 1회는 대략 2 × 파라미터 수 × 토큰 수 회의 부동소수점 연산입니다.
이걸 17.9초에 했으니:
\[\frac{53\ \text{TFLOPs}}{17.9\ \text{s}} = \mathbf{3.0\ \text{TFLOPS}}\]base M4 GPU의 이론 최대치가 약 4.3 TFLOPS이니 약 70%입니다.
결론: 둘 다 하드웨어 한계에 붙어 있다
| 단계 | 무엇에 묶여 있나 | 이론 한계 | 실측 | 달성률 |
|---|---|---|---|---|
| 생성 (decode) | 메모리 대역폭 120 GB/s | 25.6 tok/s | 21.3 | 83% |
| 프롬프트 처리 (prefill) | GPU 연산 약 4.3 TFLOPS | — | 3.0 TFLOPS | 약 70% |
즉 18초는 설정 실수가 아닙니다. 10코어 GPU가 53조 번의 연산을 하는 데 걸리는 시간입니다. Ollama를 아무리 튜닝해도, mlx-lm으로 바꿔도 이건 안 줄어듭니다.
이 확인을 안 했으면 “런타임이 문제인가?” 하면서 일주일을 날렸을 겁니다.
Step 6 — Prefill 곡선 측정: 프롬프트가 길어지면 얼마나 느려지나
18초가 하드웨어 한계라면, 얼마나 짧게 써야 쓸만해지는지를 알아야 합니다. 그래서 프롬프트 길이를 바꿔가며 쟀습니다.
측정 조건: num_ctx=8192 고정, num_predict=32, 매 요청 UUID prefix로 캐시 무효화, 각 지점 3회 중앙값.
| 목표 길이 | 실제 토큰 | TTFT (ms) | prefill (ms) | 토큰당 ms | prefill tok/s | 생성 tok/s |
|---|---|---|---|---|---|---|
| 128 | 205 | 994 | 891 | 4.35 | 230 | 19.3 |
| 512 | 578 | 3,160 | 2,790 | 4.83 | 207 | 15.1 |
| 1024 | 1,096 | 5,242 | 5,137 | 4.69 | 213 | 20.2 |
| 2048 | 2,091 | 10,850 | 10,741 | 5.14 | 195 | 19.5 |
| 4096 | 4,056 | 22,041 | 21,891 | 5.40 | 185 | 17.9 |
| 8192 | 8,016 | 48,475 | 48,337 | 6.03 | 166 | 17.0 |
(목표보다 실제 토큰이 많은 이유: 캐시 무효화용 UUID가 약 90토큰을 차지합니다. 실제 측정값 기준으로 표기했습니다.)
205~8,016 토큰. 선형 추정(초록 점선)은 길어질수록 실제와 벌어진다.
거의 선형인데, 미묘하게 휜다
토큰당 처리 시간이 4.35 → 5.40 ms로 24% 증가했습니다. 길이가 20배 늘어나는 동안입니다.
이건 이론과 정확히 맞습니다. Transformer의 비용은 두 종류입니다.
- FFN, projection 레이어: 토큰 수에 비례 —
O(T) - Attention: 모든 토큰이 앞의 모든 토큰을 봄 —
O(T²)
측정값에 맞춰 식을 세우면 (T = 프롬프트 토큰 수):
| 프롬프트 길이 | 선형 항 기여 | 2차 항 기여 |
|---|---|---|
| 205 토큰 | 99% | 1% |
| 4,056 토큰 | 85% | 15% |
| 8,016 토큰 | 74% | 26% |
데이터 5개로 attention이 지배적 비용이 되어가는 순간을 잡은 겁니다. 1K 토큰 아래에서는 선형으로 봐도 되고, 그 위로는 선형 추정을 하면 낙관적인 오답이 나옵니다.
| 프롬프트 | 선형으로 추정 | 식으로 예측 | 실측 |
|---|---|---|---|
| 8,016 | 35초 | 48.7초 | 48.3초 ✅ |
| 16,384 | 74초 | 약 126초 | 미측정 |
| 32,768 | 147초 | 약 6분 | 미측정 |
8K는 실제로 재봤습니다. 예측 48.7초, 실측 48.3초 — 0.7% 오차입니다.
사실 처음 세운 식은 데이터 양 끝점 2개만 보고 만든 거라 8,192 토큰을 53초로 예측했었습니다. 실측은 48.3초, 약 8% 빗나갔습니다. 그래서 6개 점 전부로 다시 회귀시켜 위의 계수를 얻었습니다. 2차 항이 존재한다는 형태는 맞았고, 계수가 과적합이었던 것입니다.
이 기계에서 long context는 “느린” 게 아니라 “불가능”입니다. (16K·32K는 여전히 외삽값입니다.)
덤으로 알게 된 것: 8K를 잴 때
num_ctx를 8192에서 16384로 올렸는데, 같은 4,057 토큰 프롬프트가 21,912ms(대조군) vs 21,891ms(기존)로 0.1% 차이였습니다. 즉 문맥 창 크기는 prefill 속도에 영향을 주지 않습니다. KV cache 메모리를 더 쓸 뿐, 시간을 더 쓰지는 않습니다.
그리고 이게 만드는 제약
식을 뒤집으면 내가 쓸 수 있는 프롬프트 예산이 나옵니다.
| 목표 TTFT | 쓸 수 있는 프롬프트 |
|---|---|
| 1초 | 약 220 토큰 |
| 2초 | 약 435 토큰 |
| 5초 | 약 1,060 토큰 |
일반적인 RAG 구성 — 시스템 프롬프트 + 검색된 500토큰 문서 3개 — 이면 약 1,600토큰, 즉 첫 글자까지 8초입니다. 못 씁니다.
한편 생성 속도는 프롬프트 길이와 거의 무관했습니다(19.3 → 17.9 tok/s, 578 지점의 15.1은 3회 측정 노이즈). 19.3에서 17.9로 살짝 준 건 attention이 매 스텝 더 큰 KV cache를 읽기 때문입니다.
프롬프트 길이는 TTFT를 갉아먹지, 생성 속도를 갉아먹지 않습니다.
Step 7 — 결론: prefill과 decode는 다른 문제다
하루 측정해서 얻은 제일 중요한 결론입니다.
| Prefill (프롬프트 처리) | Decode (토큰 생성) | |
|---|---|---|
| 하는 일 | 프롬프트 전체를 한 번에 처리 | 한 글자씩 생성 |
| 비용 | O(T) + O(T²) | 토큰당 O(1) |
| 묶여 있는 자원 | GPU 연산 (FLOPS) | 메모리 대역폭 |
| GPU 상태 | 꽉 참 | 대부분 놀고 있음 (메모리 기다리는 중) |
| 사용자 체감 | TTFT | 글자 흐르는 속도 |
| 해결책 | 캐싱, 짧은 프롬프트 | quantization, batching |
어떤 최적화가 어느 쪽에 듣는지를 표로 정리하면:
| 최적화 | Prefill | Decode | 비고 |
|---|---|---|---|
| Prefix / KV 캐싱 | 매우 큼 | — | 17,846ms → 58ms 실측 |
| 프롬프트 줄이기 | 직접적 | — | 재미없지만 항상 통함 |
| 4-bit quantization | 조금 | 큼 | 토큰당 읽는 바이트가 줄어듦 |
| Continuous batching | — | 큼 | 처리량 개선. 개별 latency는 아님 |
| 더 빠른 GPU | FLOPS가 올라야 | 대역폭이 올라야 | 어느 스펙이 올랐는지 확인할 것 |
그리고 아까 그 버그로 돌아가면
벤치마크를 망쳤던 prefix caching. 그 숫자를 다시 보겠습니다.
캐시 적중: 17,846 ms → 58 ms. 약 300배.
이게 이 프로젝트에서 지금까지 나온 제일 큰 숫자입니다. 그리고 RAG의 프롬프트는 구조상 앞부분이 안정적입니다 — 시스템 프롬프트, few-shot 예제, 비슷한 질문에서 반복되는 검색 청크. 즉 캐시 적중률을 높이도록 설계하는 것이 이 하드웨어에서 유일하게 의미 있는 최적화입니다.
큰 NVIDIA GPU에서는 prefill이 워낙 빨라서 다들 decode 이야기(continuous batching, PagedAttention)를 합니다. 10코어 M4에서는 prefill이 압도적이라, 그 교과서적 최적화가 엉뚱한 쪽을 겨냥합니다.
이 뒤집힘이 제가 오늘 얻은 가장 쓸모 있는 것입니다.
오늘의 타임라인 요약
| # | 단계 | 결과 | 얻은 것 |
|---|---|---|---|
| 1 | 하드웨어 스펙 검토 | 맥 = 용량형(21.3GB / 120GB/s), 데스크탑 = 속도형(8GB / 288GB/s) | 역할 분담 결정 |
| 2 | 잘못 알던 것 4개 수정 | 아래 별도 표 | 계산으로 검증하는 습관 |
| 3 | 메모리 계산 | Qwen2.5 7B 기준 4K 문맥에서 약 135 동시 요청 | 부하 테스트 목표치. 런타임 플래그부터 확인할 것 |
| 4 | 서빙 스택 조사 | Triton은 macOS 빌드 없음 | 3주 삽질 방지 |
| 5 | 첫 측정 | cold load 3초에 속을 뻔함 | warm-up 필수 |
| 6 | 측정 실패 | 캐시를 성능으로 착각 (59,646 tok/s) | prefix 무효화 필수 + 300배라는 발견 |
| 7 | 기준선 확보 | 21.3 tok/s / TTFT 394ms(짧음), 17,960ms(김) | Phase 1 완료 |
| 8 | 이론값 대조 | 대역폭의 83%, FLOPS의 70% | 하드웨어 한계임을 확인 |
| 9 | prefill 곡선 (205~8,016 토큰) | 4.50T + 1.96e-4·T² | 프롬프트 예산표 |
| 10 | 외삽 검증 | 8K 예측 53초 → 실측 48.3초 | 끝점 2개 회귀는 과적합. 전 구간 재회귀 |
시작할 때 알고 있던 것 중 틀렸던 4가지
| 알고 있던 것 | 실제 | 왜 틀렸나 |
|---|---|---|
| “8GB로는 7B 모델도 OOM” | 틀림. 7B 4-bit은 4.5GB, 잘 돌아감 | 제약은 로딩이 아니라 문맥 길이 |
| “32GB면 VRAM 24~26GB” | 낙관적. 기본 21.3GB | 36GB 이하는 2/3만 할당 |
| “애플 실리콘은 고대역폭” | 아님. base M4는 120GB/s로 4060 Ti보다 낮음 | 그건 M4 Pro/Max 이야기 |
| “30B 이상은 불가능” | 아님. 32B 4-bit은 18GB로 올라감 | 다만 4~6 tok/s에 캐시 공간이 없어서 올라가지만 쓸모없음 |
네 개 모두 그럴듯했습니다. 황당한 소리는 하나도 없었습니다. 그런데 스펙 시트에 대고 산수를 해보니 한 시간 만에 다 걸러졌습니다. 이게 이 글의 진짜 교훈입니다.
다음 편 예고
- prefix cache 적중률을 의도적으로 측정 — 앞부분은 같고 뒷부분만 바꿔가며. 300배가 실제 RAG 트래픽에서 몇 배로 남는지
8,192 토큰 실측✅ 완료 (48.3초). 다음은 16,384 토큰 — 약 126초 예측qwen2.5:3b와 비교 — 연산량 1/4이면 prefill도 1/4인지, 모델이 예측대로 작동하는지- FastAPI + SSE 스트리밍을 앞에 붙이고, 동시 요청 2건부터 시작
처음 하는 분들께 남기는 규칙 5개
- 숫자를 먼저 적어라. 개선 전 측정값 없는 최적화는 이야기지 공학이 아닙니다.
- 같은 프롬프트를 반복하는 벤치마크는 캐시를 잰다. prefix를 매번 바꾸세요.
- 실측값을 항상 이론 한계와 대조하라. 대역폭 ÷ 모델 크기, 2 × 파라미터 × 토큰. 이 두 개면 충분합니다.
- 모델 카드 말고
config.json을 읽어라.num_key_value_heads가 메모리 예산을 결정합니다. - 런타임이 실제로 어떤 플래그로 떴는지 확인하라.
pgrep -fl ollama. 저는 FP16 캐시를 가정했다가 실제로는 q8_0이라 메모리를 2배 비관적으로 계산하고 있었습니다. - 평균 말고 P50과 P99를 같이 보고하라. 평균은 장애를 숨깁니다.
측정을 17분간 쉬지 않고 돌린 탓에 뒤쪽 값에는 thermal throttling이 섞여 있을 수 있습니다. 다음 편에서는 냉각 시간과 대조군을 넣어 다시 잽니다.
이 글은 실제 측정 기록을 그대로 옮긴 것입니다. 모든 숫자는 Mac mini M4(32GB) / Ollama / Qwen2.5 7B Q4_K_M 환경에서 2026-08-31에 측정했습니다. 애플 실리콘 지원 상황은 매우 빠르게 바뀌므로, 런타임 관련 내용은 직접 확인하시기 바랍니다.