写在前面
本文参考赛灵思的官方手册UG1270,主要介绍了数据访问模式,以帮助我们更好的进行HLS的开发设计。
数据访问模式
由于 FPGA 的卓越性能,选择 FPGA 来实现 C 代码 - FPGA 的大规模并行架构使其执行操作的速度比处理器固有的顺序操作快得多,用户通常希望利用这一点表现。
这里的重点是了解 C 代码中固有的访问模式可能对结果产生的影响。 尽管最受关注的访问模式是进出硬件功能的访问模式,但值得考虑功能内的访问模式,因为硬件功能内的任何瓶颈都会对进出功能的传输速率产生负面影响。
为了强调某些数据访问模式如何对性能产生负面影响,并演示如何使用其他模式来充分利用 FPGA 的并行性和高性能功能,本节回顾了图像卷积算法。
- 第一部分回顾了算法并强调了限制 FPGA 性能的数据访问方面。
- 第二部分展示了如何编写算法以实现可能的最高性能。
数据访问模式不佳的算法
此处使用应用于图像的标准卷积函数来演示 C 代码如何对 FPGA 可能产生的性能产生负面影响。 在这个例子中,对数据先进行水平卷积,然后进行垂直卷积。 因为图像边缘的数据位于卷积窗口之外,所以最后一步是处理边界周围的数据。算法结构可概括如下:
- 水平卷积。
- 其次垂直卷积。
- 其次是对边框像素的操作。
static void convolution_orig( int width, int height, const T *src, T *dst, const T *hcoeff, const T *vcoeff) { T local[MAX_IMG_ROWS*MAX_IMG_COLS]; // Horizontal convolution HconvH:for(int col = 0; col < height; col++){ HconvWfor(int row = border_width; row < width - border_width; row++){ Hconv:for(int i = - border_width; i <= border_width; i++){ } } } // Vertical convolution VconvH:for(int col = border_width; col < height - border_width; col++){ VconvW:for(int row = 0; row < width; row++){ Vconv:for(int i = - border_width; i <= border_width; i++){ } } } // Border pixels Top_Border:for(int col = 0; col < border_width; col++){ } Side_Border:for(int col = border_width; col < height - border_width; col++){ } Bottom_Border:for(int col = height - border_width; col < height; col++){ } }
标准水平卷积
第一步是在水平方向进行卷积,如下图所示。
使用 K 个数据样本和 K 个卷积系数执行卷积。 在上图中,K 显示为 5,但 K 的值是在代码中定义的。 为了执行卷积,最少需要 K 个数据样本。 卷积窗口不能从第一个像素开始,因为该窗口需要包含图像外部的像素。
通过执行对称卷积,来自输入 src 的前 K 个数据样本可以与水平系数和计算的第一个输出进行卷积。 为了计算第二个输出,使用下一组 K 个数据样本。 此计算沿每一行进行,直到写入最终输出。
执行此操作的 C 代码如下所示。
const int conv_size = K; const int border_width = int(conv_size / 2); #ifndef __SYNTHESIS__ T * const local = new T[MAX_IMG_ROWS*MAX_IMG_COLS]; #else // Static storage allocation for HLS, dynamic otherwise T local[MAX_IMG_ROWS*MAX_IMG_COLS]; #endif Clear_Local:for(int i = 0; i < height * width; i++){ local[i]=0; } // Horizontal convolution HconvH:for(int col = 0; col < height; col++){ HconvW:for(int row = border_width; row < width - border_width; row++ int pixel = col * width + row; Hconv:for(int i = - border_width; i <= border_width; i++){ local[pixel] += src[pixel + i] * hcoeff[i + border_width]; } } }
代码简单直观。 但是,此 C 代码存在一些问题,会对硬件结果的质量产生负面影响。
第一个问题是 C 编译期间的大存储需求。 算法中的中间结果存储在内部本地数组中。 这需要一个 HEIGHT*WIDTH 数组,对于 1920*1080 的标准视频图像,该数组将包含 2,073,600 个值。
- 对于面向 Zynq SoC 或 Zynq UltraScale MPSoC 的交叉编译器以及许多主机系统,这种本地存储量可能会导致运行时堆栈溢出(例如,在目标设备上运行,或 在 Vivado HLS 中运行 co-sim 流程)。 本地数组的数据放在堆栈上,而不是堆上,堆上由操作系统管理。 使用 arm-linux-gnueabihf-g++ 进行交叉编译时,请使用 Wl,"-z stacksize=4194304" 链接器选项来分配足够的堆栈空间。 (请注意,此选项的语法因不同的链接器而异。)当函数仅在硬件中运行时,避免此类问题的一种有用方法是使用 __SYNTHESIS__ 宏。该宏在硬件功能合成硬件时由系统编译器自动定义。 上面显示的代码在 C 仿真期间使用动态内存分配来避免任何编译问题,并且仅在综合期间使用静态存储。 使用此宏的缺点是通过 C 模拟验证的代码与合成的代码不同。然而,在这种情况下,代码并不复杂,行为将是相同的。
- 这种本地阵列的主要问题是 FPGA 实现的质量。 因为这是一个阵列,它将使用内部 FPGA 块 RAM 来实现。 这是在 FPGA 内部实现的非常大的存储器。 它可能需要更大、更昂贵的 FPGA 设备。 通过使用 DATAFLOW 优化和通过小型高效 FIFO 流式传输数据,可以最大限度地减少 Block RAM 的使用,但这需要以流式顺序方式使用数据。 目前没有这样的要求。
下一个问题与性能有关:本地数组的初始化。 循环 Clear_Local 用于将数组 local 中的值设置为零。 即使这个循环在硬件中流水线化以高性能方式执行,这个操作仍然需要大约两百万个时钟周期(HEIGHT*WIDTH)来实现。 在此内存被初始化时,系统无法执行任何图像处理。 可以使用循环 HConv 内的临时变量执行相同的数据初始化,以在写入之前初始化累积。
最后,数据的吞吐量以及系统性能从根本上受到数据访问模式的限制。
- 要创建第一个卷积输出,从输入中读取前 K 个值。
- 为了计算第二个输出,读取一个新值,然后重新读取相同的 K-1 值。
高性能 FPGA 的关键之一是最大限度地减少对 PS 的访问。 对先前已获取的数据的每次访问都会对系统的性能产生负面影响。 FPGA 能够同时执行许多并发计算并达到非常高的性能,但不能在数据流不断因重新读取值而中断的情况下执行。
为了最大限度地提高性能,数据只能从 PS 访问一次,并且本地存储的小单元 - 中小型阵列 - 应该用于必须重用的数据。对于上面显示的代码,无法使用 DMA 操作直接从处理器连续流式传输数据,因为需要一次又一次地重新读取数据。
标准垂直卷积
下一步是执行下图所示的垂直卷积。
垂直卷积的过程类似于水平卷积。 在这种情况下,需要一组 K 个数据样本与卷积系数 Vcoeff 进行卷积。 使用垂直方向的前 K 个样本创建第一个输出后,使用下一组 K 值创建第二个输出。 该过程继续向下遍历每一列,直到创建最终输出。
在垂直卷积之后,由于水平和垂直边界效应,图像现在比源图像 src 小。执行这些操作的代码如下所示。
Clear_Dst:for(int i = 0; i < height * width; i++){ dst[i]=0; } // Vertical convolution VconvH:for(int col = border_width; col < height - border_width; col++){ VconvW:for(int row = 0; row < width; row++){ int pixel = col * width + row; Vconv:for(int i = - border_width; i <= border_width; i++){ int offset = i * width; dst[pixel] += local[pixel + offset] * vcoeff[i + border_width]; } } }
这段代码强调了与水平卷积代码已经讨论过的问题类似的问题。
- 许多时钟周期用于将输出图像 dst 中的值设置为零。 在这种情况下,对于 1920*1080 的图像大小,大约还有 200 万个周期。
- 每个像素有多次访问以重新读取存储在本地阵列中的数据。
- 每个像素有多次写入输出阵列/端口 dst。
上面代码中的访问模式实际上要求拥有如此大的本地数组。 该算法要求行 K 上的数据可用于执行第一次计算。在继续下一列之前处理行中的数据需要将整个图像存储在本地。 这要求存储所有值并导致 FPGA 上的大量本地存储。
此外,当您到达希望使用编译器指令优化硬件功能性能的阶段时,无法通过 FIFO(高性能和低资源单元)管理水平和垂直循环之间的数据流 ) 因为数据不是从数组本地流出的:FIFO 只能用于顺序访问模式。相反,这个需要任意/随机访问的代码需要一个乒乓块 RAM 来提高性能。 这使实现本地阵列的存储器需求加倍,达到大约 400 万个数据样本,这对于 FPGA 来说太大了。
标准边界像素卷积
执行卷积的最后一步是在边界周围创建数据。 这些像素可以通过简单地重用卷积输出中最近的像素来创建。 下图显示了这是如何实现的。
边界区域填充有最近的有效值。 下面的代码执行如图所示的操作。
int border_width_offset = border_width * width; int border_height_offset = (height - border_width - 1) * width; // Border pixels Top_Border:for(int col = 0; col < border_width; col++){ int offset = col * width; for(int row = 0; row < border_width; row++){ int pixel = offset + row; dst[pixel] = dst[border_width_offset + border_width]; } for(int row = border_width; row < width - border_width; row++){ int pixel = offset + row; dst[pixel] = dst[border_width_offset + row]; } for(int row = width - border_width; row < width; row++){ int pixel = offset + row; dst[pixel] = dst[border_width_offset + width - border_width - 1]; } } Side_Border:for(int col = border_width; col < height - border_width; col++){ int offset = col * width; for(int row = 0; row < border_width; row++){ int pixel = offset + row; dst[pixel] = dst[offset + border_width]; } for(int row = width - border_width; row < width; row++){ int pixel = offset + row; dst[pixel] = dst[offset + width - border_width - 1]; } } Bottom_Border:for(int col = height - border_width; col < height; col++){ int offset = col * width; for(int row = 0; row < border_width; row++){ int pixel = offset + row; dst[pixel] = dst[border_height_offset + border_width]; } for(int row = border_width; row < width - border_width; row++){ int pixel = offset + row; dst[pixel] = dst[border_height_offset + row]; } for(int row = width - border_width; row < width; row++){ int pixel = offset + row; dst[pixel] = dst[border_height_offset + width - border_width - 1]; } }
该代码遭受相同的数据重复访问。 阵列 dst 中存储在 FPGA 外部的数据现在必须可以作为输入数据读取多次。 即使在第一个循环中, dst[border_width_offset + border_width] 也被多次读取,但 border_width_offset 和 border_width 的值不会改变。
这段代码读和写都非常直观。 在 SDSoC 环境中实现时,大约需要 120M 时钟周期,达到或略超过 CPU 的性能。 但是,如下一节所示,最佳数据访问模式可确保在 FPGA 上以每个时钟周期一个像素或大约 200 万个时钟周期的速率实现相同的算法。
本次总结是,以下不良数据访问模式会对 FPGA 实现的性能和大小产生负面影响:
- 多次访问以读取然后重新读取数据。 尽可能使用本地存储。
- 以任意或随机访问方式访问数据。 这需要将数据本地存储在数组中并消耗资源。
- 在数组中设置默认值会消耗时钟周期和性能。
具有最佳数据访问模式的算法
将上一节中的卷积示例实现为具有最少资源的高性能设计的关键是:
- 最大化通过系统的数据流。 避免使用任何抑制数据连续流动的编码技术或算法行为。
- 最大限度地重用数据。 使用本地缓存来确保不需要重新读取数据并且传入的数据可以保持流动。
- 接受条件分支。 这在 CPU、GPU 或 DSP 上很昂贵,但在 FPGA 中是最佳的。
第一步是了解数据如何通过系统流入和流出 FPGA。 卷积算法是在图像上执行的。 当生成和使用图像中的数据时,它以标准光栅扫描方式传输,如下图所示。
如果数据以流式方式传输到FPGA,FPGA应该以流式方式处理,并以这种方式从FPGA传回。
下面显示的卷积算法包含这种编码风格。 在这个抽象级别上,显示了代码的简明视图。 但是,现在每个循环之间都有中间缓冲区 hconv 和 vconv。 因为这些是以流方式访问的,所以在最终实现中它们被优化为单个寄存器。
template<typename T, int K> static void convolution_strm( int width, int height, T src[TEST_IMG_ROWS][TEST_IMG_COLS], T dst[TEST_IMG_ROWS][TEST_IMG_COLS], const T *hcoeff, const T *vcoeff) { T hconv_buffer[MAX_IMG_COLS*MAX_IMG_ROWS]; T vconv_buffer[MAX_IMG_COLS*MAX_IMG_ROWS]; T *phconv, *pvconv; // These assertions let HLS know the upper bounds of loops assert(height < MAX_IMG_ROWS); assert(width < MAX_IMG_COLS); assert(vconv_xlim < MAX_IMG_COLS - (K - 1)); // Horizontal convolution HConvH:for(int col = 0; col < height; col++) { HConvW:for(int row = 0; row < width; row++) { HConv:for(int i = 0; i < K; i++) { } } } // Vertical convolution VConvH:for(int col = 0; col < height; col++) { VConvW:for(int row = 0; row < vconv_xlim; row++) { VConv:for(int i = 0; i < K; i++) { } } } Border:for (int i = 0; i < height; i++) { for (int j = 0; j < width; j++) { } }
所有三个处理循环现在都包含条件分支,以确保数据的连续处理。
reference
- UG1270