Published on

Triton FP16 배포에서 출력 NaN 디버깅 가이드

Authors

서빙 단계에서만 FP16 모델 출력이 NaN으로 변하는 문제는 생각보다 흔합니다. 학습/오프라인 추론에서는 정상인데 Triton에 올리면 특정 배치, 특정 입력에서만 NaN이 섞이거나 전체가 NaN이 되는 형태로 나타납니다. 원인은 크게 세 부류로 나뉩니다.

  • 수치 안정성 문제: FP16 범위/정밀도 한계로 exp, softmax, layernorm 같은 연산에서 오버플로/언더플로가 발생
  • 엔진/그래프 변환 문제: ONNX 변환, TensorRT 빌드 옵션, 플러그인/커널 선택으로 연산 순서나 축약 방식이 달라짐
  • 입출력/전처리 문제: 입력 dtype/스케일이 달라져 FP16에서만 발산하거나, 동적 shape/스트라이드/레이아웃이 꼬여 잘못된 메모리를 읽음

이 글은 “원인 후보를 체계적으로 줄이는” 방식으로 구성합니다. 먼저 재현을 고정하고, 그 다음 백엔드별로 어디에서 NaN이 생기는지 경계를 좁혀갑니다.

1) 증상 고정: 재현 가능한 최소 케이스 만들기

가장 먼저 할 일은 NaN이 나는 입력을 고정하는 것입니다. Triton은 배치/동시성/동적 shape에 따라 실행 경로가 바뀌어 재현이 흔들립니다.

1-1. 동일 입력을 바이너리로 저장

PyTorch에서 문제가 되는 입력을 저장해두면, Triton 클라이언트에서도 그대로 재사용할 수 있습니다.

# save_input.py
import torch

x = torch.randn(1, 3, 224, 224, device="cuda", dtype=torch.float32)
# 실제 서비스 전처리 결과를 그대로 저장하는 것이 핵심

torch.save({"x": x.cpu()}, "repro_input.pt")
print("saved")

1-2. Triton HTTP 클라이언트로 동일 입력 반복 호출

# triton_call.py
import numpy as np
import torch
import tritonclient.http as httpclient

data = torch.load("repro_input.pt")
x = data["x"].numpy().astype(np.float16)  # 일부러 FP16로 넣어보는 실험도 필요

client = httpclient.InferenceServerClient(url="localhost:8000")
inputs = [httpclient.InferInput("INPUT__0", x.shape, "FP16")]
inputs[0].set_data_from_numpy(x)

outputs = [httpclient.InferRequestedOutput("OUTPUT__0")]

for i in range(20):
    res = client.infer(model_name="my_model", inputs=inputs, outputs=outputs)
    y = res.as_numpy("OUTPUT__0")
    nan_ratio = np.isnan(y).mean()
    print(i, "nan_ratio=", float(nan_ratio), "min=", float(np.nanmin(y)), "max=", float(np.nanmax(y)))

여기서 중요한 건 다음 두 가지입니다.

  • 입력 dtype을 FP16/FP32 둘 다 넣어보기: 입력만 FP32로 바꿔도 NaN이 사라지면, 전처리 스케일이나 입력 범위가 FP16에서 터지는 신호입니다.
  • 동일 입력을 여러 번: 첫 호출만 NaN이면 워밍업/캐시/엔진 초기화 경로를 의심합니다.

2) Triton 설정/로그로 “어디서부터 NaN인가” 경계 좁히기

2-1. 서버 로그 레벨 올리기

컨테이너 실행 시 로그를 강화합니다.

docker run --gpus all --rm -p 8000:8000 -p 8001:8001 -p 8002:8002 \
  nvcr.io/nvidia/tritonserver:24.01-py3 \
  tritonserver --model-repository=/models \
  --log-verbose=1 --log-info=true --log-warning=true --log-error=true

TensorRT 백엔드를 쓴다면 빌드/로딩 로그에서 레이어 퓨전, 정밀도 선택, 플러그인 로딩 실패 등을 확인합니다.

2-2. config.pbtxt에서 dtype/shape를 명시적으로 고정

동적 shape가 섞이면 예상치 못한 커널이 선택되거나, 최적화 프로파일이 잘못 맞아 오동작이 나기도 합니다. 우선은 문제를 재현하는 shape만 고정해서 단순화합니다.

# models/my_model/config.pbtxt
name: "my_model"
platform: "tensorrt_plan"
max_batch_size: 0

input [
  {
    name: "INPUT__0"
    data_type: TYPE_FP16
    dims: [ 1, 3, 224, 224 ]
  }
]
output [
  {
    name: "OUTPUT__0"
    data_type: TYPE_FP16
    dims: [ 1000 ]
  }
]

instance_group [{ kind: KIND_GPU, count: 1 }]

max_batch_size를 켜둔 상태에서 클라이언트가 배치를 다르게 보내면 내부에서 reshape/concat이 발생할 수 있습니다. 원인 규명 단계에서는 max_batch_size: 0으로 고정하는 편이 좋습니다.

3) 가장 흔한 원인 1: FP16에서 Softmax/Exp 오버플로

FP16은 표현 가능한 최대값이 작고, exp가 쉽게 오버플로합니다. 특히 logits 스케일이 커지면 exp(logit)inf가 되고, 이후 inf/infNaN으로 번질 수 있습니다.

3-1. 오프라인에서 FP16으로 강제해 재현되는지 확인

import torch
import torch.nn.functional as F

logits = torch.randn(1, 1000, device="cuda") * 50  # 스케일을 키워보는 실험

fp16 = logits.half()
prob = F.softmax(fp16, dim=-1)
print("nan?", torch.isnan(prob).any().item(), "inf?", torch.isinf(prob).any().item())

오프라인에서도 재현되면 모델 자체의 수치 안정성 이슈입니다. 해결책은 보통 다음 중 하나입니다.

  • softmax 앞에 logits 클램프 또는 log-sum-exp 안정화 적용
  • softmax/LayerNorm 같은 민감 레이어만 FP32로 강제 (mixed precision)
  • 입력 스케일/정규화 재점검 (특히 이미지/오디오 전처리)

TensorRT에서는 일부 레이어를 FP32로 유지하도록 설정할 수 있지만, 그래프 변환 방식에 따라 제어가 까다롭습니다. 가능하면 모델 레벨에서 안정화하는 편이 장기적으로 안전합니다.

4) 가장 흔한 원인 2: TensorRT 빌드 옵션과 정밀도 선택

Triton에서 TensorRT plan을 올릴 때, 엔진 생성 옵션이 달라지면 결과가 바뀔 수 있습니다. 특히 다음 조합에서 문제가 자주 발생합니다.

  • FP16 활성화 + 특정 플러그인/커널 선택
  • dynamic shape + 최적화 프로파일 미스매치
  • --fp16만 켜고 --precisionConstraints나 레이어별 제어 없이 전부 FP16으로 내려감

4-1. trtexec로 엔진을 독립적으로 검증

Triton을 배제하고 TensorRT 엔진 자체가 NaN을 내는지 확인합니다.

# ONNX로부터 엔진 생성 및 간단 검증
trtexec --onnx=model.onnx --saveEngine=model_fp16.plan \
  --fp16 --verbose \
  --shapes=INPUT__0:1x3x224x224

# 엔진 실행
trtexec --loadEngine=model_fp16.plan --verbose \
  --shapes=INPUT__0:1x3x224x224

여기서도 NaN이 나오면 Triton 문제가 아니라 TensorRT 변환/커널 문제입니다.

4-2. 레퍼런스와 수치 비교(오차 허용) 체계 만들기

FP16은 FP32와 완전히 동일할 수 없습니다. 중요한 건 “허용 가능한 오차”인지 “발산(NaN/inf)”인지 구분하는 것입니다.

import numpy as np

def compare(fp32, fp16):
    fp32 = fp32.astype(np.float32)
    fp16 = fp16.astype(np.float32)
    diff = np.abs(fp32 - fp16)
    print("max_abs", float(diff.max()), "mean_abs", float(diff.mean()))
    print("nan_fp16", float(np.isnan(fp16).mean()), "inf_fp16", float(np.isinf(fp16).mean()))

오차가 커지는 건 튜닝 대상일 수 있지만, NaN/inf는 즉시 원인을 찾아야 합니다.

관련해서 양자화/가속 파이프라인을 이미 쓰고 있다면, TensorRT 변환 이슈와 수치 안정성 포인트가 겹치는 경우가 많습니다. 이전에 정리한 PyTorch에서 TensorRT INT8로 3배 가속하기도 함께 보면, 엔진 빌드/검증 루틴을 잡는 데 도움이 됩니다.

5) 가장 흔한 원인 3: 입력 전처리 스케일/범위가 FP16에 과격

서빙에서만 NaN이 나는 케이스의 상당수는 전처리 불일치입니다.

  • 학습/오프라인은 float32mean/std 정규화
  • 서빙은 uint8float16으로 바로 캐스팅 후 정규화
  • 또는 RGB/BGR 채널 순서가 뒤집혀 값 분포가 달라짐

5-1. Triton 앞단에서 입력 통계 로깅

가능하면 모델 앞에 Python backend 또는 전처리 서비스에서 입력 텐서 통계를 찍습니다.

# 입력 텐서 x: numpy
import numpy as np

print("dtype", x.dtype, "shape", x.shape)
print("min", float(x.min()), "max", float(x.max()), "mean", float(x.mean()))
print("nan", float(np.isnan(x).mean()), "inf", float(np.isinf(x).mean()))

FP16에서 max가 비정상적으로 크거나, 정규화 과정에서 0으로 나눌 가능성이 있으면 바로 후보가 됩니다.

6) ONNX Runtime/Ensemble/Python backend에서의 함정

Triton은 백엔드에 따라 dtype 캐스팅과 연산 커널이 달라집니다.

  • ONNX Runtime GPU EP: 특정 연산이 FP16에서만 다른 커널을 타며 NaN이 발생할 수 있음
  • Ensemble: 전처리 단계에서 FP32로 계산해야 할 것을 FP16으로 해버리는 구성
  • Python backend: numpy 연산이 float16으로 수행되며 오버플로가 빨리 발생

6-1. Ensemble에서 중간 텐서를 FP32로 고정

Ensemble을 쓴다면 중간 텐서 dtype을 명확히 정의합니다. (모델 간 연결에서 암묵적 캐스팅이 생기지 않게)

# 개념 예시: 전처리 모델 출력은 FP32, 본 모델 입력에서 FP16으로 다운캐스트
output [
  { name: "PRE_OUT" data_type: TYPE_FP32 dims: [ 1, 3, 224, 224 ] }
]

“전처리는 FP32, 본 추론은 FP16” 같은 경계가 명확하면 NaN 원인 추적이 쉬워집니다.

7) 동시성/배치에서만 NaN: 메모리/shape/프로파일 의심

단일 요청에서는 정상인데 동시 요청에서만 NaN이 나오면, 수치 문제보다 shape 프로파일 미스매치 또는 버퍼 재사용/스트라이드 문제일 가능성이 올라갑니다.

체크 포인트:

  • dynamic shape 모델이라면 TensorRT optimization profile이 요청 shape를 커버하는지
  • max_batch_size와 클라이언트 배치가 일치하는지
  • 입력이 contiguous인지(특히 PyTorch에서 transpose 후 .contiguous() 없이 넘기는 경우)

7-1. 요청 shape를 강제하고 비교

클라이언트에서 shape를 완전히 고정해보고, 다음을 바꿔가며 실험합니다.

  • batch=1 고정
  • 동시성 concurrency=1 고정
  • 그 다음에만 concurrency를 올려서 재현되는 임계점을 찾기

부하/오토스케일 환경에서만 재현된다면, 시스템 레벨 리소스 문제도 같이 봐야 합니다. 예를 들어 파일 디스크립터 고갈이나 소켓 이슈가 로그를 왜곡하거나 재시도를 유발해 “특정 요청만 망가진 것처럼” 보이게 만들기도 합니다. 인프라 관점에서는 리눅스 Too many open files 해결 - ulimit·systemd·Nginx 같은 체크리스트가 의외로 도움이 됩니다.

8) 실전 디버깅 루트(권장 순서)

문제 해결 시간을 줄이려면 아래 순서가 효율적입니다.

  1. 동일 입력 고정: 재현 입력을 파일로 저장
  2. Triton 외부에서 FP16 재현 여부 확인: PyTorch FP16, ONNX Runtime FP16, trtexec 순으로
  3. 입력 범위/전처리 검증: dtype, min/max, 정규화 분모 0 여부
  4. 민감 연산 FP32 유지: softmax, layernorm, exp/log 계열
  5. dynamic shape 단순화: 문제 shape만 고정, 프로파일 재생성
  6. 동시성/배치 분리: concurrency=1에서 정상인지 확인 후 점진적 증가

이 과정을 거치면 “모델 수치 문제인지, 변환/엔진 문제인지, 서빙 구성 문제인지”가 대부분 명확해집니다.

9) 마무리: NaN은 증상이고, 원인은 경계에서 드러난다

FP16 NaN은 보통 한 지점에서 시작해 파이프라인 전체로 전염됩니다. 핵심은 경계를 잘라가며 최초 발생 지점을 찾는 것입니다. Triton 배포에서는 그 경계가 보통 세 군데입니다.

  • 입력 전처리 직후
  • 변환된 그래프/엔진 내부의 특정 연산(softmax, norm, exp)
  • dynamic shape/프로파일/동시성으로 실행 경로가 바뀌는 지점

위 체크리스트대로 “재현 고정 → 백엔드 분리 → 입력/수치/프로파일 점검”을 하면, 대부분의 NaN 케이스는 하루 안에 원인을 특정할 수 있습니다.