4. 模型构建与操作(Operations)

好,咱们接着往下聊。上一章我们把模型的基础结构搭起来了,但一个神经网络模型光有结构可不行,它得能「干活」。怎么干活?就是靠操作(Operations)。

说白了,操作就是告诉硬件:「嘿,你把这堆数据做个卷积,再把结果做个ReLU激活,最后给我吐出来。」NNAPI里,这些操作被抽象成一个个的ANeuralNetworksModel_addOperation调用。

我个人习惯把模型构建比作搭积木。张量是积木块,操作就是胶水。没有胶水,积木堆得再高也是一碰就倒。

4.1 支持的操作类型列表

NNAPI支持的操作类型,其实一直在扩充。从Android 8.1(API 27)的几十个,到Android 14(API 34)已经超过100个。我列一下最常用的几类,你心里有个谱就行。

类别 典型操作 说明
激活函数 ANEURALNETWORKS_RELU, ANEURALNETWORKS_SIGMOID, ANEURALNETWORKS_TANH 给线性变换加点非线性,不然堆再多层也是线性
卷积 ANEURALNETWORKS_CONV_2D, ANEURALNETWORKS_DEPTHWISE_CONV_2D 图像处理的核心,我项目中90%的模型都用到了
池化 ANEURALNETWORKS_AVERAGE_POOL_2D, ANEURALNETWORKS_MAX_POOL_2D 降采样,减少计算量
全连接 ANEURALNETWORKS_FULLY_CONNECTED 分类器最后一层常用
归一化 ANEURALNETWORKS_SOFTMAX, ANEURALNETWORKS_BATCH_NORMALIZATION 让数据分布更稳定
张量操作 ANEURALNETWORKS_RESHAPE, ANEURALNETWORKS_CONCATENATION, ANEURALNETWORKS_SLICE 调整数据形状,拼接拆分
算术运算 ANEURALNETWORKS_ADD, ANEURALNETWORKS_MUL, ANEURALNETWORKS_SUB 逐元素加减乘除
逻辑运算 ANEURALNETWORKS_EQUAL, ANEURALNETWORKS_GREATER, ANEURALNETWORKS_LOGICAL_AND 比较、与或非
小提示:并不是所有设备都支持全部操作。你可以在运行时用ANeuralNetworksCompilation_getSupportedOperations查一下。我曾经在某个低端手机上栽过跟头——模型里用了ANEURALNETWORKS_BATCH_NORMALIZATION,结果设备不支持,编译直接失败。

4.2 添加操作:ANeuralNetworksModel_addOperation

这个函数是模型构建的核心。它的签名长这样:

int ANeuralNetworksModel_addOperation(
    ANeuralNetworksModel* model,
    ANeuralNetworksOperationType type,
    uint32_t inputCount,
    const uint32_t* inputs,
    uint32_t outputCount,
    const uint32_t* outputs
);

参数看着多,其实逻辑很简单:

  • model:你正在构建的模型句柄
  • type:操作类型,比如ANEURALNETWORKS_CONV_2D
  • inputCount / inputs:输入张量的数量及其索引数组
  • outputCount / outputs:输出张量的数量及其索引数组

嗯,这里要注意:输入输出的顺序是固定的。比如卷积操作,输入张量必须排在第0位,权重排在第1位,偏置排在第2位。顺序搞反了,模型跑出来的结果就是一团浆糊。

避坑指南:我曾经在项目里把卷积的输入和权重顺序写反了。编译没报错,跑出来的结果全是NaN。查了整整两天,最后发现是索引写错了。所以,每次添加操作前,务必对照NNAPI文档确认输入输出顺序

来看一个完整的例子:添加一个ReLU激活操作。

// 假设我们已经有了一个输入张量,索引为0
uint32_t inputIndex = 0;
uint32_t outputIndex = 1;

// 创建输出张量(形状与输入相同)
ANeuralNetworksModel_addOperand(model, &operandType);
// 设置输出张量的形状(略)

// 添加ReLU操作
uint32_t reluInputs[] = {inputIndex};
uint32_t reluOutputs[] = {outputIndex};

int result = ANeuralNetworksModel_addOperation(
    model,
    ANEURALNETWORKS_RELU,   // 操作类型
    1,                      // 输入数量
    reluInputs,             // 输入索引数组
    1,                      // 输出数量
    reluOutputs             // 输出索引数组
);

if (result != ANEURALNETWORKS_NO_ERROR) {
    // 处理错误
    __android_log_print(ANDROID_LOG_ERROR, "NNAPI", 
                        "Failed to add ReLU operation: %d", result);
}

你看,代码本身并不复杂。复杂的是你要清楚每个操作需要几个输入、几个输出,以及它们的顺序。

4.3 操作数与张量

操作数(Operand)和张量(Tensor)这两个概念,我刚开始学的时候也容易搞混。简单说:

  • 张量是数据容器,它存着具体的数值
  • 操作数是张量的「引用」或「索引」,它告诉操作去哪里找数据

打个比方:张量就像图书馆里的书,操作数就是书的索书号。你告诉管理员「我要借索书号A-001的书」,管理员就知道去哪个书架拿。

在NNAPI里,每个张量在创建时都会得到一个索引(从0开始递增)。这个索引就是操作数。当你调用ANeuralNetworksModel_addOperation时,传入的就是这些索引。

核心要点:操作数只是整数索引,不是数据本身。数据存储在张量中,通过ANeuralNetworksModel_setOperandValue来填充。

来看一个更完整的例子:构建一个简单的「输入→卷积→ReLU→输出」模型。

// 伪代码,展示操作数与张量的关系

// 步骤1:创建张量
uint32_t inputTensor, weightTensor, biasTensor, convOutputTensor, reluOutputTensor;

// 创建输入张量(索引0)
ANeuralNetworksModel_addOperand(model, &inputOperandType);
inputTensor = 0;

// 创建权重张量(索引1)
ANeuralNetworksModel_addOperand(model, &weightOperandType);
weightTensor = 1;

// 创建偏置张量(索引2)
ANeuralNetworksModel_addOperand(model, &biasOperandType);
biasTensor = 2;

// 创建卷积输出张量(索引3)
ANeuralNetworksModel_addOperand(model, &convOutputOperandType);
convOutputTensor = 3;

// 创建ReLU输出张量(索引4)
ANeuralNetworksModel_addOperand(model, &reluOutputOperandType);
reluOutputTensor = 4;

// 步骤2:设置张量数据(权重、偏置等)
ANeuralNetworksModel_setOperandValue(model, weightTensor, weightData, weightSize);
ANeuralNetworksModel_setOperandValue(model, biasTensor, biasData, biasSize);

// 步骤3:添加卷积操作
// 输入顺序:input, weight, bias
// 输出顺序:convOutput
uint32_t convInputs[] = {inputTensor, weightTensor, biasTensor};
uint32_t convOutputs[] = {convOutputTensor};
ANeuralNetworksModel_addOperation(model, ANEURALNETWORKS_CONV_2D,
                                  3, convInputs, 1, convOutputs);

// 步骤4:添加ReLU操作
// 输入顺序:convOutput
// 输出顺序:reluOutput
uint32_t reluInputs[] = {convOutputTensor};
uint32_t reluOutputs[] = {reluOutputTensor};
ANeuralNetworksModel_addOperation(model, ANEURALNETWORKS_RELU,
                                  1, reluInputs, 1, reluOutputs);

你想想看,这个流程其实很直观。先准备好所有「积木块」(张量),然后用「胶水」(操作)把它们粘起来。每个操作都知道自己该从哪个积木块读数据,该往哪个积木块写结果。

4.4 知识体系总览

为了让你更直观地理解这一章的内容,我画了一张图。它展示了操作、操作数、张量三者之间的关系。

操作、操作数与张量的关系 张量(数据容器) 输入张量(索引0) 权重张量(索引1) 偏置张量(索引2) 操作数(索引引用) inputs[0] = 0 inputs[1] = 1 inputs[2] = 2 操作(Operation) CONV_2D 输入3个,输出1个 RELU 输入1个,输出1个 张量存储数据 → 操作数引用索引 → 操作使用索引执行计算

从这张图你可以看到,整个流程是单向的:张量负责存数据,操作数负责「指路」,操作负责「干活」。各司其职,清晰明了。

4.5 一些实战经验

最后,分享几个我在项目中踩过的坑:

  • 操作数索引不要重复使用:每个张量只能有一个索引。我曾经图省事,把同一个张量既当输入又当输出,结果模型编译直接崩溃。
  • 注意操作的生命周期:操作添加的顺序就是执行顺序。NNAPI不会帮你重排序,你添加的顺序决定了计算图的结构。
  • 善用ANEURALNETWORKS_ADD做残差连接:在构建ResNet这类模型时,残差连接其实就是把两个张量加起来。用ANEURALNETWORKS_ADD操作就能实现,不需要自己写复杂的逻辑。
调试小技巧:如果你不确定操作添加得对不对,可以在编译前调用ANeuralNetworksModel_getSupportedOperations检查一下。这个函数会返回一个布尔数组,告诉你每个操作是否被当前设备支持。我每次写完模型都会跑一遍这个检查,能省下不少调试时间。

好了,这一章的内容就到这里。操作是NNAPI模型构建的灵魂,理解了操作、操作数、张量三者的关系,你就能搭建出任意复杂的神经网络了。


公众号:蓝海资料掘金营,微信deep3321