上篇文章我们本地跑通了更通用的本地推理框架 ONNX Runtime(ORT),本文我们对上文的代码进行拆解和总结。

0. 系列文章

1. AI 推理的通用“四步公式”

不管你用 ONNX Runtime (ORT)、TensorRT、NCNN 还是 TFLite,也不管你是写 C++ 还是 Python,所有的 AI 推理代码都逃不出这 4 个步骤。

记住这个公式,以后你换任何框架都能秒上手:

(1)初始化 (Init): 准备环境,加载模型文件到内存。

  • llama.cpp 中也是准备环境,加载模型

(2)前处理 (Pre-process): 把人类看的图片 (JPG) 变成模型能懂的数学数组 (Tensor)。

  • llama.cpp 中也是将 prompt 转成 token 向量

(3)推理 (Inference): 调用 Run 函数,算出结果。

  • llama.cpp decode解码和采样器采样,进行推理

(4)后处理 (Post-process): 把模型吐出的数学数组,变回人类能看的结果 (画框、文字)。

  • llama.cpp 是将输出 token 转换成文字输出

2. 代码逻辑深度拆解

我们来看看 main.cpp 里的关键代码为什么非得这么写。

2.1 为什么要有 Env 和 Session?

Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "YoloTest");
Ort::Session session(env, model_path, session_options);
  • Env (环境): 想象它是厨房。它管理全局的线程池、日志系统。一个 App 通常只需要一个厨房。

  • Session (会话): 想象它是正在做的一道菜(比如加载了 YOLO 模型)。如果你还要跑一个 人脸识别模型,你就需要再 new 一个 Session。

任何 AI 框架都有类似 LoadModel 或 CreateSession 的步骤,目的是把几百 MB 的模型权重文件从硬盘读到内存里解析好。

2.2 推理 Run 函数

session.Run(..., input_names, input_values, ..., output_names, ...);

这是一个典型的 “黑盒函数”。

  • 输入: 给它名字 (images) 和数据 (input_tensors)。

  • 输出: 给它你想要拿到的输出名字 (output0)。

返回值: 它会返回一个包含结果数据的向量。

2.2.1 函数输入与输出

std::vector<Ort::Value> Run(
    const RunOptions& run_options,  // 1. 运行配置
    const char* const* input_names, // 2. 输入节点的名字列表
    const Ort::Value* input_values, // 3. 输入数据的列表 (Tensor)
    size_t input_count,             // 4. 输入数据的个数
    const char* const* output_names,// 5. 想要获取的输出名字列表
    size_t output_count             // 6. 想要获取的输出个数
);
参数详细拆解
  • run_options (运行配置)

    • 含义: 这次推理的“特殊指令”。

    • 用途: 你可以用它来设置“超时时间”(比如超过 100ms 还没算完就强制停止),或者设置日志级别。

    • 通常我们不需要特殊配置,直接传一个空的或者默认的即可。

Ort::RunOptions run_options{nullptr};
  • input_names (输入节点的名字列表)

    • 含义: 模型的大门叫什么名字?

    • 模型内部是一个图 (Graph)。数据进入图的入口是有名字的。YOLOv8 的入口名字通常叫 “images”。

    • 为什么是列表? 因为有的模型有多个入口!比如一个“图文匹配模型”,它有两个入口:一个入口进图片,一个入口进文字。

// 读取第 0 个输入的名字
auto input_name_ptr = session.GetInputNameAllocated(0, allocator);
std::string input_name = input_name_ptr.get();
std::cout << "Input Name: " << input_name << std::endl;

std::vector<const char*> input_node_names = { input_name.c_str() };
  • input_values (输入数据的列表)

    • 真正的“干货”数据。它包含了图片的 NCHW 数据、数据的维度形状、内存位置等所有信息。

    • 注意: 它的顺序必须和 input_names 一一对应。名字数组的第 0 个是 “images”,那么数据数组的第 0 个必须是图像 Tensor。

input_values 后面详细说。

  • input_count (输入数据的个数)

    • 你到底塞了几个输入进去?
  • output_names (输出节点的名字列表)

    • 你想要拿回谁的结果?模型算完后,可能会产生很多中间结果。你需要指定你关心哪一个出口的数据。
// 获取输出名字
auto output_name_ptr = session.GetOutputNameAllocated(0, allocator);
std::string output_name = output_name_ptr.get();
std::vector<const char*> output_node_names = { output_name.c_str() };
  • output_count (输出数据的个数)

    • 含义: 你想要拿回几个结果?

2.2.2 输入参数:input_values

它包含了图片的 NCHW 数据、数据的维度形状、内存位置等所有信息。

std::vector<Ort::Value> input_tensors;
input_tensors.push_back(Ort::Value::CreateTensor<float>(
    memory_info, 
    input_tensor_values.data(), 
    input_tensor_size, 
    input_dims.data(), 
    input_dims.size()
));

看看它的构造需要什么:

(1)memory_info

// 创建内存信息 (CPU)
auto memory_info = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);

这是一张“身份证”或“地契”。
当你把数据传给 ONNX Runtime 时,它是个瞎子。它不知道你给它的这堆数据是在 CPU 上,还是在显卡里。
你需要创建一个 MemoryInfo 对象,这就好比给数据贴了一张标签。

  • CreateCpu: 明确告诉 ORT,这块内存位于 CPU 上。如果你在写 CUDA 代码,这里就会变成 CreateCuda。

  • OrtArenaAllocator: (内存池),这是一个优化策略。普通的 malloc/new 申请内存很慢。ORT 内部维护了一个大内存池(Arena),需要内存时直接切一块给你,用完还回去,不用频繁找操作系统申请。意思就是使用 ORT 默认的高性能内存管理策略。

  • OrtMemTypeDefault: 默认内存类型(通常指非锁页内存)。

(2)input_tensor_values / input_tensor_size

这里是根据 yolo模型的输入尺寸,造了一个假的 NCHW 的图像数据。

// 5. 构造一个假的输入数据 (Fake Input)
// 形状通常是 [1, 3, 640, 640]
// 元素总数 = 1 * 3 * 640 * 640
size_t input_tensor_size = 1 * 3 * 640 * 640;
std::vector<float> input_tensor_values(input_tensor_size);

// 初始化为 0.5 (模拟灰色图片)
for (size_t i = 0; i < input_tensor_size; i++) {
    input_tensor_values[i] = 0.5f;
}

(3)input_dims / input_dims.size()

获取输入的尺寸和格式。

// 读取第 0 个输入的形状 (Shape)
Ort::TypeInfo input_type_info = session.GetInputTypeInfo(0);
auto input_tensor_info = input_type_info.GetTensorTypeAndShapeInfo();
std::vector<int64_t> input_dims = input_tensor_info.GetShape();

(4)Ort::Value::CreateTensor<float> 在干什么?

  • <float>: 告诉这里面装的是 float 类型的沙子,不是 int 也不是 double。这决定了读取时每次读几个字节。

  • input_tensor_values.data() (最关键的一点!):

    • 这是零拷贝 (Zero Copy) 技术的核心!这里传入的是 vector 内部数据的首地址指针。

    • 重点:CreateTensor 没有把你的数据复制一份存起来,它只是记录了这个指针。如果你在创建 Tensor 后,修改了 vector 里的数据,Tensor 里的数据也会跟着变!

    • input_tensor_size: 告诉箱子这堆沙子一共有多少粒(例如 1,228,800 个)。

  • input_dims.data() 和 size(): 告诉箱子怎么理解这堆沙子。是排成一行?还是排成 640x640 的方阵?还是 [1, 3, 640, 640] 的立方体?

2.3 为什么要将input_dims里面的值固定为1 ?

以下代码:

std::cout << "Input Shape: [";
for (size_t i = 0; i < input_dims.size(); i++) {
    // YOLO 模型导出时 Batch 维度可能是动态的 (-1),我们需要将其固定为 1
    if (input_dims[i] == -1) input_dims[i] = 1; 
    std::cout << input_dims[i] << (i < input_dims.size() - 1 ? ", " : "");
}
std::cout << "]" << std::endl;

这是因为在 ONNX 模型导出时(通常是从 PyTorch 导出),工程师通常会将 Batch 维度设置为 动态 (Dynamic)。

  • 静态维度: 比如 640x640,意味着模型说:“我只接受 640 宽高的图片,别的我不认。”

  • 动态维度 (-1): 意味着模型说:“我不关心你一次给我几张图。你给我 1 张也行,给我 8 张也行,给我 64 张也行。”

所以在模型看来,输入形状是 [N, 3, 640, 640],这里的 N 是未知的,用 -1 表示。

在 C++ 代码的后面,我们需要创建 Tensor 并分配内存,代码逻辑如下:

// 假设 input_dims 是 [-1, 3, 640, 640]
int64_t element_count = 1;
for(auto d : input_dims) {
    element_count *= d; 
}
// 此时 element_count = -1 * 3 * 640 * 640 = -1228800 
// 这是一个负数!

// 接下来我们要分配内存:
std::vector<float> data(element_count); 
// ❌ 崩溃!你不能申请 "负数" 大小的内存数组。

总结:将模型“支持任意数量”的这种 可能性,坍缩成“当前只要处理 1 张”的 既定事实,以便后续代码能正确计算内存大小。

3. 核心概念:什么是 NCHW?

这是重要的知识点,也是 CV (计算机视觉) 领域的“行话”。

3.1 维度的含义

YOLO 模型的输入形状是 [1, 3, 640, 640],这四个数字分别代表:

  • N (Batch Size / 批次大小): 1

意思是一次处理几张图。这里我们只处理 1 张。如果是 4,就是一次并行处理 4 张图。

  • C (Channel / 通道数): 3

  • 代表颜色通道。一张彩色图片由 R (红)、G (绿)、B (蓝) 三层叠加而成。所以是 3。

  • H (Height / 高度): 640

图片的高。

  • W (Width / 宽度): 640

图片的宽。

3.2 数据的排布 (Layout):HWC vs NCHW

HWC (Human/OpenCV 视角)

你在电脑上看一张图,或者是用 OpenCV 读取一张图时,内存里的数据是这样排列的:

第 1 个像素 (R, G, B)
第 2 个像素 (R, G, B)

内存排列: R G B R G B R G B …

特点: 像素交织。这符合显示器的原理,一个像素点由三个子像素组成。

NCHW (AI 模型/GPU 视角)

大多数 AI 模型(包括 YOLO、ResNet)训练时,为了计算方便(矩阵乘法优化),要求数据把颜色拆开:

先放所有的 R (红) 分量。
再放所有的 G (绿) 分量。
最后放所有的 B (蓝) 分量。

内存排列: R R R … G G G … B B B …

特点: 通道分离 (Planar)。

4. 为什么输入是一个一维数组 vector?

你在代码里看到了:

// 虽说是 [1, 3, 640, 640],但 C++ 只有一维内存
std::vector<float> input_tensor_values(1 * 3 * 640 * 640);

物理内存是线性的 (1D)。

不管你有多少维度(4D, 5D),在计算机的内存条里,它们都是排成一长队的字节。

逻辑上: 它是一个 640x640 的 3 层立方体。

物理上: 它是 1,228,800 个连续的 float 数字。

当我们创建 Ort::Value::CreateTensor 时,我们需要传入两个东西:

  • 数据指针 (data()): 告诉它数据从内存哪里开始。

  • 形状 (dims): 告诉它怎么“理解”这一长串数据(例如:前 640x640 个是 R,后面是 G…)。

5. 总结

好了,差不多每块代码都解析了,下面,总结一下整个过程,在开头四步公式的基础上进行补充一些细节:

(1)初始化 (Init): 准备环境,加载模型文件到内存。

  • 先制定一个环境
  • 然后创建 Ort::Session,加载模型

(2)前处理 (Pre-process): 把人类看的图片 (JPG) 变成模型能懂的数学数组 (Tensor)。

  • 先获取模型需要什么输入,输入的名称、尺寸
  • 构造输入 Tensor (要指定数据位置 memory_info)

(3)推理 (Inference): 调用 Run 函数,算出结果。

  • 先获取输出的名称

(4)后处理 (Post-process): 把模型吐出的数学数组,变回人类能看的结果 (画框、文字)。

这篇文章的输入是一个全是 0.5 的假图片。下篇文章,我们将读取真正的图片作为输入,看下需要哪些特殊处理。

在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐