실행코드
import torch
import torch.nn as nn
import torch.onnx
import onnx
import onnxruntime
import numpy as np
import os
import time
def print_section(title):
print(f"\n{'='*10} {title} {'='*10}")
print_section("1. 사라진 숫자를 찾아라 (FP32 vs FP16)")
small_val = 0.0001
iterations = 10000
expected_value = 1.0
total_fp32 = torch.tensor(0.0, dtype=torch.float32)
for _ in range(iterations):
total_fp32 += small_val
total_fp16 = torch.tensor(0.0, dtype=torch.float16)
for _ in range(iterations):
total_fp16 += torch.tensor(small_val, dtype=torch.float16)
print(f"목표값: {expected_value}")
print(f"FP32 결과: {total_fp32.item():.6f}")
print(f"FP16 결과: {total_fp16.item():.6f}")
print_section("2. 내 모델은 몇 MB일까? (파라미터 계산)")
conv_layer = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, bias=True)
num_params = sum(p.numel() for p in conv_layer.parameters())
cal_params = (3 * 3 * 3 * 64) + 64
print(f"계산된 파라미터 수 (이론): {cal_params})")
print(f"실제 파라미터 수 (코드): {num_params}")
print(f"FP32 예상 용량: {num_params * 4} bytes")
print(f"INT8 예상 용량: {num_params * 4} bytes")
print_section("3. ONNX로 이사 가자 (모델 변환)")
class TinyModel(nn.Module):
def __init__(self):
super().__init__()
self.linear1 = nn.Linear(10,20)
self.relu = nn.ReLU()
self.linear2 = nn.Linear(20,2)
def forward(self, x):
return self.linear2(self.relu(self.linear1(x)))
model = TinyModel()
model.eval()
dummy_input = torch.randn(1,10)
onnx_file_path = "tiny_model.onnx"
torch.onnx.export(
model,
dummy_input,
onnx_file_path,
input_names=['x'],
output_names=['y'],
opset_version=17,
dynamic_axes={'x': {0: 'batch_size'}, 'y': {0: 'batch_size'}}
)
print(f"모델 저장 완료: {onnx_file_path}")
onnx_model = onnx.load(onnx_file_path)
onnx.checker.check_model(onnx_model)
print("ONNX 모델 구조 검증(check_model) 완료: 이상 없음")
print_section("4. 누가 더 빠를까? (PyTorch vs ONNX Runtime)")
batch_size = 64
input_tensor = torch.randn(batch_size, 10)
input_numpy = input_tensor.numpy()
start_time = time.time()
with torch.no_grad():
for _ in range(1000):
_ = model(input_tensor)
torch_time = (time.time() - start_time) * 1000
ort_session = onnxruntime.InferenceSession(onnx_file_path, providers=['CPUExecutionProvider'])
ort_inputs = {ort_session.get_inputs()[0].name: input_numpy}
start_time = time.time()
for _ in range(1000):
_ = ort_session.run(None, ort_inputs)
onnx_time = (time.time() - start_time) * 1000
print(f"PyTorch 소요 시간: {torch_time:.2f} ms")
print(f"ONNX 소요 시간: {onnx_time:.2f} ms")
print(f"속도 향상: {torch_time / onnx_time:.2f}배")
if os.path.exists(onnx_file_path):
os.remove(onnx_file_path)
결과
(base) PS C:\Users\forex\PycharmProjects\Quantization_Practice> python main.py
========== 1. 사라진 숫자를 찾아라 (FP32 vs FP16) ==========
목표값: 1.0
FP32 결과: 1.000054
FP16 결과: 0.250000
========== 2. 내 모델은 몇 MB일까? (파라미터 계산) ==========
계산된 파라미터 수 (이론): 1792)
실제 파라미터 수 (코드): 1792
FP32 예상 용량: 7168 bytes
INT8 예상 용량: 7168 bytes
========== 3. ONNX로 이사 가자 (모델 변환) ==========
C:\Users\forex\PycharmProjects\Quantization_Practice\main.py:60: UserWarning: # 'dynamic_axes' is not recommended when dynamo=True, and may lead to 'torch._dynamo.exc.UserError: Constraints violated.' Supply the 'dynamic_shapes' argument instead if export is unsuccessful.
torch.onnx.export(
W0124 17:55:26.019000 31528 site-packages\torch\onnx\_internal\exporter\_compat.py:114] Setting ONNX exporter to use operator set version 18 because the requested opset_version 17 is a lower version than we have implementations for. Automatic version conversion will be performed, which may not be successful at converting to the requested version. If version conversion is unsuccessful, the opset version of the exported model will be kept at 18. Please consider setting opset_version >=18 to leverage latest ONNX features
W0124 17:55:26.627000 31528 site-packages\torch\onnx\_internal\exporter\_registration.py:107] torchvision is not installed. Skipping torchvision::nms
[torch.onnx] Obtain model graph for `TinyModel([...]` with `torch.export.export(..., strict=False)`...
[torch.onnx] Obtain model graph for `TinyModel([...]` with `torch.export.export(..., strict=False)`... ✅
[torch.onnx] Run decomposition...
[torch.onnx] Run decomposition... ✅
[torch.onnx] Translate the graph into ONNX...
[torch.onnx] Translate the graph into ONNX... ✅
The model version conversion is not supported by the onnxscript version converter and fallback is enabled. The model will be converted using the onnx C API (target version: 17).
모델 저장 완료: tiny_model.onnx
ONNX 모델 구조 검증(check_model) 완료: 이상 없음
========== 4. 누가 더 빠를까? (PyTorch vs ONNX Runtime) ==========
PyTorch 소요 시간: 39.65 ms
ONNX 소요 시간: 24.11 ms
속도 향상: 1.64배
코드 결과 분석
1. FP32 vs FP16 (사라진 숫자의 비밀)
"FP32는 $2^{32}$승, FP16은 $2^{16}$승 같은 느낌인가?"
비슷하지만, 정확히는 **"숫자를 담는 그릇의 크기(비트 수)"**입니다.
- FP32 (32-bit Floating Point): 숫자 하나를 저장하는 데 0과 1이 32개 필요합니다. 아주 정밀하고 큰 숫자도 담을 수 있습니다.
- FP16 (16-bit Floating Point): 숫자 하나에 0과 1이 16개만 필요합니다. 용량은 절반이지만, 표현할 수 있는 **정밀도(섬세함)**가 떨어집니다.
실습 결과 해석 (0.25가 나온 이유): FP16이라는 '작은 그릇'은 1.0과 1.0 + 0.0001을 구별하지 못합니다. 너무 작은 수(0.0001)를 더하려고 하면 "에이, 너무 작아서 안 보여!" 하고 무시해버린 것입니다. 이를 Underflow라고 합니다.
2. INT8 예상 용량이 왜 똑같이 나왔을까? (중요 체크!)
"INT8 예상 용량: 7168 bytes로 똑같이 나왔어"
여기가 중요한 포인트입니다. 코드 작성 시 오타가 있었거나 계산이 잘못 출력된 것 같습니다.
- 파라미터 수(개수): 1,792개 (이건 변하지 않습니다. 사람 수는 그대로!)
- FP32 용량: $1,792 \times 4$바이트 = 7,168 bytes (맞음)
- INT8 용량: $1,792 \times 1$바이트 = 1,792 bytes (가 되어야 함!)
로그에는 7168로 찍혔지만, 실제 INT8(양자화)을 하면 용량은 무조건 1/4로 줄어야 정상입니다. 아마 출력 코드 부분에 * 4가 그대로 들어가 있었을 확률이 높습니다.
실제로 코드에서 *4로 잘못 작성해서 수정했습니다
3. ONNX 파일에는 뭐가 들어있을까?
"tiny_model.onnx에 fp32랑 fp16이 다 들어있는 건가?"
아니요, 지금 만드신 ONNX 파일은 FP32 상태 그대로입니다.
- 우리는 모델을 INT8로 변환하는 코드를 짜긴 했지만(이론 계산), torch.onnx.export를 할 때 넣은 모델(model)은 FP32 원본 모델이었습니다.
- 그래서 속도가 빨라진 이유는 "양자화(용량 줄이기)" 때문이 아니라, **ONNX Runtime이라는 엔진이 PyTorch보다 수학 계산을 더 효율적으로 했기 때문(최적화)**입니다.
4. 성능(정확도) 테스트는 안 해도 되나?
"속도는 1.64배 빨라졌는데, 성능 테스트는 따로 안 해도 되나?"
매우 날카로운 질문입니다. 반드시 해야 합니다.
- 단순 포맷 변환 (PyTorch → ONNX FP32):
- 이번 실습처럼 포맷만 바꾼 경우, 결과값은 99.999% 똑같습니다. (소수점 아주 아래 미세한 차이만 있음).
- 그래서 보통은 "잘 돌아가는지"만 확인하면 됩니다.
- 양자화 (FP32 → INT8):
- 만약 나중에 INT8로 줄이게 되면, 화질이 떨어지듯 정확도가 떨어질 수 있습니다. 이때는 반드시 정확도 검증을 해야 합니다.
[간단 확인법] 아래 코드를 실행해서 두 모델의 결과값이 얼마나 비슷한지 확인해보겠습니다.
is_same = np.allclose(
model(input_tensor).detach().numpy(), # PyTorch 결과
ort_session.run(None, ort_inputs)[0], # ONNX 결과
rtol=1e-03, atol=1e-05
)
print(f"두 모델의 결과값이 동일한가요? -> {is_same}")
두 모델의 결과값이 동일한가요? -> True
1. 왜 INT8 계산을한건가?
이번 실습(Part 2)에서 INT8 용량을 계산해 본 것은 **"미리보기(이론 학습)"**였습니다.
- Part 2 (계산기): "만약 우리가 나중에 이걸 INT8로 줄인다면 용량이 1/4로 줄어들 거야! 대박이지?"라고 잠재력을 확인하는 단계였습니다.
- Part 3 (실전): 실제로 수행한 건 **모델의 포맷만 변경(PyTorch → ONNX)**하는 작업이었습니다. 아직 살을 빼는 다이어트(양자화)는 안 했습니다.
2. ONNX와 INT8의 명확한 차이
두 가지는 서로 다른 최적화 방법입니다.
| 구분 | ONNX 변환 (Part 3에서 한 것) | INT8 양자화 (Part 2에서 계산만 한 것) |
| 비유 | 번역 (Language) | 압축 (Compression) |
| 내용 | 한국어 책을 영어로 번역함 | 책의 내용을 요약해서 페이지를 줄임 |
| 정밀도 | 내용(FP32)은 100% 똑같음 | 내용(INT8)이 조금 뭉개질 수 있음 |
| 목적 | 어디서든 호환되고 실행 속도 높이기 | 용량 줄이고 메모리 아끼기 |
| 결과 | 파일 크기 거의 그대로 (7KB) | 파일 크기 1/4로 감소 (1.7KB) |
즉, 작성하신 코드의 흐름은 다음과 같습니다.
- FP32 모델 생성 (PyTorch)
- 이론상 INT8로 줄이면 얼마나 될까? (계산해봄 -> 1.7KB 예상)
- 일단 포맷만 바꿔보자! (ONNX 변환 -> 여전히 FP32, 7KB)
- 속도 비교 (ONNX가 엔진이 좋아서 FP32임에도 빨랐음!)
fp32_model_path = "tiny_model.onnx"
preprocessed_model_path = "tiny_model.pre.onnx"
int8_model_path = "tiny_model.int8.onnx"
shape_inference.quant_pre_process(
input_model_path=fp32_model_path,
output_model_path=preprocessed_model_path,
skip_symbolic_shape=False
)
quantize_dynamic(
model_input=preprocessed_model_path,
model_output=int8_model_path,
weight_type=QuantType.QUInt8
)
print(f"양자화 완료 생성된 파일: {int8_model_path}")
fp32_size = os.path.getsize(fp32_model_path)
int8_size = os.path.getsize(int8_model_path)
print(f"\n[용량 비교]")
print(f"FP32 모델 크기: {fp32_size} bytes")
print(f"INT8 모델 크기: {int8_size} bytes")
print(f"감소율: {(1 - int8_size/fp32_size)*100:.2f}% (약 {fp32_size/int8_size:.1f}분의 1)")
print(f"\n[정밀도 비교]")
session_fp32 = onnxruntime.InferenceSession(fp32_model_path, providers=["CPUExecutionProvider"])
session_int8 = onnxruntime.InferenceSession(int8_model_path, providers=["CPUExecutionProvider"])
test_input = np.random.rand(1,10).astype(np.float32)
res_fp32 = session_fp32.run(None, {'x': test_input})[0]
res_int8 = session_int8.run(None, {'x': test_input})[0]
print(f"FP32 결과: {res_fp32[0]}")
print(f"INT8 결과: {res_int8[0]}")
mse = np.mean((res_fp32 - res_int8) ** 2)
print(f"평균 오차(MSE): {mse:.10f}")
if mse < 0.01:
print("결론 : 용량은 줄었지만 결과 값은 거의 동일")
else:
print("결론 : 용량은 줄었지만 오차가 다소 큽니다")
if os.path.exists(preprocessed_model_path):
os.remove(preprocessed_model_path)
양자화 완료 생성된 파일: tiny_model.int8.onnx
[용량 비교]
FP32 모델 크기: 962 bytes
INT8 모델 크기: 2482 bytes
감소율: -158.00% (약 0.4분의 1)
[정밀도 비교]
FP32 결과: [0.07805231 0.13584945]
INT8 결과: [0.07880233 0.1366243 ]
평균 오차(MSE): 0.0000005815
결론 : 용량은 줄었지만 결과 값은 거의 동일
로그를 확인해보면 오히려 용량이 더 늘었습니다
하지만 이런현상은
왜 양자화를 했는데 용량이 커졌을까요? (배꼽이 더 큰 상황)
- 배보다 배꼽이 더 크다:
- 우리가 만든 모델(TinyModel)은 파라미터가 고작 1,700개뿐이라 데이터 자체가 몇 바이트 안 됩니다.
- 그런데 양자화를 하려면 **"이 레이어는 어떤 스케일(Scale)로 줄였고, 0점(Zero-point)은 어디다"**라는 **설명서(Metadata)**가 필요합니다.
- 모델 데이터(살)는 줄어들었지만, 설명서(옷)의 무게가 더해져서 전체 무게가 늘어난 것입니다.
- 실전 모델에서는?:
- 파라미터가 수천만 개인 진짜 모델(ResNet, BERT 등)에서는 설명서 무게는 티도 안 나고, 데이터가 엄청나게 줄어들기 때문에 무조건 용량이 1/4로 줄어듭니다.
결과 분석: 아주 성공적입니다!
비록 용량은 (너무 작은 모델이라) 늘어났지만, 기술적으로는 완벽하게 성공했습니다.
- 정밀도(Accuracy):
- 평균 오차(MSE)가 0.0000005815입니다.
- 0.0780 vs 0.0788 → 소수점 셋째 자리까지 거의 비슷하죠? 양자화가 아주 예쁘게 잘 되었다는 증거입니다
'AI 엔지니어준비' 카테고리의 다른 글
| 현업에서 많이 사용하는 양자화 기법에 대해 (0) | 2026.01.24 |
|---|---|
| 성능 보존이 잘된다면 int8, onnx화는 반드시 필요한가? (0) | 2026.01.24 |
| 🧩 PyTorch로 배우는 양자화: QuantizableCNN 완전 정복! (0) | 2026.01.23 |
| 📉 INT8 vs INT4: AI 모델, 얼마나 더 가볍게 만들까? 🤔 (0) | 2026.01.23 |
| (PyTorch/TF)도 triton을 사용하면성능이 올라가나? (0) | 2026.01.20 |