Parallel Programming for FPGAs FIR滤波器 阅读笔记

718 阅读10分钟

第二章 FIR滤波器

有限脉冲响应(FIR)滤波器

滤波器的两个基本应用是信号重建和信号分离。

滤波器系数可以用来精确地创建许多不同类型的滤波器:低通滤波器, 高通滤波器,带通滤波器等等。一般来说,设计滤波器时,阶数越大提供的自由度越多,设计滤波器的性能越好。

FIR结构基础

滤波器系数存储在函数内部声明的数组c[]中,定义为静态常数。注意系数是对称的。即它们以中心值c[5] = 500为镜像对称。许多FIR滤波器具有这种对称性。我们可以利用这个特点来减少数组c[]所需的存储容量。

#define N 11
#include "ap_int.h"  

typedef int coef_t;
typedef int data_t;
typedef int acc_t;

void fir(data_t *y,data_t x){
    coef_t C[N] = {
        53,0,-91,0,313,500,313,0,-91,0,53
    };
    static
    data_t shift_reg[N];
    acc_t acc;
    int i;
    acc = 0;
    Shift_Accum_Loop:
    for(i = N - 1;i >= 0;i--){
        if(i == 0){
            acc += x * C[0];
            shift_reg[0] = X;
        }else {
            shift_reg[i] = shift_reg[i - 1];
            acc += shift_reg[i] * C[i];
        }
    }
    * y = acc;
}

计算性能

  • 位/秒
  • 操作/秒
  • MACs/秒

每种度量方法都是相互关联的

优化时钟周期的同时还要对时钟频率进行优化

虽然更高频率在通常情况下是能达到更高性能的关键,但对于整个系统来说提高时钟频率并不一定是在整个系统中最优的优化方式。较低的时钟频率为工具在单个周期中组合多个相关操作提供了更多的时间余量,这个过程叫做 操作链接

改进的操作链接可以提高(或者降低)流水处理数据输入间隔。一般来说,提供一个频率约束但该频率值不超过实际运行时钟频率。将时钟周期约束在5−10ns的范围内通常是一个好的开始。

操作链接

增加时钟周期可以组合更多相关操作,但是可能会使得,后续的操作必须等待下一个周期来到才能进行,从而会浪费时间,导致操作/秒降低。如下图所示:

假设加法运算需要2个ns,乘法运算需要3个ns。

指标 图a) 图b) 图c)
处理性能 2亿次乘累加/秒 1.67亿次乘累加/秒 2亿次乘累加/秒。

Summary:

  • 权衡:时钟频率和其他优化策略之间
  • 准则:不幸的是,这里没有好的准则来选择最佳频率。你可以通过更改时钟周期来观察处理性能上的差异。

代码提升

for循环内部的if/else语句效率很低。

Shift_Accum_Loop:
for(i = N-1;i > 0;i--){
    shift_reg[i] = shift_reg[i-1];
    acc += shift_reg[i] * c[i];
}

acc += x * c[0];
shift_reg[0] = x;

代码块:2.3:将for循环中条件语句删除,可以实现更有效的硬件结构。

最终结果是实现一个更加紧凑的结构,适合进一步的循环优化,例如,展开和流水线。我们稍后讨论这些优化。

循环拆分

循环分裂是分别在两个循环中实现各自操作。虽然这样做看起来不像是一个好主意,但是这样做允许我们在每个循环上分别进行优化。

TDL:
for(i = N - 1;i > 0;i--){
    shift_reg[i] = shift_reg[i - 1];
}
shift_reg[0] = x;

acc = 0;
MAC:
for(i = N-1;i >= 0;i--)
{
    acc += shift_reg[i] * c[i];
}

代码块2.4:将for循环分解为两个独立循环的代码片段。

延时线(TDL)是数字信号处理中FIFO操作的术语;MAC是“乘累加”的缩写。

每个循环单独拆分往往不能提高硬件实现效率。但是它可以实现每个循环独立地进行优化,这可能比优化原始的整体for循环更可能得到好结果。反之亦然;将两个(或多个)循环合并到一个循环中可能会产生最好的结果。

优化思路不一样导致优化过程也会有差异。

循环展开

默认情况下Vivado HLS 将循环综合成顺序执行方式。

TDL循环展开

TDL:
for(i = N - 1;i > 1;i = i - 2){
    shift_reg[i] = shift_reg[i - 1];
    shift_reg[i - 1] = shift_reg[i - 2];   
}
if(i == 1){
    shift_reg[1] = shift_reg[0];
}
shift_reg[0] = x;

代码块2.5:手动展开fir11函数中TDL循环

每次循环迭代中循环主体重复的次数成为因子,代码块2.5以因子2将循环展开后的结果。每次循环迭代都执行两个移位操作。因此我们迭代次数变为原来的一半。

在for循环之后还有一个if条件判断。当循环次数为奇数时,这个判断是必需的。在这种情况下,我们必须自己执行最后一次“半”迭代。if语句中的代码执行最后的“半”迭代,即将数据从shift_reg[0]移动到shift_reg[1]。

每次迭代可并行性的限制:

  • 同时shift_reg数组中的两个值
  • 同时shift_reg数组中的两个值

假设我们将shift_reg数组存储在一个BRAM中,BRAM有两个读端口和一个写端口。因此,我们可以在一个循环中可以执行两个读操作,但我们只能在两个周期内顺序进行写操作。

我们可以将所有shift_reg数组的值存储在独立寄存器中。每个寄存器可以每个周期进行读写。

用户可以使用unroll指令告诉Vivado HLS自动循环展开。

#pragma HLS unroll factor=2

当你增加unroll因子时,资源(FFs、LUTs、BRAMs、dsp48等)的数量如何改变?它如何影响吞吐量?当使用数组分区指令与unroll指令一起使用时会产生什么结果?如果你不使用unroll指令会产生什么结果?

MAC循环展开

acc = 0;
MAC:
for(i = N - 1;i >= 3;i -= 4){
    acc += shift_reg[i] * c[i] +
    shift_reg[i - 1] * c[i - 1] +
    shift_reg[i - 2] * c[i - 2] +
    shift_reg[i - 2] * c[i - 2] +
    shift_reg[i - 3] * c[i - 3];
}

for(;i >= 0; i--){
    acc += shift_reg[i] * c[i];
}

注意:后一个循环初始条件为上一个循环后i的值

在for循环展开之后还有一个加法for循环。这是执行任意剩余部分迭代运算的必要条件。

可利用#pragma HLS unroll factor=4以因子4进行循环展开。当没有指定因子参数时for循环将完全展开。在这里因子变为11.

优化参数 skip_exit_check Vivado HLS将不会增加对循环最后部分迭代运算的检查。当你知道循环永远不需要这些最后部分的迭代时,跳过运算检查是很有用的。或者执行最后几次迭代运算不会对结果产生(主要的)影响,因此这些运算可以被跳过。通过使用这个指令,Vivado HLS工具不会创建额外的for循环迭代。因此产生的硬件更简单,而且资源利用率更高。

循环流水

MAC循环

循环体内部操作:

  • 读取c[]:从c数组加载指定数据。
  • 读取 shift_reg[]:从shift_reg数组加载指定数据。
  • ∗:数组c[]和shifit_reg[]相乘。
  • +:将这些相乘的结果累积到acc变量中。

图2.7: a)表示MAC for循环时序图。b)表示三个迭代运算用流水形式优化的MAC for循环。

迭代延迟:执行一次循环运算所需的时钟周期数

PS:另一种计算延迟的方式为延时时钟周期数量等于输入数据和输出数据之间的最大寄存器数。一个32位乘法可能需要5个内部寄存器来达到与使用一个内部寄存器的8位乘法相同的运算频率。因此,操作延迟将更大(5个周期而不是1个周期)

前提:读取操作需要两个时钟周期

假设:

  • 两个读取操作并行(没有依赖关系)
    • 操作可以从第2个周期开始;假设它花费三个周期才能完成
  • +操作在1个周期能够完成

迭代延迟=4

循环延迟:完成整个循环所需的时钟周期数

这个时钟周期数包括初始化周期数(例如,i = 0),条件判断周期数(例如,i>= 0),和增量计算周期数(例如,i−−)

假设:这三个for循环控制语句与循环并行执行

循环延迟:迭代的次数(11)乘以迭代延迟(4)再加上一个判断循环停止的额外周期。然后减去1。=44

PS: “减1”:Vivado HLS通过数据输出就绪的时钟周期来计算延迟。这里,最后一个数据在第43周期准备好。也就等效于在第43周期结束第44个周期开始时写入一个寄存器。

循环流水线

如图2.7,流水线循环延迟是14。

TDL循环

循环起始间隔(II):本次循环到下一次循环开始的时钟周期数。

图2.8:a)TDL for循环两次迭代流程表。b)II=1,TDL for循环三次迭代流程表。

受资源限制:如利用 #pragma HLS resource variable=shift_reg core=RAM 1P 指令强制Vivado HLS工具使用单端口RAM 。当使用该指令与循环流水优化相结合时,Vivado HLS工具将无法使用II=1来连接此循环。

位宽优化

这些C语言数据类型实际位宽可能由于处理器架构不同而不同。例如,int 类型在微处理上可以是16位,通用处理器上可能是32位。C标准规定最小位宽(例如, int 类型至少为16位)和各类型之间关系(例如,long 类型不小于 int 类型,int 类型不小于 short 类型)。C99语言标准消除了这种含糊不清规定,并定义多种类型,如int8t、int16_t、int32 t和int64_t。

作用

  • 大量数据存储容量问题

    位宽较小数据类型

    • 更多数据容量(使用8bit而不是16bit可以将内存容量要求减少一半。)
    • 更少的时钟周期
    • 实现更多指令并行操作
  • 处理位宽不是2的幂次的数据

    • 增加性能
    • 降低资源消耗

使用: c++文件,并且包含头文件ap_int.h,即在你的项目中添加#include“ap_int.h”代码

为了减少计算中整体的舍入误差,最好在积累过程中保留更多比特,然后再针对最终的结果进行截位。

层次化结构

复数FIR滤波器

typedef int data_t;
void firI1(data_t *y,data_t x);
void firQ1(data_t *y,data_t x);
void firI2(data_t *y,data_t x);
void firQ2(data_t *y,data_t x);

void complexFIR(data_t Iin, data_t Qin,data_t *Iout,data_t *Qout){

    data_t IinIfir,QinQfir,QinIfir,IinQfir;

    firI1(&IinIfir,Iin);
    firQ1(&QinQfir,Qin);
    firI2(&QinIfir,Qin);
    firQ2(&IinQfir,Iin);
    * Iout = IinIfir + QinQfir;
    * Qout = QinIfir - IinQfir;
}

函数调用充当接口。Vivado HLS工具不能跨越函数边界进行优化。

如果你希望Vivado HLS工具针对某个特定函数在其父函数中共同与其它代码共同优化,你可以使用inline指令。使层次结构更加易读

  • 提高性能和资源面积优化
  • 也产生了需要工具综合的大量代码。代码综合可能需要很长时间,甚至会综合失败,或者导致不可优化设计。

Vivado HLS工具可以自动进行函数内联。这些函数通常具有少量代码。