读 ncnn 源码(XIX):fuse_convolution_add——融合逐通道加法,进一步合并线性计算

本篇,我们将继续沿着合并连续线性计算的优化思路,分析 ncnn 中另一个重要的融合 Pass:fuse_convolution_add

此 Pass 专门针对 **Convolution 层后紧跟一个特定模式的逐通道加法(通过 BinaryOp 实现)**的场景。这种情况可能出现在某些网络结构(如 ResNet bottleneck 中的 residual connection 如果退化为加偏置)或者模型转换过程中。将其融合,可以消除冗余的加法操作,进一步提升推理效率。

TL;DR

  1. 目标: 将 Convolution 层后接一个扮演逐通道加法 (Per-Channel Bias Addition) 角色的 BinaryOp 层(操作类型为 Add,且第二个输入来自 MemoryData)进行融合。
  2. 模式匹配: 查找 Convolution -> BinaryOp 结构,并附加严格条件:
    • BinaryOp 的类型必须是加法 (op_type == 0)。
    • BinaryOp 不能是与标量相加 (!with_scalar)。
    • BinaryOp 的第二个输入 bottoms[1] 必须来自一个 MemoryData 层。
    • MemoryData 层的数据维度必须符合逐通道加法的广播要求(例如,形状为 [channels][channels, 1, 1],ncnn 代码中检查了 [channels][1, 1, channels] 两种情况)。
  3. 数学原理:
    • Conv: y=Wx+bconvy = W * x + b_{conv}
    • BinaryOp(Add): z=y+Bz = y + B (其中 BB 是来自 MemoryData 的、维度为 [channels] 的偏置向量,B 会被广播到 yy 的空间维度)
    • 融合后: z=Wx+bconvz = W * x + b'_{conv}
    • 推导得: bconv=bconv+Bb'_{conv} = b_{conv} + B (卷积核 WW 保持不变)
  4. 代码实现:
    • memorydata->data 中提取偏置向量 B (通过 reshape 确保其为一维)。
    • 检查 Convolution 层是否已有偏置 (bias_term == 1)。
    • 如果没有偏置,则直接将 B 赋值给 convolution->bias_data,并将 bias_term 设为 1。
    • 如果已有偏置 bconvb_{conv},则将 B 逐元素地加convolution->bias_data 上 (bias[i] = bias[i] + bias_data[i])。
  5. 图结构修改: 将 Convolution 层的 top 指向原 BinaryOp 层的 top,更新 blobproducer,并将 BinaryOp 层标记为 "ncnnfused"
  6. 效果: 消除了 BinaryOp 层引入的逐元素加法计算和额外的内存读写,将加偏置操作无损地合并到卷积的偏置项中。


1. 融合动机:合并连续的偏置添加

Convolution 层的计算结果 y=Wx+bconvy = W * x + b_{conv} 本身就包含一个偏置项 bconvb_{conv}。如果其后紧跟的 BinaryOp 只是对每个通道加上一个固定的偏置值 BB(来自 MemoryData),那么这两个连续的加法操作 (+bconv+b_{conv}+B+B) 显然可以在代数上合并为一次加法 (+(bconv+B))(+ (b_{conv} + B))

将这个合并后的偏置 (bconv+B)(b_{conv} + B) 直接存入 Convolution 层的 bias_data 中,就可以在推理时完全跳过 BinaryOp 层,从而减少计算量和内存访问。


2. 模式匹配:确保是“逐通道加偏置”

fuse_convolution_add 的模式匹配与 fuse_convolution_mul 非常相似,关键在于确保 BinaryOp 确实是在执行“逐通道加偏置”这一特定任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
int NetOptimize::fuse_convolution_add()
{
const size_t layer_count = layers.size();
for (size_t i = 0; i < layer_count; i++) // 遍历找到 Convolution
{
if (layers[i]->type != "Convolution") continue;
int top_blob_index = layers[i]->tops[0];

size_t j = i + 1; // 从下一层开始查找 BinaryOp
for (; j < layer_count; j++)
{
if (layers[j]->type != "BinaryOp") continue;
if (layers[j]->bottoms.size() != 2) continue;
if (layers[j]->bottoms[0] == top_blob_index) break; // 确认第一个输入来自 Conv
}
if (j == layer_count) continue; // 未找到

ncnn::Convolution* convolution = (ncnn::Convolution*)layers[i];
ncnn::BinaryOp* binaryop = (ncnn::BinaryOp*)layers[j];

// 条件 1: 必须是加法操作 (op_type 0 代表 Add)
// 条件 2: 不能是与标量相加
if (binaryop->op_type != 0 || binaryop->with_scalar) continue;

// 条件 3: 查找 BinaryOp 的第二个输入的生产者,必须是 MemoryData
size_t k = 0;
for (; k < j; k++)
{
if (layers[k]->type != "MemoryData") continue;
if (layers[k]->tops[0] == binaryop->bottoms[1]) break;
}
if (k == j) continue; // 未找到 MemoryData

ncnn::MemoryData* memorydata = (ncnn::MemoryData*)layers[k];

// 条件 4: 校验 MemoryData 的形状是否符合逐通道加偏置
int channels = convolution->num_output;
bool broadcasting_type_ok = false;
// 支持 [channels] 形状的向量
if (memorydata->w == channels && memorydata->h == 0 && memorydata->c == 0)
broadcasting_type_ok = true;
// 也支持 [1, 1, channels] 形状 (或者在ncnn中可能表示为 [channels, 1, 1]?)
// 注意: ncnn Mat 的 w, h, c 语义有时需要结合上下文理解,这里检查了两种常见的广播形式
if (memorydata->w == 1 && memorydata->h == 1 && memorydata->c == channels)
broadcasting_type_ok = true;

if (!broadcasting_type_ok)
{
// 如果形状不匹配,则不能融合
continue;
}

fprintf(stderr, "fuse_convolution_add %s %s\n", convolution->name.c_str(), binaryop->name.c_str());
// ... 执行参数变换和图修改 ...
}
return 0;
}

这个匹配过程确保了只有当 BinaryOp 确实是“使用一个固定的偏置向量 B 对来自 Convolution 的特征图 y 进行逐通道相加 y + B”时,融合才会触发。代码特别检查了 MemoryData 的两种常见广播形状。


3. 数学原理与代码实现:合并偏置项

融合的核心是将 BinaryOp 的偏置向量 B (即 memorydata->data) 加到 Convolution 的偏置 b_{conv} 上。

a) 数学推导:

我们希望找到 bconvb'_{conv} 使得:

z=Wx+bconv=(Wx+bconv)+Bz = W * x + b'_{conv} = (W * x + b_{conv}) + B

显然,只需令:

bconv=bconv+Bb'_{conv} = b_{conv} + B

卷积核 WW 保持不变。

b) 代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1. 将 MemoryData 的数据 reshape 成一维向量 B
ncnn::Mat bias_data = memorydata->data.reshape(channels);
{
// 2. 检查 Convolution 是否已有偏置
if (convolution->bias_term == 0)
{
// 如果没有,直接将 B 作为新的偏置
convolution->bias_term = 1; // 标记为有偏置
convolution->bias_data = bias_data; // 赋值 (可能是浅拷贝,取决于 Mat 实现)
}
else
{
// 如果已有偏置 b_conv,则执行逐元素加法 b_conv = b_conv + B
float* bias = convolution->bias_data; // 获取指向 b_conv 的指针
for (int i = 0; i < channels; i++)
{
bias[i] = bias[i] + bias_data[i]; // b_new = b_old + B
}
}
}
  • memorydata->data.reshape(channels): 确保 bias_data 是一个 [channels] 的一维 Mat,方便后续访问。
  • 处理 bias_term == 0: 如果原卷积层没有偏置,融合后就需要添加偏置项,并将 MemoryData 的数据直接赋给它。
  • 处理 bias_term == 1: 如果原卷积层已有偏置,则将 MemoryData 的数据逐元素加到现有偏置上。

4. 图结构修改

参数更新完毕后,同样需要更新计算图的拓扑结构:

1
2
3
4
int top_blob_index_final = binaryop->tops[0]; // 获取 BinaryOp 原来的输出 Blob
convolution->tops[0] = top_blob_index_final; // 将 Conv 的输出直接连接到最终 Blob
blobs[top_blob_index_final].producer = i; // 更新 Blob 的生产者为 Conv 层
binaryop->type = "ncnnfused"; // 标记 BinaryOp 层为无效

这与 fuse_convolution_mulfuse_convolution_batchnorm 的图修改逻辑完全一致。


5. 意义与限制

  • 意义:
    • 减少计算量: 消除了 BinaryOp 层对整个特征图进行逐元素加法的计算。
    • 减少内存访问: 避免了读取 MemoryData、读取 Conv 输出、写入 BinaryOp 输出的内存操作。
    • 优化线性链: 与乘法融合类似,加法融合有助于进一步合并连续的线性操作。
  • 限制:
    • 仅限 Add: 只处理加法类型的 BinaryOp
    • 仅限 MemoryData: 只处理第二个操作数来自固定 MemoryData 的情况。
    • 广播模式: MemoryData 的形状必须符合逐通道加偏置的广播规则。

6. 结语

fuse_convolution_add 作为 ncnnoptimize 工具箱中的一员,进一步完善了对 Convolution 层后接线性操作的融合优化能力。它通过简单的偏置项合并,有效地消除了冗余的逐通道加法运算。这一系列的融合 Pass(包括 BN、Scale、Mul、Add 等)共同作用,使得 ncnn 能够将原始的、可能带有冗余操作的网络计算图,转化为一个更加紧凑、计算和访存都更高效的执行形式,为端侧部署提供了坚实的性能基础。

该封面图片由Roderick QiuPixabay上发布