阵列配置 - 2022.1 简体中文

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

Document ID
UG1393
Release Date
2022-05-25
Version
2022.1 简体中文

Vitis 编译器可将大型阵列映射到 PL 区域中的块 RAM 存储器。这些块 RAM 最多可包含 2 个访问点或端口。这可能限制应用性能,因为在硬件中实现时,无法并行访问阵列内的所有元素。

根据性能要求,您可能需要在同一个时钟周期内访问某一阵列的部分或全部元素。为此, pragma HLS array_partition 可用于指令编译器拆分阵列元素,并将其映射到较小的阵列或者映射到单个寄存器。编译器可提供 3 种类型的阵列分区,如下图所示。这 3 种分区类型分别是:

  • block:原始阵列分割为原始阵列的连续元素块(大小相同)。
  • cyclic:原始阵列分割多个大小相同的块,这些块交织成原始阵列的元素。
  • complete:将阵列按其独立元素进行拆分。这对应于将存储器解析为独立寄存器。这是 ARRAY_PARTITION 编译指示的默认操作。
图 1. 阵列分区

对于块分区和周期分区,factor 选项可指定要创建的阵列数量。在前图中,使用因子 2 将阵列分割为 2 个更小的阵列。如果阵列的元素数量并非该因子的整数倍,那么排列在后的阵列所含元素数量较少。

对多维阵列进行分区时,dimension 选项可用于指定对哪个维度进行分区。下图显示了使用 dimension 选项通过 3 种方式对以下代码示例进行分区的方式:
void foo (...) {
  // my_array[dim=1][dim=2][dim=3] 
  // The following three pragma results are shown in the figure below
  // #pragma HLS ARRAY_PARTITION variable=my_array dim=3 <block|cyclic> factor=2
  // #pragma HLS ARRAY_PARTITION variable=my_array dim=1 <block|cyclic> factor=2
  // #pragma HLS ARRAY_PARTITION variable=my_array dim=0 complete
  int  my_array[10][6][4];
  ...   
}
图 2. 阵列维度分区

此图中的示例演示了如何通过对维度 3 进行分区来生成 4 个独立阵列,以及如何对维度 1 进行分区以生成 10 个独立分区。如果指定维度 0,则将对所有维度进行分区。

谨慎分区的重要性

完整的阵列分区会将所有阵列元素都映射到各独立寄存器。这样有助于提升内核性能,因为所有这些寄存器都可在同一周期内并发访问。

警告:
完整的大型阵列分区会耗用大量 PL 区域。甚至可能导致编译进程变慢,并出现容量问题。请仅在必要时执行阵列分区。请谨慎考虑选择对特定维度进行分区或者执行块分区或周期分区。

选择对特定维度进行分区

假设 A 和 B 是表示 2 个矩阵的二维阵列。请考量以下矩阵乘法算法:
int A[64][64];
int B[64][64];
 
ROW_WISE: for (int i = 0; i < 64; i++) {
  COL_WISE : for (int j = 0; j < 64; j++) {
    #pragma HLS PIPELINE
    int result = 0;
    COMPUTE_LOOP: for (int k = 0; k < 64; k++) {
      result += A[i ][ k] * B[k ][ j];
    }
    C[i][ j] = result;
  }
}
由于使用的是 PIPELINE 编译指示,ROW_WISECOL_WISE 循环均已平铺并结合在一起,COMPUTE_LOOP 已完全展开。要并发执行 COMPUTE_LOOP 的每次迭代,代码必须并行访问矩阵 A 的每个列和矩阵 B 的每个行。因此,矩阵 A 应在第二个维度内进行拆分,矩阵 B 应在第一个维度内进行拆分。
#pragma HLS ARRAY_PARTITION variable=A dim=2 complete
#pragma HLS ARRAY_PARTITION variable=B dim=1 complete

周期分区与块分区的选择

此处使用相同的矩阵乘法算法来演示如何通过了解底层算法的阵列访问模式,在周期分区与块分区之间进行选择,以及如何判定相应的因子。

int A[64 * 64];
int B[64 * 64];
#pragma HLS ARRAY_PARTITION variable=A dim=1 cyclic factor=64
#pragma HLS ARRAY_PARTITION variable=B dim=1 block factor=64
 
ROW_WISE: for (int i = 0; i < 64; i++) {
  COL_WISE : for (int j = 0; j < 64; j++) {
    #pragma HLS PIPELINE
    int result = 0;
    COMPUTE_LOOP: for (int k = 0; k < 64; k++) {
      result += A[i * 64 +  k] * B[k * 64 + j];
    }
    C[i* 64 + j] = result;
  }
}

在此版本的代码中,A 和 B 现在均为一维阵列。要并行访问矩阵 A 的每个列和矩阵 B 的每个行,可按上述示例中所示方式来使用周期分区和块分区。为并行访问矩阵 A 的每个列,此处应用 cyclic 分区,并将 factor 指定为行大小,此处即 64。同样,为并行访问矩阵 B 的每个行,此处应用 block 分区,并将 factor 指定为列大小,即 64。

通过缓存来最大程度减少阵列访问

由于阵列映射到含有限数量的访问端口的块 RAM,因此重复进行阵列访问可能限制加速器的性能。您应熟练掌握算法的阵列访问模式,并通过在本地缓存数据来限制阵列访问次数,从而提高内核性能。

以下代码示例显示了访问阵列导致最终实现性能受限的案例。在此示例中,对 mem[N] 阵列执行了 3 次访问以创建求和结果。
#include "array_mem_bottleneck.h"
dout_t array_mem_bottleneck(din_t mem[N]) {  
  dout_t sum=0;
  int i;
  SUM_LOOP:for(i=2;i<N;++i) 
    sum += mem[i] + mem[i-1] + mem[i-2];    
  return sum;
}
前述示例中的代码可按以下示例中所示方式重写,以便按 II = 1 对代码进行流水打拍。通过执行预读取并手动对数据访问进行流水打拍,即可在循环每次迭代中仅指定 1 次阵列读取。这样可确保只需 1 个单端口块 RAM 即可实现性能目标。
#include "array_mem_perform.h"
dout_t array_mem_perform(din_t mem[N]) {  
  din_t tmp0, tmp1, tmp2;
  dout_t sum=0;
  int i;
  tmp0 = mem[0];
  tmp1 = mem[1];
  SUM_LOOP:for (i = 2; i < N; i++) { 
    tmp2 = mem[i];
    sum += tmp2 + tmp1 + tmp0;
    tmp0 = tmp1;
    tmp1 = tmp2;
  }     
  return sum;
}