跳转至

Wishbone 接口与顶层

本节介绍如何把 NPU 挂到 Wishbone 总线上,以及 npu_top 的接口和地址映射。NPU 对 CPU 来说就是一个普通的 Wishbone Slave:CPU 通过 MMIO 读写寄存器,向 L0A/L0B 写数据,从 L0C 读结果,并通过写 CMD 寄存器触发 LDTILE/TMUL 指令。

Wishbone 总线

NPU 的核心控制逻辑已经实现完成,只剩下最后一步:让 CPU 访问 NPU 上的 L0A/L0B/L0C,同时可以让 NPU 执行 LDTILE/TMUL 指令。上面提到,这可以通过 MMIO 实现,由于在先前的实验里,CPU 采用了 Wishbone 总线,因此这里就可以把 NPU 作为一个 Wishbone 的 Slave,挂到 Wishbone 总线下,供 CPU 访问。由于 Wishbone 上都是读和写请求,因此需要设计一个约定,即地址和对应的功能,约定如下:

  • NPU 对应的 MMIO 地址空间从 0x2000_0000 到 0x2000_FFFF,定义了这些功能:
  • 0x2000_0000 CMD:仅支持写,写入时会在 NPU 上执行对应的指令,写入 1 代表执行 LDTILE 指令,写入 2 代表执行 TMUL 指令且 acc=0,写入 6 代表执行 TMUL 指令且 acc=1(即 CMD[0] 代表 LDTILE,CMD[1] 代表 TMUL,CMD[2] 代表 acc)
  • 0x2000_0004 STATUS:读取时,最低位 STATUS[0] 为 1 代表 NPU 正在执行指令,STATUS[1] 为 1 代表 NPU 完成了一条指令的执行;写入时,STATUS[1] 会被清零,代表 CPU 已经知晓 NPU 指令完成执行
  • 0x2000_0008 A_BASE:仅支持写,为 TMUL 指令所采用的 L0A 基址,单位是 4 字节
  • 0x2000_000C B_BASE:仅支持写,为 LDTILE 指令所采用的 L0B 基址,单位是 4 字节
  • 0x2000_0010 C_BASE:仅支持写,为 TMUL 指令所采用的 L0C 基址,单位是 16 字节
  • 0x2000_0014 M_M1:仅支持写,为 TMUL 指令所计算的 A/C 矩阵的行数 M 减一
  • 0x2000_0100 到 0x2000_01FF L0A:仅支持写,即访问 L0A 内的数据
  • 0x2000_0200 到 0x2000_020F L0B:仅支持写,即访问 L0B 内的数据
  • 0x2000_0300 到 0x2000_06FF L0C:仅支持读,即访问 L0C 内的数据

下面是 cod-npu 仓库根目录下的 rtl/sv/npu_wb.sv 中对应的硬件实现,首先是判断何时来了一个新的 Wishbone 请求:

// ============ Wishbone 握手 ============
reg  stb_prev;
wire req = wb_cyc_i & wb_stb_i & ~stb_prev;
always_ff @(posedge clk_i) begin
  if (rst_i) stb_prev <= 1'b0;
  else       stb_prev <= wb_stb_i;
end

接着,实现各参数寄存器的写入(代码里 W1C 是 Write 1 Clear 的缩写,意思是写入 1 会把对应的位清零):

// 指令参数寄存器
reg [L0A_AW-1:0] a_base;
reg [L0B_AW-1:0] b_base;
reg [L0C_AW-1:0] c_base;
reg [MM1_W-1:0]  m_m1;
always_ff @(posedge clk_i) begin
  if (rst_i) begin
    a_base <= '0; b_base <= '0; c_base <= '0; m_m1 <= '0;
  end else if (req && wb_we_i) begin
    case (wb_adr_i[15:0])
      16'h0008: if (wb_sel_i[0]) a_base <= wb_dat_i[L0A_AW-1:0];
      16'h000C: if (wb_sel_i[0]) b_base <= wb_dat_i[L0B_AW-1:0];
      16'h0010: if (wb_sel_i[0]) c_base <= wb_dat_i[L0C_AW-1:0];
      16'h0014: if (wb_sel_i[0]) m_m1 <= wb_dat_i[MM1_W-1:0];
      default: ;
    endcase
  end
end

// done 状态寄存器:core 完成时置位,软件 W1C 清除
reg status_done;
always_ff @(posedge clk_i) begin
  if (rst_i) begin
    status_done <= 1'b0;
  end else begin
    if (core_done) status_done <= 1'b1;
    if (req && wb_we_i) begin
      case (wb_adr_i[15:0])
        16'h0004: if (wb_dat_i[1]) status_done <= 1'b0;   // STATUS W1C
        default: ;
      endcase
    end
  end
end

处理针对 CMD 的写入,启动 NPU 对应指令的执行:

// ============ 指令译码 ============
assign ldtile_valid = req & wb_we_i & (wb_adr_i[15:0] == 16'h0000) & wb_dat_i[0];
assign tmul_valid    = req & wb_we_i & (wb_adr_i[15:0] == 16'h0000) & wb_dat_i[1];
assign tmul_acc      = req & wb_we_i & (wb_adr_i[15:0] == 16'h0000) & wb_dat_i[2];
assign ldtile_base  = b_base;
assign tmul_a_base   = a_base;
assign tmul_m_m1     = m_m1;
assign tmul_c_base   = c_base;

接下来,处理 L0A/L0B 的写入,把 Wishbone 写入的地址和数据转给 npu_core 模块:

// L0 地址译码
wire [15:0] l0a_off = wb_adr_i[15:0] - L0A_BASE;
wire [15:0] l0b_off = wb_adr_i[15:0] - L0B_BASE;
wire [15:0] l0c_off = wb_adr_i[15:0] - L0C_BASE;
wire in_l0a = (wb_adr_i[15:0] >= L0A_BASE) & (wb_adr_i[15:0] < L0A_END);
wire in_l0b = (wb_adr_i[15:0] >= L0B_BASE) & (wb_adr_i[15:0] < L0B_END);
wire in_l0c = (wb_adr_i[15:0] >= L0C_BASE) & (wb_adr_i[15:0] < L0C_END);

// L0A/L0B 写入
// 注:此处的实现假设了 PE_N 等于 4;
// 当 PE_N 大于 4 时,一次只能写入 A/B 一行的一部分,这里的逻辑需要修改
assign aw_valid = req & wb_we_i & in_l0a;
assign aw_addr  = l0a_off[L0A_AW+1:2];
assign aw_data  = wb_dat_i;
assign bw_valid = req & wb_we_i & in_l0b;
assign bw_addr  = l0b_off[L0B_AW+1:2];
assign bw_data  = wb_dat_i;

L0C 的读取要更加复杂:首先,L0C 读取的时候,TMUL 可能正在进行累加,此时就需要等待;其次,Wishbone 总线是 32 位的数据宽度,而 L0C 一次读取出来是 128 位,需要截取其中的 32 位作为读取结果。

// CPU 读 L0C 时,若 core 正在 acc 读改写(占用读端口),请求挂起;
// core 空闲拍才发读,wishbone 的 ACK 同步延迟(CPU 忙等)。
// L0C 每字 = PE_N*4 字节:字地址 = 字节偏移 >> (2+clog2(PE_N)),lane = 偏移的低位
wire [L0C_AW-1:0] l0c_word_off = l0c_off[$clog2(PE_N)+L0C_AW+1 : $clog2(PE_N)+2];
wire [$clog2(PE_N)-1:0] l0c_lane = l0c_off[$clog2(PE_N)+1 : 2];

reg l0c_rd_pend;
reg [L0C_AW-1:0] l0c_rd_addr;               // 挂起时的字地址
reg [$clog2(PE_N)-1:0] l0c_rd_lane;         // 挂起/发读时的 lane(读数据取 32 位用)

// ready-valid 协议:cr_valid 独立(请求即置位并保持,直到握手),不依赖 cr_ready;
// 握手 = cr_valid & cr_ready(core 空闲)才真正发读。未握手期间 core 的
// acc 读改写照常使用读端口(core 内仲裁,见 npu_core)。
wire l0c_rd_req = req & ~wb_we_i & in_l0c;
assign cr_valid = l0c_rd_pend || l0c_rd_req;   // valid 独立(请求即有效,挂起时保持)
wire l0c_rd_hs  = cr_valid && core_cr_ready;   // ready-valid 握手(同时为 1 才生效)
always_ff @(posedge clk_i) begin
  if (rst_i) begin
    l0c_rd_pend <= 1'b0;
    l0c_rd_addr <= '0;
    l0c_rd_lane <= '0;
  end else if (l0c_rd_req) begin
    l0c_rd_addr <= l0c_word_off;            // 锁存字地址(挂起或握手都用它)
    l0c_rd_lane <= l0c_lane;                // 锁存 lane(读数据取 32 位)
    if (!core_cr_ready) l0c_rd_pend <= 1'b1;   // 未握手:挂起(valid 保持)
  end else if (l0c_rd_hs) begin
    l0c_rd_pend <= 1'b0;                    // 握手完成
  end
end

assign cr_addr  = l0c_rd_pend ? l0c_rd_addr : l0c_word_off;

接下来,处理 Wishbone 总线的其余信号,例如 ACK 要在请求完成的那个周期拉高:

// ============ ACK ============
// 读执行拍:STATUS 读 = req;L0C 读 = l0c_rd_hs(可能因仲裁挂起而延迟)
wire is_status_rd = req & ~wb_we_i & (wb_adr_i[15:0] == 16'h0004);
wire rd_exec = wb_we_i ? 1'b0 : (is_status_rd ? req : l0c_rd_hs);   // L0C 读 = 握手拍

reg rd_exec_reg;
always_ff @(posedge clk_i) begin
  if (rst_i) rd_exec_reg <= 1'b0;
  else       rd_exec_reg <= rd_exec;
end

reg ack_reg;
always_ff @(posedge clk_i) begin
  if (rst_i) ack_reg <= 1'b0;
  else if (wb_we_i) ack_reg <= wb_cyc_i & wb_stb_i;   // 写事务寄存 1 拍
  else ack_reg <= rd_exec_reg;                         // 读事务(L0C 读含仲裁延迟)
end
assign wb_ack_o = ack_reg;
assign wb_err_o = 1'b0;
assign wb_rty_o = 1'b0;

最后,根据地址,把 L0C 读取的数据或 STATUS 的值通过 wb_dat_o 信号返回给 CPU:

// ============ 读数据 ============
// L0C 读结果取 32 位 lane
wire [L0C_DW-1:0] cr_lane_shift = cr_data >> ({l0c_rd_lane,5'b0});

reg [DATA_WIDTH-1:0] dat_reg;
always_ff @(posedge clk_i) begin
  if (rd_exec_reg) begin
    if (wb_adr_i[15:0] == 16'h0004)
      dat_reg <= {30'b0, status_done, core_busy};
    else
      dat_reg <= cr_lane_shift[31:0];   // L0C 读结果(发读后 1 拍有效)
  end
end
assign wb_dat_o = dat_reg;

这样就完成了把 NPU 控制逻辑挂载在 Wishbone 总线上的功能。

顶层模块

最后,顶层模块 npu_top 做的事情就是例化 npu_core 和 npu_wb,顶层接口只剩下了 Wishbone,用于接到 Wishbone 总线上:

module npu_top #(
    parameter ADDR_WIDTH = 32,
    parameter DATA_WIDTH = 32,
    parameter PE_N       = 4,    // 脉动阵列大小
    parameter L0A_DEPTH  = 64,
    parameter L0B_DEPTH  = 4,
    parameter L0C_DEPTH  = 64,
    localparam L0A_AW = $clog2(L0A_DEPTH),
    localparam L0B_AW = $clog2(L0B_DEPTH),
    localparam L0C_AW = $clog2(L0C_DEPTH),
    localparam M_MAX  = (L0A_DEPTH < L0C_DEPTH) ? L0A_DEPTH : L0C_DEPTH,
    localparam MM1_W  = $clog2(M_MAX)    // 行数 M-1 位宽
) (
    input  wire [ADDR_WIDTH-1:0] wb_adr_i,
    input  wire [DATA_WIDTH-1:0] wb_dat_i,
    output wire [DATA_WIDTH-1:0] wb_dat_o,
    input  wire                  wb_we_i,
    input  wire [DATA_WIDTH/8-1:0] wb_sel_i,
    input  wire                  wb_stb_i,
    output wire                  wb_ack_o,
    input  wire                  wb_cyc_i,
    output wire                  wb_err_o,
    output wire                  wb_rty_o,

    input  wire                  clk_i,
    input  wire                  rst_i
);

顶层参数

参数 默认值 说明
ADDR_WIDTH 32 Wishbone 地址位宽
DATA_WIDTH 32 Wishbone 数据位宽
PE_N 4 脉动阵列大小
L0A_DEPTH 64 L0A 深度(字)
L0B_DEPTH 4 L0B 深度(字)
L0C_DEPTH 64 L0C 深度(字)

PE_N 与 npu_wb 的假设

npu_core 和 systolic_array 是参数化的,但 npu_wb 中 L0A/L0B 的写入逻辑按“一次 32 位写入一行”实现,隐含假设 PE_N=4。如果把阵列扩大到 PE_N=8 或更大,需要修改 npu_wb 的写入逻辑(例如把一行拆成多次 32 位写),否则 A/B 的一行会写不全。

顶层例化

npu_top 内部例化 npu_wb 与 npu_core,并把两者的指令、L0 访问和状态信号连接起来。核心连接如下(完整代码见 cod-npu 仓库根目录下的 rtl/sv/npu_top.sv):

npu_wb #(...) u_npu_wb (
    .clk_i (clk_i),
    .rst_i (rst_i),
    .wb_adr_i(wb_adr_i), .wb_dat_i(wb_dat_i), .wb_dat_o(wb_dat_o),
    .wb_we_i (wb_we_i),  .wb_sel_i(wb_sel_i), .wb_stb_i(wb_stb_i),
    .wb_ack_o(wb_ack_o), .wb_cyc_i(wb_cyc_i), .wb_err_o(wb_err_o), .wb_rty_o(wb_rty_o),
    .ldtile_valid(ldtile_valid), .ldtile_base(ldtile_base),
    .tmul_valid(tmul_valid), .tmul_a_base(tmul_a_base), .tmul_m_m1(tmul_m_m1),
    .tmul_c_base(tmul_c_base), .tmul_acc(tmul_acc),
    .core_busy(core_busy), .core_done(core_done), .core_cr_ready(core_cr_ready),
    .aw_valid(aw_valid), .aw_addr(aw_addr), .aw_data(aw_data),
    .bw_valid(bw_valid), .bw_addr(bw_addr), .bw_data(bw_data),
    .cr_valid(cr_valid), .cr_addr(cr_addr), .cr_data(cr_data)
);

npu_core #(...) u_npu_core (
    .clk(clk_i), .rst(rst_i),
    .ldtile_valid(ldtile_valid), .ldtile_base(ldtile_base),
    .tmul_valid(tmul_valid), .tmul_a_base(tmul_a_base), .tmul_m_m1(tmul_m_m1),
    .tmul_c_base(tmul_c_base), .tmul_acc(tmul_acc),
    .cmd_ready(), .busy(core_busy), .done(core_done), .cr_ready(core_cr_ready),
    .aw_valid(aw_valid), .aw_addr(aw_addr), .aw_data(aw_data),
    .bw_valid(bw_valid), .bw_addr(bw_addr), .bw_data(bw_data),
    .cr_valid(cr_valid), .cr_addr(cr_addr), .cr_data(cr_data)
);

复位极性

npu_top 的复位 rst_i 是高有效。如果你的 SoC 使用低有效复位,请在例化时取反。

至此,NPU 的硬件部分就完成了。接下来需要在 CPU 上运行软件来操作它,见 NPU 软件实现。

作者:Jiajie Chen