Published on

PyTorch→ONNX→TensorRT INT8 양자화 실수 7가지

Authors

서빙 성능을 끌어올리기 위해 PyTorch → ONNX → TensorRT 파이프라인에서 INT8 양자화를 적용하는 경우가 많습니다. 문제는 “엔진은 만들어졌는데 정확도가 박살”, “빌드는 되는데 느려짐”, “어제 되던 게 오늘 안 됨” 같은 장애가 꽤 높은 확률로 발생한다는 점입니다.

이 글은 TensorRT INT8에서 특히 많이 겪는 실수 7가지를 원인 → 증상 → 점검/해결 형태로 정리합니다. 분류상 PTQ 중심이지만, QAT에서도 동일하게 터지는 항목이 섞여 있습니다.

전체 파이프라인 한 장 요약

  • PyTorch 모델을 eval 모드로 고정하고 입력 전처리까지 포함한 “추론 그래프”를 만든다
  • ONNX Export 시 opset, dynamic axes, constant folding 등을 안정적으로 설정한다
  • TensorRT에서 INT8 빌드 시 캘리브레이션 데이터셋과 전처리, 동적 shape 프로파일을 맞춘다
  • 엔진 검증은 반드시 FP16/FP32 대비로 수치/성능을 함께 본다

아래 7개 실수는 대부분 여기서 어긋납니다.

실수 1) PyTorch를 train 모드로 export 한다

증상

  • ONNX는 잘 나오는데 TensorRT 결과가 랜덤하게 흔들림
  • 특히 Dropout, BatchNorm이 있는 모델에서 정확도 급락

원인

model.train() 상태로 export 하면 Dropout이 살아있거나, BatchNorm이 러닝 통계를 제대로 쓰지 못해 추론 그래프가 불안정해집니다.

해결

  • export 전 model.eval() 강제
  • 가능하면 torch.inference_mode()로 그래프 고정
import torch

model = build_model()
ckpt = torch.load("model.pt", map_location="cpu")
model.load_state_dict(ckpt)
model.eval()

dummy = torch.randn(1, 3, 224, 224)

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

실수 2) 캘리브레이션 데이터 전처리가 “학습/서빙”과 다르다

증상

  • INT8에서만 정확도 급락하고 FP16은 정상
  • 특정 클래스만 유독 망가짐

원인

TensorRT PTQ캘리브레이션 데이터의 activation 분포로 스케일을 잡습니다. 이때 전처리(리사이즈, 크롭, 정규화, 색공간, 채널 순서)가 학습/실서빙과 다르면 activation 분포가 달라져 양자화 스케일이 틀어집니다.

자주 하는 실수:

  • BGR로 학습했는데 캘리브레이션은 RGB
  • mean/std 정규화를 빼먹음
  • letterbox 사용 모델인데 캘리브레이션은 단순 리사이즈

해결

  • 캘리브레이션 입력 파이프라인을 서빙 입력과 1:1로 동일하게 만듭니다
  • 데이터는 최소 수백 장, 도메인 대표성이 중요합니다
import cv2
import numpy as np

def preprocess(img_bgr: np.ndarray) -> np.ndarray:
    # 예시: BGR 유지, 정규화 포함
    img = cv2.resize(img_bgr, (224, 224), interpolation=cv2.INTER_LINEAR)
    img = img.astype(np.float32) / 255.0
    mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
    std  = np.array([0.229, 0.224, 0.225], dtype=np.float32)
    img = (img[..., ::-1] - mean) / std  # BGR 입력을 RGB로 바꾸는 경우라면 의도적으로
    img = np.transpose(img, (2, 0, 1))   # CHW
    return img

실수 3) 동적 shape 모델인데 TensorRT 프로파일과 캘리브레이션 shape가 불일치

증상

  • 엔진 빌드 실패 또는 빌드는 되는데 특정 배치/해상도에서만 결과 이상
  • 로그에 프로파일 관련 경고가 나오거나, 런타임에 shape set 실패

원인

TensorRT에서 동적 shape는 Optimization Profilemin/opt/max를 정의합니다. INT8 캘리브레이션은 이 프로파일의 범위 내 shape를 기준으로 진행되는데, 캘리브레이션이 opt와 크게 다르거나 범위를 벗어나면 스케일이 엇나가거나 빌드가 실패합니다.

해결

  • 실제 서빙 분포에 맞춰 opt를 잡고, 캘리브레이션도 그 근처로 구성
  • 여러 입력 shape를 지원해야 하면 프로파일을 여러 개 만들거나, 배치/해상도 정책을 단순화
import tensorrt as trt

logger = trt.Logger(trt.Logger.INFO)
builder = trt.Builder(logger)
network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH))
parser = trt.OnnxParser(network, logger)

with open("model.onnx", "rb") as f:
    assert parser.parse(f.read())

config = builder.create_builder_config()
profile = builder.create_optimization_profile()

input_name = network.get_input(0).name
# 예시: NCHW
profile.set_shape(input_name, min=(1,3,224,224), opt=(8,3,224,224), max=(16,3,224,224))
config.add_optimization_profile(profile)

실수 4) ONNX export에서 연산이 깨지거나, opset과 플러그인 의존성을 과소평가

증상

  • TensorRT 파서가 특정 노드에서 실패
  • Unsupported operator 또는 Plugin required 류 에러
  • 빌드는 되지만 결과가 PyTorch/ONNXRuntime과 다름

원인

PyTorch에서 ONNX로 내보낼 때 일부 연산이:

  • 더 낮은 opset에서 비표준 형태로 변환되거나
  • Constant folding 과정에서 shape 추론이 꼬이거나
  • TensorRT가 지원하지 않는 연산으로 남습니다

특히 LayerNorm, 일부 Resize, GridSample, NonMaxSuppression 계열은 버전 조합에 따라 민감합니다.

해결

  • opset을 충분히 올리고, ONNXRuntime로 1차 검증
  • TensorRT 지원 연산 목록을 기준으로 모델을 단순화하거나 플러그인을 사용
  • 가능하면 ONNX 그래프에 simplify를 적용하되, 결과 검증 필수
import onnx
import onnxruntime as ort
import numpy as np

onnx_model = onnx.load("model.onnx")
onnx.checker.check_model(onnx_model)

sess = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])
x = np.random.randn(1,3,224,224).astype(np.float32)
out = sess.run(None, {sess.get_inputs()[0].name: x})
print([o.shape for o in out])

실수 5) INT8인데 FP16/TF32 설정과 레이어 정밀도 강제가 뒤섞여 성능이 역전된다

증상

  • INT8 엔진이 FP16 엔진보다 느림
  • GPU utilization은 낮고 latency가 들쭉날쭉

원인

INT8은 “모든 레이어가 다 INT8”이 아닙니다. TensorRT는 정확도/지원 여부에 따라 일부 레이어를 FP16 또는 FP32로 올립니다. 이때:

  • 재양자화 Q/DQ 비용이 커지거나
  • 메모리 포맷 변환이 자주 발생하거나
  • 특정 레이어가 FP32로 고정되어 병목이 됩니다

또한 Ampere 이후 환경에서 TF32, FP16, INT8이 섞일 때 예상과 다른 커널 선택이 일어나기도 합니다.

해결

  • 반드시 trtexec로 레이어별 정밀도와 타이밍을 확인
  • 병목 레이어가 특정 연산이면 모델 구조 변경이나 플러그인 고려
  • 성능 목표가 latency인지 throughput인지에 따라 배치와 프로파일을 재설계
# 엔진 빌드 및 레이어 타이밍 확인 예시
trtexec --onnx=model.onnx --int8 --fp16 \
  --minShapes=input:1x3x224x224 \
  --optShapes=input:8x3x224x224 \
  --maxShapes=input:16x3x224x224 \
  --dumpLayerInfo --dumpProfile --separateProfileRun

실수 6) 캘리브레이터 캐시를 “그대로 재사용”해서 데이터/전처리 변경이 반영되지 않는다

증상

  • 캘리브레이션 데이터를 늘렸는데 정확도가 그대로거나 오히려 나빠짐
  • 코드/전처리를 바꿨는데 결과가 변하지 않음

원인

TensorRT 캘리브레이션은 캐시 파일을 남길 수 있고, 동일한 네트워크/구성으로 판단되면 캐시를 재사용합니다. 그런데 데이터셋이나 전처리를 바꿨는데 캐시를 삭제하지 않으면 예전 스케일이 계속 적용됩니다.

해결

  • 캘리브레이션 캐시는 “실험 단위”로 버전 관리
  • 전처리/데이터/shape/profiling이 바뀌면 캐시를 폐기
from pathlib import Path

cache = Path("calib.cache")
if cache.exists():
    cache.unlink()  # 실험 조건이 바뀌면 과감히 삭제

운영 환경에서도 비슷한 문제가 생깁니다. 이미지/모델 아티팩트를 가져오는 과정에서 캐시가 꼬이면 재현이 어려워지는데, 쿠버네티스에서 이미지 풀 문제가 겹치면 더 혼란스럽습니다. 배포 파이프라인에서 당장 컨테이너가 안 내려받아져 실험을 못 하는 상황이라면 Kubernetes ImagePullBackOff·ErrImagePull 해결 체크리스트도 같이 점검해두면 좋습니다.

실수 7) 검증을 “정확도만” 보거나 “한두 배치”만 본다

증상

  • 오프라인에서 얼추 맞는 것 같아 배포했는데 실트래픽에서 오탐/미탐 급증
  • 특정 입력 크기, 특정 카메라, 특정 조명에서만 망가짐

원인

INT8은 입력 분포 변화에 민감합니다. 검증을 소량 샘플로만 하면:

  • long-tail 케이스에서의 오차 증가를 놓치고
  • 동적 shape에서 특정 구간만 스케일이 나빠지는 문제를 놓칩니다

또한 성능은 p50만 보면 안 되고 p95/p99가 중요합니다. INT8에서 일부 레이어가 FP32로 승격되거나 재양자화가 늘어나면 tail latency가 튈 수 있습니다.

해결

  • 최소한 다음을 같이 봅니다
    • FP16 대비 정확도 지표 하락폭
    • 입력 분포별 slice 평가
    • latency p50/p95/p99, throughput
  • 가능하면 ONNXRuntime, TensorRT FP16, TensorRT INT8을 같은 입력으로 비교
import time
import numpy as np
import onnxruntime as ort

def benchmark_ort(path: str, n: int = 200):
    sess = ort.InferenceSession(path, providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
    name = sess.get_inputs()[0].name
    x = np.random.randn(8,3,224,224).astype(np.float32)

    # warmup
    for _ in range(20):
        sess.run(None, {name: x})

    ts = []
    for _ in range(n):
        t0 = time.perf_counter()
        sess.run(None, {name: x})
        ts.append((time.perf_counter() - t0) * 1000)

    ts = np.array(ts)
    return float(np.percentile(ts, 50)), float(np.percentile(ts, 95)), float(np.percentile(ts, 99))

p50, p95, p99 = benchmark_ort("model.onnx")
print("ORT latency ms:", p50, p95, p99)

실전 체크리스트: 실패를 빨리 좁히는 순서

  1. PyTorch eval 고정, 입력 전처리 동일성 확인
  2. ONNX를 ONNXRuntime로 먼저 검증해서 “export 문제”를 분리
  3. TensorRT는 FP16 엔진을 먼저 만들고 결과 일치 확인
  4. INT8은 캘리브레이션 전처리/shape/pro파일을 고정하고 캐시를 버전 관리
  5. trtexec로 레이어 프로파일을 보고 INT8이 느려지는 병목을 확인
  6. 정확도는 slice 평가로, 성능은 p95/p99까지 본다

마무리

PyTorch → ONNX → TensorRT INT8에서 문제의 대부분은 “양자화 자체”보다 전처리/shape/캐시/검증 방법 같은 운영 디테일에서 발생합니다. 위 7가지는 재현과 디버깅이 어려운 편이라, 처음부터 체크리스트로 고정해두면 시행착오를 크게 줄일 수 있습니다.

배포 환경에서 실험 반복이 잦다면, 이미지 풀 실패나 레이트 리밋 같은 외부 요인도 함께 관리해야 합니다. 예를 들어 사내 레지스트리나 퍼블릭 레지스트리에서 당겨오는 과정이 불안정하면 INT8 실험 자체가 멈춰버리므로, 필요 시 EKS ImagePullBackOff 429 Too Many Requests 해결도 같이 참고해두면 좋습니다.