Models & AlgorithmsEN

TurboQuant를 실제 KV 텐서 위에서 밑바닥부터 — 3비트의 진짜 비용, 그리고 포크가 논문 레이아웃을 이기는 이유

PolarQuant를 PyTorch 60줄로 구현해 Llama-3.2-1B·Qwen3-8B의 실제 KV에 적용. 3비트는 +10%, k8v4는 +0.2%, QJL은 저비트에서만 유효, 블록-32 레이아웃이 포크 우위의 절반을 설명합니다.

TurboQuant를 실제 KV 텐서 위에서 밑바닥부터 — 3비트의 진짜 비용, 그리고 포크가 논문 레이아웃을 이기는 이유

TurboQuant를 실제 KV 텐서 위에서 밑바닥부터 -- 3비트의 진짜 비용, 그리고 포크가 논문 레이아웃을 이기는 이유

지금까지 읽으신 TurboQuant 수치는 전부 -- 저희 글까지 포함해서 -- 남이 짠 커널에서 나온 것입니다. 이 글은 그 층을 걷어냅니다. PolarQuant(TurboQuant의 Algorithm 1)를 PyTorch 60줄로 직접 구현하고, HuggingFace 캐시에 끼워 넣어 모델이 *정확히* 압축 캐시가 돌려줄 값 위에서 어텐션하게 만든 뒤, Llama-3.2-1B와 Qwen3-8B의 실제 KV 텐서로 잽니다. 복원 오차, 어텐션 분포 왜곡, 그리고 끝단 perplexity -- 비트 수, norm 보정, QJL 잔차, K/V 비대칭, 그리고 llama.cpp 포크들이 조용히 채택한 블록-32 레이아웃까지.

통설과 어긋나는 결과 세 가지입니다.

  1. 3비트 키·값은 공짜가 아닙니다. 헤드 벡터당 norm 하나 기준으로 Qwen3-8B에서 perplexity +10%, Llama-3.2-1B에서 +34%.
  2. QJL 잔차 단계는 쓸모없지 않습니다 -- *4비트에서* 안 남는 것뿐입니다. 2~3비트에서는 perplexity 1~17점을 되찾아 줍니다.
  3. 블록-32는 병렬화 트릭이 아닙니다. 32개 값마다 norm을 따로 두면 Qwen3-8B의 3비트 손실이 +10.3%에서 +6.3%로 줄어듭니다. 논문 레이아웃과 포크 수치 사이 간격의 대부분은 회전이 아니라 이것입니다.

이 글은 TurboQuant 시리즈 Part 6입니다. 무엇이 어디에 탑재됐는지는 Part 3, 같은 모델로 CUDA 포크를 벤치마크한 결과는 Part 5에 있습니다.

1. 왜 이렇게 재는가

KV 캐시 양자화기를 평가하는 방법은 둘입니다. 논문 방식은 벡터 수준입니다: 벡터를 뽑고, 양자화하고, 왜곡률을 보고합니다. 서빙 엔진 방식은 끝단입니다: 커널을 통해 벤치마크를 돌립니다. 둘 다 무언가를 숨깁니다. 벡터 왜곡률은 *모델*이 왜곡된 키로 무엇을 하는지 말해주지 않고, 커널 벤치마크는 알고리즘을 레이아웃 결정·아웃라이어 처리·아무도 문서화하지 않은 구현 지름길과 한 덩어리로 묶어 버립니다.

저희 설정은 그 사이에 있습니다. 알고리즘을 직접 쓰고, forward pass 중 캐시로 들어오는 모든 키와 값을 가짜 양자화(양자화 직후 역양자화)합니다. 텐서가 작아지지는 않지만 실제 압축 캐시가 돌려줄 값과 수치적으로 동일하므로, 여기서 잰 perplexity는 같은 수학을 실제로 구현했을 때 나올 perplexity입니다. 레이아웃 선택은 숨은 기본값이 아니라 명시적 손잡이가 됩니다.

python
class PolarQuantCache(DynamicCache):
    def update(self, key_states, value_states, layer_idx, cache_kwargs=None):
        key_states   = self.qk.fake_quant(key_states)
        value_states = self.qv.fake_quant(value_states)
        return super().update(key_states, value_states, layer_idx, cache_kwargs)

아래는 전부 A100 한 장에서 돌렸습니다. perplexity는 wikitext-2 16,384 토큰을 2,048 토큰 청크로 teacher-forcing한 값이고, 청크 안의 모든 토큰이 양자화된 키·값 위에서 어텐션합니다.

2. 알고리즘 60줄

PolarQuant-MSE는 벡터마다 네 가지를 합니다.

  1. 방향과 크기를 분리합니다. ‖x‖를 fp16 스칼라 하나로 저장하고, 단위 벡터 u = x / ‖x‖를 양자화합니다.
  2. 무작위 부호를 곱한 Walsh-Hadamard 변환으로 회전합니다: y = H·D·u. 회전 후에는 R^d 단위 벡터의 모든 좌표가 모델과 무관하게 근사적으로 N(0, 1/d)를 따릅니다.
  3. 좌표마다 스칼라 양자화합니다. 코드북은 해당 비트 수의 N(0, 1/d)에 대해 Lloyd-Max로 *한 번만* 계산합니다. 캘리브레이션 데이터가 없습니다 -- 코드북은 d에만 의존합니다.
  4. 나갈 때 역회전합니다.

그 위에 선택 단계 둘이 얹힙니다.

  • norm 보정: 역양자화 후 복원된 방향이 다시 단위 길이가 되도록 되돌립니다. 나눗셈 하나, 공짜입니다.
  • QJL 잔차: 잔차 r = u − Q(u)를 무작위 가우시안 행렬로 투영하고 부호 비트만 남긴 뒤(좌표당 1비트), 표준 QJL 내적 추정량으로 나갈 때 보정을 더합니다.
python
def fwht(x):                                  # 마지막 차원의 정규직교 Walsh-Hadamard
    d = x.shape[-1]; h = 1; y = x.clone()
    while h < d:
        y = y.view(*y.shape[:-1], d // (2*h), 2, h)
        a, b = y[..., 0, :], y[..., 1, :]
        y = torch.stack((a + b, a - b), dim=-2).reshape(*x.shape[:-1], d); h *= 2
    return y / math.sqrt(d)

def fake_quant(self, x):
    norm = x.norm(dim=-1, keepdim=True)
    y    = fwht((x / norm) * self.sign)      # 단위 벡터를 회전
    yq   = self.code[torch.bucketize(y, self.bounds)]   # Lloyd-Max 조회
    uq   = fwht(yq) * self.sign               # 역회전
    if self.qjl:                              # 선택: 1비트 잔차
        r = x / norm - uq
        uq = uq + math.sqrt(math.pi/2) * r.norm(dim=-1, keepdim=True) * (torch.sign(r @ self.S.T) @ self.S) / self.dim
    if self.nc:                               # 선택: norm 보정
        uq = uq / uq.norm(dim=-1, keepdim=True)
    return uq * norm

이게 방법의 전부입니다. 캐시 래퍼와 측정 하네스를 포함한 전체 스크립트는 글 끝에 있습니다.

3. 검산: 양자화기가 이론 한계에 정확히 닿는다

모델을 건드리기 전에 양자화기를 이론과 대조합니다. 가우시안 좌표에 대한 최적(Lloyd-Max) 스칼라 양자화기의 상대 왜곡률은 2비트 약 0.118, 3비트 0.0345, 4비트 0.0095입니다.

Qwen3-8B의 실제 키(헤드 차원 128, 8개 레이어 샘플)에서 측정한 값:

비트상대 MSE (K)상대 MSE (V)이론값
20.1120.1180.118
30.0320.0340.0345
40.00860.00920.0095

실제 KV 벡터가 가우시안 한계 *바로 그 지점*에서 양자화됩니다. 회전이 논문이 말한 일을 정확히 하고 있습니다. 모델별 키 분포를 코드북이 만들어진 분포처럼 보이게 만드는 것. 튜닝할 게 없습니다.

4. 발견 1: 3비트의 끝단 비용

!비트별 perplexity 변화 -- Qwen3-8B

bf16 KV 대비 perplexity, 128차원 헤드 벡터당 norm 하나, norm 보정 켬:

K/V 비트Qwen3-8B (PPL 8.40)Llama-3.2-1B (PPL 12.61)
4 / 4+3.8%+6.3%
3 / 3+10.3%+33.8%
2 / 2+55%+642%
8 / 4 (k8v4)+0.19%+0.29%
4 / 3+4.4%+8.0%
3 / 4+9.4%+29.6%

세 가지가 눈에 띕니다.

벡터 왜곡률은 피해를 숨깁니다. 키의 상대 MSE 3.2%가 8B 모델에서는 perplexity 10%, 1B 모델에서는 34%가 됩니다. 이유는 어텐션 분포에 보입니다. 3비트에서 KL(어텐션 ‖ 양자화 어텐션)은 Qwen3-8B 기준 쿼리당 0.18 nat -- 키의 작은 오차가 *어느* 토큰에 어텐션하느냐의 큰 오차가 됩니다.

키가 값보다 훨씬 중요합니다. k4v3는 +4.4%, k3v4는 +9.4%. 총 비트는 같은데 빠진 비트가 키에서 나오면 피해가 두 배 이상입니다. 이유는 norm에 있습니다. Qwen3-8B에서 K 벡터 평균 norm은 V의 21.8배이고, 최악의 레이어는 490배입니다. 키 오차는 softmax가 키우고, 값 오차는 평균으로 씻겨 나갑니다. (커뮤니티의 "최대 182배"는 다른 모델에서 본 같은 현상입니다. Llama-3.2-1B는 평균 9.8배, 최대 24배.)

k8v4가 정직한 최적점입니다. 키 8비트, 값 4비트는 두 모델 모두 0.2~0.3% -- 노이즈와 구분이 안 됩니다 -- 에 2.6배 압축을 줍니다. vLLM의 turboquant_k8v4 프리셋이 정확히 이것이고, vLLM 자체 연구가 찾은 결론도 같습니다.

작은 모델이 더 취약합니다. 모든 설정에서 Llama-3.2-1B가 Qwen3-8B보다 2~3배 더 다칩니다. 30B급 모델에서 잰 "3비트에서 +1%"는 아래로 내려오지 않습니다.

5. 발견 2: QJL은 쓸모없지 않다 -- 저비트 도구일 뿐이다

"Algorithm 1만으로 충분하다"는 커뮤니티 합의는 4비트에서는 맞고 그 아래에서는 틀립니다.

설정Qwen3-8BLlama-3.2-1B
4비트+3.8% → QJL 시 +2.9%+6.3% → +5.1%
3비트+10.3% → QJL 시 +9.2%+33.8% → +23.1%
2비트+55% → QJL 시 +38.6%+642% → +290%

4비트에서 QJL이 되찾는 건 1점 미만 -- 좌표당 1비트 잔차와 무작위 투영의 값어치가 아닙니다. 3비트, 작은 모델에서는 11점을 되찾습니다. 2비트에서는 망가진 것과 그저 나쁜 것의 차이입니다. 포크들이 QJL을 뺀 건 큰 모델의 3~4비트를 겨냥하기 때문이고, 거기서는 정말로 남는 게 없습니다. 논문의 2단계가 작동하지 않아서가 아닙니다.

6. 발견 3: norm 보정은 어텐션을 고치지 perplexity를 고치지 않는다 -- 2비트만 빼고

!norm 보정 유무에 따른 어텐션 KL -- Qwen3-8B

norm 보정은 한 줄짜리 되돌림인데 프로필이 묘합니다. 어텐션 분포에는 효과가 큽니다. 3비트에서 Qwen3-8B의 KL이 2.16에서 0.18 nat으로 떨어집니다(12배). 보정이 없으면 복원된 방향이 체계적으로 단위 길이보다 짧고(스칼라 양자화는 중심점 쪽으로 수축합니다), 모든 내적이 낮게 편향되고, softmax가 평평해집니다.

perplexity에는 같은 보정이 3~4비트에서 거의 안 보입니다(+10.3% vs +13.1%, +3.8% vs +4.4%). 모델은 KL이 시사하는 것보다 균일하게 평평해진 어텐션에 강건합니다. 2비트에서는 결정적입니다: 있으면 +55%, 없으면 +98%.

그러니 항상 켜되(공짜니까), 3비트 품질을 구해줄 거라 기대하지는 마세요. 포크의 _nc 접미사가 사주는 게 이것입니다.

7. 발견 4: 포크의 수치는 블록-32에서 나온다

모든 llama.cpp 포크는 32개 값 블록 단위로, 블록마다 자체 fp16 스케일을 두고 양자화하며, 커뮤니티는 이를 flash-attention 병렬화 선택으로 설명했습니다. 같은 Qwen3-8B에서 CUDA 포크의 turbo3는 perplexity +5.5%(Part 5) -- 저희 +10.3%의 대략 절반입니다. block=32 옵션을 추가했습니다. 32차원 부분 벡터마다 따로 회전·양자화하고, 자체 norm을 둡니다.

설정128차원 벡터당 norm 1개블록-32 (벡터당 norm 4개)포크 (turbo*)
2비트+55%+69%+83% (turbo2)
3비트+10.3%+6.3%+5.5% (turbo3)
3비트 + QJL+9.2%+5.6%--
4비트+3.8%+2.6%+0.65% (turbo4)
K4 / V3+4.4%+2.6%--

블록-32가 3비트에서 간격 대부분을 메웁니다. 값당 0.5비트가 더 듭니다(32개 값마다 16비트 norm) -- 포크들이 turbo3를 3이 아니라 3.25~3.5 bpv로 보고하는 이유가 정확히 이것입니다. 다시 말해, 커뮤니티가 "병렬화 때문에" 채택한 레이아웃은 진짜 알고리즘적 개선입니다. 더 고운 입도의 극좌표 양자화이고, "거의 무손실"이라는 평판의 의미 있는 몫이 여기서 나옵니다.

주의 둘. 2비트에서는 블록-32가 *더 나쁩니다*(+69% vs +55%). 코드북 레벨이 넷뿐이면 32차원 블록으로 쪼갤 때 좌표 분포가 가우시안에서 더 벗어나기 때문입니다. 그리고 turbo4의 +0.65%는 저희 +2.6%보다 여전히 분명히 좋습니다 -- 포크는 추가로 아웃라이어를 처리하고 비대칭 평균 중심화를 쓰는데, 저희는 재현하지 않았습니다. 남은 간격은 미스터리가 아니라 할 일입니다.

k4v3_blk32k4v4_blk32가 소수 둘째 자리까지 같다는 것도 보세요. 블록-32에서는 값을 3비트로 내리는 게 공짜입니다. 포크와 vLLM이 모두 정착한 비대칭 K/V 할당의 가장 강한 근거입니다.

8. 발견 5: 잔차 윈도는 perplexity에 도움이 안 된다

마지막 128 토큰을 full precision으로 두는 것(흔한 권고)은 Qwen3-8B 3비트를 +10.3%에서 +12.5%로, Llama-1B를 +33.8%에서 +32.1%로 바꿨습니다 -- 노이즈입니다. teacher-forcing 평가에서는 어텐션 질량 거의 전부가 긴 양자화 접두부에 실리므로 128 토큰을 면제해도 별 일이 없습니다. 짧은 프롬프트의 생성 품질에는 더 중요할 수 있지만, 장문 perplexity에는 무관합니다.

9. 이 실험이 보여주는 것과 아닌 것

보여주는 것: 실제 KV 텐서에서 PolarQuant는 캘리브레이션 없이 스칼라 양자화 이론 한계에 닿는다. 키는 norm이 10~500배 커서 값보다 5~20배 민감하다. QJL은 3비트 이하에서 돕는다. norm 보정은 공짜이고 주로 어텐션 분포를 고친다. 블록-32가 3비트에서 포크 우위의 절반쯤을 설명한다.

보여주지 않는 것: 다운스트림 태스크 정확도(perplexity는 대리 지표입니다 -- vLLM 연구는 추론 벤치마크가 perplexity보다 3비트를 더 세게 벌준다고 봤습니다). 속도(가짜 양자화는 공짜지만 실제 커널은 아닙니다 -- Part 5 참고). 16K 토큰 초과 구간. 포크의 아웃라이어 처리와 평균 중심화(구현하지 않았습니다).

10. 정리

  • 무손실을 원하면: 키 8비트, 값 4비트. +0.2%. 끝.
  • 3비트를 원하면: 블록 레이아웃을 쓰고, 키에 값보다 최소 1비트 더 주고, norm 보정을 켜세요. 8B 모델에서 perplexity +5~6%, 더 작은 모델에서는 그 이상을 각오하세요.
  • 3비트 아래로 가면 QJL을 무시하지 마세요. 4비트에서는 무시해도 됩니다.
  • 벡터 왜곡률 숫자를 믿지 마세요. MSE 3%가 perplexity 10%였습니다.

전체 스크립트(polarquant_kv.py, 하네스 포함 약 200줄)와 결과 JSON 두 개는 이 글에 첨부합니다.

참고 자료

더 많은 콘텐츠를 받아보세요

SNS에서 새로운 글과 튜토리얼 소식을 가장 먼저 받아보세요

이메일로 받아보기

관련 포스트