NCNN+C++部署Stable-Diffusion:不依赖Python的本地推理实战

发布时间:2026/10/3 6:54:23
NCNN+C++部署Stable-Diffusion:不依赖Python的本地推理实战 简介本资源面向希望将大模型落地到移动端与嵌入式设备的开发者聚焦使用NCNN框架与C语言部署Stable-Diffusion模型并实现文生图与图生图两大核心功能。内容覆盖NCNN工作原理、模型格式转换、C接口设计、输入输出处理与后处理技巧同时讨论模型兼容性、性能优化与资源消耗等实战问题并延伸至图像分割、特征提取及系统架构设计、模型监控维护策略。压缩包共750个文件约66.63MB以hpp、h头文件与cpp源码为主体配合param模型参数、cmake构建脚本、a与lib静态库、dll动态库及少量png、jpg示例图结构完整便于直接编译调试。目前已有380人学习。读者可获得从模型训练到多平台部署的完整代码示例与开发指南掌握Android、iOS及嵌入式设备上的部署方法并积累排错与优化经验。1. 用 NCNNCpp 把 Stable-Diffusion 塞进本地一条不依赖 Python 运行时的部署路线很多人第一次接触 AI 生图都是从 WebUI 或 ComfyUI 这类 Python 生态起步的。提示词一填、采样器一选图就出来了确实方便。但真要把生图能力塞进一个 C 桌面软件、一个移动端 App或者一个不能装 Python 环境的边缘设备里Python 那套依赖链就成了累赘——torch 动辄几个 G启动慢内存占用高还得处理各种动态库冲突。这时候 NCNN 这条路线就值得认真看一眼了。NCNN 是腾讯开源的一个为移动端和嵌入式设备优化的高性能神经网络推理框架纯 C 实现不依赖任何第三方库编译出来就是一个轻量级的静态库或动态库。把 Stable-Diffusion 的 UNet、VAE、CLIP 文本编码器分别导出成 ONNX再转成 NCNN 的 parambin 格式最后用 C 写一个推理管线把文生图和图生图串起来——这就是这个项目标题要解决的核心问题。它适合两类人一是想把生图功能集成进自己 C 项目的工程师二是想搞清楚扩散模型推理底层到底在算什么、不想被 Python 框架当黑匣子挡在外面的开发者。下面我按自己实际跑通的顺序把整条链路拆开讲。2. 模型拆解与 NCNN 转换从 safetensors 到 parambin 的完整链路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序列长度固定为 77CLIP 的标准上下文长度否则转换后可能报维度不匹配。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_dtypetorch.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_version12, do_constant_foldingTrue ) # 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_version12 ) # 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_version12 )这段代码的关键点在于 dummy input 的形状必须和实际推理时完全一致。VAE 的输入潜变量是 4 通道、64×64 空间尺寸对应 512×512 输出UNet 的 context 维度 768 是 CLIP ViT-L/14 的嵌入维度如果你的模型是 SD 2.1 则可能是 1024需要对应调整。opset_version 建议用 12NCNN 的 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_VULKANON -DNCNN_BUILD_TOOLSON .. 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::vectorncnn::Mat denoise_loop( ncnn::Net unet, ncnn::Mat latent, // 初始噪声 (4,64,64) ncnn::Mat text_embedding, // CLIP 输出 (77,768) int num_steps, const std::vectorfloat alphas_cumprod) { std::vectorncnn::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×512SD 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_VULKANON运行时在 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 再对最后串起来。这样出问题时能快速定位是哪个模块的转换或推理出了偏差而不是面对一个全黑输出完全不知道从哪查起。另一个习惯是保留中间结果。去噪循环每一步的潜变量都存下来出问题时可以回放看是从哪一步开始跑偏的。这个后悔药在调试采样器参数时特别有用。希望帮到你。本文还有配套的精品资源点击获取