4. MAC阵列设计(一):乘累加单元(MAC)的RTL实现、流水线设计、输入重定时

各位同学,今天咱们来啃一块硬骨头——MAC单元。说它硬,是因为整个NPU的计算能力,说白了就靠它撑着。你想想看,卷积、全连接、矩阵乘法,这些深度学习里的核心操作,拆到最底层,全是一堆乘法和加法在反复折腾。

我个人习惯把MAC单元比作NPU的「心脏」。心脏跳得快不快,直接决定了整个芯片的性能。所以这一章,咱们就把这颗心脏的RTL实现、流水线怎么搭、输入重定时怎么玩,彻底讲透。

4.1 从数学到硬件:MAC单元的本质

先看数学公式,很简单:

Y = A × B + C

一个乘法,一个加法,完事。但到了硬件里,事情就没这么简单了。我在项目中遇到过最典型的问题:新手直接写一个组合逻辑的乘法器,然后发现时序根本跑不通。为什么?因为乘法器在硅片上是真真切切要花时间的。

咱们来看一个最基础的MAC RTL实现:

module mac_unit #(
    parameter DATA_WIDTH = 8
)(
    input  logic                     clk,
    input  logic                     rst_n,
    input  logic                     valid_in,
    input  logic [DATA_WIDTH-1:0]    a,
    input  logic [DATA_WIDTH-1:0]    b,
    input  logic [2*DATA_WIDTH-1:0]  c,
    output logic [2*DATA_WIDTH-1:0]  result,
    output logic                     valid_out
);

    logic [2*DATA_WIDTH-1:0] mul_result;
    logic [2*DATA_WIDTH-1:0] acc_result;

    // 乘法
    assign mul_result = a * b;

    // 累加
    assign acc_result = mul_result + c;

    // 输出寄存器
    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            result    <= '0;
            valid_out <= 1'b0;
        end else begin
            result    <= acc_result;
            valid_out <= valid_in;
        end
    end

endmodule

嗯,这里要注意:上面的代码虽然功能正确,但实际项目中几乎没人这么用。为什么?因为乘法器和加法器串在一起,组合逻辑路径太长了。你想想看,8位乘法还好,要是32位、64位呢?那延迟直接爆炸。

⚠️ 避坑指南: 我曾经在一个项目中,直接把16位MAC写成了纯组合逻辑。综合后一看,关键路径延迟3.2ns,目标频率根本跑不到。后来不得不推倒重来,加了流水线才搞定。所以,别偷懒,该流水线就流水线。

4.2 流水线设计:把长路径切短

流水线的核心思想很简单:把一个大任务拆成几个小任务,每个小任务用一个时钟周期完成。这样虽然单个数据的延迟增加了,但整体吞吐率上去了。

对于MAC单元,我建议至少拆成两级流水线:

  • 第一级:乘法运算 + 结果寄存器
  • 第二级:加法运算 + 结果寄存器

来看改进后的代码:

module mac_pipeline #(
    parameter DATA_WIDTH = 8
)(
    input  logic                     clk,
    input  logic                     rst_n,
    input  logic                     valid_in,
    input  logic [DATA_WIDTH-1:0]    a,
    input  logic [DATA_WIDTH-1:0]    b,
    input  logic [2*DATA_WIDTH-1:0]  c,
    output logic [2*DATA_WIDTH-1:0]  result,
    output logic                     valid_out
);

    // 流水线寄存器 - 第一级
    logic [2*DATA_WIDTH-1:0] mul_reg;
    logic [2*DATA_WIDTH-1:0] c_reg;
    logic                     valid_stage1;

    // 流水线寄存器 - 第二级
    logic [2*DATA_WIDTH-1:0] acc_reg;
    logic                     valid_stage2;

    // 第一级:乘法
    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            mul_reg       <= '0;
            c_reg         <= '0;
            valid_stage1  <= 1'b0;
        end else begin
            mul_reg       <= a * b;
            c_reg         <= c;
            valid_stage1  <= valid_in;
        end
    end

    // 第二级:加法
    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            acc_reg       <= '0;
            valid_stage2  <= 1'b0;
        end else begin
            acc_reg       <= mul_reg + c_reg;
            valid_stage2  <= valid_stage1;
        end
    end

    // 输出
    assign result    = acc_reg;
    assign valid_out = valid_stage2;

endmodule
💡 关键点: 注意看,我把c也打了一拍。为什么?因为c和mul_result需要对齐。如果c不经过流水线,直接送到第二级,那c和mul_reg就不是同一拍的数据了。这就是所谓的「数据对齐」问题,新手很容易忽略。

4.3 输入重定时:让时序再飞一会儿

好了,流水线加上了,时序应该没问题了吧?不一定。你想想看,如果输入数据a和b是从很远的地方送过来的,走线延迟本身就很大。这时候,即使MAC内部时序OK,输入路径也可能成为瓶颈。

怎么办?输入重定时(Input Retiming)。说白了,就是在输入端再加一级寄存器,把输入数据先锁存一拍。

我个人习惯的做法是:

  1. 先看综合报告,找到关键路径
  2. 如果关键路径在输入端口到乘法器之间,就在输入加寄存器
  3. 如果关键路径在乘法器到加法器之间,就在中间加流水线
  4. 如果关键路径在加法器到输出之间,就在输出加寄存器

来看加入输入重定时后的完整代码:

module mac_retimed #(
    parameter DATA_WIDTH = 8
)(
    input  logic                     clk,
    input  logic                     rst_n,
    input  logic                     valid_in,
    input  logic [DATA_WIDTH-1:0]    a,
    input  logic [DATA_WIDTH-1:0]    b,
    input  logic [2*DATA_WIDTH-1:0]  c,
    output logic [2*DATA_WIDTH-1:0]  result,
    output logic                     valid_out
);

    // 输入重定时寄存器
    logic [DATA_WIDTH-1:0]   a_reg, b_reg;
    logic [2*DATA_WIDTH-1:0] c_reg_in;
    logic                     valid_in_reg;

    // 流水线寄存器 - 第一级
    logic [2*DATA_WIDTH-1:0] mul_reg;
    logic [2*DATA_WIDTH-1:0] c_reg_pipe;
    logic                     valid_stage1;

    // 流水线寄存器 - 第二级
    logic [2*DATA_WIDTH-1:0] acc_reg;
    logic                     valid_stage2;

    // 输入重定时
    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            a_reg        <= '0;
            b_reg        <= '0;
            c_reg_in     <= '0;
            valid_in_reg <= 1'b0;
        end else begin
            a_reg        <= a;
            b_reg        <= b;
            c_reg_in     <= c;
            valid_in_reg <= valid_in;
        end
    end

    // 第一级:乘法
    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            mul_reg      <= '0;
            c_reg_pipe   <= '0;
            valid_stage1 <= 1'b0;
        end else begin
            mul_reg      <= a_reg * b_reg;
            c_reg_pipe   <= c_reg_in;
            valid_stage1 <= valid_in_reg;
        end
    end

    // 第二级:加法
    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            acc_reg      <= '0;
            valid_stage2 <= 1'b0;
        end else begin
            acc_reg      <= mul_reg + c_reg_pipe;
            valid_stage2 <= valid_stage1;
        end
    end

    // 输出
    assign result    = acc_reg;
    assign valid_out = valid_stage2;

endmodule
📌 实战技巧: 输入重定时有个副作用——数据延迟增加了。原来2个时钟周期出结果,现在变成3个。但别担心,在NPU这种数据流驱动的架构里,延迟增加几拍完全不是问题,只要吞吐率不变就行。我做过一个项目,输入重定时加了3级寄存器,时序直接改善了40%。

4.4 流水线深度怎么选?

这个问题没有标准答案。我一般遵循几个原则:

数据位宽 目标频率 建议流水线级数 备注
8位 < 500MHz 1-2级 组合逻辑即可,加1级保险
16位 500MHz - 1GHz 2-3级 乘法器需要拆开
32位 > 1GHz 3-5级 可能需要专用乘法器IP

当然,这只是经验值。具体用几级,还是要看综合后的时序报告。我记得有一次,一个16位MAC我用了3级流水线,结果综合后时序还有余量,我就试着减了一级,发现也能跑。所以,别死板,多试试。

4.5 本章小结

这一章咱们把MAC单元从零开始,一步步搭了起来。核心就三件事:

  • 基础实现:乘法+加法,但别用纯组合逻辑
  • 流水线设计:把长路径切短,提升时序
  • 输入重定时:解决输入路径的时序瓶颈

下一章,咱们要把这些MAC单元拼成阵列,真正开始搭建NPU的计算核心。到时候你会发现,单个MAC的时序做好了,阵列的时序问题会少很多。所以,这一章的基础一定要打牢。

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

📢 课后思考: 如果我要把MAC单元改成支持负数(有符号数)运算,代码需要怎么改?提示:Verilog的signed关键字和$signed()系统函数。

公众号:蓝海资料掘金营,微信deep3321