本节所提供的快照用于演示如何将专为 CPU 编写的程序转变为专为基于 FPGA 的加速而编写的应用。本节主要旨在演示应用构建的核心思想,不涉及细节。您可能会接触到多个新术语,请参阅 术语 了解其定义。
下图演示了 Vitis 应用加速环境的执行流程。在 CPU 上运行的应用(称为主机程序)与 FPGA 上运行的硬件加速内核之间对应用程序进行拆分,并在两者之间建立通信通道。主机程序是以 C/C++ 并使用 XRT API 来编写的,经编译为可执行文件并在基于 x86 的主机处理器上运行,而硬件加速内核则编译为可执行器件二进制文件 (.xclbin),在 Alveo 加速器卡上的 AMD 器件的可编程逻辑 (PL) 区域内运行。
由 XRT 管理的 API 调用用于处理主机程序与硬件加速器之间的传输事务。主机与内核之间的通信(包括控制和数据传输)均跨 PCIe 总线发生。Vitis 应用的执行模型可拆分为下列步骤:
- 主机程序可通过 Alveo 数据中心加速器卡上的 PCIe 接口,将内核所需的数据写入已连接的器件的全局存储器。
- 主机程序使用其输入参数来设置内核。
- 主机程序在 FPGA 上触发内核函数的执行。
- 必要时,内核在从全局存储器中读取数据时执行所需的计算。
- 内核将数据写回全局存储器并通知主机它已完成任务。
- 主机程序将数据从全局存储器读回主机存储器,并根据需要继续处理。
以下是以 C++ 编写的简单程序,此程序在 CPU 上执行。此程序包含 compute()
函数,该函数将作为内核在 Alveo 加速器卡上进行加速。
#include <vector>
#include <iostream>
#include <ap_int.h>
#include "hls_vector.h"
#define totalNumWords 512
unsigned char data_t;
int main(int, char**) {
// initialize input vector arrays on CPU
for (int i = 0; i < totalNumWords; i++) {
in[i] = i;
}
compute(data_t in[totalNumWords], data_t Out[totalNumWords]);
check_results();
}
void compute (data_t in[totalNumWords ], data_t Out[totalNumWords ]) {
data_t tmp1[totalNumWords], tmp2[totalNumWords];
A: for (int i = 0; i < totalNumWords ; ++i) {
tmp1[i] = in[i] * 3;
tmp2[i] = in[i] * 3;
}
B: for (int i = 0; i < totalNumWords ; ++i) {
tmp1[i] = tmp1[i] + 25;
}
C: for (int i = 0; i < totalNumWords ; ++i) {
tmp2[i] = tmp2[i] * 2;
}
D: for (int i = 0; i < totalNumWords ; ++i) {
out[i] = tmp1[i] + tmp2[i] * 2;
}
}
程序与任何其它 C++ 程序都极为相似,其 main 函数会调用计算函数、设置要发送到 compute 函数的数据,并在 compute 函数完成后对比黄金结果来检查计算结果。此程序在 CPU 上按顺序执行。此程序也可在 FPGA 上按顺序执行并生成正确结果,但相比于 CPU 并无任何性能增益。为了使该应用在 FPGA 上执行时能够提升性能,需重新设计此程序的架构,在各层次启用并行性。并行性的示例包括:
- compute 函数可先启动,随后再将所有数据从主机传递到 compute 函数
- 多个 compute 函数能以重叠方式运行,例如,“for”循环能够在上一次迭代完成前启动下一次迭代
- “for”循环内的各项操作都能在多个码字上并发运行,无需逐字执行
您需将 compute 函数架构重新设计为驻留在 FPGA 上的加速内核以及在 CPU 上运行并与加速内核进行通信的主机应用。
内核代码架构重新设计
根据前述示例,compute()
函数架构需重新设计,以实现基于 FPGA 的加速。
在 compute()
函数中,循环 A 将输入乘以 3,并创建两条独立路径,分别是 B 和 C。循环 B 和 C 执行操作并将数据馈送给 D。这是一种现实状况的简单表示法,您需在其中逐一执行多项任务,这些任务彼此相连形成如下所示网络。
以下是内核代码架构重新设计的要点总结:
- 在函数级别实现任务级并行度。为实现任务级并行度,需将循环推送到多个独立的函数中。原始
compute()
函数拆分为多个子函数。根据经验法则,顺序函数可并发执行,但顺序循环则将按顺序执行。 - 这些任务(或子函数)使用
hls::stream
充当 FIFO 通道进行彼此通信。hls::stream
类属于 C++ 模板类,用于对各函数之间的串流行为进行建模。 - 指令级并行度是通过从存储器中读取 16 个 32 位码字(或 512 位数据)来实现的。在所有这些码字上可以并行执行计算。
hls::vector
类属于 C++ 模板类,用于对多个样本并发执行矢量运算。 -
compute()
函数架构需重新设计为多个 load-compute-store(加载 - 计算 - 存储)子函数,如以下示例所示。load 函数和 store 函数用于封装数据访问,并对各 compute 函数执行的计算进行隔离。 - 此外还有以
#pragma
开头的编译器指令,可将顺序代码转换为并行执行。
#include "diamond.h"
#define NUM_WORDS 16
extern "C" {
void diamond(vecOf16Words* vecIn, vecOf16Words* vecOut, int size)
{
hls::stream<vecOf16Words> c0, c1, c2, c3, c4, c5;
assert(size % 16 == 0);
#pragma HLS dataflow
load(vecIn, c0, size);
compute_A(c0, c1, c2, size);
compute_B(c1, c3, size);
compute_C(c2, c4, size);
compute_D(c3, c4,c5, size);
store(c5, vecOut, size);
}
}
void load(vecOf16Words *in, hls::stream<vecOf16Words >& out, int size)
{
Loop0:
for (int i = 0; i < size; i++)
{
#pragma HLS performance target_ti=32
#pragma HLS LOOP_TRIPCOUNT max=32
out.write(in[i]);
}
}
void compute_A(hls::stream<vecOf16Words >& in, hls::stream<vecOf16Words >& out1, hls::stream<vecOf16Words >& out2, int size)
{
Loop0:
for (int i = 0; i < size; i++)
{
#pragma HLS performance target_ti=32
#pragma HLS LOOP_TRIPCOUNT max=32
vecOf16Words t = in.read();
out1.write(t * 3);
out2.write(t * 3);
}
}
void compute_B(hls::stream<vecOf16Words >& in, hls::stream<vecOf16Words >& out, int size)
{
Loop0:
for (int i = 0; i < size; i++)
{
#pragma HLS performance target_ti=32
#pragma HLS LOOP_TRIPCOUNT max=32
out.write(in.read() + 25);
}
}
void compute_C(hls::stream<vecOf16Words >& in, hls::stream<vecOf16Words >& out, int size)
{
Loop0:
for (data_t i = 0; i < size; i++)
{
#pragma HLS performance target_ti=32
#pragma HLS LOOP_TRIPCOUNT max=32
out.write(in.read() * 2);
}
}
void compute_D(hls::stream<vecOf16Words >& in1, hls::stream<vecOf16Words >& in2, hls::stream<vecOf16Words >& out, int size)
{
Loop0:
for (data_t i = 0; i < size; i++)
{
#pragma HLS performance target_ti=32
#pragma HLS LOOP_TRIPCOUNT max=32
out.write(in1.read() + in2.read());
}
}
void store(hls::stream<vecOf16Words >& in, vecOf16Words *out, int size)
{
Loop0:
for (int i = 0; i < size; i++)
{
#pragma HLS performance target_ti=32
#pragma HLS LOOP_TRIPCOUNT max=32
out[i] = in.read();
}
}
重新设计主机应用架构
原始程序中的 main 函数负责设置数据、调用 compute 函数、检查结果等。对于加速应用,主机代码负责对通过 PCIe® 总线在器件存储器上发送/接收的数据进行初始化。它还会按类似 main 函数调用 compute 函数的方式来设置内核函数实参。由 XRT 管理的 API 调用用于处理主机程序与硬件加速器之间的传输事务。
总之,主机应用的结构可分为以下步骤:
- 将生成的 .xclbin 加载到程序中。
- 在全局存储器中分配缓冲器
- 创建输入测试数据,并将缓冲器映射到主机存储器
- 设置内核与内核实参。
- 在主机与内核之间传输缓冲器
- 执行内核。
- 接收返回给主机的输出结果,并将其发送到输出缓冲器内
如上所述,为 compute()
函数重写主机应用,使用 XRT 本机 API 在 Alveo 加速器卡上运行,如下所示:
// XRT includes
#include "experimental/xrt_bo.h"
#include "experimental/xrt_device.h"
#include "experimental/xrt_kernel.h"
#include "types.h"
int main(int argc, char** argv) {
unsigned int device_index = 0;
auto uuid = device.load_xclbin("diamond.hw.xclbin");
size_t vector_size_bytes = sizeof(int) * totalNumWords;
auto krnl = xrt::kernel(device, uuid, "diamond");
std::cout << "Allocate Buffer in Global Memory\n";
auto bufIn = xrt::bo(device, vector_size_bytes, krnl.group_id(0));
auto bufOut = xrt::bo(device, vector_size_bytes, krnl.group_id(1));
// Map the contents of the buffer object into host memory
auto bufIn_map = bufIn.map<int*>();
auto bufOut_map = bufOut.map<int*>();
std::fill(bufIn_map, bufIn_map + totalNumWords, 0);
std::fill(bufOut_map, bufOut_map + totalNumWords, 0);
// Create the input data
for (int i = 0; i < totalNumWords; i++)
bufIn_map[i] = (uint32_t)i;
// Create the output golden data
int bufReference[totalNumWords];
for (int i = 0; i < totalNumWords; ++i) {
bufReference[i] = ((i*3)+25)+((i*3)*2);
}
// Synchronize buffer content with device side
bufIn.sync(XCL_BO_SYNC_BO_TO_DEVICE);
std::cout << "Execution of the kernel\n";
auto run = krnl(bufIn,bufOut,totalNumWords/16);
run.wait();
// Get the output;
std::cout << "Get the output data from the device" << std::endl;
bufOut.sync(XCL_BO_SYNC_BO_FROM_DEVICE);
for (int i = 0; i < totalNumWords; i++)
{
std::cout << "Referece " << bufReference[i] << std::endl;
std::cout << "Out " << bufOut_map[i] << std::endl;
}
// Validate our results
if (std::memcmp(bufOut_map, bufReference, totalNumWords))
throw std::runtime_error("Value read back does not match reference");
std::cout << "TEST PASSED\n";
应用执行时间线
在 Alveo 加速器卡上运行时,应用时间线如下所示。
由于存在多种类型的并行度,在 FPGA 上执行应用的方式与 CPU 上截然不同,如上图所示。内核代码编写为通过为每个循环创建子函数来利用任务级并行度。结果是 compute_A
、compute_B
、compute_C
和 compute_D
以重叠方式运行。实际上,compute_A
, compute_B
, compute_C
, and compute_D
均为 compute 函数中的子函数。多个内核可完成类似的重叠执行。
虽然硬件器件及其内核旨在提供潜在的并行化,但必须合理设计软件应用以利用这种潜在的并行化。通过重叠主机到器件数据传输和重叠 compute 函数执行即可进一步实现任务级并行度。