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에서 실행됩니다.
- 이미지 디코딩 (JPEG/PNG)
- 종횡비를 유지하며 크기 조정
- 대상 크기에 맞게 패딩 (레터박스)
- 픽셀 값을
[0, 255]에서[0, 1]범위로 정규화 - HWC에서 CHW로 레이아웃 변환
DALI를 사용하면 이러한 작업을 모두 GPU에서 실행하여 CPU 병목을 제거할 수 있습니다. 다음과 같은 경우 특히 유용합니다.
| 시나리오 | DALI의 장점 |
|---|---|
| GPU 추론 속도가 빠른 경우 | 밀리초 미만의 추론을 수행하는 TensorRT 엔진에서는 CPU 전처리가 주요 비용이 됩니다. |
| 고해상도 입력 | 1080p 및 4K 비디오 스트림은 비용이 많이 드는 크기 조정 작업이 필요합니다. |
| 큰 배치 크기 | 서버 측 추론에서 여러 이미지를 병렬로 처리하는 경우 |
| 제한된 CPU 코어 수 | NVIDIA Jetson과 같은 엣지 디바이스 또는 GPU당 CPU 코어 수가 적은 고밀도 GPU 서버 |
사전 요구 사항#
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.resize | fn.resize(mode="not_larger") |
| 2 | 중앙 패딩 | cv2.copyMakeBorder | fn.crop(out_of_bounds_policy="pad") |
| 3 | BGR → RGB | im[..., ::-1] | fn.decoders.image(output_type=types.RGB) |
| 4 | HWC → CHW + /255 정규화 | np.transpose + tensor / 255 | fn.crop_mirror_normalize(std=[255,255,255]) |
레터박스 작업은 다음 방식으로 종횡비를 유지합니다.
- 스케일 계산:
r = min(target_h / h, target_w / w) (round(w * r), round(h * r))으로 크기 조정- 대상 크기에 맞도록 남은 공간을 회색(
114)으로 패딩 - 이미지를 가운데에 배치하여 양쪽에 패딩이 동일하게 분배되도록 합니다.
YOLO용 DALI 파이프라인#
권장 DALI 파이프라인은 표준 YOLO 추론에서 사용하는 Ultralytics의 기본 LetterBox(center=True) 동작을 재현합니다.
중앙 정렬 파이프라인(권장, Ultralytics LetterBox와 일치)#
이 버전은 중앙 패딩을 적용하는 Ultralytics 기본 전처리를 정확하게 재현하며, LetterBox(center=True)과 일치합니다.
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정확한 LetterBox(center=True) 호환성이 필요하지 않다면 fn.crop(..., out_of_bounds_policy="pad") 대신 fn.pad(...)을 사용해 패딩 단계를 단순화할 수 있습니다. 이 변형은 오른쪽 및 아래쪽 가장자리만 패딩하므로 사용자 지정 배포 파이프라인에는 적합할 수 있지만, Ultralytics 기본 중앙 정렬 레터박스 동작과 정확히 일치하지는 않습니다.
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를 설정하세요. 이를 생략하면 미세한 수치 차이가 발생하여 모델 정확도에 영향을 미칠 수 있습니다.
파이프라인 실행하기#
# 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 배포 등 모든 외부 전처리 경로에 적용됩니다.
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 또는 사용자 지정 캡처 라이브러리 등 모든 소스의 프레임을 공급하세요.
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 outputDALI를 사용하는 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.pbtxt1단계: DALI 파이프라인 생성#
Triton DALI 백엔드에서 사용할 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로 내보내기#
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단계: 추론 요청 전송#
Ultralytics에는 전처리와 후처리를 자동으로 처리하는 Triton 내장 지원이 있습니다. 하지만 DALI 앙상블에서는 작동하지 않습니다. YOLO()은 전처리된 float32 텐서를 보내지만, 앙상블은 원시 JPEG 바이트를 예상하기 때문입니다. DALI 앙상블에는 tritonclient를 직접 사용하고, DALI가 없는 표준 배포에는 내장 통합 기능을 사용합니다.
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 이미지 배치를 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=Truerect 모드는 지원되지 않습니다. TensorRT는 동적 입력 형태를 지원하지만, 처리량을 최대화하려면 고정 크기 DALI 파이프라인을 고정 크기 엔진과 함께 사용하는 것이 적합합니다. - 여러 인스턴스를 사용할 때의 메모리: Triton에서
instance_group과count> 1을 함께 사용하면 메모리 사용량이 높아질 수 있습니다. DALI 모델에는 기본 인스턴스 그룹을 사용합니다.
자주 묻는 질문#
네.
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)가 발생할 수 있지만, 탐지 정확도에는 측정 가능한 영향이 없습니다.