写在前面
本文参考赛灵思的官方手册UG1270,主要介绍了硬件函数的设计方法论和操作的优化策略,以帮助我们更好的进行HLS的开发设计。
优化硬件函数概述
Vivado HLS 的默认行为是以顺序方式执行函数和循环,以便硬件准确反映 C/C++ 代码。 优化指令可用于增强硬件函数的性能,允许流水线操作,从而显着提高函数的性能。 本文概述了优化设计以获得高性能的一般方法。
在尝试使用 Vivado HLS 优化设计时,有许多可能的目标。 该方法假设您希望创建具有尽可能最高性能的设计,在每个时钟周期处理一个新输入数据样本,因此在用于减少延迟或资源的优化之前解决这些优化问题。
硬件函数优化方法论
Vivado HLS 编译器将硬件函数综合到可编程逻辑 (PL) 中的硬件中。 该编译器自动将 C/C++ 代码转换为 FPGA 硬件实现,并且与所有编译器一样,使用编译器默认值来执行此操作。 除了编译器默认值之外,Vivado HLS 还通过在代码中使用编译指示提供了许多应用于 C/C++ 代码的优化。 本文解释了可以应用的优化以及应用它们的推荐方法。
这是优化硬件函数的两个流程。
- 自上而下的流程:在此流程中,将程序分解为硬件函数在 SDK 环境中自上而下进行,让系统编译器创建在数据流模式下自动运行的函数流水线。 每个硬件函数的微架构都使用 Vivado HLS 进行了优化。
- 自下而上的流程:在此流程中,硬件函数使用 Vivado 设计套件中提供的 Vivado HLS 编译器独立于系统进行优化。 分析硬件函数,可以应用优化指令来创建除默认之外的实现,然后将生成的优化硬件函数合并到 SDK 环境中。
自下而上的流程通常用于软件和硬件由不同团队优化的组织,可供希望利用组织内部或合作伙伴现有硬件实现的软件程序员使用。两种流程都受支持,并且在任何一种情况下都使用相同的优化方法。 两种工作流程都产生相同的高性能系统。
硬件函数的优化方法如下图所示:
设计将在第 3 步之后达到最佳性能。第 4 步用于最小化或专门控制整个设计中的延迟,并且仅在需要考虑这一点的应用程序中才需要。 步骤 5 解释了如何减少硬件实现所需的资源,通常仅在可用资源中无法实现较大的硬件函数时应用。 FPGA 具有固定数量的资源,如果满足性能目标,创建较小的实现通常没有任何好处。
基准硬件函数设计
在寻求执行任何硬件函数优化之前,重要的是要了解使用现有代码和编译器默认值实现的性能,并了解如何衡量性能。 这是通过选择实现硬件的函数和构建项目来实现的。
项目构建完成后,IDE 的报告部分将提供一份报告。 此报告详细介绍了性能估计和利用率估计。也即是在不加任何约束的情况下默认优化方式进行C综合生成的综合报告。
性能估计中的关键因素是按顺序排列的时序、间隔和延迟。
- 时序总结报告显示目标和估计时钟频率如果估计的时钟频率大于目标,则硬件将不会在此时钟频率下运行。 应降低项目时钟频率。 因为这只是流程中此时的估算,如果估算仅超出目标 20%,则可能会继续执行流程的其余部分。 生成比特流时会应用进一步的优化,但仍有可能满足时序要求。 但是,这表明硬件函数不能保证满足时序要求。
- 启动间隔 (initiation interval,II) 是函数可以接受新输入之前的时钟周期数,通常是任何系统中最关键的性能指标。 在理想的硬件函数中,硬件以每个时钟周期一个样本的速率处理数据。 如果传入硬件的最大数据集大小为 N(例如,my_array[N]),则最佳 II 为 N + 1。这意味着硬件函数在 N 个时钟周期内处理 N 个数据样本,并且在处理完所有 N 个样本后一个时钟周期可以接受新数据。 可以创建一个 II < N 的硬件函数,但是,这需要更多的 PL 资源,而且收益通常很小。 硬件函数通常是理想的,因为它以比系统其他部分更快的速度消耗和产生数据。
- 循环启动间隔(loop initiation interval)是循环的下一次迭代开始处理数据之前的时钟周期数。 当您深入分析以定位和消除性能瓶颈时,此指标变得很重要。
- 延迟(latency)是函数计算所有输出值所需的时钟周期数。 这只是从应用数据到准备就绪之间的延迟。 对于大多数应用程序来说,这无关紧要,尤其是当硬件函数的延迟大大超过软件或系统函数(如 DMA)的延迟时。 但应该查看和确认的性能指标对应用程序来说没有问题。
- 循环迭代延迟(loop iteration latency)是完成一次循环迭代所需的时钟周期数,循环延迟是执行循环所有迭代的周期数。
报告的面积估计部分详细说明了 PL 中需要多少资源来实现硬件函数以及设备上有多少可用资源。 这里的关键指标是利用率 (%)。 任何资源的利用率 (%) 不应超过 100%。 大于 100% 的数字表示没有足够的资源来实现硬件函数,可能需要更大的 FPGA 设备。 与时间一样,在流程的这一点上,这是一个估计值。 如果数字仅略高于 100%,则可能会在比特流创建期间优化硬件。
指标优化
下表显示了您应该考虑添加到设计中的第一个指令。
指令和配置 | Description |
LOOP_TRIPCOUNT | 用于具有可变边界的循环。 提供循环迭代计数的估计值。 这对合成没有影响,只对报告有影响。 |
首次编译硬件函数时,一个常见问题是报告文件将延迟和间隔显示为问号“ ?” ,而不是作为数值。 如果设计具有可变循环边界的循环,则编译器无法确定延迟或 II 并使用“?” 来表示这种情况。 可变循环边界是在编译时无法解决循环迭代限制的地方,因为当循环迭代限制是硬件函数的输入参数时,例如可变高度、宽度或深度参数。
要解决此情况,请使用硬件函数报告来定位未能报告数值的最低级别循环,并使用 LOOP_TRIPCOUNT 指令来应用估计的行程计数。 行程计数是预期迭代的最小、平均和/或最大数量。 这允许报告延迟和间隔的值,并允许比较具有不同优化的实现。
由于LOOP_TRIPCOUNT值仅用于报告,并且对生成的硬件实现没有影响,因此可以使用任何值。 但是,准确的预期值会产生更有用的报告。
使用流水操作进行性能优化
创建高性能设计的下一个阶段是流水线化函数、循环和操作。 流水线导致最高级别的并发性和最高级别的性能。 下表显示了可用于流水线的指令。
在优化过程的这个阶段,您希望创建尽可能多的并发操作。 您可以将 PIPELINE 指令应用于函数和循环。 您可以在包含函数和循环的级别使用 DATAFLOW 指令以使它们并行工作。 虽然很少需要,但 RESOURCE 指令可用于挤出最高级别的性能。
推荐的策略是自下而上工作,并注意以下事项:
- 一些函数和循环包含子函数。 如果子函数未流水线化,则其上方的函数在流水线化时可能会显示有限的改进。 非流水线子函数将是限制因素。
- 一些函数和循环包含子循环。 当您使用 PIPELINE 指令时,该指令会自动展开下面层次结构中的所有循环。 这可以创建大量的逻辑。 在下面的层次结构中流水循环可能更有意义。
- 对于流水操作的上层层次结构并展开层次结构中较低层的任何循环确实有意义的情况,无法展开具有可变边界的循环,并且无法对这些循环之上的层次结构中的任何循环和函数进行流水处理。
要解决此问题**,请使用可变边界流水线化这些循环,并使用 DATAFLOW 优化来确保流水线化循环并发运行,以最大限度地提高包含循环的任务的性能。**或者,重写循环以删除变量绑定;应用带有条件中断的最大上限。
优化过程中此时的基本策略是尽可能多地流水线化任务(函数和循环)。
也可以在操作级别应用流水线。 例如,FPGA 中的布线可能会引入大量的意外延迟,从而使设计难以在所需的时钟频率下实现。在这种情况下,您可以使用 RESOURCE 指令对乘法器、加法器和块 RAM 等特定操作进行流水线化,以在逻辑级别添加额外的流水线寄存器阶段,并允许硬件函数以尽可能高的性能级别处理数据,而无需为递归。
Config 命令用于更改优化默认设置,并且仅在使用自下而上的流程时在 Vivado HLS 中可用。
硬件函数流水线策略
获得高性能设计的关键优化指令是 PIPELINE 和 DATAFLOW 指令。 下面进行讨论如何将这些指令应用于各种 C 代码架构。
有两种基本类型的C/C++函数架构:基于帧的函数和基于采样的函数。无论使用哪种编码方式,在这两种情况下,硬件函数都可以以相同的性能实现。区别仅在于如何应用优化指令。
Frame-Based C Code
基于帧的编码风格的主要特征是该函数处理多个数据样本:一个数据帧。通常作为数组或指针提供,在每个事务期间通过指针算法访问数据(一个事务被认为是一个完整的执行 C 函数)。 在这种编码风格中,数据通常通过一系列循环或嵌套循环进行处理。
下面显示了基于框架的 C 代码的示例概要。
void foo( data_t in1[HEIGHT][WIDTH], data_t in2[HEIGHT][WIDTH], data_t out[HEIGHT][WIDTH] { Loop1: for(int i = 0; i < HEIGHT; i++) { Loop2: for(int j = 0; j < WIDTH; j++) { out[i][j] = in1[i][j] * in2[i][j]; Loop3: for(int k = 0; k < NUM_BITS; k++) { . . . . } } }
当寻求在何处(C/C++ 代码中)进行添加流水操作指令以在硬件中获得最大性能时,希望将流水优化指令放置在处理数据样本的级别。
上面的例子代表了用于处理图像或视频帧的代码,可以用来强调如何有效地流水线化硬件函数。 两组输入作为数据帧提供给函数,输出也是数据帧。 此函数可以在多个位置进行流水线处理:
- 在函数foo 级别。
- 在循环Loop1 的级别。
- 在循环Loop2 级别。
- 在循环Loop3 级别。
查看在每个位置放置 PIPELINE 指令的优缺点有助于解释为代码放置流水指令的最佳位置。
函数层foo: 函数接受一帧数据作为输入(in1 和 in2)。 如果函数是在 II = 1 的情况下流水线化的——每个时钟周期读取一组新的输入——这会通知编译器在单个时钟周期内读取 in1 和 in2 的所有 HEIGHT*WIDTH 值。 这不太可能是您想要的设计。
如果 PIPELINE 指令应用于函数 foo,则必须展开此级别以下层次结构中的所有循环。 这是流水线的要求,即**流水线内部不能有时序逻辑。 这将创建HEIGHT*WIDTH*NUM_ELEMENT 量级的逻辑复制,从而导致大型设计。
由于数据是按顺序访问的,因此硬件函数接口上的数组可以实现为多种类型的硬件接口:
- Block RAM 接口
- AXI4 接口
- AXI4-Lite 接口
- AXI4-Stream 接口
- FIFO 接口
块RAM接口可以实现为双端口接口,每个时钟提供两个样本。其他接口类型只能为每个时钟提供一个样本。这将导致瓶颈。将有一个大型的高度并行硬件设计,无法并行处理所有数据,并将导致硬件资源的浪费。
循环层Loop1: Loop1中的逻辑处理二维矩阵的一整行。 将 PIPELINE 指令放置在这里将创建一个设计,该设计试图在每个时钟周期中处理一行。 同样,这将展开下面的循环并创建额外的逻辑。 但是,使用附加硬件的唯一方法是在每个时钟周期传输整行数据:一个 HEIGHT 数据字数组,WIDTH*<data_t bit>位宽的数据。类似下图,如果数据宽度为8,每个数据的位宽为8,此时在一个时钟周期传输下图这样一整行的数据提供运算。
由于运行在 PS 上的主机代码不太可能处理如此大的数据字,这将再次导致存在许多高度并行的硬件资源由于带宽限制而无法并行运行的情况。
循环层Loop2 :Loop2 中的逻辑试图处理数组中的一个样本。 在图像算法中,这是单个像素的级别。 如果设计要在每个时钟周期处理一个样本,则这是流水线级别。 这也是接口从 PS 消耗和产生数据的速率。
这将导致 Loop3 完全展开,但每个时钟处理一个样本。 Loop3 中的所有操作都要求并行执行。 在典型设计中,Loop3 中的逻辑是一个移位寄存器或正在处理一个字中的位。 要在每个时钟执行一个样本,您希望这些过程并行发生,因此您希望展开循环。 由流水线 Loop2 创建的硬件函数每个时钟处理一个数据样本,并仅在需要实现所需的数据吞吐量水平时创建并行逻辑。
循环层Loop3 :鉴于 Loop2 对每个数据样本或像素进行操作,Loop3 通常会执行位级或数据移位任务,因此该级别对每个像素执行多项操作。 流水线化这个级别意味着每个时钟在这个循环中执行一次每个操作,因此每个像素执行 NUM_BITS 个时钟:以每个像素或数据样本多个时钟的速率进行处理。
例如,Loop3 可能包含一个移位寄存器,用于保存窗口或卷积算法所需的先前像素。 在此级别添加 PIPELINE 指令会通知编译器每个时钟周期移位一个数据值。 该设计只会返回到 Loop2 中的逻辑并在 NUM_BITS 次迭代后读取下一个输入,从而导致数据处理速度非常慢。
本示例中流水指令的理想位置是 Loop2。
在处理基于帧的代码时,您将需要在循环级别进行流水线处理,并且通常对在样本级别运行的循环进行流水线处理。 如果有疑问,将打印命令放入 C 代码并确认这是您希望在每个时钟周期执行的级别。
优化建议:
对于在同一层次结构中有多个循环的情况—上面的例子只显示了一组嵌套循环—可以为每个循环确定放置 PIPELINE 指令的最佳位置,然后将 DATAFLOW 指令应用于函数以确保每个循环以并发方式执行。
Sample-Based C Code
下面显示了基于示例的 C 代码的示例概要。 这种编码风格的主要特征是该函数在每个事务期间处理单个数据样本。
void foo (data_t *in, data_t *out) { static data_t acc; Loop1: for (int i=N-1;i>=0;i--) { acc+= ..some calculation..; } *out=acc>>N; }
基于样本的编码风格的另一个特点是函数通常包含一个静态变量:在函数调用之间必须记住其值的变量,例如累加器或样本计数器。
对于基于样本的代码,PIPELINE 指令的位置很明确,即实现 II = 1 并在每个时钟周期处理一个数据值,为此必须对函数进行流水线处理。
这会展开函数内的任何循环并创建额外的硬件逻辑,但没有办法解决这个问题。 如果 Loop1 是流水线的,则最少需要 N 个时钟周期才能完成。 只有这样,函数才能读取下一个 x 输入值。
在处理在样本级别处理的 C 代码时,策略始终是流水函数。
在这种类型的编码风格中,循环通常在数组上运行并执行移位寄存器或行缓冲区功能。 优化结构以确保所有样本在单个时钟周期内移位,将这些数组划分为单独的元素并不少见。 如果阵列在块 RAM 中实现,则每个时钟周期最多只能读取或写入两个样本,从而造成数据处理瓶颈。
这里的解决方案是流水函数 foo。 这样做会导致设计每个时钟处理一个样本。
reference
- UG1270