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、。
为什么
算法不清,代码必错。 后面的所有模块划分都来自这张图。
第 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 的最大分数 ;
- 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 的测试流程:
- 从文件读序列;
- 写入 BRAM;
- 启动;
- 等待
stop; - 收集
tb_pointer; - 和软件结果比对。
第 13 步:优化和迭代
做什么
- 加流水线,提高频率;
- 优化 SRAM 访问,减少冲突;
- 调整 NUM_PE、MAX_TILE_SIZE;
- 加 X-Drop 剪枝,减少无效计算。
产出
- 满足面积、功耗、频率、吞吐要求的设计。
二、每一步的产出和验证方法
| 步骤 | 产出 | 验证方法 |
|---|---|---|
| 0 | 数据流图 | 人工检查 |
| 1 | 参数表 | 面积估算 |
| 2 | 顶层端口 | 接口检查 |
| 3 | PE 模块 | 单元测试 |
| 4 | PE 阵列 | 波形检查 |
| 5 | BRAM + 序列输入 | 仿真读序列 |
| 6 | 归约树 | 单元测试 |
| 7 | 状态机 | 波形检查 |
| 8 | tb_bram | 写读一致性 |
| 9 | 收敛检测 | 手动构造收敛 |
| 10 | traceback | 比对软件结果 |
| 11 | block 切换 | 多 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 的某一行。