背景
最近做一個FPGA加速專案,懶得寫RTL,所以又選擇了HLS(High Level Synthesis,高層次綜合)。之前的文章《Ultra96V2開發板簡單使用》中介紹瞭如何用HLS寫IP核並且在Ultra96V2開發板上透過Pynq環境跑起來,但是我現在用的是OpenSSD開發板,如《SpinalHDL上板過程記錄》說的,雖然也是Zynq系列的但是沒有官方的帶PYNQ的作業系統映象,而是直接用Vitis透過JTAG把執行時和硬體位元流一起傳到沒有作業系統的ARM核上跑,在這個過程中就出現了一些兩篇文章中都沒有出現過的問題,所以做個記錄。
此外,專案還需要在SmartSSD上跑,這個環境下是透過X86的主機控制FPGA裝置。在這個環境上面部署HLS也會遇到一些問題,所以在這裡一併記錄。
Zynq裸機
暫存器API
Vitis裡不能像Pynq裡那樣直接用引數名來控制AXILite暫存器。所幸HLS在打包IP核的時候會自動生成一套控制該IP核的原始碼。假設HLS程式碼的主函式名是IPmain,則在Export RTL之後,即可在HLS專案目錄下的solution名\impl\ip\drivers\IPmain_v1_0\src
裡找到,把除了Makefile
之外的所有檔案複製到Vitis的C/C++原始碼目錄下,這樣就能在Host程式裡呼叫IP核初始化、讀寫暫存器等操作的函式了,具體可以檢視這些檔案裡面包含的內容。
IP核的啟動
在SpinalHDL裡,可以透過定義AXIlite介面的onWrite事件來觸發IP核的啟動(準確來說是讓硬體的狀態機從idle狀態變為工作狀態),但在HLS則沒法這樣做。因此如果沒有啟動控制訊號,硬體就會一直在引數未知的情況下不停地執行,如果是組合邏輯電路,則狀態更是無法確定。為此需要顯示地指定一個啟動訊號,可以額外定義一個布林引數start
,然後在HLS程式裡顯示寫明:
if (start) {
// 實際工作程式碼
}
此外,在Host端傳完引數後要在開啟start
訊號之後立刻關閉,保證硬體在執行完當前引數指定的任務就處在idle狀態,而不是一直執行下去。具體來說是這樣:
XTime before, after;
// 傳別的引數
XIPmain_Set_start_r(IPmain, true);
XIPmain_Set_start_r(IPmain, false);
XTime_GetTime(&before);
// 硬體開始執行
執行時間的測量
開始執行的時刻我們已經明確了,就是開啟start訊號的那一刻,那麼結束執行的時刻呢?一個直觀的想法就是在HLS裡定義一個end變數,當start為真時,end設定為假,然後在程式碼的最後再設定為真,Host檢測到end為真的時刻就是結束時刻。然而,這種方法是不行的,因為HLS到底是按軟體的思路去編譯的,這樣搞編譯器看到end變數函式返回時必定為false,就會生成一個assign end = false
的組合電路了,顯然不符合我們的心意。正確的方法是利用HLS給每個輸出引數(也就是引數傳遞時傳的是引用的引數)生成的o_vld
訊號。雖然end一直是false,但它的o_vld
訊號確實是等到它被賦值之後才變成真,具體而言,在Host需要做的事就是:
// 傳引數
while (!XIPmain_Get_end_o_vld(IPmain));
XTime_GetTime(&after);
std::cout << (double)((u64)after - (u64)before) / COUNTS_PER_SECOND; // 輸出執行時間
注意當o_vld
為真時被讀了一次之後就會立刻變為假。此外如果IP核有別的輸出引數,也可以不用專門定義一個end
引數,用這個引數的o_vld
就行,只要保證這個引數的賦值時刻是在任務執行程式碼的末尾即可。
快取重新整理
什麼時候需要重新整理快取?這個問題不是隻有寫HLS才會遇到的,而是隻要在沒有作業系統的ARM核上跑硬體都會遇到的,所以這裡也記錄一下。只需要記住一點,重新整理快取就是把快取裡的東西寫到DRAM裡,並將快取標記為無效。標記為無效的意思就是下次Host端再要讀取資料,就必須從DRAM裡讀取而不能從快取裡讀取了。我遇到的一般有兩個地方需要重新整理快取:
- Host端寫完資料,接下來PL端要讀的時候。因為Host寫資料都是寫到快取,這時就要將快取寫到DRAM裡,PL端才能讀。
- Host端讀完資料,然後PL端修改了這些資料,接下來Host端又要讀的時候。因為Host讀資料的時候也會把資料讀入快取,之後PL端修改了DRAM但快取沒有被修改,這時Host再從快取讀出來的資料就是錯的。因此需要在PL端修改資料前重新整理快取,讓快取無效。
我重新整理快取用的是Xil_DCacheFlush
,本來按理來說是應該用可以指定重新整理地址區域的函式的,但不知道為什麼我測出來的時間是用上面這個重新整理整個DRAM的函式更快。可能是因為我的板子DRAM比較小,只有4G,重新整理整個DRAM的額外開銷比找地址的額外開銷還要小吧。
X86主機
SmartSSD(整合FPGA和SSD的智慧儲存裝置)是以板卡的形式透過PCIE介面連線到裝有作業系統的X86主機。Xilinx為這種平臺提供了兩種控制FPGA的API:XRT Native API和OpenCL API,我用的是OpenCL API。這個平臺下就不需要給HLS程式碼新增前面說的那些內容,因為有顯式的啟動硬體的函式。具體可以參考Xilinx的UG1393文件。
HLS主函式引數
OpenCL平臺下的HLS主函式引數不需要顯式的指定引數透過AXILite控制,即不需要#pragma HLS INTERFACE mode=s_axilite port=
這種,只需要指定透過AXI傳輸的引數即#pragma HLS INTERFACE mode=m_axi port=
這種。雖然不需要指定,但實際在實現的時候這些引數還是透過AXILite控制的,而且還是單工的AXILite,只能Host往硬體傳參,不能硬體往Host返回,所以普通HLS程式碼裡的引用傳參就被ban了,想往外傳資料只能透過DRAM。
此外有一個非常坑爹的地方,那就是非指標引數的型別不能是ap_[u]int型別,這個地方坑了我特別久,也不報錯,就是傳參的時候給你傳個殘缺的資料,你說惡不噁心!非指標引數的型別只能是C/C++的基礎型別,包括bool、int、long這些。不過指標引數是可以的,也就是說可以宣告ap_int<128>*
這種。另外和普通FPGA平臺不同,普通平臺上HLS程式碼定義了這樣的指標型別打包出來IP核的AXI匯流排位寬就是128位,如果是老的FPGA晶片(比如Zynq7000系列的)不支援這麼寬的AXI協議,就會出問題,而這個平臺上就沒事,OpenCL會自動進行適配。
資料傳輸
和Zynq平臺不同,這個平臺下Host和FPGA的DRAM不是共享的。OpenCL提供了兩種分配(主機)緩衝區的方法,詳見UG1393。Using Host Pointer Buffers的方法沒啥坑點,而Letting XRT Allocate Buffers卻有陷阱。按照文件說的,先clCreateBuffer
建立緩衝區,然後clEnqueueMapBuffer
獲取Host端的指標,有的人可能以為這個函式和Linux的mmap
一樣是把Host的記憶體區域對映到Device,因此只需要對映一次,之後讀寫這段記憶體區域就是讀寫Device的資料了。然而這個想法是錯的,如果是往裝置寫資料,clEnqueueMapBuffer
獲取指標並寫入後需要執行clEnqueueMigrateMemObject
才能將主機的資料傳到裝置。 如果是從裝置讀資料,可以不呼叫clEnqueueMigrateMemObject
,但那樣的話每次讀的時候都需要重新執行clEnqueueMapBuffer
重新獲取指標並將裝置的資料傳回主機。 這點官方文件沒有強調,導致很多人犯迷糊,我也是做了實驗才明白這一點。
從上面可以看出,不管是哪種分配緩衝區的方法,都需要在Host端和FPGA裝置端之間倒騰資料,區別只是在於用clEnqueueMigrateMemObject
還是clEnqueueMapBuffer
(獲取裝置端資料的時候),倒騰的過程總是有時間開銷的。有沒有不需要倒騰的方法嗎?有,Xilinx擴充套件OpenCL,提供了一個叫P2P Buffer的東西,這個程式碼倉庫給出了示例,簡而言之就是在clCreateBuffer
的時候新增CL_MEM_EXT_PTR_XILINX
標示位,並傳一個擴充套件指標進該函式。然後就像前面所預想的,只需要用clEnqueueMapBuffer
對映一次,之後讀寫這段記憶體區域就是讀寫Device的資料了。不過正因為是直接讀取FPGA裝置上DRAM的資料,傳到Host的開銷還是客觀存在的,因此如果讀寫大量資料效率會非常低,所以要讀寫大量資料還是用前面說的方法,直接一次性倒騰。當然如果是讀寫SmartSSD中整合的SSD裡的檔案到P2P緩衝區,這就非常快了,因為這兩種的傳輸是不需要經過Host的。
HLS使用技巧
這裡再補充三個HLS的使用技巧吧。
編譯期計算log2
寫硬體程式碼的時候經常有計算一個常量整數的\(log_2\)的需求,SpinalHDL提供了對應的內建函式,但HLS居然沒有提供。而且為了不生成多餘的電路,我們需要在編譯期進行計算,才能保證生成的RTL程式碼中結果也是以常數的形式表示。所幸我用的是C++:
template <int N, int P = 0>
struct log2i {
static constexpr int value = log2i<N / 2, P + 1>::value;
};
template <int P>
struct log2i<1, P> {
static constexpr int value = P;
};
這裡用到了模板超程式設計,能夠在編譯期計算常量整數取2對數的結果。不過我這裡是向下取整,現實需求還是向上取整的比較多,還好我只把它用在輸入整數剛好是2的冪的情況,所以怎麼取整無所謂。
DRAM和BRAM之間的資料傳輸
當DRAM的位寬和BRAM的位寬不一樣時,兩者之間的資料傳輸就是個比較麻煩的問題。這裡記錄一下我用模板超程式設計寫的(實際AI幫了我很多😋)比較通用的程式碼:
#include <type_traits>
template<int buffer_width, int bus_width, typename std::enable_if<bus_width >= buffer_width, int>::type = 0>
void load_dram(addr_t address, addr_t size, addr_t buffer_offset, ap_uint<buffer_width> *buffer, ap_uint<bus_width> *dram) {
load_wide_dram_ij:
for (addr_t i = 0, j = 0; i < size / (bus_width / 8); i++, j += bus_width / buffer_width) {
#pragma HLS PIPELINE
ap_uint<bus_width> data = dram[address / (bus_width / 8) + i];
load_wide_dram_k:
for (addr_t k = 0; k < bus_width / buffer_width; k++) {
buffer[buffer_offset + j + k] = data((k + 1) * buffer_width - 1, k * buffer_width);
}
}
}
template<int buffer_width, int bus_width, typename std::enable_if<bus_width < buffer_width, int>::type = 0>
void load_dram(addr_t address, addr_t size, addr_t buffer_offset, ap_uint<buffer_width> *buffer, ap_uint<bus_width> *dram) {
load_wide_buffer_ij:
for (addr_t i = 0, j = 0; i < size / (buffer_width / 8); i++, j += buffer_width / bus_width) {
ap_uint<buffer_width> data;
load_wide_buffer_k:
#pragma HLS UNROLL
for (addr_t k = 0; k < buffer_width / bus_width; k++) {
data((k + 1) * bus_width - 1, k * bus_width) = dram[address / (bus_width / 8) + j + k];
}
buffer[buffer_offset + i] = data;
}
}
template<int buffer_width, int bus_width, typename std::enable_if<bus_width >= buffer_width, int>::type = 0>
void store_dram(addr_t address, addr_t size, addr_t buffer_offset, ap_uint<buffer_width> *buffer, ap_uint<bus_width> *dram) {
store_wide_dram_ij:
for (addr_t i = 0, j = 0; i < size / (bus_width / 8); i++, j += bus_width / buffer_width) {
#pragma HLS PIPELINE
ap_uint<bus_width> data;
store_wide_dram_k:
for (addr_t k = 0; k < bus_width / buffer_width; k++) {
data((k + 1) * buffer_width - 1, k * buffer_width) = buffer[buffer_offset + j + k];
}
dram[address / (bus_width / 8) + i] = data;
}
}
template<int buffer_width, int bus_width, typename std::enable_if<bus_width < buffer_width, int>::type = 0>
void store_dram(addr_t address, addr_t size, addr_t buffer_offset, ap_uint<buffer_width> *buffer, ap_uint<bus_width> *dram) {
store_wide_buffer_ij:
for (addr_t i = 0, j = 0; i < size / (buffer_width / 8); i++, j += buffer_width / bus_width) {
#pragma HLS PIPELINE
ap_uint<buffer_width> data = buffer[buffer_offset + i];
store_wide_buffer_k:
for (addr_t k = 0; k < buffer_width / bus_width; k++) {
dram[address / (bus_width / 8) + j + k] = data((k + 1) * bus_width - 1, k * bus_width);
}
}
}
這裡用了std::enable_if
,本來如果用if constexpr
可以簡單很多的,可惜HLS編譯器不支援C++17,所以只能藉助SFINAE去選擇編譯。此外第二個load_dram
函式里沒用#pragma HLS PIPELINE
,原因是用了之後綜合好像會報錯,我也不知道為什麼,是不是編譯器抽風了。
雙緩衝
雙緩衝是硬體設計裡一項非常重要的技術。然而,如果在HLS直接這樣寫:
int buffer[2][100];
for (int i = 0, flag = false; i < n; i++, flag = !flag) {
if (flag == 0) {
// 讀buffer[0]
// 寫buffer[1]
} else {
// 讀buffer[1]
// 寫buffer[0]
}
}
這樣迴圈是流水不起來的,原因是編譯器不知道buffer[0]和buffer[1]兩者之間不存在依賴關係。好在看到了這篇文章,關鍵思路在於透過函式來告訴編譯器這兩個buffer不會互相依賴:
void read(int buffer[100]) {
#pragma HLS ALLOCATION function instances=read limit=1
// 讀buffer
}
void write(int buffer[100]) {
#pragma HLS ALLOCATION function instances=write limit=1
// 寫buffer
}
// ...
int buffer[2][100];
#pragma HLS ARRAY_PARTITION dim=1 type=complete variable=buffer
for (int i = 0, flag = false; i < n; i++, flag = !flag) {
if (flag == 0) {
read(buffer[0]);
write(buffer[1]);
} else {
read(buffer[1]);
write(buffer[0]);
}
}
注意上面程式碼中的兩個#pragma
都非常重要:#pragma HLS ALLOCATION function
告訴HLS這個函式只綜合一套電路,強調了它的分時複用,也就暗示了雙緩衝不會同時被讀寫;#pragma HLS ARRAY_PARTITION
告訴HLS把buffer按第一維切分成完全不相交的BRAM,進一步突出雙緩衝彼此獨立。有了這兩個#pragma
,迴圈就能流水起來了。
總結
之前我覺得HLS很難排除比較複雜的依賴關係,某些並行很難實現。但經過這次我才瞭解到如果按照某些正規化來寫程式(比如上面的用函式來解除依賴),還是可以實現比較精細的控制的。畢竟HLS寫起來確實是比RTL簡單太多,坑再怎麼多寫的時候確實爽。不過我這次寫的程式比較簡單,如果是複雜的程式,要查波形(雖然HLS編譯器基本能百分之百保證程式功能的正確性,但硬體環境千變萬化,介面方面還是有出錯的可能,需要根據波形查錯)、調時序,HLS似乎還是會力不從心。