ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用NCNN和C++实现Stable Diffusion本地部署全攻略

用NCNN和C++实现Stable Diffusion本地部署全攻略 简介面向需要将Stable-Diffusion落地到移动端或资源受限场景的算法工程师与C开发者这是一份基于NCNN框架和C语言部署大模型的实战项目包完整覆盖模型转换、推理调用、输入输出处理以及文生图、图生图两大核心功能并给出跨平台工程搭建与性能调优思路。资源包共750个文件压缩包大小66.63MB以hpp/h头文件、cpp源文件、param/bin模型参数、a/lib静态库、dll动态库及cmake构建脚本为主同时包含sln、vcxproj、gradle等多平台工程文件以及少量png/jpg样例图和md/txt说明文档便于对照代码理解部署细节。已有380人学习下载。项目从环境配置到模块拆分讲解系统提供可直接参考的代码示例和开发指南重点解决模型转换兼容性、性能优化与资源消耗等部署难点对希望在Android、iOS或嵌入式设备上运行生成式AI模型的开发者具有明确参考价值。1. 大模型部署到端侧为什么是 NCNN C 跑 Stable Diffusion一张 512×512 的图在服务器上调用接口只要一两秒可一旦要把这套能力塞进工控机、国产 ARM 盒子或者没有 GPU 的老电脑事情就变味了。大模型部署这个词被 Ollama、llama.cpp 这类跑 LLM 的方案带火之后很多人误以为所有模型都能用同一套路子跑起来但 Stable Diffusion 这种出图模型完全是另一套逻辑——它要的不是“生成 token”而是迭代去噪几十步每一步都是一次完整的卷积网络推理。下面这套方案就是用 NCNN C 在本地把 Stable Diffusion 部署起来同时支持文生图和图生图两条管线模型文件全部离线存放不依赖任何云端接口。适合三种人被硬件条件卡住、需要离线出图、或者纯粹想把 AI 大模型本地部署配置这件事吃透的开发者。2. 拆解三块模型先搞清楚 NCNN 上的 SD 在跑什么2.1 三个子模型的分工Text Encoder、UNet、VAE 谁在吃算力第一次接触 Stable Diffusion 部署的人最容易犯的错是把它当成“一个大模型”去找权重。实际上它至少由三个独立网络组成转换、加载、调优都得分开处理混在一起必然翻车。Text Encoder负责把 prompt 变成向量通常来自 CLIP 模型。它把分词后的 token 序列编码成一个二维特征常见导出尺寸是 77×768 或 77×1024取决于你用的 CLIP 版本。这个网络只在整条管线开头跑一次算力占比极低但它决定你输入的提示词能不能被 UNet 理解文本语义错了后面全白搭。UNet是真正的算力大头。它接收三个输入当前带噪声的 latent 特征图、当前时间步 t、以及 Text Encoder 给出的文本特征。UNet 在 latent 空间里做几十轮去噪预测每轮都是一次完整的编码器-解码器结构推理。512×512 分辨率下latent 特征图是 4 通道 64×64UNet 内部有大量的 ResNet 块和 Attention 块NCNN 部署时绝大部分耗时都花在这里。VAE负责 latent 空间和像素空间的互相转换。文生图只需要 VAE Decoder把去噪完的 4×64×64 latent 解码成 3×512×512 的 RGB 图像图生图则还要一个 VAE Encoder把输入的像素图反向编码成 latent。VAE 的卷积运算量不小但只跑一次优化优先级排在 UNet 后面。搞清楚分工之后模型的转换和加载顺序就自然清晰了先处理 UNet因为它是瓶颈再处理 VAE因为它的输出直接决定画质Text Encoder 最后处理因为它最简单。2.2 从 ONNX 到 NCNNonnx2ncnn 转换与节点名核对NCNN 不直接吃 PyTorch 权重常规路径是先导出 ONNX再用onnx2ncnn工具转成 NCNN 的.param和.bin两个文件。.param是网络结构的文本描述.bin是二进制权重两个文件缺一不可。导出 ONNX 这一步社区常见做法是用 diffusers 的export_model.py脚本把三个子模型分别导出每个子模型导出为独立的 ONNX 文件。这个过程不是本文重点直接说转换# 分别转换三个模型这里以 UNet 为例 ./onnx2ncnn unet.onnx unet.param unet.bin # 转换结束后用 ncnnoptimize 做一次算子融合可选但建议 ./ncnnoptimize unet.param unet.bin unet_opt.param unet_opt.bin 1第一条命令把 ONNX 转成 NCNN 格式-o之类的细节参数不同版本略有差异但输入输出顺序基本不变。第二条命令中最后的1表示启用 fp16 存储0表示 fp32。转换成功后会打印每一层的转换信息一旦遇到不支持的算子会直接报错中断。转换只是开始。真正要命的是节点名核对NCNN 用extractor.input()和extractor.extract()时填的字符串必须和 ONNX 导出时的输入输出节点名一致。名字对不上NCNN 不会报错而是给你一个全零张量。我一般转换完立刻读一遍.param文件的最后几行确认输入输出层的名字或者用一个小的 ONNX 查看工具记下节点名再对照。如果你拿到一个现成的 zip 项目包第一件事同样是打开.param文件确认名字不要想当然。2.3 本地部署路线横向对比NCNN 相比 ONNX Runtime / llama.cpp 类方案赢在哪做 AI 大模型本地部署可选的推理框架不少但每个的定位差异很大。这里把 NCNN 和另外两条常见路线做个对比帮你判断你的场景该不该选它。对比维度NCNNONNX Runtime CPUllama.cpp 类方案主打场景移动端 / 嵌入式 / 边缘设备服务器与桌面 CPU/GPU 通用LLM 文本生成算子覆盖覆盖 CNN 为主Transformer 支持逐步完善覆盖最全几乎所有 ONNX 算子都有只管 Transformer 结构内存占用低支持 fp16/int8 量化后更小中等低二进制体积极小适合静态链接进 C 项目较大较小对 Stable Diffusion 支持需要手动转换和裁剪难度高官方有现成 pipeline难度低基本不适用调优空间大每层算子都能手动替换小黑匣子为主中从表格能看出一个关键结论如果只是想最快把 SD 跑起来ONNX Runtime 才是省事的选择它的 Python/C API 里已经封装好了完整的 pipeline。那 NCNN 存在的意义是什么答案是“可控”和“可裁剪”。ONNX Runtime 的二进制往嵌入式板子上一放就是几十 MB而且很多算子在 ARM 上走的是通用实现性能不可控。NCNN 的算子实现是逐层手写的 NEON 汇编和 C 优化你可以只编入用到的算子静态链接成一个很小的可执行文件。对做智能硬件、边缘盒子的人来说体积和算子可控比“开箱即用”更重要。顺带说一句llama.cpp 这类方案在 LLM 部署上确实强但它针对的是 Transformer 的逐 token 生成和 SD 这种“卷积 注意力混合”的架构不是一回事。也有人用类似思路做了 SD 的端侧推理不过维护力度参差不齐。NCNN 恰恰是那个“卷积优化到极致、注意力也够用”的折中点。3. 文生图 C 管线从 prompt 到第一张图的最小实现3.1 用 ncnn::Net 加载模型param 与 bin 的组织方式把一个 SD 项目工程化第一步不是写采样代码而是把模型加载封装好。NCNN 的 C API 核心是ncnn::Net类三个子模型分别对应三个Net对象。下面是一个最小骨架#include ncnn/net.h #include ncnn/mat.h #include string #include vector class StableDiffusionNCNN { public: bool load(const std::string model_dir) { // 加载 Text Encoder if (text_encoder.load_param((model_dir /text_encoder.param).c_str()) ! 0) { return false; } if (text_encoder.load_model((model_dir /text_encoder.bin).c_str()) ! 0) { return false; } // 加载 UNet if (unet.load_param((model_dir /unet.param).c_str()) ! 0 || unet.load_model((model_dir /unet.bin).c_str()) ! 0) { return false; } // 加载 VAE Decoder图生图时还要额外加载 VAE Encoder if (vae_decoder.load_param((model_dir /vae_decoder.param).c_str()) ! 0 || vae_decoder.load_model((model_dir /vae_decoder.bin).c_str()) ! 0) { return false; } return true; } private: ncnn::Net text_encoder; ncnn::Net unet; ncnn::Net vae_decoder; };load_param加载网络结构load_model加载权重返回非 0 表示失败。三个模型共用ncnn::Net这个类但内部参数互不干扰这也是 NCNN 设计比较干净的地方。加载完成后建议立刻做一次“空跑”验证给 UNet 喂一个全 0 的 latent、时间步和文本特征看能不能正常输出形状正确的张量。空跑能过滤掉 80% 的转换问题别等整条管线写完再查那时候定位问题成本就高了。文件组织上我习惯在模型目录里放一个config.json记录模型分辨率和节点名方便后续换模型。3.2 采样循环与 CFG 缩放DDIM 去噪在 C 里怎么写文生图的核心是采样循环。每一轮迭代UNet 要跑两次一次用真实的 prompt 特征一次用空 prompt无条件特征然后按 CFG scale 把两次预测的噪声融合。这个融合逻辑直接抄 PyTorch 实现即可难点在 NCNN 的Mat上做逐元素运算需要用指针遍历。// latent 形状: 4 x 64 x 64float32 ncnn::Mat sample latent.clone(); float dt 1.0f / steps; for (int i 0; i steps; i) { float t 1.0f - i * dt; ncnn::Mat t_mat(1); t_mat.fill(t); ncnn::Mat noise_cond; { ncnn::Extractor ex unet.create_extractor(); ex.input(sample, sample); ex.input(timestep, t_mat); ex.input(encoder_hidden_states, cond_embed); ex.extract(out_sample, noise_cond); } ncnn::Mat noise_uncond; { ncnn::Extractor ex unet.create_extractor(); ex.input(sample, sample); ex.input(timestep, t_mat); ex.input(encoder_hidden_states, uncond_embed); ex.extract(out_sample, noise_uncond); } // CFG 融合: noise noise_uncond cfg_scale * (noise_cond - noise_uncond) ncnn::Mat noise noise_uncond.clone(); for (int c 0; c noise.c; c) { float* nptr noise.channel(c); const float* ncptr noise_cond.channel(c); const float* nucptr noise_uncond.channel(c); for (int k 0; k noise.w * noise.h; k) { nptr[k] nucptr[k] cfg_scale * (ncptr[k] - nucptr[k]); } } // DDIM 简化更新: sample sample - dt * noise for (int c 0; c sample.c; c) { float* sptr sample.channel(c); const float* nptr noise.channel(c); for (int k 0; k sample.w * sample.h; k) { sptr[k] - dt * nptr[k]; } } }这段代码里的ex.input(sample, ...)这些字符串就是 2.2 节说的节点名核对对象。create_extractor()每次循环都创建新实例这是 NCNN 的推荐用法避免上一次推理的中间状态污染这一次的结果。CFG 融合循环里noise.c、noise.w、noise.h对应 latent 的 4 通道和 64×64 分辨率通道数变了这套循环同样适用。DDIM 更新被简化成了sample - dt * noise真实项目里会有更复杂的系数表但骨架就是这个。t_mat.fill(t)这行容易踩坑UNet 的时间步输入是个一维张量ncnn::Mat(1)构造的是 1 个元素的 Matfill 之后维度才对得上。3.3 六个必调参数steps、CFG、seed、分辨率、线程数、精度文生图的输出质量一半靠模型一半靠这六个参数。把它们吃透调优效率会高很多。参数推荐范围影响调参建议steps1530步数越多细节越足耗时线性增长端侧从 20 起步先保证质量再往下压CFG scale59prompt 对结果的控制强度太大过曝太小跑题默认 7画面发灰时适当加大seed任意 int固定 seed 才能复现结果调试时固定验证量化误差全靠它分辨率512×512 / 768×768显存和耗时的直接决定因素端侧先跑 512量化和算子优化后再往上提线程数28影响推理速度但非越多越快用opt.num_threads设置实测找峰值精度fp32 / fp16 / int8fp16 体积减半速度提升int8 损失画质优先 fp16画质敏感场景留 fp32关于线程数有个血泪经验线程数等于 CPU 核心数时不代表最快。NCNN 的算子内部也开了 OpenMP外层线程加内层并行会互相争抢出现“线程越多越快”的错觉实际测出来可能 4 线程比 8 线程更快。正确的做法是把ncnn::get_omp_num_threads()打印出来看真实值或者干脆用opt.num_threads 4做一组对比实验。分辨率也一样不是模型支持 1024 就一定要跑 1024端侧设备上 512×512 跑 20 步如果都要 10 秒那 768 就是不可用的参数。4. 图生图落地从读图到 Latent 加噪的完整链路4.1 图生图和文生图的差异VAE Encoder 与加噪强度 strength图生图看起来只是多了一个输入图片的步骤实际整个采样逻辑都要调整。文生图是从纯随机噪声开始去噪图生图则是从输入图片编码后的 latent 出发先加一部分噪声再去噪。加多少噪声由strength参数控制范围 01越接近 1 越像重新生成越接近 0 越保留原图结构。流程是这样的输入图片先缩放目标分辨率归一化到 [-1, 1]喂给 VAE Encoder 得到 latent然后根据strength算出要略过多少步去噪——比如 20 步里 strength 为 0.6就相当于把前 12 步的噪声直接加到 latent 上只跑后 8 步去噪。这样原图信息保留在 latent 里去噪过程又能根据 prompt 添加新细节。// strength: 0~1控制保留原图程度 int start_step (int)(steps * (1.0f - strength)); // 给 latent 加噪声: latent_noisy latent noise * noise_level ncnn::Mat latent_noisy latent.clone(); for (int c 0; c latent_noisy.c; c) { float* lptr latent_noisy.channel(c); const float* nptr gaussian_noise.channel(c); for (int k 0; k latent_noisy.w * latent_noisy.h; k) { float alpha start_step * dt; // 噪声占比随步数增大 lptr[k] lptr[k] * (1.0f - alpha) nptr[k] * alpha; } }加噪这一步看起来简单但gaussian_noise必须用和采样循环同一个随机数源。如果在加噪时用一套随机数、去噪时又用另一套结果会莫名花屏而且每次运行都不一样。我一般把噪声生成单独封装成一个函数传入 seed 作为参数保证加噪和去噪共享同一个噪声序列。图生图的采样循环和文生图几乎一样区别只在起始 latent 不同——文生图从纯噪声开始图生图从latent_noisy开始。所以工程上可以把采样循环抽成公共函数只替换起始 latent省掉重复代码。4.2 图像预处理从 cv::Mat 到 ncnn::Mat 的尺寸、通道与归一化图生图输入输出都涉及 OpenCV 和 NCNN 的数据格式转换这里面的坑比想象中多。OpenCV 默认 BGR 顺序NCNN 的from_pixels可以直接指定像素格式转换。归一化要在ncnn::Mat上做因为这时候数据已经变成 float 数组。#include opencv2/opencv.hpp #include ncnn/mat.h // 读取并缩放输入图 cv::Mat img cv::imread(input.png, cv::IMREAD_COLOR); cv::resize(img, img, cv::Size(512, 512)); // BGR - RGB 并转为 ncnn::Mat数据范围 0~255 ncnn::Mat in ncnn::Mat::from_pixels(img.data, ncnn::Mat::PIXEL_BGR2RGB, 512, 512); // 归一化到 [-1, 1]适合 VAE Encoder for (int c 0; c in.c; c) { float* ptr in.channel(c); for (int i 0; i in.w * in.h; i) { ptr[i] ptr[i] / 127.5f - 1.0f; } } // 喂给 VAE Encoder节点名以实际导出的 ONNX 为准 ncnn::Extractor ex vae_encoder.create_extractor(); ex.input(image, in); ex.extract(latent_sample, latent);PIXEL_BGR2RGB这行会多做一次通道重排很多人为了省这点时间直接传PIXEL_BGR结果生成出来的图颜色不对。这种色偏问题最难排查因为不是报错而是“看着怪”。如果你复现了别人项目发现颜色不对第一反应就去看像素格式是不是没转。输出侧反过来VAE Decoder 输出的是 float 数组范围大致在 [-1, 1] 附近需要先转回 0255 再写图同时别忘了做 clamp否则个别溢出像素会让整张图出现奇怪的色斑。输出格式也要转成 OpenCV 能写的cv::Mat这一步用ncnn::Mat::to_pixels配合PIXEL_RGB2BGR。5. 部署避坑跑通是运气跑稳靠排查5.1 现象输出全黑或花屏第一次跑通文生图看到全黑图是家常便饭。原因通常有三个一是 VAE 输出的 float 值没有 clamp直接强转 unsigned char超出 255 的数据溢出成 0二是归一化方向反了把 [-1, 1] 的数据直接当 [0, 255] 用三是通道顺序问题RGB 当 BGR 写画面颜色完全错乱。解决方法是写一个专门的输出函数把 VAE 输出逐像素 clamp 到 [-1, 1]再映射到 [0, 255]最后用PIXEL_RGB2BGR转成 OpenCV 格式。写完后用一张纯色测试图跑一遍确认每个通道的值和预期一致再跑真实 prompt。5.2 现象模型加载即崩溃或内存爆炸NCNN 加载过程中直接段错误或者加载成功但一执行就报 “not implemented”几乎都是算子转换问题。Stable Diffusion 的 UNet 里有 GroupNorm、GEGLU 这类特殊算子老版本 NCNN 可能不支持转换时就该报错如果转换时没报运行时也可能因为 shape 推导错误崩掉。解决路径按顺序试先把 NCNN 升级到较新版本重新转换再看.param文件里有没有GroupNorm层有的话检查 NCNN 编译时是否启用了对应实现都不行就用算子替换方案比如把 GEGLU 拆成两个全连接加一个激活函数。这个拆算子很磨人但做完一次后面换模型就轻车熟路了。5.3 现象换了线程数或换了机器结果就不一样同一个 seed、同一个模型在 4 线程和 8 线程下跑出来的图有肉眼可见的差异。这个现象让很多人怀疑模型坏了其实不是。NCNN 内部算子用 OpenMP 并行浮点加法在不同线程数下的累加顺序不同结果自然有微小差异如果图生图里还涉及随机数差异会被放大成花屏。解决方法是把随机数生成和采样完全分离先生成一张完整的噪声图存进ncnn::Mat采样循环里只读取不重新生成。这样至少保证同一 seed 在同规格设备上可复现。跨设备完全一致很难做到除非你关掉 OpenMP 用单线程但那样性能损失太大不现实。5.4 现象推理速度慢到不可用端侧设备跑 SD速度是最现实的坎。如果 512×512 跑 20 步要 30 秒以上优化顺序应该是先确认线程数设置合理再做 fp16 量化最后考虑 int8 量化。# 先用 ncnnoptimize 把模型转成 fp16 版本 ./ncnnoptimize unet.param unet.bin unet_fp16.param unet_fp16.bin 1 # 再用 ncnn2int8 生成 int8 版本需要先准备校准数据 ./ncnn2int8 unet_fp16.param unet_fp16.bin unet_int8.param unet_int8.bin calibration.tablencnnoptimize不仅做精度转换还会做算子融合和内存重排这一步对速度的提升往往比量化本身还明显。int8 需要校准表校准数据从你的真实使用场景里抽样——比如用 100 张图生图的输入图片过一遍 VAE Encoder 得到 latent拿这些 latent 做校准。校准集里没有的场景量化后画质会断崖式下降这是 int8 的黑匣子部分只能靠多测。6. 工程化收尾量化、验证与可复现部署模型在本地跑通只是中点距离“可交付”还差两步量化验证和回归测试。先做量化验证。fp16 量化后不要直接看生成效果而是用固定 seed 跑一组对比原模型和图生图原模型各生成 10 张图计算 PSNR 或者直接用肉眼看边缘和纹理差异。fp16 通常损失极小但如果你发现阴影区域有色带那说明 VAE Decoder 这部分的精度敏感可以考虑 VAE 保持 fp32只量化 UNet。混合精度是 NCNN 部署里很实用的技巧不同子模型各用各的精度反正Net对象本来就是分开的。再做回归测试。把设备上生成的图和服务器上 Python 端同 seed 生成的图放在一起对比只对标“轮廓是否一致、颜色是否合理”不追求像素级一致。这个步骤能快速暴露固定 Seed 的随机数差异、算子精度差异等问题。我现在的习惯是每改一个参数就存一份对比图集按日期命名积累到一定量之后哪个版本好、哪个版本翻车了一眼就能看出来。最后把整条管线做成一个可复现的构建模型文件按.param和.bin分开管理加一个简单的版本号C 工程里用 CMake 管理 NCNN 和 OpenCV 的依赖写清楚最低版本运行脚本固定 seed、steps、CFG 这三个参数保证任何人拉下来都能跑出同一张图。这套东西不复杂但能让你从“自己跑通”变成“别人也能跑通”。希望帮到你。本文还有配套的精品资源点击获取
返回列表