tags
摸鱼的笔记
课程
type
Post
status
Published
date
Mar 4, 2026
slug
micro
summary
category
摸鱼的笔记
password
icon

#0 Introduction

二十世纪六七十年代限制性能的主要因素是计算机的内存容量。
💡
一些核心思想:
加速经常性事件。经常性事件通常比罕见情形更简单、更容易加速。
通过预测提高性能。

#1 ISA

指令也是作为数据存储的(存储程序式结构),二者有统一的地址索引方式,但二者是否要混合存储,可以视具体设计而定。存在一起可以提升存储空间的利用率,分开存则可以提升读取的速度(不会互相干扰)

CISC & RISC

CISC(x86)在高性能运算(如PC,Server)占主导地位;而RISC(ARM和RISC-V)在边端设备(如手机,IoT)占主导。二者之间也有互相借鉴,如x86会把复杂指令拆分成简单指令进行pipeline,从而提升主频;RISC-V可以自由扩展,纳入更多复杂的指令
💡
nvidia的卡(H,A,B系列)基于ARM,而数据中心大多是x86,数据互联用PCIe(Intel的良好生态)。为了抢夺市场份额,nvidia推出NVlink和整机,其性能通常好于N卡+PCIe,但数据中心还是以买N卡居多。
RISC-V是load-store based,即操作的时候必须先把数据从内存提取到寄存器中,不能直接在内存中操作。与之相对地,x86允许直接对内存数据进行运算

RISC-V

指令固定为32bit。
以下讨论64位系统,即数据/地址是64bit
word=32bit;double word=64bit
注意RISC-V的地址以Byte为单位。即,如果要取下一个word,需要addr+4;取下一个double word,需要addr+8。
  • Registers
x0~x31(32个64bit寄存器) & PC(Program Counter,记录当前指令的地址) & 浮点数寄存器f0~f31。
x0是写死的“0”。就算出现在operands中也不会变,可以用来进行一些骚操作
x1(at)用来存储当前函数执行结束之后的返回地址。
寄存器分成两类:caller save 和 callee save。假设当前函数A调用了别的函数B,caller save意思是如果要保持该寄存器在B执行前后不变,那么需要A来负责保存(如先写到内存中);callee save的意思是A无需关心这个寄存器的值,B来保证调用前后它的值不会改变(B会进行压栈和弹栈操作)
在物理实现上,Regfile存的是32个64bit数据。有2个读取和1个写入口。
notion image
  • Instructions
notion image
opcode表示指令的“大分类”,funct表示指令的“小分类”
rs1/rs2代表从regfile中读取的地址
rd代表结果写入regfile的地址
immediate表示即时数
大体上保证rs/rd/opcode位置不变,其他的可能拆得比较散
💡
一般来说如果需要往指令里传入数据,不会直接把数据写在指令中(因为指令只有32位)。而会把数存在一个寄存器中,然后传入寄存器的地址(只要5位)。
但有些时候想往指令中直接传入一些比较小的数,多用于地址运算,这些数就是immediate。
  • R format
notion image
执行运算。把rs1和rs2的运算结果存入rd。
注意add之后紧跟的是rd。
  • I format
notion image
读取内存中的数写入rd。读取的内存地址=rs1中数据+immediate,immediate是偏置量。
“d”的意思是double word,即64位。如果是“lb”就是load byte,“lw”就是load word,在这些情况下,需要扩展高位使存入数据的值与预期一致。
  • S format
notion image
把寄存器rs2的值写入内存。写入的内存地址=rs1中数据+immediate,immediate是偏置量。
与I format基本同理,只是rd变成了rs2。
  • SB(Branch) format
notion image
notion image
beq rs1, rs2, L1为例,意为如果rs1==rs2,则跳转到L1。其中L1的地址为下一条指令地址(PC+4)再加上immediate的偏置。
电路实现上,正常情况下PC都需要+4(如果程序按顺序执行,一条指令是4个Byte),如果需要跳转则额外加一个偏置。
注意到immediate只传入了[12:1]而没有传入最低位,即指令跳转是以16bit为单位的(理论上可以不传入最低2位并以32bit为单位跳转)。这样一方面提升了offset的范围,另一方面可以在极端场景下满足16bit指令的要求。
  • UJ(jump) format
notion image
jal x1, ProcAddr jump and link,PC=PC+ProcAddr,并将PC+4存入x1中(作为子函数的返回地址)
  • I format(续)
jalr x0, 0(x1) jump and link register,PC=x1+0,并将PC+4存入x0中(但x0实际上不会被改变)。即跳回到上一层函数。
jalr与jal的区别在于前者PC=PC+offset,后者PC=x1+offset,前者是立即数操作,后者是寄存器操作。
无论在jal或jalr中,将x0作为rd即可实现unconditional branch(这样就会直接丢失返回地址)
  • U format
notion image
用来加载大的immediate。
  • 总结
notion image
当过程需要用到超过32个寄存器时,就需要把寄存器的数据sd到内存中,等需要时再ld回寄存器。特别的,如果要改动callee save的寄存器,必须先把原来的值放入内存,在jalr前恢复其原始值。
这一过程用栈来实现,栈顶内存指针为sp。栈底→栈顶:大地址→小地址,即sp变小是压栈,sp变大是弹出。
以上我们更改了x8和x9的值,而这两个寄存器约定为callee save,所以需要先写入栈,然后再从栈中恢复。而与之相对地,x10是caller save,也因此它可以被用作返回值的容器。
依此类推,x1也一定是caller save,即caller在使用jal改变x1的值之前,必须先保存x1(此时x1保存的是caller的返回地址)。x10~x17常被用作函数的传参,也是caller save。
x2也是caller save因为需要保证调用结束之后栈不变。

Vector Extension

notion image

#2 Datapath

Phases

  • Fetch Instructions(IFetch) / Instruction Memory(IM)
将PC作为地址给IM,读出指令,同时将PC+4
  • Decoding Instructions(Dec) / Read Regfiles(Reg)
将指令中的rs1/rs2解码成32bit数据,即从Reg中读取两个数据(因此reg需要有两个读取口)
同时根据指令内容生成控制信号
  • Execution(Exec) / ALU
执行算术运算,即包括R Format的运算和地址偏置的运算
  • Memory Read or Write(Mem) / Data Memory(DM)
对于load/store这类需要对内存进行操作的指令,先由ALU计算得到地址,然后从内存中取数或者把数写入内存
注意这一步可能对DM进行读/写,但在IFetch中只可能对IM进行读取
  • Write Back(WB) / Write Regfiles(Reg)
对于load指令,需要把从内存中读取到的数据写入Reg
store则不需要这一步

Flow

  • R-type
notion image
指令不发生跳转因此PC只需要变成PC+4
 

#3 Pipeline

CPU时间 = 指令数 * CPI * 时钟周期
CPI为平均执行每条指令需要的时间。pipeline优化的正是CPI

Hazards

  • structural hazards
在一个周期同时操作一个物理的memory
notion image
notion image
 
hazard detection 和 fowarding detection判断时间是不一样的,前者更早
stall是让一些指令不往前,即级间寄存器数据不更新

#4 Cache

cache-SRAM;内存-DRAM;外存-Flash等
主存(易失)=cache+内存;辅助存储(非易失)=外存
memory中cell本身的延时不占主要部分,反而是前置准备(如地址解码)会占大多数延时
改进memory是在利用并行来改进吞吐量,而不是在改进单个cell的延时
在物理上,不存在又大又快的memory,大的memory因为大量外围电路总是比较慢。所以我们只能在架构上进行一些设计,使得整个memory用起来像是一个又大又快的memory。
temporal locality:如果想要层次化memory发挥作用,一个数据拿上来应当需要在短时间内被计算使用很多次,这样计算的时间才能cover住访存需要的时间。(roofline analysis)
spatial locality:需要使用的数据最好有相邻的物理地址,这样方便cache从memory里取数
💡
有时把断电丢失的叫memory,断电不丢失的叫storage
所有厂家的dram都遵从一样的标准,一样的接口
Nvidia也就在片上集成了500Mb的SRAM,就算是Groq也就集成了700Mb
SRAM快的1~2个cycle,慢的8~10个cycle。DRAM基本都需要80~100个cycle
DRAM容量比SRAM更大,可能无法一次送入64位地址,因为引脚数不够(受限于封装,所以会有HBM先进封装的研究),可能需要对地址线进行时分复用。这进一步增加了DRAM的时延
如果在上层没有hit,就去下层找数据,并将其所属的block放到上层(即,将周围的数据也放到上层备用,利用了spatial locality)
notion image
直接映射:cache中永远只存了1/4的数据。但00可能存0000/0100/1000/1100这四个blocck,要知道它究竟是哪个,只需要在cache中记录数据地址的高两位作为tag(00/01/10/11)。
在寻址的时候,只需要看后两位对应的cache block,然后检查tag看是否是cache hit。
valid:刚上电的时候,cache中实际上没有存储有意义的数据,需要令valid=0
 
write back当数据被逐出cache时写入memory
write through总是保证cache和memory一致(常见于多核),但这样就没法享受cache的优势了。在实际情况中用一个write buffer,当写memory频率不高时可以起到缓冲作用
multiword cache利用spatial locality
block size变大之后功耗也会上升
往往$I的命中率比较高。把$D和$I分开可以避免$D影响$I的命中
associative:3位tag,1位index

#5 Main Memory

DRAM倾向于用延时而非clock,与逻辑的时钟域需要用某种方式对接(这样就变成了SDRAM,S for synchronous)
现在大部分内存都不是可插拔式的了
DDR: Double data rate在时钟上升下降都进行一次操作
DDR频率较低,但可以在它的一个cycle里准备更多的数据,让外部分几次取走。如DDR3-4可以在一个cycle内准备8*N的数据(不算double data rate)
DRAM的cap通过z轴方向很深的轴来提升电容而不产生额外的面积,这样1个cap跟1个mos能对齐
HBM的年增长需求远高于DDR
DDR竞争的核心是成本,因为DDR有统一的标准
DRAM最希望的是不同次之间row addr是一样的,col addr不一样。DRAM每次会把一整行(1个page)都读出来,然后根据col addr取几个bit(比如x4就是取4bit)
 
一个memory controller对应一个channel(直接对应带宽)
每个channel可能有多个DIMM(现在一般只有1-2个)
每个DIMM可以有1个或2个rank(单面或双面)
每个rank上有多个DRAM chips。例如,如果global IO是64bit,可以用8个DRAM chip每个出8bit拼起来,这样可以减少内部走线压力。(“x8”表示一个chip出8bit,64bit可以用8个x8或者16个x4等等)。同一个rank上的DRAM chip接收的指令是完全一样的。
每个DRAM chip内部分很多bank,每次选择一个bank来访问(因此不同chip的bank可以拼起来变成一个“逻辑bank”,其IO等于channel的IO,因为对应位置数据拼起来才是真的数据)。DRAM内部走线宽度可以远大于DRAM的IO
一个channel中的所有bank都可以独立接收指令独立工作,但他们都共享一个channel。可以让不同bank同时准备数据然后轮流使用IO来提高效率,我用IO的时候你在准备数据(如果读的数据都在同一个bank中就没办法了)
每个bank中有多个array,每个array独立提供1bit。因此x2就是2个array,x4就是4个array
先行选再列选,可以减少addr bus的宽度的需求
tBURST稍微长一点可以掩盖tCCD(相邻两个ColRead指令之间的最小间隔)。本来一个ColRead从一个array读一个地址A[9:0]的bit,但如果burst=4,就可以连读4次A[9:2]的共计4bit
如果数据locality较好,就只有当接收RowRead的时候才restore&prech;但如果locality不好老是换行,不如每次一读完就开始restore&prech
DRAM的访问时间有不确定性,这一点与SRAM不同
DDR4出现bank group。bank group之间的切换时间与之前相同,同个group的bank切换的时间就会更长
 
地址映射:最好把bank、col往低位放,把row往高位放
如果知道访问顺序,最优访问是一个NP-hard问题。Binary Invertible Matrix:使bank/col的熵比较高,row的熵比较低
notion image
notion image

#6 Multi Issue

true dependency, read before write err
output dependency, write before write err
anti-dependency, write before read err
read before read 不会出错
新的两种dependency实际上都是写得太快而出现的问题。可以引入“版本管理”,为后续指令的写操作找另一个位置而不覆盖原来的reg。这样以来,WB不会真正改变机器状态,真正改变机器状态的是commit。commit按顺序执行,当前面有分支或者cache miss停滞的时候,后续语句也可以开始excu,但他们只是“准备好被commit”(issue → completion → commit)
RUU:几个版本(NI),循环使用需要知道当前版本是哪个(LI)
RUU接受结果的data bus必须同时送回数据和地址(addr|LI)
reserve the result bus:记录未来时刻bus的使用,不能超限

#lab1 PyPTO

SPMD:single program, multiple data。简单来说就是同一件事同时施加到很多个数据上,类似SIMD
💡
代码报错信息不明确真的很难受。编译python代码确只报底层Cpp的错,还不指向python代码行,完全无从调试
(不过似乎报错信息里可以找loop_name?)
  • pypto.loop与range
range就是简单的在时序上串行,而pypto是在硬件层面上并行,会转化为计算图。与pypto的循环变量相关的(如index)称为SymbolicScalar,需要用一些特殊的函数比如SymbolicScalar.min。
默认情况下,pypto.loop会展开并分发到多核并行处理,适用于无数据依赖的场景。如果循环之间存在数据依赖(如前一个循环A的输出是下一个循环B的输入,注意这里AB不是嵌套关系而是同层级的循环),需设置循环B的submit_before_loop = True,确保每个循环迭代的结果写回Tensor后,再启动下一个循环
同时,如果使用loop,其中的判断分为静态分支和动态分支。静态分支如if,在生成schedule时就静态确定,而动态分支如pypto.cond(),在运行过程中进行判断
  • ./question/flash_attention_0413.py上不同循环方式的测试结果。
之前发现自迭代循环用loop存在精度问题,以为是没有设置submit_before_loop = True,但其实真实原因是pypto赋值时必须要写M[:]=才能识别依赖关系,写M=会被pypto解读为定义了一个新的变量🙄。
如前所述,submit_before_loop = True是强制将循环开始前的所有任务执行完毕,其本质是为了避免同层级的两个loop存在数据依赖,所以下方可以看到是否设置这一条对精度没有影响
分析时间容易发现,loop比range快。
time (small)
time (medium)
loop, submit=True, BF16
0
3 sec
loop, submit=False, BF16
0
4 sec
range, BF16
0
5 sec
loop, submit=True, FP32
0
3 sec
loop, submit=False, FP32
0
4 sec
range, FP32
0
6 sec
如果中间换成FP32运算,精度有提升。
但这里有个异常,用range+FP32会出现严重的精度问题。但我也不想再细究了。
max_abs_err (small)
max_abs_err (medium)
loop, submit=True, BF16
0.014
0.017
loop, submit=False, BF16
0.014
0.017
range, BF16
0.015
0.009
loop, submit=True, FP32
0.003
0.003
loop, submit=False, FP32
0.003
0.003
range, FP32
0.003
0.980(WHY?)
  • tiling
pypto需要显式指定vec和cube的tiling。
  • ./student/softmax.py上不同tiling的测试结果。这里没有matmul所以只涉及vec_tiling
vec_tiling
tasks
max_abs_err
mean_abs_err
(1,4,1,64)
320+64
1.4e—9
5.1e-11
(1,2,1,64)
576+64
1.4e—9
5.1e-11
(1,1,1,64)
1088+64
1.4e—9
5.1e-11
(1,1,1,32)
1088+64
1.4e—9
1.17e-10
(1,4,1,32)
320+64
1.4e-9
1.17e-10
(1,1,1,16)
1088+64
1.4e-9
1.21e-10
(1,1,1,8)
1088+64(1sec!)
1.4e-9
1.19e-10
(1,1,1,4)
ERR! last dim must be 32Byte align
(1,64,1,64)
352+32
1.4e-9
5.1e-11
  • matmul
必须指定out_dtype
 
量子计算导论FPGA
Loading...