Ch17: 数据容器——同一个问题的六种回答
Part 4: 核心抽象深度对比 前置章节:Ch16: MACE——深入骨髓的硬件适配(Part 3 终章) 后续章节:Ch18: 算子抽象——设计哲学的分水岭
核心问题
推理引擎要处理的数据是多维数组——一张 224x224 的 RGB 图片是一个形状为 [1,3,224,224] 的四维数组,一个卷积核是一个形状为 [64,3,3,3] 的四维数组。这些数据在引擎内部需要被创建、传递、变形、分配到不同硬件上。”数据容器”就是引擎用来承载这些数据的基本单元——它是整个推理流水线中被传递最频繁的对象,它的设计直接决定了引擎的内存效率、零拷贝能力和多后端适配的灵活性。
六个框架面对同一个问题——”怎么设计一个多维数组容器”——给出了六种截然不同的回答。这些差异不是偶然的,它们反映了每个框架最核心的设计取舍:是要极致性能,还是要跨硬件灵活性?是让容器”聪明”一些自己管理内存,还是让它”笨”一些把内存交给外部管理?
行李箱的类比
想象你要出差,需要一个行李箱来装你的东西。六个框架就像六种不同理念的行李箱设计师:
ncnn 的 Mat 像一个硬壳登机箱——箱子本身就自带了整理袋和分隔板(elempack 打包机制),你的衣服放进去就已经按照最优方式排列好了,拿取极快,而且箱子有内置的计数器知道几个人在共用它(refcount 引用计数)。缺点是它只适合一种尺寸规格的行李。
MNN 的 Tensor 像一个智能行李箱——它不仅能装东西,还能”假装”里面的东西换了排列方式,而实际上什么都没动(虚拟 Region 零拷贝 Reshape/Transpose)。箱子内部有一个复杂的管理系统(InsideDescribe)来维护这种”虚拟重排”的信息。
ORT 的 OrtValue/Tensor 像一个超薄旅行收纳袋——它自己几乎不占空间,也不拥有里面的衣服(不拥有内存),它只是标记了”这些衣服在哪个酒店的哪个房间的哪个柜子里”(OrtMemoryInfo 三元组),非常轻量但需要别人来管理物品。
TNN 的 Blob 像一个分体式行李箱——箱壳(BlobDesc 描述信息)和内胆(BlobHandle 数据指针)是分开的,你可以在不动箱壳的情况下换内胆,内胆由专门的仓库(BlobManager 内存池)统一管理。
Paddle-Lite 的 TensorLite 像一个万能旅行箱——箱子自带标签告诉你它属于哪个航空公司、什么规格、多大尺寸(TargetType + PrecisionType + DataLayoutType),而且箱子可以先预订但不立刻装东西(Buffer lazy allocation),甚至能装长度不固定的行李(LoD 变长序列支持)。
MACE 的 Tensor 像一个带万向适配器的行李箱——箱子里有一个通用接口(Buffer + Runtime 指针),到了任何国家都能通过适配器(MapBuffer/UnMapBuffer)来存取内容,还贴了标签说明里面装的是衣服还是洗漱用品(BufferContentType)。
类比到此为止——下面我们深入每一种设计的实际代码和原理。
ncnn Mat:SIMD 友好的打包容器
ncnn 的 Mat 是六个框架中最具”手工匠人”气质的数据容器。它的核心设计思想是:让数据在内存中的排列方式天然适合 SIMD 指令处理。
elempack:动态打包机制
Mat 最独特的概念是 elempack——它描述的是”一个逻辑元素在内存中实际占据多少个物理元素的空间”。听起来抽象,我们用具体例子来理解。
假设你有一个 4 通道的特征图 [1,4,8,8](NCHW 格式),常规的存储方式是把 4 个通道的数据逐通道排列。但 ncnn 在 ARM NEON 环境下会把这个张量打包成 elempack=4 的格式——把 4 个通道同一空间位置的值紧挨着放在一起,这样一条 128-bit NEON 指令就能同时加载 4 个 float32 值(正好对应 4 个通道的同一像素位置)。
// ncnn/src/mat.h 核心结构(简化版)
class Mat {
public:
void* data; // 数据指针
int* refcount; // 引用计数(多个 Mat 可共享同一块内存)
int dims; // 维度数:1/2/3/4
int w, h, d, c; // 宽、高、深度、通道
// elempack:每个逻辑位置打包的元素个数
// ARM NEON 上通常为 1/4/8
// AVX2 上通常为 1/4/8
// AVX-512 上通常为 1/4/8/16
size_t elempack;
size_t elemsize; // 每个打包元素的字节数 = elempack * sizeof(T)
size_t cstep; // 通道步长
};
elempack 的值取决于运行平台和通道数:在 ARM NEON 上,如果通道数是 4 的倍数,elempack 就是 4(128-bit / 32-bit = 4);如果通道数是 8 的倍数且平台支持,elempack 可以是 8。在 x86 AVX2 上,elempack 可以达到 8(256-bit / 32-bit);在 AVX-512 上可以达到 16。
这意味着同一个逻辑形状的张量,在不同硬件上的物理内存布局是不同的——ncnn 把”数据排列要适配 SIMD 宽度”这件事直接编码进了数据容器中,而不是留给算子实现时再去重排。
// 创建一个 elempack=4 的 Mat
// 逻辑形状:64 个通道,每个通道 56x56
// 物理存储:16 个 "pack4 通道",每个通道 56x56,每个位置存 4 个 float
ncnn::Mat packed;
packed.create(56, 56, 64 / 4, 4u * 4, 4);
// 参数含义:w=56, h=56, c=16, elemsize=16(4个float), elempack=4
// 访问时,一个"逻辑通道组"的数据连续存放
float* ptr = packed.channel(0); // 指向第 0-3 通道的打包数据
// ptr[0], ptr[1], ptr[2], ptr[3] = 通道 0,1,2,3 在位置(0,0)的值
// ptr[4], ptr[5], ptr[6], ptr[7] = 通道 0,1,2,3 在位置(1,0)的值
引用计数与零拷贝视图
Mat 通过引用计数(refcount)实现浅拷贝和零拷贝视图。当你把一个 Mat 赋值给另一个 Mat 时,并不会复制数据——两个 Mat 共享同一块内存,引用计数加 1。只有当所有引用都释放后,内存才会被真正释放。
ncnn::Mat a(224, 224, 3); // 创建 Mat,refcount = 1
ncnn::Mat b = a; // 浅拷贝,refcount = 2,共享内存
// a 和 b 指向同一块数据
// channel_range:零拷贝通道切片
ncnn::Mat first_half = a.channel_range(0, 32);
// first_half 指向 a 的前 32 个通道,不拷贝数据
// 内部只是调整了 data 指针和 c 字段
channel_range 是 ncnn 中最常用的零拷贝操作之一——在 Group Convolution(分组卷积)等操作中,需要把输入按通道分成多个组分别计算,channel_range 让这个分组操作完全不涉及数据拷贝。
fill:SIMD 友好的填充
Mat 的 fill 方法不是简单的 memset,它针对不同的 elempack 使用对应的 SIMD 指令来填充数据:
// 在 ARM NEON 上,fill(0.f) 会使用 vdupq_n_f32 指令
// 一次填充 4 个 float(128-bit)
void Mat::fill(float _v) {
int size = (int)total();
float* ptr = (float*)data;
#if __ARM_NEON
float32x4_t _p = vdupq_n_f32(_v);
int nn = size >> 2;
for (int i = 0; i < nn; i++) {
vst1q_f32(ptr, _p);
ptr += 4;
}
#endif
int remain = size & 3;
for (int i = 0; i < remain; i++) {
*ptr++ = _v;
}
}
设计取舍
ncnn Mat 的设计取舍非常明确:把性能优化编码到数据层。elempack 让数据的内存布局天然适配 SIMD,这意味着算子实现可以直接用 SIMD intrinsics 处理数据而不需要先重排。代价是:Mat 只面向 CPU(以及通过 VkMat 扩展到 Vulkan GPU),它的概念相对简单但和硬件绑定较深——你需要知道当前平台的 SIMD 宽度才能理解 Mat 中数据的真实排列。
MNN Tensor:虚拟 Region 的零拷贝魔术
MNN 的 Tensor 在概念上比 ncnn Mat 复杂一个层级——它不仅存储数据,还支持虚拟数据重排。这个能力让 MNN 在 Reshape、Transpose、Slice 等本质上只改变数据”解读方式”而不改变数据本身的操作上,实现了真正的零拷贝。
NC4HW4:MNN 的标准内存布局
MNN 默认使用 NC4HW4 布局来存储 CPU 端的张量数据。NC4HW4 的含义是:先按 4 个通道分组(C4),每组内部先排 H 行再排 W 列。
逻辑形状:[N=1, C=8, H=2, W=3]
NC4HW4 物理布局:
Group 0 (C=0~3):
位置(0,0): [c0, c1, c2, c3] // 4 个通道值连续存放
位置(0,1): [c0, c1, c2, c3]
位置(0,2): [c0, c1, c2, c3]
位置(1,0): [c0, c1, c2, c3]
位置(1,1): [c0, c1, c2, c3]
位置(1,2): [c0, c1, c2, c3]
Group 1 (C=4~7):
位置(0,0): [c4, c5, c6, c7]
... (同样排列)
这种布局和 ncnn 的 elempack=4 本质上是同一种优化思路——把 4 个通道的数据交错存放以适配 128-bit SIMD 指令。区别在于 ncnn 把打包粒度(elempack)作为 Mat 的属性暴露出来,让不同平台使用不同的打包粒度;MNN 则把 NC4HW4 作为标准化的内部布局,一律按 4 通道分组。
InsideDescribe:Tensor 的内部管理器
MNN Tensor 的核心秘密藏在 InsideDescribe 结构体中——它是 Tensor 的”内部管理器”,存储了用户看不到但引擎运转必需的信息:
// MNN/source/core/TensorUtils.hpp(简化版)
struct InsideDescribe {
enum MemoryType {
MEMORY_HOST, // CPU 内存
MEMORY_DEVICE, // 设备内存(GPU 等)
MEMORY_VIRTUAL // 虚拟内存(不实际分配,通过 Region 引用其他 Tensor)
};
MemoryType memoryType = MEMORY_HOST;
// 虚拟 Region 列表——零拷贝 Reshape/Transpose 的核心
struct Region {
Tensor* origin; // 数据实际来源
int32_t offset; // 在源数据中的偏移
int32_t size[3]; // 三维尺寸
int32_t src_stride[3]; // 源数据的步长
int32_t dst_stride[3]; // 目标数据的步长
};
std::vector<Region> regions;
Usage usage = NORMAL;
Backend* backend = nullptr;
};
MEMORY_VIRTUAL:零拷贝的 Reshape 和 Transpose
MEMORY_VIRTUAL 类型是 MNN Tensor 最精妙的设计。当引擎遇到一个 Reshape 或 Transpose 操作时,它不需要真正搬运数据——只需要创建一个 MEMORY_VIRTUAL 类型的 Tensor,在其 regions 列表中记录”如何从源 Tensor 的数据中取出元素”。
举个具体例子,假设我们要把一个 [2,3] 的矩阵 Reshape 成 [3,2]:
源 Tensor: [2, 3]
数据: [[1,2,3],
[4,5,6]]
内存中连续存储: [1, 2, 3, 4, 5, 6]
Reshape 成 [3, 2]:
结果: [[1,2],
[3,4],
[5,6]]
内存中连续存储: [1, 2, 3, 4, 5, 6] ← 完全相同!
MNN 的做法:
- 创建新 Tensor,memoryType = MEMORY_VIRTUAL
- regions[0].origin = 源Tensor
- regions[0].offset = 0
- 新 Tensor 不分配内存,直接指向源 Tensor 的数据
对于 Transpose,情况更有趣——数据在内存中的”逻辑顺序”确实需要改变,但 MNN 通过修改 Region 的 stride 映射来延迟这个操作,直到某个真正需要连续数据的算子(比如矩阵乘法)出现时才做物理搬运。这种”惰性求值”策略在计算图中连续出现 Reshape + Transpose + Reshape 等组合操作时效果尤其明显——多次逻辑变换被合并成一次物理搬运,甚至可能完全消除。
// Transpose [2,3] -> [3,2] 的 Region 描述
Region r;
r.origin = src_tensor;
r.offset = 0;
r.size[0] = 1;
r.size[1] = 3; // 目标的第一维
r.size[2] = 2; // 目标的第二维
r.src_stride[0] = 6;
r.src_stride[1] = 1; // 源中"列"方向步长为 1
r.src_stride[2] = 3; // 源中"行"方向步长为 3
r.dst_stride[0] = 6;
r.dst_stride[1] = 2; // 目标中"行"方向步长为 2
r.dst_stride[2] = 1; // 目标中"列"方向步长为 1
Geometry Compiler 的搭档
虚拟 Region 机制不是孤立存在的——它是 MNN Geometry Compiler(几何编译器,Ch21 会详细讲)的基础设施。Geometry Compiler 把复杂算子”降级”成若干基本算子的组合,降级过程中产生的中间张量大多是 MEMORY_VIRTUAL 类型,通过 Region 描述它们和源数据的映射关系,从而避免了降级带来的额外内存开销。
设计取舍
MNN Tensor 的设计取舍是:用数据容器的复杂性换取运行时的零拷贝能力。InsideDescribe 和 Region 机制增加了 Tensor 本身的认知复杂度,但让引擎在 Reshape、Transpose、Slice、Concat 等操作上省去了大量的内存分配和数据搬运。对于 Transformer 类模型中大量存在的 view/reshape/permute 操作,这种设计的收益尤其显著。
ONNX Runtime OrtValue/Tensor:最薄的数据包装
如果说 ncnn Mat 是一个全功能的行李箱,MNN Tensor 是一个智能行李箱,那 ONNX Runtime 的 Tensor 就是一个收纳袋——它薄到几乎只是一层透明塑料膜,自身不拥有内存,也不管理内存的分配和释放。
OrtValue:通用值容器
ORT 的最外层容器不是 Tensor,而是 OrtValue——它是一个可以容纳 Tensor、SparseTensor、TensorSeq 甚至 Map 等多种类型的通用值容器:
// onnxruntime/core/framework/ort_value.h(简化版)
class OrtValue {
void* data_; // 指向实际数据的 void 指针
MLDataType type_; // 数据类型信息
// 通过 type_ 来判断 data_ 实际指向的是 Tensor 还是其他类型
};
OrtValue 本身极其轻量——只有一个 void 指针和一个类型标记。真正的维度、形状信息存储在其内部的 Tensor 对象中。
Tensor:不拥有内存的薄包装
ORT 的 Tensor 对象不拥有它所指向的内存。内存的分配和释放由外部的 Allocator 体系负责——Tensor 只是记录了”数据在哪里、形状是什么、属于哪个设备”:
// onnxruntime/core/framework/tensor.h(简化版)
class Tensor {
MLDataType dtype_; // 数据类型
TensorShape shape_; // 形状信息
void* p_data_; // 数据指针(不拥有)
OrtMemoryInfo memory_info_; // 内存位置描述
ptrdiff_t byte_offset_; // 在 buffer 中的偏移
int64_t* strides_; // 步长数组
// 可选的 deleter 来释放数据
BufferDeleter buffer_deleter_;
};
OrtMemoryInfo:三元组定位
ORT 用 OrtMemoryInfo 来精确描述一块内存的位置——它由三个字段组成:
struct OrtMemoryInfo {
OrtAllocatorType alloc_type; // 分配器类型
OrtMemType mem_type; // 内存类型
OrtDevice device; // 设备信息
};
struct OrtDevice {
DeviceType device_type; // CPU / CUDA / OpenVINO / DML / ...
MemoryType memory_type; // Default / CUDA_PINNED / HBM / ...
DeviceId device_id; // 设备编号(多 GPU 场景)
};
这个三元组的意义在于:当模型在多个硬件上分布式执行时(比如一部分在 CPU 上,一部分在 GPU 上),每个 Tensor 都清楚地标记了自己的数据在哪个设备的哪种内存上。调度器在需要跨设备传数据时,可以通过比对两个 Tensor 的 OrtMemoryInfo 来判断是否需要做内存拷贝。
TensorShape:小优化大收益
ORT 的 TensorShape 有一个精巧的优化——对于 5 维及以下的形状(覆盖了绝大多数推理场景),shape 数据直接内联存储在栈上,避免堆内存分配:
class TensorShape {
static constexpr size_t kInlineSize = 5;
int64_t inline_dims_[kInlineSize]; // 栈上存储
int64_t* p_dims_; // 超过 5 维时指向堆内存
size_t n_dims_;
public:
TensorShape(std::initializer_list<int64_t> dims) {
n_dims_ = dims.size();
if (n_dims_ <= kInlineSize) {
p_dims_ = inline_dims_; // 直接用栈上数组
std::copy(dims.begin(), dims.end(), inline_dims_);
} else {
p_dims_ = new int64_t[n_dims_]; // 超过 5 维才堆分配
std::copy(dims.begin(), dims.end(), p_dims_);
}
}
};
这个优化看似微小,但在推理过程中 TensorShape 会被大量创建和销毁——每个算子在推理形状(shape inference)阶段都会创建临时的 TensorShape。避免堆分配消除了内存分配器的开销,也减少了内存碎片。
设计取舍
ORT Tensor 的设计取舍是:容器极薄,内存管理完全外置。Tensor 不拥有内存、不管打包格式、不管设备调度——这些全部交给 Allocator、ExecutionProvider 和调度器。这种”关注点分离”的设计让 ORT 能够支持几十种不同的硬件后端——每个后端只需要提供自己的 Allocator,Tensor 这个”信封”对所有后端都一样。代价是 Tensor 本身没有任何性能优化——不像 ncnn 的 elempack 那样把 SIMD 友好性编码进数据层,ORT 的 Tensor 是”性能无关”的。
TNN Blob:描述与数据的分离
TNN 的数据容器叫 Blob,它的设计哲学是把”描述”和”数据”彻底分开。BlobDesc 描述张量”长什么样”,BlobHandle 指向张量”在哪里”——两者可以独立变化。
BlobDesc:纯描述信息
// TNN/source/tnn/core/blob.h(简化版)
struct BlobDesc {
DeviceType device_type; // CPU / ARM / OPENCL / METAL / CUDA / ...
DataType data_type; // FLOAT / HALF / INT8 / INT32 / ...
DataFormat data_format; // NCHW / NHWC / NC4HW4 / NC8HW8 / ...
DimsVector dims; // 形状:{N, C, H, W}
std::string name; // Blob 名称(对应模型中的张量名)
};
TNN 定义了 6 种以上的 DataFormat,这比大多数框架都多:
enum DataFormat {
DATA_FORMAT_NCHW = 0, // 标准 NCHW
DATA_FORMAT_NHWC = 1, // TensorFlow 风格
DATA_FORMAT_NC4HW4 = 2, // 4 通道打包(ARM/GPU 友好)
DATA_FORMAT_NC8HW8 = 3, // 8 通道打包(AVX2 友好)
DATA_FORMAT_NCDHW = 4, // 5 维(3D 卷积)
DATA_FORMAT_NHC4W4 = 5, // Metal 专用布局
DATA_FORMAT_NC16HW16 = 6, // AVX-512 友好
};
这种丰富的格式支持反映了 TNN 的”架构翻新”定位——它继承了 ncnn 对 SIMD 打包的重视(NC4HW4、NC8HW8),同时增加了对更多硬件的原生支持(Metal 的 NHC4W4、AVX-512 的 NC16HW16)。
BlobHandle:数据指针的抽象
struct BlobHandle {
void* base; // 数据基地址
uint64_t bytes_offset; // 在基地址上的偏移量
};
base + bytes_offset 的设计是为了内存池复用——BlobManager(内存池管理器)会预先分配一大块连续内存,然后把不同 Blob 的数据分配到这块内存的不同区域。每个 Blob 的 BlobHandle 共享同一个 base 指针,通过不同的 bytes_offset 来定位自己的数据区域。
BlobManager:统一的内存池
// TNN/source/tnn/core/blob_manager.h(简化版)
class BlobManager {
public:
// 根据计算图分析,一次性为所有中间 Blob 分配内存
Status Init(NetworkConfig& config,
NetStructure* net_structure,
InputShapesMap inputs_shape_map,
DataType input_data_type);
// 获取所有输入/输出 Blob
Status GetAllInputBlobs(BlobMap& blobs);
Status GetAllOutputBlobs(BlobMap& blobs);
private:
// 内存池:预分配大块内存,按偏移分配给各 Blob
AbstractDevice* device_;
// key = blob 名称, value = Blob 指针
std::map<std::string, Blob*> input_blobs_;
std::map<std::string, Blob*> output_blobs_;
// 通过分析张量生命周期,复用不重叠的内存区域
MemoryAssignStrategy strategy_;
};
BlobManager 的工作流程是:在网络初始化时,先分析整个计算图中所有中间张量的形状和生命周期(哪些张量在哪些层之间”活着”),然后用内存复用策略(后面 Ch19 会详细讲)计算出需要的最小内存量,一次性分配大块内存,再把各 Blob 的 BlobHandle 设置为这块大内存中的相应偏移位置。
Blob 的完整结构
把 BlobDesc 和 BlobHandle 组合在一起,就是完整的 Blob:
class Blob {
public:
Blob(BlobDesc desc) : desc_(desc) {}
Blob(BlobDesc desc, BlobHandle handle)
: desc_(desc), handle_(handle) {}
BlobDesc& GetBlobDesc() { return desc_; }
BlobHandle GetHandle() { return handle_; }
void SetHandle(BlobHandle handle) { handle_ = handle; }
private:
BlobDesc desc_; // 描述信息(形状、格式、设备类型)
BlobHandle handle_; // 数据指针(基地址 + 偏移)
};
注意 SetHandle 方法——它允许在不修改描述信息的情况下替换底层数据。这在内存复用场景下非常有用:BlobManager 可以在不同的推理批次之间重新分配内存,只需要更新 BlobHandle 而不需要重建 Blob 对象。
设计取舍
TNN Blob 的设计取舍是:描述与数据正交分离,内存由专门的管理器统一调度。BlobDesc 只管”是什么”,BlobHandle 只管”在哪里”,BlobManager 管”怎么分配”——三者各司其职。这种分离让 TNN 在切换后端时比较灵活(换 BlobDesc 的 device_type 和 data_format,由对应后端的 BlobManager 重新分配 handle),但也带来了额外的间接层——访问数据时需要经过 Blob → BlobHandle → base + offset 的两级间接,比 ncnn Mat 的直接 data 指针多了一步。
Paddle-Lite TensorLite:自带硬件标签的万能容器
Paddle-Lite 的 TensorLite 是六个框架中”信息最密”的数据容器——它直接在张量内部嵌入了目标硬件类型、数据精度和内存布局信息,并且支持延迟分配内存和变长序列。
内嵌的三维类型标签
// paddle-lite/lite/core/tensor.h(简化版)
class TensorLite {
// 三维类型标签——直接告诉你这个张量属于哪个硬件
TargetType target_; // Host / X86 / ARM / OpenCL / Metal / FPGA / ...
PrecisionType precision_; // Float / Int8 / Int16 / Int32 / Int64 / FP16 / ...
DataLayoutType layout_; // NCHW / NHWC / NC4HW4 / Any / ...
DDimLite dims_; // 形状
Buffer buffer_; // 底层数据缓冲区
LoD lod_; // Level of Detail(变长序列信息)
size_t memory_size_{0}; // 已分配的内存大小
size_t offset_{0}; // 数据偏移
};
这三个标签(target, precision, layout)的组合直接构成了 Paddle-Lite 算子调度的”三维坐标”——当调度器需要为一个 Op 选择具体的 kernel 实现时,它会根据输入 Tensor 的这三个标签来匹配最佳 kernel(Ch18 会详细讲这个三维派发机制)。
// 类型标签的使用示例
void SomeOp::Run() {
auto& input = scope.FindVar("Input")->Get<TensorLite>();
// 根据 target 选择实现
if (input.target() == TARGET(kARM)) {
// ARM 实现
} else if (input.target() == TARGET(kOpenCL)) {
// OpenCL 实现
}
// 根据 precision 选择计算精度
if (input.precision() == PRECISION(kFloat)) {
// float32 计算
} else if (input.precision() == PRECISION(kInt8)) {
// int8 量化计算
}
}
Buffer lazy allocation:延迟内存分配
TensorLite 的 Buffer 支持延迟分配——你可以先创建一个有形状但没有实际数据的 Tensor,内存在第一次真正需要写入数据时才分配:
class Buffer {
void* data_{nullptr}; // 数据指针,初始为 null
TargetType target_; // 目标硬件
size_t space_{0}; // 已分配空间大小
public:
void* data() const { return data_; }
void ResetLazy(TargetType target, size_t size) {
// 如果已有足够空间,不重新分配
if (space_ >= size && target_ == target) return;
Free(); // 释放旧内存
target_ = target;
space_ = size;
// 延迟分配:真正的 malloc 在这里发生
data_ = TargetMalloc(target, size);
}
};
// TensorLite 的 Resize 不分配内存,只设置形状
void TensorLite::Resize(const DDimLite& dims) {
dims_ = dims;
// 注意:没有分配内存!
}
// 实际分配在 mutable_data 时发生
template <typename T>
T* TensorLite::mutable_data() {
memory_size_ = dims_.production() * sizeof(T);
buffer_.ResetLazy(target_, memory_size_);
return static_cast<T*>(buffer_.data());
}
延迟分配的好处在于:Paddle-Lite 的 MIR(中间表示)优化阶段(Ch21 会详细讲)会反复修改图结构,包括插入类型转换节点、调整算子顺序等。如果每次修改都立即分配内存,大量内存会被浪费在后来被删除或修改的中间节点上。延迟到真正执行时再分配,只有最终确定的图结构中的张量才会消耗内存。
LoD:变长序列支持
Paddle-Lite 继承了 PaddlePaddle 的 LoD(Level of Detail)机制——一种支持变长序列的数据编码方式。LoD 允许一个 Tensor 存储多个长度不同的序列,而不需要用 padding 填充到相同长度:
// LoD 示例:存储 3 个不同长度的句子
// 句子1: "我 爱 学习"(3 个词)
// 句子2: "今天 天气 真 好"(4 个词)
// 句子3: "你好"(2 个词)
TensorLite tensor;
tensor.Resize({9, 128}); // 共 9 个词,每个词 128 维特征
// LoD 信息记录每个序列的起止位置
LoD lod = {{0, 3, 7, 9}};
// 句子1: 索引 0~2
// 句子2: 索引 3~6
// 句子3: 索引 7~8
tensor.set_lod(lod);
LoD 是 PaddlePaddle 生态独有的机制,其他五个框架都不支持——它们处理变长序列的方式要么是 padding(用零填充到相同长度),要么是 dynamic shape(动态形状,每次推理传入不同尺寸的 Tensor)。LoD 的优势是完全不浪费存储空间(不需要 padding),劣势是增加了编程复杂度——算子需要处理 LoD 信息来正确切分序列。
设计取舍
Paddle-Lite TensorLite 的设计取舍是:把硬件适配信息直接嵌入数据容器,用容器的丰富性换取调度的便利性。三维类型标签让调度器可以直接从 Tensor 身上读取目标硬件、精度和布局信息,无需查询外部表。Buffer lazy allocation 适配了多阶段优化的流程。LoD 支持变长序列但增加了生态绑定——离开 PaddlePaddle 生态,LoD 就没有意义了。
MACE Tensor:面向异构硬件的多态容器
MACE 的 Tensor 是六个框架中最”面向对象”的数据容器——它通过 Buffer 基类和 Runtime 指针的多态,实现了对 CPU、GPU(OpenCL)、DSP(Hexagon)等异构硬件内存的统一抽象。
Buffer 与 Runtime 指针
// mace/core/tensor.h(简化版)
class Tensor {
public:
// Runtime 指针:多态的关键
// CpuRuntime / GpuRuntime / HexagonRuntime 等
Runtime* runtime_;
// Buffer:底层数据存储
// CpuBuffer / OpenCLBuffer / OpenCLImage 等
std::unique_ptr<Buffer> buffer_;
// 元数据
std::string name_;
DataType dtype_; // DT_FLOAT / DT_HALF / DT_UINT8 / ...
std::vector<index_t> shape_;
float scale_; // 量化 scale
int32_t zero_point_; // 量化 zero_point
// 缓冲区内容类型
BufferContentType content_type_;
};
MapBuffer / UnMapBuffer:跨设备数据访问
MACE Tensor 最显著的特色是 MapBuffer/UnMapBuffer 机制——这是它处理 CPU 和 GPU 之间数据同步的方式:
// CPU 上的 Tensor 可以直接访问数据
Tensor cpu_tensor;
float* data = cpu_tensor.mutable_data<float>();
// 直接读写 data 指针
// GPU(OpenCL)上的 Tensor 不能直接访问
// 需要先 Map 到 CPU 地址空间
Tensor gpu_tensor; // 底层数据在 GPU 上
gpu_tensor.MapBuffer(
AllocatorMapType::AMT_READ_WRITE,
BlockFlag::BF_TRUE // 阻塞等待 GPU 操作完成
);
float* mapped_data = gpu_tensor.mutable_data<float>();
// 现在可以在 CPU 上读写 mapped_data
// ...操作完成后
gpu_tensor.UnMapBuffer();
// 数据同步回 GPU
MapBuffer/UnMapBuffer 的底层实现取决于 Buffer 的具体类型。对于 OpenCLBuffer,Map 操作会调用 clEnqueueMapBuffer,这是 OpenCL API 提供的零拷贝映射(如果 GPU 和 CPU 共享物理内存的话)或数据拷贝(如果不共享的话)。对于 CpuBuffer,Map/UnMap 是空操作——CPU 数据本来就可以直接访问。
BufferContentType:8 种内容标记
MACE 的 Tensor 有一个独特的字段 BufferContentType,它标记了 Tensor 中数据的”语义类型”:
enum BufferContentType {
IN_OUT_CHANNEL = 0, // 输入/输出通道维度
IN_OUT_HEIGHT = 1, // 输入/输出高度维度
IN_OUT_WIDTH = 2, // 输入/输出宽度维度
CONV2D_FILTER = 3, // 2D 卷积权重
DW_CONV2D_FILTER = 4, // 深度可分离卷积权重
WEIGHT_HEIGHT = 5, // 权重高度
WEIGHT_WIDTH = 6, // 权重宽度
ARGUMENT = 7, // 通用参数
};
BufferContentType 的存在是为了 OpenCL Image 优化。在 OpenCL 中,GPU 的纹理单元(Texture Unit)可以高效读取 2D Image 格式的数据,但 Image 的两个维度是固定的——你需要把多维张量的某两个维度映射到 Image 的 width 和 height 上。不同”语义类型”的张量需要不同的映射策略,BufferContentType 就是告诉 GPU 后端”这个 Tensor 的数据应该怎么映射到 Image 上”。
例如,对于 CONV2D_FILTER 类型的张量(形状 [OC, IC, KH, KW]),MACE 会把 ICKHKW 映射到 Image 的 width,OC 映射到 Image 的 height;而对于 IN_OUT_CHANNEL 类型的张量(形状 [N, C, H, W]),映射方式又不同。
Buffer 继承体系
MACE 的 Buffer 是一个基类,不同硬件后端有不同的子类实现:
class Buffer {
public:
virtual ~Buffer() = default;
// 分配内存
virtual MaceStatus Allocate(index_t nbytes) = 0;
// 获取数据指针(CPU 可用)
virtual void* buffer() = 0;
// Map/UnMap(GPU 数据映射到 CPU)
virtual MaceStatus Map(AllocatorMapType map_type,
BlockFlag block_flag,
void** mapped_ptr) = 0;
virtual MaceStatus UnMap() = 0;
// 数据大小
virtual index_t size() const = 0;
protected:
DataType data_type_;
};
// CPU 实现
class CpuBuffer : public Buffer {
void* data_;
MaceStatus Allocate(index_t nbytes) override {
data_ = memalign(64, nbytes); // 64 字节对齐
return MaceStatus::MACE_SUCCESS;
}
void* buffer() override { return data_; }
// Map/UnMap 是空操作
MaceStatus Map(...) override { *mapped_ptr = data_; return ...; }
MaceStatus UnMap() override { return ...; }
};
// OpenCL Buffer 实现
class OpenCLBuffer : public Buffer {
cl_mem cl_buffer_;
MaceStatus Allocate(index_t nbytes) override {
cl_buffer_ = clCreateBuffer(context, CL_MEM_READ_WRITE,
nbytes, NULL, &err);
return ...;
}
void* buffer() override { return nullptr; } // GPU 数据不能直接访问
MaceStatus Map(...) override {
*mapped_ptr = clEnqueueMapBuffer(queue, cl_buffer_, ...);
return ...;
}
};
设计取舍
MACE Tensor 的设计取舍是:用面向对象的多态抽象来统一异构硬件的内存操作。Buffer 基类和 MapBuffer/UnMapBuffer 接口让上层代码不需要区分数据是在 CPU 还是 GPU 上——统一通过 Map/UnMap 来访问。BufferContentType 虽然增加了容器的复杂度,但解决了 OpenCL Image 映射的实际问题。代价是多态分发带来的间接调用开销,以及 BufferContentType 这种 GPU 特定概念”侵入”了通用容器的设计。
横向对比
| 维度 | ncnn Mat | MNN Tensor | ORT Tensor | TNN Blob | Paddle-Lite TensorLite | MACE Tensor |
|---|---|---|---|---|---|---|
| 内存所有权 | 拥有(refcount) | 拥有 / 虚拟引用 | 不拥有 | 不拥有(BlobManager管理) | 拥有(Buffer) | 拥有(unique_ptr) |
| 内存布局感知 | elempack 动态打包 | NC4HW4 固定 | 无(布局无关) | 6+种DataFormat | NCHW/NHWC/NC4HW4 | 由BufferContentType决定 |
| 零拷贝能力 | channel_range切片 | MEMORY_VIRTUAL Region | byte_offset视图 | base+offset复用 | 无特殊机制 | 无特殊机制 |
| 多硬件支持 | CPU(+VkMat) | CPU/GPU | OrtMemoryInfo三元组 | DeviceType字段 | TargetType嵌入 | Runtime多态 |
| 特殊能力 | SIMD fill | 虚拟Reshape/Transpose | TensorShape内联 | 内存池统一管理 | LoD变长序列 | MapBuffer跨设备访问 |
| 容器大小 | 中等 | 较大(含InsideDescribe) | 极小 | 小(Desc+Handle) | 中等 | 较大(含Buffer子类) |
核心取舍分析
六种设计本质上在同一个谱系上做取舍,这个谱系有三个主要维度。
第一个维度是容器厚度 vs 系统复杂度。ORT Tensor 几乎是零厚度——它只是一个指针加形状信息,所有复杂性都推给了外部系统(Allocator、ExecutionProvider)。ncnn Mat 和 MACE Tensor 则相对”厚”——它们在容器内部嵌入了性能优化(elempack)或多态抽象(Buffer继承体系)。TNN Blob 取了中间路线——容器本身不厚(Desc + Handle),但有一个外部管理器(BlobManager)来处理内存池。
第二个维度是性能优化的位置。ncnn 把 SIMD 优化编码在数据层(elempack),让数据天然适配硬件;MNN 把零拷贝优化编码在数据层(MEMORY_VIRTUAL + Region),让数据变换不产生开销;ORT 把性能优化完全推到算子层和后端层,数据层保持中立;Paddle-Lite 把硬件适配信息嵌入数据层(三维标签),但实际优化在 kernel 层。
第三个维度是生态耦合度。Paddle-Lite 的 LoD 机制紧耦合于 PaddlePaddle 生态——离开 PaddlePaddle 的模型,LoD 就无用武之地。MACE 的 BufferContentType 紧耦合于 OpenCL 的 Image 映射策略——不用 OpenCL 的后端不需要这个概念。ncnn 的 elempack 紧耦合于 SIMD 指令集。而 ORT 的 Tensor 几乎没有生态耦合,这也是它能支持最多后端的原因之一。
如果你要实现一个极致性能的 CPU 推理引擎,ncnn Mat 的 elempack 设计值得学习——它从数据层就开始为 SIMD 优化铺路。如果你要实现一个 Transformer 推理引擎,MNN Tensor 的虚拟 Region 机制是关键——Transformer 中大量的 Reshape 和 Transpose 操作在这种设计下几乎零开销。如果你要实现一个跨硬件的通用推理引擎,ORT Tensor 的极简设计是参考答案——让每个后端自己决定内存策略,容器只做最薄的包装。
本章小结
数据容器是推理引擎最基础的抽象——所有后续的算子、调度、优化都建立在它之上。六个框架的数据容器设计可以浓缩为一句话:ncnn 把 SIMD 编码进数据,MNN 让数据变换延迟求值,ORT 让容器极简把复杂性外推,TNN 分离描述与数据并用内存池管理,Paddle-Lite 在数据上贴满硬件标签,MACE 用面向对象多态统一异构硬件。
这些设计差异并非孤立存在——它们直接影响了每个框架的算子抽象方式。下一章我们将看到,当 ncnn 的算子拿到一个已经按 elempack 打包好的 Mat 时,它的实现方式和 ORT 的算子拿到一个”什么都不知道”的 Tensor 时,完全是两种画风。
导读目录:README.md 上一章:Ch16: MACE——深入骨髓的硬件适配 下一章:Ch18: 算子抽象——设计哲学的分水岭