ARTICLE DETAIL

资讯详情

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

NCNN+C++部署Stable Diffusion:从ONNX转换到端侧推理全指南

NCNN+C++部署Stable Diffusion:从ONNX转换到端侧推理全指南 简介面向算法部署工程师与移动端开发者这份实战资源以NCNNC为核心完整展示大模型Stable-Diffusion的轻量化部署方案文生图与图生图两大功能均有可运行实现。资源先讲解NCNN框架原理及其与Stable-Diffusion模型的结合优势再从模型格式转换、接口设计、输入输出处理到后处理技巧逐步拆解并给出Android、iOS及嵌入式平台的部署案例同时梳理兼容性、性能优化与资源消耗等常见问题的排错思路以及生产环境的架构设计建议。压缩包共750个文件类型覆盖hpp/h头文件、param/bin模型配置与权重、cmake构建脚本、cpp源码、a静态库与dll动态库等大小66.63MB目录结构清晰便于按模块查阅、复现实验并迁移到自有项目。已有380人学习下载适合具备C基础、希望掌握大模型轻量化部署的开发者作为参考。1. 大模型部署的新解法用 NCNN C 把 Stable Diffusion 跑在本地设备上Stable Diffusion 这类生成模型多数教程停在 PyTorch 推理或 WebUI 一键出图真正把大模型部署降级到 NCNN C 做端侧落地的完整资源反而少见。这个项目补的就是这块文生图、图生图两条链路都跑在 NCNN 框架上C 统一封装自带 libncnn.a、libopencv_core.a、libopencv_imgproc.a 静态库源码可以直接编进 Android、iOS 或嵌入式设备。对谁有用一类是做端侧 AI 产品的工程师需要在没有 GPU 的设备上跑 SD另一类是刚入行模型部署的开发者想搞明白权重怎么转成 NCNN 参数、shape 怎么对齐、内存怎么控。后面按「原理 → 实现 → 排错 → 压测」的顺序拆开讲参数和坑放在对应章节照抄即可。2. 适配原理PyTorch 权重怎么一步步变成 .param 和 .bin2.1 选型理由NCNN 在端侧部署里的位置先回答一个绕不开的问题部署 Stable Diffusion 为什么不直接用 ONNX Runtime 或 TensorRT非要用 NCNN看这张对比框架目标平台动态 shape 支持fp16/量化上手成本ONNX Runtime服务端/桌面为主原生支持依赖额外 EP低TensorRTNVIDIA GPU受限需 profile强高NCNN移动端/嵌入式 ARM有限需 -o 开启Vulkan fp16中核心差异在目标平台。Stable Diffusion 完整模型Text Encoder UNet VAEfp32 权重接近 2GB服务端不在乎但手机上连加载都是问题。NCNN 的优势在于权重以 fp16 存储配合 Vulkan 可以在移动 GPU 上跑卷积和 GEMM 算子针对 ARM 的 NEON 指令做过专门优化。项目里给的 libncnn.a 就是按这个方向编出来的静态库链接进 C 工程即可不需要带动态库对 Android NDK 和嵌入式交叉编译都友好。另一个值得注意的点是 NCNN 的模型文件结构.param 存网络拓扑和层参数明文可读.bin 存权重二进制。这意味着调试时可以直接改 .param 里的 shape、删层、替换算子这在排查转换问题时非常有用后面避坑章节会用到。2.2 转换链路torch 导出 onnx再用 onnx2ncnn 转 param/bin转换链路常见做法是两段式PyTorch 导出 ONNX再用 NCNN 自带的 onnx2ncnn 工具转成 .param/.bin。第一步要把 Stable Diffusion 的三段模型拆开分别导出因为 Text Encoder 输入是 token 序列UNet 输入是带噪潜变量加条件 embeddingVAE Decoder 输入是潜变量三者输入输出完全不同混在一起导出会非常难调。先看 Text Encoder 的导出脚本import torch from transformers import CLIPTextModel text_encoder CLIPTextModel.from_pretrained(path/to/text_encoder) text_encoder.eval() dummy_ids torch.randint(0, 49406, (1, 77), dtypetorch.long) torch.onnx.export( text_encoder, (dummy_ids,), text_encoder.onnx, input_names[input_ids], output_names[last_hidden_state], dynamic_axes{input_ids: {0: batch}}, opset_version11, )这里 dynamic_axes 只放开 batch 维度序列长度固定 77。Stable Diffusion 的 prompt 编码长度是 77 个 token固定长度能避免 NCNN 在动态维度上踩坑。opset_version 建议用 11NCNN 对低版本 opset 的算子覆盖更全后面遇到不支持的算子时这个经验能救一次。接下来用 onnx2ncnn 转换onnx2ncnn text_encoder.onnx text_encoder.param text_encoder.bin转换成功会输出每层拓扑信息某个算子不支持会直接报错并指出算子类型。UNet 的导出要特别注意它的输入之一是 timestep 标量另一个是条件 embedding。导出时把 timestep 也当作输入节点不要硬编码否则采样循环里每一步的 timestep 都在变模型却只认固定值出图直接崩。2.3 三段模型的差异化处理静态 shape 与动态 shape 的取舍Text Encoder 和 VAE 结构相对规整转换基本一次过。真正麻烦的是 UNet它内部有大量 concat、resize、attention 操作而且采样步数不同条件 embedding 和 timestep 维度也不一样。转换时常见做法是用 -o 参数手动指定动态维度onnx2ncnn unet.onnx unet.param unet.bin -o 0-o 0 表示允许动态 shape代价是推理时 NCNN 会在输入 shape 变化时重建部分内部结构多一次分配开销。我的建议是如果只在固定分辨率比如 512×512下部署输出尺寸写死完全不要开动态 shape性能更稳要做多尺寸输入再开 -o并且用 ncnn::Mat 的 create 接口显式声明输入尺寸。VAE Decoder 转换时常见的坑是上采样层格式。PyTorch 的 F.interpolate 导出后通常是 Resize 算子NCNN 对它的支持依赖版本老版本可能转成奇怪的组合。碰到这种情况先升级 NCNN 版本或者把 interpolate 换成 ConvTranspose2d 再导出这是社区里最常用的绕法。模块输入 shape输出 shape转换注意Text Encoder1×77 int1×77×768固定序列长度UNet1×4×64×64 timestep cond1×4×64×64动态维度慎开VAE Decoder1×4×64×641×3×512×512检查 Resize 算子3. 文生图实现CLIP 编码、UNet 采样循环与 VAE 解码3.1 工程目录与 C 封装层设计拿到项目后先看目录结构。典型的 NCNN 部署工程长这样sd_ncnn/ ├── CMakeLists.txt ├── src/ │ ├── text_encoder.cpp │ ├── unet.cpp │ ├── vae.cpp │ ├── sampler.cpp │ └── pipeline.cpp ├── include/ │ └── sd_ncnn.h └── models/ ├── text_encoder.param ├── text_encoder.bin ├── unet.param ├── unet.bin ├── vae_decoder.param └── vae_decoder.binCMakeLists 里链接项目自带的静态库target_link_libraries(sd_ncnn ${CMAKE_SOURCE_DIR}/libs/libncnn.a ${CMAKE_SOURCE_DIR}/libs/libopencv_core.a ${CMAKE_SOURCE_DIR}/libs/libopencv_imgproc.a )注意三个 OpenCV 静态库的链接顺序OpenCV 模块之间有依赖顺序写反会报 undefined reference。libopencv_imgproc.a 依赖 libopencv_core.a依赖方必须放前面。很多人在 Android Studio 里编不过不是代码问题就是链接顺序问题。封装层设计上我建议每个模型一个类对外只暴露 load 和 forward 两个接口内部持有 ncnn::Net 实例。这样三段模型的生命周期可以独立管理Text Encoder 只加载一次整个管线的多次采样都复用UNet 每次采样循环都要用VAE 只在最后解码时用一次。按使用频率控制加载和释放能省不少内存。3.2 Text Encoder 封装与 tokenizer 对齐Text Encoder 输入是 token id 序列这一步最容易出问题的是词表不一致。训练时用的 tokenizer 是 CLIP 的 BPE 词表部署端就必须用同一个 tokenizer 生成 token不能拿别的词表顶替。void TextEncoder::forward(const std::vectorint tokens, ncnn::Mat hidden_state) { ncnn::Mat in ncnn::Mat(77); for (int i 0; i 77; i) { in[i] (i (int)tokens.size()) ? tokens[i] : 49407; // eot 填充 } in in.reshape(77, 1, 1); ncnn::Extractor ex net.create_extractor(); ex.input(input_ids, in); ex.extract(last_hidden_state, hidden_state); }padding 用 49407 是 CLIP 的 eot 结尾 token id必须和 PyTorch 侧完全对齐。C 侧没有 transformers 库tokenizer 要么提前在 Python 里把 prompt 转成 token 存成文件要么用 C 版本的 BPE 实现。项目里通常走第一种省事且不容易出错。3.3 UNet 去噪循环调度器与 CFG 实现文生图的核心是采样循环。以 DDIM 调度器为例50 步去噪每步都要把潜变量、timestep、条件 embedding 喂给 UNetncnn::Mat latent init_latent(seed, height, width); // 随机高斯噪声 float alpha_bar 1.0f; for (int t total_steps - 1; t 0; t--) { float ts t * (1000.0f / total_steps); // timestep 映射到 0~1000 ncnn::Mat noise_pred; sampler.step(latent, ts, text_embedding, noise_pred); // DDIM 更新 float alpha_t sqrt(alpha_bar * (t 1) / total_steps); float alpha_prev sqrt(alpha_bar * t / total_steps); latent (latent - (1 - alpha_t) / sqrt(1 - alpha_t) * noise_pred) / sqrt(alpha_t) * sqrt(alpha_prev) sqrt(1 - alpha_prev) * noise_pred; }CFGClassifier-Free Guidance的实现是另一处关键。它要求 UNet 跑两次一次带条件一次不带空 prompt 编码然后按 guidance 系数加权ncnn::Mat noise_cond, noise_uncond; unet.forward(latent, ts, cond_emb, noise_cond); unet.forward(latent, ts, uncond_emb, noise_uncond); ncnn::Mat noise noise_uncond cfg_scale * (noise_cond - noise_uncond);cfg_scale 常见取 7.5越大图像越贴近 prompt但过大会饱和失真。注意无条件分支的 embedding 不是随机噪声而是空字符串经过同一个 Text Encoder 编码的结果必须在初始化阶段算好缓存不能每步重算。提示timestep 的映射是新手最常翻车的地方。PyTorch 侧调度器内部用 0~1000 的离散刻度C 里自己写循环时必须保证 ts 和训练时的噪声调度对齐。Diffusers 的 DDIMScheduler 里 timesteps 的生成逻辑可以直接抄过来不要自己拍脑袋定。3.4 VAE 解码与像素归一化采样循环结束得到一个 1×4×64×64 的潜变量需要经过 VAE Decoder 变成 512×512×3 的图像。这里有两个容易错的地方。第一个是缩放系数。潜变量在送入 Decoder 前要除以 0.18215latent latent * (1.0f / 0.18215f); vae_decoder.forward(latent, decoded);这个 0.18215 是训练时 VAE 潜空间方差的标定值忘了乘输出图像会整体偏移对比度明显不对。第二个是像素范围。Decoder 输出是浮点张量值域大约在 -1 到 1 之间要显示成图像得映射到 0~255ncnn::Mat rgb decoded.channel(0); // NCNN 是 packed 布局 for (int i 0; i total; i) { float v rgb[i] * 127.5f 127.5f; rgb[i] v 0 ? 0 : (v 255 ? 255 : v); } cv::Mat img(512, 512, CV_8UC3); // 把三个通道拼回 BGR交给 OpenCV 上屏NCNN 的 Mat 默认是 packed 布局三通道数据是 RRRGGGBBB 还是 RGBRGB 取决于编包时的 pack 设置。我建议在 extract 之后先做通道拆分再交给 OpenCV避免和 cv::Mat 的布局打架。4. 图生图与性能优化VAE 编码、内存复用与多线程调度4.1 图生图的输入链路VAE Encoder 与噪声强度控制图生图和文生图的差异在起点。文生图从纯高斯噪声开始图生图要把输入图像先编码进潜空间再按 denoise 强度叠加噪声。先跑 VAE Encoderncnn::Mat input_img ncnn::Mat::from_pixels_resize( bgr.data, ncnn::Mat::PIXEL_BGR, w, h, 512, 512); input_img.substract_mean_normalize(mean_vals, norm_vals); vae_encoder.forward(input_img, latents); latents latents * 0.18215f; // 注意这里是乘和 Decoder 相反这段有两个坑。第一from_pixels_resize 的 PIXEL_BGR 要和你传入的 cv::Mat 颜色顺序一致OpenCV 读进来是 BGR传 PIXEL_BGR 就对了传成 RGB 出图偏色。第二substract_mean_normalize 的 mean 和 norm 要与训练时一致Stable Diffusion 的 VAE 用 [0.5, 0.5, 0.5] 的 mean 和 [2.0, 2.0, 2.0] 的 norm也就是把像素从 0~255 归一化到 -1~1。denoise 强度决定叠加多少噪声。强度 1.0 等价于文生图从纯噪声出发强度 0.3 则保留原图大部分结构只做细节重绘float strength 0.7f; int init_steps (int)(total_steps * strength); latents latents * sqrt(alpha_bar_init) noise * sqrt(1 - alpha_bar_init);叠加噪声时用的 alpha_bar 是初始 timestep 对应的值这个值要按 strength 映射到调度器刻度上。我踩过的坑是直接拿随机噪声去叠导致 start_step 和噪声水平不匹配出来的图要么全是噪点要么跟原图一模一样根本没有「重绘」效果。4.2 内存复用权重共享与 blob 生命周期Stable Diffusion 部署到端侧内存是第一道坎。三段模型全量加载fp16 存储下大约 1GB 起步加上推理中间 blob2GB 内存的设备会非常紧张。第一招是按需加载。Text Encoder 只在 prompt 编码阶段用编码完就可以释放VAE Decoder 只在最后用采样循环期间不占内存。把三个 Net 实例的生命周期错开峰值内存能降 30% 左右。项目里封装层设计成独立类就是为了方便这么做。第二招是复用中间 buffer。采样循环里每步的 noise_pred、latent 都是固定 shape可以预先分配好ncnn::Mat latent, noise_pred; latent.create(64, 64, 4, 4u); // 复用不重新分配 noise_pred.create(64, 64, 4, 4u); for (int t total_steps - 1; t 0; t--) { // 每步直接写入预分配 Mat }注意 Mat 的 create 在 shape 相同的情况下不会重新分配内存只有 shape 变化时才重开。所以循环里保持 shape 恒定内存分配只发生一次。第三招是 fp16 存储。NCNN 的 Net 加载 .bin 时如果编包开了 fp16权重自动半精度存储。检查方式很简单grep -r FP16 config.h如果没开重新编译 NCNN 时加 -DNCNN_VULKANON 和相关 fp16 开关。fp16 对显存和带宽的收益在 UNet 这种大模型上非常明显耗时能降 40% 左右精度损失对生成任务几乎无感。4.3 多线程与 Vulkan耗时分布决定优化方向先看耗时分布再优化别上来就动代码。常见做法是在 pipeline 里打点计时auto t0 std::chrono::steady_clock::now(); // 每阶段结束打一个点最后统计实测的典型分布50 步采样里UNet 推理占 85% 以上Text Encoder 和 VAE 加起来不到 15%。所以优化重点永远在 UNet 那 50 次前向。CPU 侧extractor 支持设置线程数ex.set_num_threads(4);在 ARM 大小核架构上不建议开满。常见做法是绑定大核用 4 线程跑卷积留两个小核给系统。开满 8 线程反而因为调度开销和缓存争抢变慢这是实测结论不是理论推断。如果设备有 GPU优先开 Vulkanncnn::Net net; net.opt.use_vulkan_compute true;Vulkan 对 UNet 这种卷积密集网络收益最大在 Adreno 和 Mali GPU 上通常比纯 CPU 快 2~4 倍。但代价是首次初始化要编译 shader耗时几秒需要在启动时提前 warmup 一次避免用户第一次出图等太久。5. 部署避坑指南转换失败、黑图与内存翻车的 7 个案例5.1 转换阶段的两个典型报错案例 1onnx2ncnn 报 unsupported operator。现象转换 UNet 的 onnx 时输出 Unsupported operator XXX工具直接中断。原因PyTorch 导出的算子版本太新NCNN 解析器不认识。最常见的是 aten::grid_sampler、aten::upsample_bilinear2d 这类图像算子在 UNet 和 VAE 里很常见。解决先换低版本 opset 重新导出。还不行就把这个算子用等价结构替换比如 upsample 换成 ConvTranspose2dgrid_sample 换成手动仿射变换加 bilinear 采样。如果算子本身是 NCNN 支持但缺少实现可以在 NCNN 源码里补一个 layer但成本高建议先走替换路线。案例 2转换成功但加载时模型 magic number 不匹配。现象.param 加载报 magic number 错误或者 bin 文件读取长度不匹配。原因多数是 .param 和 .bin 版本不匹配。NCNN 不同版本的 param 文件头魔数不同新版本工具转换出的模型旧版本 libncnn.a 加载就会报这个错。解决确认编译期 NCNN 版本和转换工具版本一致。项目里自带的 libncnn.a 如果版本较老就用对应版本的 onnx2ncnn 重新转换别混用。5.2 推理阶段的黑图和花屏案例 3输出全是黑色或者纯噪声。现象生成的图像要么全黑要么全是雪花噪点完全看不出内容。原因最常见的是潜变量缩放没做对。Decoder 前没乘 1/0.18215或者 Encoder 后没乘 0.18215潜空间尺度不对解码出来就是无效图像。另一个原因是像素归一化搞错输出范围 0~255 和 -1~1 之间没做映射直接 reinterpret 成 uchar 就会花屏。解决在 Decoder 前加 latent latent * (1.0f / 0.18215f)输出后严格做 v * 127.5f 127.5f 再 clamp。这两个位置是固定套路建议封装成不可跳过的内部逻辑不给调用方犯错的机会。案例 4图像色彩整体偏绿或偏红。现象内容能看出来但颜色明显不对像通道顺序错了。原因NCNN 的 from_pixels 和 OpenCV 的通道顺序没对齐。OpenCV 读图像是 BGR按 RGB 传给 NCNN 的 PIXEL_RGB或者 extract 之后按 RGB 顺序拼回 cv::Mat颜色就偏。解决全链路统一用 BGR。OpenCV 读进来是 BGRNCNN 侧声明 PIXEL_BGRdecode 出来后先拆通道再按 BGR 顺序拼。图生图输入、文生图输出都按这个约定走。5.3 内存和性能翻车案例 5推理过程中内存持续上涨最终 OOM。现象跑前几步内存正常越到后面涨得越快最后进程被杀。原因采样循环里每步都创建新的 ncnn::Mat旧 Mat 的引用没有及时释放。extract 返回的 Mat 持有内部 blob 的引用循环里反复 extract 而 Mat 生命周期不回收内存就持续累积。解决把 latent、noise_pred 声明在循环外每步复用extract 的结果用完立刻释放引用或者用作用域包起来for (...) { ncnn::Mat noise_cond, noise_uncond; { ncnn::Extractor ex net.create_extractor(); ex.input(...); ex.extract(out, noise_cond); } // 作用域结束自动释放 }案例 6CPU 推理单步耗时 5 秒以上。现象50 步采样要跑几分钟完全不可用。原因多半是编 NCNN 时没开针对目标架构的优化或者 Vulkan 没启用。还有一个隐蔽原因线程数设置过高引起的缓存争抢。解决先确认编译参数。Android 上用 armv8.2-a 并开 fp16加 -DNCNN_VULKANON。线程数从 2 往上试探找到耗时最低点。每步耗时大于 1 秒的设备就要考虑剪枝或量化单纯调参救不回来。案例 7基准测试耗时很低但实际出图时间很长。现象单步推理测试很快整个流程跑完比预期慢很多。原因模型加载、shader 编译、内存分配这些一次性开销被忽略或者没被算进基准。解决把耗时拆成三段——模型加载、warmup、正式采样。warmup 跑两步让 Vulkan shader 编译完成从第三步开始计时。这才是真实推理性能用户体感时间要加上加载和 warmup。6. 进阶技巧用参考输出做回归验证把部署结果钉死在可信区间部署完成不等于部署正确。C 侧没有 PyTorch 的调试环境黑匣子跑完出一张图你怎么知道这张图和 PyTorch 侧的输出一致我的做法是做一个离线回归脚本把两端输出拉齐对比。具体做法先在 PyTorch 侧固定种子和 prompt导出每一阶段的中间张量——Text Encoder 的 hidden_state、UNet 第一步的 noise_pred、VAE Decoder 的最终输出各存成二进制文件。然后在 C 侧同样固定种子跑一遍把对应输出 dump 出来做余弦相似度和 PSNR 对比float cosine_similarity(const float* a, const float* b, int n) { float dot 0, na 0, nb 0; for (int i 0; i n; i) { dot a[i] * b[i]; na a[i] * a[i]; nb b[i] * b[i]; } return dot / (sqrt(na) * sqrt(nb) 1e-8f); }判定阈值我一般这样卡对比项余弦相似度说明Text Encoder 输出 0.999结构简单误差极小UNet noise_pred 0.99fp16 会有合理误差VAE 输出像素PSNR 28dB视觉无差异如果 UNet 的相似度掉到 0.95 以下基本可以断定 shape 对齐或 timestep 映射有问题逐项排查。fp16 带来的差异通常在 0.99 以上低于这个值先怀疑逻辑错误别甩锅给精度。从那以后我每次部署完模型都会强制自己走一遍「PyTorch 导出中间张量 → C dump 对比 → 阈值判定」的流程再上真机。因为部署这行最贵的从来不是跑通而是跑通了却不知道对不对。希望这份拆解能帮你少走几趟弯路。本文还有配套的精品资源点击获取
返回列表