4. 流水线与并行计算基础:流水线设计原则、数据并行与任务并行、乒乓操作与双缓冲、HLS中的pipeline与unroll指令

各位同学,欢迎来到第四章。这一章我们聊聊加速的「灵魂」——流水线与并行计算。

说实话,很多做嵌入式开发的朋友,一听到「并行」就觉得那是CPU/GPU的事。但在我做CT影像重建加速的这些年里,我越来越觉得:不懂流水线和并行,你根本没法把FPGA的潜力榨干。尤其是CT重建这种计算密集型任务,数据量大、算法复杂,单靠顺序执行,那速度慢得让人抓狂。

好,我们直接进入正题。

4.1 流水线设计原则

流水线这个概念,其实不复杂。你想想看,洗衣服的时候,你不会等洗完一桶再洗下一桶吧?你肯定是第一桶在脱水,第二桶已经开始洗了。这就是流水线。

在硬件里,流水线就是把一个大的计算任务,拆成几个小步骤。每个步骤由专门的硬件模块完成。数据像流水一样,从一个模块流到下一个模块。

核心原则有三条:

  • 任务分解要均衡:每个流水线阶段的处理时间要差不多。如果某个阶段特别慢,那整个流水线都得等它。这就是所谓的「短板效应」。我在做CT重建的滤波反投影算法时,就遇到过这种情况——反投影那一步计算量太大,导致前面几级流水线经常空转。后来我把反投影拆成了两个子阶段,才把吞吐量提上去。
  • 数据依赖要消除:流水线最怕的就是「等数据」。如果后一级必须等前一级算完才能开始,那流水线就退化成顺序执行了。所以,尽量让每个阶段只依赖上一阶段的输出,不要跨级依赖。
  • 寄存器隔离:每个流水线阶段之间,必须用寄存器(Flip-Flop)把数据锁存住。这样,不同阶段才能同时处理不同的数据。没有寄存器隔离,那就不是流水线,只是一堆组合逻辑。

重要提醒:流水线能提高吞吐量(Throughput),但不会减少单个任务的延迟(Latency)。实际上,因为加了寄存器,延迟反而会稍微增加一点点。但CT重建这种场景,我们更关心的是每秒能处理多少张图像,而不是单张图像多快出来。所以,流水线非常适合。

4.2 数据并行与任务并行

这两个概念,我经常看到有人搞混。其实很简单:

  • 数据并行:把一大块数据拆成很多小块,每个处理单元处理一小块。所有单元做的是同样的操作。
  • 任务并行:把一个大任务拆成很多小任务,每个处理单元做不同的操作。

在CT重建里,数据并行非常常见。比如,你要处理512x512的图像,你可以把它分成4个256x256的块,用4个处理单元同时算。每个单元跑的算法一模一样,只是数据不同。

任务并行呢?我举个例子。CT重建的流程里,有预处理、滤波、反投影这几个步骤。理论上,你可以让三个模块同时工作:一个在做预处理,一个在做滤波,一个在做反投影。但这里有个问题——数据依赖。滤波必须等预处理做完,反投影必须等滤波做完。所以,纯粹的任务并行在CT重建里不太容易实现。更常见的做法是:用流水线把任务串起来,同时用数据并行把每个阶段的计算能力翻倍

我的经验:在实际项目中,我一般先考虑数据并行。因为CT重建的数据量很大,数据并行往往能带来最直接的加速效果。任务并行更多是用在系统级的设计里,比如同时处理多个病人的数据。

4.3 乒乓操作与双缓冲

这两个技术,说白了就是「用空间换时间」。我刚开始做FPGA开发时,总觉得内存够用就行,没必要搞双缓冲。直到有一次,我在做CT数据的实时处理时,发现数据采集和数据处理之间总是互相等待——采集模块在写数据的时候,处理模块只能干等着。嗯,从那以后,我再也不敢小看双缓冲了。

乒乓操作:用两个缓冲区(Buffer A和Buffer B)。当处理模块在读Buffer A的时候,采集模块在写Buffer B。下一轮,两者互换。这样,读写操作可以同时进行,互不干扰。

双缓冲:其实和乒乓操作是一个意思。只不过在HLS(High-Level Synthesis)里,我们更常用「双缓冲」这个说法。HLS工具会帮你生成双缓冲的硬件结构,你只需要在代码里用 #pragma HLS dataflow 或者手动管理两个数组就行。

我给你们看个简单的伪代码示例:

// 双缓冲示例
#define BUFFER_SIZE 1024

int buffer_A[BUFFER_SIZE];
int buffer_B[BUFFER_SIZE];
int ping = 0; // 0表示用A,1表示用B

while (1) {
    if (ping == 0) {
        // 写入buffer_A
        write_data(buffer_A);
        // 同时处理buffer_B中的数据
        process_data(buffer_B);
    } else {
        // 写入buffer_B
        write_data(buffer_B);
        // 同时处理buffer_A中的数据
        process_data(buffer_A);
    }
    ping = !ping; // 切换
}

注意:双缓冲虽然好,但会占用双倍的内存资源。在资源紧张的FPGA上,你需要权衡一下。我一般只在数据吞吐量成为瓶颈的时候才用双缓冲。如果计算本身是瓶颈,那优先考虑流水线和数据并行。

4.4 HLS中的pipeline与unroll指令

好,终于到了HLS的部分。Vivado HLS(现在叫Vitis HLS)里,有两个最常用的优化指令:pipelineunroll。这两个指令,说白了就是让你用C/C++代码,告诉HLS工具:「我想在这里搞流水线」或者「我想在这里展开循环」。HLS工具会根据你的指令,生成对应的硬件电路。

4.4.1 pipeline指令

pipeline 指令用于函数或循环内部。它告诉HLS工具:把函数或循环里的操作,拆成流水线阶段。这样,下一个数据可以在上一个数据还没处理完的时候,就开始进入流水线。

举个例子:

void example(int A[100], int B[100], int C[100]) {
    for (int i = 0; i < 100; i++) {
        #pragma HLS pipeline II=1
        C[i] = A[i] + B[i];
    }
}

这里的 II=1 表示 Initiation Interval(启动间隔)为1。意思是:每个时钟周期,都能启动一次新的循环迭代。如果没有 pipeline 指令,这个循环可能需要100个时钟周期才能完成。加上 pipeline II=1 之后,只需要大约100 + 流水线深度 个时钟周期。吞吐量大大提升。

关键点:II值越小,吞吐量越高。但II值受限于数据依赖和资源。如果循环体里有数据依赖(比如当前迭代依赖上一次迭代的结果),那II可能无法达到1。我遇到过很多次这种情况——明明写了II=1,但综合报告里显示II=2或3。这时候,你需要检查一下代码里的数据依赖,或者考虑重新设计算法。

4.4.2 unroll指令

unroll 指令用于循环。它告诉HLS工具:把循环展开,生成多个并行的硬件单元。这样,多个循环迭代可以同时执行。

举个例子:

void example(int A[100], int B[100], int C[100]) {
    #pragma HLS unroll factor=4
    for (int i = 0; i < 100; i++) {
        C[i] = A[i] + B[i];
    }
}

这里的 factor=4 表示:把循环展开4倍。也就是说,HLS会生成4个加法器,每个时钟周期同时处理4个数据。这样,原来需要100个时钟周期的循环,现在只需要25个时钟周期(加上一些开销)。

unroll和pipeline的区别

  • unroll 是空间上的并行——用更多的硬件资源,换取更快的速度。
  • pipeline 是时间上的并行——用流水线的方式,让硬件单元一直忙个不停。

在实际项目中,我经常把两者结合起来用。比如,先对循环做 unroll,生成多个并行路径;然后在每个路径内部,再用 pipeline 提高吞吐量。但要注意资源消耗——unroll会线性增加资源使用量,FPGA资源有限,别一下子展开太多。

避坑指南:我曾经在一个CT重建的滤波模块里,把循环展开了16倍。结果综合报告显示,DSP资源用了80%,BRAM也快满了。虽然速度确实快了,但整个设计变得非常拥挤,布线都成问题。后来我改成展开8倍,配合pipeline,效果反而更好。所以,不要盲目追求展开倍数,要综合考虑资源和时序

4.5 小结

这一章的内容,其实就围绕一个核心思想:让硬件一直忙,别闲着

  • 流水线让不同阶段同时工作。
  • 数据并行让多个单元同时处理不同数据。
  • 乒乓操作让读写同时进行。
  • HLS的pipeline和unroll指令,让你用C代码就能控制这些并行策略。

下一章,我们会把这些技术应用到CT重建的具体算法里。到时候你会发现,这些基础概念,才是真正加速的关键。

好,今天就到这里。有问题随时问我。