포스트

맥미니 M4로 LLM 서빙 시작하기 — 스펙 판단부터 첫 벤치마크까지

LLM을 처음 다뤄보는 웹 개발자가 맥미니 M4에서 Qwen2.5 7B를 띄우고, 잘못 알던 것 4가지를 계산으로 걸러내고, TTFT와 prefill 곡선을 측정하기까지의 기록.

맥미니 M4로 LLM 서빙 시작하기 — 스펙 판단부터 첫 벤치마크까지

이 글의 독자: LLM을 처음 다뤄보는 개발자. KV cache, TTFT, quantization 같은 단어를 오늘 처음 듣는 분을 기준으로 썼습니다. 모든 용어는 처음 나올 때 설명하고, 모든 숫자는 계산 과정을 같이 적었습니다.

글의 형식: 시간 순서(timeline)입니다. 결과만 정리한 글이 아니라, 어떤 결정을 왜 했고, 무엇을 잘못 알고 있었고, 몇 번 만에 제대로 된 숫자를 얻었는지를 그대로 적었습니다. 틀렸던 부분을 지우지 않은 이유는, 그게 이 글에서 제일 쓸모 있는 부분이기 때문입니다.


들어가며 — 왜 이걸 시작했나

저는 웹 개발자입니다. AI 모델을 학습시키는 사람이 되고 싶은 게 아니라, 오픈소스 LLM을 실제 트래픽 아래에서 버티게 만드는 백엔드 엔지니어가 되고 싶습니다.

이 둘은 완전히 다른 일입니다. 전자는 모델을 만드는 일(research)이고, 후자는 이미 있는 모델을 빠르고 싸고 안 죽게 서비스하는 일(serving/inference)입니다. 웹 개발자가 가진 기술 — 동시성 처리, 큐잉, 캐싱, 모니터링 — 이 그대로 먹히는 쪽은 후자입니다.

그래서 목표를 한 문장으로 정했습니다.

“LLM 추론의 병목(latency, GPU 비효율, 동시성)을 백엔드 엔지니어링으로 해결하고, 그 차이를 숫자로 증명한다.”

이 글은 그 프로젝트의 1일차 기록입니다. 아직 아무것도 만들지 않았고, 대신 내 하드웨어가 뭘 할 수 있는지를 측정했습니다. 그리고 그 과정에서 처음에 알고 있던 것 중 상당수가 틀렸다는 걸 알게 됐습니다.


Step 0 — 시작 전에 알아야 할 최소한의 용어

본문에 들어가기 전에, 앞으로 계속 나올 단어 5개만 먼저 정리하겠습니다. 이것만 알면 나머지는 따라올 수 있습니다.

용어비유
Parameter (파라미터)모델이 학습으로 얻은 숫자들. “7B 모델” = 파라미터 70억 개모델의 “지식”이 저장된 값들
Quantization (양자화)파라미터 하나를 몇 bit로 저장할지 줄이는 것사진을 JPEG로 압축하는 것. 용량이 줄고, 화질이 아주 조금 나빠짐
VRAMGPU가 쓸 수 있는 메모리. 모델은 여기 통째로 올라가야 함작업대 크기. 작으면 큰 모델을 아예 못 올림
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/s288 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/s16 / 24 / 32GB
M4 Pro273 GB/s24 / 48 / 64GB
M4 Max410–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 TiCUDA 벤치마크 장비 — vLLM, TensorRT, TritonCUDA는 맥에서 아예 안 됨. 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크기설명
FP161616 GB압축 안 한 원본
INT888 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비고
FP162.0교과서적 가정. vLLM 기본값
q8_0약 1.068bit + 32개 블록마다 FP16 scale → 8.5bit. Ollama 기본값
q4_0약 0.56공격적. 긴 문맥에서 품질 손해가 보임

Llama 3.1 8B (L=32, H_kv=8, d_head=128):

\[\text{FP16:}\quad 2 \times 32 \times 8 \times 128 \times 2 = 131{,}072\ \text{B} = \mathbf{128\ KB}\] \[\text{q8\_0:}\quad 2 \times 32 \times 8 \times 128 \times 1.0625 = 69{,}632\ \text{B} = \mathbf{68\ KB}\]

즉 실제로 돌아가는 환경에서는 토큰당 68 KB입니다. 이걸 확인 안 하고 FP16으로 계산했으면 메모리 예산을 2배 비관적으로 잡을 뻔했습니다.

이제 이게 왜 무서운지 보여주는 표입니다.

q8_0 기준 (실제로 돌아가는 환경):

문맥 길이1명10명50명
4K 토큰0.27 GB2.7 GB13.6 GB
8K 토큰0.53 GB5.3 GB26.6 GB — 불가능
32K 토큰2.1 GB21.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.jsonnum_key_value_heads를 봐야 합니다. GQA가 잘 된 14B가 GQA 없는 7B보다 동시접속에서 더 쌀 수 있습니다.

실제로 쓸 모델로 계산 — Qwen2.5 7B

제가 고른 모델입니다. L=28, H_kv=4 (GQA가 아주 공격적), d_head=128:

\[2 \times 28 \times 4 \times 128 \times 1.0625 = 30{,}464\ \text{B} = \mathbf{30\ KB}\]

토큰 하나당 30 KB. GQA가 공격적(KV head 4개)인 데다 캐시까지 8-bit이라, Llama 3.1 8B의 절반 이하입니다.

항목계산
GPU 가용 메모리21.3 GB32GB × 2/3
Weights−4.2 GB7.6B × 4.5bit ÷ 8
여유분(overhead)−1.5 GB경험값
KV cache 예산15.6 GB 
저장 가능 토큰 수약 550,00015.6GB ÷ 30KB
4K 문맥 기준 동시 요청약 135건550,000 ÷ 4,096

135라는 숫자가 나왔습니다. 이게 부하 테스트의 목표치가 됩니다. 단, 이 숫자는 캐시를 빈틈없이 채울 수 있을 때 이야기입니다. 순진하게 짠 서버는 이걸 못 합니다 (뒤에서 설명).


Step 3 — 서빙 스택 결정 (결정 2)

여기서 불편한 사실을 마주쳤습니다.

“LLM 서빙 포트폴리오”의 표준 구성은 vLLM + NVIDIA Triton Inference Server입니다. 채용 공고에 그 이름이 적혀 있고, 면접에서 그걸 묻습니다. 그런데 맥미니에서는:

런타임맥 GPU에서 동작?실제 상황
NVIDIA Triton Server아예 안 됨macOS 빌드가 존재하지 않음. “느리다”가 아니라 바이너리가 없음
vLLMCPU만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:7bqwen2.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 ms778 tok/s
3,473 토큰58 ms59,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
138436592866921.1
239437542937221.4
340335663026821.5
437936172826921.3
539436272916921.3
중앙값3943627291 21.3

긴 프롬프트 (3,505 토큰)

회차TTFT (ms)전체 (ms)prefill (ms)생성 토큰tok/s
11761121022174996519.1
21796021235178466519.9
31792721267178096519.5
41818421833180676919.0
51807021645179056919.3
중앙값179602126717846 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 × 파라미터 수 × 토큰 수 회의 부동소수점 연산입니다.

\[2 \times 7.62\times10^9 \times 3505 \approx 5.3\times10^{13} = \mathbf{53\ TFLOPs}\]

이걸 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/s25.6 tok/s21.383%
프롬프트 처리 (prefill)GPU 연산 약 4.3 TFLOPS3.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)토큰당 msprefill tok/s생성 tok/s
1282059948914.3523019.3
5125783,1602,7904.8320715.1
10241,0965,2425,1374.6921320.2
20482,09110,85010,7415.1419519.5
40964,05622,04121,8915.4018517.9
81928,01648,47548,3376.0316617.0

(목표보다 실제 토큰이 많은 이유: 캐시 무효화용 UUID가 약 90토큰을 차지합니다. 실제 측정값 기준으로 표기했습니다.)

측정한 prefill 곡선. 측정점은 2차항을 포함한 모델과 일치하고, 선형 추정은 8K에서 28% 낮게 나온다. 205~8,016 토큰. 선형 추정(초록 점선)은 길어질수록 실제와 벌어진다.

거의 선형인데, 미묘하게 휜다

토큰당 처리 시간이 4.35 → 5.40 ms로 24% 증가했습니다. 길이가 20배 늘어나는 동안입니다.

이건 이론과 정확히 맞습니다. Transformer의 비용은 두 종류입니다.

  • FFN, projection 레이어: 토큰 수에 비례O(T)
  • Attention: 모든 토큰이 앞의 모든 토큰을 봄 — O(T²)

측정값에 맞춰 식을 세우면 (T = 프롬프트 토큰 수):

\[\text{prefill (ms)} \approx 4.50\,T + 1.96 \times 10^{-4}\,T^{2}\]
프롬프트 길이선형 항 기여2차 항 기여
205 토큰99%1%
4,056 토큰85%15%
8,016 토큰74%26%

데이터 5개로 attention이 지배적 비용이 되어가는 순간을 잡은 겁니다. 1K 토큰 아래에서는 선형으로 봐도 되고, 그 위로는 선형 추정을 하면 낙관적인 오답이 나옵니다.

프롬프트선형으로 추정식으로 예측실측
8,01635초48.7초48.3초
16,38474초약 126초미측정
32,768147초약 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

어떤 최적화가 어느 쪽에 듣는지를 표로 정리하면:

최적화PrefillDecode비고
Prefix / KV 캐싱매우 큼17,846ms → 58ms 실측
프롬프트 줄이기직접적재미없지만 항상 통함
4-bit quantization조금토큰당 읽는 바이트가 줄어듦
Continuous batching처리량 개선. 개별 latency는 아님
더 빠른 GPUFLOPS가 올라야대역폭이 올라야어느 스펙이 올랐는지 확인할 것

그리고 아까 그 버그로 돌아가면

벤치마크를 망쳤던 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%하드웨어 한계임을 확인
9prefill 곡선 (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.3GB36GB 이하는 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개

  1. 숫자를 먼저 적어라. 개선 전 측정값 없는 최적화는 이야기지 공학이 아닙니다.
  2. 같은 프롬프트를 반복하는 벤치마크는 캐시를 잰다. prefix를 매번 바꾸세요.
  3. 실측값을 항상 이론 한계와 대조하라. 대역폭 ÷ 모델 크기, 2 × 파라미터 × 토큰. 이 두 개면 충분합니다.
  4. 모델 카드 말고 config.json을 읽어라. num_key_value_heads가 메모리 예산을 결정합니다.
  5. 런타임이 실제로 어떤 플래그로 떴는지 확인하라. pgrep -fl ollama. 저는 FP16 캐시를 가정했다가 실제로는 q8_0이라 메모리를 2배 비관적으로 계산하고 있었습니다.
  6. 평균 말고 P50과 P99를 같이 보고하라. 평균은 장애를 숨깁니다.

측정을 17분간 쉬지 않고 돌린 탓에 뒤쪽 값에는 thermal throttling이 섞여 있을 수 있습니다. 다음 편에서는 냉각 시간과 대조군을 넣어 다시 잽니다.

이 글은 실제 측정 기록을 그대로 옮긴 것입니다. 모든 숫자는 Mac mini M4(32GB) / Ollama / Qwen2.5 7B Q4_K_M 환경에서 2026-08-31에 측정했습니다. 애플 실리콘 지원 상황은 매우 빠르게 바뀌므로, 런타임 관련 내용은 직접 확인하시기 바랍니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.