HLS卷积加速器吞吐与资源优化 - 第3节
数据类型选择:别让精度成为性能的绊脚石
做HLS加速,第一个要面对的问题就是数据类型。我见过太多人一上来就用float或者int,结果综合出来的电路又大又慢。说白了,FPGA不是CPU,它没有通用的ALU,每个运算单元都是你亲手“画”出来的。
定点数 vs 浮点数:一个老工程师的忠告
我个人习惯,能用定点绝不用浮点。为什么?浮点运算器在FPGA上占的资源太大了。我记得有一次做卷积加速,用float做乘法,一个乘法器就吃掉了几百个LUT。换成ap_fixed<16,8>后,资源直接砍半。
你想想看,卷积核里的权重,真的需要32位浮点精度吗?我实测过,8位整数量化后,精度损失不到1%,但吞吐量能翻好几倍。
核心原则:用最小的位宽,满足你的精度需求。
| 数据类型 | 资源消耗 | 延迟 | 适用场景 |
|---|---|---|---|
| float | 极高 | 高 | 精度要求极高,且资源充裕 |
| ap_fixed<32,16> | 中等 | 中 | 大部分卷积加速场景 |
| ap_fixed<16,8> | 低 | 低 | 量化后的推理加速 |
| ap_int<8> | 极低 | 极低 | 二值化或极低精度网络 |
注意:别滥用ap_fixed的位宽。我曾经有个项目,把位宽从32位降到16位,结果综合出来的电路面积小了40%,频率还提升了20%。
HLS中的整数类型:选对才能跑得快
标准C/C++的int在HLS里默认是32位。但很多时候,你根本用不到这么多位。比如卷积窗口内的累加,用ap_int<16>就足够了。
我建议你养成习惯:
- 能用
char别用int - 能用
ap_uint<8>别用char - 能用定点别用浮点
嗯,这里要注意:ap_int和ap_uint的区别。有符号和无符号,在乘法器实现上差很多。无符号乘法器更小,如果你确定数据不会为负,就用ap_uint。
循环风格:HLS性能优化的核心战场
循环是HLS加速的灵魂。你写的每一个for循环,都可能被综合成一个流水线或者一个状态机。怎么写循环,直接决定了你的加速器能跑多快。
循环展开:用面积换速度
我刚开始做HLS时,以为循环展开就是加个#pragma HLS UNROLL就完事了。后来发现,展开因子选不对,反而会拖慢性能。
举个例子:
// 不好的写法
for (int i = 0; i < 16; i++) {
#pragma HLS UNROLL factor=16
out[i] = in[i] * weight[i];
}
这样写,16个乘法器同时工作,看起来很快。但问题是,你的BRAM带宽够吗?如果in和weight存在同一个BRAM里,16个读端口根本没法同时访问。
我的经验:展开前先算算带宽。一个BRAM最多两个读端口,你展开16倍,至少需要8个BRAM来存数据。否则,综合工具会自动插入等待周期,性能反而下降。
循环流水:让数据流动起来
循环流水是HLS加速的另一个利器。说白了,就是让循环的每次迭代重叠执行。
// 好的流水线写法
for (int i = 0; i < N; i++) {
#pragma HLS PIPELINE II=1
sum += data[i] * coeff[i];
}
这里II=1表示每个时钟周期启动一次新的迭代。但要注意,这要求你的数据依赖不能有冲突。我曾经踩过一个坑:
// 有数据依赖,无法实现II=1
for (int i = 1; i < N; i++) {
#pragma HLS PIPELINE II=1
data[i] = data[i-1] + data[i]; // 依赖前一次结果
}
这种写法,综合工具会告诉你II无法达到1。为什么?因为data[i]的计算需要等data[i-1]算完。说白了,这就是个递归依赖,流水线根本没法重叠。
避坑指南:我曾经花了两天时间调试一个流水线性能问题,最后发现是循环体内有个变量在前后迭代间有依赖。改成局部变量后,II直接从4降到了1。
循环合并:减少控制开销
有时候,多个小循环可以合并成一个。这样能减少状态机的跳转开销。
// 不推荐:两个独立循环
for (int i = 0; i < N; i++) {
a[i] = b[i] * c[i];
}
for (int i = 0; i < N; i++) {
d[i] = a[i] + e[i];
}
// 推荐:合并成一个循环
for (int i = 0; i < N; i++) {
#pragma HLS PIPELINE II=1
a[i] = b[i] * c[i];
d[i] = a[i] + e[i];
}
合并后,不仅减少了循环控制逻辑,还能让数据在流水线内直接传递,不用写回内存再读出来。
指针与数组的使用限制:HLS的“潜规则”
C/C++里用指针很自由,但在HLS里,指针用不好就是灾难。我见过有人用指针实现一个简单的卷积,结果综合出来的电路比用数组慢了10倍。
指针的“坑”:别让综合工具猜你的意图
HLS综合工具对指针的处理很保守。如果你用指针访问数组,工具会假设指针可能指向任何地址,从而生成最复杂的地址计算逻辑。
// 不推荐:指针访问
void conv(int *in, int *out, int *weight) {
for (int i = 0; i < N; i++) {
out[i] = in[i] * weight[i]; // 工具不知道指针是否重叠
}
}
// 推荐:数组访问
void conv(int in[N], int out[N], int weight[N]) {
#pragma HLS ARRAY_PARTITION variable=in complete dim=1
#pragma HLS ARRAY_PARTITION variable=weight complete dim=1
for (int i = 0; i < N; i++) {
#pragma HLS PIPELINE II=1
out[i] = in[i] * weight[i];
}
}
用数组后,你可以用ARRAY_PARTITION指令告诉工具如何分配存储资源。指针就没这个待遇了。
注意:如果非要用指针,记得加restrict关键字。告诉工具这些指针不会指向同一块内存。我有个项目,加了restrict后,性能提升了30%。
数组分割:打破BRAM的带宽瓶颈
卷积加速的核心是数据复用。一个3x3的卷积核,每次需要读9个像素。如果这些像素存在同一个BRAM里,一次只能读两个,那性能就卡死了。
解决方案就是数组分割:
// 将数组分割成多个小BRAM
#pragma HLS ARRAY_PARTITION variable=img_buffer complete dim=2
// 或者按块分割
#pragma HLS ARRAY_PARTITION variable=img_buffer block factor=4 dim=2
我个人习惯用complete分割,直接把数组拆成单个寄存器。这样每个元素都能独立访问,带宽最大。当然,代价是资源消耗大。
数组映射:减少BRAM数量
有时候,多个小数组可以合并成一个。这样能减少BRAM的使用数量。
// 两个小数组
int a[16];
int b[16];
// 映射成一个数组
#pragma HLS ARRAY_MAP variable=a variable=b instance=combined
嗯,这里要注意:映射后,两个数组的访问会共享同一个BRAM的端口。如果它们需要同时读写,反而会降低性能。所以,映射前先想清楚访问模式。
我的建议:先做性能分析,再看资源。别为了省资源把性能搞砸了。我一般先做complete分割,等性能达标了,再慢慢优化资源。
知识体系总览
下面这张图,是我总结的HLS可综合规范的核心逻辑。你可以把它当作一个检查清单:
这张图把本章的三个核心知识点串起来了。你写代码的时候,可以对照着检查:数据类型选对了吗?循环结构优化了吗?数组访问方式合理吗?
说实话,HLS调优没有银弹。每个项目都有自己的瓶颈。但只要你掌握了这些基础规范,至少能保证你的代码是可综合的,而且性能不会太差。
可可精品资料库