QNN HTP OpPackage Softmax 源码分析
这篇为对照源码/qairt/2.40.0.251030/examples/QNN/OpPackage/HTP/ExampleOpPackageSoftmax.cpp 学习QNN如何自定义Softmax算子。它涵盖了:
Softmax 在 HTP OpPackage 里的注册方式
Softmax 参数顺序
Softmax -> Softmax_fp 的图改写规则
- 普通 reference 版本
- FP16 + HVX 近似版本
1. 源码分析
1.1 头文件
源码开头包含了几类 HTP core 头文件:
1
2
3
4
5
|
#include "HTP/core/constraints.h"
#include "HTP/core/op_package_feature_support.h"
#include "HTP/core/op_register_ext.h"
#include "HTP/core/optimize.h"
#include "HTP/core/simple_reg.h"
|
可以按职责理解:
| 头文件 |
作用 |
constraints.h |
给优化规则写匹配条件,例如 dtype、scalar、常量值 |
op_package_feature_support.h |
OpPackage 特性支持相关定义 |
op_register_ext.h |
注册自定义 op 的宏 |
optimize.h |
图优化和 pattern rewrite 的宏 |
simple_reg.h |
简化注册接口 |
这些宏不是普通 C++ 函数调用风格,而是 HTP backend 给 OpPackage 暴露的一套 DSL。
1.2 算子定义
1.2.1 算子定义
源码第 27 行:
1
|
BEGIN_PKG_OP_DEFINITION(PKG_Softmax);
|
这行给当前源文件里的 op 定义起内部名字 PKG_Softmax。它后面要和 ExampleOpPackageInterface.cpp 里的:
1
|
DECLARE_PKG_OPS_OPTS_LIST(PKG_Softmax)
|
对应起来。即Softmax.cpp 里声明了包内容,Interface.cpp 把这个包内容挂到最终 OpPackage 接口里。
源码第 29 行:
1
|
DEF_PACKAGE_PARAM_ORDER("Softmax", "beta", false, nullptr, "axis", false, nullptr)
|
定义了 Softmax 的参数顺序。它说明 Softmax 最多关心两个参数:
Interface.cpp 的 validateOpConfig() 对应这个约定:Softmax 允许 numOfParams <= 2。
1.2.2 函数声明
源码第 32 到 36 行先声明两个实现:
1
2
3
4
5
|
template <typename T_Ttype>
int softmaxWithbetaWrapper(T_Ttype &out, const T_Ttype &in, const Tensor &beta);
template <typename OutTtype, typename InTtype>
int softmax_fp_impl(OutTtype &out, const InTtype &in, const Tensor &beta);
|
它们代表两条路径:
softmaxWithbetaWrapper():普通模板路径,内部调用 reference 写法。
softmax_fp_impl():FP16 快路径,内部调用 softmax_hf_approx()。
1.2.3 注册 Softmax 算子
源码第 43 到 44 行:
1
2
|
DEF_PACKAGE_OP(softmaxWithbetaWrapper<Tensor>, "Softmax")
DEF_PACKAGE_OP((softmaxWithbetaWrapper<QuantUint8Tensor>), "Softmax")
|
把函数模板实例注册成名为 Softmax 的 op 实现。
源码第 56 到 59 行:
1
2
3
4
|
DEF_PACKAGE_OP_AND_COST_AND_FLAGS((softmaxWithbetaWrapper<PlainFloatTensor>),
"Softmax",
SNAIL,
Flags::RESOURCE_HVX)
|
也注册 Softmax,但多了两个信息:
SNAIL:成本模型里比较慢的实现。
Flags::RESOURCE_HVX:这个实现会占用 HVX 资源。
1.2.4 Tensor 属性
源码第 61 行:
1
|
DEF_TENSOR_PROPERTIES(Op("Softmax", "in", "Beta"), Flat("*"), MainMemory("*"))
|
定义 Softmax 的 tensor 属性:
- 匹配
Softmax(in, Beta) 这种形式。
Flat("*") 表示相关 tensor 使用 flat 形态。
MainMemory("*") 表示相关 tensor 在 main memory。
这里的 * 是 pattern 里的通配符,HTP graph optimizer 会拿这些属性判断某个 op 实现能不能接住当前图里的 tensor。
1.2.5 注册 Softmax_fp 算子
源码第 64 到 72 行注册了两个 Softmax_fp:
1
2
3
4
5
6
7
8
9
|
DEF_PACKAGE_OP_AND_COST_AND_FLAGS((softmax_fp_impl<PlainFloat16Tensor, PlainFloat16Tensor>),
"Softmax_fp",
FAST,
Flags::RESOURCE_HVX)
DEF_PACKAGE_OP_AND_COST_AND_FLAGS((softmax_fp_impl<PlainFloat16Tensor_TCM, PlainFloat16Tensor_TCM>),
"Softmax_fp",
FAST,
Flags::RESOURCE_HVX)
|
这两条都标成 FAST,说明作者希望真正的 float 计算最后走 Softmax_fp。
区别在 tensor 类型:
PlainFloat16Tensor:普通 FP16 tensor。
PlainFloat16Tensor_TCM:放在 TCM 里的 FP16 tensor。
TCM(Tightly Coupled Memory) 是 HTP/DSP 侧更靠近计算单元的片上存储。这里注册 TCM 版本,是为了让 optimizer 在合适的内存布局下能选更快路径。
1.3 算子优化
1.3.1 float32 -> fp16
源码第 77 到 93 行是一条关键优化规则:
1
2
3
4
5
6
|
DEF_PACKAGE_OPTIMIZATION_WITH_FLAGS(
GRAPH_CLEANUP,
relaxed_precision_flag,
Op("Softmax", "In", "Beta", "axis"),
AND(EQ(DTYPE_OF("In"), DType::Float32), EQ(DTYPE_OF("*"), DType::Float32)),
... Op("Softmax_fp", WITH_SIZE("In", Op(FROM_DEFAULT_PACKAGE("Cast"), "In")), "Beta") ...)
|
它做的事情是:
1
2
3
4
|
Softmax(float32 In, Beta, axis)
-> Cast(In -> float16)
-> Softmax_fp(float16, Beta)
-> Cast(output -> float32)
|
触发条件是:
- 输入
In 是 Float32
- 输出也是
Float32
- 启用了
relaxed_precision_flag
所以这里不是无条件把 float32 改成 fp16,而是在精度允许时,把主计算切到 HTP 更友好的 FP16 实现。
1.3.2 折叠前置乘法
源码第 95 到 110 行:
1
2
3
4
5
|
Op("Softmax_fp",
Op(FROM_DEFAULT_PACKAGE("QNN_OP_ELEMENT_WISE_MULTIPLY"),
"In",
Op(FROM_DEFAULT_PACKAGE("QNN_OP_CAST"), OPCONST("MaybeScalarVal"))),
"Beta")
|
它匹配这种图:
1
|
Softmax_fp(Multiply(In, Cast(scalar)), Beta)
|
如果 MaybeScalarVal 是正的 float32 scalar,就改写成:
1
|
Softmax_fp(In, Beta * scalar)
|
写成公式形式就是:
$$
\exp(\beta \cdot x')
$$其输入前面有一个正标量乘法:
$$
x' = s \cdot x
$$那就可以变成:
$$
\exp(\beta \cdot s \cdot x)
$$也就是把 s 合进 beta。
1.3.3 默认参数规整
源码第 112 到 117 行有两条更简单的 rewrite:
1
2
3
4
5
6
|
DEF_PACKAGE_OPTIMIZATION(QNN,
Op("Softmax", "In"),
OK,
Op("Softmax", "In", gen_ConstScalar_f32(1.0f)))
DEF_PACKAGE_OPTIMIZATION(QNN, Op("Softmax", "In", "Beta", "axis"), OK, Op("Softmax", "In", "Beta"))
|
第一条把:
1
|
Softmax(In) -> Softmax(In, 1.0f)
|
第二条把:
1
|
Softmax(In, Beta, axis) -> Softmax(In, Beta)
|
这个例子里的实现实际按最后一维做 softmax,没有使用 axis 参数。
1.3.4 softmax_hf_approx() 总览
源码第 119 行开始是 FP16 快路径核心:
1
|
void softmax_hf_approx(Float16 *pout, const Float16 *pin, float scale, int length)
|
输入含义:
pin:一段 FP16 输入。
pout:一段 FP16 输出。
scale:外部传入的缩放系数,后面由 beta 和 interface_scale() 合成。
length:最后一维长度,也就是 softmax 的归一化长度。
整体流程对应普通 softmax:
1
2
3
4
|
max = reduce_max(x)
y = approx_exp(scale * (x - max))
sum = reduce_sum(y)
out = y / sum
|
只是这里全部手写成 HVX vector intrinsic。
1.4 伪代码
算子真正执行的核心计算是:
1
|
softmax(x) = exp(scale * (x - max(x))) / sum(exp(scale * (x - max(x))))
|
这几段伪代码的调用关系是:
1
2
3
4
5
6
7
|
softmax_fp_impl()
└── 遍历输入 tensor 的 [B, H, W, D] 外层切片[B, H, W]
└── 对每个连续的最后一维 [D] 切片调用 softmax_hf_approx()
├── reduce max 计算最大值
├── approx_exp2() 近似计算 exp 的主体
├── reduce sum 求和
└── 原地归一化输出
|
其中 approx_exp2() 不是源码里的真实函数名,而是为了说明第 184 到 266 行的指数近似逻辑抽出来的概念函数。源码里这些逻辑直接内联写在 softmax_hf_approx() 的主循环中。
1.4.1 softmax_fp_impl() 伪代码
softmax_fp_impl() 本身不直接写 HVX 算法。它负责遍历输入的外层维度,把每个最后一维切片交给 softmax_hf_approx():
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
|
function softmax_fp_impl(out, in, beta_tensor):
# 输出 tensor 的 shape 和输入一致。
out.shape = in.shape
# 示例实现按 4D tensor 处理:[B, H, W, D]。
# D 是最后一维,也是 softmax 的归一化维度。
B, H, W, D = in.shape
# beta 是 softmax 指数缩放参数。
# interface_scale 是输入 tensor 接口层的 scale,需要合进总 scale。
scale = in.interface_scale() * beta_tensor[0]
# 每个 [b, h, w, :] 是一段连续的 D 维 fp16 buffer。
for b in 0 .. B-1:
for h in 0 .. H-1:
for w in 0 .. W-1:
# 当前最后一维切片的输入/输出首地址。
pin = &in[b, h, w, 0]
pout = &out[b, h, w, 0]
# 对这个 D 维切片做 softmax。
softmax_hf_approx(
pout,
pin,
scale,
length = D
)
|
也就是说,这个示例实现里的归一化维度是最后一维 D。
1.4.2 softmax_hf_approx() 总体伪代码
softmax_hf_approx() 是真正的 FP16 + HVX 快路径。忽略向量 intrinsic 的细节,它做的是稳定版 softmax:
1
|
out[i] = exp(scale * (in[i] - max(in))) / sum_j exp(scale * (in[j] - max(in)))
|
伪代码如下:
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
|
function softmax_hf_approx(pout, pin, scale, length):
# 换底:后面用 2^x 近似来实现 e^x。
log2_scale = scale / ln(2)
# 拟合 2^x 的三阶多项式系数,不是标准 Taylor 系数。
c0 = 1.0
c1 = 0.692850309695840
c2 = 0.237504551482093
c3 = 0.046751431261525
# pass 1: 找 max,避免 exp 溢出。
# 源码中每次读 64 个 fp16,先得到 vector 形式的部分最大值。
xmax_vec = fp16_negative_infinity
for block in pin split by 64 fp16 elements:
x = load_fp16_vector(block)
x = mask_tail_if_needed(x)
xmax_vec = vector_max_fp16(xmax_vec, x)
# 把 vector lane 内的最大值继续横向归约成一个标量 max。
xmax = horizontal_max_fp16(xmax_vec)
# pass 2: 计算未归一化 exp,并累加 sum。
# pout 先被当成临时 buffer 保存 exp 近似值。
sum_vec = 0
for block in pin split by 64 fp16 elements:
x = load_fp16_vector(block)
x = mask_tail_if_needed(x)
# 稳定版 softmax 的核心:先减全局 max。
xd = x - xmax
# fp16 扩展成两组 qf32,方便做指数近似。
lo_qf32, hi_qf32 = widen_fp16_to_two_qf32_vectors(xd)
# 进入 base-2 指数域。
z_lo = lo_qf32 * log2_scale
z_hi = hi_qf32 * log2_scale
# 源码第 184 到 266 行内联做了这个近似。
exp_lo = approx_exp2(z_lo)
exp_hi = approx_exp2(z_hi)
# 暂存未归一化 exp 到 pout。
y_fp16 = narrow_qf32_pair_to_fp16(exp_lo, exp_hi)
store_fp16(pout_block, y_fp16)
# 同时累加 softmax 分母。
sum_vec += exp_lo
sum_vec += exp_hi
# 把 vector 形式的部分和横向归约成标量 sum。
sum = horizontal_sum_qf32(sum_vec)
recip = 1.0 / sum
# pass 3: 原地归一化。
# pout 当前保存的是 exp 近似值,乘以 1/sum 后才是最终 softmax。
for block in pout split by 64 fp16 elements:
y = load_fp16_vector(block)
lo_qf32, hi_qf32 = widen_fp16_to_two_qf32_vectors(y)
out_lo = lo_qf32 * recip
out_hi = hi_qf32 * recip
out_fp16 = narrow_qf32_pair_to_fp16(out_lo, out_hi)
store_fp16(pout_block, out_fp16)
|
1.4.3 approx_exp2() 伪代码
源码里没有单独的 approx_exp2() 函数,但第 184 到 266 行实际在做类似事情。先把自然指数换底:
$$
e^x = 2^{x / \ln 2}
$$所以 kernel 里要近似的是 2^z。抽象成伪代码:
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
|
function approx_exp2(z):
# z 是 base-2 指数域的输入。
# 源码先把 qf32 转成便于按 float32 bit 位拆分的形式。
z_as_float = convert_qf32_to_float_bits(z)
# 拆出 float32 exponent bits。
exponent_bits = get_float_exponent_bits(z_as_float)
# 限制 exponent,让多项式输入落在较小范围。
exponent_limit = min(exponent_bits, 126 << 23)
exponent_delta = exponent_bits - exponent_limit
# 用受限 exponent 重组规格化输入。
z_norm = replace_exponent(z_as_float, exponent_limit)
# Horner 形式计算三阶多项式:
# p = c0 + c1*z_norm + c2*z_norm^2 + c3*z_norm^3
p = c3 * z_norm
p = p + c2
p = p * z_norm
p = p + c1
p = p * z_norm
p = p + c0
# repeated squaring:先构造多个幂次候选。
p2 = p * p
p4 = p2 * p2
p8 = p4 * p4
p16 = p8 * p8
p32 = p16 * p16
p64 = p32 * p32
# 根据 exponent_delta 选择对应幂次,恢复更大范围的指数效果。
if exponent_delta == 1 << 23:
return p2
if exponent_delta == 2 << 23:
return p4
if exponent_delta == 3 << 23:
return p8
if exponent_delta == 4 << 23:
return p16
if exponent_delta == 5 << 23:
return p32
if exponent_delta > 5 << 23:
return p64
return p
|
1.5 算子实现
1.5.1 exp 近似
源码第 124 到 130 行:
1
2
3
4
5
|
scale /= float(log(2.0));
c0.f = 1.f;
c1.f = 0.692850309695840;
c2.f = 0.237504551482093;
c3.f = 0.046751431261525;
|
这里把自然指数换到底数 2 的指数域:
$$
e^x = 2^{x / \ln 2}
$$然后,构造类似 2^x 的多项式近似。如下,c0/c1/c2/c3 是三阶多项式系数:
$$
c0 + c1 \cdot x + c2 \cdot x^2 + c3 \cdot x^3
$$实际上,这里会先给$x$做上下范围的限制($x = 2^k \times r$),然后用多项式近似小范围值$2 ^ r$,最后由于$2^x = 2^{2^k \cdot r} = (2^r)^{2^k}$,所以通过 repeated squaring 的方式构造 $p^2$、$p^4$、$p^8$… 来恢复更大范围的指数效果(就是前面的${2^k}$的指数部分)。
1.5.2 找最大值
源码第 132 到 151 行完成 reduce max。
第 133 行:
1
|
HVX_Vector xmax = Q6_Vh_vsplat_R(0xFC00);
|
0xFC00 是 half precision 的负无穷。Q6_Vh_vsplat_R() 把这个 16-bit 值广播到整个 HVX vector。
第 136 到 140 行每次读 64 个 FP16,寻找当前切片的最大值:
1
2
3
4
5
|
for (int d = length; d > 63; d -= 64) {
HVX_Vector xinval = vmemu(iptr);
iptr++;
xmax = Q6_Vhf_vmax_VhfVhf(xmax, xinval);
}
|
这里 Vhf 可以读成 vector half float。Q6_Vhf_vmax_VhfVhf 就是对两个 half-float vector 做逐元素 max。
第 141 到 145 行处理最后不足 64 个元素的情况:
1
2
3
4
5
6
7
8
|
if ((length & 63) != 0) {
HVX_Vector xinval = vmemu(iptr);
HVX_VectorPred qfinalmask = Q6_Q_vsetq2_R(length * 2);
// mask 外的位置填成当前 xmax,避免尾部无效数据影响 max。
xinval = Q6_V_vmux_QVV(qfinalmask, xinval, xmax);
xmax = Q6_Vhf_vmax_VhfVhf(xmax, xinval);
}
|
第 147 到 151 行把 vector 里的多个 lane 继续横向归约成一个最大值。
1
2
3
4
5
6
7
|
// 横向归约:把 vector 内多个 lane 的最大值继续合并成一个 xmax。
int nshift = 2;
for (int i = 0; i < 6; i++) {
HVX_VectorPair temps = Q6_W_vshuff_VVR(xmax, xmax, nshift);
xmax = Q6_Vhf_vmax_VhfVhf(Q6_V_lo_W(temps), Q6_V_hi_W(temps));
nshift <<= 1;
}
|
1.5.3. 计算未归一化 exp
源码第 154 到 285 行是 softmax_hf_approx() 的主计算部分。它做的是:
1
2
|
tmp_i = approx_exp(scale * (x_i - max))
sum = sum_i tmp_i
|
这里的 tmp_i 会先暂存到 pout,下一节再除以 sum。
进入主循环前,源码先准备常量、系数、指针和累加器:
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
|
// zero vector,后面用于类型转换、加法归一化和清尾部 lane。
HVX_Vector vzero = Q6_V_vzero();
// fp16 的 1.0,0x3c00 是 half precision 1.0 的 bit pattern。
HVX_Vector voneh = Q6_Vh_vsplat_R(0x3c00);
// 把三阶多项式系数广播到 HVX vector。
HVX_Vector f0 = Q6_V_vsplat_R(c0.i);
HVX_Vector f1 = Q6_V_vsplat_R(c1.i);
HVX_Vector f2 = Q6_V_vsplat_R(c2.i);
HVX_Vector f3 = Q6_V_vsplat_R(c3.i);
// 这些 mask/常量用于拆 float32 的指数位和尾数部分。
HVX_Vector c7f800000 = Q6_V_vsplat_R(0x7f800000);
HVX_Vector c807fffff = Q6_V_vsplat_R(0x807fffff);
// vbeta 是已经除以 ln(2) 后的 scale:
// exp(scale_orig * x) = 2^((scale_orig / ln2) * x)
HVX_Vector vbeta = Q6_Vqf32_vadd_VsfVsf(Q6_V_vsplat_R(scaleu.i), vzero);
// 126 << 23 对应 float32 exponent field 的一个上限基准。
// 后面会用它限制/拆分指数部分,避免近似计算范围过大。
HVX_Vector c126 = Q6_V_vsplat_R(126 << 23);
// c1w, c2w, ... c5w 是指数位差值的比较常量。
// 后面根据 xexp 的大小选择 p、p^2、p^4、...、p^64。
HVX_Vector c1w = Q6_V_vsplat_R(1 << 23);
HVX_Vector c2w = Q6_Vw_vadd_VwVw(c1w, c1w);
HVX_Vector c3w = Q6_Vw_vadd_VwVw(c2w, c1w);
HVX_Vector c4w = Q6_Vw_vadd_VwVw(c3w, c1w);
HVX_Vector c5w = Q6_Vw_vadd_VwVw(c4w, c1w);
// vsumf 累加当前 softmax 切片所有未归一化 exp 的和。
HVX_Vector vsumf = Q6_V_vzero();
// optr 指向输出 buffer。此时输出 buffer 被用作 tmp buffer,
// 用来暂存未归一化的 exp 近似值。
HVX_Vector *optr = (HVX_Vector *)pout;
iptr = (HVX_Vector *)pin;
|
主循环每轮处理 64 个 FP16。第一步是读取输入并减最大值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
for (int d = length; d > 0; d -= 64) {
HVX_Vector x, x0, x1, p0, p1;
HVX_VectorPair xdiff, p10;
HVX_VectorPred q0, q1;
// 读取当前 64 个 fp16 输入。
x = vmemu(iptr);
iptr++;
// 稳定版 softmax 的关键:
// xd = x - max(x)
HVX_Vector xd = Q6_Vqf16_vsub_VhfVhf(x, xmax);
// 把 fp16 差值扩展成两组 qf32 数据。
// 返回 HVX_VectorPair,后面分别处理 lo/hi 两半。
xdiff = Q6_Wqf32_vmpy_Vqf16Vhf(xd, voneh);
|
接着乘以 vbeta,进入 2^x 的近似域:
1
2
3
4
5
6
|
// x0/x1 = (x - max) * scale / ln(2)
x0 = Q6_Vqf32_vmpy_Vqf32Vqf32(Q6_V_lo_W(xdiff), vbeta);
x0 = Q6_Vsf_equals_Vqf32(x0);
x1 = Q6_Vqf32_vmpy_Vqf32Vqf32(Q6_V_hi_W(xdiff), vbeta);
x1 = Q6_Vsf_equals_Vqf32(x1);
|
后面这组 bit 操作是在拆 float32 的指数位和规格化尾数。直观理解是:把 2^x 的计算拆成“较小范围的多项式近似”和“指数缩放/幂次恢复”两部分。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// 取出 float32 的 exponent bits。
HVX_Vector x0exp = Q6_V_vand_VV(x0, c7f800000);
HVX_Vector x1exp = Q6_V_vand_VV(x1, c7f800000);
// 把 exponent 限制到 c126 以内,构造后续多项式的输入范围。
HVX_Vector x0explimit = Q6_Vw_vmin_VwVw(x0exp, c126);
HVX_Vector x1explimit = Q6_Vw_vmin_VwVw(x1exp, c126);
// xexp 保存剩余的指数位差值,用于后面选择 p^2/p^4/...。
x0exp = Q6_Vw_vsub_VwVw(x0exp, x0explimit);
x1exp = Q6_Vw_vsub_VwVw(x1exp, x1explimit);
// xnorm 是规格化后的多项式输入。
HVX_Vector x0norm = Q6_V_vor_VV(Q6_V_vand_VV(c807fffff, x0), x0explimit);
HVX_Vector x1norm = Q6_V_vor_VV(Q6_V_vand_VV(c807fffff, x1), x1explimit);
|
然后用 Horner 形式计算三阶多项式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// p0 = ((c3 * x0norm + c2) * x0norm + c1) * x0norm + c0
// 即 p0 = c0 + c1*x + c2*x^2 + c3*x^3
p0 = Q6_Vqf32_vmpy_VsfVsf(x0norm, f3);
p0 = Q6_Vqf32_vadd_Vqf32Vsf(p0, f2);
x0norm = Q6_Vqf32_vadd_VsfVsf(x0norm, vzero);
p0 = Q6_Vqf32_vmpy_Vqf32Vqf32(p0, x0norm);
p0 = Q6_Vqf32_vadd_Vqf32Vsf(p0, f1);
p0 = Q6_Vqf32_vmpy_Vqf32Vqf32(p0, x0norm);
p0 = Q6_Vqf32_vadd_Vqf32Vsf(p0, f0);
// p1 对应 vector 的另一半,计算方式相同。
p1 = Q6_Vqf32_vmpy_VsfVsf(x1norm, f3);
p1 = Q6_Vqf32_vadd_Vqf32Vsf(p1, f2);
x1norm = Q6_Vqf32_vadd_VsfVsf(x1norm, vzero);
p1 = Q6_Vqf32_vmpy_Vqf32Vqf32(p1, x1norm);
p1 = Q6_Vqf32_vadd_Vqf32Vsf(p1, f1);
p1 = Q6_Vqf32_vmpy_Vqf32Vqf32(p1, x1norm);
p1 = Q6_Vqf32_vadd_Vqf32Vsf(p1, f0);
|
接下来反复平方,构造 p^2 到 p^64。这一步服务于前面拆出来的 exponent 差值:指数位差值越大,就选择更高次的平方结果。
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
|
// 构造 p0^2, p0^4, p0^8, p0^16, p0^32, p0^64。
HVX_Vector p0_2 = Q6_Vqf32_vmpy_Vqf32Vqf32(p0, p0);
p0_2 = Q6_Vqf32_vadd_Vqf32Vsf(p0_2, vzero);
HVX_Vector p0_4 = Q6_Vqf32_vmpy_Vqf32Vqf32(p0_2, p0_2);
p0_4 = Q6_Vqf32_vadd_Vqf32Vsf(p0_4, vzero);
HVX_Vector p0_8 = Q6_Vqf32_vmpy_Vqf32Vqf32(p0_4, p0_4);
p0_8 = Q6_Vqf32_vadd_Vqf32Vsf(p0_8, vzero);
HVX_Vector p0_16 = Q6_Vqf32_vmpy_Vqf32Vqf32(p0_8, p0_8);
p0_16 = Q6_Vqf32_vadd_Vqf32Vsf(p0_16, vzero);
HVX_Vector p0_32 = Q6_Vqf32_vmpy_Vqf32Vqf32(p0_16, p0_16);
p0_32 = Q6_Vqf32_vadd_Vqf32Vsf(p0_32, vzero);
HVX_Vector p0_64 = Q6_Vqf32_vmpy_Vqf32Vqf32(p0_32, p0_32);
// p1^2 ... p1^64 同理。
HVX_Vector p1_2 = Q6_Vqf32_vmpy_Vqf32Vqf32(p1, p1);
p1_2 = Q6_Vqf32_vadd_Vqf32Vsf(p1_2, vzero);
HVX_Vector p1_4 = Q6_Vqf32_vmpy_Vqf32Vqf32(p1_2, p1_2);
p1_4 = Q6_Vqf32_vadd_Vqf32Vsf(p1_4, vzero);
HVX_Vector p1_8 = Q6_Vqf32_vmpy_Vqf32Vqf32(p1_4, p1_4);
p1_8 = Q6_Vqf32_vadd_Vqf32Vsf(p1_8, vzero);
HVX_Vector p1_16 = Q6_Vqf32_vmpy_Vqf32Vqf32(p1_8, p1_8);
p1_16 = Q6_Vqf32_vadd_Vqf32Vsf(p1_16, vzero);
HVX_Vector p1_32 = Q6_Vqf32_vmpy_Vqf32Vqf32(p1_16, p1_16);
p1_32 = Q6_Vqf32_vadd_Vqf32Vsf(p1_32, vzero);
HVX_Vector p1_64 = Q6_Vqf32_vmpy_Vqf32Vqf32(p1_32, p1_32);
|
根据 x0exp/x1exp 的值选择对应幂次:
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
|
// 如果 xexp == 1 << 23,选择 p^2。
q0 = Q6_Q_vcmp_eq_VwVw(x0exp, c1w);
q1 = Q6_Q_vcmp_eq_VwVw(x1exp, c1w);
p0 = Q6_V_vmux_QVV(q0, p0_2, p0);
p1 = Q6_V_vmux_QVV(q1, p1_2, p1);
// 如果 xexp == 2 << 23,选择 p^4。
q0 = Q6_Q_vcmp_eq_VwVw(x0exp, c2w);
q1 = Q6_Q_vcmp_eq_VwVw(x1exp, c2w);
p0 = Q6_V_vmux_QVV(q0, p0_4, p0);
p1 = Q6_V_vmux_QVV(q1, p1_4, p1);
// 后面依次选择 p^8、p^16、p^32。
q0 = Q6_Q_vcmp_eq_VwVw(x0exp, c3w);
q1 = Q6_Q_vcmp_eq_VwVw(x1exp, c3w);
p0 = Q6_V_vmux_QVV(q0, p0_8, p0);
p1 = Q6_V_vmux_QVV(q1, p1_8, p1);
q0 = Q6_Q_vcmp_eq_VwVw(x0exp, c4w);
q1 = Q6_Q_vcmp_eq_VwVw(x1exp, c4w);
p0 = Q6_V_vmux_QVV(q0, p0_16, p0);
p1 = Q6_V_vmux_QVV(q1, p1_16, p1);
q0 = Q6_Q_vcmp_eq_VwVw(x0exp, c5w);
q1 = Q6_Q_vcmp_eq_VwVw(x1exp, c5w);
p0 = Q6_V_vmux_QVV(q0, p0_32, p0);
p1 = Q6_V_vmux_QVV(q1, p1_32, p1);
// 如果指数差值更大,选择 p^64。
q0 = Q6_Q_vcmp_gt_VwVw(x0exp, c5w);
q1 = Q6_Q_vcmp_gt_VwVw(x1exp, c5w);
p0 = Q6_V_vmux_QVV(q0, p0_64, p0);
p1 = Q6_V_vmux_QVV(q1, p1_64, p1);
|
最后把这一轮的未归一化 exp 写到 pout,并累加到 vsumf:
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
|
if (d >= 64) {
// p0/p1 合成一个 vector pair,转成 fp16 后写到 pout。
// 注意:这里的 pout 暂时保存的是未归一化 exp,不是最终 softmax。
p10 = Q6_W_vcombine_VV(p1, p0);
q6op_vstu_AV(optr, Q6_Vhf_equals_Wqf32(p10));
optr++;
// 累加 softmax 分母。
vsumf = Q6_Vqf32_vadd_Vqf32Vqf32(vsumf, p0);
vsumf = Q6_Vqf32_vadd_Vqf32Vqf32(vsumf, p1);
} else {
// 尾部不足 64 个 fp16 时,用 predicate 清掉无效 lane。
// 这里 qf32 每个 lane 是 4 字节,所以 mask 长度乘 4。
HVX_VectorPred Q0 = Q6_Q_vsetq2_R(4 * (((d & 63) + 1) / 2));
HVX_VectorPred Q1 = Q6_Q_vsetq2_R(4 * (((d & 63) + 0) / 2));
p0 = Q6_V_vmux_QVV(Q0, p0, vzero);
p1 = Q6_V_vmux_QVV(Q1, p1, vzero);
// 只累加有效 lane。
vsumf = Q6_Vqf32_vadd_Vqf32Vqf32(vsumf, p0);
vsumf = Q6_Vqf32_vadd_Vqf32Vqf32(vsumf, p1);
// 只写回有效 fp16 元素。
p10 = Q6_W_vcombine_VV(p1, p0);
q6op_vstu_variable_ARV(optr, 2 * (d & 63), Q6_Vhf_equals_Wqf32(p10));
optr++;
}
}
|
1.5.4 sum 并归一化
源码第 287 到 292 行把 vsumf 横向归约,得到整段 softmax 的分母:
1
2
3
4
5
6
7
8
9
|
// vsumf 是 vector 形式的部分和。这里把 vector 内所有 lane 继续横向相加。
for (int i = 0, nshift = 4; i < 5; i++) {
HVX_VectorPair temps = Q6_W_vshuff_VVR(vsumf, vsumf, nshift);
vsumf = Q6_Vqf32_vadd_Vqf32Vqf32(Q6_V_lo_W(temps), Q6_V_hi_W(temps));
nshift <<= 1;
}
// 转成 scalar float 格式,便于从 lane 0 取出。
vsumf = Q6_Vsf_equals_Vqf32(vsumf);
|
第 295 到 297 行取出 sum 并求倒数:
1
2
3
4
5
6
7
|
// 横向归约后,每个 lane 都等价地携带总和;取 lane 0 即可。
sum.i = Q6_R_vextract_VR(vsumf, 0);
sum_recip.f = 1.0f / sum.f;
// 把 1/sum 广播成 vector,后面逐元素乘。
HVX_Vector vrecip = Q6_Vqf32_vadd_VsfVsf(Q6_V_vsplat_R(sum_recip.i), vzero);
HVX_Vector *ioptr = (HVX_Vector *)pout;
|
第 300 到 314 行第二次遍历输出 buffer,把前面暂存的 exp 乘以 1 / sum。这里的输入和输出都是 pout,相当于原地归一化:
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
|
// 处理完整的 64 个 fp16 block。
for (int d = length; d > 63; d -= 64) {
// 从 pout 读取前一节暂存的未归一化 exp,并扩展到 qf32。
HVX_VectorPair xx = Q6_Wqf32_vmpy_VhfVhf(vmemu(&ioptr[0]), voneh);
// lo/hi 两半分别乘以 1/sum。
HVX_Vector xl =
Q6_Vqf32_vmpy_Vqf32Vqf32(Q6_Vqf32_vadd_Vqf32Vsf(Q6_V_lo_W(xx), vzero), vrecip);
HVX_Vector xh =
Q6_Vqf32_vmpy_Vqf32Vqf32(Q6_Vqf32_vadd_Vqf32Vsf(Q6_V_hi_W(xx), vzero), vrecip);
// 合并并转回 fp16,写回最终 softmax 输出。
xx = Q6_W_vcombine_VV(xh, xl);
q6op_vstu_AV(ioptr, Q6_Vhf_equals_Wqf32(xx));
ioptr++;
}
// 处理尾部不足 64 个 fp16 的 block。
if ((length & 63) != 0) {
HVX_VectorPair xx = Q6_Wqf32_vmpy_VhfVhf(vmemu(&ioptr[0]), voneh);
HVX_Vector xl =
Q6_Vqf32_vmpy_Vqf32Vqf32(Q6_Vqf32_vadd_Vqf32Vsf(Q6_V_lo_W(xx), vzero), vrecip);
HVX_Vector xh =
Q6_Vqf32_vmpy_Vqf32Vqf32(Q6_Vqf32_vadd_Vqf32Vsf(Q6_V_hi_W(xx), vzero), vrecip);
xx = Q6_W_vcombine_VV(xh, xl);
// 只写回有效的 fp16 元素。
q6op_vstu_variable_ARV(ioptr, (length & 63) * 2, Q6_Vhf_equals_Wqf32(xx));
}
|
这一步对应:
$$
out_i = \frac{\exp(scale \cdot (x_i - max))}{\sum_j \exp(scale \cdot (x_j - max))}
$$1.5.5 softmax_fp_impl()
源码第 318 到 333 行是 Softmax_fp 的外层调度函数。它本身不写 HVX 计算细节,只负责按 [B, H, W] 遍历,把每段最后一维切片交给 softmax_hf_approx():
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
|
template <typename OutTtype, typename InTtype>
int softmax_fp_impl(OutTtype &out, const InTtype &in, const Tensor &beta) {
// 输出 shape 与输入一致。
out.set_dims(in);
// 示例实现按 4D tensor 处理:[B, H, W, D]。
// softmax 固定沿最后一维 D 做。
auto [b_in, h_in, w_in, d_in] = in.dims();
// beta 是 softmax 的指数缩放参数。
// interface_scale 是输入 tensor 接口层的 scale,这里合进总 scale。
float scale = in.interface_scale() * beta(0, 0, 0, 0);
// 外层 B/H/W 逐切片遍历。
// 每个切片是一段连续的 D 维 fp16 数据。
for (Idx b = 0; b < b_in; b++) {
for (Idx h = 0; h < h_in; h++) {
for (Idx w = 0; w < w_in; w++) {
using T = typename InTtype::element_type;
// 当前 [b, h, w, :] 的输入首地址。
const T *pin = &in.get_raw(b, h, w, 0);
// 当前 [b, h, w, :] 的输出首地址。
typename OutTtype::element_type *pout = &out.get_raw(b, h, w, 0);
// 对最后一维做 softmax。
softmax_hf_approx(pout, pin, scale, d_in);
}
}
}
return GraphStatus::Success;
}
|
也就是说,这个示例的 Softmax_fp 固定做最后一维 softmax;axis 在前面的优化规则里已经被规整掉了。
1.6 reference 版本
源码第 337 到 367 行是普通 C++ 版本:
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
|
template <typename Ttype>
int softmax_impl(Ttype &out, const Ttype &in, const float beta) {
// debuglog("reference softmax (%s)", __PRETTY_FUNCTION__);
out.set_dims(in);
// 示例实现按 4D tensor 处理:[B, H, W, D]。
auto [b_in, h_in, w_in, d_in] = in.dims();
for (Idx b = 0; b < b_in; b++) {
for (Idx h = 0; h < h_in; h++) {
for (Idx w = 0; w < w_in; w++) {
float max = in(b, h, w, 0);
// 先找最大值,保证数值稳定。
for (Idx d = 0; d < d_in; d++) {
float const inval = in(b, h, w, d);
max = fmaxf(inval, max);
}
float sum = 0;
// 计算未归一化的 exp 并且求和
for (Idx d = 0; d < d_in; d++) {
float const inval = in(b, h, w, d);
sum += (out(b, h, w, d) = expf(beta * (inval - max)));
}
float const sum_recip = 1.0f / sum;
// 归一化
for (Idx d = 0; d < d_in; d++) {
float const outval = out(b, h, w, d);
out(b, h, w, d) = outval * sum_recip;
}
}
}
}
return GraphStatus::Success;
}
|
- 先找最大值
- 计算
exp(beta * (x - max))
- 求和
- 除以 sum
softmaxWithbetaWrapper() 只是把 Tensor beta 里的第一个 scalar 取出来,再调用这个 reference 实现。
2. Q6_Vqf32_vmpy_Vqf32Vqf32 等类型
这不是 QNN API,也不是 C++ 标准库函数。它是 Qualcomm Hexagon/HVX intrinsic。
可以按名字拆:
1
2
3
4
5
6
7
|
Q6_ Vqf32 _vmpy_ Vqf32 Vqf32
| | | | |
| | | | +-- 第二个输入类型:qf32 vector
| | | +-------- 第一个输入类型:qf32 vector
| | +--------------- 操作:vector multiply
| +----------------------- 返回类型:qf32 vector
+--------------------------- Hexagon Q6 intrinsic 前缀
|
这里几个缩写大致这样读:
| 片段 |
含义 |
Q6 |
Hexagon Q6 intrinsic 前缀 |
V |
HVX vector |
W |
HVX vector pair |
R |
scalar register |
Q |
predicate/mask |
vmpy |
vector multiply |
vadd |
vector add |
Vhf |
vector half-float |
Vqf32 |
vector qfloat32/内部扩展浮点表示 |
Vsf |
vector single-float |
所以在这份 softmax 里,Q6_Vqf32_vmpy_Vqf32Vqf32(a, b) 主要出现在三类地方:
- 把
(x - max) 乘以 beta 缩放。
- 多项式近似里做乘法。
- 最后把未归一化输出乘以
1 / sum。
直觉上可以把它当成:
1
2
|
for each lane:
out[i] = a[i] * b[i]
|
但它的真实数据格式、lane 数、舍入行为、饱和行为要以 Hexagon intrinsic 文档为准。