读 ncnn 源码(XIV):convert_layout——层间数据格式的“翻译官”

上一篇中,我们剖析了 Extractor::extract 如何通过 forward_layer 的递归调用,实现了惰性求值和推理流程的调度。我们看到,在真正执行每一层的计算(do_forward_layer)之前,有一个关键的准备步骤。本篇,我们将聚焦于这个步骤的核心函数——NetPrivate::convert_layout,以及它如何与具体的层实现(以 Convolution_x86_fma::forward 为例)协同工作,确保数据在层与层之间流畅、高效地传递。

convert_layout 就像一位专业的“翻译官”,负责将上一层输出的“语言”(数据格式)转换成当前层最“听得懂”、处理效率最高的“语言”。

TL;DR

  1. convert_layout 的职责: 在 do_forward_layer 调用具体层的 forward 方法之前被执行。其核心任务是适配数据格式,确保输入 bottom_blob精度(Precision)和内存布局(Layout/Packing)符合当前 layer 的最佳执行要求。
  2. 双向精度转换:
    • 降精度: 如果 bottom_blob 是 FP32,且 opt 开启了低精度存储(如 FP16/BF16),并且目标 layer 支持在该低精度下计算 (layer->support_fp16_storage),则调用 cast_float32_to_float16/bfloat16bottom_blob 向下转换
    • 升精度: 如果经过布局转换后,bottom_blob 处于低精度,但目标 layer 不支持在该低精度下计算 (!layer->support_fp16_storage),则调用 cast_float16/bfloat16_to_float32bottom_blob 向上转换回 FP32。
  3. 动态布局转换 (Packing):
    • 根据 opt.use_packing_layout、硬件 SIMD 能力(AVX512/AVX/SSE/RVV/NEON)、数据维度 elemcount 以及 layer->support_packing,计算出目标层期望的 dst_elempack
    • 如果 bottom_blob.elempack != dst_elempack,则调用 convert_packing 函数执行内存布局的转换(Plain <-> Packed)。
  4. 原地修改: convert_layout 通过直接修改传入的 bottom_blob 引用 (bottom_blob = ..._converted) 来生效,后续的 layer->forward 将直接使用转换后的数据。
  5. Convolution_x86_fma::forward 的假设: 此函数作为具体层的实现,它假设输入的 bottom_blob 已经被 convert_layout 处理过,达到了它期望的格式。因此,它内部的重点是计算逻辑的调度:Padding -> 输出分配 -> 算法选择 (Winograd/GEMM/Direct Conv) -> 执行计算 -> Activation。它自身不再负责通用的格式转换。


1. NetPrivate::convert_layout:数据格式的适配桥梁

convert_layoutdo_forward_layer 中扮演着承上启下的关键角色。它确保了无论上一层输出何种格式的 Mat,都能被平滑地转换成当前层最高效处理的格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
int NetPrivate::convert_layout(Mat& bottom_blob, const Layer* layer, const Option& opt) const
{
// --- 阶段一:尝试将 FP32 输入降精度 ---
if (bottom_blob.elembits() == 32) // 仅当输入是 FP32 时考虑降精度
{
// 根据硬件能力、用户选项和层支持情况,选择性转换为 FP16 或 BF16
#if NCNN_ARM82 || NCNN_VFPV4 || NCNN_ZFH // FP16 on ARM/RISC-V
if (opt.use_fp16_storage && /* hardware support */ && layer->support_fp16_storage)
{
Mat bottom_blob_fp16;
cast_float32_to_float16(bottom_blob, bottom_blob_fp16, opt);
bottom_blob = bottom_blob_fp16; // 原地更新 bottom_blob
}
#endif
#if NCNN_BF16 // BF16 支持
else if (opt.use_bf16_storage && layer->support_bf16_storage)
{
Mat bottom_blob_bf16;
cast_float32_to_bfloat16(bottom_blob, bottom_blob_bf16, opt);
bottom_blob = bottom_blob_bf16; // 原地更新 bottom_blob
}
#endif
// ... 错误检查 ...
}

// --- 阶段二:进行内存布局 (Packing) 转换 ---
int dst_elempack = 1; // 默认为 Plain Layout (elempack=1)
if (opt.use_packing_layout) // 如果用户开启了 Packing 优化
{
// 计算当前 blob 的元素数量 (根据维度决定是 c, h, 还是 w)
int elemcount = 0;
// ... calculate elemcount based on bottom_blob.dims ...

int elembits = bottom_blob.elembits(); // 获取当前数据位宽

if (layer->support_packing) // 如果当前层支持 Packing
{
// 根据位宽、硬件能力和元素数量,决定目标 packing size
if (elembits == 32) {
#if NCNN_AVX512 // 优先选择 AVX512 的 pack16
if (elemcount % 16 == 0 && cpu_support_x86_avx512()) dst_elempack = 16;
#elif NCNN_AVX // 其次 AVX 的 pack8
else if (elemcount % 8 == 0 && cpu_support_x86_avx()) dst_elempack = 8;
#endif // ... 其他平台如 RVV ...
else if (elemcount % 4 == 0) dst_elempack = 4; // 基础 pack4
}
if (elembits == 16) { // FP16/BF16 通常 pack8 或 pack4
#if NCNN_ARM82
if (elemcount % 8 == 0 && /* fp16 arithmetic enabled */) dst_elempack = 8;
#endif // ... 其他平台 ...
else if (elemcount % 4 == 0) dst_elempack = 4;
}
// ... INT8 packing ...
}
}

// 如果当前 packing 与目标 packing 不符,执行转换
if (bottom_blob.elempack != dst_elempack)
{
Mat bottom_blob_packed;
convert_packing(bottom_blob, bottom_blob_packed, dst_elempack, opt);
bottom_blob = bottom_blob_packed; // 原地更新 bottom_blob
// ... 错误检查 ...
}

// --- 阶段三:检查低精度数据是否需要升回 FP32 ---
if (bottom_blob.elembits() == 16) // 如果当前是 FP16/BF16
{
// 如果当前层不支持低精度计算,则必须转回 FP32
#if NCNN_ARM82 || NCNN_VFPV4 || NCNN_ZFH
if (opt.use_fp16_storage && /* hardware support */ && !layer->support_fp16_storage)
{
Mat bottom_blob_fp32;
cast_float16_to_float32(bottom_blob, bottom_blob_fp32, opt);
bottom_blob = bottom_blob_fp32; // 原地更新 bottom_blob
}
#endif
#if NCNN_BF16
else if (opt.use_bf16_storage && !layer->support_bf16_storage)
{
Mat bottom_blob_fp32;
cast_bfloat16_to_float32(bottom_blob, bottom_blob_fp32, opt);
bottom_blob = bottom_blob_fp32; // 原地更新 bottom_blob
}
#endif
// ... 错误检查 ...
}

return 0; // 转换成功
}

核心逻辑解析:

  • 双向精度适配: 它既能根据 optlayer 的支持情况尝试将 FP32 输入降维以节省带宽和计算量,也能在必要时(当前层不支持低精度)将低精度数据恢复为 FP32,保证计算的正确性。
  • 动态 Packing 决策: dst_elempack 的计算是一个复杂的决策过程,它综合考虑了用户选项、硬件能力、数据本身的属性(elemcount 能否被整除)以及层的偏好 (layer->support_packing)。
  • 原地修改 bottom_blob: 这是关键。所有转换操作的结果都直接赋值回 bottom_blob 引用,使得 do_forward_layer 中后续的 layer->forward(bottom_blob, ...) 调用自然地使用了转换后的数据。

2. Convolution_x86_fma::forward:计算引擎的专注

作为具体卷积层的实现,forward 函数得以“专注”于核心的卷积计算逻辑,因为它假定输入的 bottom_blob 已经由 convert_layout “翻译”成了它最喜欢的格式(例如,可能是 Packed FP32)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
int Convolution_x86_fma::forward(const Mat& bottom_blob, Mat& top_blob, const Option& opt) const
{
// ... INT8/Flattened Input 特殊处理 ...

// 1. Padding: 根据层的参数对输入进行补边
Mat bottom_blob_bordered;
make_padding(bottom_blob, bottom_blob_bordered, opt);
// ... 错误检查 ...

// 2. Output Allocation: 计算输出尺寸,确定输出 packing,创建 top_blob
int outw = ...; int outh = ...;
int out_elempack = 1;
// ... determine out_elempack based on opt, num_output, hardware ...
size_t out_elemsize = elemsize / elempack * out_elempack;
top_blob.create(outw, outh, num_output / out_elempack, out_elemsize, out_elempack, opt.blob_allocator);
// ... 错误检查 ...

// 3. Algorithm Re-check & Dispatch:
// (这里的检查逻辑与 create_pipeline 类似,可能用于处理动态性或作为 fallback)
if (/* Dilation 条件 */) { return forwardDilation_x86(...); }
if (/* Winograd 条件 */) {
// ... 根据 prefer_winograd{23,43,63} 和 weight_data.empty() 判断 ...
if (prefer_winograd23) return conv3x3s1_winograd23(...);
// ... else if ...
}
if (/* GEMM 条件 */) {
return convolution_im2col_gemm(...);
}

// 4. Direct Convolution Dispatch:
// 检查是否匹配特定的、手写的 SSE/AVX 直接卷积核
#if __SSE2__
if (elempack == 1 && out_elempack == 4 && /* 3x3s1 */) {
conv3x3s1_pack1to4_sse(...); return 0;
}
// ... 其他 SSE/AVX 优化路径 ...
#endif

// 5. Generic Packed Convolution Fallback:
// 如果没有匹配到特殊的优化核,调用通用的 packed 卷积实现
convolution_packed(bottom_blob_bordered, top_blob, weight_data_tm, bias_data, ...);

return 0;
}

关键点解析:

  • 无格式转换: forward 函数内部不再关心输入的 bottom_blob 最初是什么格式,它直接按照当前(可能已被 convert_layout 修改过的)elempackelembits 来执行操作。
  • 计算为中心: 它的主要工作是调度计算:准备数据(Padding)、分配输出、选择并执行最合适的卷积算法(Winograd, GEMM, 各种 Packed Direct Conv)。
  • 算法再确认: 虽然 create_pipeline 已经预处理了权重,但 forward 内部似乎仍会再次检查算法适用条件。这可能是为了处理动态输入尺寸带来的变化,或是作为一种健壮性设计(例如,如果预计算的 Winograd 权重意外丢失)。

3. 协同工作:明确的分工

convert_layoutLayer::forward 的关系是一种清晰的分工:

  • convert_layout (翻译官): 负责层间的数据格式适配。它关注的是输入端,确保数据以最佳形式进入层。
  • Layer::forward (工程师): 负责层内的计算执行。它关注的是计算过程,假设输入格式已经就绪。

这种分离使得:

  • 层实现的简洁性: forward 函数可以专注于核心算法,不必为各种可能的输入格式编写复杂的处理逻辑。
  • 优化的集中管理: 数据格式转换的逻辑集中在 convert_layout 中,便于统一维护和针对不同平台进行优化。
  • 灵活性: 允许网络中的不同层采用不同的最优格式(例如,某些层可能更适合 FP16,而另一些层必须使用 FP32),convert_layout 会在它们之间进行必要的“翻译”。

4. 结语

NetPrivate::convert_layout 是 ncnn 推理流程中一个看似不起眼但至关重要的“适配器层”。它通过智能地进行精度和内存布局转换,确保了每一层都能在其最优的数据格式下运行,极大地提升了灵活性和计算效率。它与具体层 forward 函数的明确分工,共同构成了 ncnn 高性能、跨平台推理引擎的坚实基础。理解 convert_layout 的工作机制,有助于我们更深入地把握 ncnn 如何在复杂的网络结构和多变的硬件环境中保持高效运转。

该封面图片由XINGCHEN XIAOPixabay上发布