跳转至

NPU 硬件实现

本节结合 thu-cs-lab/cod-npu 仓库 rtl/sv/ 下的 SystemVerilog 代码,介绍 NPU 的硬件实现:PE、脉动阵列、L0A/L0B/L0C 片上存储,以及 LDTILE/TMUL 两条指令的控制逻辑。

PE 与脉动阵列

PE 具体实现

下面结合代码来讲解一下具体实现,PE 的实现源码就在 rtl/sv/pe.sv 当中,分析各部分功能如何对应到实现上:

首先,PE 模块设置了两个参数,允许设置不同的数据位宽:A_DW 代表输入数据位宽,也就是 A 和 B 矩阵的元素位宽。C_DW 代表输出数据位宽,也就是 C 矩阵的元素位宽。默认配置下,实现的是 int8 x int8 矩阵,结果累加到 int32,因此默认值就是 A_DW=8,C_DW=32。

接着,首先看 A 元素的处理,首先在模块端口上,设置 a_in 输入,获得来自左方 PE 的 A 元素,以及 a_out 输出,在 1 拍的延迟后,把 a_in 的值传给右方的 PE:

// A 元素:从左方 PE(或左边缘)流入,向右传播
input  wire signed [A_DW-1:0] a_in,    // 来自左方 PE(或左边缘)的 A 元素
output wire signed [A_DW-1:0] a_out,   // 传给右方 PE(1 拍延迟)

内部的逻辑也比较简单,就是设置一个寄存器,实现 A 元素从左到右的流动:

// A 元素右传寄存器:实现 A 元素从左到右的流动
reg signed [A_DW-1:0] a_out_reg;
always_ff @(posedge clk) begin
  if (rst) begin
    a_out_reg <= '0;
  end else begin
    a_out_reg <= a_in;              // A 向右流动,1 拍延迟
  end
end
assign a_out = a_out_reg;

接着是乘法和加法的部分,从上方流入部分和 sum_in,计算 sum_in + b_reg * a_in,其中 b_reg 保存的就是该 PE 对应的 B 矩阵的元素,计算结果打 1 拍后通过 sum_out 输出:

// 部分和:从上方 PE(或阵列顶部)流入,向下传播
input  wire signed [C_DW-1:0] sum_in,  // 来自上方 PE(或阵列顶部)的部分和
output wire signed [C_DW-1:0] sum_out  // 传给下方 PE

逻辑部分:

// B 元素寄存器
reg signed [A_DW-1:0] b_reg;

// 部分和寄存器:实现乘加运算,从上方流入 sum_in,计算 sum_in + b_reg * a_in 向下流动
reg signed [C_DW-1:0] sum_out_reg;
always_ff @(posedge clk) begin
  if (rst) begin
    sum_out_reg <= '0;
  end else begin
    // 乘加:sum_in + b_reg * a_in,1 拍延迟
    sum_out_reg <= sum_in + b_reg * a_in;
  end
end
assign sum_out = sum_out_reg;

最后是保存 B 矩阵元素的 b_reg 寄存器的相关逻辑,只需要在 b_ld_en 为真时,把从上方得到的 B 元素保存并向下传:

// B 元素寄存器:加载 B 矩阵元素期间(b_ld_en 为真)每拍写入 b_in,同时向下流动;
// 之后 b_ld_en 为 0,b_reg 保持不变
reg signed [A_DW-1:0] b_reg;
always_ff @(posedge clk) begin
  if (rst) begin
    b_reg <= '0;
  end else begin
    if (b_ld_en) b_reg <= b_in;     // 写入并向下流动
  end
end
assign b_out = b_reg;               // 向下传播

PE 单元的电路图如下(忽略了 rst 部分逻辑):

脉动阵列实现

有了 PE 以后,脉动阵列就是在 PE 的基础上,把 PE 之间连接起来:

  • 计算矩阵乘时,A 矩阵的元素从左到右流动,那么左右相邻的 PE 里,左边 PE 输出的 a_out 接到右边 PE 输入的 a_in
  • 计算矩阵乘时,部分和从上向下流动,那么上下相邻的 PE 里,上边 PE 输出的 sum_out 接到下边 PE 输入的 sum_in
  • 加载 B 矩阵时,B 矩阵的元素也是从上向下流动,那么上下相邻的 PE 里,上边 PE 输出的 b_out 接到下边 PE 输入的 b_in

下面是对应的代码,在 rtl/sv/systolic_array.sv 里。首先是输入输出,对应脉动阵列左侧 A 矩阵的输入、顶部 B 矩阵的输入和底部 C 矩阵的输出:

module systolic_array #(
    parameter PE_N = 4,    // 阵列大小(N x N)
    parameter A_DW = 8,    // A/B 元素位宽
    parameter C_DW = 32    // C 元素位宽(部分和)
) (
    input  wire                        clk,
    input  wire                        rst,

    // B 元素:从顶部逐拍注入一行(LDTILE)
    input  wire                        b_ld_en,     // 写入使能
    input  wire signed [PE_N*A_DW-1:0] b_ld_data,   // 从顶部注入的一行 B

    // A 元素:从左边斜入(TMUL)
    input  wire signed [PE_N*A_DW-1:0] a_left,      // A[i][j] 在拍 i+j 从行 j 进入阵列

    // 部分和:从底部斜出(TMUL)
    output wire signed [PE_N*C_DW-1:0] sum_bottom   // C[i][j] 在拍 i+j+N 从列 j 离开阵列
);

首先例化 NxN 个 PE,把这些 PE 的信号都拉出来,这里用 generate 语法来批量生成 verilog 代码:

// 例化 N x N 个 PE
genvar i, j;
generate
  for (i = 0; i < PE_N; i++) begin : gen_row
    for (j = 0; j < PE_N; j++) begin : gen_col
      pe #(
          .A_DW  (A_DW),
          .C_DW  (C_DW)
      ) u_pe (
          .clk      (clk),
          .rst      (rst),
          .b_ld_en  (b_ld_en),
          .b_in     (pe_b_in[i][j]),
          .b_out    (pe_b_out[i][j]),
          .a_in     (pe_a_in[i][j]),
          .sum_in   (pe_sum_in[i][j]),
          .a_out    (pe_a_out[i][j]),
          .sum_out  (pe_sum_out[i][j])
      );
    end
  end
endgenerate

接下来,完成 PE 之间的连线。对于 B 矩阵,从顶部注入 b_ld_data,然后逐行向下流动:

// B 矩阵
// 第 0 行 PE 从顶部注入 b_ld_data 数据用于 LDTILE
for (genvar j = 0; j < PE_N; j++) begin : gen_b_top
  assign pe_b_in[0][j] = b_ld_data[j*A_DW +: A_DW];
end
// 从第 i 行 PE 向下传播 B 元素到第 i+1 行 PE
for (genvar i = 1; i < PE_N; i++) begin : gen_b_mid
  for (genvar j = 0; j < PE_N; j++) begin : gen_b_mid_j
    assign pe_b_in[i][j] = pe_b_out[i-1][j];
  end
end

对于 A 矩阵,从左边缘注入 a_left,然后逐列向右流动:

// A 矩阵
// 第 0 列 PE 注入 a_left 数据用于 TMUL
for (genvar i = 0; i < PE_N; i++) begin : gen_a_left
  assign pe_a_in[i][0] = a_left[i*A_DW +: A_DW];
end
// 从第 i 列 PE 向右传播 A 元素到第 i+1 列 PE
for (genvar j = 1; j < PE_N; j++) begin : gen_a_mid
  for (genvar i = 0; i < PE_N; i++) begin : gen_a_mid_i
    assign pe_a_in[i][j] = pe_a_out[i][j-1];
  end
end

对于部分和,从顶部注入零,然后逐行向下流动,C 矩阵结果会从最后一行 PE 输出:

// 部分和
// 第 0 行,顶部恒为零
for (genvar j = 0; j < PE_N; j++) begin : gen_sum_top
  assign pe_sum_in[0][j] = '0;
end
// 第 i 行 PE 向下传播部分和到 第 i+1 行 PE
for (genvar i = 1; i < PE_N; i++) begin : gen_sum_mid
  for (genvar j = 0; j < PE_N; j++) begin : gen_sum_mid_j
    assign pe_sum_in[i][j] = pe_sum_out[i-1][j];
  end
end

// 底部从最后一行 PE 输出
for (genvar j = 0; j < PE_N; j++) begin : gen_sum_bottom
  assign sum_bottom[j*C_DW +: C_DW] = pe_sum_out[PE_N-1][j];
end

可见脉动阵列的结构非常简单,只有连线,没有额外的寄存器,因此脉动阵列可以把大部分面积用在 PE 内部的计算(乘法器和加法器)上。到这里,你就已经完成了一个 4x4 的脉动阵列,可以实现矩阵乘法。实际上,这个设计高度参考了 Google 第一代 TPU 的论文(2017 发表在 arXiv:1704.04760),只不过 TPU 的脉动阵列直接做到了 256x256 的规模。

但单纯一个脉动阵列还不够:还没有处理斜入斜出,没有解决数据从哪里来、到哪里去的问题,也没有能和 CPU 接起来。接下来就来解决,数据的搬运,以及和 CPU 的集成。

控制逻辑与片上存储

理解了 NPU 上数据是怎么流动之后,就知道脉动阵列外围的 NPU 逻辑都需要做什么了。下面结合代码来进行学习。

控制逻辑

控制逻辑实现在 npu_core 模块当中,这个模块做了这些事情:

  • 例化了在上文已经设计好的脉动阵列 systolic_array 模块
  • 例化了 L0A/L0B/L0C 三个片上存储,使用 FPGA 的 BlockRAM 实现
  • 实现两个状态机,分别负责 LDTILE 和 TMUL 指令的执行
  • 针对 LDTILE:状态机驱动 L0B 的读取端口,在 N+1 拍内把 L0B 读取出的数据填入脉动阵列
  • 针对 TMUL:状态机驱动 L0A 的读取端口,把读取后的数据经过若干个移位寄存器,斜入脉动阵列的左边缘,同时在脉动阵列的底部斜出,通过移位寄存器把 C 矩阵的同一行数据同步起来,从 L0C 读取旧值,累加后把新值写入 L0C
  • 针对 CPU:允许 CPU 通过 MMIO 方式通过 Wishbone 总线,把数据写入 L0A/L0B,再把数据从 L0C 读出

下面结合 rtl/sv/npu_core.sv 的源码对各部分实现进行解析。

例化脉动阵列

首先,npu_core 模块例化了 systolic_array 模块,并把对应的输入输出信号引了出来:

// ============ 脉动阵列 ============
wire [PE_N*8-1:0]   a_left;       // A 左边缘(斜入)
wire [PE_N*32-1:0]  sum_bottom;   // 底部部分和(C 斜出)
wire                b_ld_en;      // B 写入使能
wire [PE_N*8-1:0]   b_ld_data;    // 逆序从阵列顶部注入一行 B

systolic_array #(
    .PE_N  (PE_N),
    .A_DW  (8),
    .C_DW  (32)
) u_array (
    .clk       (clk),
    .rst       (rst),
    .b_ld_en   (b_ld_en),
    .b_ld_data (b_ld_data),
    .a_left    (a_left),
    .sum_bottom(sum_bottom)
);

脉动阵列的接口还是比较简单清晰的。

例化 L0A/L0B/L0C 片上存储

在 FPGA 上,L0A/L0B/L0C 被实现为双端口的 Simple Dual Port Block RAM,即它有两个端口,其中端口 A 用于写入,端口 B 用于读取,读取有一拍的延迟,这里把它封装为 blockram_1r1w 模块:

module blockram_1r1w #(
    parameter DEPTH  = 64,    // 深度
    parameter DATA_W = 32,    // 位宽
    localparam A_W = $clog2(DEPTH)
) (
    input  wire clk,
    input  wire rst,

    // 写端口 A
    input  wire                wea,
    input  wire [A_W-1:0]      addra,
    input  wire [DATA_W-1:0]   dina,

    // 读端口 B
    input  wire                enb,
    input  wire [A_W-1:0]      addrb,
    output wire [DATA_W-1:0]   doutb
);

  // 行为模型,与 BRAM 语义一致
  reg [DATA_W-1:0] mem [0:DEPTH-1];
  reg [DATA_W-1:0] doutb_reg;

  // 写:wea 时写入;读:enb 时下一拍输出
  always_ff @(posedge clk) begin
    if (wea) mem[addra] <= dina;
    if (enb) doutb_reg <= mem[addrb];
  end
  assign doutb = doutb_reg;

endmodule

对于写端口 A 来说,当 wea 为高时,往地址为 addra 的位置写入 dina 的数据;对于读端口 B 来说,当 enb 为高时,从地址为 addrb 的位置读取数据,1 拍的延迟后通过 doutb 输出。

Vivado 会识别这种实现,并把它用 Block RAM 来实现。接下来,例化 blockram_1r1w 来用于 L0A/L0B/L0C 片上存储:

// ============ L0A/L0B/L0C RAM ============
wire [L0A_DW-1:0] l0a_rdata;          // L0A 读数据(A 的一行,PE_N 个 int8)
wire [L0B_DW-1:0] l0b_rdata;          // L0B 读数据(B 的一行,PE_N 个 int8)
wire [L0C_DW-1:0] l0c_rdata;          // L0C 读数据(C 的一行,PE_N 个 int32)
wire          l0a_ren, l0b_ren, l0c_ren;
wire [L0A_AW-1:0] l0a_raddr;
wire [L0B_AW-1:0] l0b_raddr;
wire [L0C_AW-1:0] l0c_raddr, l0c_waddr;
wire          l0c_wen;
wire [L0C_DW-1:0] l0c_wdata;

blockram_1r1w #(.DEPTH(L0A_DEPTH), .DATA_W(L0A_DW)) u_l0a (
    .clk(clk), .rst(rst),
    .wea(aw_valid), .addra(aw_addr), .dina(aw_data),
    .enb(l0a_ren), .addrb(l0a_raddr), .doutb(l0a_rdata)
);

blockram_1r1w #(.DEPTH(L0B_DEPTH), .DATA_W(L0B_DW)) u_l0b (
    .clk(clk), .rst(rst),
    .wea(bw_valid), .addra(bw_addr), .dina(bw_data),
    .enb(l0b_ren), .addrb(l0b_raddr), .doutb(l0b_rdata)
);

blockram_1r1w #(.DEPTH(L0C_DEPTH), .DATA_W(L0C_DW)) u_l0c (
    .clk(clk), .rst(rst),
    .wea(l0c_wen), .addra(l0c_waddr), .dina(l0c_wdata),
    .enb(l0c_ren), .addrb(l0c_raddr), .doutb(l0c_rdata)
);

其中 L0A 和 L0B 的写口是给 CPU 上的软件用的,所以直接接到模块的输入上:

// L0A 写(软件),一次写入 A 的一行(PE_N 个 int8)
input  wire                   aw_valid,
input  wire [L0A_AW-1:0]      aw_addr,
input  wire [L0A_DW-1:0]      aw_data,
// L0B 写(软件),一次写入 B 的一行(PE_N 个 int8)
input  wire                   bw_valid,
input  wire [L0B_AW-1:0]      bw_addr,
input  wire [L0B_DW-1:0]      bw_data,

L0C 比较特殊,因为累加逻辑和 CPU 都需要读取 L0C,这部分逻辑留到后面来分析。

LDTILE 指令实现

为了实现 LDTILE 指令,首先要从 L0B 经过 N 拍读取出 B 矩阵,再用 N 拍把数据从顶部传入脉动阵列。由于 L0B 的读取有一拍的延迟,所以实际上需要 N+1 拍完成一次 LDTILE 指令,故状态机设计如下:

  • 在 IDLE 状态下,等待来自 CPU 的指令;当 CPU 发起一条 LDTILE 指令时,计算出 B 矩阵最后一行的地址 ldtile_base + PE_N - 1 保存到 l0b_addr_reg 寄存器(其中 ldtile_base 是 LDTILE 指令的参数,代表要加载的 B 矩阵在 L0B 内的地址),同时转移到 LOAD 状态
  • 在 LOAD 状态下,持续读取 L0B,同时通过 r_cnt 寄存器统计周期数:
  • 当 r_cnt == 0 时,开始读 L0B(设置 l0b_ren 为 1),此时从 L0B 读取的地址就是 ldtile_base + PE_N - 1,同时 l0b_addr_reg 寄存器的值减一,开始读 B 矩阵的倒数第二行
  • 当 1 <= r_cnt <= PE_N 时,已经有从 L0B 读取的数据,此时把读取的数据传入脉动阵列的 b_ld_data,同时设置 b_ld_en,也让 l0b_addr_reg 寄存器的值减一,逆序读取
  • 特别地,当 r_cnt == PE_N 时,转移到 IDLE 状态,此时 LDTILE 指令执行完成

一种可能的波形如下,假设 ldtile_base 等于 4,B[4] 代表 L0B 地址为 4 内的数据(假设 L0B 有足够大的空间):

clkldtile_stateIDLELOADIDLEr_cnt01234l0b_addr_reg7654l0b_renl0b_raddr7654b_ld_enb_ld_dataB[7]B[6]B[5]B[4]

对应源码如下,首先是状态机和对应的状态转移逻辑:

// LDTILE 状态机
// 只有 LOAD 一个工作状态:注入 PE_N 拍后回到 IDLE。
localparam [1:0] LS_IDLE = 2'd0, LS_LOAD = 2'd1;
reg [1:0] ldtile_state;
reg [1:0] ldtile_state_next;      // 组合次态
always_ff @(posedge clk) begin
  if (rst) ldtile_state <= LS_IDLE;
  else     ldtile_state <= ldtile_state_next;
end

// LDTILE 拍号(范围是 0..PE_N):LS_IDLE 接受指令时清 0,LS_LOAD 内每拍 +1
localparam RCNT_W = $clog2(PE_N + 1);                         // LDTILE 拍号位宽
reg [RCNT_W-1:0]       r_cnt;                                 // LDTILE 拍号
always_ff @(posedge clk) begin
  if (rst) r_cnt <= '0;
  else if (ldtile_state == LS_IDLE && ldtile_valid) r_cnt <= '0;
  else if (ldtile_state == LS_LOAD)                 r_cnt <= r_cnt + 1;
end

// 次态:LDTILE 注入 PE_N 拍后完成(LS_LOAD 拍 0 发读、拍 1..PE_N 注入)
localparam [RCNT_W-1:0] LDTILE_LAST = RCNT_W'(PE_N);          // LDTILE 注入末拍(r_cnt 同宽)
always_comb begin
  ldtile_state_next = ldtile_state;
  case (ldtile_state)
    LS_IDLE: if (ldtile_valid) ldtile_state_next = LS_LOAD;
    LS_LOAD: if (r_cnt == LDTILE_LAST) ldtile_state_next = LS_IDLE;   // 注入 PE_N 拍(PE_N 行 B)
    default: ;
  endcase
end

然后根据状态和 r_cnt 的值,驱动 L0B 的读取,把读取结果 l0b_rdata 传给脉动阵列:

// 逆序读通过递减计数器实现:LDTILE 开始时计算 B 子块末行地址 ldtile_base + PE_N - 1,
// 装入 l0b_addr_reg 寄存器,之后每周期减一。
localparam L0B_LAST = PE_N - 1;               // 子块末行偏移
reg [L0B_AW-1:0] l0b_addr_reg;
always_ff @(posedge clk) begin
  if (rst) begin
    l0b_addr_reg <= '0;
  end else if (ldtile_state == LS_IDLE && ldtile_valid) begin
    l0b_addr_reg <= ldtile_base + L0B_LAST[L0B_AW-1:0];   // 装入子块末行地址
  end else if (ldtile_state == LS_LOAD) begin
    l0b_addr_reg <= l0b_addr_reg - 1;           // 每周期减一(逆序)
  end
end

assign l0b_ren    = (ldtile_state == LS_LOAD) && (r_cnt < LDTILE_LAST);
assign l0b_raddr  = l0b_addr_reg;
// b_ld_en 在注入期(拍 1..PE_N)都为真:PE 每拍写入 b_in(向下流动),最后一拍锁存自己的 B
assign b_ld_en    = (ldtile_state == LS_LOAD) && (r_cnt >= 1);
assign b_ld_data  = l0b_rdata;                                // 顶部注入一行 B

打开 LDTILE 交互波形(新窗口)

TMUL 指令实现

接下来是 TMUL 指令实现,回顾前面对脉动阵列的分析,每一拍要做的事情如下:

  • 第 0 拍到第 M+N-2 拍:输入 A 矩阵,A 矩阵元素 A[i][j] 在第 i+j 拍从第 j 行 PE 进入阵列;
  • 第 N 拍到第 M+2N-2 拍:输出 C 矩阵,C 矩阵元素 C[i][j] 在第 i+j+N 拍第 j 列 PE 离开阵列。

针对 L0A,要做的事情就是,从第 0 拍到第 M-1 拍,每拍出 A 矩阵的一行元素,由于 L0A 读取有一个周期的延迟,因此要提前一个周期把地址传入 L0A 进行读取。为此,给状态机设计一个额外的 PRE 状态,用于提前一个周期读取 L0A 中 A 矩阵的第 0 行元素,这样它就会在进行计算的 MUL 状态下的第 0 拍得到 A 矩阵的第 0 行元素的值。接着,为了把读出来的数据 l0a_rdata 斜入脉动阵列,把 A 矩阵的不同列接入对应延迟的移位寄存器(Shift Register),再把移位后的信号 a_cols 传入脉动阵列。

针对 L0C,要做的事情是,把脉动阵列输出的 C 矩阵元素 sum_bottom 经过一系列不同延迟的移位寄存器进行对齐,对齐后的信号 c_cols,就是从第 2N-1 拍到第 M+2N-2 拍每拍出一行的 C 矩阵,累加旧值后写入 L0C。考虑到累加需要读取旧值,而读取需要一个周期的延迟,于是从第 2N-2 拍到第 M+2N-3 拍读取 C 矩阵的旧值。

状态机设计为:IDLE 状态下,收到新的 TMUL 指令,转移到 PRE 状态,一个周期后转移到 MUL 状态,通过 t_cnt 计数器统计拍数,当 t_cnt == M+2N-2 时,计算完成,转移到 IDLE 状态。

设 M=N=4,那么 L0A 的读取就是第 0 拍到第 3 拍,L0C 的读取是第 6 拍到第 9 拍,L0C 的写入是第 7 拍到第 10 拍,下面给出一次计算的波形,其中 A[i] 代表 L0A 地址为 i 的数据,对应 A 矩阵的一行,Aij 代表 A 矩阵第 i 行第 j 列的元素:

首先是 L0A 的读取波形:

clktmul_stateIDLEPREMULIDLEt_cnt012345678910l0a_renl0a_raddr0123l0a_rdata[0]A00A10A20A30l0a_rdata[1]A01A11A21A31l0a_rdata[2]A02A12A22A32l0a_rdata[3]A03A13A23A33a_cols[0]A00A10A20A30a_cols[1]A01A11A21A31a_cols[2]A02A12A22A32a_cols[3]A03A13A23A33

从 PRE 开始到 MUL 状态 t_cnt == 2 的四个周期,每个周期从 L0A 读取 A 矩阵的一行,然后通过移位寄存器,从 t_cnt == 0 到 t_cnt == 6 这 7 拍的时间把 A 矩阵斜入脉动阵列。

其次是 L0C 的读-改-写波形:

clktmul_stateIDLEPREMULIDLEt_cnt012345678910sum_bottom[0]C00C10C20C30sum_bottom[1]C01C11C21C31sum_bottom[2]C02C12C22C32sum_bottom[3]C03C13C23C33c_cols[0]C00C10C20C30c_cols[1]C01C11C21C31c_cols[2]C02C12C22C32c_cols[3]C03C13C23C33l0c_renl0c_raddr0123l0c_rdataC[0]C[1]C[2]C[3]l0c_wenl0c_waddr0123l0c_wdataC'[0]C'[1]C'[2]C'[3]

当 t_cnt == 4 时,脉动阵列底部的 sum_bottom 开始输出第一个元素,经过一系列移位寄存器对齐后,实际输出是 t_cnt == 7 到 t_cnt == 10 这四个周期,因此,提前一个周期,从 t_cnt == 6 到 t_cnt == 9 这四个周期读取 L0C 中的旧值,当旧值读取出来了,新的 c_cols 也计算出来了,此时就可以累加并写入 L0C。

对应代码如下,首先是状态机部分:

// PRE 1 拍(预取 A 第 0 行)-> MUL(M+2N-1 拍)-> IDLE。
localparam [2:0] TS_IDLE = 3'd0, TS_PRE = 3'd1, TS_MUL = 3'd2;
reg [2:0] tmul_state;
reg [2:0] tmul_state_next;
always_ff @(posedge clk) begin
  if (rst) tmul_state <= TS_IDLE;
  else     tmul_state <= tmul_state_next;
end

// TMUL 拍号(范围是 0..M+2N-2):TS_IDLE 接受指令时清 0,TS_MUL 内每拍 +1
localparam TCNT_W = $clog2(M_MAX + 2 * PE_N - 1);  // TMUL 拍号位宽(最多能数到 M_MAX+2N-2)
reg [TCNT_W-1:0]       t_cnt;                                 // TMUL 拍号
always_ff @(posedge clk) begin
  if (rst) t_cnt <= '0;
  else if (tmul_state == TS_IDLE && tmul_valid) t_cnt <= '0;
  else if (tmul_state == TS_MUL)                t_cnt <= t_cnt + 1;
end

// 次态:TMUL 在 t_cnt == m_m1+2*PE_N-1 时结束(最后一行 C 已写入,共 M+2*PE_N-1 拍)
localparam [TCNT_W-1:0] TMUL_LAST = TCNT_W'(2 * PE_N - 1);  // 最后一行 C 写出的拍号偏移(m_m1 + 2*PE_N-1)
always_comb begin
  tmul_state_next = tmul_state;
  case (tmul_state)
    TS_IDLE: if (tmul_valid) tmul_state_next = TS_PRE;
    TS_PRE:  tmul_state_next = TS_MUL;   // 1 拍预取(读第 0 行)后进入计算
    TS_MUL:  if (t_cnt == {{(TCNT_W-MM1_W){1'b0}}, m_m1} + TMUL_LAST) tmul_state_next = TS_IDLE;   // 最后一行 C 已写入
    default: ;
  endcase
end

这里用了一个常见的硬件小技巧:由于 M 不可能等于 0,并且 M 通常最大值取一个 2 的幂次,为了节省一点空间,就直接约定保存的是 M-1 而不是 M 本身,所以这里的 m 都带了 _m1 的后缀,表示 minus 1。

接下来是 L0A 的读取,首先在寄存器 l0a_addr_reg 中计算每一拍对 L0A 的读取地址并读取:

// L0A 地址用寄存器保存:指令开始时装入 a_base,之后每拍加一。
reg [L0A_AW-1:0] l0a_addr_reg;
always_ff @(posedge clk) begin
  if (rst) begin
    l0a_addr_reg <= '0;
  end else if (tmul_state == TS_IDLE && tmul_valid) begin
    l0a_addr_reg <= tmul_a_base;                // 装入 A 行块基址
  end else if (tmul_state == TS_PRE || tmul_state == TS_MUL) begin
    l0a_addr_reg <= l0a_addr_reg + 1;           // 每拍加一,读下一行
  end
end

assign l0a_ren   = (tmul_state == TS_PRE) ||    // 预取拍读第 0 行
                   ((tmul_state == TS_MUL) && (t_cnt + 1 <= m_m1));
assign l0a_raddr = l0a_addr_reg;

读取结果拆分成 int8 元素,然后经过移位寄存器实现斜入:

// A 的 PE_N 路 8bit 各经一个 shift_register(对行 k 的元素打 k 拍,行 0 打 0 拍直通),
// 打拍深度由 generate 按 PE_N 展开
wire [PE_N*8-1:0] a_cols;
genvar i;
generate
  for (i = 0; i < PE_N; i++) begin : gen_a_shift
    shift_register #(.DATA_W(8), .DEPTH(i)) u_sr_a (
        .clk(clk), .rst(rst),
        .din(l0a_rdata[i*8 +: 8]), .dout(a_cols[i*8 +: 8]));
  end
endgenerate

// A 矩阵从脉动阵列左侧斜入,从第 0 拍到第 M+N-2(即 m_m1+1+N-2)拍
localparam [TCNT_W-1:0] A_LAST    = TCNT_W'(PE_N - 1);  // 最后一个 A 元素进入的拍号偏移(m + PE_N-1)
assign a_left = (tmul_state == TS_MUL && t_cnt <= {{(TCNT_W-MM1_W){1'b0}}, m_m1} + A_LAST)
    ? a_cols
    : '0;

其中移位寄存器模块的实现在 shift_register.sv 当中,内部就是一系列的寄存器:

module shift_register #(
    parameter DATA_W = 32,   // 数据位宽
    parameter DEPTH   = 4    // 打拍数(寄存器个数,0 = 直通)
) (
    input  wire clk,
    input  wire rst,
    input  wire [DATA_W-1:0] din,
    output wire [DATA_W-1:0] dout
);

generate
  if (DEPTH == 0) begin : gen_direct
    assign dout = din;
  end else begin : gen_shift
    // DEPTH 级打拍寄存器链
    reg [DATA_W-1:0] regs [0:DEPTH-1];
    always_ff @(posedge clk) begin
      if (rst) begin
        for (int i = 0; i < DEPTH; i++) regs[i] <= '0;
      end else begin
        regs[0] <= din;
        for (int i = 1; i < DEPTH; i++) regs[i] <= regs[i-1];
      end
    end
    assign dout = regs[DEPTH-1];
  end
endgenerate

endmodule

最后来到 L0C 的处理部分,首先是对脉动阵列的输出 sum_bottom 进行移位寄存器以进行斜出的对齐:

// 阵列底部每拍斜出一个对角(C[i][j] 在拍 i+j+PE_N 出列 j);PE_N 列结果分别经过
// 一个 shift_register(对列 j 打 PE_N-1-j 拍,列 PE_N-1 打 0 拍直通)对齐后,
// 每拍得到一行 C(PE_N 个 int32),写入 L0C。
wire [PE_N*32-1:0] c_cols;
genvar j;
generate
  for (j = 0; j < PE_N; j++) begin : gen_c_shift
    shift_register #(.DATA_W(32), .DEPTH(PE_N-1-j)) u_sr_c (
        .clk(clk), .rst(rst),
        .din(sum_bottom[j*32 +: 32]), .dout(c_cols[j*32 +: 32]));
  end
endgenerate

接着,在寄存器里维护 L0C 的读取地址和写入地址,从 t_cnt == 2N-2 开始到 t_cnt == M+2N-3 读取 L0C,读完的下一个周期写 L0C:

// 从第 N 拍到第 M+2N-2 拍:阵列输出 C 矩阵的时间范围。
// 经过移位寄存器对齐后,实际写入是第 2N-1 拍到第 M+2N-2 拍,共 M 拍。
// 因此读取旧值的时间是第 2N-2 拍到第 M+2N-3 拍,比对应的写入早一拍。
localparam [TCNT_W-1:0] l0c_rdata_START = TCNT_W'(2 * PE_N - 2);  // L0C 读窗口起点(t_cnt 偏移)
reg [L0C_AW-1:0] l0c_raddr_reg;
reg [L0C_AW-1:0] l0c_waddr_reg;
always_ff @(posedge clk) begin
  if (rst) begin
    l0c_raddr_reg <= '0; l0c_waddr_reg <= '0;
  end else if (tmul_state == TS_IDLE && tmul_valid) begin
    l0c_raddr_reg <= tmul_c_base;                // 装入 C 行块基址(行 0 地址)
  end else if ((tmul_state == TS_MUL) && (t_cnt >= l0c_rdata_START) && (t_cnt <= {{(TCNT_W-MM1_W){1'b0}}, m_m1} + l0c_rdata_START)) begin
    l0c_raddr_reg <= l0c_raddr_reg + 1;         // 每拍加 1(连续存放),下一行
    l0c_waddr_reg <= l0c_raddr_reg;             // 下一拍写地址 = 本拍读地址
  end
end

// L0C 读端口仲裁(ready-valid):core 的 acc 读改写占用读端口(发读拍)时 cr_ready 拉低,
// CPU 读 L0C 挂起等待;acc 读改写的发读拍提前一拍(raddr = 下一拍写地址)
wire l0c_rdata_used = acc && (tmul_state == TS_MUL) && (t_cnt >= l0c_rdata_START) && (t_cnt <= {{(TCNT_W-MM1_W){1'b0}}, m_m1} + l0c_rdata_START);
// ready-valid 握手: 只有 valid & ready 同时为 1(握手 cr_hs)才真正读 CPU 地址。
// acc 读改写优先使用读端口,不因 CPU 请求而被打断。
wire cr_hs = cr_valid && cr_ready;              // ready-valid 握手
assign cr_ready  = !l0c_rdata_used;             // ready 只依赖端口空闲
assign l0c_ren   = l0c_rdata_used | cr_hs;      // acc 读改写或握手拍才发读
assign l0c_raddr = cr_hs ? cr_addr : l0c_raddr_reg;
assign l0c_waddr = l0c_waddr_reg;

localparam [TCNT_W-1:0] L0C_WR_START = TCNT_W'(2 * PE_N - 1);  // L0C 写窗口起点(t_cnt 偏移)
assign l0c_wen   = (tmul_state == TS_MUL) && (t_cnt >= L0C_WR_START) && (t_cnt <= {{(TCNT_W-MM1_W){1'b0}}, m_m1} + L0C_WR_START);

在读取 L0C 的时候,还需要考虑到 CPU 也需要读取 L0C,那就在 MUL 状态下让 CPU 等待,优先让累加逻辑读取。读取出数据后,最后只需要进行累加,再把累加的数据写入 L0C:

// 累加按 lane 独立相加
genvar k;
generate
  for (k = 0; k < PE_N; k++) begin : gen_l0c_lane
    assign l0c_wdata[k*32 +: 32] = acc
        ? (l0c_rdata[k*32 +: 32] + c_cols[k*32 +: 32])
        : c_cols[k*32 +: 32];
  end
endgenerate

打开 TMUL 交互波形(新窗口)

模块层次

npu_top
├── npu_wb        Wishbone 从机、寄存器、MMIO 译码
└── npu_core      计算核心
    ├── blockram_1r1w x3   L0A / L0B / L0C
    ├── systolic_array
    │   └── pe x PE_N x PE_N
    └── shift_register x (2*PE_N)   斜入 / 斜出对齐

npu_core 内部两条状态机是理解整个设计的关键:

  • LDTILE 状态机只有 IDLE 和 LOAD 两个状态,LOAD 持续 PE_N+1 拍:第 0 拍发 L0B 读请求,第 1..PE_N 拍把读出的 B 行从顶部注入阵列;
  • TMUL 状态机有 IDLE、PRE、MUL 三个状态,PRE 预取 A 的第 0 行,MUL 持续 M+2N-1 拍,期间完成 A 的斜入、C 的斜出对齐、L0C 的读改写。

一个容易忽略的点是 L0C 的读端口仲裁:TMUL 在累加时需要先读 L0C 旧值,而 CPU 也可能同时通过 Wishbone 读 L0C。npu_core 用 ready-valid 握手(cr_ready)把读端口优先让给累加逻辑,CPU 的读请求会在 npu_wb 中挂起,等 core 空闲后再发读。细节见 Wishbone 接口与顶层。

完整代码见 cod-npu 仓库根目录下的 rtl/sv/ 目录。除了 SystemVerilog 版本,仓库还提供了 Chisel 版本供集成。

作者:Jiajie Chen