4、第一个加速程序:向量加法实例
好,咱们正式开始动手了。
前面几章聊了不少概念,什么Vitis开发流程、硬件加速的原理。说实话,光听理论容易犯困。我当年刚接触Vitis时,也是恨不得马上跑一个例子看看效果。所以这一章,咱们直接上手——写一个最简单的向量加法程序。
别小看这个例子。它麻雀虽小,五脏俱全。主机端代码、设备端Kernel、编译脚本、运行流程,一个都不少。你把这个跑通了,后面那些复杂的加速器设计,无非是在这个框架上做加法。
本章核心目标: 编写一个完整的Vitis硬件加速工程,实现两个向量的加法运算。主机端用C++控制流程,设备端用C语言编写Kernel,最终在FPGA上运行并验证结果。
4.1 向量加法:为什么选它?
向量加法是并行计算的“Hello World”。
你想想看,两个数组对应元素相加,每个元素的计算完全独立。这不就是FPGA最擅长的活儿吗?把数据拆开,几百个加法器同时干活,一秒搞定。
我在项目中遇到过类似场景——图像处理里的像素叠加。说白了,就是向量加法的变种。当时我用CPU跑,一帧图像要算好久。后来改成FPGA加速,延迟直接降到微秒级。嗯,那种感觉,真爽。
4.2 编写主机端C++代码
主机端代码负责三件事:准备数据、调用Kernel、检查结果。
我个人习惯把代码分成几个清晰的模块。这样出了问题,一眼就能定位。
// host.cpp
#include <stdio.h>
#include <stdlib.h>
#include <vector>
#include "xcl2.hpp"
#define DATA_SIZE 4096
int main(int argc, char** argv) {
// 1. 读取xclbin文件
std::string binaryFile = argv[1];
cl_int err;
cl::Context context;
cl::CommandQueue q;
cl::Kernel krnl_vadd;
// 2. 初始化OpenCL运行时
auto devices = xcl::get_xil_devices();
auto device = devices[0];
// 3. 创建上下文和命令队列
OCL_CHECK(err, context = cl::Context(device, NULL, NULL, NULL, &err));
OCL_CHECK(err, q = cl::CommandQueue(context, device,
CL_QUEUE_PROFILING_ENABLE, &err));
// 4. 加载并编译Kernel
auto device_binary = xcl::import_binary(binaryFile);
OCL_CHECK(err, program = cl::Program(context, {device},
{device_binary}, NULL, &err));
OCL_CHECK(err, krnl_vadd = cl::Kernel(program, "vadd", &err));
// 5. 分配主机内存并初始化数据
std::vector<int, aligned_allocator<int>> source_a(DATA_SIZE);
std::vector<int, aligned_allocator<int>> source_b(DATA_SIZE);
std::vector<int, aligned_allocator<int>> source_results(DATA_SIZE);
for (int i = 0; i < DATA_SIZE; i++) {
source_a[i] = i;
source_b[i] = i * 2;
}
// 6. 分配设备端缓冲区
OCL_CHECK(err, cl::Buffer buffer_a(context,
CL_MEM_USE_HOST_PTR | CL_MEM_READ_ONLY,
sizeof(int) * DATA_SIZE, source_a.data(), &err));
OCL_CHECK(err, cl::Buffer buffer_b(context,
CL_MEM_USE_HOST_PTR | CL_MEM_READ_ONLY,
sizeof(int) * DATA_SIZE, source_b.data(), &err));
OCL_CHECK(err, cl::Buffer buffer_result(context,
CL_MEM_USE_HOST_PTR | CL_MEM_WRITE_ONLY,
sizeof(int) * DATA_SIZE, source_results.data(), &err));
// 7. 设置Kernel参数
int narg = 0;
OCL_CHECK(err, krnl_vadd.setArg(narg++, buffer_a));
OCL_CHECK(err, krnl_vadd.setArg(narg++, buffer_b));
OCL_CHECK(err, krnl_vadd.setArg(narg++, buffer_result));
OCL_CHECK(err, krnl_vadd.setArg(narg++, DATA_SIZE));
// 8. 将数据从主机传输到设备
OCL_CHECK(err, q.enqueueMigrateMemObjects(
{buffer_a, buffer_b}, 0));
// 9. 启动Kernel
OCL_CHECK(err, q.enqueueTask(krnl_vadd));
// 10. 将结果传回主机
OCL_CHECK(err, q.enqueueMigrateMemObjects(
{buffer_result}, CL_MIGRATE_MEM_OBJECT_HOST));
q.finish();
// 11. 验证结果
bool match = true;
for (int i = 0; i < DATA_SIZE; i++) {
if (source_results[i] != source_a[i] + source_b[i]) {
match = false;
break;
}
}
printf("Test %s\n", match ? "PASSED" : "FAILED");
return match ? EXIT_SUCCESS : EXIT_FAILURE;
}
小提示: 注意看第5步,我用了 aligned_allocator。这是Vitis推荐的分配方式,能保证内存对齐,避免数据传输时出问题。我曾经因为用了普通的 std::allocator,结果数据传过去全是乱的,排查了半天才发现是内存对齐的锅。
4.3 编写设备端Kernel代码
Kernel代码才是FPGA加速的核心。说白了,这就是你要在硬件上跑的那个函数。
写Kernel时有个原则:尽量简单。FPGA不是CPU,别搞复杂的控制流。循环展开、流水线,这些才是它的强项。
// vadd.cpp
#include <hls_stream.h>
#include "ap_int.h"
extern "C" {
void vadd(const int* a, const int* b, int* c, int size) {
#pragma HLS INTERFACE m_axi port=a offset=slave bundle=gmem0
#pragma HLS INTERFACE m_axi port=b offset=slave bundle=gmem1
#pragma HLS INTERFACE m_axi port=c offset=slave bundle=gmem2
#pragma HLS INTERFACE s_axilite port=size bundle=control
#pragma HLS INTERFACE s_axilite port=return bundle=control
for (int i = 0; i < size; i++) {
#pragma HLS PIPELINE II=1
c[i] = a[i] + b[i];
}
}
}
这段代码看着简单,但有几个关键点我得提醒你:
- 接口定义:
m_axi表示数据通过AXI总线传输。三个端口分别对应三个数组。我习惯把每个端口绑到不同的bundle上,这样综合工具能更好地做并行优化。 - 流水线:
#pragma HLS PIPELINE II=1意思是每个时钟周期处理一个数据。II是Initiation Interval,II=1就是理想情况——一拍一个结果。 - extern "C": 防止C++名字修饰。这个坑我踩过——不加这个,链接时会报找不到符号。
注意: 别把 size 设得太大。FPGA的BRAM和DSP资源有限。我见过有人把 DATA_SIZE 设成100万,结果综合直接报错——资源不够。一般建议先设成4096或8192,跑通了再慢慢加。
4.4 编译与运行
编译分两步:先编译Kernel生成xclbin文件,再编译主机程序生成可执行文件。
我个人习惯用Makefile来管理。下面是一个典型的编译脚本:
# Makefile
TARGET = hw
PLATFORM = xilinx_u200_gen3x16_xdma_2_202110_1
EMU_MODE = sw_emu
# 编译Kernel
xclbin:
v++ -t $(TARGET) \
--platform $(PLATFORM) \
--link vadd.cpp \
-o vadd.xclbin \
--kernel vadd \
--config vadd.cfg
# 编译主机程序
host:
g++ -std=c++14 -I$(XILINX_XRT)/include \
-L$(XILINX_XRT)/lib -lOpenCL -lpthread \
host.cpp -o host
# 软件仿真
sw_emu:
export XCL_EMULATION_MODE=$(EMU_MODE)
./host vadd.xclbin
# 硬件运行
run:
./host vadd.xclbin
clean:
rm -rf *.xclbin host _x
编译命令解释:
| 命令 | 作用 |
|---|---|
v++ -t hw --link vadd.cpp -o vadd.xclbin |
编译Kernel,生成硬件二进制文件 |
g++ host.cpp -o host -lOpenCL |
编译主机端程序,链接OpenCL库 |
export XCL_EMULATION_MODE=sw_emu |
设置软件仿真模式,不用真硬件也能跑 |
调试技巧: 第一次跑,建议先用 sw_emu 模式。这个模式在CPU上模拟Kernel行为,跑得飞快。等逻辑验证通过了,再切到 hw_emu 做硬件仿真,最后才烧到真板子上。我当年图省事,直接烧硬件,结果Kernel里有个死循环,板子直接卡死,只能断电重启。从那以后,我再也不敢跳过仿真了。
4.5 知识体系总览
下面这张图,把整个向量加法加速工程的流程串起来了。你对照着看,思路会更清晰。
这张图展示了整个流程:主机端准备数据→传给设备端Kernel→Kernel并行计算→结果回传→主机端验证。箭头方向就是数据流动的方向。你把这个流程刻在脑子里,后面写任何加速程序都是这个套路。
4.6 常见问题与避坑
最后,分享几个我实际踩过的坑:
- 内存分配不对齐: 用
aligned_allocator,别用普通的malloc。否则数据传输会报CL_MEM_OBJECT_ALLOCATION_FAILURE。 - Kernel参数顺序搞错: 主机端
setArg的顺序必须和Kernel函数参数顺序一致。我犯过把a和b传反了的低级错误,结果加法变成了a+a,排查了半天。 - 忘记调用
finish(): 主机端启动Kernel后,必须等它完成才能读结果。不加q.finish(),读到的可能是旧数据。 - 仿真模式没设对: 软件仿真要设
export XCL_EMULATION_MODE=sw_emu。不设的话,程序会去找真硬件,找不到就直接崩了。
好了,向量加法的例子就讲到这里。代码不多,但每个环节都踩实了。你照着敲一遍,跑通了,就算是正式迈进了Vitis硬件加速的大门。