4. 算法到硬件的映射思维

在软件世界里,我们习惯把一段逻辑封装成函数,通过调用栈、参数传递和返回值来组织程序。而在 HLS 中,函数不再只是“被调用的代码块”,它会被综合成一块具有明确端口、时序和资源占用的硬件模块。理解这种映射关系,是写出高性能 HLS 代码的前提。

本节围绕四个核心问题展开:软件函数如何变成硬件模块、流水线如何改变执行方式、数据流与控制器如何自动生成、以及面积与速度之间如何权衡。

一、从函数到模块:接口与资源的映射

一个 C/C++ 函数被 HLS 综合时,编译器会做三件事:把参数映射为端口,把局部变量映射为寄存器或存储器,把运算映射为功能单元。以最简单的加法函数为例:

int add(int a, int b) {
    return a + b;
}

综合后,a 和 b 成为输入端口,返回值成为输出端口,加法器成为一个组合逻辑单元。如果函数带有数组参数,数组通常会被映射为 BRAM 或 FIFO 接口;如果参数是指针,则可能映射为 AXI 主接口或从接口。这就是“接口综合”的基本逻辑。

更关键的是,函数内部的循环和条件分支会被展开或保留为状态机。一个没有循环的函数通常综合成纯组合逻辑或单周期模块;而带有循环的函数,循环体会被综合成状态机中的多个状态,或者被流水线化。因此,写 HLS 代码时,不能只考虑“算法对不对”,还要考虑“这段代码会变成什么样的硬件”。

二、流水线:让硬件“同时做多件事”

软件中的循环是顺序执行的:第 1 次迭代做完,再做第 2 次。但在硬件中,我们可以让不同迭代的不同阶段同时进行,这就是流水线(Pipeline)。

以一个乘加循环为例:

for (int i = 0; i < N; i++) {
    acc += x[i] * y[i];
}

如果不加流水线,每次迭代需要完成“取数—乘法—加法—写回”四个步骤,假设每步 1 个时钟周期,则 N 次迭代需要约 4N 个周期。如果加入流水线,让第 i 次迭代的加法与第 i+1 次迭代的乘法同时进行,理想情况下每个周期都能完成一次迭代,总周期数接近 N。这就是流水线带来的吞吐率提升。

在 HLS 中,常用 #pragma HLS PIPELINE II=1 来指定流水线,其中 II(Initiation Interval)表示启动间隔,即两次迭代之间相隔的时钟周期数。II=1 表示每个周期启动一次新迭代,是吞吐率最高的目标。但 II 能否达到 1,取决于数据依赖和资源冲突:如果两次迭代访问同一个 BRAM 端口,就可能产生冲突,导致 II 增大。

流水线不减少单次迭代的延迟,而是通过重叠执行提高吞吐率。理解“延迟”与“吞吐率”的区别,是 HLS 优化的关键。

三、数据流与控制器生成

当函数中存在多个可以并行执行的子任务时,HLS 可以生成数据流(Dataflow)结构。数据流的核心思想是:各个子任务通过 FIFO 或乒乓 RAM 传递数据,彼此独立运行,由数据可用性驱动。

例如,一个图像处理函数分为“读入—滤波—输出”三个阶段。如果写成顺序循环,三个阶段依次执行;如果使用 #pragma HLS DATAFLOW,HLS 会为每个阶段生成独立的模块,并通过 FIFO 连接。这样,滤波阶段在处理第 1 帧时,读入阶段已经在准备第 2 帧,输出阶段在写出第 0 帧的结果,整体吞吐率大幅提升。

与此同时,HLS 会自动生成控制器(Controller),负责调度各个模块的启动、等待和握手。控制器本质上是一个有限状态机,它根据数据依赖和接口握手信号决定何时启动哪个模块。对于数据流结构,控制器还需要管理 FIFO 的满/空状态,避免死锁。

软件函数到硬件模块的映射与流水线/数据流结构 软件函数 int process(int *in, int *out) for(i=0;i<N;i++) out[i] = f(in[i]); HLS 综合 硬件模块 端口:in, out, clk, rst 功能单元:乘法器/加法器 控制器:FSM 调度 优化 流水线 / 数据流 取数 运算 写回 II=1,每周期启动一次迭代 FIFO 连接各阶段 面积与速度权衡 面积优先 资源共享,II 增大 吞吐率降低,资源少 平衡点 部分流水线,II=2~4 资源与速度折中 速度优先 全流水线,II=1 资源多,吞吐率高

四、面积与速度的权衡

硬件设计永远绕不开一个矛盾:想要更快,通常要消耗更多资源;想要更省资源,往往要牺牲速度。在 HLS 中,这种权衡体现在多个层面。

第一,循环展开(Unroll)。将循环展开 4 次,可以同时执行 4 次迭代,吞吐率提升,但需要 4 套运算单元,面积增加。第二,流水线深度。II=1 的全流水线需要更多寄存器来保存中间结果,而 II=4 的流水线可以复用部分资源。第三,数组分割(Array Partition)。将一个大数组分割成多个小数组,可以增加并行访问端口,但会消耗更多 BRAM 或寄存器。

实际项目中,我们通常先以满足吞吐率目标为约束,再尽量降低资源占用。例如,目标是在 100 MHz 时钟下处理 1080p 视频流,每帧约 200 万像素,要求 30 帧/秒,则每秒需处理 6000 万像素。若每个周期处理 1 个像素,需要 60 MHz 以上的有效吞吐率,II=1 是必要的。此时再检查资源报告,如果 DSP 或 BRAM 超出芯片容量,就需要考虑降低并行度或优化数据位宽。

不要盲目追求 II=1。如果系统瓶颈在外部存储器带宽,而不是计算单元,那么提高流水线并行度可能毫无收益,反而浪费资源。

五、映射思维的实际应用

在实际编写 HLS 代码时,建议按以下顺序思考:首先,明确函数的接口——哪些是输入,哪些是输出,数据量多大,带宽要求多少;其次,识别循环结构——哪些循环可以流水线,哪些循环之间存在数据依赖;然后,决定数据流结构——是否可以将大循环拆分为多个独立阶段,用 FIFO 连接;最后,根据资源报告调整并行度和流水线策略。

例如,一个 3×3 卷积函数,输入图像 1920×1080,输出同样大小。如果直接写成双重循环,每个像素需要 9 次乘加,总共约 1.8 亿次乘加。若 II=1,需要 9 个乘法器同时工作,资源消耗较大。此时可以考虑行缓冲(Line Buffer)结构:每次读入一行,缓存三行,然后对每一行进行流水线处理。这样既减少了外部存储器访问,又降低了并行度需求,是典型的“以结构换资源”的映射思维。

总之,从软件函数到硬件模块的转换,不是简单的语法翻译,而是对计算结构、数据流动和资源约束的重新思考。掌握流水线、数据流和面积速度权衡,才能真正发挥 HLS 的威力。