惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
Vulnerabilities – Threatpost
博客园 - 司徒正美
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
大猫的无限游戏
大猫的无限游戏
酷 壳 – CoolShell
酷 壳 – CoolShell
Forbes - Security
Forbes - Security
D
Darknet – Hacking Tools, Hacker News & Cyber Security
小众软件
小众软件
人人都是产品经理
人人都是产品经理
Know Your Adversary
Know Your Adversary
Security Latest
Security Latest
雷峰网
雷峰网
Cisco Talos Blog
Cisco Talos Blog
Latest news
Latest news
GbyAI
GbyAI
Last Week in AI
Last Week in AI
Hacker News: Ask HN
Hacker News: Ask HN
U
Unit 42
S
SegmentFault 最新的问题
月光博客
月光博客
Security Archives - TechRepublic
Security Archives - TechRepublic
Attack and Defense Labs
Attack and Defense Labs
S
Secure Thoughts
N
News and Events Feed by Topic
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
A
Arctic Wolf
Schneier on Security
Schneier on Security
C
CERT Recently Published Vulnerability Notes
I
Intezer
V
Visual Studio Blog
Microsoft Security Blog
Microsoft Security Blog
SecWiki News
SecWiki News
Google Online Security Blog
Google Online Security Blog
N
Netflix TechBlog - Medium
I
InfoQ
T
Tor Project blog
腾讯CDC
T
Tenable Blog
Webroot Blog
Webroot Blog
Y
Y Combinator Blog
TaoSecurity Blog
TaoSecurity Blog
Google DeepMind News
Google DeepMind News
AI
AI
C
Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
V2EX - 技术
V2EX - 技术
PCI Perspectives
PCI Perspectives
C
CXSECURITY Database RSS Feed - CXSecurity.com
G
GRAHAM CLULEY
S
Schneier on Security

VnYzm的博客

游戏玩后感:ReLief:献给亲爱的你 我的周边(谷子)分享 游戏玩后感:Kanon 简谱:致真实的你 《Rust中常见的有关生命周期的误解》学习笔记 简谱:StarMap 简谱:かく咲きたらばいと恋ひめやも 简谱:东风 简谱:无法诉说的思念 简谱:Girlish 游戏玩后感:时钟机关的Layline 简谱:风之琶音 简谱:星空的记忆 简谱:因为遇见了你 简谱:月童 番茄简谱脚本转调器 游戏玩后感:青空下的约定:Refine 游戏玩后感:在这苍穹展翅 书籍读后感:控制论与科学方法论 游戏玩后感:恋爱表达式 游戏玩后感:樱之诗 MLIR-tutorial学习笔记 游戏玩后感:潜伏之赤途 游戏玩后感:纯爱咖啡厅:帕露菲重制版 游戏玩后感:智以泪聚 游戏玩后感:初雪樱 游戏玩后感:告别回忆:从今以后 游戏玩后感:梦灯花 游戏玩后感:金辉恋曲四重奏 游戏玩后感:五彩斑斓的世界 昇腾310P使用记录 游戏玩后感:AIR 游戏玩后感:弹丸论破 游戏玩后感:流景之海的艾佩莉亚 游戏玩后感:告别回忆2 游戏玩后感:恋爱绮谭 Faiss和Rapidsai_Raft使用记录 游戏玩后感:近月少女的礼仪 游戏玩后感:樱色之云,绯色之恋 游戏玩后感:幸运草的约定 游戏玩后感:星之梦、候鸟和丸子与银河龙 游戏玩后感:白色相簿2 Windows上使用VTune分析PyTorchExtension调用的Cpp程序 SpinalHDL上板过程记录 游戏玩后感:仰望夜空的星辰 最简单的算卦方法之一:梅花易数法 游戏玩后感:苍之彼方的四重奏 krkr引擎解包工具介绍 自定义CUDA实现PyTorch算子的四种简单方法 游戏玩后感:星空的记忆 游戏玩后感:9nine 游戏玩后感:AtriMyDearMoments 游戏玩后感:极限脱出 游戏玩后感:魔女的夜宴 SSH实现多跳代理 动漫观后感:向山进发 flv重封装H264、AAC流 动漫观后感:夏日重现 CSP模板 游戏玩后感:海沙风云 动漫观后感:灵能百分百 游戏玩后感:交响乐之雨 游戏玩后感:爱上火车LastRun 游戏玩后感:LittleBustersEX 游戏玩后感:SummerPockets 游戏玩后感:逆转裁判 Ultra96V2开发板简单使用 SpinalWorkshop实验笔记(三) SpinalWorkshop实验笔记(二) SpinalWorkshop实验笔记(一) PYNQ开发板上使用USB声卡+OSS兼容层播放音频 TestOS移植K210开发板 rCore-Tutorial-Book-v3学习笔记(七) 动漫观后感:凉宫春日的忧郁 rCore-Tutorial-Book-v3学习笔记(♭七) rCore-Tutorial-Book-v3学习笔记(六) rCore-Tutorial-Book-v3学习笔记(五) rCore-Tutorial-Book-v3学习笔记(四) rCore-Tutorial-Book-v3学习笔记(三) rCore-Tutorial-Book-v3学习笔记(二) rCore-Tutorial-Book-v3学习笔记(一) 游戏玩后感:RewritePlus MIT-6.S081-2020实验(xv6-riscv64)十一:net MIT-6.S081-2020实验(xv6-riscv64)十:mmap MIT-6.S081-2020实验(xv6-riscv64)九:fs MIT-6.S081-2020实验(xv6-riscv64)八:lock MIT-6.S081-2020实验(xv6-riscv64)七:thread MIT-6.S081-2020实验(xv6-riscv64)六:cow MIT-6.S081-2020实验(xv6-riscv64)五:lazy MIT-6.S081-2020实验(xv6-riscv64)四:traps MIT-6.S081-2020实验(xv6-riscv64)三:pgtbl MIT-6.S081-2020实验(xv6-riscv64)二:syscall 动漫观后感:吹响吧上低音号 MIT-6.S081-2020实验(xv6-riscv64)一:util 快速生成网络mp4视频缩略图技术 用plantuml画图示例 QQ缩略图和大图不同实现 Python制作字符图片 动漫观后感:命运石之门 使用OpenCV对图片进行特征点检测和匹配
Xilinx_HLS上板过程记录
VnYzm · 2024-06-30 · via VnYzm的博客

背景

最近做一个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程序里显示写明:

此外,在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似乎还是会力不从心。