- Published on
파이썬 ONNX Runtime로 LLM 10배 경량화 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
LLM을 서비스에 올리다 보면 가장 먼저 부딪히는 벽은 메모리와 지연시간입니다. 특히 CPU 기반 배치 서버, 엣지 디바이스, 혹은 GPU가 있어도 동시 요청이 늘어날수록 모델이 무겁다는 사실이 비용으로 직결됩니다.
이 글은 파이썬에서 ONNX Runtime(이하 ORT) 을 사용해 LLM을 실전에서 “체감 10배” 수준으로 가볍게 만드는 튜닝 루트를 다룹니다. 여기서 말하는 10배는 단일 지표가 아니라 다음 조합을 의미합니다.
- 모델 파일/가중치 용량 감소: FP16/INT8/INT4 양자화
- 런타임 메모리 감소: KV 캐시·메모리 패턴·그래프 최적화
- 추론 지연시간 감소: ORT 실행 프로바이더·스레드·I/O 바인딩
아래는 “바로 해볼 수 있는” 순서로 정리한 실전 튜닝 가이드입니다.
전체 전략: 10배 경량화는 한 방이 아니라 누적이다
LLM 최적화는 보통 아래 4단계를 누적합니다.
- ONNX Export: PyTorch 모델을 ONNX로 안정적으로 내보내기
- Graph Optimization: ORT 최적화 패스 적용, 불필요 연산 제거/퓨전
- Quantization: INT8(또는 INT4)로 가중치/활성값을 줄이기
- Runtime Tuning: 스레드·메모리·KV 캐시·바인딩으로 병목 제거
실무에서 흔한 실패 원인은 “양자화만 하면 빨라지겠지”라고 생각하는 것입니다. 실제로는 그래프/런타임 병목이 그대로면 INT8을 해도 생각보다 안 빨라집니다.
준비물: 패키지와 버전 체크
아래 조합이 가장 무난합니다.
onnxruntime또는onnxruntime-gpuonnxtransformerstorch- (선택)
optimum또는onnxruntime-tools
pip install -U onnx onnxruntime transformers torch
# GPU 환경이면
pip install -U onnxruntime-gpu
CPU vs GPU: 실행 프로바이더부터 결정
ORT는 “Execution Provider(EP)”로 성능이 갈립니다.
- CPU:
CPUExecutionProvider - NVIDIA GPU:
CUDAExecutionProvider - TensorRT:
TensorrtExecutionProvider(세팅 난이도 높지만 강력)
이 글은 CPU에서도 효과가 큰 튜닝을 중심으로 설명하되, GPU에서도 동일한 원칙이 적용됩니다.
1) ONNX Export: 내보내기에서 성능이 갈린다
LLM을 ONNX로 내보낼 때 핵심은 두 가지입니다.
- 동적 축(dynamic axes) 를 어디까지 허용할지
- KV 캐시(past key values) 를 모델 인터페이스에 포함할지
동적 축을 너무 넓게 열면 범용성은 좋아지지만, ORT가 최적화하기 어려워져 성능이 떨어질 수 있습니다. 반대로 너무 고정하면 입력 길이가 조금만 바뀌어도 재빌드/재내보내기가 필요합니다.
아래는 예시 코드(개념용)입니다. 실제 LLM은 입력/출력 시그니처가 복잡하므로, 프로젝트에서는 export 도구(예: Optimum) 사용을 추천합니다.
import torch
def export_onnx(model, sample_input_ids, onnx_path="model.onnx"):
model.eval()
with torch.no_grad():
torch.onnx.export(
model,
(sample_input_ids,),
onnx_path,
opset_version=17,
input_names=["input_ids"],
output_names=["logits"],
dynamic_axes={
"input_ids": {0: "batch", 1: "seq"},
"logits": {0: "batch", 1: "seq"},
},
)
실전 팁: KV 캐시를 ONNX로 뽑지 않으면 손해
토큰 생성형 추론에서 성능을 좌우하는 건 KV 캐시입니다. KV 캐시가 없으면 매 토큰마다 과거 토큰 전체를 다시 계산하게 되어 O(seq^2)로 무거워집니다.
- “프롬프트 2k + 생성 200” 같은 요청이 많다면
- KV 캐시가 있는지 없는지로 비용이 몇 배씩 갈립니다
따라서 ONNX export 단계에서부터 past_key_values 입력/출력을 지원하는 형태로 내보내는 전략이 중요합니다.
2) ORT 세션 옵션: 기본값으로는 10배가 안 나온다
ORT는 세션 옵션에 따라 성능이 크게 달라집니다. 특히 CPU에서 체감이 큽니다.
- 그래프 최적화 레벨
- 스레드/병렬성
- 메모리 패턴/아레나
import onnxruntime as ort
def create_session(onnx_path: str, providers=None):
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
# CPU 튜닝: 환경에 맞게 조정
so.intra_op_num_threads = 8
so.inter_op_num_threads = 1
# 입력 크기가 자주 바뀌면 메모리 패턴이 오히려 독이 될 때가 있음
so.enable_mem_pattern = True
so.enable_cpu_mem_arena = True
if providers is None:
providers = ["CPUExecutionProvider"]
return ort.InferenceSession(onnx_path, sess_options=so, providers=providers)
스레드 튜닝의 정석: intra는 코어 수 근처, inter는 낮게
intra_op_num_threads: 단일 op 내부 병렬화inter_op_num_threads: op 간 병렬화
LLM 추론은 대형 MatMul/GEMM이 많아 intra가 중요하고, inter는 보통 1이 안정적입니다. 값은 CPU/NUMA/동시성에 따라 달라서 벤치마크로 결정해야 합니다.
3) 양자화(Quantization): “파일만 줄이는” 게 아니라 “실행”이 빨라져야 한다
양자화는 크게 두 갈래입니다.
- Dynamic Quantization: 가중치 위주 INT8, 적용 쉬움, CPU에서 이득
- Static Quantization: 캘리브레이션 필요, 더 큰 이득 가능
LLM은 선형 계층이 대부분이라 가중치 INT8만으로도 메모리와 대역폭 이득이 큽니다.
3-1) ORT Dynamic Quantization 예시
from onnxruntime.quantization import quantize_dynamic, QuantType
quantize_dynamic(
model_input="model.onnx",
model_output="model.int8.onnx",
weight_type=QuantType.QInt8,
)
이 단계만으로도 다음이 동시에 좋아질 수 있습니다.
- 모델 파일 크기 감소
- CPU 메모리 대역폭 감소
- 캐시 적중률 개선
다만 “항상 빨라지는” 건 아닙니다. CPU가 INT8 GEMM에 최적화되어 있는지(예: AVX2/VNNI), ORT 빌드가 이를 활용하는지에 따라 결과가 달라집니다.
3-2) 체감 10배를 노리면 INT4도 고려
INT4는 더 공격적이지만 난이도가 올라갑니다.
- 정확도 손실 가능성 증가
- 커널/EP 지원 여부에 따라 성능 편차 큼
하지만 엣지/CPU에서 “어떻게든 들어가게” 만드는 목적이라면 INT4는 강력합니다. 프로젝트 요구 정확도(예: 요약/분류/챗봇)에 따라 A/B로 판단하세요.
4) KV 캐시 튜닝: LLM 추론 비용의 절반 이상이 여기에 있다
KV 캐시는 “길게 말하는 모델”일수록 효과가 커집니다.
- 프롬프트가 길수록
- 생성 토큰 수가 많을수록
- 동시 요청이 많을수록
튜닝 포인트는 아래 3가지입니다.
- 캐시 텐서의 dtype: FP16 또는 INT8 캐시(지원 여부 확인)
- 캐시를 어디에 둘지: CPU 메모리 vs GPU 메모리
- 캐시 재사용 전략: 동일 프롬프트 prefix 공유(서버 설계)
ORT 단독으로도 캐시를 다룰 수 있지만, 실무에서는 “서빙 레이어”와 결합한 최적화가 큽니다.
5) I/O Binding: 불필요한 복사를 줄이면 지연시간이 깎인다
ORT의 흔한 병목은 의외로 연산이 아니라 입출력 텐서 복사입니다. 특히 GPU에서는 CPU→GPU 복사가 왕복으로 일어나면 최적화가 무의미해집니다.
ORT는 io_binding으로 입력/출력을 특정 디바이스 메모리에 고정할 수 있습니다.
아래 코드는 개념 예시이며, 실제로는 디바이스 타입과 메모리 핸들링이 더 복잡합니다.
import numpy as np
import onnxruntime as ort
sess = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])
input_ids = np.ones((1, 32), dtype=np.int64)
# 기본 실행
out = sess.run(None, {"input_ids": input_ids})
GPU EP를 쓰는 경우에는 I/O 바인딩을 통해 불필요한 호스트 복사를 줄이는 것이 핵심입니다. CPU만 쓰더라도, 큰 배치/긴 시퀀스에서는 메모리 할당 패턴을 줄이는 것만으로도 효과가 있습니다.
6) 벤치마크 루프: “빠르다”는 감이 아니라 숫자로 잠그기
튜닝은 결국 측정 게임입니다. 최소한 아래 지표는 고정된 조건으로 측정해야 합니다.
- 프롬프트 길이(
seq_in) - 생성 길이(
seq_out) - 배치 크기
- 동시 요청 수
- p50/p95 지연시간
- RSS 메모리(프로세스 메모리)
간단 벤치 예시입니다.
import time
import numpy as np
import onnxruntime as ort
def bench(sess, n=50, warmup=10, seq=128):
x = np.random.randint(0, 1000, size=(1, seq), dtype=np.int64)
for _ in range(warmup):
sess.run(None, {"input_ids": x})
t0 = time.perf_counter()
for _ in range(n):
sess.run(None, {"input_ids": x})
t1 = time.perf_counter()
return (t1 - t0) / n
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.intra_op_num_threads = 8
so.inter_op_num_threads = 1
sess_fp = ort.InferenceSession("model.onnx", sess_options=so, providers=["CPUExecutionProvider"])
sess_int8 = ort.InferenceSession("model.int8.onnx", sess_options=so, providers=["CPUExecutionProvider"])
print("fp avg sec:", bench(sess_fp))
print("int8 avg sec:", bench(sess_int8))
실전 팁: 워밍업 없으면 결과가 흔들린다
- 첫 실행에 그래프 최적화/메모리 할당이 섞입니다
- 캐시/페이지 폴트 영향이 큽니다
항상 워밍업 후 측정하세요.
7) 운영에서 자주 터지는 이슈와 해결 방향
7-1) 멀티프로세싱으로 세션 공유하려다 실패
파이썬에서 워커를 늘리려고 multiprocessing을 쓰면, ORT 세션/모델 객체를 그대로 넘기다 피클링 문제가 날 수 있습니다. 이 경우는 “세션은 각 프로세스에서 생성”이 정석입니다.
멀티프로세싱에서 직렬화 오류를 겪고 있다면 이 글이 같이 도움이 됩니다.
7-2) 토크나이저/입출력에서 인코딩 문제
LLM 서빙에서 로그/프롬프트를 파일로 저장하거나 외부 시스템과 연동할 때 UnicodeDecodeError가 은근히 자주 납니다. 특히 윈도우/컨테이너 혼합 환경에서요.
7-3) CI에서 모델 다운로드/빌드가 너무 느림
ONNX 변환과 양자화는 CI에서 반복되면 비용이 큽니다. 캐시 전략을 제대로 잡으면 빌드 시간을 크게 줄일 수 있습니다.
8) “10배”에 가까워지는 조합 레시피
환경에 따라 다르지만, CPU 기준으로 체감 10배에 근접하게 만드는 조합은 보통 아래처럼 누적됩니다.
- KV 캐시 적용: 생성형에서 가장 큰 변화(프롬프트 길수록 폭발)
- INT8 양자화: 메모리/대역폭 절감으로 추가 이득
- 스레드/세션 옵션 튜닝: 남은 병목 제거
- I/O 복사 최소화: GPU 또는 큰 입력에서 효과
정리하면,
- “모델 자체”를 줄이는 건 양자화
- “생성 비용”을 줄이는 건 KV 캐시
- “남는 비용”을 줄이는 건 ORT 세션/런타임 튜닝
이 3개가 함께 가야 숫자가 크게 움직입니다.
마무리 체크리스트
실전 적용 전에 아래만 체크해도 시행착오가 줄어듭니다.
- ONNX export 시
dynamic_axes를 과도하게 열지 않았다 -
past_key_values기반 KV 캐시 경로가 있다 - ORT
graph_optimization_level이ORT_ENABLE_ALL이다 - CPU 스레드 값(
intra,inter)을 벤치로 결정했다 - INT8 모델과 FP 모델을 동일 조건으로 비교했다
- 워밍업 포함, p95까지 측정했다
원하면 다음 단계로, “특정 모델(예: Llama 계열) + 특정 환경(CPU AVX2/VNNI, CUDA, TensorRT) + 목표 TPS/지연시간”을 기준으로 튜닝 파라미터 표와 함께 더 촘촘한 레시피로 확장해드릴 수 있습니다.