作者:洪超 | 曠視科技 MegEngine 架構(gòu)師
前言
Cadence 的 Vision P6/Q6/Q7 系列 DSP 在很多的 ISP (“Image Signal Processor”) 芯片中都有部署,可以在圖像處理場景補(bǔ)充甚至碾壓 CPU 算力。而且 Cadence 官方提供了一個(gè)比較全的基礎(chǔ)算子庫 libxi,很多標(biāo)準(zhǔn)算子在 libxi 中都有特定參數(shù)組合下的參考實(shí)現(xiàn)。但是鑒于 Cadence DSP 開發(fā)群體比較小,網(wǎng)絡(luò)上能找到的中文資源幾乎沒有,從零進(jìn)入開發(fā)狀態(tài)的門檻還是不低的。本文梳理了一些 Cadence DSP 算子開發(fā)中的重點(diǎn),希望可以給對 Cadence DSP 開發(fā)有興趣的同學(xué)帶來幫助。
DSP 架構(gòu)特點(diǎn)
首先,以 Cadence 的 Q7 為例,介紹一下 DSP 架構(gòu)上的特性。下圖是 Q7 硬件架構(gòu)的簡化。

從圖中可以直觀的得到 DSP 處理器的算力、寄存器等信息,注意 DSP 上有兩塊 data ram(簡稱 dram),每一塊 dram 又分為兩個(gè)寬為 512bit 的 bank。同時(shí),DSP 上有兩個(gè) Load/Store 單元,Load/Store 模塊訪問 dram 的帶寬都是 512bit,所以理論上的訪存帶寬是 1024bit/cycle,而獨(dú)立于 Load/Store 的 SuperGather 模塊是為了支持 DSP 上高效的 gather/scatter 操作。另外,可以看到 DSP 還有一個(gè) dma 模塊,該模塊用于片外空間和 dram 之間的數(shù)據(jù)傳輸。
為了充分利用算力和訪存能力,Cadence DSP 支持了 SIMD(Single Instruction, Multiple Data) 和 VLIW(Very Long Insruction Word) 兩種特性。前者支持 64lanes * 8bit 或 32lanes * 16bit 等總位寬為 512bit 的向量訪存和向量計(jì)算,后者是一種謀求指令級并行 (ILP, instruction level parallelism) 的技術(shù)。VLIW 可以將多個(gè)指令打包后在一起同時(shí)發(fā)射,從而獲取指令級的并行度。與超標(biāo)量、亂序執(zhí)行等其他 ILP 技術(shù)不同的是,VLIW 的并行指令排布是在編譯期就確定好的,而不需要 CPU 進(jìn)行復(fù)雜的運(yùn)行時(shí)調(diào)度。VLIW 使得 DSP 處理器在不需要大幅增加硬件復(fù)雜度的情況下,就可以獲取 ILP 的加速收益。
還要補(bǔ)充一點(diǎn),Cadence DSP 是哈弗架構(gòu),其指令和數(shù)據(jù)獨(dú)立編址,具體的編址規(guī)格由 LSP(Linker Support Package) 決定,而用戶可以通過名為 memmap.xmm 的內(nèi)存配置文件來定義和修改 LSP。截取了一段 xmm 文件的內(nèi)容,簡單注釋如下:
// 存指令的地址段
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 的??臻g,創(chuàng)建在 dram1 的尾巴后面
BEGIN dram1_stack
0xe0fc000: dataRam : dram1_stack : 0x4000 : writable ;
dram1_stack : C : 0xe0fc000 - 0xe0fffff : STACK : ;
END dram1_stack
// 存 os 相關(guān)的地址段
BEGIN sram0
0x10000000: instRam : sram0 : 0x2000000 : executable,writable ;
sram0 : F : 0x10000000 - 0x11ffffff: HEAP : .sram.rodata .rtos.data
END sram0
從注釋中我們可以看出,xmm 文件規(guī)定了運(yùn)行時(shí)的數(shù)據(jù)、指令、棧、os 等各部分的地址范圍。
算子調(diào)用流程
有了上一節(jié)的背景知識,我們來感性地了解下一個(gè) DSP 算子是如何被調(diào)起來的。
我們從 CPU 側(cè)發(fā)起調(diào)用,通過 rpc 協(xié)議調(diào)起 DSP 側(cè)提供的服務(wù),將 CPU 側(cè)程序稱為 rpc_host,而 DSP 側(cè)程序稱為 rpc_dsp。rpc_dsp 負(fù)責(zé)起一個(gè)線程監(jiān)聽來自 rpc_host 的 message,并從 message 解析出需要進(jìn)行的動作,并在執(zhí)行完該動作后回復(fù) rpc_host 一個(gè) message。我們需要預(yù)先將 rpc_dsp 編譯成可執(zhí)行程序,再將可執(zhí)行程序 dump 成 bin 文件,這里稱為 dsp_bin(包含 iram.bin 和 sram.bin)。而 CPU 側(cè)負(fù)責(zé)準(zhǔn)備算子調(diào)用的所有輸入,并裝載編譯好的 dsp_bin 到 DSP 的 dram 中(前文介紹 LSP 的部分有說明應(yīng)該如何進(jìn)行內(nèi)存映射),同時(shí)把 rpc_dsp 側(cè)的監(jiān)聽線程 run 起來,最后 rpc_host 發(fā)起 rpc 調(diào)用并等待 rpc 返回。
需要說明一點(diǎn),CPU 和 DSP 之間一般會使用 IPCM(核間通信模塊)實(shí)現(xiàn)對一段 ddr 地址空間的共享。但是 DSP 直接訪問這段 ddr 的延遲是遠(yuǎn)大于訪問 dram 的延遲,所以對于算子執(zhí)行過程中需要頻繁訪問的 ddr 數(shù)據(jù),一般是先使用 dma 將其搬運(yùn)到 dram 上,算子執(zhí)行結(jié)束后,計(jì)算的輸出再通過 dma 搬回到 ddr。
以上就是算子調(diào)用流程的概述,搭配了一張時(shí)序圖,圖中用虛線框標(biāo)出了具有時(shí)序關(guān)系的若干步驟,如下所示:

工具鏈介紹
Cadence 為 DSP 開發(fā)者提供了 Xtensa 開發(fā)包,里面包含了一整套編譯、鏈接、執(zhí)行、調(diào)試等相關(guān)的命令行工具。這些命令用法上很類似 GUN 的標(biāo)準(zhǔn)工具,而 Cadence 主要是加強(qiáng)了編譯的部分,因?yàn)榍懊嫣岬?Cadence DSP 使用 VLIW 進(jìn)行加速,而 VLIW 技術(shù)要求編譯器做更多的事情,來盡可能獲得一個(gè)更優(yōu)的編譯期指令排布。
上一節(jié)講述的調(diào)用流程是在 DSP 硬件上跑算子的流程,看上去不是很友好。好在 Xtensa 工具包里還提供了 Cadence DSP 的模擬器,使用 xt-run 命令就可以在模擬器中執(zhí)行算子,從而使得開發(fā)驗(yàn)證、性能調(diào)試都可以脫離真實(shí)的硬件。
下面就以"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
不帶內(nèi)存模型執(zhí)行,用于算子初版實(shí)現(xiàn),不模擬訪存延遲:
xt-run ./hello_world.bin
帶內(nèi)存模型執(zhí)行,仿真性能非常逼近 DSP 硬件上的速度:
xt-run --mem_model ./hello_world.bin
帶--summary 選項(xiàng)執(zhí)行,可以對 cycle 分布有一個(gè)統(tǒng)計(jì)結(jié)果,比如 retaired inrstuction、branch delay、cache_miss 等各部分的 cycle 占比:
xt-run --summary ./hello_world.bin
如果需要 gdb 調(diào)試的話,可以用 xt-gdb:
xt-gdb ./hello_world.bin
如果需要 profiling 的話,需要先在執(zhí)行期加--client_cmds="profile --all gmon.out 選項(xiàng),用于在當(dāng)前目錄下生成各種 profiling 文件,包括 gmon.out.cyc, gmon.out.bdelay, gmon.out.interlock 等,然后使用 xt-gprof 工具查看上一步生成的 profiling 文件,比如執(zhí)行下面兩行命令就可以查看函數(shù)級別的 cycle 分布:
xt-run --client_cmds="profile --all gmon.out" ./hello_world.bin
xt-gprof ./hello_world.bin ./gmon.out.cyc > hello_world_cyc.txt
分塊計(jì)算
Cadence DSP 主要應(yīng)用場景是圖像處理,現(xiàn)實(shí)的業(yè)務(wù)中圖片尺寸經(jīng)常都是 1080P 甚至 4K 的分辨率,而 DSP 的 dram 容量雖然可配置,但是通常都是 200KB 左右的級別(壕配十幾兆 dram 的是例外),根本放不下一張大圖,這就是導(dǎo)致了我們的算子必須分塊計(jì)算。通過將大圖分成一個(gè)個(gè)小塊(tile), 每次通過 dma 從 ddr 搬運(yùn)一個(gè) src_tile 到 dram 上,執(zhí)行算子得到一個(gè) dst_tile, 再通過 dma 把 dst_tile 搬到 ddr 上。
認(rèn)識 tile
拿一張圖說明一下 tile 的具體參數(shù):

可以看到 tile 分兩層,里層的紅色區(qū)域是原始數(shù)據(jù)區(qū)域,尺寸即 tile_width*tile_height, 外層是一圈 edge, 因?yàn)橛行┧阕硬僮鳎热?filter2d,計(jì)算的時(shí)候需要 padding,edge 的尺寸即為 padding 的大小。也正是因?yàn)?edge 的存在,才有了 pData 和 pBuffer 的區(qū)分。
dram 內(nèi)存管理
tile 是分配在 dram 上的,就是 xmm 文件中的 dram0 和 dram1 段,dram 是我們自由使用的,所以就需要一個(gè)內(nèi)存管理的邏輯。
首先定義一個(gè)數(shù)據(jù)結(jié)構(gòu) DramCtrl:
struct DramCtrl {
char* dram_start; // xmm 文件中 dram0/1 的起始地址
char* dram_end; // xmm 文件中 dram0/1 的終止地址
char* dram_cst_use; // 算子開發(fā)中可以自由使用的起始地址
char* dram_free; // 當(dāng)前尚未分配區(qū)域的起始地址
char* dram_idx; // 區(qū)分不同 dram 段的索引
};
其中 dram_cst_use 參數(shù)的存在是因?yàn)橛行┳兞勘仨毞峙湓?dram 上,但是在調(diào)用不同算子的時(shí)候不需要更新,表現(xiàn)出一定的持久性。這種變量就包括 DramCtrl 本身,還有 dma 用于定義傳輸任務(wù)的 descriptors,所以刨掉這部分變量占用的空間,從 dram_cst_use 位置開始的 dram 才是算子調(diào)用自由使用的空間。
有了數(shù)據(jù)結(jié)構(gòu)之后,還需要定義一些接口函數(shù),才能滿足基本的管理需求:
void dram_init(): 在 DSP 開機(jī)后,調(diào)用第一個(gè)算子前,執(zhí)行 dram_init,初始化 DramCtrl 結(jié)構(gòu)體,dram_cst_use=dram_free=dram_start+sizeof(DramCtrl)
void dram_static_alloc(): 在 dma_init 調(diào)用之后,分配 dma 的 descriptors,dram_cst_use+=sizeof(descriptors), dram_free=dram_cst_use
void dram_free_size(): 查詢當(dāng)前還有多少空閑內(nèi)存,返回的是 dram_end-dram_free
void dram_alloc(sz): 分配 tile 等變量的空間,先 check 空閑空間大小,分配成功后修改 dram_free+=sz
void dram_reset(): 在一次算子執(zhí)行結(jié)束后調(diào)用,重置 dram_free=dram_cst_use
pingpong dma 搬運(yùn)
dma 完成一次 tile 搬運(yùn)的延遲是相當(dāng)可觀的,如果 dma 搬運(yùn)與算子調(diào)用是串行執(zhí)行的話,性能就會嚴(yán)重受累于 dma 的搬運(yùn)。所以正確的做法是,借用 pingpong buffer 的概念,在計(jì)算當(dāng)前 tile 的同時(shí),進(jìn)行下一個(gè) tile 的預(yù)取,這樣 dma 搬運(yùn)的時(shí)間就可以被計(jì)算時(shí)間隱藏。基于 pingpong dma 的算子執(zhí)行邏輯如下:
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
分塊邏輯
現(xiàn)在,我們已經(jīng)認(rèn)識了 tile 的概念,有了簡單的 dram 內(nèi)存管理,以及 pingpong dma 搬運(yùn)和計(jì)算并行的邏輯,但是還缺了一塊兒:分塊邏輯。分塊就是在 dram 容量的約束條件下,依據(jù) src_tile 和 dst_tile 的尺寸關(guān)系確定 tile 的尺寸。其實(shí)沒有普適的分塊邏輯,很多時(shí)候都是具體問題具體分析,這里筆者根據(jù)開發(fā)經(jīng)驗(yàn)給出三種分類:
- 第一類:src_tile 和 dst_tile 尺寸一致
比如 elelwise 類和 filter 類,elemwise 類算子輸入輸出的尺寸是完全一樣的,filter 類只比 elemwise 類多了一圈 tile_edge。這一類算子的 tile 尺寸很好確定:假定算子的輸入輸出 image 個(gè)數(shù)之和為 inout_cnt,且 tile_width 等于 tile_height,則有
tile_w=tile_h=srqt(min_dram_sz / inout_cnt)
其中,min_dram_sz 是取兩個(gè) dram 容量的小值,因?yàn)?pingpong dma 的需要,實(shí)際分配的 tile 總數(shù)是 inout_cnt * 2。
- 第二類:src_tile 和 dst_tile 的尺寸不相等,但是有明確的相對關(guān)系
比如 resize 算子,src_tile 和 dst_tile 的尺寸不再是一樣的,但是縮放比例 scale_x 和 scale_y 決定了 tile 的尺寸關(guān)系:
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 沒有明確的尺寸關(guān)系
比如 warp_perspective 算子,因?yàn)橐粋€(gè)矩形的 dst_tile 通過 warp_perspective 映射到 src_image 上,得到的是一個(gè)凸四邊形,需要框出這個(gè)凸四邊形的 bounding_box 作為 src_tile。另外,很重要的一點(diǎn),相同尺寸不同坐標(biāo)位置的 dst_tile 映射得到的 src_tile 尺寸也是不一樣的。為了保證 dst_image 中所有 dst_tile 映射得到的 src_tile 在 dram 中都能放得下,就需要一個(gè)搜索策略來確定 tile 的尺寸:
int guess_tile_size(min_dram_sz, frame) {
int l = 0, r = sqrt(min_dram_sz);
while (l <= r) {
int mid = (l + r) / 2;
int ret = 0;
ret = iter_warp_perspective(mid, frame);
if (ret < 0)
r = mid - 1; // ret < 0 表示當(dāng)前嘗試的 dst_tile 的尺寸會使得 src_tile 在 dram 上放不下,所以可行域直接減半
else
l = mid + 1; // ret = 0 表示當(dāng)前嘗試的 dst_tile 的尺寸是 ok 的,但是繼續(xù)嘗試更優(yōu)解
}
if (r < 0) {
LOG(ERROR, "get tile size failed %d\n");
return -1;
}
LOG(DEBUG, "get the best guess tile width %d\n", r);
return r;
}
其中,frame 里存的 dst_image 的整圖尺寸,iter_warp_perspective 里的邏輯就是遍歷 dst_image 各個(gè)坐標(biāo)位置的 dst_tile,通過 warp_perspective 的映射矩陣反算出 src_tile 的 bounding_box 的大小,并檢查 dram 是否放得下。如果所有位置的 check 都通過了,iter_warp_perspective 返回 0,反之返回-1。
ISA 介紹
先插播一段語法介紹,Cadence DSP 上的 SIMD 指令大體由四部分組成:prefix_op_size_suffix。第一部分的指令前綴都是 IVP(image vector prcessing); 第二部分就是具體運(yùn)算指令的名稱縮寫,如 ADD,MUL,SEL 等;第三部分是指定向量中的通道關(guān)系,比如是 64lanes * 8bit 還是 32lanes * 16it,不過前者實(shí)際寫成 2NX8, 后者寫成 NX16,因?yàn)樵谶@里 N 表示 32; 第四部分是一些后綴的修飾詞,比如 U 表示一元運(yùn)算的數(shù)據(jù)是無符號數(shù),US 表示二元運(yùn)算的數(shù)據(jù)分別是無符號數(shù)和有符號數(shù),T 表示該運(yùn)算會帶 mask, PACK 表示該運(yùn)算會對中間計(jì)算結(jié)果做位壓縮再返回較窄的數(shù)據(jù)類型,等等。
現(xiàn)在放幾條簡單的 SIMD 指令,讓大家對號入座,溫故一下:
IVP_ADDNX16: 32lanes * 16bit 有符號整數(shù)的加法運(yùn)算
IVP_MUL2NX8U: 64lanes * 8bit 無符號整數(shù)的乘法運(yùn)算
IVP_LV2NX8U_I: LV 表示 vector load, _I 后綴在這里是表示有一個(gè)立即數(shù)(immediate)的 offset,該命令是在一個(gè) 64byte 對齊的地址(base_ptr + offset)上 load 64lanes * 8bit 的數(shù)據(jù)
考慮到介紹 ISA 是比較枯燥的,而且很多人對 CPU 上的 SIMD 指令都有一些了解,所以這里只展開介紹四組較于一般的 SIMD 實(shí)現(xiàn)有一些不同點(diǎn),同時(shí)使用頻率非常高的指令。
第一組:帶指針自動對齊、自動偏移以及支持可變長度的 VLOAD 指令
Cadence DSP 要求 VLOAD 訪問不可以跨 bank,而 bank 的位寬是 512bit,也即限制了 VLOAD 的地址必須是 64byte 對齊的。如果地址滿足對齊要求,就可以使用 IVP_LVxxx 指令直接進(jìn)行訪存操作,反之就需要使用 IVP_LAxxx 指令進(jìn)行指針自動對齊的訪存操作:
void IVP_LAVNX16_XP(xb_vecNx16 v_out, valign a_load /*inout*/, const xb_vecNx16 * src_ptr /*inout*/, int bytes_cnt);
在單次或連續(xù)一組 IVP_LAVNX16_XP 調(diào)用前需要調(diào)用一次:
valign a_load = IVP_LANX16_PP(src_ptr);
其中,a_load 存放的是起始地址為 [src_ptr & 0x40] 連續(xù) 64byte 的數(shù)據(jù),a_load 的 64bytes 和 [src_ptr & 0x40 + 64] 地址處連續(xù) load 的 64bytes 組成一個(gè) 128byte 的數(shù)組,以 [src_ptr | 0x40] 為偏移量從 128bytes 的數(shù)組中截取 bytes_cnt 個(gè) bytes 輸出到 v_out。注意 bytes_cnt 的值會被截?cái)嗟?0~64 的合法范圍,也意味著這個(gè)指令可以 cover 不足 64byte 的 load 操作,也就是所謂 tail_load。還有一個(gè)要說明的特點(diǎn)是,這個(gè)指令在 load 操作完成后會更新 src_ptr 和 a_load,src_ptr 的偏移量為 bytes_cnt 截?cái)嗪蟮闹?,a_load 更新為 v_out 的內(nèi)容,這兩項(xiàng)更新使得該指令可以連續(xù)調(diào)用,而不用重新調(diào)用 IVP_LANX16_PP 和手動移動指針 src_ptr。
第二組:multiply、pack
Cadence DSP 上典型的計(jì)算流是 load 數(shù)據(jù)到 vector 中,施加計(jì)算指令,如果得到的中間結(jié)果的數(shù)值范圍有升位的需求,就需要用位寬更大的 wide vector 來存,而后再通過 PACK 類指令將 wide vector 中的數(shù)據(jù)安全地壓縮到 vector 的位寬表達(dá)范圍內(nèi):
xb_vec2Nx24 IVP_MULUSP2N8XR16(xb_vec2Nx8U b, xb_vec2Nx8U c, xb_int32 d);
上面這條命令是兩個(gè)類型為 xb_vec2Nx8U 的 vector 和兩個(gè) int16 捉對進(jìn)行向量乘法,兩個(gè)向量乘法的結(jié)果做一次向量加法,得到的輸出是類型為 xb_vec2Nx24 的 wide vector。兩組乘法分別是 b 和 d 的高 16 位之間進(jìn)行,以及 c 和 d 的低 16 位之間進(jìn)行。
xb_vec2Nx8U IVP_PACKVRU2NX24(xb_vec2Nx24 b, int c);
而這條 pack 指令就是可以將類型為 xb_vec2Nx24 的 wide vector 中每一個(gè)通道 24bit 的數(shù)據(jù)右移 c 位,接著飽和處理到 u8 的表達(dá)范圍,得到的輸出就是類型為 xb_vec2Nx8U 的 vector。
第三組:select
有時(shí)候我們的算法邏輯需要對兩個(gè) vector 進(jìn)行 interleave 或者 deinterleave,下面這個(gè)指令就可以實(shí)現(xiàn):
void IVP_DSELNX16I(xb_vecNx16 a, xb_vecNx16 b, xb_vecNx16 c, xb_vecNx16 d, immediate e);
該命令會將 d 中的 64byte 數(shù)據(jù)看成 64lanes * u8,c 中的 64byte 數(shù)據(jù)看成 64lanes * u8,然后先 d 后 c,從低位到高位,將 d 和 c 中的數(shù)據(jù)拼接成一個(gè) 128lanes * u8 數(shù)組。而 e 是一個(gè)立即數(shù),e 的每一個(gè)不同取值都對應(yīng)一個(gè)預(yù)置的 index_list,每一個(gè) index_list 都是 0~127 整數(shù)序列的一個(gè)重排列。預(yù)置的 index_list 有 8bit/16bit 兩種粒度的 interleave/deinterleave 操作,額外的,還支持各種 rotate_left/rotate_right 操作。
但是可能會遇到,IVP_DSEL2NX8I 中預(yù)置的若干種 index_list 均不能滿足我們的需求,那就需要下面這個(gè)命令:
void IVP_DSELNX16(xb_vecNx16 a, xb_vecNx16 b, xb_vecNx16 c, xb_vecNx16 d, xb_vec2Nx8 e);
該命令將 d 和 c 中的數(shù)據(jù)按照先 d 后 c, 從低位到高位的順序排成了一個(gè) 64lanes * 16bit 的數(shù)組,而 e 中數(shù)據(jù)就是 0~63 整數(shù)序的一個(gè)自定義序列。
第四組:gather/scatter
最后一組要介紹的指令就是高效的 gather/scatter。gather 是從一組不連續(xù)的 dram 地址中 load 數(shù)據(jù)存到一個(gè) vector 里面,而 scatter 就是反向操作,將一個(gè) vector 里面的數(shù)據(jù) store 到離散的 dram 地址中去。想象中 gather/scatter 的指令開銷應(yīng)該非常大,但是實(shí)際應(yīng)用中發(fā)現(xiàn) gather/scatter 確實(shí)比一般的指令多花一些 cycle,但是 overhead 不明顯,且有些場景不用 gather/scatter 的話 SIMD 就玩不轉(zhuǎn)了,就只能用標(biāo)量計(jì)算了。
簡單解釋一下 Cadence DSP 的 gather/scatter 效率高的原因。gather/scatter 指令不同于普通指令,gather/scatter 在觸發(fā) issue 之后是由 SuperGather 硬件模塊全權(quán)接管。后者會將 dram 512bit 寬的 bank 進(jìn)一步拆分成 8 個(gè) 64bit 寬的 sub-bank,并從硬件層面支持同時(shí) load 分布在不同 sub_bank 的數(shù)據(jù)(當(dāng)然這里存在更嚴(yán)重的 sub_bank_conflict 的風(fēng)險(xiǎn),后文詳細(xì)解釋)。此外,gather 還被拆分成兩個(gè)子命令,gathera 和 gatherd。gathera 才是 SuperGather 實(shí)際接管的指令,該指令負(fù)責(zé)收集離散地址上的數(shù)據(jù)到 gr 寄存器(gather register)。拆分指令的原因是 gathera 可以異步執(zhí)行,不阻塞 DSP 的處理器繼續(xù)執(zhí)行其他指令。而 gatherd 是一條執(zhí)行在 DSP 處理器上的指令,負(fù)責(zé)將 gr 寄存器里面收集完畢的數(shù)據(jù)拷貝到普通的 vector 寄存器,所以只有依賴 gatherd 返回值的命令才必須等待 gather 操作執(zhí)行完畢。至于 scatter,除了 sub_bank 并發(fā) store 的功勞,最關(guān)鍵的原因是 scatter 在遇到 sub_bank_conflict 的時(shí)候會做硬件層面的 buffer,等到有空閑 slot 的時(shí)候再調(diào)度 store 操作。
gather/scatter 的指令如下:
xb_gsr IVP_GATHERANX8U(const unsigned char * base_ptr, xb_vecNx16U offset_vec);
xb_vecNx16U IVP_GATHERDNX16(xb_gsr b);
void IVP_SCATTERNX16U(xb_vecNx16U out, const unsigned short * base_ptr, xb_vecNx16U offset_vec);
這里解釋一下,IVP_GATHERANX8U 是 gather 32lanes * u8 的數(shù)據(jù),然后每一個(gè)通道的 u8 數(shù)據(jù)高位補(bǔ) 0 拓展到 u16, 所以 gr 寄存器里面存的是 32lanes * u16 的數(shù)據(jù)。
性能優(yōu)化
前文介紹了一些高頻使用的 SIMD 指令,讀者可以嘗試開發(fā)自己的 DSP 算子了,但是第一版實(shí)現(xiàn)的性能可能不 ok,所以本節(jié)將補(bǔ)充一些優(yōu)化算子性能的知識點(diǎn)。
理解 SWP
首先要介紹 Cadence DSP 的編譯器進(jìn)行優(yōu)化調(diào)度的核心概念--SWP(software pipeline),不同于處理器執(zhí)行指令時(shí)進(jìn)行硬件層面的流水,SWP 是編譯器對算子 inner loop 的不同 iter 的指令進(jìn)行軟件層面的流水,目的就是讓 inner loop 編譯后的 VLIW 中有效指令的密度更高,最小化 nop 的比例。
為了更直觀的理解 SWP, 下面以 alphablend 為例,詳細(xì)講解一下編譯器實(shí)際調(diào)度得到的 SWP:
#define _LOCAL_DRAM0_ __attribute__((section(".dram0.data"))) // 變量是分配在 dram0 上
#define _LOCAL_DRAM1_ __attribute__((section(".dram1.data"))) // 變量是分配在 dram1 上
#define ALIGN64 __attribute__((aligned(64))) // 變量在 dram 上的起始地址是 64byte 對齊的
#define WIDTH 256
#define HEIGHT 32
#define DATA_SIZE 8192 // 256 * 32 = 8192
uint8_t _LOCAL_DRAM0_ ALIGN64 src0[DATA_SIZE];
uint8_t _LOCAL_DRAM1_ ALIGN64 src1[DATA_SIZE];
uint8_t _LOCAL_DRAM1_ ALIGN64 dst[DATA_SIZE];
void alpha_blend(uint8_t* psrc0, uint8_t* psrc1, uint8_t* pdst, int16_t alpha) {
int32_t i, j, alpha_beta;
xb_vec2Nx8U* __restrict vpsrc0 = (xb_vec2Nx8U*) psrc0;
xb_vec2Nx8U* __restrict vpsrc1 = (xb_vec2Nx8U*) psrc1;
xb_vec2Nx8U* __restrict vpdst = (xb_vec2Nx8U*) pdst;
xb_vec2Nx8U vsrc0, vsrc1, vdst;
xb_vec2Nx24 wvec0;
alpha_beta = ((0x3fff - alpha) << 16) + alpha;
// DATA_SIZE = 256 * 32
// XCHAL_IVPN_SIMD_WIDTH = 32
for (i = 0; i < DATA_SIZE / 2 / XCHAL_IVPN_SIMD_WIDTH; ++i) {
vsrc0 = *vpsrc0++; // 因?yàn)檫@里 psrc0/psrc1 的地址是 64byte 對齊的,
// 所以匯編指令為 ivp_lv2nx8_ip vsrc0,vpsrc0,64
vsrc1 = *vpsrc1++;
wvec0 = IVP_MULUSP2N8XR16(vsrc1, vsrc0, alpha_beta);
vdst = IVP_PACKVRU2NX24(wvec0, 14);
*vpdst++ = vdst;
}
}
// call alpha_blend in main function
alpha_blend(src0, src1, dst, 8192);
上面的代碼就是用 SIMD 寫的一個(gè) alpha_blend 算子,使用命令行工具拿到編譯器調(diào)度后的匯編文件:
xt-xcc -S alphablend.c -o alphablend.s -O2
截取匯編文件中的 SWP 的部分如下:
#<loop> Loop body line 139, nesting depth: 1, kernel iterations: 62
#<loop> unrolled 2 times
#<swps>
#<swps> 4 cycles per pipeline stage in steady state with unroll=2
#<swps> 3 pipeline stages
#<swps> 10 real ops (excluding nop)
#<swps>
#<swps> 4 cycles lower bound required by resources
#<swps> min 3 cycles required by recurrences
#<swps> min 4 cycles required by resources/recurrence
#<swps> min 9 cycles required for critical path
#<swps> 12 cycles non-loop schedule length
#<swps> register file usage:
#<swps> 'a' total 4 out of 16 [2-4,10]
#<swps> 'v' total 6 out of 32 [0-5]
#<swps> 'wv' total 2 out of 4 [0-1]
#<swps> 'pr' total 1 out of 16 [0]
#<swps>
#<freq> BB:72 => BB:72 probability = 0.98438
#<freq> BB:72 => BB:79 probability = 0.01562
.frequency 1.000 63.492
// steady 階段
{ # format N2
ivp_lv2nx8_ip v0,a2,128 # [0*II+0] id:45
ivp_lv2nx8_i v3,a2,64 # [0*II+0] id:45
}
{ # format N2
ivp_lv2nx8_ip v1,a3,128 # [0*II+1] id:46
ivp_lv2nx8_i v4,a3,64 # [0*II+1] id:46
}
{ # format F0
ivp_sv2nx8_ip v2,a4,128 # [2*II+2] id:47
ivp_packvru2nx24 v2,wv0,a10 # [1*II+2]
ivp_mulusp2n8xr16 wv0,v1,v0,pr0 # [0*II+2]
nop #
}
{ # format F0
ivp_sv2nx8_i v5,a4,-64 # [2*II+3] id:47
ivp_packvru2nx24 v5,wv1,a10 # [1*II+3]
ivp_mulusp2n8xr16 wv1,v4,v3,pr0 # [0*II+3]
nop #
}
從注釋部分可以看出,編譯器對循環(huán)體做了 unroll=2 的循環(huán)展開,展開后的 loop count 是 62,得到了一個(gè) 4cycle 3stage 的 SWP,并且告訴你該 SWP 中發(fā)射了 10 個(gè)非 nop 的指令(可以計(jì)算一下 CPI(cycle per instruction) 為 0.4),額外的還有一些寄存器占用比例的分析數(shù)據(jù)。
代碼部分的每一個(gè)花括號就是一個(gè) VLIW,注意每一個(gè) VLIW 編碼的指令數(shù)可以不一樣,這是因?yàn)?Cadence DSP 支持了十余種不同 format 的 VLIW(代碼中每個(gè)花括號上面都有一句注釋表明了 format 類型)。然后每一句指令的右邊都有一句注釋,只需關(guān)注方括號的部分,加號右邊的數(shù)字是表征當(dāng)前指令在 SWP 的第幾個(gè) cycle 發(fā)射出去,加號左邊與 II 相乘的數(shù)字表征的是 stage。因?yàn)?alphablend 調(diào)度出來的是一個(gè) 3stage 的 SWP,所以可以看到與 II 相乘的數(shù)字是 0,1,2,將其分別指代成 stage 0/1/2。這里 3 stage 的意思是,SWP 里會出現(xiàn)一個(gè) VLIW 同時(shí)打包了三個(gè) iter(unroll 之后的三個(gè) iter)的指令,stage 0 是當(dāng)前 iter, stage 1 是上一個(gè) iter, stage 2 是上上一個(gè) iter。
類比處理器硬件的 pipeline,上面這段代碼準(zhǔn)確說是 SWP 流水線填滿的 steady 階段,SWP 也有流水線填充和退出的階段,分別稱為 prologue 和 epilogue。這也解釋了原始 loop 的 loop count 其實(shí)是 256 * 32 / 32 / 2 為 128,但是 SWP unroll 之后的 loop count 不是 64,而是 62。為了更直觀的理解 SWP,下面將 prologue、steady 和 epilogue 三個(gè)階段的匯編代碼粘貼到一張圖中,如下:

從圖中可以看出,所謂的 SWP 就是將一個(gè)原始 iter 下不同的指令看成不同的 stage,并應(yīng)用流水線的概念,把一個(gè)原始 iter 中有嚴(yán)格時(shí)序邏輯的多個(gè)指令的發(fā)射時(shí)機(jī)分散到 SWP 的不同 iter 中,目的就是追求更低的 CPI。
其實(shí),我們還可以根據(jù)匯編代碼估計(jì)算子的執(zhí)行時(shí)間:steady 階段 4cycle * 62 + prologue 階段 7cycle + epilogue 階段 5cycle = 260cycle,執(zhí)行 xt-run 測得這個(gè)循環(huán)體的耗時(shí)是 276cycle,其他的 overhead 是 276-260=16cycle,所以根據(jù)匯編代碼估算的計(jì)算時(shí)間已經(jīng)很準(zhǔn)了(但是也有例外,后文會提及)。
了解了 SWP 的概念,接下來我們對 alphablend 的實(shí)現(xiàn)做一些修改,觀察對 SWP 的影響。第一個(gè)試驗(yàn)就是將修飾指針變量的__restrict 去掉,重新拿到匯編文件,SWP 現(xiàn)在長這樣:
#<loop> Loop body line 143, nesting depth: 1, iterations: 128
#<swps>
#<swps> 8 cycles per pipeline stage in steady state with unroll=1
#<swps> 1 pipeline stages
#<swps> 5 real ops (excluding nop)
#<swps>
#<swps> 2 cycles lower bound required by resources
#<swps> min 8 cycles required by recurrences
#<swps> min 8 cycles required by resources/recurrence
#<swps> min 8 cycles required for critical path
#<swps> 8 cycles non-loop schedule length
#<swps> register file usage:
#<swps> 'a' total 4 out of 16 [2-4,11]
#<swps> 'v' total 2 out of 32 [0-1]
#<swps> 'wv' total 1 out of 4 [0]
#<swps> 'pr' total 1 out of 16 [0]
#<swps>
#<freq> BB:30 => BB:30 probability = 0.99219
#<freq> BB:30 => BB:32 probability = 0.00781
.frequency 1.000 127.992
{ # format N2
ivp_lv2nx8_ip v0,a2,64 # [0*II+0] id:49
ivp_lv2nx8_ip v1,a3,64 # [0*II+0] id:50
}
{ # format N1
nop #
ivp_mulusp2n8xr16 wv0,v1,v0,pr0 # [0*II+1]
}
{ # format N2
nop #
ivp_packvru2nx24 v0,wv0,a11 # [0*II+4]
}
{ # format N1
ivp_sv2nx8_ip v0,a4,64 # [0*II+7] id:51
nop #
}
可以看到新的 SWP 沒有 unroll, 且 stage 為 1,這種情況表示編譯器沒有幫我們做 software pipeline,盡管還有這段 SWP 的注釋代碼,但是沒有做任何有意義的調(diào)度?,F(xiàn)在的 CPI=8/5=1.6,之前的版本 CPI 是 0.4, 所以性能下降了 3 倍多。當(dāng)然這里速度差別這么大,還有一個(gè)原因是 inner loop 的邏輯太簡單,不 unroll 的情況下編譯器實(shí)在沒有啥可調(diào)度的空間,無法發(fā)揮 VLIW 的優(yōu)勢。如果 inner loop 邏輯比較復(fù)雜,即使不 unroll,編譯器通過 VLIW 也能提高指令的并行度,與 SWP 有效調(diào)度后的性能差距就不會如此明顯。
這里解釋一下,可能有讀者看到 SWP 里面只有 4 個(gè) VLIW,不清楚為啥要用 8 個(gè) cycle。請注意看每條指令右側(cè)方括號里面的注釋,cycle 數(shù)確實(shí)是橫跨了 0~7,而中間不連續(xù)的數(shù)字都會替換成相應(yīng)個(gè)數(shù)的 bubble。為什么會產(chǎn)生 bubble? 是因?yàn)楫?dāng)前內(nèi)循環(huán)的四條 VLIW,調(diào)度的是同一個(gè) iter 的不同指令,相鄰的 VLIW 的數(shù)據(jù)又都是寫后讀的依賴關(guān)系,所以連續(xù)發(fā)射出去之后,會存在前一個(gè)指令的結(jié)果還沒有寫回,后一個(gè)指令已經(jīng)讀取該數(shù)并來到 execute 階段了,也就是所謂的 pipeline interlock。為了解決 interlock,就需要在前后兩條 VLIW 之間加一定數(shù)量的 bubble??赡苓€會有細(xì)心的讀者糾結(jié)為啥 vload 和 vmul 之間沒有 bubble,這是因?yàn)楣P者在 Q7 上跑的代碼,而 Q7 對 dram 的 vload 延遲做了特殊優(yōu)化。如果在 P6 上跑這個(gè)代碼,就會看到 vload 和 vmul 之間也會有 bubble。
接著,我們針對 SWP 做第二個(gè)試驗(yàn):地址非對齊訪問。前面說過 src0/src1 都是 64byte 對齊的,所以看匯編代碼會發(fā)現(xiàn),vload 實(shí)際使用的是 ivp_lv2nx8_ip 指令。但是假設(shè)現(xiàn)在無法保證 src0/src1 是 64bytes 對齊的,就需要實(shí)現(xiàn)以下更通用版本的 alphablend:
void alpha_blend(uint8_t* psrc0, uint8_t* psrc1, uint8_t* pdst, int16_t alpha) {
// 注意,直接粗暴的把 64byte 對齊的地址都加了 1,構(gòu)造非對齊地址
psrc0++;
psrc1++;
pdst++;
int32_t i, j, alpha_beta;
xb_vec2Nx8U* __restrict vpsrc0 = (xb_vec2Nx8U*) psrc0;
xb_vec2Nx8U* __restrict vpsrc1 = (xb_vec2Nx8U*) psrc1;
xb_vec2Nx8U* __restrict vpdst = (xb_vec2Nx8U*) pdst;
xb_vec2Nx8U vsrc0, vsrc1, vdst;
xb_vec2Nx24 wvec0;
alpha_beta = ((0x3fff - alpha) << 16) + alpha;
// DATA_SIZE = 256 * 32
// XCHAL_IVPN_SIMD_WIDTH = 32
valign va_dst = IVP_ZALIGN();
valign a_load1 = IVP_LA2NX8U_PP(vpsrc0);
valign a_load2 = IVP_LA2NX8U_PP(vpsrc1);
for (i = 0; i < DATA_SIZE / 2 / XCHAL_IVPN_SIMD_WIDTH; ++i) {
IVP_LAV2NX8U_XP(vsrc0, a_load1, vpsrc0, DATA_SIZE - 1 - i * 64);
IVP_LAV2NX8U_XP(vsrc1, a_load2, vpsrc1, DATA_SIZE - 1 - i * 64);
wvec0 = IVP_MULUSP2N8XR16(vsrc1, vsrc0, alpha_beta);
vdst = IVP_PACKVRU2NX24(wvec0, 14);
IVP_SAV2NX8U_XP(vdst, va_dst, vpdst, DATA_SIZE - 1 - i * 64);
}
IVP_SAV2NX8UPOS_FP(va_dst, vpdst);
}
使用 xt-run 測得內(nèi)循環(huán)的耗時(shí)是 298cycle,略多于對齊地址版本的 276cycle,繼續(xù)查看非對齊地址版本的 SWP(unroll 太多,篇幅原因只截取 SWP 頭部的注釋):
#<loop> Loop body line 112, nesting depth: 1, kernel iterations: 15
#<loop> unrolled 8 times
#<swps>
#<swps> 16 cycles per pipeline stage in steady state with unroll=8
#<swps> 2 pipeline stages
#<swps> 48 real ops (excluding nop)
#<swps>
#<swps> 14 cycles lower bound required by resources
#<swps> min 8 cycles required by recurrences
#<swps> min 14 cycles required by resources/recurrence
#<swps> min 15 cycles required for critical path
#<swps> 23 cycles non-loop schedule length
#<swps> register file usage:
#<swps> 'a' total 12 out of 16 [2-5,8-15]
#<swps> 'v' total 4 out of 32 [0-3]
#<swps> 'u' total 3 out of 4 [0-2]
#<swps> 'wv' total 2 out of 4 [0-1]
#<swps> 'pr' total 1 out of 16 [0]
#<swps>
#<freq> BB:83 => BB:83 probability = 0.93750
#<freq> BB:83 => BB:88 probability = 0.06250
發(fā)現(xiàn)編譯器搜出來一個(gè) unroll=8,CPI=16/48=0.33(比地址對齊版本的 0.4 更低一點(diǎn))的 SWP,但是因?yàn)?unroll 太大,prologue/epilogue 的 CPI 比較大才導(dǎo)致總的 cycle 數(shù)略大于地址對齊版本。但是如果 loop_count 更大一點(diǎn),兩個(gè)版本的速度差異就更小了。不知道讀者會不會失望了,這個(gè)試驗(yàn)的結(jié)果并沒有告訴我們怎么做速度更快,只是得出一個(gè)結(jié)論:非地址對齊的算子速度不一定比地址對齊的版本要慢,但是非地址對齊版本的算子會更通用一點(diǎn)。
理解 bank_conflict
回過頭來填一個(gè)坑,為什么基于匯編代碼估算 DSP 時(shí)間有時(shí)候會不準(zhǔn)?其實(shí)原因前文也有提過,就是 bank_conflict 和 sub_bank_confilct 搞的鬼。
先說 bank_conflict 的影響,還是拿前面的 alphablend 做例子。該算子有兩個(gè)輸入 src0/src1,如果有兩條 vload 指令被調(diào)度到同一個(gè) VLIW 里面,且訪問的兩個(gè)地址是同一個(gè) dram 上同一個(gè) bank 的不同位置,就觸發(fā)了 bank_confilct,處理器必須 stall 一個(gè) cycle。直覺告訴我們?nèi)绻麑?src0 和 src1 放在不同的 dram 上,應(yīng)該會降低 bank_confilct 發(fā)生的概率。
做個(gè)試驗(yàn)驗(yàn)證下,把前面最初版本的 alphablend 的 src0/src1 都放到 dram0 上,測得內(nèi)循環(huán)的耗時(shí)從原先的 276cycles 變成了 280cycles。速度下降好像并不明顯,查看 SWP 沒有發(fā)生任何變化,后面這點(diǎn)倒是符合預(yù)期,因?yàn)榫幾g期并不會檢查每一次地址訪問有沒有發(fā)生 bank_conflict。仔細(xì)看匯編代碼可以發(fā)現(xiàn),steady 階段的代碼都是同一個(gè) dram 的連續(xù)兩個(gè) 64byte 的 vload 被放在了一個(gè) VLIW 里面,所以本次試驗(yàn)修改對其不產(chǎn)生影響。增加的 4 個(gè) cycle 其實(shí)是因?yàn)?prologue 階段有四個(gè)綁定了不同 dram 上 vload 指令的 VLIW,恰好這四個(gè) VLIW 都觸發(fā)了 bank_confilct??紤]到不同算子的調(diào)度情況是不一樣的,為了減少 bank_conflict 對性能的影響,我們還是應(yīng)該將多個(gè)輸入 tile 創(chuàng)建在不同的 dram 上。
接著,我們來分析一下 sub_bank_conflcit 對性能的影響。sub_bank_conflict 只會發(fā)生在 gathera 指令的執(zhí)行過程中,當(dāng) gathera 收集非連續(xù)地址上的多個(gè)數(shù)據(jù)時(shí),如果出現(xiàn)多個(gè)數(shù)據(jù)的地址正好在同一個(gè) dram 的同一個(gè) bank 的同一個(gè) sub_bank 中的不同地址時(shí),就會出現(xiàn)多次 sub_bank_conflict,最極端的情況下收集一個(gè) vector32,卻出現(xiàn)了 32 次 conflcit,gathera 收集完成需要 32 個(gè) cycle。
所以如果我們開發(fā)的算子里面用到了 gathera 指令,請加上-mem_model 選項(xiàng)在模擬器上跑一下,執(zhí)行完會打印一些統(tǒng)計(jì)參數(shù),其中一項(xiàng)就是 gather stall 的 cycle 數(shù)。如果不幸 gather stall 比較大,就需要檢查算子里面的訪存邏輯,看看是否需要調(diào)整 tile 上數(shù)據(jù)的分布情況,比如可以在 tile 的水平方向插入若干列的無用數(shù)據(jù),降低 gathera 目標(biāo)數(shù)據(jù)在同一個(gè) sub_bank 的可能性。
性能優(yōu)化小結(jié)
綜上,從算子實(shí)現(xiàn)的維度上看,Cadence DSP 算子的速度只受限于 SWP 調(diào)度出來的 CPI,以及訪存的 bank 沖突。
最后,將散落在本文各個(gè)地方會影響算子性能的點(diǎn)集中到一起(還有一些可以降低 CPI 的技巧前文沒有提及),做成 checklist 便于大家對照查看:
確認(rèn)算子頻繁訪問的數(shù)據(jù)是不是位于 dram 上?而不是 ddr 上。
確認(rèn)是否使用了 pingpog dma,以及使用了之后是否真的隱藏了 dma 搬運(yùn)的開銷?可以檢查 kernel 耗時(shí)占總耗時(shí)的比例。
確認(rèn) load/store 和 gather/scatter 訪問的 dram 指針是否都加上了__restrict?
如果算子核心計(jì)算邏輯是兩重 for 循環(huán),確認(rèn)是否將 tile_height 作為內(nèi)層循環(huán)?以及是否將外層循環(huán) unroll?
如果使用了 gather 指令,請查看 gather stall 的 cycle 數(shù)是否很高?然后對癥下藥。
確認(rèn)算子內(nèi)循環(huán)中使用的局部變量是否過多?如 filter2d 在 kernel_size 大于等于 5 的情況,為了避免寄存器溢出,需要將單一的內(nèi)循環(huán)拆分成多個(gè)小的獨(dú)立的內(nèi)循環(huán)。
如果是 elemwise 類操作,計(jì)算邏輯比較復(fù)雜,但是每一個(gè)像素值最終的處理結(jié)果在一個(gè)比較有限的范圍內(nèi),比如一些色彩處理類算子,輸入是 u8 或 u8 的二元組,經(jīng)過一系列處理邏輯,最后的結(jié)果還是 u8 或 u8 的二元組。這種情況下建議轉(zhuǎn)換思路看看查表操作是否 ok(因?yàn)槲覀冊?DSP 上有 SuperGather)。
如果算子有多個(gè)輸入,確認(rèn)有沒有施加降低 bank_confilct 的措施?
9、如果從較早的 DSP 型號上移植算子到較新的 DSP 型號上,比如移植 P6 上的代碼到 Q7,需要注意新指令的應(yīng)用,比如 Q7 比 P6 多了 Dual-Quad 8x8 和 Quad 32x16 multiply 兩個(gè)可選的增強(qiáng)模塊。
雜項(xiàng)
本節(jié)整理了一些在前文不便展開的細(xì)節(jié),但其實(shí)也很重要:
gathera 指令有很多變種,有些變種 gathera 指令的 offset_vec 是 16lanes * u32, 但是必須注意的是 offset_vec 里的數(shù)據(jù)必須是 0~65535 范圍內(nèi)。否則,SuperGather 會讀取不到對應(yīng)地址的數(shù)據(jù),并給該 lanes 直接賦 0。
開發(fā)測試的過程中可能會有修改 memmap.xmm 文件的需求,比如調(diào)整棧空間的位置和大小,只需要編輯完 xmm 文件之后,使用 xtensa 命令行工具 xt-genldscripts,在 xmm 文件所在目錄下執(zhí)行
"xt-genldscripts -b ."命令,即可在。/ldscripts 目錄下得到新的 linker scripts 文件,對 xmm 的修改也即生效。舉兩個(gè)有可能發(fā)生高頻 sub_bank_conflict 的例子:一個(gè)例子是 padding 算子在做 left_padding 和 right_padding 的時(shí)候,會 gather 一個(gè) tile 的某一列連續(xù)若干個(gè)數(shù)據(jù),如果恰好該列所有的數(shù)據(jù)都在同一個(gè) sub_bank 就會性能非常差;還有一個(gè)例子就是 transpose,dst_tile 的一行其實(shí)是 src_tile 的一列,所以 gather 的時(shí)候同樣有可能出現(xiàn)極端的 sub_bank_conflict。
解釋一下編譯器只對 inner loop 應(yīng)用 SWP 調(diào)度優(yōu)化的原因,Cadence DSP 的應(yīng)用定位就是圖形處理,而一般圖形處理算法的 inner loop 計(jì)算密度非常高,幾乎決定了整個(gè)算法的性能。
SWP 優(yōu)化調(diào)度的 inner loop 實(shí)際上是號稱 zero overhead loop 的,也就是說沒有普通 loop 檢查循環(huán)條件,更新 loop iter 等工作的開銷,但是上面的 alphablend 的例子中好像 inner loop 還是有一些 overhead 的,是因?yàn)橄胍@取 zero overhead 的 inner loop,還需要兩個(gè)額外條件:inner loop 里面指令數(shù)不能太少,且 loop count 相對較大。
前文提到將不同的輸入 tile 存放在不同 dram 上來減少 bank_confilct,但是減少 bank_conflict 的終極策略是:先將不同的 tile 存放在不同的 dram 上,然后代碼里使用
#pragma ymemory (tile_on_dram0)告訴編譯器哪一個(gè) tile 是在 dram1 上的,編譯器會將該 tile 的內(nèi)存類型標(biāo)記為 ymemory,而其他 tile 的內(nèi)存類型標(biāo)記為 xmemory,最后增加一個(gè)編譯選項(xiàng)-mcbox,告訴編譯器只有訪問不同內(nèi)存類型的兩個(gè) vload 指令才可以打包到同一個(gè) VLIW 里去。
總結(jié)
本文詳細(xì)介紹了 Cadence DSP 的架構(gòu)特點(diǎn),算子的調(diào)用流程,算子的分塊執(zhí)行邏輯,以及算子的開發(fā)、調(diào)試和優(yōu)化實(shí)踐,希望可以給后面從事相關(guān)開發(fā)的同學(xué)起到一個(gè)拋磚引玉的作用。
GitHub 源碼:https://github.com/MegEngine/MegEngine