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代码风格与优化决策树 C/C++代码 可综合子集 数据类型选择 静态数组、定长循环 禁止:malloc、递归、STL ap_int:整数、精确位宽 ap_fixed:定点小数 循环与数组优化 循环优化 数组优化 PIPELINE:流水线 UNROLL:展开 ARRAY_PARTITION:分割 ARRAY_RESHAPE:重塑

好了,这一章的内容就这些。记住,代码风格和数据类型是HLS优化的第一步,也是最重要的一步。后面几章我们会深入具体的优化技巧,但根基打不好,后面全是白搭。

最后说一句:别怕试错。HLS的编译速度比传统RTL快得多,多跑几次综合,看看报告里的资源占用和II(Initiation Interval),慢慢就有感觉了。

可可精品资料库