犀牛鸟 2026 研究笔记 estelledc.github.io

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: 算子抽象——设计哲学的分水岭