- Published on
ONNX Runtime로 LLM 4bit 양자화 속도 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 LLM을 빠르게 만들 때 가장 먼저 떠오르는 카드가 4bit 양자화입니다. 모델 크기와 메모리 대역폭 요구량이 크게 줄어들어, 같은 GPU에서 더 큰 배치 또는 더 긴 컨텍스트를 처리할 수 있기 때문입니다. 하지만 4bit로 바꿨는데도 기대만큼 토큰 처리량이 안 나오거나, 오히려 지연 시간이 들쑥날쑥해지는 경우가 흔합니다. 이유는 단순히 가중치가 4bit가 됐다는 사실만으로는 전체 파이프라인이 빨라지지 않기 때문입니다.
이 글에서는 ONNX Runtime(이하 ORT)에서 LLM 4bit 양자화 모델을 기준으로, 실제로 체감 성능을 끌어올리는 튜닝 포인트를 정리합니다. 핵심은 다음 4가지입니다.
- 실행 프로바이더(Execution Provider, EP)별로 4bit 커널이 제대로 타는지 확인
- 세션 옵션과 그래프 최적화, 스레딩이 병목을 만들지 않게 설정
- 프리필과 디코드 경로를 분리하고 I/O 바인딩으로 복사 비용 제거
- KV 캐시와 동적 shape가 만드는 재할당 및 동기화를 줄이기
중간중간 디버깅 관점은 툴 호출에서 병목을 쪼개는 방식과 유사합니다. 복잡한 파이프라인을 단계별로 쪼개서 원인을 좁히는 접근은 Assistants API+LangChain 툴콜 오류 디버깅 가이드에서도 동일하게 유효합니다.
1) 4bit 양자화가 빨라지는 조건부터 점검
4bit 양자화는 보통 다음 중 하나의 형태로 구현됩니다.
MatMul자체가 4bit 커널로 실행되는 방식(가장 이상적)- 4bit로 저장하되 실행 시 부분적으로 dequantize가 섞이는 방식
- 레이어별로 혼합 정밀도(예: attention은 FP16, FFN만 INT4)
따라서 “모델 파일이 4bit다”와 “실행이 4bit 커널로 돈다”는 다른 이야기입니다. 먼저 아래를 확인하세요.
- ORT 버전: 최신 ORT는 EP별로 INT4 지원이 빠르게 바뀝니다. 최소한 ORT 1.17 이상을 권장하고, CUDA/TensorRT EP를 쓰는 경우 릴리스 노트를 확인해야 합니다.
- EP가 INT4를 지원하는가: CPU EP는 주로 INT8 최적화가 강하고, GPU는 CUDA EP, TensorRT EP에서 INT4 경로가 더 적극적입니다.
- 모델 그래프에 Q/DQ(QuantizeLinear/DequantizeLinear) 패턴이 있는가: QDQ가 있어도 커널이 안 붙으면 결국 dequantize 비용이 남습니다.
그래프에 실제로 어떤 커널이 붙는지 확인
ORT는 프로파일링과 verbose 로그로 “어떤 노드가 어떤 EP에서 실행됐는지”를 확인할 수 있습니다.
import onnxruntime as ort
so = ort.SessionOptions()
so.enable_profiling = True
so.log_severity_level = 0 # verbose
providers = [
"CUDAExecutionProvider",
"CPUExecutionProvider",
]
sess = ort.InferenceSession("model.onnx", sess_options=so, providers=providers)
# 1회 실행 후 프로파일 파일 경로 출력
# profile_path = sess.end_profiling()
프로파일 JSON을 열어보면 노드별 실행 시간이 나오고, 어떤 EP에 배치됐는지 단서가 됩니다. 만약 핵심 연산(대부분 MatMul, Attention 관련)이 CPU EP로 떨어지거나, GPU에서도 예상보다 작은 커널로 쪼개져 있다면 4bit 이점이 상쇄됩니다.
2) EP 선택: CUDA EP vs TensorRT EP
LLM 4bit에서 흔히 겪는 현상은 “CUDA EP는 안정적이지만 기대 TPS가 안 나옴”, “TensorRT EP는 빠르지만 빌드/캐시/shape 이슈가 많음”입니다.
- CUDA EP: 세팅이 단순하고 디버깅이 쉽습니다. 다만 특정 INT4 최적화는 제한적일 수 있습니다.
- TensorRT EP: 엔진 빌드가 잘 되면 매우 빠릅니다. 대신 동적 shape, 최적화 프로파일, 엔진 캐시가 성능과 안정성을 크게 좌우합니다.
TensorRT EP를 쓴다면 엔진 캐시가 제대로 먹는지 꼭 확인하세요. 캐시가 매번 미스나면 첫 요청 지연이 커지고, 서버 재시작 때마다 엔진 빌드로 CPU/GPU가 튑니다. 캐시 관련 함정은 CI에서도 똑같이 발생하므로, 원리 이해에는 GitHub Actions 캐시가 안 먹을 때 터지는 7가지 함정 관점이 도움이 됩니다.
TensorRT EP 기본 설정 예시
아래 예시는 ORT Python에서 TensorRT EP 옵션을 넣는 형태입니다. 옵션 키는 ORT 버전에 따라 조금씩 다를 수 있으니, 설치된 버전의 문서를 우선하세요.
import onnxruntime as ort
trt_opts = {
"trt_engine_cache_enable": True,
"trt_engine_cache_path": "./trt_cache",
"trt_fp16_enable": True,
"trt_builder_optimization_level": 5,
}
providers = [
("TensorrtExecutionProvider", trt_opts),
"CUDAExecutionProvider",
"CPUExecutionProvider",
]
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess = ort.InferenceSession("model.onnx", sess_options=so, providers=providers)
튜닝 포인트는 다음입니다.
- 엔진 캐시 경로를 컨테이너 볼륨으로 고정
- 동적 shape가 많은 LLM은 최적화 프로파일이 성능을 좌우하므로, 입력 길이 범위를 현실적으로 설정
- FP16 enable은 4bit와 무관해 보이지만, attention/softmax 등 혼합 정밀 구간에서 이득이 큼
3) 세션 옵션: 그래프 최적화, 스레딩, 메모리 패턴
ORT는 세션 옵션이 성능에 큰 영향을 줍니다. 특히 LLM은 반복 호출 구조(프리필 1회 + 디코드 N회)라서, 작은 오버헤드가 누적됩니다.
추천 시작점
import onnxruntime as ort
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
# CPU fallback이 있는 경우에만 의미가 큼
so.intra_op_num_threads = 1
so.inter_op_num_threads = 1
# 입력 shape가 자주 바뀌면 메모리 패턴 최적화가 오히려 독이 될 수 있음
so.enable_mem_pattern = False
# 초기화 시 비용을 줄이고 싶다면
so.enable_cpu_mem_arena = True
설명:
enable_mem_pattern: 동일한 shape로 반복 실행될 때 버퍼 재사용에 유리합니다. 하지만 LLM 서빙에서 입력 길이가 요청마다 크게 달라지면, 패턴이 자주 깨지며 재할당과 동기화가 늘어날 수 있습니다.- 스레딩: GPU가 주력인데 CPU 스레드가 과도하면 오히려 컨텍스트 스위칭과 메모리 경쟁이 생깁니다. CPU fallback이 거의 없다면 1로 두고 측정하는 편이 안전합니다.
4) 프리필과 디코드를 분리하고, 디코드는 I/O 바인딩으로 고정
LLM 서빙의 성능 튜닝은 “프리필(prefill)”과 “디코드(decode)”를 분리해서 봐야 합니다.
- 프리필: 입력 토큰 길이에 비례해 계산량이 큼
- 디코드: 매 스텝은 작지만, 토큰 수만큼 반복되어 런타임 오버헤드가 누적됨
디코드에서 중요한 건 다음입니다.
- 매 스텝마다 입력 텐서 생성과 CPU to GPU 복사가 일어나지 않게 하기
- KV 캐시를 재할당하지 않게 shape와 버퍼를 안정화하기
ORT I/O 바인딩 예시(CUDA)
아래는 개념 예시입니다. 실제 입력/출력 이름과 shape는 모델에 맞게 바꿔야 합니다.
import numpy as np
import onnxruntime as ort
from onnxruntime import OrtValue
sess = ort.InferenceSession(
"decoder.onnx",
providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
)
io = sess.io_binding()
# 예: input_ids는 매 스텝 1 토큰
input_ids = np.array([[42]], dtype=np.int64)
# GPU OrtValue로 올려서 바인딩
input_ov = OrtValue.ortvalue_from_numpy(input_ids, "cuda", 0)
io.bind_ortvalue_input("input_ids", input_ov)
# 출력도 GPU에 바인딩(이름은 모델에 맞게)
io.bind_output("logits", "cuda")
sess.run_with_iobinding(io)
# 필요할 때만 CPU로 가져오기
logits = io.copy_outputs_to_cpu()[0]
포인트:
- 디코드 루프에서
bind_ortvalue_input에 들어가는 버퍼를 재사용하고, 값만 갱신하는 구조로 만들면 파이썬 오버헤드를 줄일 수 있습니다. - 출력도 가능하면 GPU에 두고, 샘플링(top-k, temperature)을 GPU로 올리면 더 좋지만 구현 난도가 올라갑니다. 최소한 logits 전체를 매번 CPU로 복사하지 않도록 주의하세요.
5) KV 캐시 튜닝: 재할당과 동기화가 성능을 잡아먹는다
4bit 양자화로 MatMul이 빨라져도, KV 캐시가 병목이면 TPS가 안 나옵니다. 흔한 문제는 다음입니다.
- 매 스텝마다 KV 캐시 텐서가 길이에 맞춰 확장되며 재할당
- 동적 shape 때문에 커널 선택/그래프 최적화가 매번 달라짐
- KV 캐시가 CPU에 있거나, GPU와 CPU를 오가며 동기화
실무 팁:
- 가능한 경우 “최대 컨텍스트 길이” 기준으로 KV 캐시를 미리 할당하는 모델/그래프를 선택
past_key_values를 출력으로 받고 다시 입력으로 넣는 구조라면, I/O 바인딩으로 GPU 상에서만 순환시키기- 요청별로 컨텍스트 길이 분포를 관찰하고, 너무 넓으면 라우팅(짧은 요청 전용 워커, 긴 요청 전용 워커)으로 변동성을 줄이기
6) 배치와 시퀀스 길이 전략: 4bit의 이득을 실제 TPS로 바꾸기
4bit의 장점은 메모리 여유가 생긴다는 점이고, 이 여유는 다음 중 하나로 환전됩니다.
- 더 큰 batch size로 프리필 처리량 증가
- 더 긴 컨텍스트 지원
- 동시 요청 수 증가(동일 GPU에서 더 많은 세션)
하지만 디코드는 배치를 키우면 지연 시간이 늘 수 있습니다. 따라서 “프리필은 배치로 묶고, 디코드는 per-request로 빠르게” 같은 하이브리드 전략이 자주 쓰입니다.
운영에서 추천하는 측정 지표:
- 프리필 latency(ms)
- 디코드 step latency(ms/step)
- end-to-end latency(p50, p95)
- tokens per second(TPS) 또는 tokens per second per GPU
7) 측정과 프로파일링: 어디서 느린지 먼저 고정
튜닝은 감으로 하면 실패합니다. 아래 순서로 병목을 확정하세요.
- ORT 프로파일로 상위 10개 노드 시간 확인
- CPU 사용률이 높은지, GPU utilization이 낮은지 확인
- H2D, D2H 복사가 많은지 확인
- 디코드 루프에서 파이썬 오버헤드가 큰지 확인
간단 벤치마크 스켈레톤
import time
import numpy as np
import onnxruntime as ort
def bench(sess, feeds, warmup=5, iters=50):
for _ in range(warmup):
sess.run(None, feeds)
t0 = time.perf_counter()
for _ in range(iters):
sess.run(None, feeds)
t1 = time.perf_counter()
return (t1 - t0) * 1000 / iters
sess = ort.InferenceSession("model.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
feeds = {
"input_ids": np.ones((1, 128), dtype=np.int64),
}
ms = bench(sess, feeds)
print(f"avg latency: {ms:.2f} ms")
이 벤치마크는 단일 호출 기준이라 프리필에 가깝습니다. 디코드는 1토큰 입력을 N번 반복하는 루프 벤치로 따로 측정해야 합니다.
8) 흔한 함정 체크리스트
1) EP fallback이 조용히 발생
- 특정 연산이 지원되지 않아 CPU로 떨어지면, GPU와 CPU 사이 동기화가 늘고 지연이 튑니다.
- 해결: verbose 로그, 프로파일로 CPU 실행 노드를 찾아 모델 변환 또는 EP 변경.
2) 동적 shape로 인해 TensorRT 엔진이 매번 새로 빌드
- 해결: 입력 길이 범위를 제한하거나, 프로파일을 현실적으로 잡고 엔진 캐시를 고정.
3) logits를 매 스텝 전체 복사
- 디코드에서는 매 스텝이 작기 때문에, 복사 오버헤드가 상대적으로 커집니다.
- 해결: I/O 바인딩, 필요한 최소 데이터만 CPU로 가져오기.
4) 토큰화/후처리가 CPU 병목
- 모델이 빨라질수록 토큰화, 샘플링, 스트리밍 출력이 병목으로 드러납니다.
- 해결: 토큰화 배치 처리, 샘플링 최적화, 비동기 스트리밍.
운영에서 외부 API를 함께 호출하는 구조라면, 모델 TPS만 올려도 전체 체감이 안 좋아질 수 있습니다. 이 경우 재시도/백오프 정책이나 병렬성 설계가 중요해지는데, 접근 자체는 OpenAI 429/Rate Limit 재시도·백오프 설계와 같은 방식으로 병목을 분리해 보는 게 좋습니다.
9) 권장 튜닝 플로우(실전 순서)
- CUDA EP로 먼저 안정적으로 띄우고, 프로파일로 CPU fallback 여부 확인
- 프리필/디코드 벤치를 분리해 각각 p50, p95를 측정
- 디코드에 I/O 바인딩 적용, logits 복사 최소화
- KV 캐시 재할당이 있는지 확인하고, 가능하면 고정 버퍼 전략으로 변경
- TensorRT EP를 도입한다면 엔진 캐시와 shape 프로파일부터 고정
- 마지막으로 배치 전략(프리필 묶기, 디코드 분리)으로 TPS를 끌어올림
마무리
ONNX Runtime에서 LLM 4bit 양자화는 “모델이 작아졌다”에서 끝나지 않습니다. 실제 속도는 EP가 INT4 커널을 얼마나 잘 타는지, 디코드 루프에서 복사와 재할당을 얼마나 제거했는지, KV 캐시를 얼마나 안정적으로 유지했는지에 달려 있습니다.
가장 빠른 길은 하나씩 바꿔가며 측정하는 것입니다. 프로파일로 병목 노드를 확정하고, I/O 바인딩과 캐시 전략부터 적용하면 4bit의 이점을 TPS로 전환하기가 훨씬 쉬워집니다.