参考Xilinx文档,Vitis HLS Coding Styles
不支持的C/C++部分
系统调用
printf()
,fprintf(stdout,)
等不会影响算法执行的系统调用——忽略掉。getc()
,time()
,sleep()
等系统调用——不被接受。- 可以使用
__SYNTHESIS__
宏来区分综合与调试过程
void hier_func4(din_t A, din_t B, dout_t *C, dout_t *D)
{
dint_t apb, amb;
sumsub_func(&A, &B, &apb, &amb);
#ifndef __SYNTHESIS__
FILE *fp1; // The following code is ignored for synthesis
char filename[255];
sprintf(filename, Out_apb_ % 03d.dat, apb);
fp1 = fopen(filename, w);
fprintf(fp1, % d \n, apb);
fclose(fp1);
#endif
shift_func(&apb, &amb, C, D);
}
动态内存
malloc()
,new
等动态分配内存的不行, 不能在堆上分配内存,必须在栈上。(因为该HLS技术是静态分析)- 一种改动方法是直接对栈上变量取指针:
#include "malloc_removed.h"
#include <stdlib.h>
//#define NO_SYNTH
dout_t malloc_removed(din_t din[N], dsel_t width) {
#ifdef NO_SYNTH
long long *out_accum = malloc (sizeof(long long));
int* array_local = malloc (64 * sizeof(int));
#else
long long _out_accum;
long long *out_accum = &_out_accum;
int _array_local[64];
int* array_local = &_array_local[0];
#endif
// 中间计算省略
return *out_accum;
}
指针的一些限制
通用指针类型转换
HLS 只支持C/C++原生类型的转换
指针数组
如果每个指针指向一个标量或一个标量数组,则Vitis HLS支持指针数组的综合。但指针数组不能指向额外的指针(应该类似二维数组的行、列指针)
函数指针
不支持
递归函数
- 不支持。不管最后递归次数是不是有限的。
- 可以使用C++模板来构造可以用来综合的尾递归,因为C++中支持非类型模板参数(Nontype Template Parameters),类似Rust中的const泛型,这儿的模板参数为
data_t
类型的一个值 N。例子如下:
// Tail recursive call
template<data_t N>
struct fibon_s {
template<typename T>
static T fibon_f(T a, T b) {
return fibon_s<N-1>::fibon_f(b, (a+b));
}
};
// Termination condition
template<> struct fibon_s<1> {
template<typename T>
static T fibon_f(T a, T b) {
return b;
}
};
void cpp_template(data_t a, data_t b, data_t &dout){
dout = fibon_s<FIB_N>::fibon_f(a,b);
}
STL
有动态内存和递归,不能使用。
函数
顶层函数不能是静态的。
内联函数
- 综合时候花费时间、内存较多,但效果更好。
- 没有独立的RTL文件和报告了。
代码风格的影响
- 影响函数参数和接口
- 直接用函数接口的输入量来驱动变量时候,程序就不会使用某些优化手段。(如输入量是循环索引的上限)
#include "ap_int.h"
ap_int<24> foo(int x, int y) {
int tmp;
tmp = (x * y);
return tmp
}
上述代码这会导致一个32-bit乘法器,输出再被截取。
下面这个代码直接产生一个24-bit乘法器。
#include "ap_int.h"
typedef ap_int<12> din_t;
typedef ap_int<24> dout_t;
dout_t func_sized(din_t x, din_t y) {
int tmp;
tmp = (x * y);
return tmp
}
C/C++内置函数
- 只支持以下两个
__builtin_clz(unsigned int x)
: Returns the number of leading 0-bits in x, starting at the most significant bit position. If x is 0, the result is undefined.__builtin_ctz(unsigned int x)
: Returns the number of trailing 0-bits in x, starting at the least significant bit position. If x is 0, the result is undefined.
循环
- 支持得很好。可以切流水线,展开、部分展开、合并和扁平化
- 不要使用全局变量作为循环变量,否则会阻碍代码优化
循环变量范围
- 循环变量的上限如果是变量,就难以在综合时候优化。
- 循环变量的上限如果是变量,循环的latency无法确定。
- 循环变量的上限如果是变量,设计的性能未知。
#include "ap_int.h"
#define N 32
typedef ap_int<8> din_t;
typedef ap_int<13> dout_t;
typedef ap_uint<5> dsel_t;
dout_t code028(din_t A[N], dsel_t width) {
dout_t out_accum=0;
dsel_t x;
LOOP_X:for (x=0;x<width; x++) {
out_accum += A[x];
}
return out_accum;
}
为了克服无法分析性能的缺点,一般是加上
#pragma HLS loop_tripcount min=<int> max=<int> avg=<int>
或者是使用断言assert
void foo (num_samples, ...) {
int i;
...
loop_1: for(i=0;i< num_samples;i++) {
#pragma HLS loop_tripcount min=12 max=16
...
result = a + b;
}
}
注意:该编译选项只用于分析,不会用于综合。
- 对于具有可变边界的循环的解决方案是:在循环中有条件地执行,并且令循环迭代的次数为固定值。
#include "ap_int.h"
#define N 32
typedef ap_int<8> din_t;
typedef ap_int<13> dout_t;
typedef ap_uint<5> dsel_t;
dout_t loop_max_bounds(din_t A[N], dsel_t width) {
dout_t out_accum=0;
dsel_t x;
LOOP_X:for (x=0; x<N; x++) {
if (x<width) {
out_accum += A[x];
}
}
return out_accum;
}
将循环流水线化
通常通过流水线最内层的循环来找到面积和性能之间的最佳平衡。
#include "loop_pipeline.h"
dout_t loop_pipeline(din_t A[N]) {
int i,j;
static dout_t acc;
LOOP_I:for(i=0; i < 20; i++){
LOOP_J: for(j=0; j < 20; j++){
acc += A[i] * j;
}
}
return acc;
}
-
Pipeline
LOOP_J
- 只需调度一个乘法器操作和一个数组访问
- Latency is approximately 400 cycles (20x20) and requires less than 100 LUTs and registers (the I/O control and FSM are always present).
-
Pipeline
LOOP_I
- 内部循环展开20次。
- 需调度20个乘法器操作和20个数组访问
- Latency is approximately 20 cycles but requires a few hundred LUTs and registers. About 20 times the logic as first option, minus any logic optimizations that can be made.
-
Pipeline
function loop_pipeline
- 需调度400个乘法器操作和400个数组访问
- Latency is approximately 10 (20 dual-port accesses) but requires thousands of LUTs and registers (about 400 times the logic of the first option minus any optimizations that can be made)
不完美的循环嵌套
不完美的循环嵌套,或者无法将循环嵌套展开,会导致进入和退出循环的额外时钟周期。
循环并行
- HLS会使得逻辑和函数并行,但是并不会调度循环来并行。
- 例子中SUM_X和SUM_Y不会并行调度,而是顺序的。(因为两个循环变量有不同的上限)
#include "loop_sequential.h"
void loop_sequential(din_t A[N], din_t B[N], dout_t X[N], dout_t Y[N],
dsel_t xlimit, dsel_t ylimit) {
dout_t X_accum=0;
dout_t Y_accum=0;
int i,j;
SUM_X:for (i=0;i<xlimit; i++) {
X_accum += A[i];
X[i] = X_accum;
}
SUM_Y:for (i=0;i<ylimit; i++) {
Y_accum += B[i];
Y[i] = Y_accum;
}
}
把两个循环装在function里面就可以并行了
#include "loop_functions.h"
void sub_func(din_t I[N], dout_t O[N], dsel_t limit) {
int i;
dout_t accum=0;
SUM:for (i=0;i<limit; i++) {
accum += I[i];
O[i] = accum;
}
}
void loop_functions(din_t A[N], din_t B[N], dout_t X[N], dout_t Y[N],
dsel_t xlimit, dsel_t ylimit) {
sub_func(A,X,xlimit);
sub_func(B,Y,ylimit);
}
- 这是在函数中捕获循环以利用并行性的原则
循环依赖
- 一次循环开始可能会依赖上一次循环的结束。
Minim_Loop: while (a != b) {
if (a > b)
a -= b;
else
b -= a;
}
- 解决方案是尽量确保初始操作尽早执行(应该就是对循环变量做修改等)
在c++类中不会展开循环
- 应小心确保循环归纳变量不是类的数据成员,因为这会防止循环被展开。
template <typename T0, typename T1, typename T2, typename T3, int N>
class foo_class
{
private:
pe_mac<T0, T1, T2> mac;
public:
T0 areg;
T0 breg;
T2 mreg;
T1 preg;
T0 shift[N];
int k; // Class Member
T0 shift_output;
void exec(T1 *pcout, T0 *dataOut, T1 pcin, T3 coeff, T0 data, int col)
{
Function_label0:;
#pragma HLS inline off
SRL:
for (k = N - 1; k >= 0; --k)
{
#pragma HLS unroll // Loop will fail UNROLL
if (k > 0)
shift[k] = shift[k - 1];
else
shift[k] = data;
}
*dataOut = shift_output;
shift_output = shift[N - 1];
}
*pcout = mac.exec1(shift[4 * col], coeff, pcin);
};
数组
- 仿真时候内存不够怎么办?一个妥协的方法是动态内存。
- 定点数占用内存 > arbitrary precision type > C自带类型
#include "ap_int.h"
int i, acc;
#ifdef __SYNTHESIS__
// Use an arbitrary precision type & array for synthesis
ap_int<32> la0[10000000], la1[10000000];
#else
// Use an arbitrary precision type & dynamic memory for simulation
ap_int<int32> *la0 = malloc(10000000 * sizeof(ap_int<32>));
ap_int<int32> *la1 = malloc(10000000 * sizeof(ap_int<32>));
#endif
for (i=0 ; i < 10000000; i++) {
acc = acc + la0[i] + la1[i];
}
- 数组长度小于1024:实例化为FIFO
- 数组长度大于1024: 保存在block RAM或LUTRAM或UltraRAM
数组访问和性能
- 对数组访问次数越多,越会限制性能。(尤其是在一个循环中的情况)
- 所以有如下的更改策略:
#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;
}
变成
#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;
}
FIFO访问
因为是先入先出,所以必须要从0开始顺序访问。
接口上的数组(top函数参数)
HLS会默认会将接口上数组认为是内存。HLS有两种假设的实现方式
- off-chip的内存:
- 标准的block RAM,延迟只有1clock: 数据会在地址给定后一个周期准备好。
所以需要:
- 指定是RAM还是FIFO的接口
- 指定RAM是单端口还是双端口的RAM,预编译选项中的
storage_type
,语法是#pragma HLS interface - 指定RAM延时,
latency
- ARRAY_PARTITION, ARRAY_RESHAPE 两个优化输入的命令。语法是#pragma HLS array_partition, 相当于用寄存器代替RAM,或者用更小的RAM来代替大的RAM。
默认情况下:
- 默认是单端口的RAM。
- 如果initiation interval or latency可以被减少,则使用双端口的RAM
数组初始化
- 建议使用
static
关键字来建立数组,保证HLS将其变成内存。 - 确保初始化大内存不会造成操作开销。不用static会在初始化时候,写入这些值,有一定时间开销。
实例化一个ROM
- 建议使用
const
来做一个只读的ROM,如果不加,也没有问题。综合过程自己会进行优化。
数据类型
C/C++ 类型
略
Arbitrary Precision (AP) Data Types 任意精度数据类型
- C语言可以这样写
#include "types.h"
typedef int6 dinA_t;
typedef int12 dinB_t;
typedef int22 dinC_t;
typedef int33 dinD_t;
typedef int18 dout1_t;
typedef uint13 dout2_t;
typedef int22 dout3_t;
typedef int6 dout4_t;
- C++模版这样写,还有一个好处是可以定义超大的数
#include "ap_int.h"
void foo_top (…) {
ap_int<9> var1; // 9-bit
ap_uint<10> var2; // 10-bit unsigned
定点数 Arbitrary Precision Fixed-Point Data Types
#include <ap_fixed.h>
...
ap_fixed<18,6,AP_RND > my_type;
...
ap_fixed<2, 0> a = -0.5; // a can be -0.5,
ap_ufixed<1, 0> x = 0.5; // 1-bit representation. x can be 0 or 0.5
ap_ufixed<1, -1> y = 0.25; // 1-bit representation. y can be 0 or 0.25
const ap_fixed<1, -7> z = 1.0/256; // 1-bit representation for z = 2^-8
四个泛型参数分别为,见Fixed-Point Identifier Summary:
- 总长度
- 整数位数(可以为负数,见例子)
- 量化模式
- 溢出饱和位数量
复合数据类型
struct
- 默认成员是分解的。结构体的数组实现为多个数组,结构体的每个成员都有一个单独的数组。
- 利用pragma指令控制是否分解。
enum
略
unions
- HLS综合并不保证使用相同的内存或者寄存器
类型限定符
volatile
:- 综合不会进行优化
- Arbitrary precision types do not support the volatile qualifier(计算时候)
static
:- RTL等价是寄存器、触发器和内存。
- config_rtl 需要配置,不然默认复位不会初始化。
const
:- 常量或者ROM
全局变量
自由使用,但是只存在该ip核内部。
指针
可以综合,但尽量避免使用。尤其是以下情况:
- 同一个函数中,一个指针被读写多次
- 指针类型转换仅限C/C++标准类型
接口处使用指针(top函数的参数中)
- 基本指针:
- 指针可以合成为一个简单的线接口或使用握手的接口协议。
- 要合成一个FIFO接口,指针必须是只读或只写的。
- 指针运算:
- 不能实现,不能实现无序访问。
- 需要变成数组,用RAM实现。
- 流数据
- C++ 编译器可能会优化指针的访问,所以需要加volatile。
#include "pointer_stream_good.h"
void pointer_stream_good ( volatile dout_t *d_o, volatile din_t *d_i) {
din_t acc = 0;
acc += *d_i;
acc += *(d_i+1);
*d_o = acc;
acc += *(d_i+2);
acc += *(d_i+3);
*(d_o+1) = acc;
}
Vector类型
HLS的Vector类型是为了SIMD操作
- single-instruction multiple-data (SIMD): 单指令,多数据。
- Vitis HLS提供的一个模版类型
hls::vector<T, N>
: 一个有N个T类型的元素,T必须重载了数学运算。 - 最佳性能是在T的位宽和N的值均为2的幂时。
- 在
hls::vector
上的运算操作都会被并行化,所以矩阵运算一般使用这个。
#include <hls_vector.h>
hls::vector<T,N> aVec;
Vector 内存布局
- Vctor储存是对齐到2的n次方的。所以性能最好的时候是大小和位宽都为2的幂的时候。
- 实现方式如下:
constexpr size_t gp2(size_t N)
{
return (N > 0 && N % 2 == 0) ? 2 * gp2(N / 2) : 1;
}
template<typename T, size_t N> class alignas(gp2(sizeof(T) * N)) vector
{
std::array<T, N> data;
};
C++类和模版
- 类是完全支持的。
- 不建议在类中使用全局变量,会阻碍优化
- 模版是支持的。但不能作为顶层函数。
断言
- 断言可以用在综合中,提供范围的信息。如循环上限等。(不像tripcount 只能用于分析)
高性能HLS
- 是最大程度地减少对顶层函数参数的访问。
- 在阵列中设置默认值会花费时钟周期和性能。
- 多次读取和重新读取数据会消耗时钟周期和性能。
- 以任意或随机访问方式访问数据要求将数据存储在本地数组中,浪费资源。
确保数据的连续流和数据重用
- 将数据从CPU或系统内存传输到FPGA,则通常会以流传输方式进行传输。从FPGA传输回系统的数据也应以这种方式执行。
hls::stream
, 顺序访问,表现为一个无限深度的FIFO。- 在CPU体系结构中,通常避免有条件或分支操作。当程序需要分支时,它将丢失存储在CPU提取管线中的所有指令。在FPGA体系结构中,每个条件分支的硬件中已经存在一条单独的路径,并且不会与流水线任务内部的分支相关的性能下降。这只是选择要使用哪个分支的一种情况。
- HLS Pragmas的使用。