Published on

파이썬 ONNX Runtime로 LLM 10배 경량화 튜닝

Authors

LLM을 서비스에 올리다 보면 가장 먼저 부딪히는 벽은 메모리지연시간입니다. 특히 CPU 기반 배치 서버, 엣지 디바이스, 혹은 GPU가 있어도 동시 요청이 늘어날수록 모델이 무겁다는 사실이 비용으로 직결됩니다.

이 글은 파이썬에서 ONNX Runtime(이하 ORT) 을 사용해 LLM을 실전에서 “체감 10배” 수준으로 가볍게 만드는 튜닝 루트를 다룹니다. 여기서 말하는 10배는 단일 지표가 아니라 다음 조합을 의미합니다.

  • 모델 파일/가중치 용량 감소: FP16/INT8/INT4 양자화
  • 런타임 메모리 감소: KV 캐시·메모리 패턴·그래프 최적화
  • 추론 지연시간 감소: ORT 실행 프로바이더·스레드·I/O 바인딩

아래는 “바로 해볼 수 있는” 순서로 정리한 실전 튜닝 가이드입니다.

전체 전략: 10배 경량화는 한 방이 아니라 누적이다

LLM 최적화는 보통 아래 4단계를 누적합니다.

  1. ONNX Export: PyTorch 모델을 ONNX로 안정적으로 내보내기
  2. Graph Optimization: ORT 최적화 패스 적용, 불필요 연산 제거/퓨전
  3. Quantization: INT8(또는 INT4)로 가중치/활성값을 줄이기
  4. Runtime Tuning: 스레드·메모리·KV 캐시·바인딩으로 병목 제거

실무에서 흔한 실패 원인은 “양자화만 하면 빨라지겠지”라고 생각하는 것입니다. 실제로는 그래프/런타임 병목이 그대로면 INT8을 해도 생각보다 안 빨라집니다.

준비물: 패키지와 버전 체크

아래 조합이 가장 무난합니다.

  • onnxruntime 또는 onnxruntime-gpu
  • onnx
  • transformers
  • torch
  • (선택) 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가지입니다.

  1. 캐시 텐서의 dtype: FP16 또는 INT8 캐시(지원 여부 확인)
  2. 캐시를 어디에 둘지: CPU 메모리 vs GPU 메모리
  3. 캐시 재사용 전략: 동일 프롬프트 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_levelORT_ENABLE_ALL이다
  • CPU 스레드 값(intra, inter)을 벤치로 결정했다
  • INT8 모델과 FP 모델을 동일 조건으로 비교했다
  • 워밍업 포함, p95까지 측정했다

원하면 다음 단계로, “특정 모델(예: Llama 계열) + 특정 환경(CPU AVX2/VNNI, CUDA, TensorRT) + 목표 TPS/지연시간”을 기준으로 튜닝 파라미터 표와 함께 더 촘촘한 레시피로 확장해드릴 수 있습니다.