로컬에서 LLM을 돌리기 시작하면 모델 자체보다 "어느 양자화 레벨을 받아야 하나"에서 더 오래 고민하게 된다. Hugging Face 저장소에 같은 모델이 Q4_K_M, Q5_K_M, Q8_0으로 쭉 올라와 있는데, 파일 크기만 다르지 설명은 비슷비슷하다. 그냥 제일 작은 걸 받자니 품질이 걱정되고, 큰 걸 받자니 VRAM이 모자란다. 그래서 이번에는 같은 모델 하나를 레벨별로 만들어 놓고 직접 재봤다.
결론부터 말하자면, 데스크톱 GPU 한 장으로 실사용하는 경우라면 Q4_K_M을 기본값으로 두고, VRAM이 넉넉하면 Q5_K_M으로 올리면 된다. Q8_0은 품질 검증이나 작은 모델에서만 의미가 있다. 속도와 VRAM은 파일 크기에 거의 비례하고, perplexity(혼란도) 차이는 Q4_K_M과 Q8_0 사이에서도 생각보다 작다. 아래에서 레벨을 직접 만들고 세 가지 지표를 측정하는 방법을 차례로 살펴본다.
1. 양자화 레벨과 GGUF가 무엇인가
GGUF는 llama.cpp가 쓰는 모델 파일 포맷이다. 가중치와 토크나이저, 메타데이터를 한 파일에 담아 CPU와 GPU 양쪽에서 바로 로드할 수 있게 만든 것이다. Ollama도 내부적으로 llama.cpp를 쓰기 때문에 같은 GGUF를 그대로 사용한다.
양자화(quantization)는 원래 16비트 실수(FP16)로 저장된 가중치를 더 적은 비트로 줄여 담는 작업이다. 비트 수를 줄이면 파일이 작아지고 메모리에 적게 올라가며 연산도 빨라지지만, 값이 뭉개지면서 모델 품질이 조금씩 떨어진다. 이 트레이드오프를 어디에서 끊을지가 레벨 선택이다.
이름 규칙을 알아두면 읽기 편하다. Q4, Q5, Q8의 숫자는 가중치당 비트 수의 대략적인 기준이다. 뒤에 붙는 _K는 k-quant 방식을 뜻하는데, 블록 단위로 정밀도를 다르게 배분해 같은 비트에서 품질을 끌어올린 방식이다. 끝의 _S, _M, _L은 Small, Medium, Large로 중요한 텐서에 비트를 얼마나 더 주느냐의 차이다. Q8_0은 k-quant가 아닌 단순 8비트 양자화라서 뒤에 _0이 붙는다.
perplexity는 모델이 다음 토큰을 얼마나 헷갈려 하는지를 나타내는 지표다. 낮을수록 원본에 가깝다. 양자화 품질을 숫자로 비교할 때 가장 흔히 쓰는 값이라 이 글에서도 품질 축으로 삼는다.
2. 전제 조건과 준비
테스트에 쓴 환경은 NVIDIA GPU 한 장이 달린 리눅스다. llama.cpp는 양자화와 벤치마크 도구 때문에 소스에서 빌드하고, Ollama는 실사용 쪽 측정에 쓴다.
$ git clone https://github.com/ggml-org/llama.cpp $ cd llama.cpp $ cmake -B build -DGGML_CUDA=ON $ cmake --build build --config Release -j $ ls build/bin | grep -E 'quantize|bench|perplexity' llama-bench llama-perplexity llama-quantize
CUDA가 없는 환경이면 -DGGML_CUDA=ON을 빼면 CPU 전용으로 빌드된다. 양자화 자체는 GPU가 없어도 되지만, 속도 측정은 GPU에서 해야 의미가 있다.
다음으로 원본 모델을 FP16 GGUF로 한 번 변환해 둔다. 양자화 레벨들은 전부 이 FP16 파일에서 만들어 낸다. 여기서는 7B 급 모델 하나를 예로 든다.
$ python convert_hf_to_gguf.py ./models/my-7b --outfile my-7b-f16.gguf --outtype f16 ... INFO:hf-to-gguf:Model successfully exported to my-7b-f16.gguf
3. 레벨별로 GGUF 만들기
llama-quantize는 입력 GGUF와 출력 GGUF, 그리고 레벨 이름을 인자로 받는다. 같은 FP16 파일에서 세 레벨을 각각 뽑는다.
$ ./build/bin/llama-quantize my-7b-f16.gguf my-7b-Q4_K_M.gguf Q4_K_M $ ./build/bin/llama-quantize my-7b-f16.gguf my-7b-Q5_K_M.gguf Q5_K_M $ ./build/bin/llama-quantize my-7b-f16.gguf my-7b-Q8_0.gguf Q8_0
끝나면 파일 크기부터 확인한다. 이 단계에서 세 레벨의 성격이 이미 드러난다.
$ ls -lh my-7b-Q*.gguf -rw-r--r-- 1 user user 4.1G my-7b-Q4_K_M.gguf -rw-r--r-- 1 user user 4.8G my-7b-Q5_K_M.gguf -rw-r--r-- 1 user user 7.2G my-7b-Q8_0.gguf
정확한 크기는 모델 파라미터 수에 따라 다르다. llama.cpp 공식 문서의 Llama 3.1 8B 기준으로는 Q4_K_M이 가중치당 약 4.89비트, 파일 4.58GiB로 나온다. 핵심은 비율이다. Q4_K_M을 1이라 하면 Q5_K_M은 약 1.2배, Q8_0은 약 1.7배다. 뒤에서 볼 VRAM과 속도도 대체로 이 비율을 따라간다.
여기서 한 번 걸리는 지점이 있다. 끝에 스레드 수 인자를 붙일 수 있는데(llama-quantize in.gguf out.gguf Q4_K_M 8), 생략하면 자동으로 코어 수에 맞춘다. 급하게 스레드 수를 레벨 자리에 적는 실수를 하면 엉뚱한 타입으로 양자화되니 인자 순서를 확인하는 게 좋다.
4. Ollama로 불러오기
만든 GGUF는 Modelfile 한 줄로 Ollama에 등록한다. 레벨별로 이름을 다르게 줘서 세 개를 모두 올린다.
$ cat Modelfile.q4 FROM ./my-7b-Q4_K_M.gguf $ ollama create my-7b:q4 -f Modelfile.q4 $ ollama create my-7b:q5 -f Modelfile.q5 $ ollama create my-7b:q8 -f Modelfile.q8 $ ollama list NAME ID SIZE MODIFIED my-7b:q8 ... 7.2 GB ... my-7b:q5 ... 4.8 GB ... my-7b:q4 ... 4.1 GB ...
주의할 점이 하나 있다. Ollama는 GGUF를 불러올 때 재양자화를 하지 않는다. 공식 문서에도 "Ollama does not quantize GGUF models during import"라고 못 박혀 있다. ollama create의 -q(--quantize) 옵션은 FP16이나 FP32 소스에서 만들 때만 동작한다. 이미 Q4로 양자화한 GGUF에 -q Q5_K_M을 줘도 품질이 올라가지 않는다. 한 번 떨어뜨린 정밀도는 되돌아오지 않는다.
5. VRAM 사용량 측정
모델을 한 번 호출해 메모리에 올린 뒤 ollama ps로 확인하는 게 가장 간단하다. 이 명령은 모델이 GPU에 전부 올라갔는지, 아니면 일부가 CPU로 밀려났는지를 보여준다.
$ ollama run my-7b:q4 "hello" > /dev/null $ ollama ps NAME ID SIZE PROCESSOR UNTIL my-7b:q4 ... 5.8 GB 100% GPU 4 minutes from now
여기서 SIZE가 파일 크기보다 큰 이유는 KV 캐시와 컨텍스트 버퍼가 함께 올라가기 때문이다. 컨텍스트 길이를 키우면 이 값도 늘어난다. nvidia-smi로 교차 확인하면 실제 점유량이 보인다.
$ nvidia-smi --query-gpu=memory.used,memory.total --format=csv memory.used [MiB], memory.total [MiB] 6012 MiB, 12288 MiB
가장 중요한 건 PROCESSOR 칸이다. 100% GPU가 아니라 48%/52% CPU/GPU 같은 값이 보이면 모델 일부가 시스템 메모리로 넘어간 것이고, 이때 속도가 급격히 떨어진다. 12GB 카드라면 7B 모델은 세 레벨 모두 GPU에 올라가지만, Q8_0은 컨텍스트를 길게 잡으면 한계에 가까워진다. 반대로 8GB 카드에서는 Q8_0이 CPU로 밀리기 시작한다. 레벨 선택이 사실상 "GPU에 통째로 올라가느냐"로 갈리는 지점이 여기다.
6. 토큰 생성 속도 측정
속도는 두 가지로 잰다. 실사용 감은 Ollama의 --verbose로 보고, 정밀 비교는 llama.cpp의 llama-bench로 본다.
$ ollama run my-7b:q4 --verbose "양자화를 한 문단으로 설명해줘" ... total duration: 2.1s eval count: 128 token(s) eval rate: 74.3 tokens/s
eval rate가 생성 속도다. 같은 프롬프트로 q5, q8을 돌려 비교하면 레벨이 올라갈수록 수치가 내려간다. 다만 --verbose는 호출마다 값이 흔들리니, 재현 가능한 비교에는 llama-bench가 낫다.
$ ./build/bin/llama-bench -m my-7b-Q4_K_M.gguf -m my-7b-Q5_K_M.gguf -m my-7b-Q8_0.gguf -p 512 -n 128 | model | size | test | t/s | | ------------------ | ---------: | ------ | ------------: | | llama 7B Q4_K_M | 4.1 GiB| tg128 | 75.2 ± 0.6 | | llama 7B Q5_K_M | 4.8 GiB| tg128 | 63.8 ± 0.4 | | llama 7B Q8_0 | 7.2 GiB| tg128 | 44.1 ± 0.3 |
tg128은 128토큰 생성 속도, pp512는 512토큰 프롬프트 처리 속도다. 절대 수치는 GPU 모델과 드라이버에 따라 크게 달라지므로 위 표의 숫자 자체보다 비율을 보면 된다. 환경에 따라 다르지만 대략 Q4_K_M을 기준으로 Q5_K_M은 10~20% 느리고, Q8_0은 30~40% 느린 수준으로 벌어진다. 생성 속도가 메모리 대역폭에 묶이는 작업이라 파일이 큰 레벨이 그만큼 느려진다.
7. 품질(perplexity) 측정
품질은 llama-perplexity로 같은 텍스트에 대해 각 레벨의 perplexity를 재서 비교한다. 흔히 WikiText-2 테스트 셋을 쓴다.
$ ./build/bin/llama-perplexity -m my-7b-Q4_K_M.gguf -f wiki.test.raw -ngl 99 ... Final estimate: PPL = 5.9624 +/- 0.03348
세 레벨을 같은 파일로 돌려 Final estimate만 뽑아 비교한다. 핵심은 레벨 사이의 차이가 작다는 점이다. llama.cpp가 공개한 측정에서도 7B 급 모델 기준으로 FP16 대비 perplexity 증가분은 Q8_0이 +0.001 안쪽, Q5_K_M이 +0.01 안팎, Q4_K_M이 +0.05 안팎 수준이다. 즉 Q4_K_M에서 Q8_0으로 올려도 혼란도 개선은 소수점 둘째 자리에서 일어난다. 파일은 1.7배로 커지는데 품질 이득은 그 정도다.
물론 perplexity가 모든 걸 말해주지는 않는다. 코드 생성이나 긴 추론처럼 작은 오차가 누적되는 작업에서는 체감 품질 차이가 숫자보다 크게 느껴질 수 있다. 그래서 perplexity로 후보를 좁힌 뒤, 실제 쓰려는 프롬프트 몇 개로 눈으로 확인하는 단계를 같이 둔다.
8. 그래서 어느 레벨을 고르나
측정 결과를 정리하면 선택 기준은 단순해진다.
- Q4_K_M: 기본값. 품질 손실이 작으면서 VRAM과 속도 이점이 가장 크다. GPU 한 장으로 실사용한다면 여기서 시작한다.
- Q5_K_M: VRAM이 남을 때. Q4_K_M보다 perplexity가 조금 낮고, 속도 손해도 10~20% 선이라 품질이 민감한 작업에서 올려볼 만하다.
- Q8_0: 작은 모델(3B 이하)이거나 양자화 품질을 검증할 기준선이 필요할 때. 7B 이상에서는 파일 크기와 속도 손해에 비해 품질 이득이 작다.
VRAM이 모자라 Q4_K_M도 CPU로 밀린다면, 레벨을 더 낮추기(Q3, Q2)보다 파라미터가 작은 모델의 Q4_K_M을 쓰는 편이 품질이 유지된다. 같은 용량이면 큰 모델을 과하게 눌러 담기보다 작은 모델을 적당히 양자화하는 쪽이 대체로 낫다는 게 측정을 반복하며 남은 감각이다.
정리하면, 레벨 비교는 VRAM(ollama ps), 속도(llama-bench), 품질(llama-perplexity) 세 명령이면 끝난다. 같은 FP16 GGUF에서 레벨만 바꿔 뽑아 세 숫자를 나란히 놓으면, 내 GPU에서 어디서 끊어야 할지가 바로 보인다.