Published on

PyTorch→ONNX→TensorRT INT8 양자화 오류 7가지

Authors

서빙 환경에서 지연 시간을 줄이기 위해 PyTorch → ONNX → TensorRT 파이프라인으로 최적화하다 보면, FP16까지는 비교적 매끄럽게 가지만 INT8에서 갑자기 빌드가 실패하거나 정확도가 급락하는 경우가 많습니다. 특히 INT8은 캘리브레이션 데이터, 연산자 지원 여부, 정밀도 전파 규칙, 동적 shape, Q/DQ(Quantize/Dequantize) 그래프 형태에 따라 실패 양상이 달라집니다.

이 글은 “어떤 오류가 왜 생기고, 무엇을 확인해야 하는지”를 7가지 대표 케이스로 정리합니다. 각 섹션은 (1) 증상, (2) 원인, (3) 해결 순서, (4) 최소 코드 예제로 구성했습니다.

디버깅 글을 쓰다 보면 결국 공통은 “재현 가능한 체크리스트”입니다. 장애를 단계적으로 좁혀가는 방식은 PostgreSQL deadlock detected 진단·해결 9단계 같은 글의 접근과 유사합니다. INT8도 결국 관찰 지점과 가설을 잘 세우는 게 핵심입니다.

준비: 버전과 기본 커맨드(재현 가능한 상태 만들기)

INT8 문제는 버전 조합 의존성이 큽니다. 최소한 아래를 고정하거나 기록하세요.

  • CUDA / cuDNN
  • TensorRT 버전
  • ONNX opset
  • onnxruntime 또는 polygraphy 버전

아래는 “ONNX 검사 → TensorRT 빌드”의 최소 루틴입니다.

# 1) ONNX 구조/연산자 검증
python -c "import onnx; m=onnx.load('model.onnx'); onnx.checker.check_model(m); print('OK')"

# 2) onnxsim (가능하면)로 단순화
python -m onnxsim model.onnx model.sim.onnx

# 3) trtexec로 빠르게 빌드/프로파일
trtexec --onnx=model.sim.onnx --saveEngine=model.int8.engine \
  --int8 --fp16 \
  --minShapes=input:1x3x224x224 --optShapes=input:8x3x224x224 --maxShapes=input:16x3x224x224 \
  --workspace=4096 --verbose

trtexec는 실패 지점을 로그로 잘 보여주므로, Python API로 들어가기 전에 먼저 여기서 재현하는 게 좋습니다.

오류 1) INT8 캘리브레이션 데이터 부족 또는 대표성 문제

증상

  • 엔진 빌드는 되지만 정확도가 크게 하락
  • 특정 클래스만 붕괴하거나 출력 분포가 비정상
  • 로그에 calibration cache 관련 메시지가 반복

원인

TensorRT의 PTQ(Post-Training Quantization) INT8은 캘리브레이션 데이터로 activation 범위를 추정합니다. 데이터가 너무 적거나 분포가 실제 트래픽과 다르면 스케일이 틀어져 정확도가 급락합니다.

해결

  1. 캘리브레이션 샘플 수를 늘립니다(경험적으로 수백~수천 권장).
  2. 전처리(정규화, 리사이즈, 색공간)가 학습/서빙과 동일한지 확인합니다.
  3. calibration cache를 저장하고 재사용해 반복 실험을 빠르게 합니다.

코드 예시(Python Calibrator 스켈레톤)

import numpy as np
import tensorrt as trt
import pycuda.driver as cuda
import pycuda.autoinit

class EntropyCalibrator(trt.IInt8EntropyCalibrator2):
    def __init__(self, dataloader, input_shape, cache_file="calib.cache"):
        super().__init__()
        self.dataloader = dataloader
        self.input_shape = input_shape
        self.cache_file = cache_file
        self.device_input = cuda.mem_alloc(np.prod(input_shape) * np.float32().nbytes)
        self.iterator = iter(dataloader)

    def get_batch_size(self):
        return self.input_shape[0]

    def get_batch(self, names):
        try:
            batch = next(self.iterator)  # batch: numpy float32, already preprocessed
        except StopIteration:
            return None
        cuda.memcpy_htod(self.device_input, batch)
        return [int(self.device_input)]

    def read_calibration_cache(self):
        try:
            with open(self.cache_file, "rb") as f:
                return f.read()
        except OSError:
            return None

    def write_calibration_cache(self, cache):
        with open(self.cache_file, "wb") as f:
            f.write(cache)

오류 2) Q/DQ 그래프가 아니거나, Q/DQ가 깨진 ONNX

증상

  • --int8를 켰는데도 일부 레이어가 FP16/FP32로 남음
  • trtexec 로그에 Layer ... running in FP16/FP32가 다수
  • 특정 노드에서 QuantizeLinear/DequantizeLinear 처리 실패

원인

INT8 경로는 크게 두 가지입니다.

  • PTQ: TensorRT가 캘리브레이션으로 스케일을 추정
  • QAT: ONNX에 QuantizeLinear/DequantizeLinear(Q/DQ)가 포함

문제는 PyTorch에서 내보낸 ONNX가 “의도한 형태의 Q/DQ”가 아니거나, export/변환 과정에서 Q/DQ가 최적화로 사라지거나 잘못 접히는 경우입니다.

해결

  1. 목적이 PTQ면 Q/DQ 없이도 되지만, 연산자/정밀도 전파 때문에 레이어가 FP32로 남을 수 있습니다. 이 경우 “지원되는 INT8 커널”과 “정밀도 강제”를 함께 봐야 합니다.
  2. QAT 모델이라면 ONNX에 Q/DQ가 유지되는지 onnx_graphsurgeon이나 Netron으로 확인합니다.
  3. onnxsim이 Q/DQ를 과하게 접는 경우가 있어, 단순화 옵션을 조정하거나 단순화를 생략합니다.

코드 예시(ONNX에서 Q/DQ 노드 개수 확인)

import onnx

m = onnx.load("model.onnx")
q = sum(1 for n in m.graph.node if n.op_type == "QuantizeLinear")
dq = sum(1 for n in m.graph.node if n.op_type == "DequantizeLinear")
print("QuantizeLinear:", q)
print("DequantizeLinear:", dq)

오류 3) 동적 shape에서 캘리브레이션 shape 불일치

증상

  • 빌드 실패 로그에 profile 또는 dynamic shapes 관련 메시지
  • get_batch에서 반환한 텐서 shape가 profile과 달라 실패
  • 엔진은 만들어졌는데 특정 배치/해상도에서 런타임 에러

원인

TensorRT에서 동적 shape를 쓰면 optimization profile이 필요합니다. INT8 캘리브레이션은 특정 profile과 입력 shape에 종속적입니다.

  • 캘리브레이션 배치가 optShapes와 다르거나
  • min/opt/max 범위를 벗어나거나
  • 입력 바인딩 이름이 다르거나
  • 다중 입력 모델에서 일부 입력만 profile을 지정한 경우

실패가 납니다.

해결

  1. trtexec로 profile을 명시하고, 캘리브레이션 데이터도 optShapes로 맞춥니다.
  2. 다중 입력이면 모든 입력에 대해 min/opt/max를 지정합니다.
  3. Python API에서는 IOptimizationProfile에 동일하게 반영합니다.

코드 예시(trtexec에서 다중 입력 profile)

trtexec --onnx=model.onnx --int8 \
  --minShapes=image:1x3x224x224,mask:1x1x224x224 \
  --optShapes=image:8x3x224x224,mask:8x1x224x224 \
  --maxShapes=image:16x3x224x224,mask:16x1x224x224 \
  --saveEngine=model.engine --verbose

오류 4) INT8 미지원 연산자 또는 플러그인 누락

증상

  • 빌드 실패: No implementation found for node ...
  • 또는 빌드는 되는데 해당 구간이 FP32로 강등(fallback)
  • 특정 레이어에서만 성능이 기대보다 낮음

원인

TensorRT는 모든 ONNX 연산자를 동일하게 지원하지 않습니다. 특히 다음이 자주 문제를 만듭니다.

  • Resize의 특정 mode/coordinate 변형
  • LayerNorm, GroupNorm 계열(구현/버전에 따라 다름)
  • NonMaxSuppression 또는 후처리 그래프
  • 커스텀 연산자(플러그인 필요)

INT8은 FP16/FP32보다 제약이 더 크기 때문에, “지원은 되지만 INT8 커널이 없다” 같은 케이스도 흔합니다.

해결

  1. 먼저 FP16 엔진이 100%로 잘 되는지 확인한 뒤 INT8을 켭니다.
  2. 문제 노드를 특정하려면 --verbose 로그에서 강등되는 레이어를 찾습니다.
  3. 대체 연산자로 그래프를 바꾸거나(예: LayerNorm을 primitive로 분해), 플러그인을 작성/적용합니다.
  4. 후처리(NMS 등)는 GPU 커널로 분리하거나 TensorRT 밖으로 빼는 것도 방법입니다.

코드 예시(강등 레이어 찾기 팁)

trtexec --onnx=model.onnx --int8 --verbose 2>&1 | tee build.log
# build.log에서 "running in FP32" 또는 "falling back" 같은 키워드로 검색

오류 5) per-tensor vs per-channel 스케일 문제로 정확도 붕괴

증상

  • 빌드는 성공하지만 특정 레이어 이후 출력이 포화(saturation)
  • Conv 계열에서 정확도가 크게 떨어짐

원인

INT8에서 가중치 스케일을 per-tensor로 잡으면 채널별 분포 차이를 반영하지 못해 손실이 커질 수 있습니다. QAT 모델은 보통 per-channel을 사용하지만, 변환 과정에서 per-tensor로 바뀌거나 스케일이 잘못 전파되면 문제가 됩니다.

해결

  1. 가능하면 QAT를 고려하거나, PTQ라도 per-channel weight quantization이 가능한 경로를 선택합니다(도구/버전에 따라 지원이 다름).
  2. 민감 레이어(첫 Conv, 마지막 FC 등)를 INT8에서 제외하고 FP16으로 남기는 혼합 정밀도를 시도합니다.

코드 예시(혼합 정밀도: 특정 레이어 FP16 유지)

import tensorrt as trt

def set_layer_precision(network: trt.INetworkDefinition, keep_fp16_names):
    for i in range(network.num_layers):
        layer = network.get_layer(i)
        if layer.name in keep_fp16_names:
            layer.precision = trt.DataType.HALF
            for o in range(layer.num_outputs):
                layer.set_output_type(o, trt.DataType.HALF)

# 예: 첫/마지막 레이어를 FP16으로 고정
keep_fp16 = {"conv1", "fc"}

오류 6) 입력 dtype/스케일 불일치(전처리 차이 포함)

증상

  • INT8에서만 정확도 급락, FP16/FP32는 정상
  • 캘리브레이션은 성공했는데 실서빙에서만 성능/정확도 문제

원인

INT8은 “입력 분포”에 매우 민감합니다. 흔한 실수는 다음과 같습니다.

  • 캘리브레이션 데이터 전처리와 실서빙 전처리가 다름
  • 입력이 NCHW인데 NHWC로 들어감
  • uint8 이미지를 넣으면서 정규화가 빠짐
  • RGB/BGR 채널 순서가 뒤바뀜

FP16/FP32는 어느 정도 흡수되지만, INT8은 스케일이 고정되면서 오차가 크게 드러납니다.

해결

  1. 캘리브레이션 파이프라인과 서빙 파이프라인을 동일 코드로 공유합니다.
  2. 엔진 입력 바인딩의 dtype/shape를 런타임에 출력해 확인합니다.
  3. 가능하면 캘리브레이션 데이터 일부를 그대로 서빙 입력으로 재사용해 A/B 비교합니다.

코드 예시(엔진 바인딩 점검)

import tensorrt as trt

def dump_bindings(engine: trt.ICudaEngine):
    for i in range(engine.num_io_tensors):
        name = engine.get_tensor_name(i)
        mode = engine.get_tensor_mode(name)
        dtype = engine.get_tensor_dtype(name)
        shape = engine.get_tensor_shape(name)
        print(name, mode, dtype, shape)

오류 7) INT8 플래그는 켰지만 실제로는 INT8이 거의 적용되지 않음

증상

  • 엔진 크기/지연 시간이 FP16과 거의 동일
  • --int8인데도 레이어 대부분이 FP16/FP32
  • 기대한 TPS가 안 나옴

원인

INT8은 “엔진 전체를 INT8로 바꾸는 스위치”가 아니라, 가능한 레이어만 INT8로 내려갑니다. 아래 조건이 하나라도 깨지면 INT8 적용 범위가 급격히 줄어듭니다.

  • 캘리브레이션 미제공 또는 cache 불일치
  • 특정 연산자/플러그인이 INT8 미지원
  • 동적 shape에서 profile/캘리브레이션이 꼬임
  • 정밀도 제약(예: 출력은 FP16이어야 한다든지) 때문에 전파가 막힘

해결

  1. trtexec --verbose로 실제 레이어별 정밀도를 확인합니다.
  2. 병목 구간 레이어가 INT8로 내려가는지 먼저 확인하고, 안 내려가면 그 레이어의 연산자/형태를 바꿉니다.
  3. “정확도 민감 레이어만 FP16” 전략으로 타협점을 찾습니다.

코드 예시(Polygraphy로 정밀도/레이어 분석에 도움)

polygraphy inspect model model.onnx --show layers --show attrs
polygraphy run model.onnx --trt --int8 --save-engine=model.engine --verbose

실전 디버깅 체크리스트(7가지를 빠르게 훑는 순서)

  1. FP16 엔진이 정상인지: INT8 이전에 그래프/연산자 호환성을 먼저 확보
  2. 동적 shape/profile 정합성: min/opt/max와 캘리브레이션 배치 shape 일치
  3. 캘리브레이션 데이터 품질: 전처리 포함 대표성 확보, cache 재사용
  4. 강등 레이어 확인: --verbose로 FP32 fallback 구간을 식별
  5. Q/DQ 여부 확인: QAT라면 Q/DQ가 유지되는지 점검
  6. 입력 dtype/스케일 검증: 서빙 입력이 캘리브레이션 분포와 동일한지
  7. 혼합 정밀도 전략: 민감 레이어만 FP16 유지로 정확도/성능 절충

장애를 좁혀가는 과정은 결국 “로그 기반으로 가설을 세우고, 한 번에 한 변수만 바꾸는 것”이 가장 빠릅니다. 빌드/배포 파이프라인에서 캐시가 꼬이거나(예: 오래된 캘리브레이션 cache 재사용), 환경이 미묘하게 달라져 재현이 안 되는 문제도 잦으니, CI에서 재현성을 높이는 습관도 중요합니다. 이런 류의 재현/캐시 이슈는 GitHub Actions 캐시가 안 먹을 때 - 키 전략·디버깅 같은 접근이 도움이 됩니다.

마무리

PyTorch → ONNX → TensorRT INT8에서 발생하는 오류는 겉으로는 다양하지만, 대부분은 (1) 캘리브레이션, (2) 지원 연산자, (3) shape/profile, (4) 정밀도 전파 네 축으로 수렴합니다. 위 7가지를 순서대로 점검하면 “왜 INT8이 실패했는지”를 로그와 그래프 구조로 설명할 수 있게 되고, 그때부터는 해결도 빨라집니다.

다음 단계로는 모델별로 “INT8 이득이 큰 구간”을 찾아 QAT로 전환하거나, 후처리를 분리해 엔진을 단순화하는 최적화가 실전에서 효과가 큽니다.