모델 운영

MLOPS / 91번째 글

양자화 보정 — 데이터 128개가 모델 품질을 정한다

양자화에서 스케일을 정하는 절차가 보정입니다. 무엇을 재는지, 데이터를 어디서 몇 개 뽑아야 하는지, 자르는 지점을 어떻게 고르는지, 그리고 잘못된 보정이 어떤 모양으로 드러나는지를 정리합니다.

PALDYN Team21 MIN READ

지난 글에서 큰 모델의 답을 작은 모델에 옮기는 증류를 다뤘다. 모델을 가볍게 만드는 길은 하나 더 있다. 모델의 구조는 그대로 두고 숫자를 담는 그릇만 줄이는 것, 곧 양자화다. FP16으로 저장된 가중치를 INT8이나 INT4로 바꾸면 파일도 메모리도 절반 이하가 된다.

그런데 양자화에는 한 번은 반드시 지나가야 하는 관문이 있다. 실수 값을 정수 몇 번 칸에 넣을지 정하려면 값이 어디부터 어디까지 오는지를 알아야 한다. 이 범위를 실제로 재는 절차를 보정(calibration)이라 부른다. 양자화의 기본에서 스케일 인수를 계산한다고 짚고 넘어갔던 그 자리다.

보정은 겉보기에 사소하다. 데이터 몇백 개를 넣고 순전파를 돌리면 끝이고, 시간도 몇 분이면 된다. 그런데 여기서 잘못 잡힌 숫자는 배포된 모델에 그대로 박혀서, 나중에 성능이 이상하다고 느낄 때 원인을 찾기가 가장 어려운 자리이기도 하다.

보정은 학습이 아니다

가장 먼저 정리해야 할 것은 보정이 무엇을 하지 않는지다.

보정 절차의 네 단계와 가중치가 여기에 없는 이유

역전파가 없고 가중치가 갱신되지 않는다. 그래서 GPU 한 장에 보통 몇 분이면 끝나고, 학습 데이터도 라벨도 필요 없다. 필요한 것은 모델에 넣을 입력 문장 몇백 개뿐이다.

그리고 보정이 필요한 대상은 가중치가 아니라 활성값(activation)이다. 가중치는 파일에 이미 숫자로 들어 있으므로 최댓값과 최솟값을 데이터 없이 그냥 읽으면 된다. 반면 활성값은 층을 통과하는 중간 결과라서 입력이 들어오기 전에는 존재하지 않는다. 그래서 순전파를 실제로 돌려 관찰해야 한다.

여기서 헷갈리기 쉬운 지점이 하나 있다. 가중치만 양자화하는 방식에도 보정 데이터가 들어간다. AWQ와 GPTQ가 그렇다. 이때 데이터는 범위를 재려고 쓰는 것이 아니라, 어떤 가중치가 중요한지 판단하려고 쓴다. AWQ는 활성값이 큰 채널에 붙은 가중치를 덜 망가뜨리려고 활성 분포를 보고, GPTQ는 양자화 오차가 출력에 미치는 영향을 헤시안으로 근사하는데 그 헤시안을 보정 데이터로 추정한다. 하는 일은 다르지만 데이터의 성격이 결과를 정한다는 점은 같다.

무엇을 재고, 그 수로 무엇을 하는가

INT8로 대칭 양자화를 한다고 하자. 부호 있는 8비트는 −128-128부터 127127까지 256칸이다. 관찰한 값의 절댓값 최대를 α\alpha라 하면 스케일은 이렇게 나온다.

s=α127s = \frac{\alpha}{127}

그리고 실수 xx를 정수로 옮기는 식과 되돌리는 식은 이렇다.

q=clip(round(xs),−128,127),x^=q⋅sq = \mathrm{clip}\left(\mathrm{round}\left(\frac{x}{s}\right), -128, 127\right), \qquad \hat{x} = q \cdot s

대칭(symmetric) 양자화는 0을 정수 0에 그대로 두는 방식이고, 값의 분포가 0을 중심으로 퍼져 있을 때 쓴다. 분포가 한쪽으로 쏠려 있으면 — ReLU를 지난 뒤처럼 음수가 아예 없으면 — 절반을 통째로 낭비하게 되므로 비대칭(asymmetric) 양자화를 쓴다. 이때는 스케일과 함께 영점(zero-point)이라는 정수 하나를 더 둔다. 실수 0이 정수 몇 번에 놓이는지를 적어 두는 값이다.

s=xmax⁡−xmin⁡255,z=round(−xmin⁡s)s = \frac{x_{\max} - x_{\min}}{255}, \qquad z = \mathrm{round}\left(-\frac{x_{\min}}{s}\right)

보정이 만들어 내는 산출물은 결국 층마다의 이 두 숫자다. 그리고 이 숫자는 배포된 뒤 다시 재지 않는다. 그래서 보정 때 본 분포와 실제 서비스에서 들어오는 분포가 다르면, 그 어긋남이 계속 남는다.

어디서 자를 것인가

보정에서 실제로 판단이 들어가는 자리는 하나다. 관찰한 값 중 어디까지를 범위 안에 담고 어디부터 잘라 버릴 것인가.

이게 왜 판단이 되는지는 이상치 때문이다. 트랜스포머의 활성값에는 대부분의 값보다 열 배, 백 배 큰 값이 특정 채널에만 몰려 나타나는 현상이 있다. 그 채널 하나 때문에 범위를 넓히면 나머지 값 전부가 손해를 본다.

absmax와 백분위 절단이 칸 폭에 만드는 차이

그림의 숫자를 그대로 따라가 보자. 값의 99.9%가 ±0.5\pm 0.5 안에 있고 한 채널에만 8.28.2가 있는 층이다.

  • absmax로 잡으면 범위가 ±8.2\pm 8.2, 칸 폭은 16.4/255≈0.06416.4 / 255 \approx 0.064다. ±0.5\pm 0.5 구간의 값 전부가 16칸만 나눠 쓴다. 흔한 값들이 서로 구별되지 않는다.
  • 99.9 백분위에서 자르면 범위가 ±0.6\pm 0.6, 칸 폭은 1.2/255≈0.00471.2 / 255 \approx 0.0047다. 같은 구간을 213칸이 나눠 쓴다. 대신 8.28.2짜리 값은 0.60.6으로 뭉개진다.

값 하나를 크게 틀리는 것과 값 대부분을 조금씩 틀리는 것 사이의 거래다. 어느 쪽이 나은지는 층마다 다르고, 그래서 자를 자리를 고르는 방법이 여러 개다.

방법 정하는 방식 성격
absmax 관찰한 절댓값 최대를 그대로 쓴다 자르지 않는다. 이상치가 없는 층에서는 이게 최선이다
백분위 상위 0.1%·0.01% 지점에서 자른다 가장 흔하다. 자를 비율을 사람이 정해야 한다
MSE 여러 후보 범위를 시험해 ∥x−x^∥2\lVert x - \hat{x} \rVert^2이 가장 작은 것을 고른다 층마다 자동으로 정해진다. 후보를 다 돌려야 해서 느리다
KL 다이버전스 원래 분포와 양자화된 분포의 차이를 최소화한다 TensorRT가 쓰는 방식. 히스토그램을 오래 모아야 한다
이동 평균 배치를 지나며 최댓값을 지수 평균으로 갱신한다 배치 하나의 튀는 값에 덜 흔들린다

실무에서 처음 잡을 때는 MSE를 층마다 자동으로 돌리는 것이 안전하다. 사람이 백분위를 하나 정해 모든 층에 똑같이 적용하면, 이상치가 없는 층에서는 멀쩡한 값을 괜히 자르고 이상치가 심한 층에서는 여전히 부족하다.

데이터는 몇 개면 되는가

가장 자주 받는 질문이고, 답은 생각보다 작다. 128개에서 512개 사이다. GPTQ와 AWQ의 기본값이 대체로 이 언저리이고, 여기서 더 늘려도 품질이 눈에 띄게 올라가지 않는다.

이유는 재는 대상이 분포의 요약값이기 때문이다. 층 하나의 활성값은 시퀀스 길이 2,048짜리 입력 하나만 넣어도 이미 수백만 개가 나온다. 문서 128개면 최댓값과 백분위를 추정하기에 충분한 표본이 된다. 모델의 파라미터를 맞추는 학습과는 필요한 데이터의 양이 완전히 다르다.

다만 개수보다 길이가 중요하다. 짧은 문장 512개보다 2,048토큰짜리 긴 문서 128개가 낫다. 긴 문맥에서만 나타나는 활성 패턴이 있고, 짧은 입력만으로 보정하면 그 구간의 범위가 과소 추정된다.

그리고 패딩 토큰을 통계에 넣지 않도록 주의한다. 배치로 묶으며 붙인 패딩 자리의 활성값까지 함께 세면 분포가 0 쪽으로 끌려간다. 대부분의 라이브러리가 어텐션 마스크를 반영하지만, 직접 후크를 걸어 값을 모을 때는 놓치기 쉬운 자리다.

# 보정 데이터를 만들 때 챙기는 것: 길이, 실제 분포, 패딩 제외
def build_calibration_set(samples, tokenizer, n=128, seq_len=2048):
    chunks = []
    for text in samples:                      # 실제 서비스 로그에서 뽑은 문장
        ids = tokenizer(text, return_tensors="pt").input_ids
        if ids.shape[1] < seq_len:            # 짧은 것은 이어 붙여 길이를 채운다
            continue                          # 패딩으로 채우지 않는다
        chunks.append(ids[:, :seq_len])
        if len(chunks) >= n:
            break
    return chunks

개수보다 출처가 훨씬 중요하다

같은 128개라도 어디서 뽑았느냐에 따라 결과가 갈린다. 여기가 보정에서 실제로 사고가 나는 자리다.

기본값으로 딸려 오는 공개 코퍼스를 그대로 쓰는 것이 가장 흔한 실수다. 양자화 도구들이 예제에서 위키 문서나 C4 같은 영어 웹 텍스트를 쓰는데, 예제를 그대로 복사해 한국어 상담 서비스 모델을 보정하면 두 가지가 어긋난다.

  • 언어가 다르다. 한국어 입력에서 활성화되는 토큰과 채널의 분포는 영어와 다르다.
  • 형식이 다르다. 위키 문서는 잘 다듬어진 서술문이고, 실제 서비스 입력은 짧고 구어체이며 시스템 프롬프트와 대화 기록이 앞에 붙어 있다.

그래서 원칙은 하나로 정리된다. 보정 데이터는 그 모델이 배포된 뒤 실제로 받게 될 입력과 같은 분포에서 뽑는다. 프롬프트 틀까지 똑같이 씌워서 넣는 것이 좋다. 시스템 프롬프트가 항상 붙는 모델이라면 그 프롬프트를 붙인 상태의 활성 분포가 실제 분포다.

반대로 한 종류에만 쏠려도 안 된다. 서비스가 다루는 요청 유형이 열 가지인데 그중 가장 흔한 하나에서만 뽑으면, 드문 유형이 들어올 때 값이 범위를 넘어 잘린다. 유형별로 고르게 섞는다.

범위를 하나로 둘지 여럿으로 둘지

지금까지 "층마다 스케일 하나"로 이야기했지만, 그 단위는 고를 수 있고 이 선택이 보정의 난이도 자체를 바꾼다.

단위 스케일 개수 성격
텐서 단위 층마다 1개 가장 빠르고 가장 거칠다. 이상치 한 개가 층 전체를 망친다
채널 단위 출력 채널마다 1개 이상치가 그 채널 안에만 갇힌다. 가중치 양자화의 사실상 기본값
그룹 단위 가중치 64·128개마다 1개 INT4에서 흔히 쓴다. 스케일을 저장하는 비용이 붙는다
토큰 단위 활성값의 토큰마다 1개 추론 중에 계산한다. 보정이 아예 필요 없어지는 대신 연산이 는다

세분화를 올리면 이상치의 피해가 그 안에 갇히므로 보정을 어떻게 하느냐가 덜 중요해진다. INT4 그룹 단위 양자화에서 그룹 크기를 128에서 64로 줄이면 품질이 올라가는 것이 이 이유다. 대신 그룹마다 스케일을 하나씩 저장해야 하니 실효 비트 수가 올라간다. 4비트에 그룹 128이면 스케일과 영점을 더해 실제로는 4.25비트쯤 된다.

활성값 쪽을 토큰 단위 동적 양자화로 돌리면 활성에 대한 보정은 없어진다. 요청이 들어올 때마다 그 토큰의 범위를 그 자리에서 재기 때문이다. 요즘 W8A8 서빙이 대체로 이 방식이고, 보정으로 활성 범위를 미리 굳혀 두는 방식(정적 양자화)보다 안전하다. 대신 커널이 매번 최댓값을 구해야 해서 조금 느리다.

잘못된 보정은 어떤 모양으로 드러나는가

보정 사고의 성가신 점은 모델이 고장 나 보이지 않는다는 것이다. 문장은 여전히 자연스럽고 대부분의 질문에 잘 답한다. 대신 이런 모양으로 새어 나온다.

  • 긴 입력에서만 품질이 떨어진다. 짧은 문서로만 보정해서 긴 문맥의 활성 범위가 과소 추정된 경우다
  • 드문 요청 유형에서만 이상하다. 보정 데이터가 흔한 유형에 쏠려 있던 경우다
  • 특정 언어에서만 나쁘다. 영어 코퍼스로 보정한 다국어 모델의 전형적인 증상이다
  • 숫자나 코드에서 유독 틀린다. 자연어 문서로만 보정하면 코드·수식 토큰 구간의 분포가 안 잡힌다
  • 같은 설정으로 두 번 양자화했는데 결과가 다르다. 보정 데이터를 무작위로 뽑으면서 시드를 안 고정한 경우다

마지막 항목은 따로 짚어 둘 만하다. 보정 데이터 선택은 재현 가능해야 한다. 어떤 문서 128개를 썼는지를 시드나 파일 목록으로 남겨 두지 않으면, 나중에 품질 차이가 났을 때 그것이 알고리즘 때문인지 데이터 때문인지 가릴 수 없다. 양자화 산출물 옆에 보정 데이터의 해시를 함께 적어 두는 편이 좋다.

검증은 perplexity로 끝나지 않는다

양자화 품질을 볼 때 위키텍스트 perplexity 하나만 재고 넘어가는 경우가 많은데, 이 지표는 보정 사고를 잡아내지 못한다. 보정 데이터도 위키 문서, 평가도 위키 문서라면 분포가 같아서 당연히 좋게 나온다. 정작 서비스 입력에서 무너지는 것을 못 본다.

검증은 세 층으로 두는 것이 좋다.

  1. 과제 평가. 실제 서비스가 하는 일로 만든 평가 집합에서 원본과 양자화본을 비교한다. 이게 가장 중요하고, 나머지는 원인을 좁히는 용도다
  2. 층별 오차. 같은 입력을 원본과 양자화본에 넣고 층마다 출력의 코사인 유사도를 잰다. 유독 낮은 층이 있으면 그 층의 범위 설정이 잘못됐다는 뜻이다
  3. 최악 사례. 평균이 아니라 가장 나쁜 입력을 본다. 긴 문서, 드문 언어, 코드가 섞인 입력을 일부러 넣는다

그리고 비교 대상을 원본 모델로 둔다. 양자화본끼리 비교하면 둘 다 같은 방식으로 나쁠 때 그것을 못 본다.

정리

  • 보정은 학습이 아니라 활성값의 범위를 재는 순전파다. 역전파도 라벨도 없고 몇 분이면 끝난다
  • 가중치는 파일에 이미 있으니 보정이 필요 없다. 다만 AWQ·GPTQ는 어떤 가중치가 중요한지 판단하려고 데이터를 쓴다
  • 판단이 들어가는 자리는 하나다. 이상치를 담을 것인가 자를 것인가. absmax는 안 자르고, 백분위·MSE·KL은 자른다
  • 층마다 자동으로 정해 주는 MSE 기반이 무난하다. 백분위 하나를 모든 층에 똑같이 쓰면 어떤 층은 과하고 어떤 층은 모자란다
  • 데이터는 128~512개면 충분하다. 개수보다 길이와 출처가 중요하고, 실제 서비스 입력과 같은 분포에서 프롬프트 틀까지 씌워 뽑는다
  • 그룹 크기를 줄이거나 활성을 토큰 단위 동적 양자화로 돌리면 보정에 대한 민감도가 낮아진다
  • 잘못된 보정은 고장이 아니라 특정 구간에서만 나빠지는 모양으로 드러난다. 긴 입력, 드문 유형, 다른 언어, 코드가 그 자리다
  • perplexity 하나로 검증하지 않는다. 과제 평가·층별 오차·최악 사례 셋을 두고 원본과 비교한다

읽어주셔서 감사합니다. 😊

LATEST

모델 운영의 최신 글

모델 운영2026.09.02

엣지 NPU 런타임 — 모델을 기기 안 전용 칩에 올릴 때

휴대폰과 산업용 기기의 NPU에 모델을 올리는 일이 GPU 배포와 어떻게 다른지 정리합니다. 미리 컴파일해야 하는 이유, 연산자 지원에서 막히는 지점, 그리고 NPU가 실제로 이기는 자리를 다룹니다.

15 MIN
모델 운영2026.09.02

브라우저에서 WebGPU로 모델을 돌린다

설치 없이 웹 페이지 안에서 언어 모델을 돌리는 구조를 정리합니다. 첫 방문의 내려받기와 셰이더 컴파일, 버퍼 한계라는 진짜 벽, 그리고 어떤 서비스에 이 방식이 맞는지 다룹니다.

14 MIN