本节以Conv为例,介绍Hexagon V66 架构下,Conv的HVX实现。
前面的章节我们分析过VTCM,在VCAP DSP框架中,VTCM是按照下面的区域划分的:
|------------------------------------ 256 KB -------------------------------------|
|----vtcm input-----|----vtcm weight-----|-----vtcm suma----|----remain----|
量化计算原理:
acc32 = ∑(input - input_offset) * (filt - filt_offset) + bias32 (1)
= ∑(input * filt - input_offset * filt - input * filt_offset + input_offset * filt_offset) + bias32 (2)
= ∑(input * filt - input * filt_offset ) +∑(input_offset * filt_offset - input_offset * filt) + bias32 (3)
let bias_buff = bias32 + ∑(input_offset * filt_offset - input_offset * filt) (4)
bias_buff can be computed offline because all elements in equation (4) are constant. So we rewrite equation (1) as follow:
acc32 = ∑(input * filt - input * filt_offset ) + bias_buff (5)
DownScaleToInt8(acc32) (6)
注:下文中weight和filt都可以用来表示权重,含义相同
DataFormat:
Feature Map:
在CPU侧,数据的原始排布是NHWC,在DSP中,Feature Map 采用的是D32 Format ,具体的原因我们在 D32 Format浅析 这篇文章中做过简单的分析。
D32 Format的维度信息如下:
N x H x C32 x W4 x 4 x 32 → N x H x C32 x PADDED_WIDTH x 32
其中:
W4 = (FEATURE_MAP_WIDTH+ 3) >> 2
C32 = (FEATURE_MAP_CHANNEL+ 31) >> 5
对于FEATURE_MAP_CHANNEL <= 4 的模型输入,我们会采用下面的格式,即将 C (C <= 4)补齐到4:
N x H x PADDED_WIDTH x 4
这样做的原因是,通常CNN模型的输入都是3通道或者单通道,如果将其补齐到32,会导致内存占用、计算量剧增,补齐到4可以很好的解决这两个问题。
只针对模型输入这样处理的原因是,Feature Map通常是按照D32 Format排布的,频繁地进行数据排布变换会带来一定地性能损失,所以这也要求我们在
设计算法时,尽量保证模型中的OP都是32对齐。
Weights :
Weights 采用的Data Format是为了适配 D32 Format 对 Weights原始的数据排布HWCN进行了变换,变换后的数据排布和示意图如下:
N32 x H x C32 x W x 8 x 32 x 4
最后面三项 8 x 32 x 4 中,8和4都是IN_CHANNEL,32 表示 OUT_CHANNEL, 这样在计算vrmpy时,可以同时计算32个OUT_CHANNEL的结果。
采用这种排布的好处就是,满足128byte对齐的要求,可以用vmem指令进行高效的访存,同时完美适配vrmpy指令。
对于IN_CHANNEL <= 4 的Conv, 我们会采用下面的数据排布, 这个排布主要是针对FEATURE_MAP_CHANNEL <= 4 的模型输入:
N32 x H x W x 32 x 4
Conv 优化实践示例:
本文会以Mobilenet V1中的OP为例,介绍Conv3x3s2d4以及conv1x1s1d32两个Conv的优化思想:
Conv3x3s2d4
Mobilenet V1的结构如下图所示(图中的模型输入是NCHW),我们可以看到模型的输入维度是[1, 224, 224, 3](NHWC),第一层卷积的weights是[3, 3, 3, 32] (HWCN)。
所以我们会采用前文提到的 N x H x PADDED_WIDTH x 4 数据排布,即将 [1, 224, 224, 3] 补齐到 [1, 224, 224, 4],实际计算时,我们还需要考虑pads值,
对于第一个conv3x3s2而言,[pad_top, pad_right , pad_bottom , pad_left ] = [0, 1, 1, 0],也就是加上pads后Feature Map变成了[1, 225, 225, 4],由于
我们要求Width是4的整数倍,所以会将Feature Map补齐到[1, 225, 228, 4],但是这几步存在一些差异:
[1, 224, 224, 3] → [1, 224, 224, 4], 在Channel方向pads的是0,目的是保证Channel 4对齐,同时保证计算的正确性
[1, 224, 224, 4] → [1, 225, 225, 4], 在Height、Width方向pads的是zeropoint,即128,目的是保证计算的正确性
[1, 225, 225, 4] → [1, 225, 228, 4], 在Width方向pads的是0,目的是保证Width方向4对齐(其实这一步pads任意值都可以,不影响计算结果,为了统一,此处都pads 0)
我们在 DSP访存优化原理 中有分析过,L2 标量的硬件Pipeline 比VTCM更快,所以Input 的上述pads、补齐操作可以在Intermediate Buffer中完成,而不是放在VTCM上,通常Intermediate Buffer的大小为:
num_workers *
inter_buffer_height * padded_width * 4
这里解释一下这几个参数的含义:
num_workers 表示线程数
inter_buffer_height 表示Intermediate Buffer的height,通常这个值和weight的kernel height相同,针对上面这个例子 inter_buffer_height 为3
接下来,我们来分析Conv3x3s2d4 的实现,conv3x3s2d4_callback 的完整代码如下:
// conv_op.cpp
static void conv3x3s2d4_callback(void *data) {
conv_callback_t *dptr = (conv_callback_t *)data;
uint64_t L2FETCH_INPUT_REGISTER = (1ULL << 48) | ((uint64_t)dptr->next_in_width_d4 << 32) | ((uint64_t)((dptr->next_in_width_d4 + 127) & 0xffffff80) << 16) | 3ULL;
uint32_t thread_id = dspCV_atomic_inc_return((unsigned int *)(&(dptr->job_count))) - 1;
uint32_t start_height = thread_id * dptr->rows_per_job;
uint32_t end_height = MIN((thread_id + 1) * dptr->rows_per_job, dptr->top_h);
uint8_t *input = dptr->input + start_height * dptr->stride_h * dptr->bottom_c * dptr->bottom_w;
uint8_t *vtcm_input = dptr->vtcm_input + thread_id * dptr->next_in_width_d4 * dptr->vtcm_height;
uint8_t *output = dptr->output + start_height * dptr->next_out_width_depth;
L2FETCH(vtcm_input, L2FETCH_INPUT_REGISTER);
L2FETCH(input, L2FETCH_INPUT_REGISTER);
const uint32_t d4 = 4;
uint32_t tmp_pad_val = 0;
if (end_height == dptr->top_h) {
end_height -= 1;
}
// padding vtcm_buf once in each thread; pad_right = 1 for conv with stride = 2 and kernel_size = 3
for (uint32_t h = 0; h < dptr->vtcm_height; h++) {
uint8_t *tmp_vtcm_input = vtcm_input + h * dptr->next_in_width_d4 + dptr->bottom_w * d4;
tmp_vtcm_input[0] = (uint8_t)dptr->quant_info->bottom_zp;
tmp_vtcm_input[1] = (dptr->bottom_c > 1) ? (uint8_t)dptr->quant_info->bottom_zp : 0;
tmp_vtcm_input[2] = (dptr->bottom_c > 2) ? (uint8_t)dptr->quant_info->bottom_zp : 0;
tmp_vtcm_input[3] = (dptr->bottom_c > 3) ? (uint8_t)dptr->quant_info->bottom_zp : 0;
if (h == 0) tmp_pad_val = *(uint32_t *) tmp_vtcm_input;
}
for (; start_height < end_height; start_height++) {
// copy from intermediate buffer to vtcm
uint8_t *tmp_vtcm_input = vtcm_input + dptr->bottom_w * d4;
COPY_INPUT_TO_VTCM_D4(0, dptr->vtcm_height);
conv3x3s2d4_asm(vtcm_input, output, data);
input += dptr->stride_h * dptr->bottom_c * dptr->bottom_w;
output += dptr->next_out_width_depth;
if (start_height < end_height - 3)
L2FETCH(input, L2FETCH_INPUT_REGISTER);
}
if (start_height == dptr->top_h - 1) {
// only two input lines need to be copied
COPY_INPUT_TO_VTCM_D4(0, 2);
uint8_t *tmp_vtcm_input = vtcm_input + 2 * dptr->next_in_width_d4;
std::fill((uint32_t *)tmp_vtcm_input, (uint32_t *)tmp_vtcm_input + dptr->bottom_w, tmp_pad_val);
conv3x3s2d4_asm(vtcm_input, output, data);
}
dspCV_worker_pool_synctoken_jobdone(dptr->token);
}
需要注意的是,代码中的vtcm_input 其实是一块Intermediate Buffer,整段代码的第一步,是pads zeropoint,内容如下:
// padding vtcm_buf once in each thread; pad_right = 1 for conv with stride = 2 and kernel_size = 3
for (uint32_t h = 0; h < dptr->vtcm_height; h++) {
uint8_t *tmp_vtcm_input = vtcm_input + h * dptr->next_in_width_d4 + dptr->bottom_w * d4;
tmp_vtcm_input[0] = (uint8_t)dptr->quant_info->bottom_zp;
tmp_vtcm_input[1] = (dptr->bottom_c > 1) ? (uint8_t)dptr->quant_info->bottom_zp : 0;
tmp_vtcm_input[2] = (dptr->bottom_c > 2) ? (uint8_t)dptr->quant_info->bottom_zp : 0;
tmp_vtcm_input[3] = (dptr->bottom_c > 3) ? (uint8_t)dptr->quant_info->bottom_zp : 0;
if (h == 0) tmp_pad_val = *(uint32_t *) tmp_vtcm_input;
}
每个线程中只需要做vtcm_height=3次width方向的pads,而不是output_height * vtcm_height/num_workers=112 * 3 /4=84次,这样做的好处是可以大大减少数据读写的次数;另外,我们还会注意到一个变量 tmp_pad_val ,这个变量是为了便于后续pads 最后一行,如conv3x3s2d4_callback 的 #42行所示。
整段代码的第二步,是循环执行 COPY_INPUT_TO_VTCM_D4、conv3x3s2d4_asm、L2FETCH ,代码如下:
for (; start_height < end_height; start_height++) {
// copy from input to intermediate buffer
uint8_t *tmp_vtcm_input = vtcm_input + dptr->bottom_w * d4;
COPY_INPUT_TO_VTCM_D4(0, dptr->vtcm_height);
conv3x3s2d4_asm(vtcm_input, output, data);
input += dptr->stride_h * dptr->bottom_c * dptr->bottom_w;
output += dpt