👆如果您希望可以时常见面,欢迎标星🌟收藏哦~
在之前,我们做了一个报道,说一个芯片小白是如何花了两个礼拜做出了一个CPU。具体详情参看文章
《两周设计出一个CPU》
。不过正如该文所说,作者一开始只是想做一个GPU,但他在研究发现中发现后者比较难,所以才先做了一个CPU。

但原作者依然继续了这个他认为比较难的任务,并分享了自己DIY GPU的全流程。
步骤 1 :学习 GPU 架构的基础知识
我首先尝试了解现代 GPU 在架构层面的运作方式。这已经比我预想的要困难了——GPU 是专有技术,所以网上几乎没有详细的学习资源。我开始通过学习 NVIDIA 的 CUDA 框架来尝试了解 GPU 软件模式。这帮助我理解了用于编写称为内核的 GPU 程序的同指令多数据 (SIMD) 编程模式。
在此背景下,我开始深入了解 GPU 的核心要素:
  • 全局内存 - 存储数据和访问它的程序的外部内存是 GPU 编程的巨大瓶颈和限制
  • 计算核心 - 在不同线程中并行执行内核代码的主要计算单元
  • 分层缓存 - 缓存可最大限度地减少全局内存访问
  • 内存控制器 - 处理对全局内存的限制请求
  • 调度程序——GPU的主要控制单元,将线程分配给可用资源执行
然后在每个计算核心中,我了解了主要单元:
  • 寄存器 - 为每个线程存储数据的专用空间。
  • 本地/共享内存 - 线程之间共享内存以相互传递数据
  • 加载-存储单元 (LSU) - 用于从全局内存存储/加载数据
  • 计算单元 - ALU、SFU、专用图形硬件等,用于对寄存器值执行计算
  • 调度程序 - 管理每个核心中的资源,并计划何时执行来自不同线程的指令 - GPU 的大部分复杂性都在这里。
  • Fetcher - 从程序存储器中检索指令
  • 解码器——将指令解码为控制信号
这个过程让我对现代 GPU 中的不同单元有了很好的高级理解。但由于如此复杂,我知道我必须将 GPU 削减到我自己设计的必需品,否则我的项目将变得极其臃肿。
步骤 2 :创建我自己的 GPU 架构
接下来,我开始根据所学知识创建自己的 GPU 架构。
我的目标是创建一个最小的 GPU,以突出 GPU 的核心概念并消除不必要的复杂性,以便其他人可以更轻松地了解 GPU。
设计我自己的架构是一次令人难以置信的练习,可以决定什么才是真正重要的。
在整个过程中,我对我的架构进行了多次迭代,因为我通过构建学到了更多东西。
我决定在我的设计中强调以下几点:
  • 并行化 - SIMD 模式如何在硬件中实现?
  • 内存访问 - GPU 如何应对从缓慢且带宽有限的内存访问大量数据的挑战?
  • 资源管理 - GPU 如何最大限度地提高资源利用率和效率?
我想强调 GPU 在通用并行计算 (GPGPU) 和 ML 方面的更广泛用例,因此我决定专注于核心功能而不是图形特定硬件。经过多次迭代,我最终找到了在实际 GPU 中实现的以下架构(这里一切都是最简单的形式)
步骤三:为我的 GPU 编写自定义汇编语言
最关键的因素之一是我的 GPU 实际上可以执行使用 SIMD 编程模式编写的内核。为了实现这一点,我必须为我的 GPU 设计自己的指令集架构 (ISA),我可以用它来编写内核。
为了实现这一点,我受 LC4 ISA 的启发制作了自己的小型 11 指令 ISA,以允许我编写一些简单的矩阵数学内核作为概念证明。
我得到了以下指示:
  • NOP - 经典的空行(Classic empty row instruction )指令只是为了增加(increment) PC
  • BRnzp - 使用 NZP 寄存器的分支指令来启用条件语句和循环
  • CMP - 设置 NZP 寄存器以供 BRnzp 指令稍后使用的比较指令
  • ADD、SUB、DIV、MUL - 基本算术指令可实现简单的张量计算。
  • STR/LDR - 在全局数据存储器中存储/加载数据以访问初始数据并存储结果。
  • CONST - 将常量值加载到寄存器中以方便使用
  • RET - 表示线程已完成执行。
下面是我提出的 ISA 的完整表格,包括每条指令的确切结构。
步骤4 :使用我的 ISA 编写矩阵数学内核
现在我有了自己的 ISA,我创建了 2 个矩阵数学内核来运行在我的 GPU 上。
每个内核都指定要操作的矩阵、要启动的线程数以及要在每个线程中执行的代码。
我的矩阵加法内核使用 8 个线程添加了两个 1x8 矩阵,并演示了 SIMD 模式的使用、一些基本的算术指令和加载/存储功能。
我的矩阵乘法内核使用 4 个线程将两个 2x2 矩阵相乘,并额外演示了分支和循环。
演示矩阵数学功能至关重要,因为图形和机器学习中的现代 GPU 用例的基础在很大程度上围绕着矩阵计算(授予更复杂的内核)。
以下是我为矩阵加法和乘法编写的内核。
步骤5 :在 Verilog 中构建我的 GPU 并运行我的内核
在设计了我需要的一切之后,我终于开始在Verilog中构建我的GPU设计。这是迄今为止最困难的部分。我遇到了很多问题,并学到了艰难的教训。我重写了几次代码。
重写 1:
我最初将全局存储器实现为 SRAM(同步)。
但有读者谁给我反馈说,这违背了构建 GPU 的整个目的——GPU 最大的设计挑战是管理访问带宽有限的异步内存 (DRAM) 的延迟。因此,我最终使用外部异步内存来重建我的设计,并最终意识到我还需要添加内存控制器。
重写 2:
我最初使用 warp-scheduler 实现了我的 GPU(大错误,对于我的项目目标来说太复杂了,没有必要)。
然后,有读者反馈帮助我意识到这是一个不必要的复杂性。
具有讽刺意味的是,当我第一次收到反馈时,我没有足够的上下文来完全理解它。因此,我花时间尝试构建一个扭曲调度器,然后才意识到为什么这是一个坏主意lmao。
重写 3:
第一次没有在每个计算核心中正确实现调度,不得不返回并分阶段设计我的计算核心执行,以获得正确的控制流。
但是,尽管困难重重,但这是我的许多学习真正沉入深层直觉的一步。通过直面问题,我对 GPU 所面临的挑战有了更本能的感觉。
  • 当我遇到内存问题时,我真的感受到了为什么管理瓶颈内存的访问是 GPU 的最大限制之一。
  • 当我的设计无法工作时,我从第一性原理中发现了对内存控制器的需求,因为多个 LSU 试图同时访问内存,并意识到我需要一个请求队列系统。
  • 当我在调度程序/调度程序中实施简单的方法时,我看到了更高级的调度和资源管理策略(如流水线)如何优化性能。
下面是我在 Verilog 中内置到 GPU 中的单个线程的执行流程 - 它在执行时与 CPU 非常相似。
经过大量的重新设计,终于运行了我的矩阵加法和乘法内核,看到事情正常工作,我的GPU输出了正确的结果,这是一种令人难以置信的感觉。
这是我在 GPU 上运行矩阵加法内核的视频(视频参考原文链接),浏览 GPU 运行的执行跟踪,然后检查 GPU 存储最终结果的数据存储器中的最终状态。
您可以在执行跟踪中查看每个周期中每个线程/内核的单个指令、PC、ALU 处理、寄存器值等。
最重要的是,您可以在开始时看到结果矩阵的空地址,然后在最后看到正确的值被加载到数据存储器中的结果矩阵中!
步骤6 :将我的设计转换为完整的芯片布局
在完成Verilog设计后,最后一步是将我的设计通过EDA流程,以创建最终的芯片布局。
我的设计以 Skywater 130nm 工艺节点为目标(我在 2 周前做了一些设计,并且还提交了我的一个设计,通过 Tiny Tapeout 6 制造)
这一步是设计任何芯片的真正现实检查。
您可能有一个在理论上和仿真中都有效的设计,但将该设计转换为带有 GDS 文件的最终芯片布局是交付您的设计的真正障碍。
在此过程中,我还遇到了几个问题,我的芯片没有通过我正在使用的 OpenLane EDA 流程指定的一些设计规则检查 (DRC),并且不得不重新设计部分 GPU 来解决这些问题。
经过一番努力,我终于得到了一个强化版的 GPU 布局,其中包含提交所需的 GDS 文件(如下所示)
我还可以为此设计构建一个适配器,并通过 Tiny Tapeout 7 将其提交以进行流片!
最后查看我的 tiny-gpu 项目以获取所有项目详细信息!
我构建了 tiny-gpu 来创建一个单一资源,让人们从头开始了解 GPU 的工作原理。
它用 <15 个完整记录的 Verilog 文件构建,关于架构和 ISA 的完整文档,工作矩阵加法/乘法内核,以及对内核模拟和执行跟踪的完全支持,供任何想要使用它和学习的人使用。
从0开始设计Tiny GPU的完整流程
如果您想了解 CPU 从架构到控制信号的整个工作原理,有许多在线资源可以为您提供帮助。
但GPU 不一样。由于 GPU 市场竞争如此激烈,所有现代架构的底层技术细节仍然是专有的。虽然有很多资源可以学习 GPU 编程,但几乎没有任何资源可以学习 GPU 在硬件级别的工作原理。
最好的选择是浏览Miaow和VeriGPU等开源 GPU 实现,并尝试弄清楚发生了什么。这是具有挑战性的,因为这些项目的目标是功能完整和实用,因此它们非常复杂。
这就是我建造tiny-gpu的原因!
 1. 
什么是tinygpu?
tiny-gpu是一个最小的 GPU 实现,经过优化,可以从头开始学习 GPU 的工作原理。
具体来说,随着通用 GPU (GPGPU) 和 ML 加速器(如 Google 的 TPU)的发展趋势,tiny-gpu 专注于强调所有这些架构的一般原理,而不是图形特定硬件的细节。
考虑到这一动机,我们可以通过消除构建生产级显卡所涉及的大部分复杂性来简化 GPU,并专注于对所有这些现代硬件加速器至关重要的核心元素。
该项目主要致力于探索:
  • 架构- GPU 的架构是什么样的?最重要的元素是什么?
  • 并行化- SIMD 编程模型如何在硬件中实现?
  • 内存- GPU 如何解决有限内存带宽的限制?
了解该项目中阐述的基础知识后,您可以查看高级功能部分,以了解在生产级 GPU 中进行的一些最重要的优化(实施起来更具挑战性),从而提高性能。
 2. 
架构
(1)图形处理器
tiny-gpu 被构建为一次执行一个内核。
为了启动内核,我们需要执行以下操作:
  • 使用内核代码加载全局程序内存
  • 将必要的数据加载到数据存储器中
  • 指定要在设备控制寄存器中启动的线程数
  • 通过将启动信号设置为高来启动内核。
GPU本身由以下单元组成:
  • 设备控制寄存器
  • 调度员(Dispatcher)
  • 计算核心数量可变
  • 用于数据存储器和程序存储器的存储器控制器
  • 缓存
(a)设备控制寄存器
设备控制寄存器通常存储指定内核应如何在 GPU 上执行的元数据。
在这种情况下,设备控制寄存器仅存储thread_count为活动内核启动的线程总数。
(b)调度员
一旦内核启动,调度程序就是实际管理将线程分配到不同计算核心的单元。
调度程序将线程组织成可以在单个核心上并行执行的组(称为块),并将这些块发送出去以供可用核心处理。
一旦处理完所有块,调度程序就会报告内核执行已完成。
(2)存储
GPU 旨在与外部全局存储器连接。这里,为了简单起见,数据存储器和程序存储器被分开。
(i)全局记忆
tiny-gpu数据存储器有以下规格:
  • 8 位寻址能力(数据存储器总共 256 行)
  • 8 位数据(每行存储 <256 的值)
tiny-gpu程序存储器有以下规格:
  • 8 位寻址能力(256 行程序存储器)
  • 16位数据(ISA规定每条指令为16位)
(ii)内存控制器
全局内存具有固定的读/写带宽,但所有内核之间访问内存数据的传入请求可能比外部内存实际能够处理的要多得多。
内存控制器跟踪从计算核心到内存的所有传出请求,根据实际外部内存带宽限制请求,并将响应从外部内存转发回适当的资源。
每个内存控制器具有基于全局内存带宽的固定数量的通道。
(iii)缓存(WIP)
多个核心经常从全局内存请求相同的数据。不断地重复访问全局内存的成本很高,而且由于数据已经被提取一次,因此将其存储在设备上的 SRAM 中会更有效,以便在以后的请求中更快地检索。
这正是缓存的用途。从外部存储器检索的数据存储在缓存中,并且可以在以后的请求时从那里检索,从而释放内存带宽以用于新数据。
(3)核
每个核心都有大量计算资源,通常围绕它可以支持的一定数量的线程构建。为了最大化并行化,需要对这些资源进行最佳管理,以最大化资源利用率。
在这个简化的 GPU 中,每个核心一次处理一个块,对于块中的每个线程,核心都有一个专用的 ALU、LSU、PC 和寄存器文件。管理这些资源上线程指令的执行是 GPU 中最具挑战性的问题之一。
(1)调度程序
每个核心都有一个调度程序来管理线程的执行。
tiny-gpu 调度程序在拾取新块之前执行单个块的指令直至完成,并且它同步且顺序地执行所有线程的指令。
在更高级的调度程序中,使用诸如流水线之类的技术来流式执行多个指令和后续指令,以便在前面的指令完全完成之前最大化资源利用率。此外,warp 调度可用于并行执行块内的多批线程。
调度程序必须解决的主要约束是与从全局内存加载和存储数据相关的延迟。虽然大多数指令可以同步执行,但这些加载存储操作是异步的,这意味着指令执行的其余部分必须围绕这些漫长的等待时间构建。
(2)抓取器
从程序存储器中异步获取当前程序计数器处的指令(实际上大多数应该是在执行单个块后从缓存中获取)。
(3)解码器
将获取的指令解码为线程执行的控制信号。
(4)寄存文件(Register Files)
每个线程都有它自己的专用寄存器文件集。寄存器文件保存每个线程正在执行计算的数据,从而实现同一指令多数据 (SIMD) 模式。
重要的是,每个寄存器文件包含一些只读寄存器,保存有关本地执行的当前块和线程的数据,使内核能够根据本地线程 ID 使用不同的数据执行。
(5)ALU
每个线程都有专用的算术逻辑单元来执行计算。处理ADD、SUB、MUL、DIV算术指令。
还处理CMP比较指令,该指令实际输出两个寄存器之间的差异结果是负、零还是正 - 并将结果存储NZP在 PC 单元的寄存器中。
(6)LSU
每个线程都有专用的加载-存储单元来访问全局数据内存。
处理LDR&STR指令 - 并处理由内存控制器处理和中继的内存请求的异步等待时间。
(7)PCs
每个单元的专用程序计数器确定要在每个线程上执行的下一条指令。
默认情况下,每条指令后 PC 都会加 1。
使用该BRnzp指令,NZP 寄存器检查 NZP 寄存器(由前一条CMP指令设置)是否匹配某种情况 - 如果匹配,它将分支到程序存储器的特定行。这就是循环和条件的实现方式。
由于线程是并行处理的,tiny-gpu 假设所有线程在每条指令后“收敛”到同一个程序计数器 - 为了简单起见,这是一个天真的假设。
在真实的 GPU 中,各个线程可以分支到不同的 PC,从而导致分支发散,其中最初一起处理的一组线程必须分成单独的执行。
 3. 
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。
  4. 
执行
(1)核
每个内核遵循以下控制流程,经过不同阶段来执行每条指令:
  • FETCH- 从程序存储器中获取当前程序计数器的下一条指令。
  • DECODE- 将指令解码为控制信号。
  • REQUEST- 如有必要,从全局存储器请求数据(ifLDR或STR指令)。
  • WAIT- 等待全局内存中的数据(如果适用)。
  • EXECUTE- 对数据执行任何计算。
  • UPDATE- 更新寄存器文件和NZP寄存器。
  • 为了简单和易于理解,控制流程这样布置。
实际上,可以压缩其中的几个步骤以优化处理时间,并且 GPU 还可以使用流水线来流式传输和协调内核资源上许多指令的执行,而无需等待先前的指令完成。
(2)线程(Thread)
每个内核中的每个线程都遵循上述执行路径来对其专用寄存器文件中的数据执行计算。
这类似于标准 CPU 图,并且在功能上也非常相似。主要区别在于%blockIdx、%blockDim和%threadIdx值位于每个线程的只读寄存器中,从而启用 SIMD 功能。
  5. 
内核
我使用 ISA 编写了一个矩阵加法和矩阵乘法内核作为概念证明,以演示使用 GPU 进行 SIMD 编程和执行。该存储库中的测试文件能够完全模拟这些内核在 GPU 上的执行,生成数据内存状态和完整的执行跟踪。
(1)矩阵加法
该矩阵加法内核通过在单独的线程中执行 8 个元素明智的加法来添加两个 1 x 8 矩阵。
该演示使用%blockIdx、%blockDim和%threadIdx寄存器来展示该 GPU 上的 SIMD 编程。它还使用需要异步内存管理的LDR和指令。
(2)矩阵乘法
矩阵乘法内核将两个 2x2 矩阵相乘。它对相关行和列的点积执行元素级计算,并使用CMP和BRnzp指令来演示线程内的分支(值得注意的是,所有分支都会收敛,因此该内核适用于当前的tiny-gpu 实现)。
  6. 
模拟
tiny-gpu 设置为模拟上述两个内核的执行。在模拟之前,您需要安装iverilog和cocotb。
安装先决条件后,您可以使用make test_matadd和运行内核模拟make test_matmul。
执行模拟将输出一个日志文件,test/logs其中包含初始数据内存状态、内核的完整执行跟踪以及最终数据内存状态。
如果查看每个日志文件开头记录的初始数据内存状态,您应该看到计算的两个起始矩阵,并且在文件末尾的最终数据内存中,您还应该看到结果矩阵。
下面是执行跟踪的示例,显示每个周期每个内核中每个线程的执行情况,包括当前指令、PC、寄存器值、状态等。
  7. 
先进的功能
为了简单起见,现代 GPU 中实现了许多附加功能,这些功能大大提高了 tiny-gpu 省略的性能和功能。我们将在本节中讨论一些最关键的功能。
(1)多层缓存和共享内存
在现代 GPU 中,使用多个不同级别的缓存来最大限度地减少需要从全局内存访问的数据量。tiny-gpu 在请求内存的各个计算单元和存储最近缓存数据的内存控制器之间仅实现一层缓存层。
实施多层缓存可以将频繁访问的数据缓存到其使用位置的更本地位置(某些缓存位于各个计算核心内),从而最大限度地缩短该数据的加载时间。
使用不同的缓存算法来最大化缓存命中——这是一个可以改进以优化内存访问的关键维度。
此外,GPU 经常使用同一块内的线程共享内存来访问可用于与其他线程共享结果的单个内存空间。
(2)内存合并
GPU 使用的另一个关键内存优化是内存合并。并行运行的多个线程通常需要访问内存中的顺序地址(例如,一组线程访问矩阵中的相邻元素) - 但每个内存请求都是单独放入的。
内存合并用于分析排队的内存请求,并将相邻请求合并到单个事务中,最大限度地减少寻址所花费的时间,并将所有请求放在一起。
(3)流水线
在tiny-gpu的控制流程中,核心等待一组线程上执行一条指令,然后开始执行下一条指令。
现代 GPU 使用流水线技术一次性流式执行多个顺序指令,同时确保相互依赖的指令仍能按顺序执行。
这有助于最大限度地提高核心内的资源利用率,因为资源在等待时不会闲置(例如:在异步内存请求期间)。
(4)扭曲调度
用于最大化课程资源利用率的另一个策略是扭曲调度。这种方法涉及将块分解成可以一起执行的单独批次的 thead。
通过在一个 warp 等待时执行来自一个 warp 的指令,可以在单个内核上同时执行多个 warp。这与流水线类似,但处理来自不同线程的指令。
(5)分支分歧
tiny-gpu 假设单个批次中的所有线程在执行每条指令后都位于同一台 PC 上,这意味着线程可以在其整个生命周期内并行执行。
实际上,各个线程可能彼此分歧,并根据其数据分支到不同的线路。对于不同的 PC,这些线程需要分成单独的执行线,这需要管理发散的线程并注意线程何时再次聚合。
(6)同步与障碍
现代 GPU 的另一个核心功能是能够设置障碍,以便块中的线程组可以同步并等待,直到同一块中的所有其他线程都到达某个点,然后再继续执行。
这对于线程需要彼此交换共享数据的情况非常有用,这样它们就可以确保数据已被完全处理。
  8. 
下一步
我想在未来进行更新以改进设计,也欢迎其他人做出贡献:
  • 添加一个简单的指令缓存
  • 构建适配器以通过 Tiny Tapeout 7 使用 GPU
  • 添加基本分支分歧
  • 添加基本内存合并
  • 添加基本流水线
  • 优化控制流程和寄存器的使用以缩短周期时间
  • 编写基本图形内核或添加简单的图形硬件来演示图形功能
参考链接
https://twitter.com/MajmudarAdam/status/1783304260303855774
https://github.com/adam-maj/tiny-gpu?tab=readme-ov-file#kernels
点这里👆加关注,锁定更多原创内容
END
*免责声明:本文由作者原创。文章内容系作者个人观点,半导体行业观察转载仅为了传达一种不同的观点,不代表半导体行业观察对该观点赞同或支持,如果有任何异议,欢迎联系半导体行业观察。
今天是《半导体行业观察》为您分享的第3751期内容,欢迎关注。
推荐阅读
『半导体第一垂直媒体』
实时 专业 原创 深度
公众号ID:icbank
喜欢我们的内容就点“在看”分享给小伙伴哦
继续阅读
阅读原文