Xilinx_HLS上板过程记录
背景
最近做一个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似乎还是会力不从心。