로컬 VRAM 최적화 - ONNX + INT8/Q4 양자화

Nemo_Nemo·2025년 11월 21일

ONNX

목록 보기
1/3


HyperCLOVAX 1.5B를 Colab에서 ONNX로 내보내고, 거기에 INT8 / Q4 양자화를 얹어서 로컬·서버에서 가볍게 돌려보는 것이 목표였다.

ONNX Runtime의 경우 Intel, AMD, Nvidia 다양한 환경에서 Provider를 통해 최적화 및 구동이 가능하다. 따라서 한국어에 특화된 모델을 ONNX로 변환하고자 했다.

초기 설계는 다음과 같다.

  1. GPU 기반 ONNX export
  2. 정적(static) INT8 양자화 (한국어 위키로 calibration)
  3. 정적(static) Q4 양자화

그런데 실제로 돌려보니 메모리 초과 + 최적화 문제가 계속 발목을 잡았다.
결국 파이프라인을 통째로 갈아엎고, GPU를 적극적으로 쓰는 ONNX export + dynamic 양자화 중심 설계로 재구성했다.

0. Static 양자화에서 Dynamic 양자화로 전환

처음에는 현재 개발중인 Local Agent를 위해 성능 손실은 적지만 강력하게 VRAM 사용량을 줄이기 위해 Static 양자화를 적용할 예정이었다.

그런데 Colab + HyperCLOVAX 1.5B 환경에서 실제로 부딪힌 문제들이 있었다.

0-1. 메모리 초과(OOM) 문제

  • Satic 양자화:
    • libration 데이터셋 로딩
    • ONNX 모델 로딩
    • activation range 수집용 forward 여러 번 동시에 돌아가면서 CPU 메모리도 많이 쓰고, 경우에 따라 GPU 메모리도 크게 쓴다.
  • Colab 환경(특히 무료 T4 + 12~16GB RAM 기준)
    • 모델 사이즈(1.5B)
    • sequence length
    • calibration 샘플 수를 조금만 욕심 내면 OOM이 나기 딱 좋다.

한국어 위키를 calibration용으로 쓰면서 정적 INT8을 돌리려다 메모리 한계에 부딫혔다.
실험 한 번 돌릴 때마다 런타임 리셋이 발생하여 더이상 진행이 불가능했다.

0-2. 최적화(개발 생산성) 문제

정적 양자화는 잘 되면 성능/효율이 좋은 대신, 설계 단에서 고려해야 할 게 많다.

  • calibration 데이터셋 구성 (언어·도메인·길이)
    • num_samples, max_length 등 튜닝
    • 어떤 연산자만 양자화할지(operators_to_quantize) 선정
    • PPL(Perplexity) 비교로 품질 검증
    • OOM 나면 다시 세팅 줄이고 처음부터 반복…

Colab 같은 휘발성 환경에서는 이 복잡도가 상당히 부담으로 다가온다고 느꼈다. 또한 어느정도 정확도를 포기하게 되더라도 현재 가진 자원에서는 Dynamic 양자화가 최선이라고 판단된다.

0-3. Static과 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 기반)



1. 최종 설계

최종적으로 가져갈 구조는 다음과 같이 변경했다.

  1. Colab 세팅 + GPU 확인
  2. GPU 기반 ONNX FP16 export (optimum-cli)
    • --device cuda, --dtype fp16, --batch_size 1, --sequence_length 512, --monolith
  3. FP16 ONNX → INT8 dynamic 양자화 (ONNX Runtime)
    • quantize_dynamic 사용, calibration 없음
  4. Q4(4bit) dynamic 양자화는 PyTorch 쪽에서 별도 라인
    • bitsandbytes + (필요하면 Unsloth 스타일 선별적 4bit)
  5. 두 모델(INT8 ONNX, Q4 PyTorch) 비교
    • 품질(PPL / 체감) + 속도(Tokens/s) + 메모리 비교

이와 같이 설계하여, Local Agent 프로젝트에 어울리는 모델을 생성하고자 했다.



2. Colab에서 미리 알고 가야 할 문제 포인트

2-1. GPU 메모리 (T4 16GB 가정)

HyperCLOVAX 1.5B fp16 로딩은 대략 3~4GB 수준
ONNX export는 이 위에 그래프 생성 + 검증까지 올라가서 더 먹는다
그래서 --batch_size 1, --sequence_length 512, --dtype fp16 으로 최대한 VRAM을 억제해야 했다.

2-2. ONNX 2GB 제한 + external data

ONNX 단일 파일은 2GB 제한
Optimum는 자동으로 external data format으로 쪼개주기 때문에
폴더 단위 export 구조만 지켜주면 큰 문제는 없다.

2-3. GPU export 시 constant folding 이슈

PyTorch ONNX export에서 constant folding이 GPU/CPU 섞여서 돌아가다 에러가 나는 사례가 종종 있다.
그래서 optimum-cli export onnx 단계에서
--no-constant-folding 옵션을 켜서 불안정성을 줄이려 했다.

2-4. Static quant vs Transformer

ONNX Runtime 공식 문서는 “Transformer는 동적 양자화 추천”이라는 뉘앙스를 꽤 강하게 준다.
정적 양자화는 잘 튜닝하면 빠르고 좋지만, Colab 같은 제한된 환경 + 1.5B 모델 + 한국어 데이터셋 calibration 조합에서는 메모리와 개발 비용이 너무 크다는 걸 직접 체감했다.
그래서 이번 파이프라인은 처음부터 dynamic 양자화 전제로 설계했다.



3. ONNX 변환과 Dynamic 양자화

Colab환경에서 T4(15GB VRAM)을 활용하여 ONNX변환과 양자화를 진행했다.

1단계 – GPU 기반 ONNX FP16 export (optimum-cli)

이제 본격적으로 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 (느리지만 안전)으로 시도한다.

2단계 – FP16 ONNX → INT8 Dynamic 양자화 (ONNX Runtime)

이제 static 대신 dynamic INT8로 간다.

장점:

  • calibration 데이터셋이 필요 없다
  • 코드가 단순하다
  • 메모리 부담이 훨씬 적다
  • Transformer 계열에서 품질 손실도 상대적으로 안정적이라는 보고가 많다

2-1. onnxruntime.quantization 설치 확인

(앞에서 이미 onnxruntime를 설치했으면 생략 가능)

!pip install -q onnx onnxruntime onnxruntime-tools

2-2. quantize_dynamic으로 INT8 모델 생성

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으로 갈아탄 핵심 이유가 드러난다.

구분staticdynamic
과정calibration dataset 준비한 줄로 끝난다
단계fit/quantize 두 단계-
반복OOM/튜닝 반복 필요-
calibration 필요 여부필요함필요 없음
Colab 메모리 친화성낮음높음

성능(속도)은 static보다 살짝 손해일 수 있지만,
이번 목표는 “실험과 검증을 많이 돌리는 것”이기 때문에 개발 효율 관점에서 dynamic이 훨씬 이득이었다.

2-3. INT8 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 파이프라인의 최소 단위다.


3단계 – Q4(4bit) dynamic 양자화는 PyTorch 라인으로 분리

4bit(Q4)는 이야기가 조금 다르다.

ONNX / ONNX Runtime 쪽의 4bit 지원은 아직 decoder-only LLM에서 정확도 드랍이 크거나
그래프 에러/추론 실패 리포트가 꽤 있다.

반면 PyTorch 쪽에서는 bitsandbytes 기반 4bit (NF4/FP4) 가 이미 많이 쓰이고
Unsloth 같은 라이브러리는 일부 레이어만 선별적으로 4bit로 내리는 dynamic 4bit로 FP16에 근접한 성능을 보여주고 있다.

그래서 실전에서 쓸만한 Q4 dynamic을 지금 시점에 구현하려면 ONNX보다는 PyTorch 라인으로 가는 게 훨씬 현실적이라고 판단했다.

3-1. bitsandbytes + Unsloth 스타일 4bit 개념

설정 예시는 대략 다음과 같다.

!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에 가까운 품질을 동시에 노릴 수 있다.

  • Linear 레이어 → 4bit
  • 임베딩/출력 헤드 등 표현력에 민감한 부분 → bf16 유지

4. Static → Dynamic 전환 정리

마지막으로, 이번에 static 양자화에서 dynamic으로 전환한 이유를 정리해본다.

4-1. 메모리 측면

Static INT8 + calibration + 1.5B + 한국어 위키 + Colab
→ RAM, VRAM 모두 한계치에 자주 닿음

Dynamic INT8
→ calibration이 없고, 한 번의 변환으로 끝
→ 메모리 피크도 더 낮다

Q4도 ONNX static/dynamic 대신 PyTorch 4bit 라인으로 가져오면서
GPU VRAM을 예측 가능한 범위 안에 묶어둘 수 있게 됐다.

4-2. 최적화(개발 생산성) 측면

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) 을 비교해볼 예정이다.

0개의 댓글