作者:洪超 | 旷视科技 MegEngine 架构师
前言
Cadence 的 Vision P6/Q6/Q7 系列 DSP 在很多的 ISP (“Image Signal Processor”) 芯片中都有部署,可以在图像处理场景补充甚至碾压 CPU 算力。而且 Cadence 官方提供了一个比较全的基础算子库 libxi,很多标准算子在 libxi 中都有特定参数组合下的参考实现。但是鉴于 Cadence DSP 开发群体比较小,网络上能找到的中文资源几乎没有,从零进入开发状态的门槛还是不低的。本文梳理了一些 Cadence DSP 算子开发中的重点,希望可以给对 Cadence DSP 开发有兴趣的同学带来帮助。
DSP 架构特点
首先,以 Cadence 的 Q7 为例,介绍一下 DSP 架构上的特性。下图是 Q7 硬件架构的简化。

从图中可以直观的得到 DSP 处理器的算力、寄存器等信息,注意 DSP 上有两块 data ram(简称 dram),每一块 dram 又分为两个宽为 512bit 的 bank。同时,DSP 上有两个 Load/Store 单元,Load/Store 模块访问 dram 的带宽都是 512bit,所以理论上的访存带宽是 1024bit/cycle,而独立于 Load/Store 的 SuperGather 模块是为了支持 DSP 上高效的 gather/scatter 操作。另外,可以看到 DSP 还有一个 dma 模块,该模块用于片外空间和 dram 之间的数据传输。
为了充分利用算力和访存能力,Cadence DSP 支持了 SIMD(Single Instruction, Multiple Data) 和 VLIW(Very Long Insruction Word) 两种特性。前者支持 64lanes * 8bit 或 32lanes * 16bit 等总位宽为 512bit 的向量访存和向量计算,后者是一种谋求指令级并行 (ILP, instruction level parallelism) 的技术。VLIW 可以将多个指令打包后在一起同时发射,从而获取指令级的并行度。与超标量、乱序执行等其他 ILP 技术不同的是,VLIW 的并行指令排布是在编译期就确定好的,而不需要 CPU 进行复杂的运行时调度。VLIW 使得 DSP 处理器在不需要大幅增加硬件复杂度的情况下,就可以获取 ILP 的加速收益。
还要补充一点,Cadence DSP 是哈弗架构,其指令和数据独立编址,具体的编址规格由 LSP(Linker Support Package) 决定,而用户可以通过名为 memmap.xmm 的内存配置文件来定义和修改 LSP。截取了一段 xmm 文件的内容,简单注释如下:
// 存指令的地址段
BEGIN iram0
0xe000000: instRam : iram0 : 0x8000 : executable,writable ;
iram0_0 : F : 0xe000000 - 0xe007fff : .iram0.literal .iram0.text ...
END iram0
// 256k 的 dram0
BEGIN dram0
0xe080000: dataRam : dram0 : 0x40000 : writable ;
dram0_0 : C : 0xe080000 - 0xe0bffff : .dram0.rodata .dram0.data .dram0.bss;
END dram0
// 240k 的 dram1
BEGIN dram1
0xe0c0000: dataRam : dram1 : 0x3c000 : writable ;
dram1_0 : C : 0xe0c0000 - 0xe0fbfff : .dram1.rodata .dram1.data .dram1.bss;
END dram1
// 16k 的栈空间,创建在 dram1 的尾巴后面
BEGIN dram1_stack
0xe0fc000: dataRam : dram1_stack : 0x4000 : writable ;
dram1_stack : C : 0xe0fc000 - 0xe0fffff : STACK : ;
END dram1_stack
// 存 os 相关的地址段
BEGIN sram0
0x10000000: instRam : sram0 : 0x2000000 : executable,writable ;
sram0 : F : 0x10000000 - 0x11ffffff: HEAP : .sram.rodata .rtos.data
END sram0
从注释中我们可以看出,xmm 文件规定了运行时的数据、指令、栈、os 等各部分的地址范围。
算子调用流程
有了上一节的背景知识,我们来感性地了解下一个 DSP 算子是如何被调起来的。
我们从 CPU 侧发起调用,通过 rpc 协议调起 DSP 侧提供的服务,将 CPU 侧程序称为 rpc_host,而 DSP 侧程序称为 rpc_dsp。rpc_dsp 负责起一个线程监听来自 rpc_host 的 message,并从 message 解析出需要进行的动作,并在执行完该动作后回复 rpc_host 一个 message。我们需要预先将 rpc_dsp 编译成可执行程序,再将可执行程序 dump 成 bin 文件,这里称为 dsp_bin(包含 iram.bin 和 sram.bin)。而 CPU 侧负责准备算子调用的所有输入,并装载编译好的 dsp_bin 到 DSP 的 dram 中(前文介绍 LSP 的部分有说明应该如何进行内存映射),同时把 rpc_dsp 侧的监听线程 run 起来,最后 rpc_host 发起 rpc 调用并等待 rpc 返回。
需要说明一点,CPU 和 DSP 之间一般会使用 IPCM(核间通信模块)实现对一段 ddr 地址空间的共享。但是 DSP 直接访问这段 ddr 的延迟是远大于访问 dram 的延迟,所以对于算子执行过程中需要频繁访问的 ddr 数据,一般是先使用 dma 将其搬运到 dram 上,算子执行结束后,计算的输出再通过 dma 搬回到 ddr。
以上就是算子调用流程的概述,搭配了一张时序图,图中用虚线框标出了具有时序关系的若干步骤,如下所示:

工具链介绍
Cadence 为 DSP 开发者提供了 Xtensa 开发包,里面包含了一整套编译、链接、执行、调试等相关的命令行工具。这些命令用法上很类似 GUN 的标准工具,而 Cadence 主要是加强了编译的部分,因为前面提到 Cadence DSP 使用 VLIW 进行加速,而 VLIW 技术要求编译器做更多的事情,来尽可能获得一个更优的编译期指令排布。
上一节讲述的调用流程是在 DSP 硬件上跑算子的流程,看上去不是很友好。好在 Xtensa 工具包里还提供了 Cadence DSP 的模拟器,使用 xt-run 命令就可以在模拟器中执行算子,从而使得开发验证、性能调试都可以脱离真实的硬件。
下面就以"hello world"为例,介绍一下命令行工具的使用:
// file: hello_world.c
#include <stdio.h>
int main() {
printf("hello world\n");
return 0;
}
编译:
xt-xcc hello_world.c -o hello_world.bin
不带内存模型执行,用于算子初版实现,不模拟访存延迟:
xt-run ./hello_world.bin
带内存模型执行,仿真性能非常逼近 DSP 硬件上的速度:
xt-run --mem_model ./hello_world.bin
带--summary 选项执行,可以对 cycle 分布有一个统计结果,比如 retaired inrstuction、branch delay、cache_miss 等各部分的 cycle 占比:
xt-run --summary ./hello_world.bin
如果需要 gdb 调试的话,可以用 xt-gdb:
xt-gdb ./hello_world.bin
如果需要 profiling 的话,需要先在执行期加--client_cmds="profile --all gmon.out 选项,用于在当前目录下生成各种 profiling 文件,包括 gmon.out.cyc, gmon.out.bdelay, gmon.out.interlock 等,然后使用 xt-gprof 工具查看上一步生成的 profiling 文件,比如执行下面两行命令就可以查看函数级别的 cycle 分布:
xt-run --client_cmds="profile --all gmon.out" ./hello_world.bin
xt-gprof ./hello_world.bin ./gmon.out.cyc > hello_world_cyc.txt
分块计算
Cadence DSP 主要应用场景是图像处理,现实的业务中图片尺寸经常都是 1080P 甚至 4K 的分辨率,而 DSP 的 dram 容量虽然可配置,但是通常都是 200KB 左右的级别(壕配十几兆 dram 的是例外),根本放不下一张大图,这就是导致了我们的算子必须分块计算。通过将大图分成一个个小块(tile), 每次通过 dma 从 ddr 搬运一个 src_tile 到 dram 上,执行算子得到一个 dst_tile, 再通过 dma 把 dst_tile 搬到 ddr 上。
认识 tile
拿一张图说明一下 tile 的具体参数:

可以看到 tile 分两层,里层的红色区域是原始数据区域,尺寸即 tile_width*tile_height, 外层是一圈 edge, 因为有些算子操作,比如 filter2d,计算的时候需要 padding,edge 的尺寸即为 padding 的大小。也正是因为 edge 的存在,才有了 pData 和 pBuffer 的区分。
dram 内存管理
tile 是分配在 dram 上的,就是 xmm 文件中的 dram0 和 dram1 段,dram 是我们自由使用的,所以就需要一个内存管理的逻辑。
首先定义一个数据结构 DramCtrl:
struct DramCtrl {
char* dram_start; // xmm 文件中 dram0/1 的起始地址
char* dram_end; // xmm 文件中 dram0/1 的终止地址
char* dram_cst_use; // 算子开发中可以自由使用的起始地址
char* dram_free; // 当前尚未分配区域的起始地址
char* dram_idx; // 区分不同 dram 段的索引
};
其中 dram_cst_use 参数的存在是因为有些变量必须分配在 dram 上,但是在调用不同算子的时候不需要更新,表现出一定的持久性。这种变量就包括 DramCtrl 本身,还有 dma 用于定义传输任务的 descriptors,所以刨掉这部分变量占用的空间,从 dram_cst_use 位置开始的 dram 才是算子调用自由使用的空间。
有了数据结构之后,还需要定义一些接口函数,才能满足基本的管理需求:
void dram_init(): 在 DSP 开机后,调用第一个算子前,执行 dram_init,初始化 DramCtrl 结构体,dram_cst_use=dram_free=dram_start+sizeof(DramCtrl)
void dram_static_alloc(): 在 dma_init 调用之后,分配 dma 的 descriptors,dram_cst_use+=sizeof(descriptors), dram_free=dram_cst_use
void dram_free_size(): 查询当前还有多少空闲内存,返回的是 dram_end-dram_free
void dram_alloc(sz): 分配 tile 等变量的空间,先 check 空闲空间大小,分配成功后修改 dram_free+=sz
void dram_reset(): 在一次算子执行结束后调用,重置 dram_free=dram_cst_use
pingpong dma 搬运
dma 完成一次 tile 搬运的延迟是相当可观的,如果 dma 搬运与算子调用是串行执行的话,性能就会严重受累于 dma 的搬运。所以正确的做法是,借用 pingpong buffer 的概念,在计算当前 tile 的同时,进行下一个 tile 的预取,这样 dma 搬运的时间就可以被计算时间隐藏。基于 pingpong dma 的算子执行逻辑如下:
step 0. dram_alloc src_tile[2], dst_tile[2] and set pingpong = 0
step 1. dma pull src_tile[pingpong]
step 2. dma sync, make src_tile[pingpong] be ready on dram
// loop begin ->
loop_for (h = 0; h < image_height; h += tile_height)
loop_for (w = 0; w < image_width; w += tile_width)
step 3. prefetch, using dma pull src_tile[pingpong^1]
step 4. exec on src_tile[pingpong] to get dst_tile[pingong]
step 5. dma sync, sync for last iter dma push and this iter prefetch
step 6. dma push dst_tile[pingong]
step 7. pingpong = pingpong^1
// loop end <-
step 8. dma sync & dram_reset
分块逻辑
现在,我们已经认识了 tile 的概念,有了简单的 dram 内存管理,以及 pingpong dma 搬运和计算并行的逻辑,但是还缺了一块儿:分块逻辑。分块就是在 dram 容量的约束条件下,依据 src_tile 和 dst_tile 的尺寸关系确定 tile 的尺寸。其实没有普适的分块逻辑,很多时候都是具体问题具体分析,这里笔者根据开发经验给出三种分类:
- 第一类:src_tile 和 dst_tile 尺寸一致
比如 elelwise 类和 filter 类,elemwise 类算子输入输出的尺寸是完全一样的,filter 类只比 elemwise 类多了一圈 tile_edge。这一类算子的 tile 尺寸很好确定:假定算子的输入输出 image 个数之和为 inout_cnt,且 tile_width 等于 tile_height,则有
tile_w=tile_h=srqt(min_dram_sz / inout_cnt)
其中,min_dram_sz 是取两个 dram 容量的小值,因为 pingpong dma 的需要,实际分配的 tile 总数是 inout_cnt * 2。
- 第二类:src_tile 和 dst_tile 的尺寸不相等,但是有明确的相对关系
比如 resize 算子,src_tile 和 dst_tile 的尺寸不再是一样的,但是缩放比例 scale_x 和 scale_y 决定了 tile 的尺寸关系:
dst_tile_w=dst_tile_h=srqt(min_dram_sz / (1.0 + scale_x * scale_y))
src_tile_w=dst_tile_w * scale_x
src_tile_h=dst_tile_h * scale_y
- 第三类:src_tile 和 dst_tile 没有明确的尺寸关系
比如 warp_perspective 算子,因为一个矩形的 dst_tile 通过 warp_perspective 映射到 src_image 上,得到的是一个凸四边形,需要框出这个凸四边形的 bounding_box 作为 src_tile。另外,很重要的一点,相同尺寸不同坐标位置的 dst_tile 映射得到的 src_tile 尺寸也是不一样的。为了保证 dst_image 中所有 dst_tile 映射得到的 src_tile 在 dram 中都能放得下,就需要一个搜索策略来确定 tile 的尺寸:
int guess_tile_size(min_dram_sz, frame) {
int l = 0, r = sqrt(min_dram_sz);
while (l <= r) {
int mid = (l + r) /