tiny-gpu
一个用Verilog实现的最小化GPU,旨在从底层学习GPU工作原理。
由<15个完全文档化的Verilog文件构建,包含完整的架构和指令集文档、可用的矩阵加法/乘法内核,以及对内核仿真和执行跟踪的全面支持。
目录
概述
如果你想学习CPU是如何从架构到控制信号全方位工作的,网上有很多资源可以帮助你。
但GPU并非如此。
由于GPU市场竞争激烈,所有现代架构的低层技术细节仍然是专有的。
虽然有很多资源可以学习GPU编程,但几乎没有资源可以学习GPU在硬件层面是如何工作的。
最好的选择是查看像Miaow和VeriGPU这样的开源GPU实现,并尝试弄清楚它们是如何运作的。这是具有挑战性的,因为这些项目旨在功能完整且可用,所以它们相当复杂。
这就是我构建tiny-gpu
的原因!
什么是tiny-gpu?
[!重要]
tiny-gpu是一个最小化的GPU实现,旨在从底层学习GPU的工作原理。
特别是,随着通用GPU(GPGPU)和像Google的TPU这样的机器学习加速器的趋势,tiny-gpu专注于突出所有这些架构的一般原则,而不是图形特定硬件的细节。
考虑到这个动机,我们可以通过去除构建生产级显卡所涉及的大部分复杂性来简化GPU,并专注于对所有这些现代硬件加速器至关重要的核心元素。
这个项目主要关注探索:
- 架构 - GPU的架构是什么样的?最重要的元素是什么?
- 并行化 - SIMD编程模型如何在硬件中实现?
- 内存 - GPU如何解决有限内存带宽的约束?
在理解了本项目中阐述的基础知识之后,你可以查看高级功能部分,以了解生产级GPU中一些最重要的优化(实现起来更具挑战性),这些优化可以提高性能。
架构
GPU
tiny-gpu被设计为一次执行一个内核。
为了启动一个内核,我们需要执行以下操作:
- 将内核代码加载到全局程序内存中
- 将必要的数据加载到数据内存中
- 在设备控制寄存器中指定要启动的线程数
- 通过将启动信号设置为高电平来启动内核
GPU本身由以下单元组成:
- 设备控制寄存器
- 调度器
- 可变数量的计算核心
- 数据内存和程序内存的内存控制器
- 缓存
设备控制寄存器
设备控制寄存器通常存储指定内核应如何在GPU上执行的元数据。
在这种情况下,设备控制寄存器只存储thread_count
- 活动内核要启动的总线程数。
调度器
一旦内核被启动,调度器就是实际管理线程分配到不同计算核心的单元。
调度器将线程组织成可以在单个核心上并行执行的组,称为块,并将这些块发送到可用的核心进行处理。
一旦所有块都被处理完毕,调度器就会报告内核执行完成。
内存
GPU被设计为与外部全局内存接口。在这里,为了简单起见,数据内存和程序内存被分开。
全局内存
tiny-gpu数据内存有以下规格:
- 8位寻址能力(总共256行数据内存)
- 8位数据(每行存储<256的值)
tiny-gpu程序内存有以下规格:
- 8位寻址能力(256行程序内存)
- 16位数据(每条指令为16位,由ISA指定)
内存控制器
全局内存有固定的读/写带宽,但所有核心的传入请求可能远远超过外部内存实际能够处理的请求。
内存控制器跟踪所有从计算核心发出的内存请求,根据实际外部内存带宽限制请求,并将外部内存的响应传回适当的资源。
每个内存控制器根据全局内存的带宽有固定数量的通道。
缓存(正在开发中)
多个核心经常从全局内存请求相同的数据。反复访问全局内存的成本很高,而且由于数据已经被获取过一次,将其存储在设备上的SRAM中以便在后续请求中更快地检索会更有效率。
这正是缓存的用途。从外部内存检索的数据存储在缓存中,并可以在后续请求中从那里检索,从而释放内存带宽以用于新数据。
核心
每个核心都有一定数量的计算资源,通常围绕它可以支持的特定数量的线程构建。为了最大化并行化,需要对这些资源进行最优管理以最大化资源利用率。
在这个简化的GPU中,每个核心一次处理一个块,对于块中的每个线程,核心都有一个专用的ALU、LSU、PC和寄存器文件。管理这些资源上的线程指令执行是GPU中最具挑战性的问题之一。
调度器
每个核心都有一个单一的调度器来管理线程的执行。
tiny-gpu调度器在接收新块之前执行单个块的指令直至完成,并且同步且顺序地执行所有线程的指令。
在更高级的调度器中,使用流水线等技术来流式执行多个后续指令,以在前面的指令完全完成之前最大化资源利用率。此外,warp调度可以用于并行执行块内的多批线程。
调度器必须解决的主要约束是与从全局内存加载和存储数据相关的延迟。虽然大多数指令可以同步执行,但这些加载-存储操作是异步的,这意味着指令执行的其余部分必须围绕这些长等待时间构建。
取指器
异步地从程序内存中获取当前程序计数器处的指令(在执行单个块后,大多数实际上应该从缓存中获取)。
解码器
将获取的指令解码为线程执行的控制信号。
寄存器文件
每个线程都有自己专用的寄存器文件集。寄存器文件保存每个线程执行计算的数据,这使得同指令多数据(SIMD)模式成为可能。
重要的是,每个寄存器文件都包含一些只读寄存器,存储有关当前正在本地执行的块和线程的数据,使得可以根据本地线程ID执行具有不同数据的内核。
ALU
每个线程都有专用的算术逻辑单元来执行计算。处理 ADD
、SUB
、MUL
、DIV
等算术指令。
还处理 CMP
比较指令,该指令实际上输出两个寄存器之差的结果是负数、零还是正数 - 并将结果存储在 PC 单元的 NZP
寄存器中。
LSU
每个线程都有专用的加载-存储单元来访问全局数据内存。
处理 LDR
和 STR
指令 - 并处理内存请求被内存控制器处理和传递的异步等待时间。
PC
每个单元都有专用的程序计数器,用于确定每个线程要执行的下一条指令。
默认情况下,PC 在每条指令后递增 1。
通过 BRnzp
指令,NZP 寄存器会检查 NZP 寄存器(由之前的 CMP
指令设置)是否匹配某个条件 - 如果匹配,它将分支到程序内存的特定行。这就是实现循环和条件语句的方式。
由于线程是并行处理的,tiny-gpu 假设所有线程在每条指令后都"收敛"到相同的程序计数器 - 这是为简单起见而做出的一个简单假设。
在真实的 GPU 中,单个线程可以分支到不同的 PC,导致分支分歧,即原本一起处理的一组线程需要分裂为单独执行。
ISA
tiny-gpu 实现了一个简单的 11 条指令 ISA,旨在支持简单的内核,用于概念验证,如矩阵加法和矩阵乘法(在本页面下方有实现)。
为此,它支持以下指令:
BRnzp
- 分支指令,如果 NZP 寄存器匹配指令中的nzp
条件,则跳转到程序内存的另一行。CMP
- 比较两个寄存器的值,并将结果存储在 NZP 寄存器中,以供后续的BRnzp
指令使用。ADD
、SUB
、MUL
、DIV
- 基本算术运算,用于支持张量数学。LDR
- 从全局内存加载数据。STR
- 将数据存储到全局内存。CONST
- 将常量值加载到寄存器中。RET
- 表示当前线程已到达执行结束。
每个寄存器由 4 位指定,这意味着总共有 16 个寄存器。前 13 个寄存器 R0
- R12
是支持读写的空闲寄存器。最后 3 个寄存器是特殊的只读寄存器,用于提供对 SIMD 至关重要的 %blockIdx
、%blockDim
和 %threadIdx
。
执行
核心
每个核心遵循以下控制流程,通过不同阶段执行每条指令:
FETCH
- 从程序内存中获取当前程序计数器位置的下一条指令。DECODE
- 将指令解码为控制信号。REQUEST
- 如果需要,从全局内存请求数据(如果是LDR
或STR
指令)。WAIT
- 如果适用,等待全局内存的数据。EXECUTE
- 对数据执行任何计算。UPDATE
- 更新寄存器文件和 NZP 寄存器。
为了简单易懂,控制流程以这种方式布局。
实际上,可以压缩这些步骤中的几个以优化处理时间,GPU 还可以使用流水线技术来在核心资源上流式处理和协调多条指令的执行,而无需等待前面的指令完成。
线程
每个核心内的每个线程都遵循上述执行路径,对其专用寄存器文件中的数据执行计算。
这类似于标准的 CPU 图,功能上也非常相似。主要区别在于 %blockIdx
、%blockDim
和 %threadIdx
值位于每个线程的只读寄存器中,实现了 SIMD 功能。
内核
我使用我的 ISA 编写了矩阵加法和矩阵乘法内核,作为概念验证,以演示 SIMD 编程和在我的 GPU 上的执行。此存储库中的测试文件能够完全模拟这些内核在 GPU 上的执行,生成数据内存状态和完整的执行跟踪。
矩阵加法
这个矩阵加法内核通过在单独的线程中执行 8 个元素的逐元素加法来加两个 1 x 8 矩阵。
这个演示利用了 %blockIdx
、%blockDim
和 %threadIdx
寄存器来展示这个 GPU 上的 SIMD 编程。它还使用了 LDR
和 STR
指令,这需要异步内存管理。
matadd.asm
.threads 8
.data 0 1 2 3 4 5 6 7 ; 矩阵 A (1 x 8)
.data 0 1 2 3 4 5 6 7 ; 矩阵 B (1 x 8)
MUL R0, %blockIdx, %blockDim
ADD R0, R0, %threadIdx ; i = blockIdx * blockDim + threadIdx
CONST R1, #0 ; baseA (矩阵 A 的基地址)
CONST R2, #8 ; baseB (矩阵 B 的基地址)
CONST R3, #16 ; baseC (矩阵 C 的基地址)
ADD R4, R1, R0 ; addr(A[i]) = baseA + i
LDR R4, R4 ; 从全局内存加载 A[i]
ADD R5, R2, R0 ; addr(B[i]) = baseB + i
LDR R5, R5 ; 从全局内存加载 B[i]
ADD R6, R4, R5 ; C[i] = A[i] + B[i]
ADD R7, R3, R0 ; addr(C[i]) = baseC + i
STR R7, R6 ; 将 C[i] 存储到全局内存
RET ; 内核结束
矩阵乘法
矩阵乘法内核将两个 2x2 矩阵相乘。它执行相关行和列的点积的元素逐个计算,并使用 CMP
和 BRnzp
指令来演示线程内的分支(值得注意的是,所有分支都收敛,因此这个内核在当前的 tiny-gpu 实现上工作)。
matmul.asm
.threads 4
.data 1 2 3 4 ; 矩阵 A (2 x 2)
.data 1 2 3 4 ; 矩阵 B (2 x 2)
MUL R0, %blockIdx, %blockDim
ADD R0, R0, %threadIdx ; i = blockIdx * blockDim + threadIdx
CONST R1, #1 ; 递增值
CONST R2, #2 ; N (矩阵内部维度)
CONST R3, #0 ; baseA (矩阵 A 的基地址)
CONST R4, #4 ; baseB (矩阵 B 的基地址)
CONST R5, #8 ; baseC (矩阵 C 的基地址)
DIV R6, R0, R2 ; row = i // N
MUL R7, R6, R2
SUB R7, R0, R7 ; col = i % N
CONST R8, #0 ; acc = 0
CONST R9, #0 ; k = 0
LOOP:
MUL R10, R6, R2
ADD R10, R10, R9
ADD R10, R10, R3 ; addr(A[i]) = row * N + k + baseA
LDR R10, R10 ; 从全局内存加载 A[i]
MUL R11, R9, R2
ADD R11, R11, R7
ADD R11, R11, R4 ; B[i]的地址 = k * N + col + baseB
LDR R11, R11 ; 从全局内存加载B[i]
MUL R12, R10, R11
ADD R8, R8, R12 ; acc = acc + A[i] * B[i]
ADD R9, R9, R1 ; k自增
CMP R9, R2
BRn LOOP ; 当k < N时循环
ADD R9, R5, R0 ; C[i]的地址 = baseC + i
STR R9, R8 ; 将C[i]存储到全局内存
RET ; 内核结束
模拟
tiny-gpu设置为模拟执行上述两个内核。在模拟之前,您需要安装iverilog和cocotb:
- 使用
brew install icarus-verilog
和pip3 install cocotb
安装Verilog编译器 - 从https://github.com/zachjs/sv2v/releases下载最新版本的sv2v,解压并将二进制文件放入$PATH中。
- 在此仓库的根目录下运行
mkdir build
。
安装好先决条件后,您可以使用make test_matadd
和make test_matmul
运行内核模拟。
执行模拟将在test/logs
中输出一个日志文件,其中包含初始数据内存状态、内核的完整执行跟踪以及最终数据内存状态。
如果查看每个日志文件开头记录的初始数据内存状态,您应该能看到计算的两个起始矩阵,在文件末尾的最终数据内存中,您还应该能看到结果矩阵。
以下是执行跟踪的示例,显示了每个周期内每个核心中每个线程的执行情况,包括当前指令、PC、寄存器值、状态等。
对于任何想要运行模拟或使用此仓库的人,如果遇到任何问题,请随时在twitter上给我发私信 - 我希望您能成功运行它!
高级功能
为了简单起见,现代GPU中实现的许多额外功能大大提高了性能和功能,但tiny-gpu省略了这些功能。在本节中,我们将讨论一些最关键的功能。
多层缓存和共享内存
在现代GPU中,使用多个不同级别的缓存来最小化需要从全局内存访问的数据量。tiny-gpu仅在请求内存的各个计算单元和存储最近缓存数据的内存控制器之间实现了一层缓存。
实现多层缓存允许将频繁访问的数据缓存在更靠近使用位置的地方(某些缓存位于各个计算核心内),最大限度地减少这些数据的加载时间。
使用不同的缓存算法来最大化缓存命中率 - 这是可以改进以优化内存访问的关键维度。
此外,GPU通常使用共享内存,使同一块内的线程可以访问单个内存空间,用于与其他线程共享结果。
内存合并
GPU使用的另一个关键内存优化是内存合并。并行运行的多个线程通常需要访问内存中的连续地址(例如,一组线程访问矩阵中的相邻元素)- 但每个内存请求都是单独提交的。
内存合并用于分析排队的内存请求,并将相邻的请求合并为单个事务,最大限度地减少用于寻址的时间,并一起处理所有请求。
流水线
在tiny-gpu的控制流程中,核心在开始执行下一条指令之前,会等待一组线程执行完一条指令。
现代GPU使用流水线同时流式执行多条顺序指令,同时确保相互依赖的指令仍按顺序执行。
这有助于最大化核心内的资源利用率,因为资源不会在等待时(例如:在异步内存请求期间)闲置。
线程束调度
用于最大化核心资源利用率的另一种策略是线程束调度。这种方法涉及将块分解为可以一起执行的单个线程批次。
通过在一个线程束等待时执行另一个线程束的指令,多个线程束可以同时在单个核心上执行。这类似于流水线,但处理的是来自不同线程的指令。
分支分歧
tiny-gpu假设单个批次中的所有线程在每条指令之后都会在相同的PC上结束,这意味着线程可以在整个生命周期内并行执行。
实际上,各个线程可能会根据其数据而彼此分歧并分支到不同的行。由于PC不同,这些线程需要分成不同的执行线路,这需要管理分歧的线程并注意线程何时再次汇合。
同步和障碍
现代GPU的另一个核心功能是能够设置障碍,使块中的线程组可以同步并等待同一块中的所有其他线程达到某个点后再继续执行。
这在线程需要相互交换共享数据的情况下很有用,因为它们可以确保数据已被完全处理。
下一步
我想在未来做出的更新以改进设计,欢迎其他人也贡献:
- 为指令添加简单的缓存
- 构建适配器以在Tiny Tapeout 7上使用GPU
- 添加基本的分支分歧
- 添加基本的内存合并
- 添加基本的流水线
- 优化控制流和寄存器使用以改善周期时间
- 编写基本的图形内核或添加简单的图形硬件以演示图形功能
对于任何想要尝试或做出贡献的人,欢迎提交PR,添加您想要的任何改进😄