- Published on
로컬 LLM 속도 2배 - KV 캐시·양자화 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
로컬에서 LLM을 돌리다 보면 “GPU는 80% 놀고 있는데 토큰 생성이 왜 이렇게 느리지?” 같은 상황을 자주 만납니다. 대부분의 경우 병목은 모델 자체가 아니라 KV 캐시 운용 방식(프리필과 디코드 단계의 특성 차이)과 양자화 포맷 선택/적용 방법에서 발생합니다.
이 글은 다음 목표를 가집니다.
tokens/s를 측정 가능한 방식으로 쪼개서(프리필 vs 디코드) 병목을 찾는다- KV 캐시를 “덜 쓰고, 더 잘 재사용”하도록 튜닝한다
- 양자화로 메모리 대역폭 병목을 줄이고 디코드 속도를 끌어올린다
- llama.cpp, vLLM에서 바로 적용할 수 있는 커맨드/설정 예시를 제공한다
운영 환경에서의 리소스 병목을 잡는 관점은 DB/런타임 튜닝과 유사합니다. 예를 들어 메모리 압박으로 파드가 대기 상태가 되는 상황처럼, LLM도 KV 캐시가 VRAM을 잠식하면 곧바로 속도가 무너집니다. 관련해서는 EKS Pod가 Pending(Insufficient memory)일 때 점검법 같은 “병목을 수치로 쪼개는” 접근이 그대로 통합니다.
1) 먼저: 속도는 프리필과 디코드로 나눠 측정하라
LLM 추론은 크게 두 단계입니다.
- 프리필(prefill): 입력 프롬프트 전체를 한 번에 통과시키며 KV 캐시를 채우는 단계
- 디코드(decode): 이후 토큰을 1개씩 생성하며 KV 캐시를 읽고 갱신하는 단계
체감 속도는 대부분 디코드 tokens/s가 결정합니다. 반면 “첫 응답까지의 시간(TTFT)”은 프리필이 지배합니다.
최소 측정 체크리스트
- TTFT: 첫 토큰이 나오기까지 걸린 시간
- Prefill throughput: 프롬프트 토큰 처리 속도
- Decode throughput: 생성 토큰 속도
- VRAM 사용량: KV 캐시 포함 총량
- GPU util: 연산 유휴인지, 메모리 대역폭 병목인지
llama.cpp에서 간단 측정
llama.cpp는 실행 로그에 속도 지표가 나옵니다.
./llama-cli \
-m ./models/llama-3-8b-instruct-q4_k_m.gguf \
-p "요약: KV 캐시와 양자화의 차이를 설명해줘" \
-n 256 \
-ngl 999 \
--temp 0.2
로그에서 prompt eval time(프리필), eval time(디코드)의 tokens/s를 분리해서 보세요.
vLLM에서 간단 측정
vLLM은 서버 형태로 많이 씁니다.
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Meta-Llama-3-8B-Instruct \
--dtype auto \
--max-model-len 8192 \
--gpu-memory-utilization 0.90
요청은 OpenAI 호환 API로 보내고, 서버 로그/메트릭에서 TTFT와 generation throughput을 확인합니다.
2) KV 캐시가 왜 속도를 좌우하는가
KV 캐시는 각 레이어의 어텐션에서 필요한 Key/Value를 저장해, 매 토큰마다 과거 토큰을 다시 계산하지 않게 해줍니다. 문제는 KV 캐시가 길이에 선형으로 커지고, 디코드 단계에서 메모리 읽기/쓰기 대역폭을 크게 잡아먹는다는 점입니다.
즉, 디코드는 종종 “연산이 느려서”가 아니라 “메모리를 너무 많이 만져서” 느려집니다.
KV 캐시 메모리 대략 계산(감 잡기)
정확한 수치는 구현/포맷에 따라 다르지만, 직관은 이렇습니다.
- 시퀀스 길이
L이 커질수록 KV 캐시가 커짐 - 레이어 수, 헤드 수,
head_dim이 커질수록 커짐 - KV 캐시 dtype이
fp16이면 크고,fp8/int8등으로 내리면 작아짐
따라서 “컨텍스트를 8k로 늘렸더니 갑자기 디코드가 반토막”은 자연스러운 현상입니다.
3) KV 캐시 튜닝 1: 컨텍스트 길이부터 현실적으로
가장 효과가 큰 튜닝은 의외로 단순합니다.
- 정말
8192가 필요한가? - 대화형이면 최근 N턴만 남기고 요약을 끼워넣을 수 없는가?
특히 로컬 서비스에서 기본 max context를 과도하게 크게 잡아두면, KV 캐시 때문에 VRAM이 잠식되고 배치/동시성이 무너집니다.
실전 권장
- 개인 데스크톱:
4096부터 시작해서 필요 시 증가 - RAG: top-k 문서 길이를 줄이고 chunk를 조정해 컨텍스트 폭발 방지
4) KV 캐시 튜닝 2: 프리필과 디코드의 최적화 포인트는 다르다
- 프리필은 “큰 행렬 연산을 한 번에” 처리하므로 GPU 연산 효율이 잘 나옴
- 디코드는 “토큰 1개씩”이라 커널 런치 오버헤드, 메모리 접근 패턴이 불리함
따라서 튜닝도 분리해서 접근해야 합니다.
프리필이 느리면
- 프롬프트 자체를 줄이기(시스템 프롬프트 과다 여부)
- 배치가 가능하면 프리필을 묶기(vLLM의 continuous batching)
디코드가 느리면
- KV 캐시를 줄이거나(길이/동시성)
- KV 캐시 dtype을 낮추거나
- 모델 가중치 양자화로 메모리 대역폭 병목을 완화
5) 양자화: “정확도 손실”이 아니라 “대역폭 병목 해결”로 보라
로컬 LLM 속도 튜닝에서 양자화는 단순히 VRAM에 모델을 억지로 올리는 용도가 아닙니다. 디코드에서의 병목이 메모리 대역폭이라면, 가중치가 더 작아지는 것만으로도 tokens/s가 유의미하게 증가합니다.
대표 선택지(현실적인 감각)
FP16/BF16: 품질 좋음, VRAM 큼, 디코드 대역폭 부담 큼INT8: 품질 손실 적은 편, 속도/메모리 균형4bit(예: GPTQ/AWQ, GGUF의Q4_K_M등): VRAM 크게 절약, 대체로 디코드 빨라짐, 모델/프롬프트에 따라 품질 손실 체감
중요한 건 “내 워크로드에서 품질 손실이 실제로 문제인지”를 짧은 평가로 확인하는 것입니다.
6) llama.cpp에서 2배 체감이 나오는 조합
llama.cpp 계열에서는 다음 조합이 체감 개선을 자주 만듭니다.
- 적절한 4bit 양자화 GGUF 사용
- GPU 오프로딩(
-ngl)을 충분히 주기 - 컨텍스트를 과도하게 키우지 않기
예시: 8B 모델을 4bit로
./llama-cli \
-m ./models/llama-3-8b-instruct-q4_k_m.gguf \
-p "다음 로그의 원인을 분석해줘: ..." \
-n 256 \
-c 4096 \
-ngl 999 \
--temp 0.2 \
--top-p 0.9
포인트
-c 4096: 컨텍스트를 필요한 만큼만-ngl 999: 가능한 레이어를 GPU로(환경에 따라 적정값 조정)
만약 VRAM이 부족하면, 레이어 오프로딩을 낮추거나 컨텍스트를 줄이는 것이 디코드 안정성에 더 직접적입니다.
7) vLLM에서 속도를 끌어올리는 핵심: PagedAttention과 KV 캐시 예산
vLLM은 KV 캐시를 효율적으로 관리하는 것으로 유명합니다. 하지만 설정을 잘못 주면 오히려 성능이 흔들립니다.
체크해야 할 설정
--max-model-len: 너무 크게 잡으면 KV 캐시 예산이 과도하게 필요--gpu-memory-utilization: 너무 높이면 OOM/스와핑/단편화로 지연 증가- 동시성: 요청이 많을수록 KV 캐시 압박이 커짐
예시: 보수적인 안정 설정
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Meta-Llama-3-8B-Instruct \
--dtype auto \
--max-model-len 4096 \
--gpu-memory-utilization 0.85
운영에서는 “최대 길이”를 스펙 상한으로 잡기보다, 실제 제품 요구사항 기반으로 제한하는 편이 평균 지연과 tail latency에 유리합니다.
8) KV 캐시/양자화 튜닝 시 흔한 함정
함정 1: 컨텍스트만 늘려놓고 속도 저하를 모델 탓으로 돌림
컨텍스트 길이가 2배면 KV 캐시도 큰 폭으로 증가합니다. 디코드가 느려졌다면 먼저 -c 또는 --max-model-len부터 의심하세요.
함정 2: VRAM을 99%까지 꽉 채우면 오히려 느려짐
메모리는 “남겨두는 게 성능”인 경우가 많습니다. 단편화, 런타임 버퍼, 커널 워크스페이스 때문에 tail latency가 튑니다.
함정 3: 4bit면 무조건 빠를 거라는 기대
상황에 따라 4bit 디코딩/디퀀타이즈 오버헤드가 생기거나, 특정 백엔드에서 최적화가 덜 되어 INT8이 더 나을 수 있습니다. 반드시 같은 프롬프트로 decode tokens/s를 비교하세요.
함정 4: “빠른데 답이 이상해졌다”를 방치
양자화는 품질 저하를 동반할 수 있습니다. 특히 코드 생성, 수학, 긴 추론에서 체감될 수 있으니 간단한 회귀 테스트를 만들어 두는 게 좋습니다. 프롬프트 보안/품질 관점에서는 Chain-of-Thought 누출 막는 프롬프트 방어 7가지처럼 “출력 형식과 정책”을 먼저 고정해두면 튜닝 전후 비교가 쉬워집니다.
9) “2배”를 만드는 실전 레시피(우선순위)
아래 순서대로 적용하면 시행착오가 줄어듭니다.
1순위: 측정 분리
- 프리필
tokens/s - 디코드
tokens/s - TTFT
2순위: 컨텍스트/동시성 예산 설정
- 기본 컨텍스트를 줄이고(예:
8192에서4096) - 동시 요청 수를 KV 캐시 예산에 맞춰 제한
3순위: 양자화 적용
- VRAM이 빠듯하면 4bit로 모델을 내리고
- 품질이 민감하면
INT8또는 더 좋은 4bit 포맷을 탐색
4순위: 백엔드 최적화 활용
- llama.cpp: GPU 오프로딩과 적정
-c - vLLM:
--gpu-memory-utilization을 보수적으로,--max-model-len을 현실적으로
10) 간단 벤치마크 스크립트 예시
여러 설정을 바꿔가며 비교하려면 “같은 프롬프트, 같은 생성 길이”로 자동화하는 게 핵심입니다.
#!/usr/bin/env bash
set -euo pipefail
MODEL="./models/llama-3-8b-instruct-q4_k_m.gguf"
PROMPT_FILE="./prompt.txt"
for C in 2048 4096 8192; do
echo "=== context: ${C} ==="
./llama-cli \
-m "${MODEL}" \
-p "$(cat "${PROMPT_FILE}")" \
-n 256 \
-c "${C}" \
-ngl 999 \
--temp 0.2 \
2>&1 | tee "./bench_c${C}.log"
echo
done
set -euo pipefail을 쓸 때의 자잘한 함정은 운영 스크립트에서 자주 터지니, 필요하면 bash set -euo pipefail 함정 7가지와 안전패턴도 함께 참고하세요.
마무리: “KV 캐시 예산”을 잡으면 속도가 보인다
로컬 LLM 속도 튜닝은 결국 KV 캐시를 포함한 메모리 예산 관리입니다.
- 컨텍스트를 현실적으로 제한하고
- 프리필/디코드를 분리 측정하고
- 양자화로 대역폭 병목을 줄이면
동일 하드웨어에서도 디코드 tokens/s가 체감 2배 가까이 개선되는 경우가 흔합니다. 다음 단계로는 “내 워크로드에서 허용 가능한 품질 저하 한계”를 정하고, INT8과 4bit 포맷을 교차 벤치마크하면서 최적점을 찾는 것을 권합니다.