Vitis HLS Coding Styles

参考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的使用。