第四章:行静态脉动阵列设计

各位同学,今天我们来聊聊脉动阵列里一个非常经典的数据流——行静态(Row-Stationary)。说实话,我在做第一个卷积加速器项目时,用的就是这种架构。当时团队里争论了很久,到底选哪种数据流,最后还是行静态胜出。为什么?因为它对数据复用的理解,最贴近卷积的本质。

4.1 行静态数据流:核心思想

行静态,顾名思义,就是让卷积核的每一行,在计算过程中保持不动。你想想看,卷积操作本质上是什么?是卷积核在输入特征图上滑动,每次做一次点乘。如果我们把卷积核的某一行固定在一个PE里,让输入数据流过来,那就能最大程度地复用权重。

我个人习惯把这种数据流叫做“权重驻留”模式。说白了,就是权重不动,数据动。这和之前讲过的输出静态、权重静态都不一样。输出静态是累加器不动,权重静态是权重不动但数据广播。行静态呢?它让每个PE负责卷积核的一行,然后输入数据按行滑动。

核心要点:行静态数据流中,每个PE内部存储卷积核的一行权重。输入特征图的数据以行为单位,在PE阵列中流水传递。每个PE完成一行权重的乘累加后,将部分和传递给下一行PE。

我在项目中遇到过一个问题:如果卷积核尺寸是3x3,那我们就需要9个PE组成一个3x3的阵列。每个PE存一行权重,也就是3个权重值。这样,输入数据每来一行,所有PE同时计算,效率非常高。

4.2 行静态脉动阵列的PE单元设计

PE单元是脉动阵列的心脏。行静态的PE,内部结构其实不复杂,但有几个关键点要注意。

一个典型的行静态PE包含:

  • 权重寄存器组:存储卷积核的一行权重,比如3个权重值
  • 输入数据寄存器:暂存当前输入的像素值
  • 乘累加器(MAC):做乘法和累加
  • 部分和寄存器:存储中间计算结果
  • 数据转发逻辑:将输入数据传递给下一个PE

嗯,这里要注意:权重寄存器组是静态的,也就是说,在整个计算过程中,权重值不会改变。只有当我们切换到下一个卷积核时,才会更新这些寄存器。

// 行静态PE的简化Verilog描述
module row_stationary_pe #(
    parameter WEIGHT_WIDTH = 8,
    parameter DATA_WIDTH = 8,
    parameter ACCUM_WIDTH = 16
)(
    input clk, rst_n,
    input [DATA_WIDTH-1:0] data_in,      // 输入数据
    input [WEIGHT_WIDTH-1:0] weight_in,  // 权重输入(仅加载时使用)
    input [ACCUM_WIDTH-1:0] psum_in,     // 来自上一行的部分和
    output [DATA_WIDTH-1:0] data_out,    // 传递给下一PE的数据
    output [ACCUM_WIDTH-1:0] psum_out,   // 累加后的部分和
    input load_weights,                  // 权重加载使能
    input compute_en                     // 计算使能
);

    reg [WEIGHT_WIDTH-1:0] weight_reg;   // 权重寄存器
    reg [DATA_WIDTH-1:0] data_reg;       // 数据寄存器
    reg [ACCUM_WIDTH-1:0] accum_reg;     // 累加寄存器

    // 权重加载:只在load_weights有效时更新
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            weight_reg <= 0;
        else if (load_weights)
            weight_reg <= weight_in;
    end

    // 数据传递:每个时钟周期将数据向右传递
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            data_reg <= 0;
        else
            data_reg <= data_in;
    end

    // 乘累加:计算使能时执行
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            accum_reg <= 0;
        else if (compute_en)
            accum_reg <= psum_in + data_reg * weight_reg;
    end

    assign data_out = data_reg;
    assign psum_out = accum_reg;

endmodule

这段代码看起来简单,但实际项目中坑不少。我曾经因为忘记处理部分和的清零逻辑,导致整个芯片的卷积结果全是错的。排查了整整两天才发现,原来累加器在计算完一个输出像素后,没有及时复位。

避坑指南:行静态PE的累加器复位时机非常关键。每个输出像素计算完成后,必须清零。否则上一个结果会污染下一个结果。我建议用单独的clear信号,而不是依赖全局复位。

4.3 数据调度与缓冲策略

数据调度,说白了就是决定什么时候把什么数据送到哪个PE。行静态的数据流,核心是“行流水”。

我们来看一个具体的例子。假设输入特征图是5x5,卷积核是3x3,步长为1。那么:

  1. 第一拍:输入数据的第一行进入第一行PE。每个PE存了卷积核对应行的权重。
  2. 第二拍:第一行数据向右传递到第二行PE,同时第二行输入数据进入第一行PE。
  3. 第三拍:第一行数据到达第三行PE,第二行数据到达第二行PE,第三行数据进入第一行PE。
  4. 此时,三个PE都有了各自需要的数据,开始计算第一个输出像素。

你想想看,这种流水方式的好处是什么?数据复用率极高。每个输入像素会被多个PE重复使用,减少了片外带宽的压力。

缓冲策略方面,我建议采用双缓冲(Double Buffering)。为什么?因为权重加载和计算可以重叠。当一组权重在计算时,下一组权重已经在后台加载了。

个人经验:双缓冲的深度不一定要很大。对于3x3的卷积核,每个PE只需要3个权重值,用两个寄存器组就够了。一个用于当前计算,一个用于预加载下一组权重。这样切换卷积核时零延迟。

4.4 性能模型分析

性能模型,就是用来估算你的设计能跑多快、效率有多高。行静态脉动阵列的性能模型,主要看三个指标:吞吐率、利用率和延迟。

先看吞吐率。假设PE阵列是R行C列,每个PE每个时钟周期完成一次乘累加。那么理论峰值吞吐率就是R x C x F,其中F是时钟频率。但实际中,由于数据加载和边界效应,实际吞吐率会低一些。

利用率呢?行静态的利用率通常很高,因为数据流是连续的。但有一个特殊情况:当卷积核尺寸和阵列尺寸不匹配时,会有一些PE闲置。比如你的阵列是4x4,但卷积核是3x3,那就会有一行一列的PE用不上。

参数 公式 说明
理论吞吐率 R × C × F R行C列,频率F
实际吞吐率 (R × C × F) × η η为利用率
计算延迟 (H × W × C_in) / (R × C) H高W宽C_in输入通道
带宽需求 2 × 数据位宽 × 频率 输入和权重各一份

我记得有一次,我们设计了一个16x16的脉动阵列,理论吞吐率很高,但实际跑起来只有60%的利用率。后来一查,问题出在数据加载上。输入数据从片外DDR读进来,带宽不够,导致PE经常空等。这就是典型的“存储墙”问题。

所以,性能模型不能只看PE的计算能力,还要考虑数据供给能力。我建议你在设计初期,就用这个公式估算一下:

计算时间 = 数据加载时间 + 计算时间 + 数据写出时间

如果数据加载时间远大于计算时间,那你的PE阵列就是在“饿肚子”。这时候要么加大带宽,要么优化数据复用策略。

总结一下:行静态脉动阵列,核心是让权重驻留在PE中,数据以行流水的方式传递。它的优势是权重复用率高、控制逻辑简单。缺点是阵列尺寸受限于卷积核尺寸,灵活性稍差。但如果你做的是固定尺寸的卷积加速,行静态是个好选择。

好了,这一章的内容就到这里。下一章我们会讲另一种数据流——输出静态,看看它和行静态有什么不同,各自适合什么场景。

行静态脉动阵列知识体系 行静态脉动阵列 行静态数据流 权重驻留PE 数据行流水传递 部分和纵向累加 PE单元设计 权重寄存器组 乘累加器(MAC) 数据转发逻辑 数据调度与缓冲 行流水调度 双缓冲策略 权重预加载 性能模型分析 吞吐率估算 利用率分析 带宽需求计算 核心优势:权重复用率高、控制逻辑简单 适用场景:固定尺寸卷积加速

可可精品资料库