简介:本资源面向希望将大模型落地到移动端与嵌入式设备的开发者,聚焦用NCNN框架与C++部署Stable-Diffusion模型,并实现文生图与图生图两条推理链路。内容覆盖NCNN工作原理、模型格式转换、C++与深度学习模型的接口设计、输入输出处理与后处理技巧,同时讨论模型转换兼容性、性能优化与资源消耗等常见部署难题,并延伸至图像分割、特征提取等图像处理技术及生产环境的架构设计与模型监控维护策略。压缩包共750个文件,约66.63MB,以hpp、h头文件与cpp源码为主体,配合param模型参数、cmake构建脚本、a与lib静态库、dll动态库及少量png、jpg示例图,构成可直接编译运行的工程结构。已有381人学习。读者可据此获得从模型转换到多平台部署的完整代码示例与开发指南,掌握在Android、iOS及嵌入式设备上运行Stable-Diffusion的实战方法。
1. 从一堆静态库到能跑的文生图:NCNN+C++ 部署 Stable-Diffusion 到底在做什么
如果你拿到过一个只带libncnn.a、libopencv_core.a、libopencv_imgproc.a的压缩包,第一反应大概率是懵的——没有可执行文件,没有模型权重,只有几个静态库和一堆头文件引用。这正是 NCNN+C++ 部署 Stable-Diffusion 项目的典型形态:它不给你一个开箱即用的 exe,而是给你一套能在 Android、iOS 或嵌入式 Linux 上把 SD 模型跑起来的底层能力。NCNN 是腾讯开源的移动端推理框架,纯 C++ 实现、无第三方依赖、对 ARM 做了大量汇编级优化,配合 OpenCV 做图像前后处理,就能在手机或开发板上完成文生图和图生图。这套资源适合谁?适合已经跑通过 Python 版 SD、但被推理成本和部署环境卡住的算法工程师,也适合想从 PyTorch 转 C++ 推理的嵌入式开发者。它解决的不是“模型怎么训练”,而是“训练好的模型怎么塞进没有 CUDA 的设备里还能出图”。
2. NCNN 推理管线拆解:从权重转换到 C++ 接口设计
2.1 为什么选 NCNN 而不是 ONNX Runtime 或 MNN
移动端部署 Stable-Diffusion,选型时绕不开三个候选:ONNX Runtime、MNN、NCNN。ONNX Runtime 生态最全,但移动端包体积大,且对自定义算子的支持依赖版本;MNN 在阿里系场景表现好,但社区里 SD 相关的 C++ 示例偏少。NCNN 的优势在于:第一,它原生支持 Vulkan 后端,在 Adreno 和 Mali GPU 上能拿到比 CPU 高 3 到 5 倍的推理速度;第二,它的模型格式.param+.bin是纯文本加二进制权重,方便手动改图结构;第三,NCNN 的 C++ API 极其轻量,一个ncnn::Net对象就能加载整个 UNet,不需要额外的运行时环境。
但 NCNN 也有代价:它不支持动态 shape。Stable-Diffusion 的 UNet 在文生图时输入是固定的 64×64 潜空间噪声,但图生图时因为要拼接原图潜变量,通道数会从 4 变成 8。常见做法是导出两个模型:一个unet_txt2img,一个unet_img2img,或者把条件通道在外部拼好再送入同一个模型。我一般会选后者,因为 NCNN 的Extractor支持set_input时指定通道数,只要模型导出时把输入层定义为动态通道即可——但注意,NCNN 的“动态”仅限于通道,宽高必须固定。
2.2 把 PyTorch 权重转成 NCNN 格式的完整命令
转换链路是:PyTorch.pth→ ONNX → NCNN.param+.bin。中间必须经过 ONNX,因为 NCNN 官方工具onnx2ncnn只认 ONNX。先装工具:
# 安装 onnx 和 onnxsim,用于简化计算图 pip install onnx onnxsim # 下载 ncnn 预编译包,里面包含 onnx2ncnn 可执行文件 wget https://github.com/Tencent/ncnn/releases/download/20240102/ncnn-20240102-ubuntu-2204.zip unzip ncnn-20240102-ubuntu-2204.zip export PATH=$PWD/ncnn-20240102-ubuntu-2204/bin:$PATH导出 UNet 到 ONNX 时,关键是指定opset_version=11,因为 NCNN 对 opset 12 以上的某些算子支持不完整。下面是一个最小导出脚本:
import torch from diffusers import UNet2DConditionModel # 加载预训练 UNet,注意这里用的是 fp32,NCNN 转换后再量化 unet = UNet2DConditionModel.from_pretrained( "runwayml/stable-diffusion-v1-5", subfolder="unet", torch_dtype=torch.float32 ) unet.eval() # 构造虚拟输入:batch=1, in_channels=4, height=64, width=64 dummy_input = torch.randn(1, 4, 64, 64) # 文本编码器输出的 context 维度是 77x768 dummy_context = torch.randn(1, 77, 768) # 时间步 embedding dummy_timestep = torch.tensor([1]) torch.onnx.export( unet, (dummy_input, dummy_timestep, dummy_context), "unet.onnx", input_names=["sample", "timestep", "encoder_hidden_states"], output_names=["out_sample"], opset_version=11, do_constant_folding=True )导出后先做 ONNX 简化,否则onnx2ncnn可能因为冗余节点报错:
python -m onnxsim unet.onnx unet_sim.onnx onnx2ncnn unet_sim.onnx unet.param unet.bin得到的unet.param是文本格式,可以用编辑器打开检查。如果看到大量Reshape后面跟着Transpose,说明导出时维度顺序有问题,需要回 PyTorch 里调整permute。unet.bin是权重二进制,fp32 下大约 3.4GB,转成 fp16 后约 1.7GB,再经过 NCNN 的ncnnoptimize做算子融合和量化,可以压到 900MB 左右。
2.3 C++ 侧加载模型与推理循环的骨架代码
NCNN 的 C++ 接口设计很直接:Net加载模型,Extractor做单次推理。下面是一个文生图 UNet 单步去噪的骨架:
#include "net.h" #include "opencv2/core.hpp" int main() { ncnn::Net unet; // 开启 Vulkan,如果设备不支持会自动回退 CPU unet.opt.use_vulkan_compute = true; // 设置推理线程数,移动端一般 2 到 4 unet.opt.num_threads = 4; // 加载转换后的模型 unet.load_param("unet.param"); unet.load_model("unet.bin"); // 构造输入:latent 噪声 4x64x64 ncnn::Mat latent(64, 64, 4); // 填充随机噪声,实际项目中用 std::mt19937 生成 for (int i = 0; i < latent.total(); i++) { latent[i] = rand() / (float)RAND_MAX; } // 文本 context 77x768 ncnn::Mat context(768, 77); // timestep 作为标量输入 ncnn::Mat timestep(1); timestep[0] = 1.0f; ncnn::Extractor ex = unet.create_extractor(); ex.input("sample", latent); ex.input("timestep", timestep); ex.input("encoder_hidden_states", context); ncnn::Mat out; ex.extract("out_sample", out); // out 是预测的噪声残差,后续做 scheduler step return 0; }这段代码里最容易翻车的是ncnn::Mat的维度顺序。NCNN 的Mat(w, h, c)构造时,w 是最内层维度,对应 PyTorch 的最后一维。PyTorch 的 latent 形状是[1, 4, 64, 64],在 NCNN 里要写成Mat(64, 64, 4),batch 维度直接省略。如果维度写反,推理不会报错,但输出全是噪声——这是血泪经验里最常见的一条。
3. 文生图与图生图的双路实现:CLIP 编码、调度器与后处理
3.1 文本编码器在 NCNN 里的量化与截断策略
Stable-Diffusion 的文本编码器是 CLIP ViT-L/14,参数量约 123M。在 NCNN 里跑 CLIP 有两个选择:一是完整导出,二是只导出到最后一层ln_final之前,把ln_final和text_projection用 C++ 手写。我一般选后者,因为 CLIP 的 LayerNorm 在 NCNN 里可以用LayerNorm层直接支持,但text_projection是一个 768×768 的矩阵乘,手写反而更可控。
导出 CLIP 时要注意:输入是 token ids,形状[1, 77],类型是 int32。NCNN 对 int 输入的支持需要把Mat的elemsize设为 4,并且用Mat::from_pixels之类的接口填充。常见做法是:
ncnn::Mat token_ids(77, 1, 1, 4); // 4 字节 int int* ptr = token_ids; for (int i = 0; i < 77; i++) { ptr[i] = tokens[i]; // tokens 来自 tokenizer }Tokenizer 部分不建议在 C++ 里重写,直接用 Python 预处理成 id 列表,通过 JNI 或文件传给 C++。如果必须在 C++ 里做,可以用sentencepiece的 C++ 库,但会引入额外依赖。
3.2 图生图的潜空间拼接与强度参数控制
图生图和文生图的核心区别在于:图生图会把原图通过 VAE Encoder 编码成潜变量,然后和噪声按strength参数混合。strength越大,保留原图信息越少,生成结果越接近文生图。在 C++ 里实现时,混合公式是:
// latent_orig 是 VAE 编码后的原图潜变量 // noise 是随机噪声 // strength 取值 0 到 1 float strength = 0.75f; ncnn::Mat latent_mixed(64, 64, 4); for (int i = 0; i < latent_mixed.total(); i++) { latent_mixed[i] = latent_orig[i] * (1.0f - strength) + noise[i] * strength; }然后把这个混合后的潜变量送入 UNet,但注意:图生图的 UNet 输入通道是 8(4 个噪声通道 + 4 个原图潜变量通道),所以要么用单独的unet_img2img模型,要么在送入前把latent_mixed和latent_orig在通道维拼接。NCNN 的Mat支持channel拼接,用ncnn::concat即可:
ncnn::Mat concat_in; ncnn::concat(concat_in, latent_mixed, latent_orig);拼接后的concat_in形状是64×64×8,直接ex.input("sample", concat_in)。这里有个坑:如果模型导出时输入通道写死为 4,拼接后会报input shape mismatch。解决办法是在导出 ONNX 时把输入层定义为dynamic_axes,让通道维可变。
3.3 VAE 解码与 OpenCV 图像写出
UNet 输出的是预测噪声,经过 scheduler 迭代后得到最终潜变量,再送入 VAE Decoder 还原成 512×512 的 RGB 图像。VAE Decoder 在 NCNN 里跑的时候,输出是Mat(512, 512, 3),值域大约在[-1, 1]。要写成 PNG,需要先映射到[0, 255]:
ncnn::Mat decoded; // 假设已经通过 VAE 得到 cv::Mat image(512, 512, CV_8UC3); for (int y = 0; y < 512; y++) { for (int x = 0; x < 512; x++) { for (int c = 0; c < 3; c++) { float v = decoded.channel(c).row(y)[x]; // 从 [-1,1] 映射到 [0,255] int pixel = (int)((v + 1.0f) * 0.5f * 255.0f); pixel = std::max(0, std::min(255, pixel)); image.at<cv::Vec3b>(y, x)[c] = (uchar)pixel; } } } cv::imwrite("output.png", image);注意 NCNN 的Mat通道顺序是 RGB,而 OpenCV 默认是 BGR,所以上面代码里c的顺序要对应调整,否则出图颜色会偏蓝。这个坑我踩过两次,第一次以为是模型量化问题,查了一下午才发现是通道顺序。
4. 避坑与排查:模型转换、内存与 Vulkan 后端的五个真实翻车记录
4.1 现象:onnx2ncnn 报 “Unsupported operator: LayerNormalization”
原因:PyTorch 导出 ONNX 时,LayerNorm 被拆成了ReduceMean+Sub+Pow+ReduceMean+Div+Mul+Add一长串算子,NCNN 的onnx2ncnn对某些组合识别不了。解决:在导出前把模型里的LayerNorm替换成nn.GroupNorm(1, channels),因为 GroupNorm 在 NCNN 里有原生支持。或者升级onnx2ncnn到最新版,2024 年后的版本对 LayerNorm 的融合已经好很多。
4.2 现象:推理结果全黑或全灰
原因:ncnn::Mat的维度顺序写反了。PyTorch 的[N, C, H, W]在 NCNN 里要写成Mat(W, H, C),如果写成Mat(C, H, W),数据会错位。解决:在ex.input之前打印latent.w,latent.h,latent.c,确认和模型.param文件里第一层Input的 shape 一致。
4.3 现象:Vulkan 初始化失败,回退 CPU 后速度极慢
原因:Android 设备上 Vulkan 驱动版本太低,或者 NCNN 编译时没有链接 Vulkan 库。解决:在CMakeLists.txt里加find_package(Vulkan REQUIRED),并确保ncnn::Net的opt.use_vulkan_compute在load_param之前设置。如果设备确实不支持,可以设置opt.use_fp16_packed = true和opt.use_fp16_storage = true,用 fp16 在 CPU 上也能提速约 40%。
4.4 现象:图生图时输出图像和原图完全无关
原因:strength参数理解反了。很多人以为strength是“保留原图的程度”,实际上它是“加噪程度”。strength=1.0等于完全重新生成,strength=0.0等于原图不变。解决:把strength设在 0.4 到 0.7 之间,并且确保latent_orig是 VAE 编码后的潜变量,而不是原图像素。
4.5 现象:内存占用超过 4GB,移动端直接 OOM
原因:NCNN 默认加载 fp32 权重,UNet 加 VAE 加 CLIP 总共约 5GB。解决:用ncnnoptimize做 fp16 量化:
ncnnoptimize unet.param unet.bin unet_opt.param unet_opt.bin 65536最后一个参数65536表示启用 fp16 存储。量化后模型体积减半,推理时显存占用也减半。注意:fp16 在 CPU 上可能反而变慢,因为需要转换回 fp32 计算,所以只在 Vulkan 后端开启。
5. 进阶技巧:用 ncnnoptimize 做算子融合与多线程调度调优
5.1 算子融合的边界与手动改图
ncnnoptimize会自动做 Conv+BN+ReLU 的融合,但 Stable-Diffusion 的 UNet 里有大量SiLU激活函数,NCNN 对SiLU的融合支持有限。一个手动优化技巧是:把SiLU替换成x * sigmoid(x)的显式表达式,然后在.param文件里把Sigmoid和BinaryOp合并成一个Swish层。NCNN 从 2023 年开始支持Swish,但需要手动改.param:
# 原始 Sigmoid sigmoid_1 1 1 input_1 BinaryOp mul_1 1 2 input_1 sigmoid_1 # 替换为 Swish swish_1 1 1 input_1改完后用ncnnoptimize重新过一遍,确认没有报错。这个操作能把 UNet 的推理速度再提 8% 到 12%,但风险是如果Swish层参数写错,输出会变成 NaN。
5.2 多线程与 CPU 亲和性设置
NCNN 的opt.num_threads控制 OpenMP 线程数,但在 Android 上,大核和小核的调度策略会影响实际性能。我一般会绑定线程到大核:
#include <sched.h> cpu_set_t cpuset; CPU_ZERO(&cpuset); // 假设大核是 CPU 4 到 7 for (int i = 4; i < 8; i++) { CPU_SET(i, &cpuset); } sched_setaffinity(0, sizeof(cpuset), &cpuset);这段代码要在load_param之前调用,否则 NCNN 已经创建了线程池。绑定后,在骁龙 8 Gen 2 上,512×512 文生图从 28 秒降到 19 秒。但注意:如果设备只有 4 个小核,绑定到大核会导致线程饥饿,反而更慢。所以要先读/proc/cpuinfo确认核心布局。
5.3 验证部署是否成功的三个硬指标
第一,用同一组随机种子,C++ 输出和 Python 输出的 SSIM 应该大于 0.95。如果低于 0.9,说明量化或算子融合引入了过大误差。第二,连续跑 100 次推理,内存波动不超过 50MB,否则说明有Mat没有释放。第三,Vulkan 后端下,GPU 利用率应该稳定在 70% 以上,如果低于 50%,检查是否频繁在 CPU 和 GPU 之间拷贝数据。
从那以后我每次部署新模型,都会先用一张 512×512 的纯色图跑图生图,strength=0.5,看输出是否保留原图色调。如果色调完全丢失,说明 VAE 编码或潜空间拼接有问题。这个习惯帮我省下了至少三次通宵排查。希望帮到你。
本文还有配套的精品资源,点击获取