Published on

로컬 LLM 속도 2배 - KV 캐시·양자화 튜닝

Authors

로컬에서 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 계열에서는 다음 조합이 체감 개선을 자주 만듭니다.

  1. 적절한 4bit 양자화 GGUF 사용
  2. GPU 오프로딩(-ngl)을 충분히 주기
  3. 컨텍스트를 과도하게 키우지 않기

예시: 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 포맷을 교차 벤치마크하면서 최적점을 찾는 것을 권합니다.