Published on

파이썬 CNN·Transformer ONNX+TensorRT 10배 튜닝

Authors

딥러닝 추론을 운영 환경에 올리면, 모델 정확도만큼이나 지연 시간(latency), 처리량(throughput), GPU 메모리(footprint), 콜드 스타트가 성패를 가릅니다. 특히 파이썬 기반 PyTorch 추론은 편하지만, 그대로 서비스에 넣으면 다음 문제가 자주 발생합니다.

  • Python 오버헤드와 프레임워크 런타임 비용
  • Transformer의 긴 시퀀스에서 폭발하는 메모리 사용량
  • 배치 크기 변화에 따른 성능 흔들림
  • 컨테이너 기반 서빙에서 콜드 스타트로 인한 지연

이 글은 CNN·Transformer 모델을 ONNX로 내보내고 TensorRT로 엔진화하여, 실무에서 흔히 체감하는 5배~10배 수준의 속도 개선과 메모리 절감을 노리는 튜닝 가이드입니다. 단순 변환이 아니라, 실제로 성능이 나오는 지점(정밀도, 다이내믹 셰이프, 프로파일, 플러그인, 캐싱)을 순서대로 짚습니다.

운영 관점에서 콜드 스타트가 성능 체감에 미치는 영향은 꽤 큽니다. 서버리스/컨테이너에서 지연이 튀는 문제는 별도로 정리한 글도 참고하세요: GCP Cloud Run 504·콜드 스타트 10분 지연 해결법

목표: “ONNX 변환”이 아니라 “TensorRT가 잘 먹는 그래프” 만들기

많은 팀이 torch.onnx.export까지만 하고 끝내는데, 그 상태의 ONNX는 TensorRT가 최적화하기 어려운 패턴(동적 연산, 지원되지 않는 op, 불필요한 shape 연산)을 포함하는 경우가 많습니다. 성능을 내려면 다음 4단계를 한 세트로 생각해야 합니다.

  1. PyTorch 모델 정리: eval() 고정, 불필요한 분기 제거, 연산 패턴 단순화
  2. ONNX Export: opset, dynamic axes, constant folding
  3. ONNX Graph 최적화: onnxsim, shape inference, 불필요 노드 제거
  4. TensorRT Build 튜닝: FP16/INT8, 프로파일(min/opt/max), tactic 선택, workspace, 캐시

준비물: 버전 조합과 환경 체크

TensorRT는 CUDA/cuDNN, 드라이버, GPU 아키텍처에 민감합니다. 팀에서 흔히 겪는 삽질 포인트는 “빌드 머신에서는 되는데 런타임에서 엔진 로드 실패”입니다. 원칙은 간단합니다.

  • 엔진은 실행 환경과 동일한 GPU 아키텍처에서 빌드하는 것이 안전
  • 컨테이너를 쓴다면 빌드 컨테이너와 런타임 컨테이너의 TensorRT/CUDA 버전 정합 유지

권장 패키지(예시):

  • PyTorch 최신 LTS 계열
  • onnx, onnxruntime-gpu(검증용)
  • TensorRT Python wheel 또는 NVIDIA NGC 컨테이너

1) PyTorch 모델을 ONNX로 내보내기: 실전 옵션

CNN 케이스: 고정 입력이면 다이내믹을 최소화

CNN은 보통 입력 크기가 고정이므로, dynamic axes를 줄일수록 TensorRT 최적화가 쉬워집니다. 배치만 dynamic으로 두는 전략이 흔합니다.

import torch

model = ...
model.eval().cuda()

dummy = torch.randn(1, 3, 224, 224, device="cuda")

torch.onnx.export(
    model,
    dummy,
    "model.onnx",
    input_names=["input"],
    output_names=["logits"],
    opset_version=17,
    do_constant_folding=True,
    dynamic_axes={
        "input": {0: "batch"},
        "logits": {0: "batch"},
    },
)

Transformer 케이스: 시퀀스 길이 다이내믹을 어떻게 다룰지 먼저 결정

Transformer는 보통 입력이 batchseq_len 두 축이 변합니다. 여기서 튜닝의 갈림길이 생깁니다.

  • 서비스가 최대 시퀀스가 작고 고정이라면: seq_len을 고정해 최적화 극대화
  • 다양한 길이를 받아야 한다면: TensorRT의 Optimization Profile을 설계해야 함
import torch

model = ...
model.eval().cuda()

batch = 1
seq_len = 128
input_ids = torch.randint(0, 30000, (batch, seq_len), device="cuda")
attention_mask = torch.ones((batch, seq_len), device="cuda")

torch.onnx.export(
    model,
    (input_ids, attention_mask),
    "transformer.onnx",
    input_names=["input_ids", "attention_mask"],
    output_names=["logits"],
    opset_version=17,
    do_constant_folding=True,
    dynamic_axes={
        "input_ids": {0: "batch", 1: "seq"},
        "attention_mask": {0: "batch", 1: "seq"},
        "logits": {0: "batch"},
    },
)

2) ONNX 그래프를 “TensorRT 친화적”으로 정리하기

onnx-simplifier로 불필요한 노드 제거

ONNX export 결과에는 shape 계산용 노드가 과도하게 붙는 경우가 있습니다. onnxsim은 이를 줄여주고, TensorRT 빌드 성공률과 성능을 함께 올리는 경우가 많습니다.

python -m pip install onnxsim
python -m onnxsim model.onnx model.sim.onnx

ONNX Runtime으로 1차 검증: 정확도와 입출력 일치 확인

TensorRT로 바로 가기 전에, ONNX 자체가 정상인지 확인해야 합니다.

import numpy as np
import onnxruntime as ort

sess = ort.InferenceSession(
    "model.sim.onnx",
    providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
)

x = np.random.randn(2, 3, 224, 224).astype(np.float32)
outputs = sess.run(None, {"input": x})
print([o.shape for o in outputs])

여기서부터는 “모델이 틀렸다”가 아니라 “엔진이 느리다” 문제로 좁혀집니다.

3) TensorRT 엔진 빌드: 성능의 80%는 프로파일과 정밀도에서 나온다

TensorRT는 입력 shape 범위를 알고 있어야 최적의 커널(tactic)을 선택합니다. 특히 Transformer는 seq_len 범위를 어떻게 주느냐에 따라 성능이 크게 갈립니다.

trtexec로 빠르게 성능 감 잡기

가장 빠른 실전 루프는 trtexec입니다.

  • FP16: 속도/메모리 개선 폭이 크고, 정확도 손실이 대체로 작음
  • INT8: 캘리브레이션이 필요하지만, 더 큰 이득 가능

CNN 예시(FP16):

trtexec \
  --onnx=model.sim.onnx \
  --saveEngine=model_fp16.plan \
  --fp16 \
  --warmUp=200 \
  --iterations=1000 \
  --workspace=4096

Transformer 예시(다이내믹 셰이프 프로파일 포함):

trtexec \
  --onnx=transformer.sim.onnx \
  --saveEngine=trf_fp16.plan \
  --fp16 \
  --minShapes=input_ids:1x16,attention_mask:1x16 \
  --optShapes=input_ids:4x128,attention_mask:4x128 \
  --maxShapes=input_ids:8x256,attention_mask:8x256 \
  --workspace=8192 \
  --warmUp=200 \
  --iterations=1000

주의할 점:

  • optShapes가장 자주 오는 트래픽의 대표 shape로 잡아야 합니다.
  • maxShapes를 과도하게 키우면 tactic 탐색 공간이 커져 빌드가 느려지고, 최적화도 분산될 수 있습니다.

Python API로 빌드하기: 캐시와 프로파일을 코드로 고정

운영에서는 엔진을 CI에서 빌드해 아티팩트로 배포하거나, 첫 부팅 시 빌드하고 디스크에 캐시합니다. 아래는 개념을 보여주는 최소 예시입니다.

import tensorrt as trt

TRT_LOGGER = trt.Logger(trt.Logger.INFO)

def build_engine(onnx_path: str, engine_path: str):
    builder = trt.Builder(TRT_LOGGER)
    network = builder.create_network(
        1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
    )
    parser = trt.OnnxParser(network, TRT_LOGGER)

    with open(onnx_path, "rb") as f:
        if not parser.parse(f.read()):
            for i in range(parser.num_errors):
                print(parser.get_error(i))
            raise RuntimeError("ONNX parse failed")

    config = builder.create_builder_config()
    config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 8 * (1 << 30))

    if builder.platform_has_fast_fp16:
        config.set_flag(trt.BuilderFlag.FP16)

    # 다이내믹 입력이 있으면 반드시 Optimization Profile 필요
    profile = builder.create_optimization_profile()
    profile.set_shape("input", min=(1, 3, 224, 224), opt=(8, 3, 224, 224), max=(16, 3, 224, 224))
    config.add_optimization_profile(profile)

    serialized = builder.build_serialized_network(network, config)
    if serialized is None:
        raise RuntimeError("Engine build failed")

    with open(engine_path, "wb") as f:
        f.write(serialized)

build_engine("model.sim.onnx", "model_fp16.plan")

4) FP16 vs INT8: 언제 10배가 나오나

체감 10배는 보통 다음 조합에서 잘 나옵니다.

  • PyTorch eager 추론 대비 TensorRT 엔진화
  • FP32에서 FP16 또는 INT8로 다운
  • 배치/시퀀스 프로파일을 트래픽에 맞게 최적화
  • 불필요한 CPU-GPU 동기화를 제거

FP16 권장 시나리오

  • 비전 CNN, 일반적인 분류/검출 백본
  • Transformer에서도 정확도 민감도가 높거나, 캘리브레이션 데이터 준비가 어려운 경우

INT8 권장 시나리오

  • 지연이 매우 빡빡한 실시간 서비스
  • 입력 분포가 안정적이며 캘리브레이션 샘플을 충분히 확보 가능

INT8은 “성능”보다 “품질 유지”가 더 어렵습니다. 특히 Transformer는 레이어 정규화, 소프트맥스, GEMM 패턴 때문에 레이어별 민감도가 다릅니다. 가능한 전략은 다음입니다.

  • 혼합 정밀도: 대부분 INT8, 민감 레이어는 FP16/FP32 유지
  • 대표 트래픽을 반영한 캘리브레이션 데이터 구성

5) 흔한 병목과 해결 체크리스트

1) 엔진은 빠른데 전체 API는 느리다

대부분 전처리/후처리, 또는 I/O가 병목입니다.

  • 이미지 디코딩을 CPU에서 과도하게 하고 있지 않은지
  • 토크나이저가 파이썬 단일 스레드로 병목인지
  • cudaMemcpy가 잦은지(특히 CPU 텐서로 왕복)

가능하면 전처리도 GPU로 올리거나, 최소한 배치 처리로 amortize하세요.

2) 다이내믹 셰이프에서 성능이 출렁인다

  • 프로파일 범위가 너무 넓으면 최적화가 분산됩니다.
  • optShapes가 실제 트래픽 중앙값과 다르면 평균 성능이 떨어집니다.

해결: 트래픽 로그로 batch, seq_len 분포를 뽑고, 프로파일을 2~3개로 쪼개는 전략도 유효합니다.

3) 특정 op가 TensorRT에서 지원되지 않는다

  • ONNX 그래프에 커스텀 연산이 있거나
  • 최신 모델의 특정 연산이 아직 미지원일 수 있습니다.

대응 순서:

  1. ONNX simplification/버전(opset) 조정
  2. 모델 구현을 TensorRT-friendly 패턴으로 변경
  3. 최후에 플러그인 작성

4) 콜드 스타트가 체감 성능을 망친다

TensorRT 엔진 로딩, CUDA 컨텍스트 초기화는 첫 요청에서 비용이 큽니다.

  • 프로세스 시작 시 엔진을 미리 로드
  • 워밍업 inference를 몇 번 돌려 커널 캐시를 채움
  • 컨테이너에서는 최소 인스턴스 유지 또는 pre-warm 전략

서버리스에서 콜드 스타트가 실제 장애(타임아웃)로 이어지는 케이스는 꽤 흔합니다. 관련 운영 팁은 GCP Cloud Run 504·콜드 스타트 10분 지연 해결법도 같이 보시면 좋습니다.

6) 운영 배포 패턴: 엔진 아티팩트, 호환성, 관측

엔진 빌드 위치

  • 추천: CI에서 타깃 GPU와 동일한 환경으로 빌드 후 아티팩트로 배포
  • 대안: 런타임 첫 부팅에 빌드하고 디스크에 캐시(단, 콜드 스타트 증가)

호환성 규칙

  • TensorRT 엔진은 드라이버/CUDA/TensorRT 버전과 GPU 아키텍처에 영향을 받습니다.
  • 가능하면 엔진 파일에 다음 메타를 함께 저장하세요.
    • TensorRT 버전
    • CUDA 버전
    • GPU compute capability
    • 프로파일(min/opt/max)

관측 포인트

  • p50/p95/p99 latency를 전처리/추론/후처리로 분해
  • GPU utilization, memory, SM occupancy
  • 배치 크기와 시퀀스 길이 분포

서빙에서 5xx가 튀는 문제는 모델 자체보다 인프라/리소스 설정에서 오는 경우도 많습니다. 운영 장애 체크는 SageMaker 엔드포인트 5xx 급증 원인·해결 9가지처럼 “현상-원인-대응”으로 정리해두면 대응 속도가 빨라집니다.

7) 실전 결론: 10배를 만드는 우선순위

정리하면, “ONNX로 내보내고 TensorRT로 돌린다”만으로는 생각보다 성능이 안 나오는 경우가 많고, 아래 우선순위를 따라가면 성공 확률이 높습니다.

  1. FP16부터 적용해 빠르게 성능/정확도 밸런스 확보
  2. Transformer는 Optimization Profile을 트래픽 분포 기반으로 설계
  3. ONNX 그래프를 onnxsim으로 정리하고, ONNX Runtime으로 1차 검증
  4. 전처리/후처리 병목 제거(토크나이저, 디코딩, CPU-GPU 왕복)
  5. INT8은 캘리브레이션 데이터가 준비되었을 때 단계적으로
  6. 콜드 스타트는 엔진 preload와 워밍업으로 체감 성능을 지키기

이 과정을 제대로 밟으면, CNN은 물론 Transformer에서도 지연과 메모리 사용량이 눈에 띄게 줄고, 같은 GPU로 더 많은 트래픽을 처리할 수 있습니다. 다음 단계로는 Triton Inference Server, CUDA Graphs, Multi-stream, KV cache 최적화 같은 주제로 더 깊게 들어가면 좋습니다.