shinan

利用赛灵思Vivado HLS实现浮点设计

0
阅读(125) 评论(0)

大多数设计人员在设计中使用定点算术逻辑来运算数学函数,因为这种方法速度快,占用面积小。不过在许多情况下,使用浮点数值格式进行数学计算更为有利。虽然定点格式可以实现精确的结果,但给定的格式动态范围非常有限,故设计人员必须进行深度分析,判断贯穿复杂设计的数位增长特征。而且在采用定点格式的时候,设计人员还必须引入许多中间数据类型(有着不同的定点格式),才能实现理想的质量结果(QoR)。相比之下,浮点格式能够在宽泛得多的范围内表达实数,便于设计人员在许多算法的一长串计算中使用单一的数据类型。
从硬件设计的角度来看,手动实现浮点格式的成本较高。在设计中实现这种格式需占用较大的面积。另外这种格式还会增大时延,因为与实现整数(定点)算术所需的逻辑相比,使用浮点格式实现给定算术运算所需的逻辑要复杂得多。
幸运的是,赛灵思推出一款全新VivadoTM高层次综合(HLS)工具,能够帮助设计人员把C/C++设计规范用于寄存器传输级(RTL),实现需要采取浮点计算的设计。Vivodao可以显著地减少在硬件中实现浮点算法所需的设计工作量。虽然在浮点设计上运行HLS的基本做法一目了然,但一些细节需要详细说明。本文的讨论话题将涉及基本内容和高级内容,涵盖设计性能、面积,以及使用Vivado HLS工具在赛灵思FPGA中实现的浮点逻辑进行验证。

浮点数据和双精度数据 
Vivado HLS工具支持C/C++的浮点数据和双精度数据类型。这两类数据依据的是IEEE 754标准定义的单精度和双精度二进制浮点格式。在采用浮点运算时需要重视的一点是这种数值格式无法表达每一个实数,因此精度有限。
这一点比看上去更微妙、更复杂,而且关于这点已有大量专著。一般来说,即便是在纯软件环境下,针对相同计算采用不同算法乃至相同算法的不同实现(微架构)也无法得到完全相等的二进制结果。这种误差的来源有多种,其中包括舍入误差的累加。而这种累加对运算进行的次序很敏感。另外,FPU对扩展精度的支持也会影响舍入结果。以x87的80位格式为例,SIMD(SSE等)指令的行为就与x87不同。另外,许多浮点字面值只能近似地表达,即便是对有理数也是如此。其他可能导致误差的因素有库函数逼近原理,例如三角函数,常量传用或是合并效应。
此外,部分浮点算法支持“次正常”值,用于表达比浮点格式正常表达值小的数值。例如在单精度格式中,最小的正常浮点值是2-126。但是在支持次正常值的情况下,尾数位可用于通过固定指数值2-127表达定点数值。
现在介绍用于验证浮点计算结果的简单软件示例。
下面的例1说明用不同方法(乃至貌似相同的方法)完成相同计算会得到的结果略有不同。同时,例2说明二进制浮点格式不能准确地表达所有数值(甚至整数)。

例1:相同计算得到不同结果
// Simple demo of floating point predictability problem
int main(void) {
float fdelta = 0.1f; // Cannot be represented exactly
float fsum = 0.0f;
while (fsum < 1.0f)
fsum += fdelta; float fprod = 10.0f * fdelta;
double dprod = float(10.0f * fdelta);
cout.precision(20);
cout << "fsum: " << fsum << endl;
cout << "fprod: " << fprod << endl;
cout << "dprod: " << dprod << endl;
return 0;

}
Program output:
fsum: 1.0000001192092895508
fprod: 1
dprod: 1.0000000149011611938
例1的第一个输出是将0.1的近似值累加10次,会造成舍入误差的积累。每次迭代都会将不精确的单精度0.1加到累加和中,然后存储到单精度(32位)寄存器中。随着累加和指数(以2为底)的增加(从-4到0),不论浮点单元(FPU)内部的精度如何,中间和都会发生四次舍入。
第二个值的计算采用x87扩展精度执行,结果在存储为单精度格式之前进行舍入。第三个值的乘法也采用扩展精度执行,但舍入后存储为双精度格式,会导致结果产生不同偏差。
注意:在编译不同的机器架构或采用不同编译器的情况下,该代码也可能产生不同的结果。

案例2:整数也会丧失精度
// Another demo of floating-point predictability problem
int main(void)
{
int i;
float delta = 1.0f;
for (int i = 0; i < 100000000; i++) {
float x = (float)i + delta;
if (x / (float)i <= 1.0f) {
// (x / i) should always be > 1.0
printf("!!! ERROR on iteration #%d !!!\n", i + 1);
return -1;
}
}
return 0;
}
Program output:
!!! ERROR on iteration #16777217 !!!
这个例子说明,在存储为单精度浮点格式时,大于16,777,216(224)的整数值会丧失精度。这是因为有效位数为24位,超过这个位数,i的最低有效位在转换为浮点格式时必须舍弃。

使用Vivado HLS工具进行浮点设计的基础
要让HLS原生地支持基本算术运算符、关系符和格式转换(比如,整数/定点转浮点,浮点转双精度),需要将这些运算映射到在最终RTL中进行初始化的赛灵思LogiCORE IP浮点运算器内核上。另外,对sqrt()函数族的调用(从C/C++标准数学库中调用)以及1.0/x和1.0/sqrt(x)形式的代码,也需要映射到合适的浮点运算器内核上。虽然CORE GeneratorTM能够为定制精度浮点类型构建这些内核,但Vivado HLS工具只生成符合IEEE-754标准的单精度和双精度内核。软件产生的结果与浮点运算器内核硬件产生的结果之间存在微小的差异,这种差异的来源可能在于运算器内核对次正常输入的处理方式是“一次性归零”,即在遇到次正常值时,用0.0将其替代。 
如果运算过程没有反馈路径,设计人员可以在完全流水线的环境下使用HLS支持的全部浮点运算符和函数,并在每个时钟周期内生成一个结果。Vivado HLS工具虽然可以针对不常发生的串行执行来调度这些运算,但这样难以实现最大的面积效率,尤其对于触发器利用率来说尤为突出。甚至对于“/”和sqrt()也是如此,因为这两者有低吞吐量的内核可以使用。

在基于ANSI/ISO-C的项目中使用<MATH.H>
要在基于ANSI/ISO-C的项目中使用所支持的标准数学库函数,需要将math.h报头文件包含在所有需要调用函数的源文件里。基础函数用于运算(和返回)双精度值,如double sqrt(double)。大多数函数的单精度版都在函数名称上附有“f”,如float sqrtf(float)、float sinf(float)和float ceilf(float)。需要记住这点,否则即使参数和返回变量是单精度值,Vivado HLS也会执行FPGA资源占用规模更大的双精度函数。另外,格式转换也会占据更多资源,增加计算时延。
使用ANSI/ISO-C时还需要注意另外一个问题,即在以软件方式编译和运行代码时(包括RTL协同仿真中的C语言测试台)所使用的算法与在RTL(由HLS生成)中使用的算法不同。在软件中调用的是GCC libc函数,而硬件侧使用的是Vivado HLS工具的数学函数库代码。这会导致两者间存在比特级的不匹配,但从分析角度来说,两种结果都与真实的结果极为接近。

例3:无意识地使用双精度数学函数
// Unintended consequences?
#include <math.h>
float top_level(float inval)
{
// double-precision natural logarithm
return log(inval);
}
这个例子在实现RTL过程中将输入转换为双精度格式,以双精度计算自然对数,然后再将结果转换为单精度输出。

例4:单精度数学函数的显式使用
// Be sure to use the right version of math functions...
#include <math.h>
float top_level(float inval)
{
// single-precision natural logarithm
return logf(inval);
}
由于调用的是单精度对数函数,故在RTL中实现的过程中无须进行输入/输出格式转换。

在C++项目中使用<CMATH>
在使用C++进行设计时,取得标准数学函数库支持的最直接办法是将<cmath>系统头文件包含在所有需要调用函数的源文件中。这个头文件提供各种版本的基础(双精度)函数,函数经加载后可作为参数,并在std命名空间中返回单精度(float)值。要使用函数的单精度版,必须将std命名空间包含在范围内,方法有两种:1使用范围解析运算符(:;2利用指令导入整个命名空间。
和在ANSI/ISO-C项目中使用<math.h>的情况一样,当在代码中使用<cmath>中的函数时(代码通过Vivado HSL工具进行综合),以软件方式运行代码的结果与用RTL实现代码的结果也可能存在差异,因为两种方式采用的近似算法不同。为此,Vivado HLS编程环境提供一些特定的算法,可用于对RTL进行综合以便用于C++建模。
在对HLS的C++代码修改情况进行验证,以及后续用C++测试台对得到的RTL进行协同仿真的过程中,赛灵思建议在HLS源代码中使用相同的数学库调用,并在测试台代码中使用C++标准库,以便生成参考值。这样能够在开发过程中为HLS模型和数学库提供多一层验证。
要采用这种方法,应只将<hls_math.h>Vivado HLS工具头文件包含在需要综合为RTL的源文件中。只有在验证HLS设计时使用的源文件(如测试程序和支持代码)才需要包含<cmath>系统头文件。<hls_math.h>头文件中函数的HLS版是hls::namespace的组成部分。对于需要针对软件建模和验证进行编译的HLS版函数来说,应针对每一个函数调用使用hls::scope解析。
注意:在使用C++标准数学库调用时,不应导入hls::namespace(通过“using namespace hls”命令),因为这样会在HLS综合的过程中导致编译错误。
编译该代码并以软件方式运行(如在Vivado HLS图形用户界面中“Run C/C++ Project”),hw_cos_top()返回的结果与HLS生成的RTL所产生的结果相同,同时该程序还会根据软件参考模型(即std:cos())测试不匹配结果。如果之前将<cmath>包含在hw_cos.cpp中,则C/C++项目进行编译并以软件方式运行时就不会出现不匹配情况,但在RTL协同仿真的过程中会出现不匹配。由于浮点运算使用的资源量比整数运算或者定点运算要大得多,所以Vivado HLS工具会尽量高效地使用这些资源。
重要的是不要假定Vivado HLS工具会进行看似平淡又微不足道的优化。和大多数C/C++软件编译器一样,涉及浮点字面值(数值常量)的表达式可能不会在HLS综合过程中得到优化。
如果把这个函数综合到RTL,得到用于计算r0、r1和r2的三个线路会有显著不同。根据C/C++的规则,字面值0.1代表一个无法精确表达的双精度数。因此工具会实例化一个双精度(double)乘法器内核,以及用于将inval转换为双精度,再将乘积转换回单精度类型(*r0的类型)的内核。如果需要的是单精度(float)常量,应给该字面值附加一个f,比如0.1f。因此,上述r1的值就是(非精确)0.100的浮点表达与inval的单精度乘积。最终,r2用单精度除法内核求得,inval是分子,10.0f是分母。实数值10可以确切地用二进制浮点格式表达。因此根据inval值的情况,r2的计算可能是精确的,而r0或者r1都欠精确。
由于浮点运算的顺序可能会影响结果(例如,在不同的时间进行舍入),因此表达式中的多个浮点字面值可能无法合并到一起。

并行性、同步性和资源共享
由于浮点运算使用的资源量比整数运算或者定点运算要大得多,所以Vivado HLS工具会尽量高效地使用这些资源。在数据关系和约束允许的条件下,该工具一般会让源运算的多次调用共享浮点运算器内核。下面的例子将对四个浮点值求和,用以说明这个概念。
在数据带宽允许的情况下,可以将本需要顺序执行的大量运算在给定时间内同步完成,以达到提高运算量的目的。在下面的例子中,HLS工具创建的RTL将流水线循环中两个源数组的元素相加,生成结果数组的值。Vivado HLS把顶层数组参数映射到存储器接口上,从而限制每周期的访问数量(例如,对双端口RAM来说是每周期两次,FIFO等是每周期一次)。
    在默认条件下,Vivado HLS工具安排该循环迭代32次并实现单个加法器内核,前提是输入数据持续不断,且输出FIFO不会满。得到的RTL模块需要使用32个周期,还需要一些周期用于清空加法器流水线。在I/O数据速率允许的条件下速度越快越好。另一方面如果数据速率提高,设计人员还可以用HLS技术相应地提升处理速度。作为对上述例子的扩展,我们可以使用Vivado HLS工具的数组维度改变指令将接口宽度加倍,从而增大I/O带宽。要提高处理速度,需将循环局部展开两倍,使其与带宽的增加相匹配。
    使用这些增加的指令,Vivado HLS工具能综合出具有两个加法器流水线的RTL。两个流水线可以并行工作,使迭代数量减半,每次迭代能产生两个输出样本。这样做之所以可行,是因为每个计算都是完全独立的,而且加法运算的次序不会影响结果的精度。不过如果出现更加复杂的计算,比如需求出一连串独立浮点运算的结果,Vivado HLS工具就无法重新安排这些运算的次序。最终导致并行性或者共享性低于预期。另外,如果流水线数据路径中存在反馈或者递归,那么通过同步化来增大设计的吞吐量就需要对源代码结构进行一些手动重调整。
    由于这种形式的累加会导致递归,浮点加法的延迟一般大于一个周期,故该流水线不能实现一个周期一次累加的吞吐量。
例如,如果浮点加法器的延迟是四个周期,则流水线初始化间隔也是四个周期。由于只有在完成一次累加之后才能开始另一次累加,因此这个例子中所能实现的最大吞吐量是每四个周期完成一次累加。累加循环迭代32次,每次行程耗时四个周期,最终共需要128个周期,以及用于清空流水线的附加周期。
一种性能更高的替代方法是在相同的加法器内核上穿插进行四个局部累加,每四个周期完成一次局部累加,从而减少完成32次加法运算所需的时间。但是Vivado HLS工具无法用例14提供的代码实现这种优化方案,因为这需要变更累加的运算次序。如果每次部分累加都将x[]的第四个元素作为输入,那么单个加数的次序发生变化就会造成结果的不同。
避开这个局限的方法是对源代码进行小幅修改,让自己的意图更加明显。下面的示例代码引入一个用于存储部分和的数组acc_part。部分和随后进行加总,同时让主累加循环部分展开。
采用这种代码结构,Vivado HLS工具能够通过交替周期的方式将四次部分累加调度到一个加法器内核上,从而实现更高效率的资源利用。后续的最终累加也可以根据其他因素使用相同的加法器内核。现在主累加循环需要八次迭代(32/4),每次迭代用四个周期完成四个部分累加。这样,完成相同工作量所需的时间大大减少,而且只需多占用很少量的FPGA资源。最终累加循环也使用相同的加法器内核,会使周期数量有所增加,但增加的数量是固定的,而且考虑到通过避免展开主累加循环而节省的大量周期,这个增加值是很小的。可以进一步优化最终累加算法,但会造成性能面积比下降。
在有更大I/O带宽的情况下,我们可以设定更大的展开因数,让更多的算术内核承担运算任务。在上述的例子中,如果每个时钟周期有两个x[]元素可用,就可以把展开因素增加到8。此时能实现两个加法器内核,每个周期能完成八个部分累加。目标器件的选择和用户的时间约束会给具体的运算器延迟造成影响。一般来说,有必要运行HLS并对较为简单的基础案例进行性能分析,用以判断理想的展开量。

控制实现资源
赛灵思LogiCORE IP浮点运算器内核能够控制一些运算的DSP48利用率。例如,乘法器内核有四个变量,它们可以用逻辑资源(LUT)交换DSP48用量。在正常情况下,Vivado HLS工具会根据性能约束自动判断需要使用哪类内核。设计人员可使用Vivado HLS工具的RESOURCE指令覆盖自动选项,并针对给定的运算设定所使用的浮点运算器类型。例如,对于例14中提供的代码,加法器一般会使用“全占用”内核的方式来实现,并使用Kintex™-7 FPGA上的两个DSP48E1资源。如综合报告的“组件”部分所示。

验证浮点计算的结果
用不同方式运行相同计算所得的浮点结果之间存在数位级(或者更高)的差异,这里面的原因有很多。误差会发生的位置也不同,包括不同的近似算法、计算顺序的重新排序导致的舍入差异、以及次正常值的处理(此时浮点运算器内核清零)。
一般来说,两个浮点值的比较结果(特别是相等比较),可能造成误导。两个被比较的值可能只在“最后一位”(ULP;二进制格式中的作用最小的位)存在差异,从而导致极小的相对误差,造成“==”运算符号返回假值。例如,在使用单精度浮点格式的情况下,如果两个操作数都非零(也非次正常值),1ULP的差异代表0.00001%的相对误差。因此,在比较浮点数时,应避免使用“==”和“!=”运算符。要检查两个值是否“足够接近”,可以使用可接受误差阈值。
在大多数情况下,设置可接受的ULP或相对误差级就可以,比设置绝对误差(或者“ε”)阈值要好。但是如果需要比较的值中有一个是零(0.0),这种方法就失效了。如果比较的值中有一个是恒零值或可以产生恒零值,那么就需要使用绝对误差阈值。下面的示例代码提供了一种用于比较两个浮点数近似相等的方法,便于您设置ULP和绝对误差限值。这个函数可以用在C/C++“测试台”代码中,用于验证HSL源代码的修改情况,并验证Vivado HLS工具的RTL协同仿真。您还可以在HLS的实现代码中使用类似的方法。
关于应该将 ULP和绝对误差阈值设置在什么水平这个问题没有唯一的答案,因为设置会因设计而异。相对于基准结果而言,复杂的算法可能会在输出中累加更多ULP误差。其他关系运算符也可能会误导结果。例如,在测试某值是否小于(或者大于)另一值时,误差仅有几个ULP,这种情况下是否可以做出合理的结论?我们可以将上面提供的函数与小于/大于比较法结合使用,用来判别含糊的结果。

强大的功能
在赛灵思FPGA上用C/C++源代码轻松实现浮点算法硬件(RTL代码)是Vivado HLS工具所具备的一种强大功能。但浮点算法的使用不管是从软件的角度、硬件的角度还是混合的角度都并非像看上去那么直观。IEEE-754标准二进制浮点格式的非精确性给验证计算结果带来难度。另外,设计人员在C/C++源代码层面以及应用HLS优化指令的时候,都必须额外小心,才能在FPGA资源利用和设计性能方面获得理想的结果。