手头有一块FPGA开发板,又想折腾点比流水线CPU更有挑战的硬件设计,那NEMA_p这个项目绝对值得你花上几个月好好钻研。它是一份用Verilog/SystemVerilog从零实现GPU核心的开源RTL工程,目标不是做一个能跑图形学算法的渲染器,而是把NVIDIA风格的SIMT执行模型完整落到可综合的硬件里。我在这个项目上前后折腾了小半年,从指令集梳理到最终在仿真环境里跑通并行归约程序,踩坑无数,也把GPU底层的门道摸了个七七八八。这篇文章把我自己的理解、拆解思路和实操记录完整写出来,给同样想啃这块硬骨头的朋友一份参考。
1. 项目到底在做什么:为什么有人要在RTL里造GPU
1.1 这不是CPU,是SIMT执行模型
很多人第一次看到NEMA_p的代码仓库时会愣一下:为什么指令集看起来像汇编,但硬件结构跟常规的RISC-V流水线完全不同?原因在于它的执行模型不是单指令流单数据流,而是单指令多线程,也就是SIMT。这个模型的核心思想是一条指令同时驱动多个线程,每个线程有自己的寄存器和私有状态,但取指和调度是统一进行的。
对比一下你可能更直观:普通CPU里面一个PC寄存器、一组通用寄存器,一条指令操作一份数据。SIMT GPU里面,32个线程组成一个warp,一个warp共享同一个PC,硬件上每次取出的一条指令会广播给这32个线程,让它们各自用自己寄存器里的数据执行。这就是所谓的"锁步"执行。NEMA_p顺了这个思路,把一条指令的宽度设计成包含线程掩码,硬件在发射指令时带上当前活跃线程的掩码,只有掩码为1的线程才真正写回结果。
这种架构带来最直接的挑战就是:硬件里不能只有一个寄存器堆,得分32个bank;写回的时候不能用同一个写端口,得做逐线程的写使能控制;分支指令执行时也不是简单跳转,得考虑哪些线程走true路径、哪些走false路径,并且最终要让所有线程重新收敛。这些细节在NAMA_p的RTL代码里都有对应模块,后面我会展开讲。
1.2 复刻NVIDIA架构的切入点在哪
NEМА_p这个项目有意思的地方在于,它不是凭空发明一套新架构,而是尽量仿照真实GPU的行为来设计。具体来说,它的架构里能看到这些熟悉的概念:
- 类似CUDA的kernel执行模型:主机端把一段代码编译成GPU指令,然后以kernel的形式加载到设备端执行。NEMA_p在接口上支持把编译后的指令镜像载入显存并启动执行。
- 类似PTX的虚拟指令集:指令类型包括整数运算、逻辑操作、比较分支、全局内存读写、共享内存访问等,跟实际GPU的ISA分类很接近。
- 类似流多处理器的核心结构:整个RTL顶层是一个大的SM核心,内部包含warp调度器、执行流水线、寄存器堆、共享内存以及访存接口。
我觉得它选择这些切入点的原因非常实际:如果只做单个ALU的运算器,那跟CPU没什么区别;如果要做完整的GPU集群、多SM互联、缓存一致性,那工程量和验证复杂度根本不是个人能扛住的。NEMA_p的定位刚好卡在中间——用一个SM加上必要的访存通路,把SIMT的执行本质讲清楚。这个取舍非常适合学习者,你理解了这一个SM,后面再去看真实GPU的多SM架构,很多东西就是按比例放大而已。
1.3 为什么选Verilog/SystemVerilog而不是HLS
关于实现语言,我个人的看法是:SystemVerilog是这个项目最合理的选择。虽然现在用Vivado HLS或者更高级的Chisel也能做硬件设计,但对于这种核心逻辑需要精确控制时序和资源的项目,RTL级别的SystemVerilog优势很明显。
SystemVerilog相比老式Verilog多了几个特别香的功能:interface和modport让模块间连接变得清晰;always_ff和always_comb让时序逻辑和组合逻辑一眼就能区分;enum、struct、package让状态机和数据通路可读性大幅提升。NEMA_p的代码正是充分利用了这些特性。我自己在复现过程中也体会到,如果一个模块做warp调度、一个模块做译码、一个模块做SIMT栈,用传统Verilog的端口列表会非常痛苦,而用interface定义好握手协议之后,整个顶层看起来就像一张清晰的连接图。
另外还要泼一盆冷水:这项目不适合用纯HLS来做。HLS适合做计算密集型的流水线,但GPU核心里有大量不规则的控制逻辑,比如分支收敛栈、线程调度仲裁、多bank寄存器堆的冲突处理,这些用HLS描述起来反而更费劲,而且综合出的时序往往不如手写RTL可控。
2. 架构拆解:指令集、Warp调度与五级流水线
2.1 指令集长什么样
拿到NEMA_p的RTL代码后,我第一件事是找它的指令集文档。这份指令集的设计很克制,没有一上来就搞浮点,而是把整数和访存指令做扎实。基本可以分成四类。
第一类是ALU运算指令,包括整数加减、按位逻辑、移位、比较。每条指令通常采用三操作数格式,目标寄存器、源寄存器1、源寄存器2或者立即数。第二类是访存指令,支持从全局内存加载到寄存器、从寄存器写回全局内存、在共享内存和寄存器之间搬数据。第三类是控制流指令,包括无条件跳转、条件分支以及与分支收敛配套的特殊指令。第四类是同步指令,用于线程间的执行屏障,这个在并行归约类算法里至关重要。
指令编码上让我印象最深的是它的显式线程掩码设计。一条分支指令在译码阶段之后,硬件会把掩码更新进SIMT栈,保证执行流正确汇聚。这种小小的设计改动,把SIMT的控制流语义向前推了一大步。
2.2 Warp调度器:让多个warp共用一条流水线
NEMA_p内部支持多个warp,而不是只做一个warp的玩具。这意味着硬件上得有多个PC、多个寄存器堆上下文、多组活动掩码。warp调度器的作用,就是每个周期从这些warp里选一个,把它的下一条指令送到流水线里去执行。
调度策略用的是最简单的轮转方式。每个周期计数器轮一遍,选中某个warp后检查它的状态:如果有正在等待的内存请求,或者有指令在流水线里占用资源没返回,就跳过。这种方式实现简单,配合上scoreboard机制,就能避免乱序执行带来的复杂依赖追踪。所以这个调度器的RTL代码其实不复杂,核心是一个状态寄存器和一组ready信号仲裁逻辑。
我当时一开始不理解为什么不用优先级调度,后来测试了不同负载才明白:对于这种小规模核心,轮转调度已经能保证足够的吞吐,而且逻辑简单不容易出时序问题。如果真按优先级做,某些warp可能被饿死,反而需要更复杂的公平性策略。
2.3 五级流水线各阶段都在干什么
NEMA_p的处理器核心采用经典的五级流水线结构:取指、译码、执行、访存、写回,但这个五级流水线在SIMT语境下跟CPU有显著区别。
取指阶段每周期从指令缓存里取出一条128位左右的长指令,包含操作码、目标寄存器索引、两个源寄存器索引和立即数,以及预留给掩码控制的数据位。译码阶段做的事比CPU多一点,它要解析出线程掩码、识别操作数来自寄存器堆还是立即数、判断访存指令的目标地址空间。执行阶段处理ALU运算,同时计算访存的地址。访存阶段真正访问存储层次,可能是共享内存也可能是全局内存。写回阶段把结果写回各个线程的寄存器。
这里要特别注意寄存器堆的读写问题。32个线程同时执行一条指令,译码阶段需要同时读出64份源操作数,写回阶段又要保证32份结果能正确落到各自的物理寄存器。实际操作中,寄存器堆有多个读端口,通过线程索引选择一个bank读取对应数据。NEMA_p在黄金时间上做了简化——没有做复杂的bank冲突检测,而是让每个线程的寄存器索引和线程号共同决定物理bank,避免很多冲突场景。
3. 关键RTL模块的实现细节与心得
3.1 顶层接口定义:interface带来的整洁性
我在仿写顶层模块时,最先感受到SystemVerilog interface的威力。比如warp调度器和译码器之间的接口,如果按传统方式声明,端口列表会有十几个信号:请求有效、warp编号、指令数据、指令地址、ready信号等。但用interface封装之后,主模块的连接代码可以大大精简。
interface decode_if import types_pkg::*; logic valid; logic [WARP_ID_WIDTH-1:0] warp_id; inst_t inst; logic ready; modport decoder(input valid, input inst, output ready, input warp_id); modport fetcher(output valid, output inst, output warp_id, input ready); endinterface这个interface配合modport,让两个模块之间的角色非常清晰。取指侧是发起方,要输出valid和指令数据;译码侧是接收方,执行完当前指令后拉高ready。我在调仿真时发现一个很大的好处——把interface作为一个整体加进波形图,一眼就能看到一整组握手信号的时序关系,比一个一个信号去对要高效得多。
3.2 译码器与操作数收集
译码模块核心是一个大组合逻辑:根据opcode和func字段确定执行单元的微操作。这里有一个很值得借鉴的设计:译码结果不是单一的control signal,而是一个译码后的数据结构体。
always_comb begin decoded_ctrl = '0; unique case (instr.opcode) OP_IADD: begin decoded_ctrl.alu_op = ALU_ADD; decoded_ctrl.wb_en = 1'b1; decoded_ctrl.reg_dst = instr.rd; decoded_ctrl.src1_sel = SEL_REG; decoded_ctrl.src2_sel = instr.use_imm ? SEL_IMM : SEL_REG; end OP_LOAD: begin decoded_ctrl.mem_op = MEM_LOAD_GLOBAL; decoded_ctrl.wb_en = 1'b1; decoded_ctrl.dst_reg = instr.rd; decoded_ctrl.addr_sel = SEL_REG; end default: decoded_ctrl.illegal = 1'b1; endcase end这里有个细节容易踩坑:组合逻辑里如果没有default分支,很容易综合出锁存器。SystemVerilog检查起来比Verilog严格,我最初写完仿真没报错,但综合时报了warning,正是因为这个。所以写always_comb的时候务必给每个输出都赋默认值,最好在case之前加一句decoded_ctrl = '0。
操作数收集阶段最需要注意的是立即数的符号扩展。NEMA_p的指令里某些立即数是负偏移量,如果按无符号方式扩展,算出来的地址就是错的。我调试时发现一段内存越界访问,查了好久才发现是符号扩展位少写了一位。这里强烈建议在testbench里专门加一组边界测试,验证立即数的最大正值和最小负值都能正确解析。
3.3 SIMT栈:分支收敛的关键
分支收敛是SIMT架构里最容易绕晕的部分。NVIDIA GPU真实的做法是维护一个内部栈,每条分支指令把当前掩码压栈,然后在分支目标处弹出并做掩码合成。NEMA_p用RTL显式实现了这个栈结构。
栈的每个条目记录三样东西:当前参与执行的线程掩码、分支目标地址、以及下一条要执行的地址。执行分支指令时,当前掩码中被选中走true路径的线程进入一个掩码集合,走false路径的进入另一个集合。硬件把其中一个集合的掩码和地址压栈,先执行另一个;执行完后从栈弹出继续。这个过程在软件里看是递归的,在硬件里用栈正好契合LIFO的特性。
实现SIMT栈时我用了一个固定深度的SRAM块来当栈存储,再配一个栈指针寄存器。这里有一个教训:如果栈深度设置不当,遇到深度嵌套的分支就会出现溢出,我甚至为了观察这个现象故意写过8层嵌套的if-else来压测。在验证阶段,这种边界条件测试比功能测试更容易暴露RTL缺陷,建议有兴趣复现的朋友把测试用例往多层分支上堆。
3.4 共享内存与存储层次设计
共享内存是整个GPU性能的关键之一,因为同一个block里的线程需要频繁交换数据。NEMA_p实现了类似真实GPU的shared memory,访问Unit在流水线的访存阶段。
共享内存的地址空间是独立编址的。访存指令里通过地址位来区分是访问shared还是global:如果最高位为1则命中shared,否则走全局内存接口。实现上这只是一个简单的地址译码逻辑,但它的意义在于明确了存储层次的含义——shared memory是每个block私有的,延迟低、不需要经过远端的DRAM接口。我在后仿真阶段测试过用shared memory做局部累加,和直接读写global memory相比,访存等待周期数降了一半以上。
我这块最想提醒的是缓存一致性和同步问题。多个warp同时访问shared memory的同一地址时,如果没有原子操作保护,结果可能不符合预期。NEMA_p在访存接口上支持原子操作信号,但使用时要非常小心,仿真环境下还好,一旦上了FPGA的时序约束,原子操作逻辑往往是最容易产生频率瓶颈的地方。
4. 仿真、调试与验证:实测下来的完整技术路线
4.1 测试平台搭建与最小启动流程
我搭建验证环境的思路是分两层:第一层是模块级定向测试,关注单个模块的功能;第二层是核心级集成测试,把整个流水线、调度器和存储连起来跑。
最小启动流程比较有代表性:先把指令镜像放进一个模拟的指令ROM里,然后释放复位,warp调度器开始逐周期取指,访存模块模拟从"显存"里读写数据。整个系统的终点是kernel执行完毕信号。第一次跑通这个流程时,那种感觉就像看着一台微型GPU真正跑起来。
Testbench里我坚持用接口来连接待测核心和存储模型,这样可以很方便地在不同的存储测试模型之间切换。比如先用一个简单的数组模拟全局内存验证功能,再用一个带延迟的模型验证scoreboard和等待逻辑。几个模型之间的替换只需要改顶层实例化,不用改动内部逻辑,SystemVerilog的灵活在这里体现得淋漓尽致。
4.2 用波形定位bug的几个实用技巧
调波形是RTL调试的日常,但GPU这种多线程并发结构让波形图极其庞大。我有几个常用技巧想分享。
第一,分组信号。把所有warp各自的PC、掩码和寄存器堆写使能信号编成一个group,在波形工具里折叠起来,重点只看顶层握手信号和流水线valid信号,出现异常时再逐层展开。第二,用断言定位。SystemVerilog断言在这里价值巨大,我对总线协议写了简单的assert来检查读数据返回时是否有对应的请求有效信号。一旦触发断言,波形会自动停在出错周期,不需要人工逐帧找。第三,善用$display加$time。在关键状态机里打印时间戳和状态转换,比起扑在波形里直观很多。
4.3 三个高频Bug:流水线冒险、死锁、时序违规
这几个坑我猜每个做这项目的人都会遇到,我把具体症状和处理思路记录出来。
首先是最典型的load-use冒险。访存指令需要多个周期返回数据,而紧随其后的ALU指令可能马上要用这条加载结果。如果调度器不做处理,取到的就是旧值。NEMA_p的处理方式是保留一个scoreboard表项,检测到目标寄存器还在等待写回时,就暂停这个warp的发射。我一开始以为这个逻辑只在访存时触发,后来发现共享内存的读取也有延迟,同样需要处理。
其次是死锁问题。当warp在等待一个永远不会回来的内存响应时,调度器会一直跳过它,其他warp也可能因为共享资源冲突卡住。这类问题的根因多半是仲裁逻辑不完善。我的排查思路是:让仿真加长运行时间,打印每个warp的PC和状态,找出哪个warp一直没进展。如果能定位到某个等待信号一直无效,就顺着这个信号的驱动往前查。
最后是时序违规。在FPGA上布局布线后,如果时钟频率设得太高,访存路径经常成为最差路径。解决方法通常是降频,或者给流水线中间加一拍转发寄存器。就我实测的经验,NEMA_p这类设计在100MHz以内跑是稳的,但往200MHz冲就需要很多时序优化手活了。
5. 我踩坑换来的几点实操体会
做NEMA_p的这几个月,我最大的感受是:GPU架构的核心秘密其实不在浮点运算单元有多快,而在调度和控制逻辑有多精密。SIMT模型看起来只是"一条指令多个线程",但真正落到RTL里,每个线程的掩码维护、栈收敛、寄存器堆多端口读写都是绕不开的硬骨头。
如果你也想从零动手复现这个项目,我建议按这个顺序推进:先啃透指令集定义和warp调度的顶层握手,再实现单warp的单条指令通路,确认能跑通最简单的加法和跳转用例;然后扩展多warp轮转调度和访存等待逻辑;最后才考虑分支收敛和共享内存优化。这个顺序能让你在每一步都有清晰的可验证目标,不至于一上来就被复杂的并发交互劝退。
工具链方面,仿真首选商业工具的学习版或者开源的仿真工具都可以。综合上FPGA的话,建议从低时钟频率起步,先把功能验证扎实,再慢慢优化时序。如果你手头有开发板,强烈建议把核心跑在板子上看看实际资源占用,那种把抽象架构烧进真实硬件、用逻辑分析仪观测warp调度信号的感觉,跟仿真完全是两码事。
另一点小建议:试着给自己设计一个稍微复杂一点的并行算法,比如向量点积或者直方图统计,把它编译成NEMA_p的指令并在RTL仿真里跑通。这远比跑几个单指令用例有成就感,也才能真正体会到shared memory的性能价值和同步指令的必要性。
我自己的下一步计划是在这个核心外面再接一个简单的总线接口,把多个SM实例化出来看看多核扩展会遇到什么问题。这个方向应该还能玩很久,也希望有更多人入坑一起聊RTL GPU设计的各种细节。