2026.07.31 · vlm · quantization
pocket-vlm — 4B VLM을 2.6GB로 줄여 노트북 CPU에서 돌리기
양자화가 판독 정확도를 어디서 무너뜨리는지 같은 검증셋 319문항으로 측정했습니다 — 8→4비트는 사실상 무손실, 절벽은 딱 한 곳
fundus-vlm에서 4B 모델을 QLoRA로 파인튜닝해 전용 CNN급 성능을 얻었습니다. 그런데 그 모델은 RTX 4090 위에서만 돌아갑니다. 가중치 8GB, 16비트. 정작 쓰일 자리 — 검진 노트북, 현장 스크리닝 PC — 에는 GPU가 없습니다.
그래서 다음 질문은 자연스럽게 정해졌습니다. GPU 없는 CPU에 넣을 수 있나? 넣으면 정확도를 얼마나 내줘야 하나?
결론부터 적으면, 이 모델은 2.65GB로 압축해 GPU 없이 CPU에서 정확도 손실 없이 돌아갑니다. 다만 압축으로 해결되는 건 디스크 용량뿐이고, 메모리는 거의 그대로였습니다 — 이 글에서 가장 말하고 싶은 부분이 그 반직관적인 대목입니다.
양자화, 30초 설명
모델은 숫자(가중치) 수십억 개의 묶음입니다. 학습할 때는 숫자 하나를 16비트로 적어 두는데, 이걸 4비트로 거칠게 다시 적는 것이 양자화입니다. 파일은 1/4로 줄고, 대신 반올림 오차가 생깁니다.
문제는 "오차가 생긴다"가 아니라 "그 오차가 어디서 답을 바꾸는가"입니다. 그걸 재려면 압축 말고는 아무것도 바뀌지 않아야 합니다.
규칙 — 바꾸는 건 비트폭 하나
fundus-vlm의 병합 모델·검증셋 319문항·채점 스크립트를 손대지 않고 그대로 가져왔습니다. 추론 엔진만 transformers(GPU)에서 llama.cpp(CPU)로 바꾸고, 프롬프트·greedy 디코딩·최대 생성 길이는 앵커와 동일하게 맞췄습니다. 지표 구현을 조금이라도 고치면 비교 자체가 의미를 잃기 때문입니다.
측정하는 태스크는 세 개인데, 일부러 기계학습 성격이 다른 세 종류로 골랐습니다. 압축이 모든 능력을 똑같이 깎지 않을 거라고 봤기 때문입니다.
| 태스크 | 성격 | 지표 | 문항 |
|---|---|---|---|
| 5단계 등급 매기기 | 순서형 분류 | QWK | 163 |
| 비율값 추정 | 연속값 회귀 | Pearson r | 60 |
| 라벨 8종 판정 | 다중 라벨 분류 | macro-F1 | 96 |
먼저, 엔진을 갈아 끼우는 것만으로 손실이 없는지
양자화를 재기 전에 확인할 게 있습니다. GPU에서 CPU로 옮기는 것 자체가 점수를 깎으면, 이후에 나오는 모든 하락이 무엇 때문인지 말할 수 없게 됩니다.
GPU bf16 앵커 → CPU GGUF F16으로 옮긴 결과는 QWK 0.874 → 0.880, r 0.737 → 0.768, macro-F1 0.558 → 0.571. 전부 노이즈 범위 안에서 근소하게 올랐습니다. 이사 비용이 0이라는 뜻이고, 덕분에 이후 열화는 전부 양자화 탓으로 귀속할 수 있게 됐습니다.
물론 F16 자체는 실용적이지 않습니다. 파일 7.2GB, 최대 메모리 약 28.5GB, 문항당 17초. 노트북에 들어갈 크기가 아닙니다.
절벽은 딱 한 곳뿐
여섯 단계로 양자화해 매번 319문항을 다시 풀게 했습니다. 곡선이 완만한 내리막일 거라고 예상했는데, 평지와 절벽이 나왔습니다.
| 빌드 | 가중치 | 등급 QWK | 연속값 상관 | 라벨 F1 | 파싱 실패 |
|---|---|---|---|---|---|
| F16 (기준) | 7.2 GB | 0.880 | 0.768 | 0.571 | 0 |
| Q8_0 | 3.9 | 0.873 | 0.766 | 0.571 | 0 |
| Q6_K | 3.0 | 0.888 | 0.799 | 0.564 | 0 |
| Q5_K_M | 2.6 | 0.891 | 0.791 | 0.567 | 0 |
| Q4_K_M | 2.3 | 0.896 | 0.830 | 0.568 | 0 |
| Q3_K_M | 2.0 | 0.888 | 0.714 | 0.536 | 0 |
| Q2_K | 1.6 | 0.440 | 0.430 | 0.307 | 15 |
가중치는 텍스트 부분의 크기이고 비전 프로젝터 0.79GB는 별도입니다. 파싱 실패는 라벨 8종 태스크 96문항 중 답변 형식이 깨져 채점 자체가 불가능했던 건수입니다.
- 8비트에서 4비트까지는 사실상 무손실입니다. 오히려 Q4_K_M이 전 구간 최고점을 찍었는데, 이건 약한 정규화 효과 + 평가 노이즈로 보는 게 안전합니다(연속값 태스크는 60문항뿐입니다). 과대해석하지 않고 "F16과 구분되지 않는다"까지만 주장합니다.
- Q3_K_M은 경고 구간입니다. 등급 매기기는 멀쩡한데(0.888) 연속값과 다중 라벨이 먼저 금이 갑니다.
- Q2_K에서 한 번에 무너집니다. 세 지표가 전부, 그리고 채점 자체가 불가능한 답변이 15개 나옵니다.
열화가 점진적이지 않다는 건 배포하는 사람에게 좋은 소식입니다. "몇 비트까지 안전한가"를 눈금 하나 단위로 고민할 필요 없이 절벽 위쪽이면 된다가 되기 때문입니다.
무엇이 먼저 깨지나 — 태스크 성격이 갈랐다
Q3_K_M을 보면 순서가 보입니다. 먼저 흔들리는 건 연속값 추정(0.768 → 0.714)이고, 다중 라벨이 근소한 차로 뒤따르며(0.571 → 0.536), 순서형 등급은 마지막까지 버팁니다(0.888).
이유를 추측하면, 등급 매기기는 경계만 대충 맞으면 인접 등급으로 떨어져도 QWK가 크게 깎이지 않습니다. 반면 비율값 추정은 소수점 두 자리의 미세한 차이가 곧 점수라서, 반올림 오차가 그대로 상관계수를 갉아먹습니다. 거친 판단은 거친 가중치로도 되지만, 미세한 수치는 안 된다는 이야기입니다.
이 순서가 절대적이지는 않았습니다 — 뒤에 나오는 극저비트 구간에서는 오히려 등급 태스크가 먼저 무너집니다.
Q2_K에서 벌어지는 일 — 정확도보다 형식이 먼저 죽는다
절벽 지점의 출력을 뜯어보니, 두 가지가 동시에 일어나고 있었습니다.
첫째, 출력 규율이 무너집니다. 파인튜닝으로 "정해진 라벨 8종 안에서만 답하기"를 가르쳐 놨는데, Q2_K는 라벨 밖의 단어를 만들어 내거나(12건) 같은 구절을 반복하다 문장을 끝내지 못합니다(3건).
"Hazy … media opacity (Hazy … media opacity"— 문구가 반복되다 답변이 완성되기 전에 잘립니다.
둘째, 정량 신호가 정답과 어긋납니다. 처음에는 "예측이 평균값 하나로 수축한 것"으로 봤는데, 데이터를 보면 그렇지 않았습니다. 예측 분산은 Q3_K_M이 더 작은데도 상관계수는 0.714로 멀쩡합니다. Q2_K만 변동은 남아 있는데 그 변동이 정답과 무관해집니다.
정리하면, 극단적 양자화는 모델을 조금씩 부정확하게 만드는 게 아니라 파인튜닝으로 심어 놓은 것부터 지웁니다. 사전학습으로 얻은 능력보다 사후에 가르친 습관이 먼저 날아갑니다.
반직관 — 양자화가 줄여 주는 건 파일이지 메모리가 아니다
여기가 이 프로젝트에서 제일 실용적인 발견입니다. F16과 권장 빌드(Q4_K_M)를 같은 조건에서 비교했습니다.
파일은 3분의 1로 줄었는데 메모리는 3분의 2가 남고, 속도는 사실상 그대로입니다. 이유는 프로파일을 보면 분명합니다.
- 메모리를 지배하는 건 가중치가 아닙니다. KV 캐시(컨텍스트 4096), 비전 인코딩 연산 버퍼, 비전 프로젝터가 상주 메모리의 대부분입니다. 가중치만 압축해서는 온디바이스 메모리 문제가 풀리지 않습니다.
- 지연을 지배하는 것도 가중치가 아닙니다. CPU 전용 멀티모달 추론에서는 이미지 인코딩과 고정된 생성 길이가 시간을 먹습니다. Q2_K조차 F16보다 크게 빠르지 않습니다.
혹시 비전 쪽을 줄이면 다를까 싶어 비전 프로젝터만 F16 → Q8로 바꿔 봤습니다(LLM은 Q4_K_M 고정). 결과는 메모리 0.23GB 절약, 연속값 정확도 0.037 하락 — 교환비가 나빠서 F16을 유지하기로 했습니다. 온디바이스 메모리를 줄이는 레버는 양자화가 아니라 컨텍스트 길이·이미지 해상도·KV 캐시 쪽에 있습니다. 데모에서 컨텍스트를 2048로 내린 것도 그래서입니다.
imatrix — 절벽을 구제하고, 스윗스팟을 한 칸 더 내리기
여기서 한 가지를 더 시도했습니다. 지금까지의 양자화는 모든 가중치를 똑같이 거칠게 다뤘습니다. 그런데 실제 추론에서 중요한 가중치와 그렇지 않은 가중치는 다릅니다. 보정용 텍스트를 흘려보내면서 어디가 중요한지 실측한 표(importance matrix)를 만들고, 중요한 곳에 비트를 더 배분하면 어떨까요.
보정 텍스트는 학습 데이터 쪽 여집합에서만 뽑아 검증셋 누수가 없게 했습니다.
| 빌드 | 가중치 | 등급 QWK | 연속값 상관 | 라벨 F1 | 파싱 실패 |
|---|---|---|---|---|---|
| Q2_K | 1.62 GB | 0.440 | 0.430 | 0.307 | 15 |
| Q2_K + imatrix | 1.62 | 0.813 | 0.656 | 0.436 | 1 |
| Q3_K_M | 1.96 | 0.888 | 0.714 | 0.536 | 0 |
| Q3_K_M + imatrix | 1.96 | 0.892 | 0.688 | 0.535 | 0 |
| IQ3_M + imatrix | 1.86 | 0.902 | 0.788 | 0.549 | 0 |
| IQ2_M + imatrix | 1.44 | 0.677 | 0.702 | 0.519 | 1 |
세 가지가 눈에 띕니다.
절벽은 상당 부분 비트 낭비 때문이었습니다. Q2_K의 QWK가 0.440에서 0.813으로 돌아오고, 형식이 깨져 채점 못 한 문항이 15개에서 1개로 줄었습니다. 다만 다중 라벨은 여전히 하한 아래라 구제는 되고 완치는 아닙니다.
보정의 이득은 저비트에만 있습니다. Q3_K_M에 같은 보정을 적용해도 변화가 없습니다(0.888 → 0.892, 연속값은 오히려 소폭 하락). 이미 손실이 작은 구간에는 아낄 비트가 없다는 뜻으로, 이론과 맞는 결과입니다.
스윗스팟이 한 칸 내려갔습니다. imatrix 전용 방식인 IQ3_M은 1.86GB에서 세 지표 모두 하한을 통과합니다. 2.33GB의 Q4_K_M보다 작은데 등급 지표는 오히려 높습니다. 같은 비트 예산이면 IQ 계열이 K-quant보다 낫다는 게 이 데이터의 결론입니다.
가장 작은 IQ2_M(1.44GB)은 흥미로운 반전을 보여줍니다. 연속값·다중 라벨은 준수한데 순서형 등급이 먼저 무너집니다(0.677). 앞에서 본 붕괴 순서와 정반대입니다. 극저비트에서는 태스크별 붕괴 순서가 재편되는 것으로 보이지만, 관측 하나뿐이라 여기까지만 적어 둡니다.
그래서 무엇을 배포하나
"쓸 만하다"의 기준을 먼저 못 박아 두고 판정했습니다 — 등급 QWK ≥ 0.80(전용 CNN 상한선 근방), 연속값 r ≥ 0.60(학습된 값과 붕괴 사이의 안전 마진), 다중 라벨 macro-F1은 기준선 −0.05 이내.
| 목표 | 빌드 | 총 용량 | 판정 |
|---|---|---|---|
| 안전 최우선 | Q5_K_M / Q4_K_M | 3.4 / 3.1 GB | F16급 |
| 용량 최소(무손실) | IQ3_M + imatrix | 2.65 GB | 하한 전부 통과 |
| 극한 압축 | IQ2_M + imatrix | 2.23 GB | 등급 태스크 신뢰 불가 |
| 금지 | Q2_K (보정 없음) | — | 전 지표 붕괴 |
총 용량은 텍스트 가중치 + 비전 프로젝터 0.79GB입니다. 그래서 2.6GB 남짓이 이 4B 모델을 GPU 없이 돌리기 위한 현재의 하한선입니다.
마지막으로 권장 빌드로 CPU 데모를 만들었습니다. Gradio에 사진 한 장을 올리면 벤치마크와 같은 프롬프트·같은 파서로 추론해 구조화된 판독 결과를 돌려줍니다. GPU는 한 장도 쓰지 않고, 코어 수에 따라 1~3분 걸립니다. 데모와 벤치 숫자가 같은 경로를 지나므로, 화면에 보이는 결과는 위 표의 그 숫자입니다.
배운 것
- 압축의 대가는 값을 매길 수 있습니다. "4비트면 괜찮다더라"가 아니라 태스크별 하한을 먼저 정해 놓고 재면, 배포 결정이 감이 아니라 표가 됩니다.
- 먼저 죽는 건 정확도가 아니라 형식입니다. 파인튜닝이 심어 놓은 "정해진 형식으로 답하기"가 정확도보다 먼저 사라집니다. 그래서 파싱 실패 건수는 사실상 조기 경보 지표였습니다.
- 양자화는 배포 용량 문제를 풀고, 메모리 문제는 풀지 않습니다. 상주 메모리는 KV 캐시와 비전 버퍼가 지배합니다 — 다른 레버를 봐야 합니다.
- 기준선의 이사를 먼저 검증해야 합니다. CPU F16이 GPU 앵커를 재현한다는 확인이 없었다면, 이후 모든 하락의 원인을 양자화로 돌릴 수 없었습니다.
남은 한계도 그대로 적어 둡니다. 연속값 태스크는 60문항이라 소수점 둘째 자리를 진지하게 받아들이면 안 됩니다. 보정 텍스트는 텍스트 전용이어서 이미지 조건부 활성을 반영하지 못하고, 비전 프로젝터는 보정 대상이 아니었습니다. 지연은 코어 수에 따라 달라지므로 절대값보다 레벨 간 비교로 읽는 게 맞습니다.
- 저장소: anears/pocket-vlm — phase 0~5 리포트 · 양자화·채점 스크립트 · CPU 데모
- 선행 프로젝트: fundus-vlm · ko-chart-vlm
스택: MedGemma-4B + QLoRA(반입) · llama.cpp / GGUF · llama-quantize · imatrix · Gradio · uv