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 파이프라인을 구축하시나요? GPU에서 YOLO의 레터박스 전처리를 재현하려면
fn.resize(mode="not_larger")+fn.crop(out_of_bounds_policy="pad")+fn.crop_mirror_normalize를 사용합니다. - Ultralytics와 통합하시나요? DALI 출력을
torch.Tensor으로model.predict()에 전달하면 Ultralytics가 이미지 전처리를 자동으로 건너뜁니다. - Triton으로 배포하시나요? TensorRT 앙상블과 함께 DALI 백엔드를 사용하여 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), # Target size
center=True, # Center the image (pad equally on both sides)
stride=32, # Stride alignment
padding_value=114, # Gray padding (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 파이프라인은 Ultralytics의 기본 LetterBox(center=True) 동작을 재현하며, 이는 표준 YOLO 추론에서 사용되는 동작입니다.
중앙 정렬 파이프라인(권장, 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.
"""
# Read and decode images on 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)
# Aspect-ratio-preserving resize
resized = fn.resize(
images,
resize_x=target_size,
resize_y=target_size,
mode="not_larger",
interp_type=types.INTERP_LINEAR,
antialias=False, # Match cv2.INTER_LINEAR (no antialiasing)
)
# Centered padding using fn.crop with out_of_bounds_policy
# When crop size > image size, fn.crop centers the image and pads symmetrically
padded = fn.crop(
resized,
crop=(target_size, target_size),
out_of_bounds_policy="pad",
fill_values=114, # YOLO padding value
)
# Normalize and convert layout
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)을 활성화하지만, OpenCV의 cv2.resize와 INTER_LINEAR은 앤티앨리어싱을 적용하지 않습니다. 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}]")DALI를 Ultralytics Predict와 함께 사용하기#
전처리된 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
# boxes: tensor of shape (N, 4) in xyxy format, in 640x640 letterboxed coords
# Scale boxes from letterboxed (640, 640) back to original (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."""
# External source for feeding frames from OpenCV, GStreamer, etc.
frames = fn.external_source(device="cpu", name="input")
frames = fn.reshape(frames, layout="HWC")
# Move to GPU and preprocess
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#
프로덕션 배포에서는 앙상블 모델을 사용하여 Triton Inference Server에서 DALI 전처리와 TensorRT 추론을 결합합니다. 이렇게 하면 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."""
# Input: raw encoded image bytes from 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
# Serialize pipeline to model repository
pipe = triton_dali_pipeline()
pipe.serialize(filename="model_repository/dali_preprocessing/1/model.dali")2단계: YOLO를 TensorRT로 내보내기#
from ultralytics import YOLO
model = YOLO("yolo26n.pt")
model.export(
format="engine", imgsz=640, quantize=16, batch=8, dynamic=True, nms=False
) # NMS-free (N, 300, 6); TensorRT >= 8.5
# Copy the .engine file to model_repository/yolo_trt/1/model.plan3단계: 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 기본 지원이 있습니다. 그러나 YOLO()은 전처리된 float32 텐서를 전송하는 반면 앙상블은 원시 JPEG 바이트를 요구하므로 DALI 앙상블에서는 작동하지 않습니다. 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은 입력 텐서에 대해 균일한 배치 shape을 요구합니다.
지원되는 작업#
DALI 전처리는 표준 LetterBox 파이프라인을 사용하는 모든 YOLO 작업에서 작동합니다.
| 태스크 | 지원됨 | 참고 |
|---|---|---|
| 탐지 | ✅ | 표준 letterbox 전처리 |
| 인스턴스 세그멘테이션 | ✅ | Detection과 동일한 전처리 |
| 시맨틱 세그멘테이션 | ✅ | Detection과 동일한 이미지 전처리 |
| 분류 | ❌ | letterbox가 아닌 torchvision 변환(center crop)을 사용합니다 |
| 포즈 추정 | ✅ | Detection과 동일한 전처리 |
| 방향성 Detection (OBB) | ✅ | Detection과 동일한 전처리 |
제한 사항#
- Linux 전용: DALI는 Windows 또는 macOS를 지원하지 않습니다
- NVIDIA GPU 필요: CPU 전용 fallback을 지원하지 않습니다
- 정적 pipeline: Pipeline 구조는 빌드 시점에 정의되며 동적으로 변경할 수 없습니다
fn.pad은 오른쪽/아래쪽 전용입니다: 가운데 정렬 패딩에는out_of_bounds_policy="pad"와 함께fn.crop을 사용합니다- rect 모드 없음: DALI pipeline은 고정 크기 출력(예: 640×640)을 생성합니다. 가변 크기 출력(예: 384×640)을 생성하는
auto=Truerect 모드는 지원되지 않습니다. TensorRT는 동적 입력 shape을 지원하지만, 최대 처리량을 위해 고정 크기 DALI pipeline을 고정 크기 engine과 함께 사용하는 것이 자연스럽습니다 - 여러 instance 사용 시 메모리: Triton에서
count> 1과 함께instance_group을 사용하면 메모리 사용량이 높아질 수 있습니다. DALI 모델에는 기본 instance group을 사용합니다
FAQ#
이점은 pipeline에 따라 달라집니다. TensorRT를 사용한 GPU inference가 이미 빠른 경우 CPU 전처리에 2~10ms가 소요되면 이것이 주요 비용이 될 수 있습니다. DALI는 GPU에서 전처리를 실행하여 이 병목을 제거합니다. 가장 큰 성능 향상은 고해상도 입력(1080p, 4K), 큰 batch size, 그리고 GPU당 CPU 코어 수가 제한된 시스템에서 확인됩니다.
예.
DALIGenericIterator을 사용하여 전처리된torch.Tensor출력을 얻은 다음model.predict()에 전달합니다. 그러나 inference가 이미 매우 빠르고 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)가 발생할 수 있지만, 이러한 차이는 Detection 정확도에 측정 가능한 영향을 미치지 않습니다.CV-CUDA는 GPU 가속 vision processing을 위한 또 다른 NVIDIA 라이브러리입니다. DALI의 pipeline 접근 방식과 달리, GPU에서 OpenCV와 유사한 연산자별 제어를 제공합니다. CV-CUDA의
cvcuda.copymakeborder()는 면별 패딩을 명시적으로 지원하므로 가운데 정렬 letterbox를 간단하게 구현할 수 있습니다. Pipeline 기반 workflow(특히 Triton 사용)에는 DALI를 선택하고, custom inference 코드에서 세밀한 연산자 수준 제어가 필요하면 CV-CUDA를 선택합니다.