훈련이 끝난 뒤 part 3 of 13

가중치를 4분의 1로 줄이면 무엇이 사라지나

guide / / 7 sections

2편 끝에서 캐시가 가중치보다 커졌다. 이번 편은 반대쪽, 가중치를 줄인다.

방법은 단순하다. float32 하나가 4바이트인데, 값의 범위를 알면 정수 하나로 바꿔 담을 수 있다.

qmax = 2 ** (bits - 1) - 1
scale = w.abs().max() / qmax
q = (w / scale).round().clamp(-qmax - 1, qmax)     # 정수
w_hat = q * scale                                  # 다시 펼친 값

scale 하나를 float 로 들고 나머지를 int8 로 담으면 4바이트가 1바이트가 된다. 문제는 round 에서 버린 것이 얼마나 아픈가다.

int8 은 거의 공짜다

13편 모델을 그대로 쓴다. float32 검증 손실이 2.0272 다.

                    행렬당 한 스케일   출력채널당 스케일
8비트                     2.0290           2.0280
float32 는 2.0272

+0.0017 이다. 소수점 셋째 자리에서 겨우 보인다. 63만 개 가중치를 전부 256단계로 뭉갰는데 모델은 거의 눈치를 못 챈다.

메모리는 이만큼 준다.

2차원 가중치 631,808 개    2.41 MB (fp32)  ->  0.60 MB (int8)
전체 637,156 개            2.43 MB         ->  0.62 MB     74.4% 감소

더 줄이면 어디서 무너지나

2 3 4 5 6 8 6 4 3 2 가중치 비트 수 검증 손실 float32 2.0272 행렬당 한 스케일 출력채널당 스케일
가중치를 몇 비트로 줄였을 때의 검증 손실. 8비트와 6비트는 float32 선에 거의 붙어 있고, 4비트에서 벌어지기 시작해 3비트에서 무너진다. 2비트에서는 채널별 스케일이 오히려 나쁜데, 그 자리에서는 두 방식 다 쓸 수 없는 상태다.
비트   행렬당 한 스케일   출력채널당 스케일
  8         2.0290           2.0280
  6         2.0599           2.0387
  4         2.6380           2.3571
  3         4.3960           3.4381
  2         5.7894           6.3112

6 비트까지는 여전히 소수점 둘째 자리 얘기다. 4 비트에서 2.36 으로 벌어지고 3 비트에서 3.44 로 무너진다. 1편에서 바이그램이 2.6501 이었으니, 4비트로 줄인 모델은 앞 글자만 세는 것보다 겨우 나은 정도가 된다.

2비트에서 뒤집힌다

표의 마지막 줄이 이상하다. 2 비트에서는 채널별 스케일이 6.3112 로 행렬당 하나인 5.7894 보다 나쁘다. 세 줄 위까지는 계속 좋았는데.

무슨 일인지 재 보면 이렇다. blocks.0.f1 하나만 보면

행렬 전체 스케일   0 으로 죽은 원소 95.2%   평균절대오차 0.0519
채널별 스케일      0 으로 죽은 원소 78.1%   평균절대오차 0.0419

가중치 복원은 채널별이 더 잘했다. 죽은 원소도 적고 오차도 작다. 그런데 손실은 더 나쁘다.

즉 2비트 지점에서는 “가중치를 얼마나 잘 복원했나” 와 “모델이 얼마나 잘 동작하나” 가 이미 갈라져 있다. 둘 다 못 쓰는 상태이고, 그 안에서의 순위는 의미가 없다. 재서 나온 숫자를 그대로 적되 거기서 결론을 끌어내지는 않는 편이 맞다.

어디가 예민한가

한 번에 다 줄이는 대신 행렬 하나만 4비트로 바꾸고 나머지를 float32 로 두면 누가 아픈지 보인다.

tok.weight              손실 +0.0976
pos.weight              손실 +0.0618
blocks.2.f1.weight      손실 +0.0184
blocks.0.f2.weight      손실 +0.0174
blocks.2.qkv.weight     손실 +0.0132
...
blocks.2.f2.weight      손실 -0.0020   (가장 둔감, 오히려 조금 내려간다)

임베딩 두 개가 압도적이다. 글자 임베딩 하나가 블록 안 어떤 행렬보다 다섯 배 넘게 아프다. 실제 양자화 구현이 임베딩과 출력 층을 높은 정밀도로 남겨 두는 이유가 이것이다.

가장 둔감한 쪽이 -0.0020 으로 음수인 것도 적어 둔다. 양자화가 우연히 도움이 된 게 아니라, 이 정도 크기는 손실 측정 자체의 흔들림 안이다.

개별 손해를 다 더하면 +0.2830 인데 전부 4비트로 하면 +0.3299 다. 손해가 조금 더해지기만 하는 게 아니라 서로를 키운다.

손실은 안 움직여도 글은 달라진다

int8 은 손실을 +0.0017 만 올렸다. 그럼 같은 글이 나오나. 같은 씨앗으로 뽑아 보면

float32  '`16, a=0, 36, 112003  0.3009849\n```\n\nThe smallest `1.000h`. **`shape(2'
int8     '`16, a=0, 36, 112003  20350  0.63103  20.013910   0.016693939  16\n9 = '

스물두 번째 글자에서 갈린다. 거기까지는 같고 그 뒤로는 완전히 다른 글이다.

당연하다. 1편에서 본 대로 자리마다 후보가 붙어 있고, 확률 차이가 아주 작은 자리에서는 0.0017 어치 흔들림이 다른 글자를 뽑게 만든다. 그리고 한 글자가 달라지면 그 뒤 문맥이 전부 달라진다.

2편에서 캐시의 9.06e-06 을 두고 “비트 단위로 같지 않다” 고 적었던 것이 여기서 눈에 보이는 결과로 나타난다. 평균 성능이 같다는 것과 같은 출력이 나온다는 것은 다른 얘기다.

이 모델에서는 per-channel 이 별로 안 중요했다

양자화 이야기에서 보통 강조되는 것이 이상치다. 어떤 채널의 범위가 유별나게 크면 행렬당 스케일 하나로는 나머지 전부가 뭉개진다. 그래서 채널별로 나눈다.

이 모델에서 재 보면 그 근거가 약하다.

채널 범위의 최대/중앙 비    가장 심한 행렬 2.49    전체 중앙값 1.51

1.5 배쯤이다. 이 정도면 스케일 하나로 덮어도 크게 손해가 아니고, 실제로 8비트에서 두 방식 차이가 0.0010 이다.

활성값 쪽도 마찬가지다.

블록0 FFN 입력      채널 최대/중앙 1.50    최대 5.24
블록2 FFN 입력      채널 최대/중앙 1.54    최대 6.24
잔차 흐름(마지막)    채널 최대/중앙 1.64    최대 6.67

큰 모델에서 활성값 양자화가 어려운 이유로 꼽히는 것이 몇몇 채널이 나머지의 수십 배에서 백 배까지 튀는 현상인데, 여기서는 1.6 배다. 즉 이 실험은 그 문제를 재현하지 못한다. 13편 모델이 작고 6만 자로 5000걸음 돌린 것이라서 그렇다고 보는 게 맞고, 그래서 이 편의 결론 중 “per-channel 이 별로 안 낫다” 는 부분은 이 모델에 한정된 것이다.

그래서

  • int8 은 거의 공짜다. 검증 손실 2.02722.0290, 메모리 2.43 MB0.62 MB, 74.4% 감소
  • 6 비트까지 버틴다. 4 비트는 2.3571 로 바이그램 2.6501 을 겨우 넘고, 3 비트는 무너진다
  • 2 비트에서는 가중치를 더 잘 복원한 쪽이 손실은 더 나쁘다. 두 지표가 이미 갈라진 자리라 순위에 의미가 없다
  • 임베딩이 가장 예민하다. 4비트로 줄이면 tok+0.0976, 블록 안 어떤 행렬보다 다섯 배 아프다
  • 손실이 +0.0017 이어도 뽑은 글은 22번째 글자에서 갈린다. 평균이 같은 것과 같은 출력은 다르다
  • 이 모델에는 이상치가 없다. 채널 범위 비가 1.5 배라 per-channel 의 이점이 작고, 그래서 이 결론은 큰 모델로 옮길 수 없다

세 번 다 “이론이 약속한 것” 과 “실제로 받은 것” 이 달랐다.

다음 편에서는 여기서 만든 4비트 모델을 버리지 않고 쓴다. 싼 모델이 미리 써 둔 글자를 비싼 모델이 한 번에 검사하는 방식인데, 출력이 정말 안 바뀌는지부터 잰다.

Comments