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