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操作统一几何变换

问题背景:原始的图像切分和重排逻辑,涉及到多次reshapetranspose操作。这不仅代码繁琐,而且每次操作都可能触发内存拷贝,效率不高。

我们的解决方案:我们深入分析后发现,切图和重排的本质,都是在更高维度上对张量(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 所需的全部四个静态输入张量。

  1. 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,送入模型。所有动态尺寸处理都在模型外部完成。

  2. 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 网格,统一映射到一个固定的 patchesPerSide x patchesPerSide 虚拟坐标系中,确保模型能准确理解每个 Patch 的相对空间位置。

  3. attention_mask (注意力掩码) 和 tgt_sizes (目标尺寸): 我们同时生成 attention_masktgt_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 模型上传至社区,欢迎下载体验:




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • MNN模型支持:Qwen3-VL
  • 一图读懂Qwen
  • 端侧LLM硬件系列(二):内存容量
  • Qwen3-Next:下一代MoE模型架构解析
  • 端侧LLM硬件系列(一):内存带宽