Ultralytics YOLO27:

NVIDIA DALI를 사용한 GPU 가속 전처리#

프로덕션 환경에 Ultralytics YOLO 모델을 배포할 때 전처리가 병목이 되는 경우가 많습니다. TensorRT는 모델 추론을 몇 밀리초 만에 실행할 수 있지만, CPU 기반 전처리(크기 조정, 패딩, 정규화)에는 이미지당 2~10ms가 걸릴 수 있으며, 특히 해상도가 높은 경우 더욱 그렇습니다. NVIDIA DALI(데이터 로딩 라이브러리)는 전체 전처리 파이프라인을 GPU로 옮겨 이 문제를 해결합니다.

이 가이드에서는 Ultralytics YOLO 전처리를 정확하게 재현하는 DALI 파이프라인을 구축하고, model.predict()과 통합하며, 비디오 스트림을 처리하고, Triton Inference Server를 사용해 엔드투엔드로 배포하는 방법을 안내합니다.

이 가이드는 누구를 위한 것인가요?

이 가이드는 CPU 전처리가 측정 가능한 병목인 프로덕션 환경에 YOLO 모델을 배포하는 엔지니어를 위한 것입니다. 일반적으로 TensorRT를 NVIDIA GPU에 배포하거나, 처리량이 높은 비디오 파이프라인 또는 Triton Inference Server 환경을 구축하는 경우가 해당합니다. model.predict()로 일반적인 추론을 실행하고 있으며 전처리 병목이 없다면 기본 CPU 파이프라인으로도 충분히 잘 작동합니다.

빠른 요약
  • DALI 파이프라인을 구축하시나요? fn.resize(mode="not_larger") + fn.crop(out_of_bounds_policy="pad") + fn.crop_mirror_normalize를 사용해 YOLO의 레터박스 전처리를 GPU에서 재현하세요.
  • Ultralytics와 통합하시나요? DALI 출력을 torch.Tensor으로 model.predict()에 전달하면 Ultralytics가 이미지 전처리를 자동으로 건너뜁니다.
  • Triton으로 배포하시나요? DALI 백엔드와 TensorRT 앙상블을 사용하면 CPU 전처리를 없앨 수 있습니다.

YOLO 전처리에 DALI를 사용하는 이유#

일반적인 YOLO 추론 파이프라인에서는 전처리 단계가 CPU에서 실행됩니다.

  1. 이미지 디코딩 (JPEG/PNG)
  2. 종횡비를 유지하며 크기 조정
  3. 대상 크기에 맞게 패딩 (레터박스)
  4. 픽셀 값을 [0, 255]에서 [0, 1] 범위로 정규화
  5. HWC에서 CHW로 레이아웃 변환

DALI를 사용하면 이러한 작업을 모두 GPU에서 실행하여 CPU 병목을 제거할 수 있습니다. 다음과 같은 경우 특히 유용합니다.

시나리오DALI의 장점
GPU 추론 속도가 빠른 경우밀리초 미만의 추론을 수행하는 TensorRT 엔진에서는 CPU 전처리가 주요 비용이 됩니다.
고해상도 입력1080p 및 4K 비디오 스트림은 비용이 많이 드는 크기 조정 작업이 필요합니다.
큰 배치 크기서버 측 추론에서 여러 이미지를 병렬로 처리하는 경우
제한된 CPU 코어 수NVIDIA Jetson과 같은 엣지 디바이스 또는 GPU당 CPU 코어 수가 적은 고밀도 GPU 서버

사전 요구 사항#

Linux 전용

NVIDIA DALI는 Linux만 지원합니다. Windows 또는 macOS에서는 사용할 수 없습니다.

필수 패키지를 설치합니다.

pip install ultralytics
pip install --extra-index-url https://pypi.nvidia.com nvidia-dali-cuda130

요구 사항:

  • NVIDIA GPU(컴퓨팅 성능 5.0 이상 / Maxwell 이상)
  • CUDA 11.0+, 12.0+ 또는 13.0+
  • Python 3.10~3.14
  • Linux 운영 체제

YOLO 전처리 이해하기#

DALI 파이프라인을 구축하기 전에 Ultralytics가 전처리 과정에서 정확히 어떤 작업을 수행하는지 알아두는 것이 좋습니다. 핵심 클래스는 ultralytics/data/augment.py의 LetterBox입니다.

from ultralytics.data.augment import LetterBox

letterbox = LetterBox(
    new_shape=(640, 640),  # 대상 크기
    center=True,  # 이미지를 가운데에 배치합니다(양쪽에 동일하게 패딩).
    stride=32,  # 스트라이드 정렬
    padding_value=114,  # 회색 패딩(114, 114, 114)
)

ultralytics/engine/predictor.py의 전체 전처리 파이프라인은 다음 단계를 수행합니다.

단계작업CPU 함수DALI 대응 작업
1레터박스 크기 조정cv2.resizefn.resize(mode="not_larger")
2중앙 패딩cv2.copyMakeBorderfn.crop(out_of_bounds_policy="pad")
3BGR → RGBim[..., ::-1]fn.decoders.image(output_type=types.RGB)
4HWC → CHW + /255 정규화np.transpose + tensor / 255fn.crop_mirror_normalize(std=[255,255,255])

레터박스 작업은 다음 방식으로 종횡비를 유지합니다.

  1. 스케일 계산: r = min(target_h / h, target_w / w)
  2. (round(w * r), round(h * r))으로 크기 조정
  3. 대상 크기에 맞도록 남은 공간을 회색(114)으로 패딩
  4. 이미지를 가운데에 배치하여 양쪽에 패딩이 동일하게 분배되도록 합니다.

YOLO용 DALI 파이프라인#

권장 DALI 파이프라인은 표준 YOLO 추론에서 사용하는 Ultralytics의 기본 LetterBox(center=True) 동작을 재현합니다.

중앙 정렬 파이프라인(권장, Ultralytics LetterBox와 일치)#

이 버전은 중앙 패딩을 적용하는 Ultralytics 기본 전처리를 정확하게 재현하며, LetterBox(center=True)과 일치합니다.

중앙 패딩을 적용하는 DALI 파이프라인(권장)
from nvidia import dali
from nvidia.dali import fn, types

@dali.pipeline_def(batch_size=8, num_threads=4, device_id=0)
def yolo_dali_pipeline_centered(image_dir, target_size=640):
    """DALI pipeline replicating YOLO preprocessing with centered padding.

    Matches Ultralytics LetterBox(center=True) behavior exactly.
    """
    # GPU에서 이미지 읽기 및 디코딩
    jpegs, _ = fn.readers.file(file_root=image_dir, random_shuffle=False, name="Reader")
    images = fn.decoders.image(jpegs, device="mixed", output_type=types.RGB)

    # 종횡비를 유지하는 크기 조정
    resized = fn.resize(
        images,
        resize_x=target_size,
        resize_y=target_size,
        mode="not_larger",
        interp_type=types.INTERP_LINEAR,
        antialias=False,  # cv2.INTER_LINEAR와 일치(안티앨리어싱 없음)
    )

    # out_of_bounds_policy가 지정된 fn.crop을 사용한 중앙 패딩
    # 자르기 크기가 이미지 크기보다 크면 fn.crop이 이미지를 가운데에 배치하고 대칭으로 패딩합니다.
    padded = fn.crop(
        resized,
        crop=(target_size, target_size),
        out_of_bounds_policy="pad",
        fill_values=114,  # YOLO 패딩 값
    )

    # 정규화 및 레이아웃 변환
    output = fn.crop_mirror_normalize(
        padded,
        dtype=types.FLOAT,
        output_layout="CHW",
        mean=[0.0, 0.0, 0.0],
        std=[255.0, 255.0, 255.0],
    )
    return output
`fn.pad`만으로 충분한 경우는 언제인가요?

정확한 LetterBox(center=True) 호환성이 필요하지 않다면 fn.crop(..., out_of_bounds_policy="pad") 대신 fn.pad(...)을 사용해 패딩 단계를 단순화할 수 있습니다. 이 변형은 오른쪽 및 아래쪽 가장자리만 패딩하므로 사용자 지정 배포 파이프라인에는 적합할 수 있지만, Ultralytics 기본 중앙 정렬 레터박스 동작과 정확히 일치하지는 않습니다.

중앙 패딩에 `fn.crop`을 사용하는 이유

DALI의 fn.pad 연산자는 오른쪽 및 아래쪽 가장자리에만 패딩을 추가합니다. 중앙 패딩(Ultralytics LetterBox(center=True)과 일치)을 적용하려면 out_of_bounds_policy="pad"과 함께 fn.crop를 사용하세요. 기본 crop_pos_x=0.5 및 crop_pos_y=0.5를 사용하면 이미지가 자동으로 가운데에 배치되고 대칭으로 패딩됩니다.

안티앨리어싱 불일치

DALI의 fn.resize은 기본적으로 안티앨리어싱을 활성화하지만(antialias=True), INTER_LINEAR을 사용하는 OpenCV의 cv2.resize는 안티앨리어싱을 적용하지 않습니다. CPU 파이프라인과 일치하도록 DALI에서 항상 antialias=False를 설정하세요. 이를 생략하면 미세한 수치 차이가 발생하여 모델 정확도에 영향을 미칠 수 있습니다.

파이프라인 실행하기#

DALI 파이프라인 빌드 및 실행
# Build and run the pipeline
pipe = yolo_dali_pipeline_centered(image_dir="/path/to/images", target_size=640)
pipe.build()

# Get a batch of preprocessed images
(output,) = pipe.run()

# Convert to numpy or PyTorch tensors
batch_np = output.as_cpu().as_array()  # Shape: (batch_size, 3, 640, 640)
print(f"Output shape: {batch_np.shape}, dtype: {batch_np.dtype}")
print(f"Value range: [{batch_np.min():.4f}, {batch_np.max():.4f}]")

Ultralytics Predict에서 DALI 사용하기#

전처리된 PyTorch 텐서를 model.predict()에 직접 전달할 수 있습니다. torch.Tensor를 전달하면 Ultralytics는 이미지 전처리(레터박스, BGR→RGB, HWC→CHW 및 /255 정규화)를 건너뛰고, 모델에 전달하기 전에 장치 전송과 dtype 변환만 수행합니다.

이 경우 Ultralytics는 원본 이미지 크기에 접근할 수 없으므로 감지 상자 좌표는 640×640 레터박스 공간을 기준으로 반환됩니다. 좌표를 원본 이미지 좌표로 변환하려면 LetterBox에서 사용하는 정확한 반올림 로직을 처리하는 scale_boxes을 사용하세요.

from ultralytics.utils.ops import scale_boxes

# 상자: 640x640 레터박스 좌표계의 xyxy 형식, 크기 (N, 4)인 텐서
# 레터박스 (640, 640)에서 원본 (orig_h, orig_w)으로 상자 스케일 조정
boxes = scale_boxes((640, 640), boxes, (orig_h, orig_w))

이 내용은 직접 텐서 입력, 비디오 스트림, Triton 배포 등 모든 외부 전처리 경로에 적용됩니다.

DALI + Ultralytics predict
from nvidia.dali.plugin.pytorch import DALIGenericIterator

from ultralytics import YOLO

# Load model
model = YOLO("yolo26n.pt")

# Create DALI iterator
pipe = yolo_dali_pipeline_centered(image_dir="/path/to/images", target_size=640)
pipe.build()
dali_iter = DALIGenericIterator(pipe, ["images"], reader_name="Reader")

# Run inference with DALI-preprocessed tensors
for batch in dali_iter:
    images = batch[0]["images"]  # Already on GPU, shape (B, 3, 640, 640)
    results = model.predict(images, verbose=False)
    for result in results:
        print(f"Detected {len(result.boxes)} objects")
전처리 오버헤드 없음

torch.Tensor을 model.predict()에 전달하면 이미지 전처리 단계가 CPU 전처리의 ~1~10ms에 비해 ~0.004ms(사실상 0)에 불과합니다. 텐서는 BCHW 형식의 float32(또는 float16)여야 하며 [0, 1]로 정규화되어 있어야 합니다. Ultralytics는 장치 전송과 dtype 변환을 계속 자동으로 처리합니다.

비디오 스트림에서 DALI 사용하기#

실시간 비디오 처리에는 fn.external_source을 사용해 OpenCV, GStreamer 또는 사용자 지정 캡처 라이브러리 등 모든 소스의 프레임을 공급하세요.

비디오 스트림 전처리를 위한 DALI 파이프라인
from nvidia import dali
from nvidia.dali import fn, types

@dali.pipeline_def(batch_size=1, num_threads=4, device_id=0)
def yolo_video_pipeline(target_size=640):
    """DALI pipeline for processing video frames from external source."""
    # OpenCV, GStreamer 등에서 프레임을 전달하기 위한 외부 소스
    frames = fn.external_source(device="cpu", name="input")
    frames = fn.reshape(frames, layout="HWC")

    # GPU로 이동하고 전처리 수행
    frames_gpu = frames.gpu()
    resized = fn.resize(
        frames_gpu,
        resize_x=target_size,
        resize_y=target_size,
        mode="not_larger",
        interp_type=types.INTERP_LINEAR,
        antialias=False,
    )
    padded = fn.crop(
        resized,
        crop=(target_size, target_size),
        out_of_bounds_policy="pad",
        fill_values=114,
    )
    output = fn.crop_mirror_normalize(
        padded,
        dtype=types.FLOAT,
        output_layout="CHW",
        mean=[0.0, 0.0, 0.0],
        std=[255.0, 255.0, 255.0],
    )
    return output

DALI를 사용하는 Triton Inference Server#

프로덕션 배포에서는 DALI 전처리와 TensorRT 추론을 Triton Inference Server의 앙상블 모델로 결합합니다. 이렇게 하면 CPU 전처리를 완전히 제거할 수 있습니다. 원시 JPEG 바이트를 입력하면 탐지 결과가 출력되며, 모든 처리는 GPU에서 수행됩니다.

모델 리포지토리 구조#

model_repository/
├── dali_preprocessing/
│   ├── 1/
│   │   └── model.dali
│   └── config.pbtxt
├── yolo_trt/
│   ├── 1/
│   │   └── model.plan
│   └── config.pbtxt
└── ensemble_dali_yolo/
    ├── 1/                  # Empty directory (required by Triton)
    └── config.pbtxt

1단계: DALI 파이프라인 생성#

Triton DALI 백엔드에서 사용할 DALI 파이프라인을 직렬화합니다:

Triton용 DALI 파이프라인 직렬화
from nvidia import dali
from nvidia.dali import fn, types

@dali.pipeline_def(batch_size=8, num_threads=4, device_id=0)
def triton_dali_pipeline():
    """DALI preprocessing pipeline for Triton deployment."""
    # 입력: Triton에서 전달된 인코딩된 이미지 바이트
    images = fn.external_source(device="cpu", name="DALI_INPUT_0")
    images = fn.decoders.image(images, device="mixed", output_type=types.RGB)

    resized = fn.resize(
        images,
        resize_x=640,
        resize_y=640,
        mode="not_larger",
        interp_type=types.INTERP_LINEAR,
        antialias=False,
    )
    padded = fn.crop(
        resized,
        crop=(640, 640),
        out_of_bounds_policy="pad",
        fill_values=114,
    )
    output = fn.crop_mirror_normalize(
        padded,
        dtype=types.FLOAT,
        output_layout="CHW",
        mean=[0.0, 0.0, 0.0],
        std=[255.0, 255.0, 255.0],
    )
    return output

# 파이프라인을 모델 리포지토리에 직렬화
pipe = triton_dali_pipeline()
pipe.serialize(filename="model_repository/dali_preprocessing/1/model.dali")

2단계: YOLO를 TensorRT로 내보내기#

YOLO 모델을 TensorRT 엔진으로 내보내기
from pathlib import Path

from ultralytics import YOLO

model = YOLO("yolo26n.pt")
engine_path = model.export(
    format="engine", imgsz=640, quantize=16, batch=8, dynamic=True, nms=False
)  # NMS가 없는 (N, 300, 6); TensorRT >= 8.5

# Ultralytics는 .engine 파일에 메타데이터 헤더를 추가합니다. Triton에서 원시 TensorRT 플랜을 로드할 수 있도록 헤더를 제거합니다.
with open(engine_path, "rb") as f:
    meta_len = int.from_bytes(f.read(4), byteorder="little")  # JSON 메타데이터 헤더의 길이
    f.seek(4 + meta_len)
    plan = f.read()
Path("model_repository/yolo_trt/1").mkdir(parents=True, exist_ok=True)
Path("model_repository/yolo_trt/1/model.plan").write_bytes(plan)

3단계: Triton 구성#

dali_preprocessing/config.pbtxt:

name: "dali_preprocessing"
backend: "dali"
max_batch_size: 8
input [
  {
    name: "DALI_INPUT_0"
    data_type: TYPE_UINT8
    dims: [ -1 ]
  }
]
output [
  {
    name: "DALI_OUTPUT_0"
    data_type: TYPE_FP32
    dims: [ 3, 640, 640 ]
  }
]

yolo_trt/config.pbtxt:

name: "yolo_trt"
platform: "tensorrt_plan"
max_batch_size: 8
input [
  {
    name: "images"
    data_type: TYPE_FP32
    dims: [ 3, 640, 640 ]
  }
]
output [
  {
    name: "output0"
    data_type: TYPE_FP32
    dims: [ 300, 6 ]
  }
]

ensemble_dali_yolo/config.pbtxt:

name: "ensemble_dali_yolo"
platform: "ensemble"
max_batch_size: 8
input [
  {
    name: "INPUT"
    data_type: TYPE_UINT8
    dims: [ -1 ]
  }
]
output [
  {
    name: "OUTPUT"
    data_type: TYPE_FP32
    dims: [ 300, 6 ]
  }
]
ensemble_scheduling {
  step [
    {
      model_name: "dali_preprocessing"
      model_version: -1
      input_map {
        key: "DALI_INPUT_0"
        value: "INPUT"
      }
      output_map {
        key: "DALI_OUTPUT_0"
        value: "preprocessed_image"
      }
    },
    {
      model_name: "yolo_trt"
      model_version: -1
      input_map {
        key: "images"
        value: "preprocessed_image"
      }
      output_map {
        key: "output0"
        value: "OUTPUT"
      }
    }
  ]
}
앙상블 매핑 작동 방식

앙상블은 가상 텐서 이름을 통해 모델을 연결합니다. DALI 단계의 output_map 값 "preprocessed_image"은 TensorRT 단계의 input_map 값 "preprocessed_image"과 일치합니다. 이러한 이름은 한 단계의 출력을 다음 단계의 입력에 연결하는 임의의 이름이며, 모델의 내부 텐서 이름과 일치할 필요는 없습니다.

4단계: 추론 요청 전송#

`YOLO('http://...')` 대신 `tritonclient`를 사용하는 이유는 무엇인가요?

Ultralytics에는 전처리와 후처리를 자동으로 처리하는 Triton 내장 지원이 있습니다. 하지만 DALI 앙상블에서는 작동하지 않습니다. YOLO()은 전처리된 float32 텐서를 보내지만, 앙상블은 원시 JPEG 바이트를 예상하기 때문입니다. DALI 앙상블에는 tritonclient를 직접 사용하고, DALI가 없는 표준 배포에는 내장 통합 기능을 사용합니다.

Triton 앙상블로 이미지 전송
import numpy as np
import tritonclient.http as httpclient

client = httpclient.InferenceServerClient(url="localhost:8000")

# Load image as raw bytes (JPEG/PNG encoded)
image_data = np.fromfile("image.jpg", dtype="uint8")
image_data = np.expand_dims(image_data, axis=0)  # Add batch dimension

# Create input
input_tensor = httpclient.InferInput("INPUT", image_data.shape, "UINT8")
input_tensor.set_data_from_numpy(image_data)

# Run inference through the ensemble
result = client.infer(model_name="ensemble_dali_yolo", inputs=[input_tensor])
detections = result.as_numpy("OUTPUT")  # Shape: (1, 300, 6) -> [x1, y1, x2, y2, conf, class_id]

# Filter by confidence (no NMS needed for the nms=False export)
detections = detections[0]  # First image
detections = detections[detections[:, 4] > 0.25]  # Confidence threshold
print(f"Detected {len(detections)} objects")
JPEG 이미지 배치 처리

JPEG 이미지 배치를 Triton으로 보낼 때는 인코딩된 모든 바이트 배열을 같은 길이(배치에서 바이트 수가 가장 큰 배열의 길이)로 패딩합니다. Triton은 입력 텐서의 배치 형태가 모두 동일해야 합니다.

지원 작업#

DALI 전처리는 표준 LetterBox 파이프라인을 사용하는 모든 YOLO 작업에서 작동합니다:

작업지원됨참고 사항
탐지✅표준 레터박스 전처리
인스턴스 세그멘테이션✅탐지와 동일한 전처리
시맨틱 세그멘테이션✅탐지와 동일한 이미지 전처리
깊이 추정✅탐지와 동일한 이미지 전처리
분류❌레터박스가 아닌 torchvision 변환(중앙 자르기)을 사용합니다.
포즈 추정✅탐지와 동일한 전처리
방향성 객체 탐지(OBB)✅탐지와 동일한 전처리

제한 사항#

  • Linux 전용: DALI는 Windows 또는 macOS를 지원하지 않습니다.
  • NVIDIA GPU 필요: CPU 전용 대체 경로는 없습니다.
  • 정적 파이프라인: 파이프라인 구조는 빌드 시 정의되며 동적으로 변경할 수 없습니다.
  • fn.pad은 오른쪽/아래쪽에만 적용됩니다: 가운데 정렬 패딩에는 out_of_bounds_policy="pad"와 함께 fn.crop을 사용합니다.
  • rect 모드 미지원: DALI 파이프라인은 고정 크기 출력(예: 640×640)을 생성합니다. 가변 크기 출력(예: 384×640)을 생성하는 auto=True rect 모드는 지원되지 않습니다. TensorRT는 동적 입력 형태를 지원하지만, 처리량을 최대화하려면 고정 크기 DALI 파이프라인을 고정 크기 엔진과 함께 사용하는 것이 적합합니다.
  • 여러 인스턴스를 사용할 때의 메모리: Triton에서 instance_group과 count > 1을 함께 사용하면 메모리 사용량이 높아질 수 있습니다. DALI 모델에는 기본 인스턴스 그룹을 사용합니다.

자주 묻는 질문#

  • 이점은 파이프라인에 따라 달라집니다. TensorRT를 사용한 GPU 추론이 이미 빠른 경우, 2~10ms가 걸리는 CPU 전처리가 주요 병목이 될 수 있습니다. DALI는 GPU에서 전처리를 실행하여 이 병목을 제거합니다. 가장 큰 성능 향상은 고해상도 입력(1080p, 4K), 큰 배치 크기, GPU당 CPU 코어 수가 제한된 시스템에서 나타납니다.

  • 네. DALIGenericIterator을 사용해 전처리된 torch.Tensor 출력을 생성한 다음 model.predict()에 전달하면 됩니다. 다만 추론이 이미 매우 빠르고 CPU 전처리가 병목이 되는 TensorRT 모델에서 성능 이점이 가장 큽니다.

  • fn.pad은 오른쪽과 아래쪽 가장자리에만 패딩을 추가합니다. out_of_bounds_policy="pad"와 함께 사용하는 fn.crop은 이미지를 가운데 정렬하고 모든 면에 대칭으로 패딩을 추가하여 Ultralytics의 LetterBox(center=True) 동작과 일치시킵니다.

  • 거의 동일합니다. OpenCV의 cv2.INTER_LINEAR와 일치하도록 fn.resize에서 antialias=False을 설정합니다. GPU와 CPU의 연산 차이로 인해 미세한 부동소수점 차이(< 0.001)가 발생할 수 있지만, 탐지 정확도에는 측정 가능한 영향이 없습니다.

  • CV-CUDA는 GPU 가속 비전 처리를 위한 또 다른 NVIDIA 라이브러리입니다. DALI의 파이프라인 방식과 달리, GPU에서 OpenCV를 사용하는 것처럼 연산자별 제어 기능을 제공합니다. CV-CUDA의 cvcuda.copymakeborder()는 각 면의 패딩을 명시적으로 설정할 수 있어 가운데 정렬 레터박스를 간편하게 구현할 수 있습니다. 파이프라인 기반 워크플로(특히 Triton 사용)에는 DALI를, 맞춤형 추론 코드에서 세밀한 연산자 수준 제어가 필요하면 CV-CUDA를 선택합니다.

댓글