- Published on
ONNX Runtime로 LLM 4bit 양자화해 속도 2배
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 비용을 줄이면서도 응답 속도를 끌어올리는 가장 확실한 방법 중 하나가 양자화입니다. 특히 LLM은 파라미터가 크고 메모리 대역폭에 민감해서, int4 수준의 4bit 양자화가 잘 먹히면 VRAM·RAM 사용량을 크게 줄이면서 실측 지연 시간을 눈에 띄게 낮출 수 있습니다.
이 글에서는 ONNX Runtime(이하 ORT)을 기준으로 LLM을 4bit로 양자화하고, 실제 추론 파이프라인에서 속도를 2배 수준까지 끌어올릴 때 필요한 절차를 정리합니다. 단순히 “양자화하면 빨라진다”가 아니라, 어떤 설정에서 빨라지고 어떤 지점에서 병목이 생기는지, 그리고 품질 저하를 어떻게 통제하는지에 초점을 맞춥니다.
왜 4bit가 LLM에서 특히 효과적인가
LLM 추론은 대체로 다음 비용으로 구성됩니다.
- 가중치 로딩 및 메모리 이동: 레이어별로 큰 가중치를 읽고, 활성값과 곱해 출력으로 내보냄
- GEMM/MatMul 연산: 대규모 행렬곱이 대부분
- KV 캐시: 오토리그레시브 생성에서 토큰이 늘어날수록 누적
이 중에서 많은 환경에서 가장 큰 병목은 연산량보다 메모리 대역폭입니다. 4bit로 가중치가 작아지면
- 같은 대역폭에서 더 많은 가중치를 읽을 수 있고
- 캐시 적중률이 좋아지고
- CPU에서는 L3 캐시/메모리 압박이 줄어
결과적으로 토큰당 지연 시간이 줄어드는 경우가 많습니다.
다만 “무조건 2배”가 보장되는 건 아닙니다. 다음 조건에서 2배에 가까워지기 쉽습니다.
- 배치가 작고(온라인 추론) 메모리 이동이 지배적인 경우
- CPU 추론에서 AVX2/AVX512 + int4 커널이 잘 동작하는 경우
- GPU에서도 int4 커널 경로가 안정적으로 타는 경우(환경 의존)
ONNX Runtime에서의 4bit 양자화 개요
ORT에서 LLM 최적화는 보통 다음 순서로 진행합니다.
- PyTorch 모델을 ONNX로 내보내기
- 그래프 최적화(퓨전, 상수 폴딩 등)
- 양자화(주로 가중치 중심)
- 실행 프로바이더(EP)와 세션 옵션 튜닝
- 품질/성능 검증 및 회귀 방지
LLM에서는 int8보다 int4가 더 공격적이라 품질 리스크가 있지만, 가중치만 4bit로 내리고 활성값은 더 높은 정밀도를 유지하는 방식으로 타협점을 잡는 경우가 많습니다.
준비물과 권장 환경
- Python 3.10 이상
onnxruntime또는onnxruntime-gputransformers,torch,onnx- CPU 추론이면 최신 ORT + MKL/oneDNN 경로가 유리
설치 예시는 다음처럼 시작할 수 있습니다.
pip install -U onnx onnxruntime transformers torch
GPU 사용 시에는 환경에 맞는 onnxruntime-gpu를 설치합니다.
pip install -U onnxruntime-gpu
1) PyTorch에서 ONNX로 내보내기
LLM 전체를 한 번에 ONNX로 뽑는 건 모델 구조, 동적 축, 캐시 처리 때문에 까다롭습니다. 실무에서는 다음 중 하나를 선택합니다.
- 이미 ONNX/ORT 경로가 준비된 모델(또는 예제)을 사용
- 디코더 블록/모듈 단위로 내보내고 래핑
- KV 캐시 입력·출력을 명시해 동적 축을 잡아 내보내기
아래 코드는 “ONNX 내보내기 형태”를 보여주는 최소 예시입니다. 실제로는 모델별로 입력 시그니처가 다릅니다.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "gpt2" # 데모용
model = AutoModelForCausalLM.from_pretrained(model_id)
model.eval()
tok = AutoTokenizer.from_pretrained(model_id)
inputs = tok("hello", return_tensors="pt")
# 데모: 단순 forward만 export (실제 LLM 서빙에는 KV cache 고려 필요)
with torch.no_grad():
torch.onnx.export(
model,
(inputs["input_ids"],),
"model.onnx",
input_names=["input_ids"],
output_names=["logits"],
dynamic_axes={"input_ids": {0: "batch", 1: "seq"}, "logits": {0: "batch", 1: "seq"}},
opset_version=17,
)
핵심은 동적 축을 명확히 지정하는 것입니다. 동적 축이 없으면 시퀀스 길이가 고정되어 실서빙에서 바로 막힙니다.
2) ORT 그래프 최적화: 퓨전이 먼저다
양자화보다 먼저 그래프 최적화를 해두면, 양자화 대상이 줄고 커널 선택이 좋아져 체감 성능이 올라갑니다.
ORT 세션 옵션에서 기본 최적화를 켜는 예시는 다음과 같습니다.
import onnxruntime as ort
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess = ort.InferenceSession("model.onnx", sess_options=so, providers=["CPUExecutionProvider"])
GPU라면 CUDAExecutionProvider를 넣고, 환경에 따라 TensorrtExecutionProvider를 고려할 수 있습니다.
3) 4bit 양자화 전략: 무엇을 4bit로 내릴 것인가
LLM에서 가장 큰 덩어리는 Linear 계열 가중치입니다. 일반적으로 다음 전략이 안전합니다.
- 가중치만 4bit(Weight-only
int4) - 활성값은
fp16또는bf16유지 - 민감한 레이어(예: 임베딩, LM 헤드)는 제외하거나 더 높은 정밀도 유지
이렇게 하면 품질 저하를 줄이면서도 메모리 이득을 크게 얻습니다.
ONNX Runtime 양자화 API 예시
ORT에는 onnxruntime.quantization 모듈이 있고, int8 중심 기능이 널리 쓰이지만 최근에는 weight-only 저비트 경로도 확장되고 있습니다. 버전별로 지원 범위가 달라서, 아래 코드는 “구조를 이해하기 위한 예시”로 보되 실제 적용 시 ORT 버전 문서를 확인해야 합니다.
from onnxruntime.quantization import quantize_dynamic, QuantType
# 단순 예시: 동적 양자화(int8). 4bit는 버전/옵션에 따라 별도 경로가 필요할 수 있음.
quantize_dynamic(
model_input="model.onnx",
model_output="model.int8.onnx",
weight_type=QuantType.QInt8,
)
실제로 4bit를 노린다면 다음을 체크하세요.
- ORT 버전에서
int4weight-only 양자화가 지원되는지 - CPU에서
int4커널이 실제로 선택되는지(프로파일링 필요) - GPU에서
int4경로가 활성화되는지(EP별로 다름)
버전/플랫폼 편차가 크기 때문에, “양자화 파일 생성”보다 “실행 시 커널이 타는지”가 더 중요합니다.
4) 성능 2배를 만드는 실행 옵션 튜닝
4bit 모델을 만들었는데도 속도가 안 나오면 대부분은 다음 중 하나입니다.
- EP가 기대한 커널을 선택하지 못함
- 스레드/메모리 할당 전략이 비효율적
- 토크나이저/후처리/네트워크가 병목
- KV 캐시 관리가 비효율적
CPU: 스레드·메모리 아레나 설정
import onnxruntime as ort
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
# 스레드 튜닝(서버 환경에 맞게 조정)
so.intra_op_num_threads = 8
so.inter_op_num_threads = 1
# 메모리 패턴/아레나 (워크로드에 따라 효과가 갈림)
so.enable_mem_pattern = True
so.enable_cpu_mem_arena = True
sess = ort.InferenceSession(
"model.int4.onnx", # 예시
sess_options=so,
providers=["CPUExecutionProvider"],
)
권장 접근은 “추측 튜닝”이 아니라, 프로파일을 켜서 병목을 확인하고 조정하는 것입니다.
ORT 프로파일링으로 병목 찾기
import onnxruntime as ort
so = ort.SessionOptions()
so.enable_profiling = True
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess = ort.InferenceSession("model.onnx", sess_options=so, providers=["CPUExecutionProvider"])
# ... 추론 몇 번 수행 ...
profile_path = sess.end_profiling()
print("profile:", profile_path)
프로파일 JSON을 보면 MatMul/Attention 관련 노드에서 시간이 많이 쓰이는지, 아니면 예상 밖의 노드(reshape, cast, transpose 등)가 시간을 잡아먹는지 확인할 수 있습니다.
5) 품질 저하를 통제하는 방법
4bit는 품질 리스크가 있으니, “성능만 보고 배포”하면 사고가 납니다. 최소한 아래를 자동화하세요.
- 대표 프롬프트 세트에 대한 정량 평가(정답형이면 정확도)
- 생성형이면 휴리스틱 지표(길이, 반복, 금칙어, JSON 파싱 성공률)
- 회귀 테스트(주요 시나리오 20~50개)
특히 에이전트/툴콜 기반 서비스라면, 양자화로 인해 출력 형식이 흐트러지는 문제가 더 치명적입니다. 이 경우 모델을 더 강하게 “형식 제약”하는 전략이 필요합니다. 예를 들어 JSON 스키마 기반 출력 강제나 툴콜 방어는 아래 글이 실전적으로 도움이 됩니다.
또한 체인 오브 쏘트 형태의 장문 추론이 품질 민감도를 높이기도 합니다. 가능하면 출력 전략을 단순화해 품질 하락을 완충하는 것도 방법입니다.
6) “진짜로 2배”를 확인하는 벤치마크 방법
LLM 성능은 보통 tokens/sec로 봅니다. 하지만 서비스 관점에서는 다음도 같이 봐야 합니다.
- TTFT(Time To First Token): 첫 토큰까지 지연
- TPOT(Time Per Output Token): 출력 토큰당 지연
- 동시성에서의 P95/P99
간단한 측정 코드 예시는 다음과 같습니다.
import time
import numpy as np
import onnxruntime as ort
def run(sess, input_ids):
return sess.run(None, {"input_ids": input_ids})
def bench(sess, input_ids, warmup=5, iters=30):
for _ in range(warmup):
run(sess, input_ids)
ts = []
for _ in range(iters):
t0 = time.perf_counter()
run(sess, input_ids)
ts.append(time.perf_counter() - t0)
arr = np.array(ts)
return {
"mean_ms": float(arr.mean() * 1000),
"p50_ms": float(np.percentile(arr, 50) * 1000),
"p95_ms": float(np.percentile(arr, 95) * 1000),
}
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess_fp = ort.InferenceSession("model.onnx", sess_options=so, providers=["CPUExecutionProvider"])
sess_q = ort.InferenceSession("model.int4.onnx", sess_options=so, providers=["CPUExecutionProvider"])
# 예시 입력(배치 1, 시퀀스 32)
input_ids = np.random.randint(0, 1000, size=(1, 32), dtype=np.int64)
print("fp:", bench(sess_fp, input_ids))
print("q4:", bench(sess_q, input_ids))
주의할 점:
- 위 코드는 “한 번의 forward”만 재는 형태라 실제 생성 루프(토큰을 1개씩 늘려가는 디코딩)와 다릅니다.
- 실제 서비스 성능은 디코딩 루프 + KV 캐시 + 샘플링 로직까지 포함해야 합니다.
그럼에도 불구하고, 동일 조건에서 fp 대비 q4의 P50/P95가 유의미하게 내려가면 “양자화가 먹혔다”는 1차 신호로 볼 수 있습니다.
7) 실전에서 자주 터지는 함정 7가지
1) 커널이 안 탄다
int4로 저장했는데 실행은 fp16로 돌아가는 경우가 있습니다. 프로파일에서 연산 노드의 타입과 EP를 확인하세요.
2) 임베딩/LM 헤드까지 4bit로 내려 품질이 무너진다
민감 레이어는 제외하거나 더 높은 비트로 유지하세요.
3) 시퀀스 길이가 길어질수록 이득이 사라진다
KV 캐시가 지배하면 가중치 압축 이득이 상대적으로 줄어듭니다. KV 캐시 최적화와 함께 봐야 합니다.
4) 토크나이저가 병목이다
모델이 빨라졌는데 전체가 안 빨라졌다면, 토크나이저/전처리/후처리 시간을 측정해보세요.
5) 동시성에서 지연이 튄다
스레드 설정, NUMA, 메모리 아레나 설정이 동시성에서 악영향을 줄 수 있습니다. P95/P99로 검증하세요.
6) 출력 형식이 흐트러진다
툴콜, JSON 출력, 정책 준수 같은 “형식 안정성”은 양자화에서 먼저 깨집니다. 스키마 강제와 회귀 테스트를 같이 두세요.
7) 벤치마크가 공정하지 않다
워밍업, 동일한 입력 분포, 동일한 EP/옵션, 동일한 스레드 고정이 없으면 수치가 흔들립니다.
결론: 4bit는 “모델 파일”이 아니라 “시스템 최적화”다
ONNX Runtime로 LLM 4bit 양자화를 적용하면, 조건이 맞을 때 추론 속도를 2배 수준까지 끌어올릴 수 있습니다. 하지만 성패는 단순히 4bit로 변환했는지가 아니라
- 그래프 최적화가 충분한지
- 실행 프로바이더가 올바른 커널을 선택하는지
- 스레드/메모리 설정이 워크로드에 맞는지
- 품질 회귀를 자동으로 잡는지
에 달려 있습니다.
다음 액션 아이템을 추천합니다.
- ORT 프로파일링을 켜고 “어떤 노드가 시간을 먹는지”부터 확인
- weight-only 4bit를 우선 적용하고, 민감 레이어는 제외
- TTFT/TPOT/P95를 함께 측정해 서비스 관점으로 검증
- 툴콜·JSON 출력이 있다면 스키마 강제와 회귀 테스트를 필수로 구축
이 과정을 밟으면 “양자화했는데 왜 안 빨라지지”를 피하면서, 재현 가능한 형태로 2배 개선에 가까워질 수 있습니다.