我们的量化交付包只用了这个模型的前 11 层。这一页把完整结构拆开讲清楚:每一层是什么、 多出来的 neck 与 Pose 头在做什么、代价怎么分布,以及这些结构和我们后续几轮接口决策的关系。
24 层,三段式:backbone(0–10) 抽特征 → neck(11–22) 多尺度融合 → Pose 头(23) 出框和关键点。我们只要第一段。
虚线是我们的抽头;实线是模型自己的数据流。注意 neck 的四条跨层连接(6→12、4→15、13→18、10→21), 它们正是「融合」的实现方式,也是为什么 neck 不能脱离 backbone 单独存在。
五次 stride-2 下采样把 640 压到 20×20,通道从 3 升到 256。n 档的缩放是
depth 0.50 / width 0.25 / max_channels 1024 —— 所以 yaml 里写 [512] 的那层实际只有 128 通道。
| 模块 | 出现在 | 一句话 |
|---|---|---|
| Conv | 0,1,3,5,7 | 3×3 stride-2 卷积 + BN + SiLU,每出现一次分辨率减半 |
| C3k2 | 2,4,6,8 | CSP 结构,特征劈两半、一半过 Bottleneck 再合并 |
| SPPF | 9 | 三次串联 5×5 maxpool,廉价地把感受野撑到全图 |
| C2PSA | 10 | backbone 里唯一的注意力,2 头自注意力 + FFN |
每个模块的内部结构、实际参数与量化视角见下面的 特殊层与特殊算子。
标准 PAFPN,两趟走完:先自顶向下把深层语义送到浅层,再自底向上把细节送回深层。
Upsample 和 Concat 零参数,真正花钱的是 4 个 C3k2 和 2 个下采样 Conv。
| 方向 | 路径 | 产出 | 参数 |
|---|---|---|---|
| 自顶向下 | 10 →Upsample→ 拼 6 → C3k2(13) →Upsample→ 拼 4 → C3k2(16) | P3/8 64ch 80×80 — 小目标 | 143,392 |
| 自底向上 | 16 →Conv s2→ 拼 13 → C3k2(19) | P4/16 128ch 40×40 — 中目标 | 123,712 |
| 自底向上 | 19 →Conv s2→ 拼 10 → C3k2(22) | P5/32 256ch 20×20 — 大目标 | 526,592 |
neck 的输出(16/19/22)仍然是带空间维度的特征图,这一点很关键:它是所有"要知道东西在哪"的头的输入。
我们项目里的 bowling_subject_head 就是在这三张图上做 RoIAlign。
在 P3/P4/P5 三个尺度上各挂三条并行分支(解耦头),所有尺度的结果拼成一个张量。
| 分支 | 输出通道 | 作用 | 参数 |
|---|---|---|---|
| cv2 | 64 = 16×4 | 框回归,DFL 形式:每条边预测 16 个 bin 的分布,再取期望得到距离 | 381,888 |
| cv3 | 1 | 类别分数。nc=1,只有 person | 48,963 |
| cv4 | 51 = 17×3 | COCO-17 关键点的 (x, y, visible) | 284,427 |
YOLO11 里真正需要解释的结构就这几个。每个都附上量化视角 —— 它在定点后端上是便宜还是贵、我们实测到什么。
卷积 + BatchNorm + SiLU。SiLU(x) = x · sigmoid(x),
比 ReLU 平滑、负半轴不完全截断,但代价是它在计算图里是 Sigmoid + Mul 两个算子,不是一个激活函数。
*/act/Mul_output_0 张量。CSP 思路:1×1 卷积把特征劈两半,一半走 Bottleneck、一半直连, 末尾 concat 再 1×1 融合。好处是梯度路径短、重复特征少,参数比堆卷积省。
n 档里两种内部结构并存 —— 这点容易看漏:
| 层 | 内部 | 深度 |
|---|---|---|
| layer 2 / 4 | C3k=False → 普通 Bottleneck | 1 个,带 shortcut |
| layer 6 / 8 | C3k=True → C3k 块 | C3k 里再套 2 个 Bottleneck |
所以越深的 C3k2 越"厚",这也是 layer 8 有 346k 参数、 layer 4 只有 26k 的原因。
一个 5×5 stride=1 的 maxpool 串联跑三次,把四份结果 concat:
串两次等价于 9×9、串三次等价于 13×13。相比并联三个不同核的池化,串联把计算量压到几乎不变,
这就是名字里那个 F(Fast)。参数只有两个 1×1 卷积(256→128 和 512→256)。
YOLO11 相对 v8 的主要结构改动。在 20×20 的最深层上做 2 头自注意力 + FFN,给纯卷积的 backbone 补一层全局上下文。实际参数:
QK^T 是全图动态范围最大的张量之一
(实测 −75.7 ~ +117.2,跨度 193)。softmax 输出则被钉在 [−1, 1]。
这两个算子当初都被列为"可能回退 CPU",实测全落 NPU。最近邻上采样 + 通道维拼接,不含任何权重,却是 PAFPN "把深层语义和浅层细节混起来"的全部机制。计算上是纯访存。
Distribution Focal Loss 的推理侧实现。每条边不直接回归一个距离, 而是预测 16 个 bin 上的概率分布,再取期望得到距离:
距离 = Σ softmax(logits[0..15])ᵢ × i # i = 0,1,2,…,15
图里它是一个权重固定为 0,1,2,…,15 的 1×1 卷积(实测确认),
所以"求期望"这步就是一次卷积。好处是模型能表达"边界在这附近但不确定",比单点回归稳。
这也是 cv2 分支输出 64 通道的原因:4 条边 × 16 个 bin。
实测自 C 臂(backbone + neck + Pose 头)的 w8a16 QDQ 图。 「每档」= 跨度 ÷ 量化档位数,直接反映该张量能分辨多细。
| 张量 | 实测范围 | 跨度 | int16 每档 | int8 每档 |
|---|---|---|---|---|
| DFL 解码后的框(像素坐标) | −40.8 ~ +724.4 | 765 | 0.012 px | 3.0 px |
| 注意力 QKT(C2PSA) | −75.7 ~ +117.2 | 193 | 0.003 | 0.76 |
| 注意力 ×V 之后 | −9.3 ~ +8.7 | 18.0 | 0.0003 | 0.071 |
| SPPF maxpool 输出 | −7.4 ~ +10.6 | 18.0 | 0.0003 | 0.071 |
| 注意力 softmax | −1.0 ~ +1.0 | 2.0 | 0.00003 | 0.008 |
| 类别分数 sigmoid | 0.0 ~ +1.0 | 1.0 | 0.00002 | 0.004 |
这张图解释了为什么截断能拿到 2.3× 而不是 1.9×:省掉的那一半,算力占比比参数占比更大。
| 段 | 参数 | 占比 | GFLOPs | 占比 |
|---|
蓝色高亮行是我们抽特征的四层。from 列里出现列表的就是跨层连接 —— 这些层无法脱离前面的层单独存在。
| # | 模块 | from | 输出 | stride | 参数 | GFLOPs |
|---|
打分头和门控头回答的是"这一帧整体像不像一个好动作"——全局语义判断,不需要知道人在哪里。 所以我们在层 4/6/8/10 抽特征、池化成 1536 维,neck 和 Pose 头算完就扔。
| 位置 | 来源 | 特征图 | avg | max |
|---|---|---|---|---|
| 0–255 | 层 4(P3) | 128×80×80 | 128 | 128 |
| 256–511 | 层 6(P4) | 128×40×40 | 128 | 128 |
| 512–1023 | 层 8(P5) | 256×20×20 | 256 | 256 |
| 1024–1535 | 层 10(C2PSA) | 256×20×20 | 256 | 256 |
原来用 forward hook 取中间层,但 hook 不进 traced graph ——
导出 ONNX 时图会止于 Pose 头,抽头根本不存在。任何量化/端侧部署都必须从一个显式的单路径 module 开始,
这就是 PoseTrunk 的由来。截断后数值逐位一致(max|Δ| = 0.0)。
默认 pool=True:图内池化,每帧只搬 1536 个数。
pool=False 导出 4 张原始特征图(约 123 万个数,800× 的搬运量),给将来需要定位的头留路 ——
但端上 DMA 代价还没实测。
这段结构上的取舍,直接决定了交付包三个版本的接口长相。
HTP 的激活是 per-tensor 量化,而这 8 个 block 的幅度差十几倍(avg 块 ≲1,max 块到 ~7)。
头的 ONNX 去掉了 Sigmoid 节点,sigmoid 交给算法 pipeline。 动机是 sigmoid 饱和会压平差异(14% 样本 float32 下恰好 ==1.0,反推不回去);代价是产品阈值是概率, 拿 logit 直接比不报错、只是全错。哪些环节能留在 logit 空间,是实测出来的:
| 环节 | 能否留在 logit 空间 | 依据 |
|---|---|---|
| 门控:中值滤波 → 迟滞 → 阈值 | ✅ 可以,逐位等价 阈值 ±0.4055 |
中值与迟滞只依赖大小关系、sigmoid 单调;2 万点随机检验 valid 完全一致 |
| raw 的滑动平均(显示/排序) | ❌ 必须先 sigmoid | mean(sigmoid) ≠ sigmoid(mean),实测最大差 0.39 |
soft 模式 raw × gate | ❌ 必须先 sigmoid | 乘法在 logit 空间没有对应 |
letterbox 居中填 114,与原版 ultralytics/yolo 约定一致。 改成 0 的代价实测是整个量化误差的 5.2 倍(raw mean|Δ| 0.05975 vs 0.01143,100 张里 19 张 [email protected] 判定翻转)—— 因为 backbone 在 114 上预训练、头在 114 的特征分布上拟合 mu/sd、校准集也全是 114。 16:9 广播素材约 44% 画面是填充,最受影响。
bowling_subject_head:在 neck 的 16/19/22 上 RoIAlign,框来自 Pose 头。
连 maps 变体都不够 —— 它要的不只是特征图,还要检测结果。cv4 那条分支。难点不在 Conv。DFL 解码、anchor 网格、NMS 都不是标准卷积算子, 落 NPU 的情况要单独验证;backbone 那段已验证零回退,不能直接外推到头上。
bowling_moment_head 换到量化 backbone 的特征源(它吃的正是这 1536 维)以现交付的 backbone 段为基线,在同一台端侧设备(Samsung Galaxy S23 / SM8550 / hexagon v73)上 把切点往后推两级,量三件事:延迟、算子落位、每帧要搬多少数据。 三个臂用同一份校准集、同样 W8A16、同样编译 runtime,唯一变量是图切到哪里。
每个臂都编译两遍:默认(I/O 为 fp32)与 --quantize_io(I/O 也量化)。
后者顺带回答了挂了很久的「量化 I/O」待办。
按 int16 口径比较每帧的输出搬运量。注意输入侧:640×640×3 的输入张量本身就有 1,228,800 个值, fp32 下 4.92 MB/帧 —— 对 backbone 臂来说,输入搬运量是输出的 800 倍,真正的瓶颈在入口不在出口。
A 臂那条几乎看不见 —— 这就是真实量级:1536 个值对上 70 万个。输入行是三个臂共同的入口成本。
AI Hub 只给推理时间,不单列 DMA。下表按 字节 ÷ 有效带宽 估算,
列出几档有效带宽 —— SM8550 的 LPDDR5X 标称峰值约 67 GB/s,单个客户端实际能吃到的通常是峰值的
三到五成,所以 20 GB/s 一档最接近现实,5 GB/s 是悲观下界。
同一批真实图上,QDQ 与 FP32 的相对 L2。越靠后的输出,误差越大 —— 这是本轮的一个关键观察。
59 张有检测的图上(FP32 conf≥0.25):
| 口径 | 量化 vs FP32 | 读法 |
|---|---|---|
| top-1 置信度 |Δ| | 0.0043(最大 0.0136) | 判"有没有人"完全一致:59/59 |
| 同一 anchor 上框坐标最大 |Δ| | 1.71 px(最大 4.79) | 640×640 画布 —— 量化本身很温和 |
| 同一 anchor 上关键点平均位移 | 1.35 px(最大 2.70) | 同上 |
| argmax anchor 翻转 | 24/59(41%) | 相邻 anchor 置信度接近时的选择抖动 |
| 各自 top-1 框的 IoU | 均值 0.902,92% > 0.9 | 翻转多数无害,但有个别 IoU=0(选到了另一个人) |
bowling_subject_head 恰恰在意选中的是哪个人,这条要单独验。B / C 臂只给空间特征或框,现有的打分头与门控头就没得吃了。 解法是在同一张图里,把 backbone 的 layer 4/6/8/10 再做一次全局 avg/max 池化引出来 —— 输出多 1536 个值(3 KB),现有两个头不改代码继续用。
out = [y[i] for i in self.outputs] # p3/p4/p5 或 (1,56,8400) for i in (4, 6, 8, 10): # 抽头,顺序仍按 ORDER 按层交错 out.append(y[i].mean((2, 3))) out.append(y[i].amax((2, 3)))
| 臂(均 uint16 I/O) | 延迟 | Δ | 算子落位 | 输出/帧 @int16 |
|---|---|---|---|---|
| B · backbone + neck | 3.992 ms | — | NPU 256 / CPU 0 | 1.43 MB |
| B · backbone + neck + 8 路抽头 | 4.120 ms | +3.2% | NPU 264 / CPU 0 | 1.44 MB |
| C · backbone + neck + Pose 头 | 5.769 ms | — | NPU 376 / CPU 0 | 0.94 MB |
| C · backbone + neck + Pose 头 + 8 路抽头 | 5.900 ms | +2.3% | NPU 384 / CPU 0 | 0.94 MB |
多出来的正好是 8 个池化算子,全部落 NPU、零回退。
量化编码是逐图标定的 —— 同一条 backbone 放进更大的图,
池化分支的编码未必还与现交付的 A 臂一致。若不一致,两个头的 mu/sd 就读错了坐标系。
60 张真实图实测:
| 口径 | B+抽头 / C+抽头 vs A 臂 | 放进量级看 |
|---|---|---|
| 抽头特征相对 L2 | 0.00112(最大 0.00149) | 量化误差本身是 0.027 → 占 4% |
| 交付头读出的 raw |Δ| | 0.00032(最大 0.00132) | 量化本身 0.0114 → 占 3%;比端上实现差异 0.0019 还小 |
| gate |Δ| | 0.00000(最大 0.00002) | 门控判定不受影响 |
| raw 排序相关 ρ | 0.999944 | 排序几乎完全一致 |
mu/sd 不用重拟合,现有两个头直接搬过去即可。
B 与 C 两臂的抽头数值完全一致,说明 backbone 子图在两种切法下量化结果相同,差异只来自「图变大了」本身。现在 bowling_subject_head 那条链路要在 host 上再跑一遍完整模型。
合进一张图之后,一次前向同时给出:打分/门控用的 1536 维、人框与关键点(C)或 RoIAlign 用的特征图(B)。
v2 踩过的坑是:真量化器给 concat 选截断式共享 range,把幅度大的输入整块削掉。 neck 有 17 个 concat,这次逐个查了编码。
| Concat | 各输入跨度 | 幅度比 | 结论 |
|---|---|---|---|
| /m.12(拼 backbone P4) | 9.52 / 10.49 | 1.10× | 输出范围覆盖输入并集,无截断 |
| /m.15(拼 backbone P3) | 7.23 / 7.57 | 1.05× | |
| /m.18(拼 neck 13) | 13.26 / 7.23 | 1.83× | |
| /m.21(拼 backbone P5) | 9.52 / 9.52 | 1.00× | |
| 全部 17 个 concat:幅度比中位 1.05×、最大 1.83× | |||
| 对照:v2 失败的那个 | 1.10 … 14.08 | 12.8× | max 分支被整块截断,ρ 掉 0.027 |
机制说清楚了:截断只在输入幅度严重失配时发生。 neck 的 concat 拼的都是同类卷积激活、幅度相近,所以安全;我们当初那个 concat 拼的是 avg 池化(≲1)和 max 池化(~7–14), 差一个数量级,才被共享 range 削掉。
此前担心 DFL 解码、anchor 网格这些非标准卷积算子会回退 CPU。 实测 378 个算子全部落 NPU、零回退。注意 NMS 不在图里,仍在 host 侧。
A100 上加 neck 是 +67% 墙钟(访存密集、launch-bound), 端侧只有 +26%;加 Pose 头 GPU 上 +124%,端侧 +67%。 GPU 的相对代价不能外推到 NPU —— 这类大图小通道的层,HTP 的访存结构明显更合适。
B 臂(输出 1.43 MB)省 0.44 ms(−10%),峰值内存从 13.76 MB 掉到 2.02 MB; C 臂几乎持平;A 臂输出只有 3 KB,反而慢了 0.16 ms(换算开销大于收益)。 输出越大,量化 I/O 越值。顺带解掉了挂了很久的「量化 I/O」待办:`--quantize_io` 产出的是 uint16 I/O。
backbone 池化特征 0.027 → neck 的 P3/P4/P5 是 0.060/0.088/0.107。 但任务级看 Pose 头很稳(同 anchor 框差 1.7px)。结论取决于下游怎么用这些张量, 不能只看相对 L2。
B/C 臂再引一组 8 路池化抽头:+0.13 ms、+3 KB、+8 个算子全在 NPU, 且抽头与现交付 A 臂的偏差只有量化误差的 3–4% —— 现有两个任务头不用重训、不改代码。