← 목록으로 돌아가기

3개월 만에 발견한 Q4_K_M의 숨은 배신, 그리고 Neovim이 나를 구원한 이야기

## 프로덕션의 조용한 살인자

솔직히 말해서, 그 버그를 처음 마주했을 때 나는 양자화의 Q자도 의심하지 않았다. 누가 그렇겠나? 모델 서빙 파이프라인을 vLLM 0.3.1에서 0.4.2로 올리고, `Mistral-7B-Instruct-v0.2`의 Q4_K_M 체크포인트를 교체한 지 3개월째였다. 벤치마크는 완벽했다. MMLU 점수도, 처리량도, 지연 시간도. 프로덕션 로그는 평화로웠다.

문제는 "부산 해운대 복어 맛집 추천" 같은 프롬프트에서 시작됐다. 사용자가 "해운대에서 복어로 유명한 곳 알려줘"라고 입력하면, 모델이 갑자기 "물론이죠. 해운대에는 훌륭한 일식당이 많습니다만, 복어는 독성이 있어 전문 자격증을 가진..." 이라고만 내뱉고 멈추는 거다. 추천 리스트는 실종됐다. instruction following이 미묘하게 깨졌다. 마치 똑똑하지만 약간 반항적인 인턴 같았다.

## GGUF와 숨겨진 차원의 희생

이게 무슨 상관이냐고? 들어봐. Q4_K_M 양자화는 `llama.cpp`의 `ggml` 라이브러리에서 구현된 특수한 블록 양자화 방식이다. K-quant 계열은 2023년 8월 `ggerganov`가 PR #2398로 도입했는데, 여기서 중요한 건 `hidden_dim`이 4096이 아닌 14336인 Mistral 같은 이상한 아키텍처에서 발생하는 패딩 문제다.

K-quant는 중요도에 따라 가중치 블록을 다르게 자른다. `_M` 변종은 중간 크기로, `superblock` 크기를 256으로 유지하면서 특정 히든 레이어의 마지막 32차원을 4비트 대신 6비트로 저장한다. 이론상 완벽하다. 하지만 실제로는 `k_quant`의 `get_scale_min_k4` 함수가 `n_el`이 256의 배수가 아닐 때 마지막 블록을 살짝 잘라버린다. 정확히 말하면 `quantize_row_q4_K_M` 내부의 `make_q4_quants` 호출에서, residual 차원 8개가 조용히 0으로 수렴한다.

이게 instruction tuning에 어떤 영향을 줄까? Mistral의 instruction 포맷인 `[INST]` 토큰 임베딩은 놀랍게도 이 잘린 차원들에 과도하게 의존한다. 2023년 11월 HuggingFace 포럼에서 `@philpax`가 비슷한 현상을 보고했지만, 그때는 "attention drift"로 치부됐다. 나는 이걸 "silent instruction collapse"라고 부른다.

## Neovim inlay hint의 우연한 발견

내가 이걸 어떻게 찾았냐면, Neovim의 `nvim-lspconfig`에서 `sumneko_lua`의 inlay hint 때문에 거의 미칠 뻔했다. `llama.cpp` 소스를 분석 중이었는데, `vim.lsp.buf_request`가 반환하는 타입 힌트가 계속해서 `quantize_row_q4_K_M` 함수 시그니처에 `const block_q4_K * restrict x` 파라미터의 메모리 정렬을 `sizeof(float) * 16`으로 잘못 표시하는 거다.

처음에는 LSP 버그인 줄 알았다. clangd 16.0.2의 inlay hint 렌더링이 `alignas` 속성을 무시하는 건지, 아니면 내 `init.lua`에서 `vim.lsp.inlay_hint.enable()`을 두 번 호출한 탓인지. 하지만 아니었다. 실제로 `ggml`의 `block_q4_K` 구조체 패딩이 `__AVX2__` 매크로가 정의된 상태에서 2바이트 밀리고 있었다. 이 미세한 정렬 오류가 `_mm256_loadu_si256` 내장 함수를 통해 저 차원 8개의 값을 조용히 왜곡하고 있었던 거다.

Neovim에서 GGML 양자화 커널의 메모리 정렬 디버깅 화면

## 여의도 마사지 이벤트와 양자화의 아이러니

결국 이 버그는 `ggml`의 `#if defined(__AVX2__)` 블록 안에서 `ggml_quantize_q4_K_M`의 마지막 인라인 어셈블리 명령어가 `vpmovusdb` 대신 `vpmovdb`를 사용한 단 한 줄 때문에 발생했다. 포화(saturation) 없는 이동이 residual을 날려버린 거다. 패치는 `vpmovusdb`로 바꾸는 게 다였다.

이걸 고치고 나니, "부산 해운대 복어 맛집 추천"에 대한 응답이 제대로 돌아왔다. 사용자는 "해운대 달맞이길 근처 복어 전문점 세 곳"을 완벽하게 추천받았다. 나는 이틀 밤을 샌 끝에 Neovim 화면 앞에서, 마치 여의도 마사지 이벤트라도 찾아가야 할 것 같은 피로를 느꼈다. 2024년 2월 `ggerganov`의 PR #5639에서 이 수정이 머지됐지만, 아직도 수많은 프로덕션 환경이 저 패치 이전의 `llama.cpp` 바이너리를 돌리고 있을 거다. 너희 서버, 이제 한번쯤 확인해보는 게 어떨까?

함께 보면 좋은 정보