3. HLS数据类型与优化:任意精度数据类型、定点数运算、数组分割与映射、循环流水线与展开

好,咱们进入第三章。这一章的内容,说白了就是HLS优化的基本功。你想想看,写C代码谁都会,但要让它在FPGA上跑得又快又省资源,那就得懂点“门道”了。我个人习惯,拿到一个算法,第一件事不是急着写代码,而是先琢磨数据类型和循环结构怎么安排。

3.1 任意精度数据类型:别再浪费你的LUT了

做FPGA设计,最忌讳的就是用C语言的 int 或 float。为什么?因为 int 默认是32位,float 是32位。你一个卷积核里的权重,可能只需要8位精度,你非要用32位去存,那资源消耗直接翻四倍。我在项目中遇到过,一个简单的3x3卷积,就因为数据类型没优化,LUT用掉了80%,后来改成 ap_int<8>,直接降到30%。

HLS提供了 ap_int<W> 和 ap_uint<W> 系列,W就是位宽。比如:

// 8位有符号整数,适合存像素值
ap_int<8> pixel_val;

// 16位无符号整数,适合存累加结果
ap_uint<16> acc_result;

// 千万别这么写
int pixel_val;  // 32位,浪费!

避坑指南:我曾经在某个项目中,用 ap_int<16> 存卷积结果,结果发现溢出导致图像出现条纹。后来一查,卷积输出范围是 [-255*9, 255*9],16位有符号范围是 [-32768, 32767],按理说够用。但中间累加过程会超过这个范围。所以,中间变量和最终变量的位宽要分开考虑。

我的小技巧:先估算数据范围,然后加2位“安全位”。比如像素值0-255,用9位存;累加结果最大255*9=2295,用12位存。多留一点余量,避免溢出。

3.2 定点数运算:浮点数的“平替”方案

浮点数在FPGA上是个“奢侈品”。一个浮点乘法器,面积大、延迟高。所以,做卷积加速,我们几乎都用定点数。说白了,就是用整数来模拟小数。

HLS里用 ap_fixed<W, I>,W是总位宽,I是整数部分位宽。比如 ap_fixed<16, 8>,表示总16位,整数部分8位,小数部分就是8位。

// 定义定点数:总16位,整数部分8位
ap_fixed<16, 8> weight = 1.5;
ap_fixed<16, 8> pixel = 128.0;
ap_fixed<32, 16> result = weight * pixel;  // 注意乘积位宽要扩大

为什么乘积位宽要扩大? 两个16位定点数相乘,结果至少需要32位。不然精度丢失,算出来的卷积结果全是错的。我刚开始做的时候,就犯过这个错,结果图像边缘全是噪点,排查了两天才找到原因。

数据类型 总位宽 整数位宽 小数位宽 适用场景
ap_fixed<8, 4> 8 4 4 权重存储
ap_fixed<16, 8> 16 8 8 像素与权重乘法
ap_fixed<32, 16> 32 16 16 累加结果
注意:定点数的量化模式也很重要。默认是 AP_TRN(截断),如果你需要更高精度,可以用 AP_RND(四舍五入)。但四舍五入会多消耗一点逻辑资源,自己权衡。

3.3 数组分割与映射:让数据“跑”得更快

数组在HLS里默认是BRAM实现的。BRAM读写速度慢,而且一次只能读一个数据。你想想看,卷积核要同时读9个权重,如果存在一个BRAM里,那得串行读9次,性能直接拉胯。

所以,我们要对数组进行分割和映射。说白了,就是把一个大数组拆成多个小数组,或者映射到多个BRAM上,实现并行读写。

HLS提供了三种方式:

  • complete:完全分割,每个元素独立成一个寄存器。适合小数组。
  • block:按块分割,比如一个16元素的数组,分成4块,每块4个元素。
  • cyclic:循环分割,比如第0、4、8、12元素分到第一块,第1、5、9、13分到第二块。
// 原始数组
int kernel[9];

// 完全分割:每个权重独立,可以并行读
#pragma HLS ARRAY_PARTITION variable=kernel complete dim=1

// 或者按块分割:分成3块,每块3个元素
#pragma HLS ARRAY_PARTITION variable=kernel block factor=3 dim=1

我建议:对于卷积核这种小数组,直接用 complete 分割,反正就9个元素,不差那点寄存器。对于特征图这种大数组,用 block 或 cyclic,根据你的数据访问模式来定。

核心原则:数组分割的目的是消除数据访问的瓶颈。如果你的循环里需要同时读N个数据,那数组就要分割成至少N块。

3.4 循环流水线与展开:让硬件“忙”起来

循环是HLS优化的主战场。默认情况下,循环是串行执行的,一个迭代完了才进入下一个。这就像一条流水线上只有一个工人,效率极低。

流水线(PIPELINE):让循环的不同迭代重叠执行。比如,第一个迭代还在做乘法,第二个迭代已经开始读数据了。这样,虽然单个迭代的延迟没变,但整体的吞吐量上去了。

// 不优化的循环
for (int i = 0; i < 9; i++) {
    sum += kernel[i] * pixel[i];
}
// 这个循环需要9个时钟周期

// 加流水线
for (int i = 0; i < 9; i++) {
    #pragma HLS PIPELINE II=1
    sum += kernel[i] * pixel[i];
}
// 理想情况下,每个时钟周期都能输出一个结果,9个元素只需要9+latency个周期

展开(UNROLL):把循环体复制多份,并行执行。比如,把上面的循环完全展开,就是9个乘法器同时工作,一个周期就出结果。

// 完全展开
for (int i = 0; i < 9; i++) {
    #pragma HLS UNROLL
    sum += kernel[i] * pixel[i];
}
// 一个周期完成,但消耗9个乘法器

怎么选? 我个人经验是:如果资源够,用展开,延迟最低;如果资源紧张,用流水线,平衡性能和面积。我在做某个实时视频处理项目时,就是用了流水线+部分展开,把卷积计算从100多个周期压缩到了20个周期以内。

避坑指南:我曾经在一个循环里同时用了PIPELINE和UNROLL,结果综合报错。后来发现,UNROLL后的循环体里如果有数据依赖,是不能加PIPELINE的。比如,sum += ... 这种累加操作,就有数据依赖,不能流水线。解决办法是:先UNROLL,然后手动处理累加(比如用加法树)。

3.5 本章知识体系

下面这张图,是我自己总结的本章知识结构,你可以对照着复习:

HLS数据类型与优化 任意精度数据类型 ap_int / ap_uint 位宽自定义 减少资源消耗 定点数运算 ap_fixed 整数位+小数位 量化模式选择 数组分割与映射 complete/block/cyclic 并行数据访问 BRAM→寄存器 循环优化 PIPELINE UNROLL II=1 优化目标 1. 减少资源消耗 → 用任意精度数据类型 2. 提高计算精度 → 用定点数并合理选择位宽 3. 提高数据吞吐 → 数组分割实现并行访问 4. 提高计算效率 → 循环流水线与展开 最终目标:在资源与性能之间找到最佳平衡点

嗯,这一章的内容就这些。数据类型和循环优化,是HLS设计里最基础也最重要的两个点。你把这些吃透了,后面做卷积加速架构设计,就会顺手很多。

可可精品资料库