如何通过 ncnn 算子融合黑魔法减少推理时的内存读写
在端侧推理优化中,算力往往不是唯一的瓶颈,内存带宽(Memory Bandwidth)才是。在 ncnn 推理框架中,虽然 ncnnoptimize 工具已经能自动处理大部分算子融合(如 Conv+ReLU),但面对一些复杂的自定义模型或未被工具识别的结构(如卷积后接自定义的 Scale),手动进行参数合并(Parameter Merging)能显著减少内存读写次数,提升推理速度。
1. 为什么要手动合并?
每一个算子的执行流程通常是:从内存读取输入 -> 计算 -> 将结果写入内存。
如果你的模型中有 Convolution -> Scale 的结构,默认情况下 ncnn 会执行两次读写。通过数学变换,我们可以将 Scale 的系数直接合并到卷积的权重(Weight)和偏置(Bias)中,从而将两个算子合并为一个卷积算子,消除一次中间内存操作。
2. 数学原理:合并 Scale 到 Convolution
假设卷积层的输出为 $Y = W \cdot X + B$,Scale 层的操作为 $Z = S \cdot Y + O$($S$ 为缩放因子,$O$ 为偏移)。
将 $Y$ 代入 $Z$:
$Z = S \cdot (W \cdot X + B) + O$
$Z = (S \cdot W) \cdot X + (S \cdot B + O)$
由此可见,我们只需要更新卷积层的权重为 $W’ = S \cdot W$,偏置为 $B’ = S \cdot B + O$,即可完全取代 Scale 层。
3. 实操步骤:手动修改 ncnn 参数
虽然可以在 C++ 代码中动态修改,但最直接的方法是编写 Python 脚本处理 .param 和 .bin 文件。
代码示例:合并 Conv 和 Scale 的权重
import numpy as np
def merge_conv_scale(conv_weight, conv_bias, scale_data, scale_bias):
# 假设 conv_weight 形状为 [out_ch, in_ch, k, k]
# scale_data 形状为 [out_ch]
# 1. 更新权重:每个输出通道的卷积核乘以对应的 scale
new_weight = conv_weight * scale_data.reshape(-1, 1, 1, 1)
# 2. 更新偏置:新的偏置 = scale * 原偏置 + scale_bias
if conv_bias is None:
conv_bias = np.zeros(len(scale_data))
new_bias = scale_data * conv_bias + scale_bias
return new_weight, new_bias
# 模拟从 .bin 文件加载数据
# weight = np.fromfile('model.bin', offset=..., dtype=np.float32)
4. 在 ncnn 中实现手动合并的黑魔法
如果你不想修改物理文件,可以在 ncnn 加载模型后,利用 Layer 指针直接操作内存。这在处理动态生成的参数时非常有用。
#include "net.h"
#include "layer.h"
void optimize_conv_scale(ncnn::Net& net) {
// 找到卷积层和其后的 Scale 层
ncnn::Layer* conv = net.layers()[conv_idx];
ncnn::Layer* scale = net.layers()[scale_idx];
// 获取卷积层的权重权重矩阵 (ncnn::Mat)
ncnn::Mat& weight = conv->layers[0]->weights; // 简化表达
ncnn::Mat& bias = conv->layers[0]->bias;
// 获取 Scale 层的参数
const ncnn::Mat& scale_data = scale->weights;
const ncnn::Mat& shift_data = scale->bias;
// 遍历通道进行合并
float* w_ptr = weight;
float* b_ptr = bias;
int kernel_size = weight.w * weight.h;
for (int i = 0; i < weight.c; i++) {
float s = ((const float*)scale_data)[i];
float o = ((const float*)shift_data)[i];
// 更新偏置
b_ptr[i] = b_ptr[i] * s + o;
// 更新权重
float* channel_w_ptr = weight.channel(i);
for (int k = 0; k < kernel_size; k++) {
channel_w_ptr[k] *= s;
}
}
// 注意:最后需要在 param 中剔除 scale 层,或将其设置为 PassThrough
}
5. 总结与建议
- 优先使用自动化工具:运行 ncnnoptimize 通常能解决 90% 的问题。
- 识别冗余:使用网络可视化工具(如 Netron)查看模型,如果发现大量卷积后跟着独立的 Scale 或 Power 算子,这就是手动优化的机会。
- 对齐对齐再对齐:手动合并时,务必注意 ncnn 的数据排布(如通道对齐、量化系数等),防止精度丢失。
通过这种“黑魔法”,在低算力设备(如 MCU 或低端安卓机)上,通常能获得 5%-10% 的帧率提升。
汤不热吧