
HyperCLOVAX 1.5B를 Colab에서 ONNX로 내보내고, 거기에 INT8 / Q4 양자화를 얹어서 로컬·서버에서 가볍게 돌려보는 것이 목표였다.
ONNX Runtime의 경우 Intel, AMD, Nvidia 등 다양한 환경에서 Provider를 통해 최적화 및 구동이 가능하다. 따라서 한국어에 특화된 모델을 ONNX로 변환하고자 했다.
초기 설계는 다음과 같다.
- GPU 기반 ONNX export
- 정적(static) INT8 양자화 (한국어 위키로 calibration)
- 정적(static) Q4 양자화
그런데 실제로 돌려보니 메모리 초과 + 최적화 문제가 계속 발목을 잡았다.
결국 파이프라인을 통째로 갈아엎고, GPU를 적극적으로 쓰는 ONNX export + dynamic 양자화 중심 설계로 재구성했다.
처음에는 현재 개발중인 Local Agent를 위해 성능 손실은 적지만 강력하게 VRAM 사용량을 줄이기 위해 Static 양자화를 적용할 예정이었다.
그런데 Colab + HyperCLOVAX 1.5B 환경에서 실제로 부딪힌 문제들이 있었다.
- Satic 양자화:
- libration 데이터셋 로딩
- ONNX 모델 로딩
- activation range 수집용 forward 여러 번 동시에 돌아가면서 CPU 메모리도 많이 쓰고, 경우에 따라 GPU 메모리도 크게 쓴다.
- Colab 환경(특히 무료 T4 + 12~16GB RAM 기준)
- 모델 사이즈(1.5B)
- sequence length
- calibration 샘플 수를 조금만 욕심 내면 OOM이 나기 딱 좋다.
한국어 위키를 calibration용으로 쓰면서 정적 INT8을 돌리려다 메모리 한계에 부딫혔다.
실험 한 번 돌릴 때마다 런타임 리셋이 발생하여 더이상 진행이 불가능했다.
정적 양자화는 잘 되면 성능/효율이 좋은 대신, 설계 단에서 고려해야 할 게 많다.
- calibration 데이터셋 구성 (언어·도메인·길이)
- num_samples, max_length 등 튜닝
- 어떤 연산자만 양자화할지(operators_to_quantize) 선정
- PPL(Perplexity) 비교로 품질 검증
- OOM 나면 다시 세팅 줄이고 처음부터 반복…
Colab 같은 휘발성 환경에서는 이 복잡도가 상당히 부담으로 다가온다고 느꼈다. 또한 어느정도 정확도를 포기하게 되더라도 현재 가진 자원에서는 Dynamic 양자화가 최선이라고 판단된다.
| 항목 | 정적 양자화 (Static) | 동적 양자화 (Dynamic) |
|---|---|---|
| Calibration 필요? | 필요 (데이터셋 필수) | 필요 없음 |
| 정확도(품질) 손실 수준 | ★ 최소화 가능 (튜닝 시 FP16에 매우 근접) | ★ 중간 (잘 되면 괜찮지만 편차 있음) |
| 속도/효율(추론 지연) | ★ 최고 (AVX2/VNNI 등 CPU 최적화 최대 활용) | 중간 (activation scale 동적 계산 오버헤드) |
| 메모리 절감 효과 | 매우 큼 | 큼 (하지만 static보다 덜함) |
| 적용 난이도 | 높음 (설치/데이터/calibration 필요) | 매우 쉬움 (함수 한 번 호출) |
| 사용 추천 환경 | 서버/배포/실서비스 | Colab, 프로토타입, 실험, 모델 탐색 |
| LLM(Decoder-only) 안정성 | 매우 안정적 | 안정적이나 성능 변동 가능 |
| ONNX 지원 | 강함 (QDQ 기반 정식 지원) | 매우 강함 (가장 잘 지원되는 방식) |
| 4bit(Q4) 적용 가능성 | 낮음 (실전에서는 weight-only PTQ 사용) | 높음 (PyTorch bitsandbytes/Unsloth 기반) |
최종적으로 가져갈 구조는 다음과 같이 변경했다.
- Colab 세팅 + GPU 확인
- GPU 기반 ONNX FP16 export (optimum-cli)
- --device cuda, --dtype fp16, --batch_size 1, --sequence_length 512, --monolith
- FP16 ONNX → INT8 dynamic 양자화 (ONNX Runtime)
- quantize_dynamic 사용, calibration 없음
- Q4(4bit) dynamic 양자화는 PyTorch 쪽에서 별도 라인
- bitsandbytes + (필요하면 Unsloth 스타일 선별적 4bit)
- 두 모델(INT8 ONNX, Q4 PyTorch) 비교
- 품질(PPL / 체감) + 속도(Tokens/s) + 메모리 비교
이와 같이 설계하여, Local Agent 프로젝트에 어울리는 모델을 생성하고자 했다.
HyperCLOVAX 1.5B fp16 로딩은 대략 3~4GB 수준
ONNX export는 이 위에 그래프 생성 + 검증까지 올라가서 더 먹는다
그래서 --batch_size 1, --sequence_length 512, --dtype fp16 으로 최대한 VRAM을 억제해야 했다.
ONNX 단일 파일은 2GB 제한
Optimum는 자동으로 external data format으로 쪼개주기 때문에
폴더 단위 export 구조만 지켜주면 큰 문제는 없다.
PyTorch ONNX export에서 constant folding이 GPU/CPU 섞여서 돌아가다 에러가 나는 사례가 종종 있다.
그래서 optimum-cli export onnx 단계에서
--no-constant-folding 옵션을 켜서 불안정성을 줄이려 했다.
ONNX Runtime 공식 문서는 “Transformer는 동적 양자화 추천”이라는 뉘앙스를 꽤 강하게 준다.
정적 양자화는 잘 튜닝하면 빠르고 좋지만, Colab 같은 제한된 환경 + 1.5B 모델 + 한국어 데이터셋 calibration 조합에서는 메모리와 개발 비용이 너무 크다는 걸 직접 체감했다.
그래서 이번 파이프라인은 처음부터 dynamic 양자화 전제로 설계했다.
Colab환경에서 T4(15GB VRAM)을 활용하여 ONNX변환과 양자화를 진행했다.
이제 본격적으로 GPU를 사용해서 ONNX export를 한다.
!optimum-cli export onnx \
--model naver-hyperclovax/HyperCLOVAX-SEED-Text-Instruct-1.5B \
--task text-generation \
--device cuda \
--dtype fp16 \
--batch_size 1 \
--sequence_length 512 \
--monolith \
--no-constant-folding \
onnx_fp16_hyperclova
옵션 설명
--device cuda
- 모델과 더미 입력을 GPU에 올려 export 수행
- CPU보다 훨씬 빠르게 변환 가능
--dtype fp16
fp16으로 export → VRAM/디스크 둘 다 절약, export 속도도 빨라짐
--batch_size 1, --sequence_length 512
그래프의 기본 input shape를 작게 잡아 메모리 사용을 줄인다
--monolith
CausalLM을 단일 model.onnx로 export
--no-constant-folding
GPU export 시 자주 문제 되는 constant folding 이슈 회피
성공하면 onnx_fp16_hyperclova/ 폴더에 model.onnx, config.json 등 메타 파일이 생성된다.
⚠️ 만약 여기서 CUDA OOM이 나면,
--sequence_length를 256으로 더 줄이거나 Colab 런타임 재시작 후 다시 시도.
그래도 안 되면 최후의 수단으로 --device cpu (느리지만 안전)으로 시도한다.
이제 static 대신 dynamic INT8로 간다.
장점:
- calibration 데이터셋이 필요 없다
- 코드가 단순하다
- 메모리 부담이 훨씬 적다
- Transformer 계열에서 품질 손실도 상대적으로 안정적이라는 보고가 많다
(앞에서 이미 onnxruntime를 설치했으면 생략 가능)
!pip install -q onnx onnxruntime onnxruntime-tools
from onnxruntime.quantization import quantize_dynamic, QuantType
fp16_model_path = "onnx_fp16_hyperclova/model.onnx"
int8_dynamic_path = "onnx_int8_dynamic_hyperclova/model.onnx"
quantize_dynamic(
model_input=fp16_model_path,
model_output=int8_dynamic_path,
op_types_to_quantize=["MatMul", "Gemm"], # LLM 핵심 연산만
weight_type=QuantType.QInt8,
per_channel=True, # weight per-channel → 정확도에 유리
reduce_range=True, # 일부 모델에서 안정성/범위에 도움
)
print("Dynamic INT8 model saved to:", int8_dynamic_path)
여기서 static에서 dynamic으로 갈아탄 핵심 이유가 드러난다.
| 구분 | static | dynamic |
|---|---|---|
| 과정 | calibration dataset 준비 | 한 줄로 끝난다 |
| 단계 | fit/quantize 두 단계 | - |
| 반복 | OOM/튜닝 반복 필요 | - |
| calibration 필요 여부 | 필요함 | 필요 없음 |
| Colab 메모리 친화성 | 낮음 | 높음 |
성능(속도)은 static보다 살짝 손해일 수 있지만,
이번 목표는 “실험과 검증을 많이 돌리는 것”이기 때문에 개발 효율 관점에서 dynamic이 훨씬 이득이었다.
import onnxruntime as ort
from transformers import AutoTokenizer
import numpy as np
int8_model_path = "onnx_int8_dynamic_hyperclova/model.onnx"
tokenizer = AutoTokenizer.from_pretrained(
"naver-hyperclovax/HyperCLOVAX-SEED-Text-Instruct-1.5B"
)
sess_options = ort.SessionOptions()
session_int8 = ort.InferenceSession(
int8_model_path,
sess_options=sess_options,
providers=["CPUExecutionProvider"], # dynamic INT8는 CPU에서 효과 좋음
)
def run_int8(prompt: str):
inputs = tokenizer(prompt, return_tensors="np")
ort_inputs = {
"input_ids": inputs["input_ids"].astype(np.int64),
"attention_mask": inputs["attention_mask"].astype(np.int64),
}
logits = session_int8.run(["logits"], ort_inputs)[0]
return logits
logits = run_int8("서울시 행정 업무에서 HyperCLOVAX를 활용할 때의 장점은")
print("INT8 dynamic logits shape:", logits.shape)
여기까지가 INT8 dynamic ONNX 파이프라인의 최소 단위다.
4bit(Q4)는 이야기가 조금 다르다.
ONNX / ONNX Runtime 쪽의 4bit 지원은 아직 decoder-only LLM에서 정확도 드랍이 크거나
그래프 에러/추론 실패 리포트가 꽤 있다.
반면 PyTorch 쪽에서는 bitsandbytes 기반 4bit (NF4/FP4) 가 이미 많이 쓰이고
Unsloth 같은 라이브러리는 일부 레이어만 선별적으로 4bit로 내리는 dynamic 4bit로 FP16에 근접한 성능을 보여주고 있다.
그래서 실전에서 쓸만한 Q4 dynamic을 지금 시점에 구현하려면 ONNX보다는 PyTorch 라인으로 가는 게 훨씬 현실적이라고 판단했다.
설정 예시는 대략 다음과 같다.
!pip install -q "transformers>=4.44.0" accelerate bitsandbytes
!pip install -q unsloth
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
model_id = "naver-hyperclovax/HyperCLOVAX-SEED-Text-Instruct-1.5B"
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # QLoRA에서 자주 쓰는 NF4
bnb_4bit_compute_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained(model_id)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model_4bit = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto",
torch_dtype=torch.bfloat16,
)
model_4bit.eval()
여기에 “중요 레이어는 4bit에서 제외” 하는 식으로 Unsloth 스타일을 일부 가져올 수 있다.
예를 들어(개념 코드):
with torch.no_grad():
# 임베딩과 lm_head는 bf16 유지 (민감한 부분 보호)
model_4bit.get_input_embeddings().weight.data = \
model_4bit.get_input_embeddings().weight.data.to(torch.bfloat16)
model_4bit.lm_head.weight.data = \
model_4bit.lm_head.weight.data.to(torch.bfloat16)
이렇게 하면 다음과 같은 구조가 되면서
Q4의 장점(메모리/속도) + FP16에 가까운 품질을 동시에 노릴 수 있다.
마지막으로, 이번에 static 양자화에서 dynamic으로 전환한 이유를 정리해본다.
Static INT8 + calibration + 1.5B + 한국어 위키 + Colab
→ RAM, VRAM 모두 한계치에 자주 닿음
Dynamic INT8
→ calibration이 없고, 한 번의 변환으로 끝
→ 메모리 피크도 더 낮다
Q4도 ONNX static/dynamic 대신 PyTorch 4bit 라인으로 가져오면서
GPU VRAM을 예측 가능한 범위 안에 묶어둘 수 있게 됐다.
Static:
데이터셋 설계, calibration 튜닝, 에러/튜닝 반복,
Colab 런타임 리셋 반복…
Dynamic:
INT8은 quantize_dynamic 한 줄
Q4는 bitsandbytes/Unsloth 쪽 레시피 활용
설계와 디버깅에 쓰던 시간을 실제 실험, 비교, 분석에 투입할 수 있게 됐다.
GPU를 적극 활용한 ONNX FP16 export (optimum-cli) 그 위에서
INT8 → ONNX Runtime dynamic quantization
Q4 → PyTorch bitsandbytes 기반 dynamic 4bit (선별적 양자화)
Static INT8은 메모리 초과와 설계 복잡도 때문에 이번 목적에는 맞지 않는다고 판단했고,
dynamic 양자화로 갈아탄 덕분에 Colab 환경에서도 훨씬 안정적으로 실험을 반복할 수 있게 됐다.
다음 글에서는 이 파이프라인으로 만든 세 가지 버전을 실제로 돌려보면서
토큰 생성 속도, 메모리 사용량, 한국어 데이터 기준 품질(PPL) 을 비교해볼 예정이다.