【腾讯犀牛鸟2026】Youtu-VL-4B 的 ncnn 移植与双平台部署 #6850
Replies: 1 comment
|
This is a strong porting report, especially the end-to-end token-by-token comparison. For making the result easier to reproduce and useful to ncnn users, I would add a small reproducibility section alongside the accuracy table. The most valuable items would be:
The fp32 greedy comparison is the right correctness gate, but it is only one path. A second test set should cover variable image aspect ratios, a long prompt, an image with no obvious text, empty/short prompts, and non-greedy sampling as a separate quality benchmark. For Windows/Linux parity, report whether the same thread count and math path are used; exact token equality can become sensitive to compiler and SIMD differences even when the model is correct. Since the implementation is split into many independently validated graphs, packaging the conversion scripts and a single command that regenerates the artifacts would make future ncnn changes much easier to audit. The current evidence is already convincing; the next step is making the benchmark and artifact provenance durable. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
【腾讯犀牛鸟2026】Youtu-VL-4B 的 ncnn 移植与双平台部署
任务目标
将 `tencent/Youtu-VL-4B-Instruct 从 PyTorch/Hugging Face 推理链路移植到 ncnn,交付一个纯 C++、运行时仅依赖 ncnn 与 stb_image 的多模态推理程序。程序读取一张图片和任意文本 prompt,在 C++ 内完成全部工作并输出文本:
完成情况
结果展示
测试输入:一张 1468×220 的横幅图片(经预处理为 14×92 = 1288 patch,合并后 322 个视觉 token);prompt 为
Describe the image briefly.。运行命令:
对齐结果:C++ ncnn runtime 与 PyTorch fp32 参考使用相同图片、相同 prompt、greedy 解码,逐 id 输出完全一致:
Linux(GCC)与 Windows(MinGW-w64)两平台在同一份源码上构建运行,输出 token id 完全一致:
两张截图除"平台"一行外内容完全相同——这正是双平台交付要证明的:两套工具链编出的程序,在相同输入下产出完全相同的 token 序列。
逐级对齐数据(均在 fp32 下测得):
解码与采样说明
当前采用 greedy argmax 解码,不启用随机采样。每一步经 final RMSNorm 与 lm_head 得到整个词表的 logits 后取概率最高的 token——只有确定性解码才能与 PyTorch 逐 id 对齐。回答质量取决于模型本身与采样策略,不属于移植正确性范畴;移植的验收标准是"相同输入下与参考逐 token 一致",而非"回答是否准确"。
实现流程
1. 建立 PyTorch 参考输出
移植前先运行原始
tencent/Youtu-VL-4B-Instruct,dump 一整套 ground truth:输入 token ids、image token 数量与位置、pixel_values、spatial_shapes、vision 输出、merger 输出、逐层 hidden、prefill logits、greedy token 与最终文本。此后每完成一个 ncnn 模块,都与该参考逐级比对;排查顺序固定为 输入处理 → vision encoder → merger → LLM layer → KV cache → lm_head → token,不依据最终文本猜测错误来源。一处必须注意:参考须与实现同精度生成。本项目受限于 16GB 内存,最初的 dump 以 bf16 完成,导致视觉链路对齐停在 0.9977 而非预期的 0.9999,一度被误判为实现缺陷。补跑一份仅将
siglip2与merger转 fp32 的参考后,同一套 ncnn 图的余弦相似度即升至 0.999867——差距全部来自参考侧的量化。跨精度比对只能考察余弦相似度,不能作为逐 token 验收依据。2. C++ 图片预处理
image_proc直接读取图片,执行与 PyTorch processor 一致的处理:最终 C++ 实现与 PyTorch 参考的差异为 989184 个元素中 141 个差 1 LSB,与 Python 侧直接调用 PIL 的结果逐位相同。该差异不改变最终 token(已端到端验证)。
3. 图片转 image embeddings
视觉部分拆为 29 张 ncnn 子图串联:
SigLIP-2 视觉塔采用窗口注意力:第 7/15/23/26 层为全局注意力,其余为 8×8 合并 patch 的窗口注意力。窗口分组通过一次置换(
window_index)实现,注意力掩码为块对角矩阵。这些看似动态的结构,对固定图片尺寸而言全部是常量——窗口置换、二维 RoPE 的 cos/sin、两种掩码均由 C++ runtime 预先生成后作为图输入喂入,ncnn 图内只保留纯计算。VLPatchMerger 将相邻 2×2 patch 特征拼接(1152×4=4608)后经两层 MLP 投影到语言模型的 2560 维。视觉链路全程 fp32。4. C++ 编码任意 prompt
tokenizer加载从tokenizer.json提取的 vocab 与 merges,实现 Llama-3 系 byte-level BPE:该 tokenizer 与常见的 Qwen 系不同:词表由 128000 个基础 token 与 155386 个 added_token 组成,合计 283386;pre_tokenizer 是三段 Sequence,首段先把 CJK 字符切成独立片段,次段才是 Llama-3 的七分支正则。C++ 侧以手写字符分类状态机等价实现,不依赖任何正则库。
验收标准是与参考 input_ids 逐 token 一致,不看解码后的文本是否相似。实测 348/348 完全一致。为降低在 C++ 中调试正则的成本,先在 Python 中以"仅使用 C++ 可等价实现的原语"写了一份原型并验证通过,再逐行移植——这一步使 C++ 版本一次通过。
5. 多模态 embedding 融合
文本 token 先经 embedding 查表得到
[seq_len, 2560],再把 image 占位行(id=128264)按位置精确替换为 merger 输出,并校验 image token 数量与 merger 输出行数严格相等。若把占位 id 当普通词表 id 查表,模型将完全看不到图片,却仍会输出一段流畅、看似合理的描述——此类缺陷不崩溃、只表现为"效果不佳",故必须显式校验。6. LLM 拆 prefill 与 decode 两类图
语言模型 40 层,每层导出三张 ncnn 图:
RoPE、掩码、cache 的生命周期全部由 C++ runtime 显式管理,不留在图内。
YoutuLLM 采用 MLA(Multi-head Latent Attention),但这份实现并未使用 MLA 的"吸收/latent-cache"加速技巧:每层都把压缩的 KV(512 维)经
kv_b_proj展开为完整的 per-head K/V,cache 存储的是展开后的结果。因此从 runtime 视角看,它就是标准的每头 attention cache,只是 K/V 维度不对称(K=192,V=128)。lora 的 a/b 两级投影只是多几个 Linear,均为可导出的纯线性算子。另有一处比常见 GQA 更简单:num_key_value_heads = num_attention_heads = 32,无需repeat_kv展开。embedding 与 lm_head 不进图。模型
tie_word_embeddings=true,二者共享一张[283386, 2560]fp16 词表,以原始二进制存储;runtime 直接查表(embedding)、以 final_norm 后逐行点积取 argmax(lm_head)。此举省去约 1.45GB 的词表大图,且 greedy 解码本就只需 argmax。7. 并行 prefill 与定长窗口 decode
prompt 右填充至 512 后逐层并行 prefill。KV cache 采用定长窗口而非动态裁剪:K cache 恒为
[32, 512, 192]、V cache 恒为[32, 512, 128],以加性掩码标记有效前缀(位置 ≤ t 处为 0,其余为 -1e9)。全部张量形状静态,对 ncnn 最友好;prompt padding 由因果掩码自然屏蔽——只取真实末位的输出,该位置在因果掩码下看不到任何 padding,无需显式裁剪 cache;当前 token 的 K/V 由 runtime 直接写入 cache 槽位,图内不出现小张量拼接。8. 逐 token decode 与解码
prefill 得到首 logits 后 runtime 取 greedy argmax,之后每轮只处理新 token:embedding → 40 层(dqkv 写 cache、dattn 读 cache)→ final RMSNorm → lm_head argmax。达到
max_new_tokens或遇到 EOS(128001)时停止,最后由 tokenizer 把 token ids 解码回 UTF-8。9. 构建与运行
CMake 支持 Linux 与 Windows。MinGW-w64 GCC 与 Linux 同源,一次通过。Windows 下中文 prompt 经 argv 传入时为系统区域编码,须经
GetCommandLineW取 UTF-16 转 UTF-8,并SetConsoleOutputCP(CP_UTF8),否则输出乱码。WSL 下编译 ncnn 时构建目录须置于 ext4(如~/)而非 NTFS,否则 CMakeconfigure_file报权限错误;且 WSL 默认内存有限,ncnn 的 x86 AVX512 kernel 单个编译单元即占 1~2GB,-j$(nproc)必然 OOM,需按可用内存推算并行度并关闭 AVX512 相关选项。实现难点
以下为移植中最具代表性的若干问题,每一项均已复现并修复。
1. pnnx 无法稳定转换完整多模态模型
原始模型包含 Hugging Face remote code、动态 shape、FlashAttention、窗口注意力与 KV cache,直接整体转换会生成 pnnx 无法稳定解析或 ncnn 无法正确执行的图。解决办法是按计算边界拆分为 149 张子图,动态部分全部上提到 runtime。拆图的另一收益是每张子图可独立与参考比对,使"整链错误"能被二分定位到具体层与算子。
此外,不应直接 trace HF 层——其内部的 dtype 分支与控制流会使 pnnx 解析失败。本实现改用纯
nn.Linear与自写 RMSNorm 重建数学等价的层,从 HF 逐名拷贝权重,并以随机输入对 HF 原层自检后放行(视觉层相对误差 2.8e-07,MLA 层 6.8e-08)。该自检不可省略,尤其对 MLA 这类投影路径复杂的结构。2. rope_interleave 的双重融合风险
YoutuLLM 的
rope_interleave=True,其 RoPE 在标准 rotate-half 之前多一步反交错重排:两个环节都可能被 pnnx 融合成 ncnn 的
RotaryEmbed,而该算子的轴语义与此处的交错布局并不匹配。注意到反交错与 rotate-half 都是固定的 64×64 置换/±1 矩阵,可合并为两个常量矩阵P与PR = P @ R,改写为:pnnx 只看到两次普通 matmul,无从融合。导出后静态审查确认图中留下 4 个
MatMul(q/k 各两次)与 2 个MemoryData(P/PR 常量),符合预期。3. pnnx 的其它自动融合与 HF 语义失配
除 RoPE 外,还需防范:注意力的
scale+mask+softmax+matmul会被融合成SDPA,语义失配且部分平台 kernel 崩溃,改为手工展开 softmax(amax→sub→exp→sum→div);无 batch 维的三维张量会使 pnnx 批轴推断混乱,全程保留显式 batch=1、按[1,H,N,D]布局组织。导出后另需对 param 做两处后处理:重名图层(嵌套子模块产生的同名
splitncnn)加后缀去重;为被非 Split 层直接消费的网络输入插入 Split 隔离层,否则 inplace 层直连输入会运行时崩溃。本项目 149 张图每张均需此后处理,已集成进导出脚本。4. ncnn 在小尺寸张量上越界
ncnn 的 RMSNorm 与 UnaryOp 在 h 小于 SIMD 打包宽度时越界崩溃,而 decode 的查询长度天然为 1。本实现将 decode 图按查询长度 8 而非 1 导出:runtime 输入 8 份复制的当前 token embedding,仅取输出第 0 行,cache 写入亦取第 0 行。decode 图单 token 计算量极小,8 倍冗余可忽略。此法可一般化为——框架在小张量上有缺陷时,把张量填充回已验证的形状区间,比修改框架或替换算子更稳妥。
5. 图优化工具会破坏隔离层
为提速曾尝试用
ncnnoptimize将 decode 图转为 fp16 存储。转换本身成功(16.6GB → 7.8GB),但其eliminate_split优化会把上文提到的输入隔离 Split 一并消除,并连带把 Input 层的输出 blob 改名(in0→in0_iso0)。结果是先出现find_blob_index_by_name in0 failed,绕过命名问题后又立刻触发0xC0000005访问违例——正是隔离层被移除后 inplace 层直连输入的经典症状。两处修正:对优化后的 param 重新执行一次隔离 Split 插入;runtime 侧不再依赖 blob 名,改为在加载时通过
net.input_names()取得图的真实输入名并按声明顺序对号入座——图经过任何优化后 blob 名都可能变化,但输入顺序不会。调试方法
移植中期主要瓶颈由编码转为定位。除沿用 param 静态审查(导出后即以文本检查残留融合算子、重名图层、直连输入的 inplace 层)外,本项目最见效的方法是先切问题域、再二分:遇到"模块单测全过但整链失败"时,不逐层猜测,而是先构造两个对照——纯 PyTorch 跑同样的 glue 逻辑,以及 ncnn 与 PyTorch 在同一输入下的单模块比对。两个对照的结果组合可直接判定问题出在 glue 复刻、图保真度、还是验证代码自身,再在确定的域内二分。
另一条纪律是先在 Python 中以受限原语写原型:tokenizer 与图片预处理这类需要逐位复刻的模块,若直接在 C++ 中调试,正则与定点算术的排查成本极高。改为先在 Python 中只使用"C++ 可等价实现的原语"(手写字符分类而非正则库、显式循环而非向量化)写出原型并对参考验证通过,再逐行移植——两个模块的 C++ 版本均一次通过。
已知限制
YoutuDensePrediction后处理,本移植未覆盖。完整代码、导出脚本、逐级验证脚本与从零复现步骤见代码仓库。欢迎指正与讨论。
All reactions