样本应用 - 2023.2 简体中文

Vitis 统一软件平台文档 应用加速开发 (UG1393)

Document ID
UG1393
Release Date
2023-12-13
Version
2023.2 简体中文

本节所提供的快照用于演示如何将专为 CPU 编写的程序转变为专为基于 FPGA 的加速而编写的应用。本节主要旨在演示应用构建的核心思想,不涉及细节。您可能会接触到多个新术语,请参阅 术语 了解其定义。

下图演示了 Vitis 应用加速环境的执行流程。在 CPU 上运行的应用(称为主机程序)与 FPGA 上运行的硬件加速内核之间对应用程序进行拆分,并在两者之间建立通信通道。主机程序是以 C/C++ 并使用 XRT API 来编写的,经编译为可执行文件并在基于 x86 的主机处理器上运行,而硬件加速内核则编译为可执行器件二进制文件 (.xclbin),在 Alveo 加速器卡上的 AMD 器件的可编程逻辑 (PL) 区域内运行。

图 1. CPU/FPGA 交互

由 XRT 管理的 API 调用用于处理主机程序与硬件加速器之间的传输事务。主机与内核之间的通信(包括控制和数据传输)均跨 PCIe 总线发生。Vitis 应用的执行模型可拆分为下列步骤:

  1. 主机程序可通过 Alveo 数据中心加速器卡上的 PCIe 接口,将内核所需的数据写入已连接的器件的全局存储器。
  2. 主机程序使用其输入参数来设置内核。
  3. 主机程序在 FPGA 上触发内核函数的执行。
  4. 必要时,内核在从全局存储器中读取数据时执行所需的计算。
  5. 内核将数据写回全局存储器并通知主机它已完成任务。
  6. 主机程序将数据从全局存储器读回主机存储器,并根据需要继续处理。

以下是以 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。这是一种现实状况的简单表示法,您需在其中逐一执行多项任务,这些任务彼此相连形成如下所示网络。

图 2. 内核架构

以下是内核代码架构重新设计的要点总结:

  • 在函数级别实现任务级并行度。为实现任务级并行度,需将循环推送到多个独立的函数中。原始 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 调用用于处理主机程序与硬件加速器之间的传输事务。

总之,主机应用的结构可分为以下步骤:

  1. 将生成的 .xclbin 加载到程序中。
  2. 在全局存储器中分配缓冲器
  3. 创建输入测试数据,并将缓冲器映射到主机存储器
  4. 设置内核与内核实参。
  5. 在主机与内核之间传输缓冲器
  6. 执行内核。
  7. 接收返回给主机的输出结果,并将其发送到输出缓冲器内

如上所述,为 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 加速器卡上运行时,应用时间线如下所示。

图 3. 应用时间线

由于存在多种类型的并行度,在 FPGA 上执行应用的方式与 CPU 上截然不同,如上图所示。内核代码编写为通过为每个循环创建子函数来利用任务级并行度。结果是 compute_Acompute_Bcompute_Ccompute_D 以重叠方式运行。实际上,compute_A, compute_B, compute_C, and compute_D 均为 compute 函数中的子函数。多个内核可完成类似的重叠执行。

虽然硬件器件及其内核旨在提供潜在的并行化,但必须合理设计软件应用以利用这种潜在的并行化。通过重叠主机到器件数据传输和重叠 compute 函数执行即可进一步实现任务级并行度。