MNN模型支持:面壁小钢炮MiniCPM-V-4
面壁智能的MiniCPM模型,自发布以来就被誉为“端侧小钢炮”,以其在端侧设备上出色的多模态能力而闻名。MNN作为一个端侧推理框架,支持目前主流的端侧模型,端侧小钢炮的模型也不例外。这里记录一下MNN对MiniCPM-V-4的支持过程。
MiniCPM-V-4模型介绍
首先,简单了解一下 MiniCPM-V-4:
- 参数规模:4.1B,由一个400M的视觉编码器和一个3B的语言模型组成。
- 模型性能:在权威的OpenCompass评测中得分69.0,表现优于许多同量级模型。在旗舰手机上,可以实现流畅的实时交互(首token延迟<2s,解码速度>17 token/s)。
对这个模型的支持主要工作是对他的视觉处理部分的支持
模型导出
我们主要针对模型的视觉处理部分(Vision Encoder)进行了三项关键优化。
优化策略一:变动态搜索为静态计算的图像切分
问题背景:为了处理高清大图,MiniCPM-V 4.0会智能地将图像切分成多个小块(Slices)。原始实现需要通过一个搜索算法,在运行时动态计算出最佳的切分网格,这个过程在端侧会带来不必要的延迟。
我们的解决方案:我们发现这个搜索过程可以被一个确定性的数学模型替代。通过分析图像的面积、长宽比等几何特征,我们可以用一次前向计算直接得出最优的切分方案。
优化后的核心算法逻辑:
def calculate_image_processing_plan(self, original_size, max_slice_nums=9, scale_resolution=448):
original_height, original_width = original_size
ratio = (original_width * original_height) / (scale_resolution * scale_resolution)
multiple = min(math.ceil(ratio), max_slice_nums)
# 智能网格划分算法
if multiple > 1:
candidates = []
for num in {multiple - 1, multiple, multiple + 1}:
if 1 < num <= max_slice_nums:
m = 1
while m * m <= num:
if num % m == 0:
candidates.append((m, num // m))
m += 1
# 选择最接近原图长宽比的网格
log_ratio = math.log(original_width / original_height)
best_grid = min(candidates, key=lambda g: abs(log_ratio - math.log(g[1] / g[0])))
优化效果:这个改动将一个复杂的动态逻辑,简化为了C++中易于实现的纯数学运算。
优化策略二:用一次Permute操作统一几何变换
问题背景:原始的图像切分和重排逻辑,涉及到多次reshape和transpose操作。这不仅代码繁琐,而且每次操作都可能触发内存拷贝,效率不高。
我们的解决方案:我们深入分析后发现,切图和重排的本质,都是在更高维度上对张量(Tensor)的数据进行位置重排。因此,这些看似复杂的多步操作,完全可以通过一次精心设计的reshape和一次permute(维度置换)操作来完成。
实现对比 (示意):
- 原始逻辑:
# 步骤1:切分 patches = image.reshape(...) # 步骤2:多次重排 temp1 = patches.transpose(...) result = temp1.reshape(...).transpose(...) - 优化后逻辑:
# 一步到位 # 先构建一个包含所有维度信息的高维张量 high_dim_tensor = images.reshape(...) # 再通过一次permute完成所有数据的位置交换 permuted_tensor = high_dim_tensor.permute(...) result = permuted_tensor.reshape(...)优化效果:这一改动对C++的实现极为友好。原本需要编写复杂循环和索引计算的代码,现在简化为对底层
permute算子的一次调用,代码更简洁,执行效率也更高。
优化策略三:为计算图导出重构推理逻辑
问题背景:为了实现跨平台部署,模型必须能够导出计算图。
我们的解决方案:遵循“动静分离”的原则,我们将所有动态逻辑从模型的核心计算图中剥离出去。
- 预处理阶段完成填充:在C++的图像预处理阶段,就将所有输入数据填充(Pad)到固定的最大尺寸。这样,送入模型的张量尺寸永远是静态的。
- 简化和前置掩码计算:将动态生成注意力掩码(Attention Mask)的逻辑,同样移到模型外部的预处理环节。
- 引入缓存机制:对于可复用的计算结果,如位置编码,我们增加了缓存,避免在每次推理中重复生成。
优化效果:经过重构,模型变成了一个完全静态的计算图,可以顺利地导出为ONNX文件,为最终在MNN上的高效运行铺平了道路。
模型推理
理论层面的优化最终需要通过高效、稳健的代码实现来落地。我们将 MiniCPM 视觉处理的核心逻辑在 MNN 框架内,通过 C++ 进行了全面重构。下面,我们将详细拆解 minicpmVisionProcess 函数的实现,展示如何将优化思想转化为高性能的推理管线。
核心实现:reoderImage 变换函数
整个流程中最精妙的部分被封装在一个名为 reoderImage 的 Lambda 函数中。它体现了优化策略二(用一次 Permute 统一几何变换),负责将输入图像高效地缩放、切分并重排为模型所需的 Patch 序列。
auto reoderImage = [this, &patchSize](
Express::VARP img, std::pair<int, int> targetSize, std::pair<int,int> grid, std::vector<int>& tgtSize) {
// 1. 图像预处理:缩放、归一化、颜色空间转换
auto patches = MNN::CV::resize(img, ...);
patches = Express::_Convert(patches, NCHW);
// 2. 高效切片与重排的核心:Reshape -> Permute -> Reshape
patches = Express::_Reshape(patches, {
channel, gridH, numPatchesH, patchSize, gridW, numPatchesW, patchSize
});
patches = Express::_Permute(patches, {1, 4, 0, 3, 2, 5, 6});
patches = Express::_Reshape(patches, {
gridH * gridW, channel, patchSize, numPatchesH * numPatchesW * patchSize
});
// ...
return patches;
};
此函数的核心在于,它将复杂的切图逻辑收敛到了一次 _Permute 原子操作。通过先 _Reshape 将图像张量提升到包含了网格、Patch、通道等所有几何信息的 7 维,然后一次 _Permute 就完成了所有数据块的位置交换,最后 _Reshape 回目标形状。这套操作避免了繁琐的循环和内存拷贝,为 C++ 的高性能实现提供了巨大便利。
静态模型输入构建
遵循优化策略三(动静分离)的原则,我们在模型外部的 C++ 预处理阶段,准备好了 Vision Encoder 所需的全部四个静态输入张量。
-
pixel_values(像素值):auto globalImage = reoderImage(image, globalSize, std::make_pair(1, 1), tgtSize); auto refineImage = reoderImage(image, refineSize, sliceGrids, tgtSize); // 对 globalImage 进行 Padding,使其尺寸与 refineImage 对齐 globalImage = _Pad(globalImage, ...); auto pixel_values = _Concat({globalImage, refineImage}, 0);我们分别对全局图像(1x1网格)和高清切片图像调用
reoderImage。然后,将较小的globalImage填充(Pad)到与refineImage相同的尺寸,最后将它们拼接(Concat)成一个 Batch,送入模型。所有动态尺寸处理都在模型外部完成。 -
position_ids(位置ID):for (int h_idx = 0; h_idx < nb_patches_h; ++h_idx) { for (int w_idx = 0; w_idx < nb_patches_w; ++w_idx) { long bucket_h = floor((h_idx / nb_patches_h) * patchesPerSide); long bucket_w = floor((w_idx / nb_patches_w) * patchesPerSide); posPtr[...] = bucket_h * patchesPerSide + bucket_w; } }这部分代码为每个 Patch 生成高精度的位置编码。它通过线性插值,将不同分辨率的 Patch 网格,统一映射到一个固定的
patchesPerSidexpatchesPerSide虚拟坐标系中,确保模型能准确理解每个 Patch 的相对空间位置。 -
attention_mask(注意力掩码) 和tgt_sizes(目标尺寸): 我们同时生成attention_mask和tgt_sizes张量。前者用于在注意力计算中屏蔽掉因 Padding 产生的无效数据;后者则向模型传递每个切片的原始 Patch 尺寸,作为计算的元数据。
模型推理与多模态指令生成
当所有输入张量准备就绪后,调用 MNN 引擎执行 Vision Encoder 的推理。
auto imageEmbedding = mVisionModule->onForward({pixel_values, position_ids, attention_mask, tgt_sizes})[0];
推理完成后,我们得到包含所有图像特征的 imageEmbedding。最后一步,是构建一个语言模型能够理解的“多模态指令”序列。
std::vector<int> imgIds;
// 插入 <image> token 和 64 个 <unk> 占位符
imgIds.push_back(mVisionStart);
for (int p = 0; p < visionLen; p++) {
imgIds.push_back(mVisionPad);
}
imgIds.push_back(mVisionEnd);
// 为每个 slice 插入 <slice> token 和 64 个 <unk> 占位符
for (int i = 0; i < B - 1; i++) {
imgIds.push_back(visionSliceStart);
// ... 插入 64 个 <unk> 占位符 ...
imgIds.push_back(visionSliceEnd);
}
return imgIds;
这段代码使用特殊的 Token ID 来标记全局图像(<image>)和每个切片(<slice>)的边界,并在其中填充固定数量(64个)的占位符。这些占位符将在后续步骤中,被 imageEmbedding 中的实际视觉特征向量所替换,从而完成图文信息的最终融合。
通过这套精心设计的 C++ 管线,一张原始图像被高效地转换为了一个结构精密、可供多模态大模型直接处理的输入序列,成功将算法原型产品化。
模型下载
我们已经将转换好的 MNN 模型上传至社区,欢迎下载体验:
- ModelScope: https://modelscope.cn/models/MNN/MiniCPM-V-4-MNN
- Hugging Face: https://huggingface.co/taobao-mnn/MiniCPM-V-4-MNN
Enjoy Reading This Article?
Here are some more articles you might like to read next: