3、C/C++代码风格与HLS优化:可综合的C/C++子集、数据类型选择(ap_int/ap_fixed)、循环优化基础(PIPELINE/UNROLL)、数组优化(ARRAY_PARTITION/RESHAPE)
好,咱们直接进入正题。这一章讲的是HLS的根基——代码风格和优化手段。说白了,你C/C++代码写得怎么样,直接决定了综合出来的硬件是“跑车”还是“老爷车”。我见过太多人,算法仿真跑得飞起,一上板就歇菜,十有八九是代码风格和数据类型没选对。
3.1 可综合的C/C++子集:哪些能用,哪些是坑?
首先得搞清楚一件事:不是所有C++语法都能被HLS工具综合成硬件。它只支持一个子集。嗯,这里要注意,动态内存分配(malloc、new)、虚函数、递归、标准模板库(STL)的大部分容器,这些统统不行。为什么?因为硬件是静态的,没有“堆”这个概念,也没有“运行时多态”。
我个人习惯,写HLS代码时就把自己当成一个“硬件设计师”,而不是“软件工程师”。能用静态数组就别用指针,能用循环就别用递归。
可综合的常用结构:
- 基本数据类型:
int、char、float(但慎用) - 自定义结构体(
struct) - 定长数组(
int arr[1024]) - 循环(
for、while,但需边界确定) - 条件语句(
if、switch) - 函数(可内联)
避坑指南:我曾经在一个项目中用了std::vector来动态调整数组大小,仿真完全没问题。结果综合时报错,说“不支持动态内存”。最后只能改成静态数组,重写了一大片代码。所以,从一开始就用静态结构,省得后面返工。
3.2 数据类型选择:ap_int vs ap_fixed,别小看这步
数据类型的选择,直接决定了你的资源消耗和性能。你想想看,一个32位整数和一个8位整数,综合出来的乘法器面积能差好几倍。HLS提供了ap_int(任意精度整数)和ap_fixed(任意精度定点数),就是为了让我们精确控制位宽。
什么时候用ap_int?
- 数据是整数,没有小数部分。
- 需要精确的位宽控制,比如正好12位、24位。
- 做地址计算、计数器、状态机等。
什么时候用ap_fixed?
- 数据有小数部分,比如图像处理中的像素值、滤波器的系数。
- 对精度要求不那么苛刻,可以接受量化误差。
- 想省资源——定点数比浮点数省太多。
我个人习惯,能用ap_int绝不用int,能用ap_fixed绝不用float。举个例子,一个图像处理算法,像素值范围0~255,用ap_uint<8>就够了,你非要用int,那就是浪费了24位的资源。
| 数据类型 | 位宽 | 适用场景 | 资源消耗 |
|---|---|---|---|
int |
32 | 通用整数 | 高 |
ap_uint<8> |
8 | 像素值、小范围计数 | 低 |
ap_fixed<16,8> |
16 | 定点小数、滤波器系数 | 中 |
float |
32 | 高精度浮点 | 极高 |
小技巧:在HLS中,ap_fixed的模板参数是<总位宽, 整数位宽>。比如ap_fixed<16,8>,表示总16位,其中8位是整数部分,剩下8位是小数部分。我刚开始用的时候老搞反,后来就记住了:先写总位数,再写整数位数。
3.3 循环优化基础:PIPELINE和UNROLL
循环是HLS优化的主战场。你想想看,一个算法里90%的时间都花在循环上。所以,优化循环就是优化性能。两个最常用的指令:PIPELINE和UNROLL。
PIPELINE(流水线)
说白了,就是让循环的多次迭代重叠执行。比如一个循环要执行10次,每次需要3个时钟周期。不流水线的话,总共需要30个时钟周期。加上PIPELINE后,可能只需要12个时钟周期。为什么?因为第一次迭代还没结束,第二次迭代就开始了。
// 不流水线
for (int i = 0; i < N; i++) {
a[i] = b[i] + c[i]; // 每次迭代需要3个周期
}
// 加PIPELINE
#pragma HLS PIPELINE
for (int i = 0; i < N; i++) {
a[i] = b[i] + c[i]; // 现在每次迭代只需要1个周期(理想情况)
}
UNROLL(展开)
展开就是把循环体复制多份,并行执行。比如一个循环执行4次,每次做一次加法。展开后,可以同时做4次加法。代价是资源消耗增加——你复制了多少份,就多用多少资源。
// 不展开
for (int i = 0; i < 4; i++) {
a[i] = b[i] + c[i]; // 一个加法器,串行执行4次
}
// 完全展开
#pragma HLS UNROLL
for (int i = 0; i < 4; i++) {
a[i] = b[i] + c[i]; // 四个加法器,并行执行
}
我的经验:PIPELINE和UNROLL不是万能的。PIPELINE受限于数据依赖——如果下一次迭代需要用到上一次的结果,那就没法流水线。UNROLL受限于资源——你展开太多,FPGA放不下。我一般先试PIPELINE,如果性能还不够,再考虑UNROLL。
3.4 数组优化:ARRAY_PARTITION和RESHAPE
数组在HLS中默认被综合成BRAM(块RAM)。BRAM有个特点:每个时钟周期只能读写一个端口。如果你的算法需要同时访问多个数组元素,那就卡住了。这时候就需要ARRAY_PARTITION和RESHAPE。
ARRAY_PARTITION(数组分割)
把一个大数组拆成多个小数组,每个小数组独立映射到不同的BRAM或寄存器。这样就能同时访问多个元素了。
- block:按块分割。比如一个16元素的数组,分成4块,每块4个元素。
- cyclic:循环分割。比如一个16元素的数组,分成4块,第0、4、8、12号元素分到第0块。
- complete:完全分割。每个元素独立成一个寄存器。资源消耗最大,但访问速度最快。
// 原始数组
int arr[16];
// 按块分割成4个数组,每个数组4个元素
#pragma HLS ARRAY_PARTITION variable=arr block factor=4
// 完全分割,每个元素独立
#pragma HLS ARRAY_PARTITION variable=arr complete
RESHAPE(数组重塑)
这个和PARTITION有点像,但更省资源。RESHAPE会把多个数组元素合并到一个更宽的BRAM中。比如你有4个8位的数组,RESHAPE后变成一个32位的BRAM。这样你一次就能读出4个元素。
// 原始数组
int arr[16];
// 重塑:将4个元素合并到一个更宽的BRAM中
#pragma HLS ARRAY_RESHAPE variable=arr block factor=4
避坑指南:我曾经在一个项目中,为了追求极致性能,把所有数组都做了complete分割。结果资源爆了,综合不过。后来改成block factor=2,性能只降了一点点,但资源省了一大半。所以,别一上来就搞complete,先试试block或cyclic。
3.5 知识体系总览
下面这张图,是我自己总结的本章知识体系。你可以把它当成一个“决策树”,遇到问题时顺着走一遍,基本不会跑偏。
好了,这一章的内容就这些。记住,代码风格和数据类型是HLS优化的第一步,也是最重要的一步。后面几章我们会深入具体的优化技巧,但根基打不好,后面全是白搭。
最后说一句:别怕试错。HLS的编译速度比传统RTL快得多,多跑几次综合,看看报告里的资源占用和II(Initiation Interval),慢慢就有感觉了。
可可精品资料库