4. Tengine 核心 API 详解:graph_t、tensor_t、node_t 数据结构与推理流程
好,咱们进入正题。上一章我们把 Tengine 环境搭好了,也跑通了第一个 demo。但说实话,那会儿你只是调了个接口,底层到底发生了什么,可能还是一团黑。这一章,我就带你把这些核心数据结构扒开看看。
我个人习惯,学一个推理框架,先不看它有多花哨的功能,先把三个东西搞明白:图(graph)、张量(tensor)、节点(node)。这三样搞懂了,后面所有操作都是水到渠成的事。
4.1 三大核心数据结构
先给个全景图,你脑子里有个印象。
一句话总结:
- graph_t —— 整个计算图的句柄,相当于“模型实例”
- tensor_t —— 数据容器,输入输出都靠它
- node_t —— 图中的算子节点,一般不需要你直接操作
下面我逐个细说。
4.1.1 graph_t —— 计算图的“身份证”
graph_t 本质上是一个不透明指针(opaque pointer)。你不需要知道它内部长什么样,只要知道它是你加载的模型在内存中的代表就行。
我在项目中遇到过一个问题:有人试图把 graph_t 直接跨线程传递,结果 crash 了。为什么?因为 graph_t 内部有线程不安全的资源。记住,一个 graph 实例只在一个线程里用,别乱传。
| 属性/操作 | 说明 |
|---|---|
| 创建 | create_graph() 或 create_runtime_graph() |
| 销毁 | destroy_graph() |
| 获取输入张量 | get_graph_input_tensor() |
| 获取输出张量 | get_graph_output_tensor() |
| 前向推理 | prerun_graph() + run_graph() + postrun_graph() |
4.1.2 tensor_t —— 数据的“搬运工”
tensor_t 是你打交道最多的数据结构。说白了,它就是一块带维度信息的内存区域。
它的核心成员包括:
- data —— 实际数据指针
- dims —— 维度信息(NCHW 或 NHWC)
- elem_size —— 每个元素字节数(float 就是 4)
- data_type —— 数据类型(TENGINE_DT_FP32 等)
- layout —— 数据排布(NCHW / NHWC)
小技巧: 我习惯在设置输入数据前,先调用 set_tensor_shape() 明确指定维度。虽然有些模型会自动推导,但显式指定能避免很多隐晦的 bug。
4.1.3 node_t —— 算子的“黑盒”
node_t 代表计算图中的一个算子节点,比如 Conv2D、ReLU、Pooling 等。在大多数推理场景下,你不需要直接操作它。但如果你要做图编辑或自定义算子,那就绕不开了。
嗯,这里要注意:node_t 的创建和销毁由 graph 管理,你千万别手动 free。我曾经见过有人直接 free(node),结果整个 graph 都崩了。
4.2 图的创建与销毁
创建图有两种方式,我分别说一下。
4.2.1 从文件加载
这是最常用的方式。你有一个训练好的模型文件(比如 .tmfile),直接加载。
graph_t graph = create_graph(nullptr, "tengine", model_path);
if (graph == nullptr) {
fprintf(stderr, "创建图失败\n");
return -1;
}
第一个参数是 context,传 nullptr 表示使用默认上下文。第二个参数是后端名称,"tengine" 就是原生 Tengine 后端。
4.2.2 从内存加载
有时候模型是加密的,或者从网络下载后直接存在内存里。这时候用 create_graph_from_mem()。
graph_t graph = create_graph_from_mem(nullptr, "tengine", model_buffer, buffer_size);
我在嵌入式设备上经常用这种方式,因为文件系统可能不可靠,直接从内存加载更安全。
4.2.3 销毁图
用完了记得销毁,不然内存泄漏。
destroy_graph(graph);
graph = nullptr; // 我习惯置空,防止野指针
警告: 销毁图之后,所有从该图获取的 tensor_t 和 node_t 都变成悬空指针。不要再访问它们!
4.3 设置输入输出
图创建好了,接下来就是塞数据进去,拿结果出来。
4.3.1 获取输入张量
tensor_t input_tensor = get_graph_input_tensor(graph, 0, 0);
if (input_tensor == nullptr) {
fprintf(stderr, "获取输入张量失败\n");
return -1;
}
第二个参数是输入索引,一般模型只有一个输入,传 0 就行。第三个参数是数据布局,传 0 表示默认。
4.3.2 设置输入数据
// 假设输入是 224x224 的 RGB 图像,batch=1
int dims[] = {1, 3, 224, 224};
set_tensor_shape(input_tensor, dims, 4);
// 分配内存并填充数据
float* input_data = (float*)malloc(1 * 3 * 224 * 224 * sizeof(float));
// ... 填充数据 ...
set_tensor_buffer(input_tensor, input_data, 1 * 3 * 224 * 224 * sizeof(float));
这里有个坑:set_tensor_buffer() 只是把指针传进去,不会拷贝数据。所以 input_data 的内存必须保持有效,直到推理完成。
4.3.3 获取输出张量
tensor_t output_tensor = get_graph_output_tensor(graph, 0, 0);
// 推理完成后,从这里读取结果
float* output_data = (float*)get_tensor_buffer(output_tensor);
int output_size = get_tensor_buffer_size(output_tensor) / sizeof(float);
4.4 前向推理接口
Tengine 的前向推理分三步走:prerun、run、postrun。你想想看,为什么不是一步到位?
- prerun_graph() —— 做内存预分配、算子优化、图变换。这一步比较耗时,但只做一次。
- run_graph() —— 真正的推理计算。可以反复调用。
- postrun_graph() —— 释放 prerun 时分配的资源。
// 第一步:预运行
if (prerun_graph(graph) != 0) {
fprintf(stderr, "预运行失败\n");
return -1;
}
// 第二步:推理(可以多次调用)
for (int i = 0; i < 100; i++) {
// 更新输入数据...
run_graph(graph, 1); // 第二个参数是阻塞标志,1 表示阻塞等待
}
// 第三步:后处理
postrun_graph(graph);
性能建议: 如果你要连续推理多帧,把 prerun 和 postrun 放在循环外面。我见过有人每帧都 prerun,性能直接掉一个数量级。
4.5 完整流程与知识图谱
下面这张图把整个流程串起来了。你对照着看,思路会更清晰。
4.6 常见问题与避坑
最后,我把自己踩过的坑列一下,你遇到了直接对照。
| 问题 | 原因 | 解决办法 |
|---|---|---|
| prerun 失败 | 模型文件损坏或算子不支持 | 检查模型文件,确认后端支持 |
| 推理结果全为 0 | 输入数据未正确设置 | 检查 set_tensor_buffer 是否调用 |
| 内存泄漏 | 忘记调用 destroy_graph |
确保成对调用 create/destroy |
| 多线程 crash | graph 被多个线程共享 | 每个线程创建独立的 graph |
我曾经踩过的一个坑: 在嵌入式设备上,我用了 set_tensor_buffer 传了一个栈上的数组。结果函数返回后,栈空间被回收,推理时数据全乱了。记住,输入数据的内存生命周期必须覆盖整个推理过程。