Cadence DSP 算子開發上手指南


作者:洪超 | 曠視科技 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) / 2;
        int ret = 0;
        ret = iter_warp_perspective(mid, frame);
        if (ret < 0)
            r = mid - 1; // ret < 0 表示當前嘗試的 dst_tile 的尺寸會使得 src_tile 在 dram 上放不下,所以可行域直接減半
        else
            l = mid + 1; // ret = 0 表示當前嘗試的 dst_tile 的尺寸是 ok 的,但是繼續嘗試更優解
    }

    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 各個坐標位置的 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); 第二部分就是具體運算指令的名稱縮寫,如 ADD,MUL,SEL 等;第三部分是指定向量中的通道關系,比如是 64lanes * 8bit 還是 32lanes * 16it,不過前者實際寫成 2NX8, 后者寫成 NX16,因為在這里 N 表示 32; 第四部分是一些后綴的修飾詞,比如 U 表示一元運算的數據是無符號數,US 表示二元運算的數據分別是無符號數和有符號數,T 表示該運算會帶 mask, PACK 表示該運算會對中間計算結果做位壓縮再返回較窄的數據類型,等等。

現在放幾條簡單的 SIMD 指令,讓大家對號入座,溫故一下:

IVP_ADDNX16: 32lanes * 16bit 有符號整數的加法運算
IVP_MUL2NX8U: 64lanes * 8bit 無符號整數的乘法運算
IVP_LV2NX8U_I: LV 表示 vector load, _I 后綴在這里是表示有一個立即數(immediate)的 offset,該命令是在一個 64byte 對齊的地址(base_ptr + offset)上 load 64lanes * 8bit 的數據

考慮到介紹 ISA 是比較枯燥的,而且很多人對 CPU 上的 SIMD 指令都有一些了解,所以這里只展開介紹四組較於一般的 SIMD 實現有一些不同點,同時使用頻率非常高的指令。

第一組:帶指針自動對齊、自動偏移以及支持可變長度的 VLOAD 指令

Cadence DSP 要求 VLOAD 訪問不可以跨 bank,而 bank 的位寬是 512bit,也即限制了 VLOAD 的地址必須是 64byte 對齊的。如果地址滿足對齊要求,就可以使用 IVP_LVxxx 指令直接進行訪存操作,反之就需要使用 IVP_LAxxx 指令進行指針自動對齊的訪存操作:

void IVP_LAVNX16_XP(xb_vecNx16 v_out, valign a_load /*inout*/, const xb_vecNx16 * src_ptr /*inout*/, int bytes_cnt);

在單次或連續一組 IVP_LAVNX16_XP 調用前需要調用一次:

valign a_load = IVP_LANX16_PP(src_ptr);

其中,a_load 存放的是起始地址為 [src_ptr & 0x40] 連續 64byte 的數據,a_load 的 64bytes 和 [src_ptr & 0x40 + 64] 地址處連續 load 的 64bytes 組成一個 128byte 的數組,以 [src_ptr | 0x40] 為偏移量從 128bytes 的數組中截取 bytes_cnt 個 bytes 輸出到 v_out。注意 bytes_cnt 的值會被截斷到 0~64 的合法范圍,也意味着這個指令可以 cover 不足 64byte 的 load 操作,也就是所謂 tail_load。還有一個要說明的特點是,這個指令在 load 操作完成后會更新 src_ptr 和 a_load,src_ptr 的偏移量為 bytes_cnt 截斷后的值,a_load 更新為 v_out 的內容,這兩項更新使得該指令可以連續調用,而不用重新調用 IVP_LANX16_PP 和手動移動指針 src_ptr。

第二組:multiply、pack

Cadence DSP 上典型的計算流是 load 數據到 vector 中,施加計算指令,如果得到的中間結果的數值范圍有升位的需求,就需要用位寬更大的 wide vector 來存,而后再通過 PACK 類指令將 wide vector 中的數據安全地壓縮到 vector 的位寬表達范圍內:

xb_vec2Nx24 IVP_MULUSP2N8XR16(xb_vec2Nx8U b, xb_vec2Nx8U c, xb_int32 d);

上面這條命令是兩個類型為 xb_vec2Nx8U 的 vector 和兩個 int16 捉對進行向量乘法,兩個向量乘法的結果做一次向量加法,得到的輸出是類型為 xb_vec2Nx24 的 wide vector。兩組乘法分別是 b 和 d 的高 16 位之間進行,以及 c 和 d 的低 16 位之間進行。

xb_vec2Nx8U IVP_PACKVRU2NX24(xb_vec2Nx24 b, int c);

而這條 pack 指令就是可以將類型為 xb_vec2Nx24 的 wide vector 中每一個通道 24bit 的數據右移 c 位,接着飽和處理到 u8 的表達范圍,得到的輸出就是類型為 xb_vec2Nx8U 的 vector。

第三組:select

有時候我們的算法邏輯需要對兩個 vector 進行 interleave 或者 deinterleave,下面這個指令就可以實現:

void IVP_DSELNX16I(xb_vecNx16 a, xb_vecNx16 b, xb_vecNx16 c, xb_vecNx16 d, immediate e);

該命令會將 d 中的 64byte 數據看成 64lanes * u8,c 中的 64byte 數據看成 64lanes * u8,然后先 d 后 c,從低位到高位,將 d 和 c 中的數據拼接成一個 128lanes * u8 數組。而 e 是一個立即數,e 的每一個不同取值都對應一個預置的 index_list,每一個 index_list 都是 0~127 整數序列的一個重排列。預置的 index_list 有 8bit/16bit 兩種粒度的 interleave/deinterleave 操作,額外的,還支持各種 rotate_left/rotate_right 操作。

但是可能會遇到,IVP_DSEL2NX8I 中預置的若干種 index_list 均不能滿足我們的需求,那就需要下面這個命令:

void IVP_DSELNX16(xb_vecNx16 a, xb_vecNx16 b, xb_vecNx16 c, xb_vecNx16 d, xb_vec2Nx8 e);

該命令將 d 和 c 中的數據按照先 d 后 c, 從低位到高位的順序排成了一個 64lanes * 16bit 的數組,而 e 中數據就是 0~63 整數序的一個自定義序列。

第四組:gather/scatter

最后一組要介紹的指令就是高效的 gather/scatter。gather 是從一組不連續的 dram 地址中 load 數據存到一個 vector 里面,而 scatter 就是反向操作,將一個 vector 里面的數據 store 到離散的 dram 地址中去。想象中 gather/scatter 的指令開銷應該非常大,但是實際應用中發現 gather/scatter 確實比一般的指令多花一些 cycle,但是 overhead 不明顯,且有些場景不用 gather/scatter 的話 SIMD 就玩不轉了,就只能用標量計算了。

簡單解釋一下 Cadence DSP 的 gather/scatter 效率高的原因。gather/scatter 指令不同於普通指令,gather/scatter 在觸發 issue 之后是由 SuperGather 硬件模塊全權接管。后者會將 dram 512bit 寬的 bank 進一步拆分成 8 個 64bit 寬的 sub-bank,並從硬件層面支持同時 load 分布在不同 sub_bank 的數據(當然這里存在更嚴重的 sub_bank_conflict 的風險,后文詳細解釋)。此外,gather 還被拆分成兩個子命令,gathera 和 gatherd。gathera 才是 SuperGather 實際接管的指令,該指令負責收集離散地址上的數據到 gr 寄存器(gather register)。拆分指令的原因是 gathera 可以異步執行,不阻塞 DSP 的處理器繼續執行其他指令。而 gatherd 是一條執行在 DSP 處理器上的指令,負責將 gr 寄存器里面收集完畢的數據拷貝到普通的 vector 寄存器,所以只有依賴 gatherd 返回值的命令才必須等待 gather 操作執行完畢。至於 scatter,除了 sub_bank 並發 store 的功勞,最關鍵的原因是 scatter 在遇到 sub_bank_conflict 的時候會做硬件層面的 buffer,等到有空閑 slot 的時候再調度 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 的數據,然后每一個通道的 u8 數據高位補 0 拓展到 u16, 所以 gr 寄存器里面存的是 32lanes * u16 的數據。

性能優化

前文介紹了一些高頻使用的 SIMD 指令,讀者可以嘗試開發自己的 DSP 算子了,但是第一版實現的性能可能不 ok,所以本節將補充一些優化算子性能的知識點。

理解 SWP

首先要介紹 Cadence DSP 的編譯器進行優化調度的核心概念--SWP(software pipeline),不同於處理器執行指令時進行硬件層面的流水,SWP 是編譯器對算子 inner loop 的不同 iter 的指令進行軟件層面的流水,目的就是讓 inner loop 編譯后的 VLIW 中有效指令的密度更高,最小化 nop 的比例。

為了更直觀的理解 SWP, 下面以 alphablend 為例,詳細講解一下編譯器實際調度得到的 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++; // 因為這里 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 寫的一個 alpha_blend 算子,使用命令行工具拿到編譯器調度后的匯編文件:

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                              	#  
 }

從注釋部分可以看出,編譯器對循環體做了 unroll=2 的循環展開,展開后的 loop count 是 62,得到了一個 4cycle 3stage 的 SWP,並且告訴你該 SWP 中發射了 10 個非 nop 的指令(可以計算一下 CPI(cycle per instruction) 為 0.4),額外的還有一些寄存器占用比例的分析數據。

代碼部分的每一個花括號就是一個 VLIW,注意每一個 VLIW 編碼的指令數可以不一樣,這是因為 Cadence DSP 支持了十余種不同 format 的 VLIW(代碼中每個花括號上面都有一句注釋表明了 format 類型)。然后每一句指令的右邊都有一句注釋,只需關注方括號的部分,加號右邊的數字是表征當前指令在 SWP 的第幾個 cycle 發射出去,加號左邊與 II 相乘的數字表征的是 stage。因為 alphablend 調度出來的是一個 3stage 的 SWP,所以可以看到與 II 相乘的數字是 0,1,2,將其分別指代成 stage 0/1/2。這里 3 stage 的意思是,SWP 里會出現一個 VLIW 同時打包了三個 iter(unroll 之后的三個 iter)的指令,stage 0 是當前 iter, stage 1 是上一個 iter, stage 2 是上上一個 iter。

類比處理器硬件的 pipeline,上面這段代碼准確說是 SWP 流水線填滿的 steady 階段,SWP 也有流水線填充和退出的階段,分別稱為 prologue 和 epilogue。這也解釋了原始 loop 的 loop count 其實是 256 * 32 / 32 / 2 為 128,但是 SWP unroll 之后的 loop count 不是 64,而是 62。為了更直觀的理解 SWP,下面將 prologue、steady 和 epilogue 三個階段的匯編代碼粘貼到一張圖中,如下:

從圖中可以看出,所謂的 SWP 就是將一個原始 iter 下不同的指令看成不同的 stage,並應用流水線的概念,把一個原始 iter 中有嚴格時序邏輯的多個指令的發射時機分散到 SWP 的不同 iter 中,目的就是追求更低的 CPI。

其實,我們還可以根據匯編代碼估計算子的執行時間:steady 階段 4cycle * 62 + prologue 階段 7cycle + epilogue 階段 5cycle = 260cycle,執行 xt-run 測得這個循環體的耗時是 276cycle,其他的 overhead 是 276-260=16cycle,所以根據匯編代碼估算的計算時間已經很准了(但是也有例外,后文會提及)。

了解了 SWP 的概念,接下來我們對 alphablend 的實現做一些修改,觀察對 SWP 的影響。第一個試驗就是將修飾指針變量的__restrict 去掉,重新拿到匯編文件,SWP 現在長這樣:

#<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 的注釋代碼,但是沒有做任何有意義的調度。現在的 CPI=8/5=1.6,之前的版本 CPI 是 0.4, 所以性能下降了 3 倍多。當然這里速度差別這么大,還有一個原因是 inner loop 的邏輯太簡單,不 unroll 的情況下編譯器實在沒有啥可調度的空間,無法發揮 VLIW 的優勢。如果 inner loop 邏輯比較復雜,即使不 unroll,編譯器通過 VLIW 也能提高指令的並行度,與 SWP 有效調度后的性能差距就不會如此明顯。

這里解釋一下,可能有讀者看到 SWP 里面只有 4 個 VLIW,不清楚為啥要用 8 個 cycle。請注意看每條指令右側方括號里面的注釋,cycle 數確實是橫跨了 0~7,而中間不連續的數字都會替換成相應個數的 bubble。為什么會產生 bubble? 是因為當前內循環的四條 VLIW,調度的是同一個 iter 的不同指令,相鄰的 VLIW 的數據又都是寫后讀的依賴關系,所以連續發射出去之后,會存在前一個指令的結果還沒有寫回,后一個指令已經讀取該數並來到 execute 階段了,也就是所謂的 pipeline interlock。為了解決 interlock,就需要在前后兩條 VLIW 之間加一定數量的 bubble。可能還會有細心的讀者糾結為啥 vload 和 vmul 之間沒有 bubble,這是因為筆者在 Q7 上跑的代碼,而 Q7 對 dram 的 vload 延遲做了特殊優化。如果在 P6 上跑這個代碼,就會看到 vload 和 vmul 之間也會有 bubble。

接着,我們針對 SWP 做第二個試驗:地址非對齊訪問。前面說過 src0/src1 都是 64byte 對齊的,所以看匯編代碼會發現,vload 實際使用的是 ivp_lv2nx8_ip 指令。但是假設現在無法保證 src0/src1 是 64bytes 對齊的,就需要實現以下更通用版本的 alphablend:

void alpha_blend(uint8_t* psrc0, uint8_t* psrc1, uint8_t* pdst, int16_t alpha) {
  // 注意,直接粗暴的把 64byte 對齊的地址都加了 1,構造非對齊地址
  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 測得內循環的耗時是 298cycle,略多於對齊地址版本的 276cycle,繼續查看非對齊地址版本的 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

發現編譯器搜出來一個 unroll=8,CPI=16/48=0.33(比地址對齊版本的 0.4 更低一點)的 SWP,但是因為 unroll 太大,prologue/epilogue 的 CPI 比較大才導致總的 cycle 數略大於地址對齊版本。但是如果 loop_count 更大一點,兩個版本的速度差異就更小了。不知道讀者會不會失望了,這個試驗的結果並沒有告訴我們怎么做速度更快,只是得出一個結論:非地址對齊的算子速度不一定比地址對齊的版本要慢,但是非地址對齊版本的算子會更通用一點。

理解 bank_conflict

回過頭來填一個坑,為什么基於匯編代碼估算 DSP 時間有時候會不准?其實原因前文也有提過,就是 bank_conflict 和 sub_bank_confilct 搞的鬼。

先說 bank_conflict 的影響,還是拿前面的 alphablend 做例子。該算子有兩個輸入 src0/src1,如果有兩條 vload 指令被調度到同一個 VLIW 里面,且訪問的兩個地址是同一個 dram 上同一個 bank 的不同位置,就觸發了 bank_confilct,處理器必須 stall 一個 cycle。直覺告訴我們如果將 src0 和 src1 放在不同的 dram 上,應該會降低 bank_confilct 發生的概率。

做個試驗驗證下,把前面最初版本的 alphablend 的 src0/src1 都放到 dram0 上,測得內循環的耗時從原先的 276cycles 變成了 280cycles。速度下降好像並不明顯,查看 SWP 沒有發生任何變化,后面這點倒是符合預期,因為編譯期並不會檢查每一次地址訪問有沒有發生 bank_conflict。仔細看匯編代碼可以發現,steady 階段的代碼都是同一個 dram 的連續兩個 64byte 的 vload 被放在了一個 VLIW 里面,所以本次試驗修改對其不產生影響。增加的 4 個 cycle 其實是因為 prologue 階段有四個綁定了不同 dram 上 vload 指令的 VLIW,恰好這四個 VLIW 都觸發了 bank_confilct。考慮到不同算子的調度情況是不一樣的,為了減少 bank_conflict 對性能的影響,我們還是應該將多個輸入 tile 創建在不同的 dram 上。

接着,我們來分析一下 sub_bank_conflcit 對性能的影響。sub_bank_conflict 只會發生在 gathera 指令的執行過程中,當 gathera 收集非連續地址上的多個數據時,如果出現多個數據的地址正好在同一個 dram 的同一個 bank 的同一個 sub_bank 中的不同地址時,就會出現多次 sub_bank_conflict,最極端的情況下收集一個 vector32,卻出現了 32 次 conflcit,gathera 收集完成需要 32 個 cycle。

所以如果我們開發的算子里面用到了 gathera 指令,請加上-mem_model 選項在模擬器上跑一下,執行完會打印一些統計參數,其中一項就是 gather stall 的 cycle 數。如果不幸 gather stall 比較大,就需要檢查算子里面的訪存邏輯,看看是否需要調整 tile 上數據的分布情況,比如可以在 tile 的水平方向插入若干列的無用數據,降低 gathera 目標數據在同一個 sub_bank 的可能性。

性能優化小結

綜上,從算子實現的維度上看,Cadence DSP 算子的速度只受限於 SWP 調度出來的 CPI,以及訪存的 bank 沖突。

最后,將散落在本文各個地方會影響算子性能的點集中到一起(還有一些可以降低 CPI 的技巧前文沒有提及),做成 checklist 便於大家對照查看:

  1. 確認算子頻繁訪問的數據是不是位於 dram 上?而不是 ddr 上。

  2. 確認是否使用了 pingpog dma,以及使用了之后是否真的隱藏了 dma 搬運的開銷?可以檢查 kernel 耗時占總耗時的比例。

  3. 確認 load/store 和 gather/scatter 訪問的 dram 指針是否都加上了__restrict?

  4. 如果算子核心計算邏輯是兩重 for 循環,確認是否將 tile_height 作為內層循環?以及是否將外層循環 unroll?

  5. 如果使用了 gather 指令,請查看 gather stall 的 cycle 數是否很高?然后對症下葯。

  6. 確認算子內循環中使用的局部變量是否過多?如 filter2d 在 kernel_size 大於等於 5 的情況,為了避免寄存器溢出,需要將單一的內循環拆分成多個小的獨立的內循環。

  7. 如果是 elemwise 類操作,計算邏輯比較復雜,但是每一個像素值最終的處理結果在一個比較有限的范圍內,比如一些色彩處理類算子,輸入是 u8 或 u8 的二元組,經過一系列處理邏輯,最后的結果還是 u8 或 u8 的二元組。這種情況下建議轉換思路看看查表操作是否 ok(因為我們在 DSP 上有 SuperGather)。

  8. 如果算子有多個輸入,確認有沒有施加降低 bank_confilct 的措施?

9、如果從較早的 DSP 型號上移植算子到較新的 DSP 型號上,比如移植 P6 上的代碼到 Q7,需要注意新指令的應用,比如 Q7 比 P6 多了 Dual-Quad 8x8 和 Quad 32x16 multiply 兩個可選的增強模塊。

雜項

本節整理了一些在前文不便展開的細節,但其實也很重要:

  • gathera 指令有很多變種,有些變種 gathera 指令的 offset_vec 是 16lanes * u32, 但是必須注意的是 offset_vec 里的數據必須是 0~65535 范圍內。否則,SuperGather 會讀取不到對應地址的數據,並給該 lanes 直接賦 0。

  • 開發測試的過程中可能會有修改 memmap.xmm 文件的需求,比如調整棧空間的位置和大小,只需要編輯完 xmm 文件之后,使用 xtensa 命令行工具 xt-genldscripts,在 xmm 文件所在目錄下執行"xt-genldscripts -b ."命令,即可在。/ldscripts 目錄下得到新的 linker scripts 文件,對 xmm 的修改也即生效。

  • 舉兩個有可能發生高頻 sub_bank_conflict 的例子:一個例子是 padding 算子在做 left_padding 和 right_padding 的時候,會 gather 一個 tile 的某一列連續若干個數據,如果恰好該列所有的數據都在同一個 sub_bank 就會性能非常差;還有一個例子就是 transpose,dst_tile 的一行其實是 src_tile 的一列,所以 gather 的時候同樣有可能出現極端的 sub_bank_conflict。

  • 解釋一下編譯器只對 inner loop 應用 SWP 調度優化的原因,Cadence DSP 的應用定位就是圖形處理,而一般圖形處理算法的 inner loop 計算密度非常高,幾乎決定了整個算法的性能。

  • SWP 優化調度的 inner loop 實際上是號稱 zero overhead loop 的,也就是說沒有普通 loop 檢查循環條件,更新 loop iter 等工作的開銷,但是上面的 alphablend 的例子中好像 inner loop 還是有一些 overhead 的,是因為想要獲取 zero overhead 的 inner loop,還需要兩個額外條件:inner loop 里面指令數不能太少,且 loop count 相對較大。

  • 前文提到將不同的輸入 tile 存放在不同 dram 上來減少 bank_confilct,但是減少 bank_conflict 的終極策略是:先將不同的 tile 存放在不同的 dram 上,然后代碼里使用#pragma ymemory (tile_on_dram0)告訴編譯器哪一個 tile 是在 dram1 上的,編譯器會將該 tile 的內存類型標記為 ymemory,而其他 tile 的內存類型標記為 xmemory,最后增加一個編譯選項-mcbox,告訴編譯器只有訪問不同內存類型的兩個 vload 指令才可以打包到同一個 VLIW 里去。

總結

本文詳細介紹了 Cadence DSP 的架構特點,算子的調用流程,算子的分塊執行邏輯,以及算子的開發、調試和優化實踐,希望可以給后面從事相關開發的同學起到一個拋磚引玉的作用。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM