개요

30~34편은 에이전트를 어떻게 굴리느냐의 이야기였다. 읽을거리를 주고, 손을 달아주고, 계획을 세우게 하고, 여럿을 붙이고, 강화학습으로 훈련시켰다.

그런데 그 시리즈를 쓰는 내내 같은 단어가 계속 나왔다. 비용. 에이전트를 하나 더 붙이면 토큰이 몇 배로 늘고, 모델을 하나 더 띄우면 GPU가 하나 더 필요하다. 33편에서 다중 에이전트의 단점으로 적어뒀던 것도 결국 그거였다.

이번 자료는 그 비용을 정면으로 다룬다. 제목이 “리소스 효율적 AI모델”이다. 모델을 어떻게 줄일 것인가, 줄이면 뭘 잃는가, 그러면서도 어떻게 튜닝할 것인가.

그런데 자료의 1번 챕터가 “컴퓨터의 수 체계”였다. 고정소수점, 부동소수점, 2의 보수.. 이름만 익숙하고 제대로 들여다본 적은 없는 것들이었다. AI 수업에서 이게 왜 나오나 싶었는데, 읽고 나니 순서가 이것밖에 없더라.

이번 글이 답할 질문 모델 경량화 이야기를 하는데 왜 이진수부터 시작할까? 그리고 NVIDIA는 왜 세대마다 FP16, INT8, FP8 같은 더 짧은 숫자를 하나씩 늘려왔을까?

이번 편은 그 밑바닥인 수 체계와 연산 비용까지만 다룬다. 실제 경량화 기법(양자화·가지치기·지식 증류)은 36편, 파인튜닝과 LoRA/QLoRA는 37편으로 나눴다. 한 편에 넣기엔 양이 너무 많았다..

교육 과정 자료는 대외비라 원본 슬라이드는 싣지 않는다.


1. 왜 갑자기 수 체계인가 — 경량화는 결국 숫자를 줄이는 일이다

모델을 가볍게 만든다는 말을 처음 들으면 “레이어를 줄이나?” 싶다. 물론 그것도 방법이지만, 실무에서 가장 먼저 손대는 건 다른 쪽이다.

신경망의 가중치(weight)는 전부 숫자다. 70억 개짜리 모델이라면 숫자가 70억 개 들어있는 것이다. 이 숫자 하나를 32비트로 담느냐 8비트로 담느냐에 따라 모델 파일 크기가 4배 차이 난다.

  • FP32(32비트 실수)로 담은 7B 모델 → 대략 28GB
  • INT8(8비트 정수)로 담은 같은 모델 → 대략 7GB

숫자를 줄였을 뿐인데 24GB짜리 그래픽카드에 안 들어가던 모델이 들어간다. 이게 양자화(quantization) 다. 정밀도를 낮춰서 자리를 아끼는 것.

산업 현장 사례도 그랬다. 테슬라의 자율주행 전용 칩은 모델을 INT8로 양자화해서 곱셈과 누산을 전부 정수로 처리한다. 차 안에 서버를 넣을 수는 없으니 전력과 지연을 최소화해야 하고, 그 답이 “숫자를 짧게 쓰기”였다. 삼성이 갤럭시에서 번역·요약을 기기 안에서 돌리는 것도 같은 이야기다.

그래서 경량화를 이해하려면 숫자가 비트 안에 어떻게 들어가 있는지를 먼저 알아야 한다. 자를 곳을 알아야 자를 수 있으니까.

여기까지 읽고 나서야 자료의 목차 순서가 납득이 갔다.


2. 이진수와 16진수 — 컴퓨터가 자릿수를 세는 법

우리가 쓰는 십진수 5374는 사실 자릿값의 합이다.

$$ 5374_{10} = 5 \times 10^3 + 3 \times 10^2 + 7 \times 10^1 + 4 \times 10^0 $$

이진수도 똑같다. 밑이 10에서 2로 바뀔 뿐이다.

$$ 11012 = 1 \times 2^3 + 1 \times 2^2 + 0 \times 2^1 + 1 \times 2^0 = 13{10} $$

반대로 십진수를 이진수로 바꿀 땐 큰 자릿값부터 떼어내면 된다. 47을 바꾸면 32+8+4+2+1 이니까 101111이 된다.

2의 거듭제곱은 외워두면 편하다

자료에서 굳이 한 장을 써서 강조한 부분이다. 2의 9승 정도까지 외워두면 큰 수를 어림짐작할 수 있다.

거듭제곱 별명
2¹⁰ 1,024 1 kilo (≈ 천)
2²⁰ 1,048,576 1 mega (≈ 백만)
2³⁰ 1,073,741,824 1 giga (≈ 십억)

이걸 알면 2²⁴ = 2⁴ × 2²⁰ ≈ 16 million 처럼 암산이 된다. 32비트 변수가 표현할 수 있는 패턴의 가짓수는 2² × 2³⁰ ≈ 40억. int는 21억까지라고만 외우고 썼던 그 숫자가 여기서 나온 거였다..

16진수는 이진수의 축약형이다

16진수는 이진수 네 자리를 한 글자로 줄인 것뿐이다. A1010, F1111.

0x4AF를 이진수로 바꾸면 그냥 세 글자를 각각 4비트로 펼치면 된다.

  • 40100, A1010, F1111
  • 합쳐서 0100 1010 1111

메모리 주소나 색상 코드가 16진수로 쓰이는 이유가 이거다. 이진수는 너무 길고, 십진수는 비트 경계가 안 보인다.

자주 나오는 단위들

용어
MSB (Most Significant Bit) 가장 왼쪽 비트. 가장 큰 자릿값을 가짐
LSB (Least Significant Bit) 가장 오른쪽 비트. 가장 작은 자릿값
Nibble 4비트. 16진수 한 글자에 대응
Byte 8비트

비트 폭을 늘릴 때 — 부호 확장과 제로 확장

4비트 값을 8비트 변수에 넣어야 할 때가 있다. 이때 위쪽 빈 자리를 뭘로 채우느냐가 문제다.

  • 부호 확장(sign extension): 맨 앞 비트를 복사해서 채운다. 값을 유지한다
  • 제로 확장(zero extension): 무조건 0으로 채운다. 비트 패턴을 유지한다

-5의 4비트 표현이 1011이라고 하면,

방식 결과 읽히는 값
부호 확장 1111 1011 −5 (그대로)
제로 확장 0000 1011 11 (값이 바뀜!)

부호 있는 수를 옮길 땐 부호 확장, 부호 없는 수나 비트 마스크를 옮길 땐 제로 확장. 이걸 헷갈리면 음수가 갑자기 양수로 변한다.


3. 2의 보수 — 음수를 어디에 담을 것인가

여기가 처음 배울 때 제일 안 와닿았던 부분이다. 왜 굳이 뒤집고 1을 더하나?

음수를 표현하는 가장 직관적인 방법은 부호-크기 표현(signed-magnitude) 이다. 맨 앞 비트를 부호로 쓰고, 나머지로 크기를 적는다.

  • +60110
  • −61110

사람 눈엔 이게 제일 읽기 편하다. 그런데 덧셈을 시켜보면 바로 깨진다.

   1110   (-6)
 + 0110   (+6)
 -------
  10100   (?)

−6 + 6은 0이어야 하는데 전혀 엉뚱한 값이 나온다. 부호 비트를 크기랑 같이 더해버렸기 때문이다. 게다가 0000도 0이고 1000도 0이다. 0이 두 개다.

그래서 나온 게 2의 보수(two’s complement) 다. 발상이 좀 다르다. 맨 앞 비트를 “부호 표시”로 보는 게 아니라, −2ⁿ⁻¹ 이라는 값을 가진 자릿수로 본다.

$$ A = a_{n-1} \times (-2^{n-1}) + \sum_{i=0}^{n-2} a_i 2^i $$

4비트라면 맨 앞자리가 −8을 뜻한다. 그래서 1110은 −8 + 4 + 2 = −2가 된다.

부호-크기와 2의 보수 비교

이렇게 정의하고 나면 아까 그 덧셈이 그냥 맞는다.

    0110   (+6)
  + 1010   (-6)
  -------
   10000   → 넘친 자리를 버리면 0000 = 0

부호반전도 간단하다. 모든 비트를 뒤집고 1을 더한다. 3(0011)을 뒤집으면 1100, 1을 더하면 1101 = −3.

2의 보수를 쓰는 이유는 “음수를 예쁘게 적기 위해서”가 아니다. 덧셈기 하나로 뺄셈까지 처리하기 위해서다.

실제로 뺄셈기는 따로 없다. A − BA + (−B)로 바꿔서 덧셈기에 넣는다. 하드웨어 입장에서 회로 하나를 통째로 아끼는 셈이다.

더 쉬운 비유로

부호-크기: “앞에 마이너스 표시를 붙인다” 2의 보수: “시계를 거꾸로 돌린다”

시계를 생각하면 편하다. 12시에서 시계 방향으로 3칸 가면 3시다. 그런데 9칸을 가도 12시 기준으로는 3시간 전, 즉 −3시와 같은 자리에 선다.

12시간짜리 시계에서 +9−3이 같은 자리를 가리키는 것처럼, 4비트에서 1101은 13이기도 하고 −3이기도 하다. 어느 쪽으로 읽을지는 약속의 문제고, 덧셈 회로는 그 약속을 몰라도 그냥 돌아간다. 넘친 자리는 시계 한 바퀴처럼 버려지니까.


4. 고정소수점 — 소수점은 저장되지 않는다

정수를 담는 법은 알았다. 그럼 1.5 같은 소수는?

가장 단순한 방법이 고정소수점(fixed-point) 이다. 8비트가 있으면 앞의 4비트는 정수부, 뒤의 4비트는 소수부로 쓰기로 미리 정해두는 것이다.

$$ X = x_{K-1} 2^{K-1} + \cdots + x_0 2^0 + x_{-1} 2^{-1} + \cdots + x_{-M} 2^{-M} $$

여기서 제일 중요한 사실 하나.

이진 소수점의 위치는 실제로 저장되지 않는다. 하드웨어는 이걸 그냥 8비트 정수로 본다.

소수점 위치는 프로그래머의 약속일 뿐이고, 하드웨어는 저장된 정수에 배율(scaling factor) 을 곱해서 해석하라고 넘겨받는 것도 아니다. 그냥 정수 연산을 한다. 그래서 이런 일이 생긴다.

고정소수점의 배율 문제

덧셈은 문제가 없다. 두 값에 같은 배율이 곱해져 있으니 결과에도 배율이 한 번만 남는다.

$$ aX + aY = a(X + Y) $$

곱셈은 다르다. 배율끼리도 곱해지니 배율이 두 번 곱해진 값이 나온다.

$$ aX \times aY = a^2 (X \times Y) $$

위 그림의 예시로 보면, 1.5와 0.5를 곱하려고 저장된 정수 24와 8을 곱하면 192가 나온다. 배율(2⁻⁴)을 한 번만 붙여 읽으면 12다. 정답은 0.75인데. 배율을 한 번 더 나눠줘야 맞는다.

나눗셈은 반대다. 배율이 서로 상쇄돼서 사라지니 다시 곱해줘야 한다.

고정소수점은 회로가 단순하고 빠르다. 대신 소수점 관리를 사람이 한다. 실수 한 번이면 값이 16배 틀어진다.

임베디드나 DSP에서는 지금도 많이 쓰지만, 범용으로 쓰기엔 부담이 크다. 그래서 다음 방식이 나온다.


5. 부동소수점 — 정규화와 바이어스 지수, 그리고 IEEE 754

고정소수점의 진짜 문제는 따로 있다. 아주 크거나 아주 작은 수를 담으려면 비트가 너무 많이 필요하다.

64비트 정수로도 표현 범위는 대략 ±9.22 × 10¹⁸에서 끝난다. 그런데 지수 표기를 쓰면 어떨까? 1.23 × 10³ 처럼 가수(mantissa)지수(exponent) 로 나눠 적으면 같은 자리 수로 훨씬 넓은 범위를 담을 수 있다. 대신 모든 값을 정확히 담는 건 포기한다. 유효숫자만 챙기는 것이다.

소수점이 고정되어 있지 않고 지수에 따라 둥둥 떠다닌다고 해서 부동소수점(floating-point) 이다.

$$ F = (-1)^S \times M \times \beta^{E} $$

부호(S), 가수(M), 밑(β), 지수(E). 밑은 보통 2로 미리 약속해두니까 실제로 저장되는 건 부호·지수·가수 세 덩어리다.

부동소수점 포맷 비교

정규화 — 표현이 하나로 정해지게

문제가 하나 있다. 같은 값을 적는 방법이 여러 가지다.

0.11010 × 2¹⁰¹ 이나 0.01101 × 2¹⁰⁰ 이나 값은 같다. 이러면 두 수를 비교할 때마다 어느 쪽이 큰지 계산을 해봐야 한다.

그래서 정규화(normalization) 라는 약속을 둔다. 맨 앞이 0으로 시작하지 않는 형태로만 적기로 하는 것이다. 이렇게 하면,

  • 표현이 하나로 정해진다
  • 유효숫자를 최대한 확보한다 (앞의 0은 정보가 아니니까)
  • 지수를 먼저 비교하고, 같으면 가수를 비교하면 대소 비교가 끝난다

바이어스 지수 — 음수 지수를 양수로 밀어놓기

지수는 음수가 될 수 있다. 2⁻¹²⁶ 같은 아주 작은 수를 담아야 하니까. 그런데 지수 자리에 2의 보수를 쓰면, 위에서 말한 “지수를 먼저 비교” 트릭이 깨진다. 부호 비트가 껴 있으면 부호 없는 정수처럼 비교가 안 되기 때문이다.

그래서 지수에 미리 정해진 상수를 더해서 저장한다. 이게 바이어스 지수(biased exponent) 다.

$$ E = E^{true} + bias $$

지수가 7비트라면 실제 지수의 범위는 −64 ~ +63이다. 여기에 bias 64를 더해서 저장하면 0 ~ 127이 된다. 전부 양수다.

실제 지수 저장되는 값 (bias = 64)
−64 0000000
0 1000000
+63 1111111

바이어스 지수를 쓰면 부호·지수·가수를 통째로 부호 없는 정수처럼 비교해도 대소 관계가 그대로 맞는다. 비교 회로를 따로 안 만들어도 되는 것이다.

IEEE 754 — 지금 우리가 쓰는 float

이걸 표준으로 못 박은 게 IEEE 754다. C에서 float, double이 바로 이거다.

항목 Single (float) Double (double)
전체 길이 32비트 64비트
가수 + hidden bit 23 + 1 52 + 1
지수 8비트 11비트
bias 127 1023
대략적인 범위 약 10³⁸ 약 10³⁰⁷
대략적인 해상도 2⁻²³ ≈ 10⁻⁷ 2⁻⁵² ≈ 10⁻¹⁵

여기서 재밌는 게 두 가지 있었다.

1) Hidden bit — 정규화를 1.xxxx 형태로 하기로 약속하면, 맨 앞의 1항상 1이니까 저장할 필요가 없다. 안 적고 아낀 그 1비트만큼 정밀도가 공짜로 늘어난다.

2) bias가 128이 아니라 127 — 8비트 지수면 자연스럽게 bias는 2⁷ = 128일 것 같은데 127을 쓴다. 표현 범위를 양의 방향으로 한 칸 더 밀기 위해서다. 덕분에 정규화된 수의 역수가 상한초과(overflow) 없이 표현된다.

그리고 특수 패턴이 정의돼 있다.

E f = 0 f ≠ 0
E = 0 0 비정규화 수 (denormalized)
E = 255 ±∞ NaN

0/0을 했을 때 나오는 그 NaN이 여기서 나온 거였다. 에러 표시 정도로 보이던 게 사실은 비트 패턴으로 정의된 값이었다..

더 쉬운 비유로

고정소수점: 자를 하나 정해놓고 눈금을 읽는다 부동소수점: 줌 배율을 바꿔가며 사진을 찍는다

30cm 자로는 책상 길이를 재기 어렵고, 줄자로는 머리카락 굵기를 못 잰다. 고정소수점은 자를 하나 골라서 끝까지 쓰는 방식이다. 정밀도가 일정한 대신 범위가 좁다.

부동소수점은 카메라 줌 같은 것이다. 멀리 있는 산을 찍을 땐 배율을 낮추고, 곤충을 찍을 땐 배율을 높인다. 지수가 줌 배율이고, 가수가 그 배율에서 찍은 사진이다. 어떤 크기든 담을 수 있지만, 산 사진에서 나뭇잎 한 장은 안 보인다. 큰 수일수록 표현 간격이 성글어지는 게 딱 이 이유다.


6. 연산의 값 — 덧셈 ≪ 곱셈 ≪ 나눗셈, 그래서 GPU는

수를 담는 법을 알았으니 이제 연산 비용이다. 여기가 개인적으로 제일 실감났던 부분이었다.

우리는 코드에서 a + ba / b나 똑같이 한 줄로 쓴다. 그런데 회로 입장에서 이 둘은 전혀 다른 물건이다.

연산별 비용 비교

  • 덧셈·뺄셈: 회로가 거의 같다. 뺄셈은 2의 보수 덧셈으로 처리하니까
  • 곱셈: 자릿수마다 부분곱을 만들어 더한다. n비트끼리 곱하면 결과가 2n비트까지 늘어난다
  • 나눗셈: 몫과 나머지 두 개를 만들어야 한다. 기본적으로 반복 구조라 제일 느리고 제일 크다

부동소수점은 여기에 한 겹이 더 붙는다. 덧셈 하나를 하려면,

  1. 두 수의 지수를 비교해서 큰 쪽에 맞춘다
  2. 작은 쪽 가수를 오른쪽으로 밀어 자릿수를 맞춘다
  3. 가수끼리 더한다
  4. 결과가 정규화 범위를 벗어났으면 다시 정규화한다
  5. 그 과정에서 지수가 변했으니 상한초과·하한미달을 다시 확인한다

정수 덧셈은 1번만 하면 되는 일인데 부동소수점은 5단계다. 자료의 그래프에서도 FP 연산은 같은 비트 폭의 INT 연산보다 지연 시간과 회로 면적이 눈에 띄게 컸다.

그래서 “정수로 바꿔서 계산한다”는 게 그냥 메모리를 아끼는 이야기가 아니다. 곱셈기 자체가 작아지고 빨라지고 전력을 덜 먹는다.

그래서 GPU는 짧은 숫자를 늘려왔다

이 흐름을 알고 나면 NVIDIA의 세대별 변화가 다르게 보인다.

GPU 숫자 포맷 연표

FP32와 FP64는 처음부터 있었다. 과학 계산용 GPU가 원래 그런 물건이었으니까. 그런데 그 뒤로 늘어난 건 전부 “더 짧은” 쪽이다.

세대 새로 지원한 포맷 무엇을 노렸나
Pascal (2016) INT8 양자화된 추론
Volta (2017) FP16 (Tensor Core) 학습 속도
Turing (2018) INT4 더 공격적인 추론 압축
Ampere (2020) TF32 / BF16 학습 안정성
Hopper (2022) FP8 Transformer 전용 엔진
Blackwell Ultra (2025) FP6 / NVFP4 초저정밀 LLM

여기서 BF16이 특히 재밌다. FP16은 지수가 5비트뿐이라 학습 중에 값이 표현 범위를 넘거나 0으로 주저앉기 쉽다. 그래서 구글이 만든 게 FP32의 지수 8비트는 그대로 두고 가수만 잘라낸 BF16이다.

범위는 유지하고 정밀도만 버렸다. 학습에서 중요한 건 정밀도가 아니라 값이 안 넘치는 것이라는 판단이 포맷 설계에 그대로 박혀 있는 셈이다.

숫자를 어떻게 담을지가 하드웨어 로드맵을 결정하고 있었다. 이걸 모르고 경량화 기법만 외웠으면 “INT8이 좋대”에서 끝났을 것 같다..


정리

  • 경량화는 결국 숫자를 짧게 쓰는 일이다. FP32 → INT8이면 모델 크기가 4분의 1이 된다
  • 2의 보수를 쓰는 이유는 예쁘게 적기 위해서가 아니라 덧셈기 하나로 뺄셈까지 처리하기 위해서다. 0이 하나뿐이라는 것도 덤
  • 비트 폭을 늘릴 땐 부호 있는 수는 부호 확장, 비트 패턴을 지켜야 하면 제로 확장
  • 고정소수점은 소수점 위치를 저장하지 않는다. 덧셈은 그냥 맞지만 곱셈·나눗셈은 배율 보정이 필요하다
  • 부동소수점은 지수와 가수로 나눠 담는다. 정규화로 표현을 하나로 못 박고, 바이어스 지수로 대소 비교를 부호 없는 정수처럼 만든다
  • IEEE 754는 hidden bit로 1비트를 아끼고, bias를 127로 잡아 범위를 한 칸 더 벌었다. 0, ±∞, NaN, 비정규화 수는 전부 약속된 비트 패턴이다
  • 연산 비용은 덧셈 ≪ 곱셈 ≪ 나눗셈이고, 부동소수점은 정규화·예외 확인 단계가 더 붙어 정수보다 훨씬 비싸다
  • 그래서 GPU는 세대마다 더 짧은 포맷을 늘려왔다. BF16은 범위를 지키고 정밀도를 버린 선택이었다

참고 자료

한줄 평

숫자 하나 담는 데 이런 고민이 들어가 있는 줄 몰랐다. 이름만 알고 넘어갔던 2의 보수가 LLM 경량화의 밑바닥이었다는 게 제일 신기했다!