Skip to main content

Design Guide

从零写出 TALCO-XDrop 的 SystemVerilog:一步步的方法论

下面是一个可复现的工程流程。每一步都告诉你:先做什么、为什么这么做、产出什么、怎么验证。


第 0 步:读懂算法,写出"纸面版"数据流​

做什么​

把论文 Algorithm 1 和 Section IV 翻译成一张数据流图,不要写代码。

输入:序列 R, Q
│
▼
┌──────────────────────────────────────────────┐
│ 外层循环:每个 tile │
│ ┌────────────────────────────────────────┐ │
│ │ 内层循环:每个 frontier │ │
│ │ Phase 1: 算 H/I/D, 存 traceback 指针 │ │
│ │ 到达 marker │ │
│ │ Phase 2: 传播 convergence pointer │ │
│ │ 收敛 → 确定 C_tb │ │
│ └────────────────────────────────────────┘ │
│ Traceback 当前 tile │
│ 下一个 tile 用 marker 分数初始化 │
└──────────────────────────────────────────────┘

产出​

  • 一张图,标注所有数据依赖;
  • 一张表,列出所有状态变量:H、I、D、traceback pointer、convergence pointer、marker、Hmax⁡H_{\max}。

为什么​

算法不清,代码必错。 后面的所有模块划分都来自这张图。


第 1 步:确定硬件架构参数​

做什么​

回答这些问题:

问题答案来源例子
PE 阵列多宽?带宽和面积权衡NUM_PE = 16
分数位宽?分数范围PE_WIDTH = 16
tile 多大?片上内存容量MAX_TILE_SIZE = 512
支持多少 block?带宽动态性NUM_BLOCK = 1
traceback BRAM 多大?最长回溯路径TB_ADDR_WIDTH = 14

产出​

一个参数表,对应模块的 parameter 列表。

为什么​

参数决定接口和内存大小。 先定参数,再写端口。


第 2 步:定义顶层接口​

做什么​

写出 TALCO_XDrop 的端口列表,不写内部逻辑。

module TALCO_XDrop #(...) (
input clk, rst, start,
input [14*PE_WIDTH-1:0] in_param,
input [REF_LEN_WIDTH-1:0] total_ref_length,
input [QUERY_LEN_WIDTH-1:0] total_query_length,
// 序列写入端口
input ref_wr_en, ...
// 输出
output [1:0] tb_pointer, ...
);

产出​

  • 顶层端口列表;
  • 一张模块框图:PE_Array、ref_bram、query_bram、tb_bram。

为什么​

接口是合约。 定了接口,内部实现可以自由替换。


第 3 步:先写最小的计算单元 —— PE​

做什么​

不要一开始就写整个阵列。先写一个 PE。

3.1 写出 PE 的端口​

module PE #(...) (
input clk, rst, init_in, set_param,
input [2:0] ref_in,
input [PE_WIDTH-1:0] sub_A_in, ...,
input signed [PE_WIDTH-1:0] H_PE_prev, I_PE_prev,
output signed [PE_WIDTH-1:0] H_PE, I_PE, D_PE,
output [3:0] dir_out,
...
);

3.2 先写组合逻辑:score_calculation​

// I (i,j)
HV_score = H_PE_prev + gap_open;
IV_score = I_PE_prev + gap_extend;
I_score = max(HV_score, IV_score);

// D (i,j)
HH_score = H_prev + gap_open;
DH_score = D_prev + gap_extend;
D_score = max(HH_score, DH_score);

// H (i,j)
HD_score = H_PE_prev2 + match_reward;
H_score = max(HD_score, D_score, I_score);

3.3 再写 traceback pointer​

dir[1:0] = DIAG;
if (D_score > H_score) { H_score = D_score; dir[1:0] = HOR; }
if (I_score > H_score) { H_score = I_score; dir[1:0] = VER; }
dir[2] = D_flag;
dir[3] = I_flag;

3.4 再写 convergence logic​

casex (dir)
4'b??00 : CH_score = CH_score_reg2;
4'b?001 : CH_score = CH_score_reg;
...
endcase

3.5 最后写寄存器更新​

always_ff @(posedge clk) begin
if (set_param) begin ... end
else if (param_valid) begin ... end
end

产出​

一个可以独立仿真的 PE 模块。

为什么​

PE 是整个设计的原子。 PE 对了,阵列才有可能对。

怎么验证​

写一个 testbench,手动给 PE 喂输入,检查 H/I/D 和 dir 是否符合手算结果。


第 4 步:把 PE 连成阵列 —— PE_Array(只做计算)​

做什么​

先用 generate for 实例化 NUM_PE 个 PE,只连数据通路,不加控制逻辑。

generate
for (i = 1; i < NUM_PE; i = i + 1) begin: systolic_array_connections
assign ref_in[i] = ref_out[i-1];
assign H_PE_prev[i] = H_PE[i-1];
assign I_PE_prev[i] = I_PE[i-1];
...
end
endgenerate

产出​

  • PE 阵列可以流水线工作;
  • 能算出一个 wavefront 的 H/I/D 和 dir。

为什么​

先通数据,再加控制。 数据通路对了,控制只是加时序。

怎么验证​

写 testbench,手动驱动 ref 流和 query 流,观察 PE 阵列输出。


第 5 步:加 BRAM 和序列输入​

做什么​

实例化 ref_bram、query_bram,加 ascii2nt 和 nt2param。

ascii2nt ref_ascii2nt (.ascii(ref_bram_data_out), .nt(ref_nt));
ascii2nt query_ascii2nt (.ascii(query_bram_data_out), .nt(query_nt));
nt2param query_nt2param (.nt(query_nt), .in_param(in_param), .out_param(out_param));

产出​

  • 序列能从 BRAM 读出,转成 nt;
  • 替换矩阵参数能按 query 选行。

为什么​

序列和参数是计算的输入。 没有它们,PE 阵列空转。


第 6 步:加归约树​

做什么​

写 reduction_tree_max 和 reduction_tree_value,实例化到 PE_Array。

reduction_tree_max rd_tree_max (.array(H_PE), .reduction_value(rd_value), ...);
reduction_tree_value rd_tree_M (.array(CH_PE), .reduction_value(H_rd_value), ...);
reduction_tree_value rd_tree_I (.array(CI_PE), ...);
reduction_tree_value rd_tree_D (.array(CD_PE), ...);

产出​

  • 每个 wavefront 的最大分数 Hmax⁡H_{\max};
  • H/I/D 收敛指针是否全部相同。

为什么​

归约树是收敛检测的核心。 没有它,Phase 2 无法终止。

怎么验证​

单独仿真 reduction tree,喂已知数组,检查输出。


第 7 步:加状态机(控制流)​

做什么​

写 STATE 枚举和状态转移逻辑。

enum {WAIT, READ_PARAM, SET_PARAM, STREAM_REF_START, STREAM_REF, ...} STATE;

always_ff @(posedge clk) begin
case (STATE)
WAIT: if (start) STATE <= READ_PARAM;
READ_PARAM: STATE <= SET_PARAM;
...
endcase
end

产出​

  • 状态机能驱动整个 tile 的流程;
  • 每个状态对应论文 Algorithm 1 的一行。

为什么​

状态机是时序的骨架。 它决定"什么时候做什么"。


第 8 步:加 traceback BRAM 和写控制​

做什么​

实例化 tb_bram,加写地址和写数据逻辑。

assign tb_bram_wr_en_next = init_out[0] && (was_stream_ref || (STATE==STREAM_REF)) && (!past_marking);
assign tb_bram_data_in = dir_out;
always_ff @(posedge clk) begin
if (tb_bram_wr_en_next) tb_bram_addr <= tb_bram_addr + 1;
end

产出​

  • Phase 1 的 traceback 指针能写入 BRAM;
  • Phase 2 不再写。

为什么​

Phase 1 的产出必须存下来,否则 traceback 没有原材料。


第 9 步:加收敛检测和 Phase 2 控制​

做什么​

加 past_marking、convergence_detected、C1–C5。

always @(posedge clk) past_marking <= curr_ad_idx > marker + 1;

assign H_converged = H_converged_this_block && ... ;
assign this_ad_converged = H_converged && ... ;
assign convergence_detected = C5 && (STATE==STREAM_REF) && ... ;

产出​

  • Phase 1 和 Phase 2 的自动切换;
  • 收敛时能自动终止当前 tile。

为什么​

这是 TALCO 区别于 GACT 的关键。 没有收敛检测,就退化成 GACT。


第 10 步:加 traceback 模块​

做什么​

写 traceback 模块,从 C_tb 出发,读 tb_bram,输出 tb_pointer。

enum {WAIT, INIT, TB, DONE} state;
assign tb_ptr_read = tb_bram_data_out[query_pe_idx*4+:4];
assign this_move = cur_tb_state == 0 ? h_move : cur_tb_state;

产出​

  • 能从收敛点回溯出当前 tile 的 partial alignment;
  • 输出 2-bit tb_pointer。

为什么​

traceback 是最终产出。 没有它,硬件只算了分数,没算比对。


第 11 步:加 block 切换和跨 block 衔接​

做什么​

加 lower_bound_check、lower_bound_value、diag_score、diag_CH_score。

STREAM_REF_CONTINUE: begin
block <= 1;
start_idx <= lower_bound_value;
...
end

产出​

  • 一个 tile 内多个 block 能正确衔接;
  • 分数和收敛指针不丢失。

为什么​

X-Drop 的带子是动态的。 一个 block 装不下,必须切分。


第 12 步:集成和联调​

做什么​

把所有模块在顶层连起来,跑端到端仿真。

TALCO_XDrop talco_xdrop_instance (
.clk(clk), .rst(rst), .start(start),
.in_param(in_param), ...
);

产出​

  • 一个完整的 TALCO-XDrop 加速器;
  • 能处理任意长序列。

为什么​

集成是最容易出错的地方。 接口不匹配、时序不对、握手错误,都在这一步暴露。

怎么验证​

用 tb.sv 的测试流程:

  1. 从文件读序列;
  2. 写入 BRAM;
  3. 启动;
  4. 等待 stop;
  5. 收集 tb_pointer;
  6. 和软件结果比对。

第 13 步:优化和迭代​

做什么​

  • 加流水线,提高频率;
  • 优化 SRAM 访问,减少冲突;
  • 调整 NUM_PE、MAX_TILE_SIZE;
  • 加 X-Drop 剪枝,减少无效计算。

产出​

  • 满足面积、功耗、频率、吞吐要求的设计。

二、每一步的产出和验证方法​

步骤产出验证方法
0数据流图人工检查
1参数表面积估算
2顶层端口接口检查
3PE 模块单元测试
4PE 阵列波形检查
5BRAM + 序列输入仿真读序列
6归约树单元测试
7状态机波形检查
8tb_bram写读一致性
9收敛检测手动构造收敛
10traceback比对软件结果
11block 切换多 block 测试
12顶层集成端到端测试
13优化综合报告

三、几个关键原则​

3.1 先组合,后时序​

先写 always_comb,确认逻辑对;再写 always_ff,加时序。

3.2 先数据,后控制​

先把数据通路连起来,能算;再加状态机,控制什么时候算。

3.3 先单模块,后集成​

每个模块独立仿真通过后,再集成。

3.4 先功能,后性能​

先保证功能正确,再优化频率、面积、功耗。

3.5 对着论文写注释​

每行代码对应 Algorithm 1 的哪一行、Figure 7 的哪个部分,写在注释里。


四、如果重来一遍,我会这样排期​

周任务
1读论文,画数据流图,定参数
2写 PE 模块 + 单元测试
3写 PE_Array(只算分) + 测试
4加 BRAM、ascii2nt、nt2param
5加归约树 + 收敛检测
6加状态机 + tb_bram
7加 traceback 模块
8加 block 切换 + 跨 block 衔接
9顶层集成 + 端到端测试
10优化 + 综合

五、一句话总结​

从算法到 RTL 的流程是:画数据流图 → 定参数 → 定接口 → 写最小计算单元(PE)→ 连成阵列 → 加内存和输入 → 加归约树 → 加状态机 → 加 traceback → 加 block 切换 → 集成 → 优化。每一步都独立验证,先组合后时序,先数据后控制,先功能后性能。对着论文写注释,让每行代码都能对应到 Algorithm 1 的某一行。