☰
NCNN+C++部署Stable-Diffusion:不依赖Python的本地推理实战
2026/10/3 6:54:23 网站建设 项目流程

简介:本资源面向希望将大模型落地到移动端与嵌入式设备的开发者,聚焦使用NCNN框架与C++语言部署Stable-Diffusion模型,并实现文生图与图生图两大核心功能。内容覆盖NCNN工作原理、模型格式转换、C++接口设计、输入输出处理与后处理技巧,同时讨论模型兼容性、性能优化与资源消耗等实战问题,并延伸至图像分割、特征提取及系统架构设计、模型监控维护策略。压缩包共750个文件,约66.63MB,以hpp、h头文件与cpp源码为主体,配合param模型参数、cmake构建脚本、a与lib静态库、dll动态库及少量png、jpg示例图,结构完整便于直接编译调试。目前已有380人学习。读者可获得从模型训练到多平台部署的完整代码示例与开发指南,掌握Android、iOS及嵌入式设备上的部署方法,并积累排错与优化经验。

1. 用 NCNN+Cpp 把 Stable-Diffusion 塞进本地:一条不依赖 Python 运行时的部署路线

很多人第一次接触 AI 生图,都是从 WebUI 或 ComfyUI 这类 Python 生态起步的。提示词一填、采样器一选,图就出来了,确实方便。但真要把生图能力塞进一个 C++ 桌面软件、一个移动端 App,或者一个不能装 Python 环境的边缘设备里,Python 那套依赖链就成了累赘——torch 动辄几个 G,启动慢,内存占用高,还得处理各种动态库冲突。这时候 NCNN 这条路线就值得认真看一眼了。

NCNN 是腾讯开源的一个为移动端和嵌入式设备优化的高性能神经网络推理框架,纯 C++ 实现,不依赖任何第三方库,编译出来就是一个轻量级的静态库或动态库。把 Stable-Diffusion 的 UNet、VAE、CLIP 文本编码器分别导出成 ONNX,再转成 NCNN 的 param+bin 格式,最后用 C++ 写一个推理管线把文生图和图生图串起来——这就是这个项目标题要解决的核心问题。它适合两类人:一是想把生图功能集成进自己 C++ 项目的工程师,二是想搞清楚扩散模型推理底层到底在算什么、不想被 Python 框架当黑匣子挡在外面的开发者。下面我按自己实际跑通的顺序,把整条链路拆开讲。

2. 模型拆解与 NCNN 转换:从 safetensors 到 param+bin 的完整链路

2.1 为什么 Stable-Diffusion 不能整模型直接转

Stable-Diffusion 不是一个单一网络,它是一组模型的组合:文本编码器(通常是 CLIP 的 text encoder)、UNet 去噪网络、VAE 解码器,图生图还要用到 VAE 编码器。这三个部分的输入输出形状、计算图结构完全不同,NCNN 的转换工具对动态 shape 和复杂控制流的支持有限,所以常见做法是分模块导出、分模块转换,最后在 C++ 侧用代码把它们串起来。

具体来说,文生图的流程是:提示词经过 tokenizer 变成 token id 序列,送入 CLIP text encoder 得到文本嵌入;随机噪声潜变量和文本嵌入一起送入 UNet,经过多步去噪迭代;最后去噪后的潜变量送入 VAE decoder 还原成像素图。图生图则多一步:输入图像先经过 VAE encoder 变成潜变量,再加噪后送入 UNet 去噪,后续流程一致。

注意:NCNN 对动态维度的支持不如 ONNX Runtime 灵活,导出 ONNX 时尽量把 batch size 固定为 1,序列长度固定为 77(CLIP 的标准上下文长度),否则转换后可能报维度不匹配。

2.2 分模块导出 ONNX 的具体操作

假设你已经有一份 Stable-Diffusion 的 safetensors 权重,第一步是把它拆成三个独立的 PyTorch 模块并分别导出 ONNX。这一步在 Python 环境里做一次就行,导出后的 ONNX 和 NCNN 模型就不再需要 Python 了。

import torch from diffusers import StableDiffusionPipeline # 加载完整管线,只用于导出,后续推理不再依赖 pipe = StableDiffusionPipeline.from_pretrained( "./sd-model", torch_dtype=torch.float32 ).to("cpu") # 1. 导出 VAE Decoder:输入潜变量 (1,4,64,64),输出图像 (1,3,512,512) vae_dec = pipe.vae dummy_latent = torch.randn(1, 4, 64, 64) torch.onnx.export( vae_dec, dummy_latent, "vae_decoder.onnx", input_names=["latent"], output_names=["image"], opset_version=12, do_constant_folding=True ) # 2. 导出 VAE Encoder:图生图用,输入图像,输出潜变量 dummy_image = torch.randn(1, 3, 512, 512) torch.onnx.export( vae_dec, dummy_image, "vae_encoder.onnx", input_names=["image"], output_names=["latent"], opset_version=12 ) # 3. 导出 UNet:输入潜变量+时间步+文本嵌入 unet = pipe.unet dummy_ts = torch.tensor([1]) dummy_ctx = torch.randn(1, 77, 768) torch.onnx.export( unet, (dummy_latent, dummy_ts, dummy_ctx), "unet.onnx", input_names=["sample", "timestep", "context"], output_names=["out_sample"], opset_version=12 )

这段代码的关键点在于 dummy input 的形状必须和实际推理时完全一致。VAE 的输入潜变量是 4 通道、64×64 空间尺寸(对应 512×512 输出),UNet 的 context 维度 768 是 CLIP ViT-L/14 的嵌入维度,如果你的模型是 SD 2.1 则可能是 1024,需要对应调整。opset_version 建议用 12,NCNN 的 ONNX 解析器对这个版本兼容性最好。

2.3 ONNX 转 NCNN:工具选择与参数

转换工具有两个选择:一是 NCNN 官方提供的 onnx2ncnn 命令行工具,需要自己编译;二是在线转换网站,上传 ONNX 直接下载 param 和 bin。在线工具适合快速验证,但模型文件较大时上传下载耗时,而且涉及模型隐私,生产环境建议本地编译工具链。

# 编译 NCNN 工具链(如果还没编译过) git clone https://github.com/Tencent/ncnn.git cd ncnn && mkdir build && cd build cmake -DNCNN_VULKAN=ON -DNCNN_BUILD_TOOLS=ON .. make -j$(nproc) # 转换三个模块 ./tools/onnx2ncnn vae_decoder.onnx vae_decoder.param vae_decoder.bin ./tools/onnx2ncnn vae_encoder.onnx vae_encoder.param vae_encoder.bin ./tools/onnx2ncnn unet.onnx unet.param unet.bin

转换完成后会得到三组 .param 和 .bin 文件。.param 是网络结构描述文本,.bin 是权重二进制。这里有个血泪经验:转换后一定要用 ncnn 自带的 ncnnoptimize 工具做一次图优化,把一些冗余的算子融合掉,否则推理速度会明显偏慢。

./tools/ncnnoptimize vae_decoder.param vae_decoder.bin vae_decoder_opt.param vae_decoder_opt.bin 0 ./tools/ncnnoptimize unet.param unet.bin unet_opt.param unet_opt.bin 0

最后一个参数 0 表示输出 fp32,如果设备支持 fp16 可以改成 1,模型体积减半,速度也有提升,但部分老设备可能精度损失明显,需要实测。

3. C++ 推理管线搭建:把 UNet 去噪循环写对

3.1 工程结构与 NCNN 集成

C++ 侧的工程我一般这样组织:一个 models 目录放 param 和 bin,一个 src 目录放推理代码,CMakeLists 里链接 ncnn 库。NCNN 的集成非常干净,不需要额外的依赖管理工具。

cmake_minimum_required(VERSION 3.10) project(sd_ncnn_demo) set(CMAKE_CXX_STANDARD 17) find_package(ncnn REQUIRED) add_executable(sd_demo src/main.cpp src/text_encoder.cpp src/unet_runner.cpp src/vae_runner.cpp src/scheduler.cpp ) target_link_libraries(sd_demo ncnn)

text_encoder 这块需要单独处理,因为 CLIP 的 tokenizer 在 C++ 里没有现成的轻量实现。常见做法是提前把 tokenizer 的词表导出成一个简单的文本文件,C++ 侧实现一个 BPE 分词器,或者更省事的办法是:把提示词的 token id 在 Python 侧算好,以文件形式传给 C++ 程序。如果要做成完全独立的 C++ 应用,BPE 分词器大概两百行代码能搞定,这里不展开。

3.2 UNet 去噪循环的核心代码

整个推理管线里最容易翻车的就是 UNet 的去噪循环。扩散模型的采样不是一次前向传播就完事,而是要迭代几十步,每一步的输入都依赖上一步的输出,时间步的嵌入方式也必须和训练时一致。

#include <ncnn/net.h> #include <vector> // 简化版 DDIM 采样循环 std::vector<ncnn::Mat> denoise_loop( ncnn::Net& unet, ncnn::Mat& latent, // 初始噪声 (4,64,64) ncnn::Mat& text_embedding, // CLIP 输出 (77,768) int num_steps, const std::vector<float>& alphas_cumprod) { std::vector<ncnn::Mat> latents; ncnn::Mat sample = latent.clone(); for (int i = 0; i < num_steps; ++i) { // 时间步从大到小,DDIM 是等间隔取 int t = num_steps - 1 - i; ncnn::Extractor ex = unet.create_extractor(); ex.input("sample", sample); ex.input("timestep", ncnn::Mat(1, &t)); // 标量时间步 ex.input("context", text_embedding); ncnn::Mat noise_pred; ex.extract("out_sample", noise_pred); // DDIM 更新公式:x_{t-1} = sqrt(alpha_{t-1}) * pred_x0 + ... float alpha_t = alphas_cumprod[t]; float alpha_prev = (t > 0) ? alphas_cumprod[t-1] : 1.0f; ncnn::Mat pred_x0 = sample - noise_pred * sqrtf(1.0f - alpha_t); pred_x0 = pred_x0 / sqrtf(alpha_t); sample = pred_x0 * sqrtf(alpha_prev) + noise_pred * sqrtf(1.0f - alpha_prev); latents.push_back(sample.clone()); } return latents; }

这段代码里几个参数必须对齐:num_steps 一般设 20 到 50,步数太少图像模糊,太多收益递减;alphas_cumprod 是训练时固定的噪声调度表,必须从原始模型里导出,不能自己随便生成;时间步 t 的传入方式要和导出 ONNX 时一致,有些实现是归一化到 0-1 的浮点数,有些是整数索引,搞错了去噪结果就是纯噪声。

3.3 VAE 解码与图像后处理

去噪循环结束后拿到的是潜变量,还需要过 VAE decoder 才能变成人能看的图。VAE 的输出范围通常在 -1 到 1 之间,要映射到 0-255 的像素值。

ncnn::Mat decode_vae(ncnn::Net& vae_dec, ncnn::Mat& latent) { ncnn::Extractor ex = vae_dec.create_extractor(); ex.input("latent", latent); ncnn::Mat image; ex.extract("image", image); return image; } // 后处理:从 (3,512,512) 的 float 转成 RGB 像素 void save_image(const ncnn::Mat& out, const char* path) { int w = out.w, h = out.h; unsigned char* rgb = new unsigned char[w * h * 3]; for (int c = 0; c < 3; ++c) { const float* ptr = out.channel(c); for (int i = 0; i < w * h; ++i) { float v = (ptr[i] + 1.0f) * 127.5f; // [-1,1] -> [0,255] v = std::max(0.0f, std::min(255.0f, v)); rgb[i * 3 + c] = (unsigned char)v; } } // 这里用 stb_image_write 或自己写 BMP 都行 stbi_write_png(path, w, h, 3, rgb, w * 3); delete[] rgb; }

VAE 解码是整条链路里显存/内存占用最大的环节,512×512 输出时中间特征图会膨胀到 (512,512,512) 这个量级。如果设备内存紧张,可以考虑分块解码,但实现复杂度会上去。我一般先在 PC 上跑通,再根据目标设备的内存预算决定要不要做分块优化。

4. 图生图模式:VAE 编码器接入与去噪强度控制

4.1 图生图和文生图的管线差异

图生图不是简单地把输入图当条件加进去,它的核心机制是:先用 VAE encoder 把输入图编码成潜变量,然后根据去噪强度(denoising strength)决定从哪一步开始加噪。strength 设 0.3 意味着只加少量噪声、保留大部分原图结构;设 0.8 则接近重新生成,原图只提供大致构图。

// 图生图:编码输入图 -> 加噪 -> 从中间步开始去噪 ncnn::Mat img_to_latent(ncnn::Net& vae_enc, ncnn::Mat& input_image) { ncnn::Extractor ex = vae_enc.create_extractor(); ex.input("image", input_image); ncnn::Mat latent; ex.extract("latent", latent); return latent; } // 根据 strength 计算起始步 int start_step = (int)(num_steps * (1.0f - strength)); // 从 start_step 开始去噪,而不是从 num_steps-1

这里有个容易忽略的点:VAE encoder 输出的潜变量需要乘以一个缩放因子(通常是 0.18215),这个因子是 SD 训练时定的,不乘的话加噪后的分布和 UNet 训练时的输入分布不匹配,生成结果会偏色或者结构崩坏。

4.2 去噪强度的实际调参经验

strength 这个参数没有理论最优值,完全看场景。我自己的经验是:如果想让生成图保留原图的构图和色调,strength 设 0.4 到 0.55 之间比较稳;如果是做风格迁移、想让画面变化大一些,0.65 到 0.8;超过 0.85 基本就等于重新生成了,输入图的影响微乎其微。

提示:图生图时如果 strength 设得太低(比如 0.2),去噪步数很少,UNet 来不及修正潜变量里的噪声,输出图会出现明显的网格状伪影。这不是 bug,是扩散模型采样步数不足的固有现象。

另外,图生图的输入图尺寸必须和模型期望的潜变量尺寸匹配。SD 1.5 是 512×512,SD 2.1 是 768×768。如果输入图不是这个尺寸,需要先做等比缩放加裁剪,否则 VAE encoder 输出的潜变量形状对不上,UNet 直接报错。

5. 避坑与排查:NCNN 部署 Stable-Diffusion 最常见的五个翻车点

5.1 转换后推理输出全黑或全灰

现象:C++ 程序跑完不报错,但保存出来的图是纯黑或者纯灰。

原因:最常见的是 VAE 输出后处理的范围映射搞错了。有些导出方式 VAE 输出已经是 0-255,你又做了一次 [-1,1] 到 [0,255] 的映射,结果全被截断到 0 或 255。另一个可能是潜变量没有乘 0.18215 缩放因子。

解决:先用 Python 跑一遍同样的潜变量输入 VAE,对比输出数值范围。确认 VAE 输出的实际 min/max 值,再决定后处理公式。

5.2 UNet 推理速度异常慢

现象:单步去噪耗时超过 5 秒,50 步跑完要几分钟。

原因:NCNN 默认可能没有启用 Vulkan GPU 加速,或者启用了但设备不支持。另外 ncnnoptimize 没跑,网络里有冗余算子。

解决:编译时加 -DNCNN_VULKAN=ON,运行时在 Net 初始化后调用 net.opt.use_vulkan_compute = true。同时确认 ncnnoptimize 已经执行过。如果设备确实没有 GPU,考虑用 fp16 存储权重减少内存带宽压力。

5.3 文本编码器输出和 Python 侧对不上

现象:同样的提示词,C++ 生成的图和 Python 生成的图差异巨大。

原因:CLIP tokenizer 的分词结果不一致。Python 的 transformers 库有完整的 BPE 实现,C++ 侧如果自己实现,容易在特殊 token(如 <|startoftext|>、<|endoftext|>)和 padding 处理上出错。

解决:把 Python 侧 tokenizer 输出的 token id 序列打印出来,和 C++ 侧逐位对比。最稳妥的做法是先用固定 token id 输入测试,确认文本编码器本身没问题,再排查分词器。

5.4 图生图输出和输入图完全无关

现象:strength 设了 0.5,但生成结果和输入图没有任何相似性。

原因:VAE encoder 的输入图像预处理不对。SD 的 VAE encoder 期望输入是 [-1,1] 范围的浮点像素,如果你直接传了 0-255 的整数,编码出来的潜变量完全偏离正常分布。

解决:检查输入图像的归一化流程,确保是 (pixel / 127.5) - 1.0。同时确认输入图像是 RGB 三通道,不是 BGR 或灰度。

5.5 Android 端加载模型时崩溃

现象:PC 上跑得好好的模型,推到 Android 上加载就闪退。

原因:Android 的 NCNN 库需要单独编译,而且对 param 文件的格式版本有要求。PC 上用最新版 ncnn 转换的模型,Android 端的 ncnn 库版本太老可能解析不了。

解决:确保 PC 转换工具和 Android 推理库来自同一个 ncnn 版本。另外 Android 上模型文件要放在 assets 里或者应用私有目录,不能直接读 SD 卡路径(权限问题)。

6. 进阶技巧:用 Vulkan 加速和模型量化把推理压进移动端

6.1 Vulkan 后端开启与性能对比

NCNN 的 Vulkan 后端在支持 GPU 的 Android 设备上能带来数倍加速。开启方式很简单,但有几个细节决定成败。

ncnn::Net unet; unet.opt.use_vulkan_compute = true; unet.opt.use_fp16_packed = true; unet.opt.use_fp16_storage = true; unet.opt.use_fp16_arithmetic = true; unet.load_param("unet_opt.param"); unet.load_model("unet_opt.bin");

use_fp16_storage 让权重以 fp16 存储,内存占用直接减半;use_fp16_arithmetic 让计算也用 fp16,速度更快但精度损失稍大。在骁龙 8 系以上的设备上,这三个开关全开,UNet 单步去噪能从 CPU 的 3 秒左右降到 0.5 秒以内。但在一些中低端设备上,fp16 算术可能导致输出图出现色块,这时候把 use_fp16_arithmetic 关掉、只保留 storage 的 fp16 即可。

6.2 模型量化:int8 的收益与边界

NCNN 支持 int8 量化,通过 ncnn2table 和 ncnn2int8 两个工具完成。量化后模型体积缩小到 fp32 的四分之一,推理速度在支持 int8 指令集的 CPU 上也有明显提升。但扩散模型对量化比较敏感,UNet 量化后生成质量下降通常比分类模型更明显。

我的做法是:VAE 保持 fp32 不量化,因为 VAE 解码对精度敏感,量化后容易出现色彩断层;UNet 可以尝试 int8,但需要准备一批校准图,量化后逐张对比生成结果,确认没有明显退化再上线。文本编码器量化影响不大,可以放心做。

6.3 一个实用的调试习惯

部署这条链路,我最深刻的教训是:不要等整个管线搭完再调试。每转完一个模块,就单独写一个最小 C++ 测试程序,用固定输入跑一遍,和 Python 侧的同模块输出做数值对比。VAE 先对,UNet 再对,最后串起来。这样出问题时能快速定位是哪个模块的转换或推理出了偏差,而不是面对一个全黑输出完全不知道从哪查起。

另一个习惯是保留中间结果。去噪循环每一步的潜变量都存下来,出问题时可以回放看是从哪一步开始跑偏的。这个后悔药在调试采样器参数时特别有用。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询