使用 NVIDIA DALI 进行 GPU 加速预处理#
在生产环境中部署 Ultralytics YOLO 模型时,预处理 往往会成为瓶颈。虽然 TensorRT 可以在短短几毫秒内运行模型推理,但基于 CPU 的预处理(调整大小、填充、归一化)每张图像可能需要 2-10 毫秒,在高分辨率下尤其明显。NVIDIA DALI(数据加载库)通过将整个预处理流程迁移到 GPU 来解决这个问题。
本指南将带你构建完美复制 Ultralytics YOLO 预处理的 DALI 管道,将其与 model.predict() 集成、处理视频流,并通过 Triton Inference Server 进行端到端部署。
本指南适用于在生产环境中部署 YOLO 模型的工程师,在这些环境中 CPU 预处理是一个经测量的瓶颈 —— 通常是在 NVIDIA GPU 上的 TensorRT 部署、高吞吐量视频管道或 Triton Inference Server 设置。如果你使用的是 model.predict() 运行标准推理且没有预处理瓶颈,则默认的 CPU 管道就能很好地工作。
- 正在构建 DALI 管道? 使用
fn.resize(mode="not_larger")+fn.crop(out_of_bounds_policy="pad")+fn.crop_mirror_normalize在 GPU 上复制 YOLO 的 letterbox 预处理。 - 与 Ultralytics 集成? 将 DALI 输出作为
torch.Tensor传递给model.predict()—— Ultralytics 会自动跳过图像预处理。 - 使用 Triton 部署? 使用带有 TensorRT 集成模型的 DALI 后端,实现零 CPU 预处理。
为什么为 YOLO 预处理使用 DALI#
在典型的 YOLO 推理流水线中,预处理步骤在 CPU 上运行:
- 解码 图像 (JPEG/PNG)
- 调整大小 同时保持纵横比
- 填充 至目标尺寸 (letterbox)
- 将像素值从
[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 | Letterbox 调整大小 | 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]) |
Letterbox 操作通过以下方式保持纵横比:
- 计算缩放比例:
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.pad(...) 代替 fn.crop(..., out_of_bounds_policy="pad") 来简化填充步骤。该变体仅填充右侧和底部边缘,这对于自定义部署管道可能是可以接受的,但它不会完全匹配 Ultralytics 默认的居中 letterbox 行为。
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 不应用抗锯齿。务必在 DALI 中设置 antialias=False 以匹配 CPU 管道。省略此设置会导致细微的数值差异,从而可能影响模型准确率。
运行流水线#
# 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 会跳过图像预处理(letterbox、BGR→RGB、HWC→CHW 以及 /255 归一化),在将其发送给模型之前仅执行设备传输和数据类型转换。
由于在这种情况下 Ultralytics 无法访问原始图像尺寸,因此返回的检测框坐标是在 640×640 的 letterbox 空间中的。要将它们映射回原始图像坐标,请使用 scale_boxes,它处理由 LetterBox 使用的确切舍入逻辑:
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() 时,图像预处理步骤耗时约 ~0.004ms(基本上为零),而 CPU 预处理则需要约 ~1-10ms。张量必须为 BCHW 格式、float32(或 float16),并归一化到 [0, 1]。Ultralytics 仍将自动处理设备传输和数据类型转换。
带有视频流的 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 output带有 DALI 的 Triton Inference Server#
对于生产部署,可使用集成模型将 DALI 预处理与 Triton Inference Server 中的 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.pbtxt步骤 1:创建 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."""
# 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)
# Copy the .engine file to model_repository/yolo_trt/1/model.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 — YOLO26 is end-to-end)
detections = detections[0] # First image
detections = detections[detections[:, 4] > 0.25] # Confidence threshold
print(f"Detected {len(detections)} objects")当向 Triton 发送一批 JPEG 图像时,请将所有编码后的字节数组填充至相同长度(即批处理中最大字节数)。Triton 要求输入张量的批次形状必须一致。
支持的任务#
DALI 预处理适用于使用标准 LetterBox 管道的所有 YOLO 任务:
| 任务 | 已支持 | 注意事项 |
|---|---|---|
| Detection | ✅ | 标准 letterbox 预处理 |
| 实例分割 | ✅ | 与检测任务相同的预处理 |
| 语义分割 | ✅ | 与检测任务相同的图像预处理 |
| Classification | ❌ | 使用 torchvision 变换(中心裁剪),而非 letterbox |
| Pose Estimation | ✅ | 与检测任务相同的预处理 |
| 定向检测 (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)行为相匹配。几乎完全相同。在
fn.resize中设置antialias=False以匹配 OpenCV 的cv2.INTER_LINEAR。由于 GPU 与 CPU 算术的差异,可能会产生微小的浮点差异(< 0.001),但这不会对检测准确率产生可测量的影响。