简介本资源面向希望将大模型落地到移动端与嵌入式设备的算法工程师和C开发者聚焦NCNN框架与Stable-Diffusion模型的结合解决在资源受限环境中部署生成式模型、实现文生图与图生图功能的实战难题。压缩包共750个文件约66.63MB以hpp、h头文件与cpp源码为主体配合param模型参数、cmake构建脚本、a与lib静态库、dll动态库及少量png示例图覆盖从模型转换、接口设计到跨平台编译的完整工程结构。已有380人学习下载。项目按模块讲解NCNN工作原理、模型格式转换、输入输出处理与后处理技巧并针对兼容性、性能优化和资源消耗给出排错思路同时涉及图像分割、特征提取等图像处理技术的融合方式以及Android、iOS和嵌入式平台的系统架构设计与模型监控维护策略配有代码示例与开发指南便于读者掌握从训练到部署的全流程。1. 用 NCNNCpp 把 Stable-Diffusion 塞进本地一条不依赖 Python 的部署路线很多人第一次跑 Stable-Diffusion 都是在 Python 环境里装 torch、装 diffusers、拉权重跑通之后想交付给客户或者嵌进自己的 C 桌面端立刻就卡住了——总不能给每个用户装一个 5GB 的 conda 环境。我去年接过一个需求要把文生图和图生图做进一个纯 C 的桌面工具里目标机器是 Windows 加一张 8G 显存的消费级卡不允许装 Python。当时试过 ONNX Runtime模型转换链路太长也试过直接调 Python 子进程延迟和部署体积都不可接受。最后落到的方案就是 NCNN Cpp把 Stable-Diffusion 的 UNet、VAE、CLIP 文本编码器分别转成 NCNN 的 param/bin用 C 串起整个采样循环文生图和图生图共用同一套推理骨架。这条路线的价值在于部署极轻、无 Python 依赖、CPU 也能兜底跑适合做端侧 AI 生图工具、插件或者嵌入式 demo 的团队。下面把我踩过的完整路径拆开讲包括模型怎么转、C 侧怎么组织、参数怎么调、哪些地方最容易翻车。2. NCNN 为什么能扛住 Stable-Diffusion从计算图到内存布局的选型理由2.1 NCNN 的定位与它和 ONNX Runtime 的差别NCNN 是腾讯开源的一个为移动端和桌面端优化的神经网络推理框架纯 C 实现不依赖任何第三方库编译出来就是一个静态库或者几个源文件。它的核心设计目标是「无依赖、低内存、可嵌入」这和 ONNX Runtime 的定位有明显区别。ONNX Runtime 生态更全算子覆盖广但二进制体积大Windows 上还要带一堆 dllNCNN 反过来算子集相对精简但胜在干净一个 exe 加几个 bin 就能跑。对 Stable-Diffusion 这种模型来说NCNN 的另一个优势是它对 fp16 存储和 int8 量化的支持比较直接。SD 的 UNet 参数量在 860M 左右fp32 权重接近 3.4GBfp16 直接砍半到 1.7GB这对显存和内存都紧张的场景很关键。NCNN 的ncnn::Net在 load 模型时可以指定opt.use_fp16_packed、opt.use_fp16_storage、opt.use_fp16_arithmetic这几个开关让推理全程走半精度速度提升明显。不过要清楚边界NCNN 不是万能的。SD 里有一些动态 shape 的操作比如 attention 里的 reshape 和 transpose如果转换时没处理好NCNN 会直接报 shape mismatch。这也是为什么模型转换这一步是整个方案里最花时间的环节。2.2 Stable-Diffusion 的三个子模型与 NCNN 的对应关系Stable-Diffusion 的推理链路可以拆成三块子模型作用输入输出转换难度CLIP 文本编码器把 prompt 转成 embeddingtoken ids77x768 的 context低UNet去噪主干迭代采样latent timestep context预测噪声高VAE Decoderlatent 转回像素图4x64x64 latent3x512x512 图像中文生图和图生图的区别只在 UNet 的输入上文生图从纯高斯噪声开始图生图从输入图像经 VAE Encoder 编码后的 latent 加噪开始。所以如果只做文生图VAE Encoder 可以不要做图生图就必须把 Encoder 也转出来。我一般会先把三个模型分别导出成 ONNX再用工具转 NCNN。这里有个热词里提到的「onnx转ncnn在线网站」我的建议是别用在线工具模型文件动辄几百 MB上传下载不现实而且在线工具对自定义算子的处理不可控。老老实实在本地用onnx2ncnn命令行工具转出问题还能看日志。2.3 转换前的环境准备与依赖版本转换这一步我建议单独开一个 Python 环境只用来做导出不和推理环境混。核心依赖是 PyTorch、diffusers、onnx、onnxsim。版本上不用追最新diffusers 用 0.20 以上的版本对 SD1.5 的导出支持比较稳。导出脚本的大致逻辑是加载 pipeline把 UNet、VAE、text_encoder 分别 trace 成 torchscript 或者直接 export 成 ONNX。# export_unet.py import torch from diffusers import StableDiffusionPipeline pipe StableDiffusionPipeline.from_pretrained( ./sd15, torch_dtypetorch.float32 ).to(cpu) unet pipe.unet unet.eval() # UNet 的输入latent (1,4,64,64), timestep (1,), context (1,77,768) dummy_latent torch.randn(1, 4, 64, 64) dummy_t torch.tensor([1]) dummy_ctx torch.randn(1, 77, 768) torch.onnx.export( unet, (dummy_latent, dummy_t, dummy_ctx), unet.onnx, input_names[latent, timestep, context], output_names[noise], opset_version14, dynamic_axesNone # 固定 shapeNCNN 对动态 shape 支持有限 )这段脚本的关键点在dynamic_axesNone。SD 的 UNet 在 batch 和分辨率上本来是可以动态的但 NCNN 对动态 shape 的支持不如 ONNX Runtime 成熟固定成 1x4x64x64 能省掉大量转换后的调试时间。代价是只能出 512x512 的图要出其他分辨率得重新导出对应 shape 的模型或者在外面做分块。timestep 这里用torch.tensor([1])是标量输入NCNN 侧对应一个 1 维的 Mat。导出完 ONNX 之后用 onnxsim 做一次简化去掉冗余的 Identity 和 Constant 节点能显著减少转换后的 param 文件行数。# 简化 ONNX python -m onnxsim unet.onnx unet_sim.onnx # 转 NCNN onnx2ncnn unet_sim.onnx unet.param unet.binonnx2ncnn是 NCNN 自带的工具编译 NCNN 时会一起生成。转换过程中最常见的报错是Unsupported operator: XXX这时候要看是哪个算子NCNN 的 tools 目录下有算子支持列表实在不支持的只能改模型结构或者自己写自定义层。3. C 侧推理骨架把 UNet、VAE、CLIP 串成一条采样流水线3.1 工程结构与 NCNN 的集成方式C 侧的工程我一般这么组织sd_ncnn/ ├── third_party/ncnn/ # NCNN 源码或预编译库 ├── models/ # 转换好的 param/bin │ ├── unet.param │ ├── unet.bin │ ├── vae_decoder.param │ ├── vae_decoder.bin │ ├── text_encoder.param │ └── text_encoder.bin ├── src/ │ ├── main.cpp │ ├── sd_pipeline.cpp │ ├── sd_pipeline.h │ └── tokenizer.cpp └── CMakeLists.txtNCNN 的集成有两种方式一是把 NCNN 源码直接放进 third_party 一起编译二是用预编译的静态库。我倾向第一种因为可以按需裁剪算子编译出来的体积更小。CMake 里加add_subdirectory(third_party/ncnn)就行NCNN 自己的 CMake 会处理好 Vulkan、OpenMP 这些可选依赖。cmake_minimum_required(VERSION 3.10) project(sd_ncnn) set(CMAKE_CXX_STANDARD 17) add_subdirectory(third_party/ncnn) add_executable(sd_ncnn src/main.cpp src/sd_pipeline.cpp src/tokenizer.cpp ) target_link_libraries(sd_ncnn PRIVATE ncnn)如果目标机器有 Vulkan 支持的 GPU编译 NCNN 时打开NCNN_VULKANON推理时用ncnn::Net::set_vulkan_device指定设备UNet 这种计算密集的部分能快好几倍。没有 Vulkan 就退回 CPU用 OpenMP 多线程8 核 CPU 出一张 512x512 的图大概在 30 到 60 秒能接受但不快。3.2 文本编码器与 tokenizer 的对接CLIP 文本编码器的输入是 token ids所以 C 侧必须有一个和 Python 侧一致的 tokenizer。SD1.5 用的是 CLIP 的 BPE tokenizer词表文件是vocab.json和merges.txt。C 里没有现成的 diffusers tokenizer我一般用tokenizers-cpp这个库或者自己写一个简化版只处理英文 BPE。// tokenizer.cpp 核心逻辑 std::vectorint clip_tokenize(const std::string text) { // 1. 转小写按空格和标点切分 // 2. 对每个词查 BPE 合并表递归合并 // 3. 查 vocab.json 得到 id // 4. 加 BOS(49406) 和 EOS(49407)padding 到 77 std::vectorint ids; ids.push_back(49406); // BOS // ... BPE 合并逻辑 ... ids.push_back(49407); // EOS while (ids.size() 77) ids.push_back(49407); // padding return ids; }这里有个容易翻车的点Python 的 CLIP tokenizer 在 padding 时用的是 EOS token 还是专门的 pad token不同版本行为不一样。如果 C 侧和 Python 侧不一致生成的 embedding 会有偏差出图效果肉眼可见地变差。我的做法是先用 Python 跑一遍同样的 prompt把 token ids 打出来再和 C 侧对比确保完全一致再往下走。文本编码器推理完得到的是 1x77x768 的 context这个 Mat 要一直留着UNet 每次迭代都要用。3.3 UNet 采样循环与 scheduler 的实现采样循环是整个 pipeline 的核心。以 DDIM 为例50 步采样每一步都要把当前 latent、当前 timestep、context 喂给 UNet拿到预测噪声再用 scheduler 公式更新 latent。// sd_pipeline.cpp 采样主循环 ncnn::Net unet, vae_dec, text_enc; unet.load_param(models/unet.param); unet.load_model(models/unet.bin); unet.opt.use_fp16_packed true; unet.opt.use_fp16_storage true; unet.opt.use_fp16_arithmetic true; // 初始 latent文生图用随机噪声图生图用 VAE Encoder 输出加噪 ncnn::Mat latent(64, 64, 4); if (mode TEXT2IMG) { fill_random(latent); } else { latent vae_encode(input_image); latent add_noise(latent, strength); } for (int step 0; step num_steps; step) { float t timesteps[step]; ncnn::Mat timestep(1); timestep[0] t; ncnn::Extractor ex unet.create_extractor(); ex.input(latent, latent); ex.input(timestep, timestep); ex.input(context, context); ncnn::Mat noise; ex.extract(noise, noise); // DDIM 更新公式 latent ddim_step(latent, noise, t, timesteps[step1]); }ddim_step里要做的是根据当前 timestep 的 alpha_cumprod 和下一步的 alpha_cumprod算出预测的 x0 和方向再组合成新的 latent。这些系数在 Python 侧提前算好存成一个数组传给 C不要在 C 里现算避免浮点精度差异。图生图的关键在add_noise这一步。输入图像先 resize 到 512x512归一化到 [-1,1]过 VAE Encoder 得到 latent然后根据 strength 参数决定加多少噪声。strength0.5 意味着从 timesteps 的中间开始采样既保留原图结构又有足够的变化空间。这个参数直接决定图生图的效果后面会细说。3.4 VAE 解码与后处理采样结束后得到的是 4x64x64 的 latent要过 VAE Decoder 变成 3x512x512 的图像。VAE Decoder 的输出范围大概在 [-1,1]要映射到 [0,255] 再存成图片。ncnn::Mat decoded; ncnn::Extractor ex vae_dec.create_extractor(); ex.input(latent, latent); ex.extract(image, decoded); // decoded 是 512x512x3 的 Mat通道在前 unsigned char* rgb new unsigned char[512*512*3]; for (int c 0; c 3; c) { for (int i 0; i 512*512; i) { float v decoded.channel(c)[i]; v (v 1.0f) * 127.5f; // [-1,1] - [0,255] rgb[i*3 c] (unsigned char)std::clamp(v, 0.0f, 255.0f); } } // 用 stb_image_write 存 PNG stbi_write_png(output.png, 512, 512, 3, rgb, 512*3);VAE Decoder 在 NCNN 里跑的时候如果开了 fp16有时候会出现色偏或者噪点这是半精度累积误差导致的。解决办法是 VAE 这部分单独关掉 fp16用 fp32 跑速度慢一点但画质稳定。UNet 可以继续用 fp16因为它的输出还要经过多步迭代误差会被 scheduler 平滑掉。4. 文生图与图生图的参数调优哪些参数真正影响出图质量4.1 采样步数、CFG scale 与 seed 的配合文生图里最常调的三个参数是 steps、cfg_scale、seed。steps 决定采样迭代次数20 到 30 步是质量和速度的平衡点低于 15 步画面会糊高于 50 步收益递减。cfg_scale 控制 prompt 的引导强度7 到 12 之间比较合理太低 prompt 不起作用太高画面会过饱和、出现伪影。seed 在 C 侧对应的是初始 latent 的随机数种子。这里有个坑Python 的 torch.randn 和 C 的 std::mt19937 生成的随机序列不一样所以同样的 seed 在两边出的图不同。如果要求可复现得自己实现一个和 torch 一致的随机数生成器或者干脆在 Python 侧把初始 latent 存成文件C 直接读。// 用固定 seed 生成初始 latent std::mt19937 gen(seed); std::normal_distributionfloat dist(0.0f, 1.0f); for (int i 0; i 64*64*4; i) { latent[i] dist(gen); }cfg_scale 的实现是在 UNet 推理时做两次前向一次用 prompt 的 context一次用空 contextunconditional然后noise uncond cfg * (cond - uncond)。这意味着开启 CFG 后 UNet 的计算量翻倍出图时间也差不多翻倍。如果追求速度可以把 cfg_scale 设成 1但 prompt 的引导效果会弱很多。4.2 图生图的 strength 与 VAE Encoder 精度图生图的 strength 参数决定加噪程度范围 0 到 1。strength 越小保留原图信息越多适合做风格迁移、局部修改strength 越大越接近文生图适合做大幅度的重绘。我实测下来0.4 到 0.7 是比较实用的区间低于 0.3 基本就是原图加滤镜高于 0.8 原图结构就保不住了。VAE Encoder 的精度对图生图影响很大。如果 Encoder 用 fp16编码出来的 latent 会有偏差再经过加噪和去噪最终图像可能出现颜色偏移或者细节丢失。我的做法是 Encoder 和 Decoder 都用 fp32只有 UNet 用 fp16这样显存占用增加不多但图生图的还原度明显更好。还有一个细节是输入图像的预处理。SD 的 VAE 是在 512x512 上训练的如果输入图像不是这个尺寸要先 resize。resize 的方式也有讲究直接拉伸会变形我一般用等比缩放加中心裁剪保证主体不变形。4.3 显存与内存的边界控制8G 显存的卡跑 SD1.5 的 NCNN 版本UNet fp16 大概占 1.7GVAE fp32 占 1G 左右加上中间激活值峰值在 4G 上下是够的。但如果同时加载 text_encoder 和 VAE Encoder内存会紧张。我的做法是 text_encoder 推理完就释放VAE Encoder 只在图生图时加载用完也释放。NCNN 的ncnn::Net提供clear()方法释放模型权重但要注意释放后不能再用同一个 Net 对象推理。如果频繁切换文生图和图生图可以把 VAE Encoder 单独放一个 Net 对象按需 load 和 clear。CPU 推理的话内存占用会更高因为 fp16 在 CPU 上会被转成 fp32 计算。16G 内存的机器跑 512x512 是够的但如果要出 768x768 或者更大就得考虑分块或者用更小的模型。5. 避坑与排查NCNN 部署 Stable-Diffusion 最常见的五类翻车5.1 转换后推理输出全黑或者全灰现象模型转换成功C 侧也能跑通但 VAE 解码出来的图是全黑或者全灰。原因最常见的是 VAE Decoder 的输入 latent 数值范围不对。Python 侧 latent 是标准化过的均值 0 方差 1如果 C 侧初始化的随机噪声范围不对或者 scheduler 更新公式写错latent 会发散或者收敛到常数。解决在每一步采样后打印 latent 的均值和方差和 Python 侧对比。正常情况下 latent 的均值应该在 0 附近方差在 1 附近。如果方差越来越大说明 scheduler 系数用错了如果方差趋近于 0说明噪声预测的方向反了。5.2 onnx2ncnn 报 Unsupported operator现象转换 UNet 时卡在某个算子报Unsupported operator: Einsum或者Unsupported operator: ScaledDotProductAttention。原因NCNN 的算子集不覆盖 ONNX 的全部算子尤其是 attention 相关的复合算子。diffusers 导出的 UNet 里 attention 是用torch.nn.functional.scaled_dot_product_attention实现的导出成 ONNX 后可能变成Attention或者Einsum。解决在导出 ONNX 之前把 attention 的实现替换成手动展开的版本用 matmul、softmax、matmul 三个基本算子拼出来。diffusers 的 attention processor 可以替换或者直接在导出脚本里 monkey patch。这样导出的 ONNX 只包含基础算子onnx2ncnn 就能处理了。5.3 C 侧出图速度远慢于预期现象同样的模型Python 侧 10 秒一张C 侧要 60 秒。原因NCNN 默认可能没开多线程或者没开 Vulkan。另外 fp16 的开关如果没设对实际还是在用 fp32 计算。解决检查ncnn::Net::opt的几个开关num_threads设成 CPU 核心数use_vulkan_compute设成 true如果有 Vulkan。UNet 的 fp16 三个开关都要打开。还有一个容易忽略的点是opt.use_packing_layout打开后 NCNN 会用打包布局对 ARM 和 x86 都有加速。5.4 图生图结果和原图完全不像现象strength 设成 0.5但输出图和输入图毫无关系。原因VAE Encoder 的输出没有正确加噪或者 timesteps 的起始位置算错了。图生图的加噪是从 timesteps 数组的中间开始如果起始 index 算错相当于从纯噪声开始就变成文生图了。解决确认start_step num_steps * (1 - strength)然后从timesteps[start_step]开始采样。另外检查 VAE Encoder 的输出是否做了 scalingSD 的 VAE 有个scaling_factor通常是 0.18215编码后的 latent 要乘这个系数解码前要除。5.5 多次推理后内存持续增长现象连续出图每出一张内存涨几百 MB跑十几张后 OOM。原因NCNN 的 Extractor 或者 Mat 没有及时释放或者每次推理都重新 load 模型。解决模型只 load 一次复用同一个 Net 对象。Extractor 是轻量对象每次推理创建没问题但要注意ex.extract出来的 Mat 在不需要时手动release()。如果用了 Vulkan还要注意 command buffer 的回收NCNN 的 Vulkan 设备在每次推理后会缓存一些资源可以通过ncnn::VulkanDevice::clear清理。6. 进阶技巧用 fp16 混合精度和分块推理把 8G 卡榨到极限前面讲的都是 512x512 的基础流程实际项目里经常要出更大尺寸的图或者要在更低的显存上跑。这一章讲两个我常用的进阶手段。第一个是混合精度的精细控制。NCNN 的 fp16 开关是全局的但不同层对精度的敏感度不一样。UNet 的 attention 层对精度比较敏感fp16 容易导致注意力权重溢出而卷积层对 fp16 很友好。NCNN 支持在 param 文件里对特定层指定精度把 attention 相关的层标成 fp32其余保持 fp16能在几乎不损失速度的情况下提升稳定性。具体做法是在转换后的 param 文件里找到 attention 的层在层定义后面加0fp32的标记然后重新 load。第二个是分块推理。8G 卡出 1024x1024 的图UNet 的激活值会爆显存。这时候可以把 latent 切成 2x2 的四块每块单独过 UNet块与块之间留 overlap最后拼接。VAE Decoder 也可以分块因为它是逐像素的卷积操作分块不影响结果。分块推理的代价是速度慢一些因为 overlap 区域要重复计算但能把显存需求降到原来的四分之一。// 分块 UNet 推理示意 int tile_size 32; // latent 空间的分块大小 int overlap 4; for (int ty 0; ty 64; ty tile_size - overlap) { for (int tx 0; tx 64; tx tile_size - overlap) { ncnn::Mat tile crop_latent(latent, tx, ty, tile_size, tile_size); ncnn::Mat tile_noise run_unet(tile, t, context); paste_noise(noise, tile_noise, tx, ty, overlap); } }分块的关键在 overlap 的处理拼接时重叠区域要做加权平均否则会有明显的接缝。权重用高斯核中心权重大边缘权重小过渡就自然了。还有一个技巧是 timestep embedding 的预计算。UNet 每一步都要算 timestep 的 sinusoidal embedding这个计算和 latent 无关可以提前把所有 timestep 的 embedding 算好存成数组推理时直接查表省掉每步的重复计算。50 步采样能省下大概 5% 的时间不多但积少成多。最后说一个我自己的习惯每次改完转换脚本或者 C 推理代码先用一个固定的 prompt 和 seed 出一张图和上一版的输出做像素级对比。如果差异超过阈值说明改动影响了数值精度要回去查是哪一步引入的。这个习惯帮我抓过好几次隐蔽的 bug比如某次升级 NCNN 版本后 fp16 的舍入行为变了导致出图偏色靠对比才发现。部署这条路没有捷径能把每一步的数值都对上后面才敢往产品里放。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?