第四章 设计指令集架构(ISA):定义我们的微型GPU指令
好,到了真正动刀子的环节了。
前面几章我们聊了GPU的宏观架构,也画了数据流图。但说句实话,那些都还是「纸上谈兵」。真正让硬件动起来的,是指令。没有指令集,GPU就是一堆不会动的门电路。
这一章,我们就来定义自己的微型GPU指令集。我管它叫 MG-ISA(Mini GPU Instruction Set Architecture)。
4.1 指令集设计原则:少即是多
设计指令集,有点像做菜。调料太多,反而坏了味道。
我见过一些新手,恨不得把所有功能都塞进指令里。结果呢?解码器变得巨复杂,频率上不去,功耗还高。我在项目中吃过这个亏——有一次为了支持一个「看起来很美」的复杂寻址模式,硬生生把流水线的时序搞崩了,最后不得不砍掉重做。
所以,我们的MG-ISA遵循几个简单原则:
- 精简:指令数量控制在16条以内。够用就行。
- 定长编码:所有指令长度固定为32位。这样取指、解码都简单。
- 显式操作数:不搞隐式寄存器,每个操作数都明明白白写在指令里。
- 面向SIMD:天然支持向量操作,一条指令处理多个数据。
4.2 操作码与操作数设计
我们的指令由三部分组成:操作码、目标操作数、源操作数。
操作码占8位,理论上支持256条指令。但我们只用了16条,剩下的留着以后扩展。
4.2.1 操作数类型
说白了,操作数就是数据从哪里来、到哪里去。我们支持以下几种:
- 标量寄存器(R0-R15):16个32位通用寄存器,存单个值。
- 向量寄存器(V0-V15):16个向量寄存器,每个能存4个32位数据(SIMD宽度为4)。
- 立即数:直接写在指令里的常数,8位或16位。
- 内存地址:通过基址+偏移的方式访问显存。
嗯,这里要注意:向量寄存器和标量寄存器是分开的。为什么?因为向量寄存器需要更多的读端口,混在一起会让寄存器堆变得又大又慢。我见过有人把两者合并,结果综合出来的面积直接翻倍。
4.2.2 核心指令列表
我把指令分成了四类:数据搬运、算术运算、逻辑运算、控制流。下面这个表列出了我们最常用的指令:
| 类别 | 指令 | 操作码 | 功能描述 |
|---|---|---|---|
| 数据搬运 | MOV | 0x01 | 寄存器间数据移动,或加载立即数 |
| 数据搬运 | LOAD | 0x02 | 从显存加载数据到向量寄存器 |
| 数据搬运 | STORE | 0x03 | 从向量寄存器存储数据到显存 |
| 算术运算 | ADD | 0x10 | 向量加法(逐元素) |
| 算术运算 | MUL | 0x11 | 向量乘法(逐元素) |
| 算术运算 | MAD | 0x12 | 乘加运算:dst = src1 * src2 + src3 |
| 逻辑运算 | AND | 0x20 | 按位与 |
| 逻辑运算 | CMP | 0x21 | 比较运算,结果写入标志寄存器 |
| 控制流 | JMP | 0x30 | 无条件跳转 |
| 控制流 | JEQ | 0x31 | 相等时跳转 |
重点: 注意看MAD指令。这是GPU的看家本领。一条MAD = 一次乘法 + 一次加法,而且不损失精度。在图形渲染中,矩阵乘法、颜色混合全靠它。我们的模拟器里,MAD指令会直接映射到硬件上的乘加单元。
4.3 指令编码格式
好了,指令有了,操作数也定了。接下来就是怎么把它们塞进32位里。
我设计了两套编码格式:RR型(寄存器-寄存器)和 RI型(寄存器-立即数)。
4.3.1 RR型格式(寄存器操作)
这种格式用于大多数算术和逻辑指令。32位分配如下:
31 24 | 23 20 | 19 16 | 15 12 | 11 0
+-----------+----------+----------+----------+-----------+
| 操作码 | 目标寄存器 | 源寄存器1 | 源寄存器2 | 保留/标志 |
| (8位) | (4位) | (4位) | (4位) | (12位) |
+-----------+----------+----------+----------+-----------+
举个例子:ADD V0, V1, V2 这条指令,编码后就是:
0x10 | 0x0 | 0x1 | 0x2 | 0x000
= 0001 0000 | 0000 | 0001 | 0010 | 0000 0000 0000
= 0x10012000
你想想看,解码器看到这个数,直接拆开就行。不需要查表,不需要复杂的状态机。这就是定长编码的好处。
4.3.2 RI型格式(立即数操作)
当我们需要把一个常数直接喂给指令时,用这个格式:
31 24 | 23 20 | 19 16 | 15 0
+-----------+----------+----------+---------------+
| 操作码 | 目标寄存器 | 源寄存器 | 立即数 |
| (8位) | (4位) | (4位) | (16位) |
+-----------+----------+----------+---------------+
比如 MOV R0, #42 就是把42这个数加载到R0寄存器:
0x01 | 0x0 | 0x0 | 0x002A
= 0x0100002A
4.4 一个完整的指令示例
光说不练假把式。我们来看一个真实的渲染场景。
假设我们要计算两个向量的点积,然后加上一个偏移量。用我们的MG-ISA写出来就是:
; 假设 V0 存向量A,V1 存向量B,R0 存偏移量
LOAD V0, [addr_A] ; 从显存加载向量A
LOAD V1, [addr_B] ; 从显存加载向量B
MUL V2, V0, V1 ; 逐元素乘法,结果存V2
; 这里需要把V2的四个分量加起来,但我们没有专门的归约指令
; 所以用标量寄存器手动累加
MOV R1, V2[0] ; 取第一个分量
MOV R2, V2[1] ; 取第二个分量
ADD R1, R1, R2 ; 累加
MOV R2, V2[2] ; 取第三个分量
ADD R1, R1, R2 ; 累加
MOV R2, V2[3] ; 取第四个分量
ADD R1, R1, R2 ; 累加完成,R1 = 点积结果
ADD R1, R1, R0 ; 加上偏移量
STORE [addr_out], R1 ; 存回显存
这段代码虽然看起来啰嗦,但每个指令都对应硬件里一个确定的操作。没有歧义,没有隐式行为。这就是ISA的意义——它是软件和硬件之间的契约。
4.5 关于指令扩展的思考
我们的MG-ISA目前只有16条指令。但说实话,对于一个教学用的模拟器,这已经够了。
如果你以后想加功能,比如纹理采样指令(TEX)、分支预测指令(BPRED),操作码空间是够的。但要注意:每加一条指令,解码器就要多一个判断分支,流水线就要多一份风险。
我记得有一次,为了支持一个「看起来很酷」的位反转指令,结果导致整个解码器的关键路径增加了0.3ns。最后权衡了一下,还是砍掉了。因为实际着色器代码里,几乎没人用这个指令。
好了,这一章的内容就到这。下一章,我们会把这些指令真正跑起来——开始搭建我们的模拟器核心执行引擎。