威廉玛丽-CSCI654-计算机组成笔记-全-

威廉玛丽 CSCI654 计算机组成笔记(全)

001:芯片是如何制造的 💻

在本节课中,我们将学习计算机体系结构的基础知识,并深入探讨现代计算机的核心——芯片是如何被设计和制造出来的。我们将从最基础的晶体管开始,了解其工作原理,并逐步构建出对芯片制造全流程的理解。

课程概述与目标

这是新学期的开始,我们将教授高级计算机体系结构课程。本课程将采用讲座形式,覆盖从数字设计基础到现代CPU/GPU架构的广泛主题。课程目标是为你进行计算机体系结构相关研究铺平道路,并理解该领域的基础知识。

评估方式将包括作业、测验和期末考试。作业将围绕构建一个简单的RISC-V CPU模拟器展开,这是本课程的核心实践部分。我们鼓励使用生成式AI来辅助完成作业,但测验和考试中不允许使用。

什么是计算机体系结构?

在深入细节之前,我们首先需要理解计算机体系结构在整个技术栈中的位置。计算机体系结构是连接物理硬件与应用软件的桥梁。

技术栈从底层的物理现象(如电子、半导体)开始,向上依次是:

  • 设备:例如晶体管。
  • 门电路:例如与非门、或非门。
  • 微架构:芯片的硬件实现。
  • 指令集架构:软件与硬件之间的协议,定义了二进制指令的含义。
  • 汇编与机器码:由编译器生成,供硬件执行的代码。
  • 操作系统、编程语言、算法和应用

指令集架构是一个关键的分界点,其上是软件领域,其下是硬件领域。计算机体系结构的研究主要集中在微架构和机器码层面,其核心作用是吸收底层物理技术(如更小的晶体管)的进步,并满足上层应用(如大型语言模型)的需求,从而设计出性能更高、更可靠、更安全的计算系统。

从晶体管到逻辑门

计算机设计的起点是晶体管。在数字电路领域,我们主要关注代表“0”和“1”的电压信号。

晶体管的工作原理

晶体管是一个三端器件。以CMOS(互补金属氧化物半导体)晶体管为例,它有一个控制端(栅极)和两个电流通路端(源极和漏极)。其核心功能是:根据栅极的电压,控制源极和漏极之间是否导通。

CMOS技术的优势在于其静态功耗极低。当电路处于稳定状态(不进行开关切换)时,从电源到地之间没有直接的导通路,因此几乎不消耗能量。

构建基本逻辑门

使用CMOS晶体管可以构建基本的逻辑门。以下是两个关键示例:

  1. 反相器:实现“非”逻辑功能。

    • 当输入A为低电平(0)时,上方的P型晶体管导通,输出Q被拉至高电平(1)。
    • 当输入A为高电平(1)时,下方的N型晶体管导通,输出Q被拉至低电平(0)。
    • 逻辑公式:Q = NOT A
  2. 与非门:实现“与非”逻辑功能。在现代集成电路设计中,与非门是构建所有复杂数字电路的基础单元。

    • 仅当输入A和B均为高电平(1)时,两个串联的N型晶体管才导通,输出被拉低(0)。
    • 其他情况下,并联的P型晶体管中至少有一个导通,输出被拉高(1)。
    • 逻辑公式:Q = NOT (A AND B)

整个数字电路本质上就是由无数个与非门以特定方式连接而成的网络。

集成电路的制造

从分立元件到集成电路是计算技术的巨大飞跃。罗伯特·诺伊斯等人发明的集成电路,使得将数百万甚至数十亿个晶体管集成到一块微小的硅片上成为可能。

摩尔定律

戈登·摩尔观察到一个趋势,即集成电路上可容纳的晶体管数量,大约每18-24个月便会增加一倍。这一定律并非物理定律,而是技术竞争和商业驱动的结果,它长期指引着半导体行业的发展。尽管多次被预言终结,但通过新工艺(如更小的制程节点)和新架构(如Chiplet技术),性能增长仍在以某种形式持续。

芯片制造流程

芯片制造是一个极其复杂和精密的过程,主要步骤包括:

  1. 制备晶圆:从高纯度的硅晶体上切割出圆形的薄片,称为晶圆。
  2. 光刻:这是最关键且昂贵的步骤。在涂有光刻胶的晶圆上,使用预先制作好的掩膜版,通过紫外光(如今使用极紫外光EUV)进行曝光。光刻定义了晶体管和电路的图案。
  3. 刻蚀与掺杂:通过化学方法去除或修饰被曝光(或未曝光)区域的光刻胶及下方的硅材料,形成晶体管结构,并通过离子注入等方式形成P型或N型半导体区域。
  4. 沉积与互连:重复以上步骤,沉积绝缘层和金属层,并刻蚀出连接各个晶体管的铜互连线,形成多达十几层的立体结构。
  5. 切片与测试:将晶圆切割成单个的芯片(裸片),并进行测试。
  6. 封装:为裸片加上保护外壳和外部引脚,使其能够与电路板连接并散热。封装技术本身也在快速发展,如2.5D/3D封装、硅中介层等,以提供更高的互联带宽和集成度。

制造中的挑战与权衡

芯片制造面临诸多物理和工程挑战:

  • 缺陷与良率:由于尘埃、硅晶体缺陷等原因,制造出的芯片可能存在故障。芯片面积越大,出现缺陷的概率越高,良率就越低。这限制了单颗芯片的最大尺寸(通常在800平方毫米以下)。
  • 功耗与散热:随着晶体管密度增加,功耗和散热成为严峻挑战。芯片的热设计功耗是指导散热方案设计的关键指标。架构师通过动态电压频率调节、时钟门控等技术来管理功耗。
  • 登纳德缩放定律的终结:过去,晶体管尺寸缩小,其功耗密度保持不变。但近年来,这一定律已失效,单位面积功耗在上升,进一步加剧了散热和能效设计的难度。

为了应对良率问题,厂商常采用“产品分级”策略:将功能完好的芯片作为高端产品出售,而将有部分核心或缓存缺陷的芯片,通过禁用故障部分后,作为低端产品出售。

总结

本节课我们一起学习了计算机体系结构的定位,并深入探讨了芯片制造的物理基础与工程流程。我们从晶体管的工作原理出发,了解了CMOS技术如何构成现代数字电路的基石。随后,我们回顾了驱动半导体行业发展的摩尔定律,并详细拆解了从硅晶圆到封装芯片的复杂制造步骤,同时分析了制造过程中面临的缺陷、良率、功耗和散热等核心挑战。

理解这些硬件制造层面的约束,对于计算机体系结构设计师至关重要,因为我们的任务正是在这些物理限制下,设计出性能最优、能效最高的计算系统。从下节课开始,我们将进入数字逻辑和二进制数表示的世界,为后续的CPU架构设计打下基础。

002:布尔逻辑、二进制数与编码 🔢

在本节课中,我们将回顾计算机科学的基础知识:布尔逻辑、二进制数以及如何在计算机中表示和编码各种信息。这些概念是理解后续组合逻辑和时序逻辑等电路设计内容的基石。

布尔逻辑回顾 🧠

上一节我们介绍了课程概述,本节中我们来看看布尔逻辑的基础。布尔逻辑处理的是只有两种值的变量,这两种值可以用不同方式表示:真(True)或假(False)、1或0、高电平(High)或低电平(Low)。使用二进制表示是为了简化硬件设计并提高其可靠性。

以下是三种最基本的布尔逻辑运算及其真值表:

  • 非门(NOT Gate):简单地翻转输入值。

    • 公式:NOT X = X'
    • 真值表:
      X NOT X
      0 1
      1 0
  • 与门(AND Gate):仅当所有输入都为真时,输出才为真。

    • 公式:X AND Y = X · Y
    • 真值表:
      X Y X AND Y
      0 0 0
      0 1 0
      1 0 0
      1 1 1
  • 或门(OR Gate):只要有一个输入为真,输出就为真。

    • 公式:X OR Y = X + Y
    • 真值表:
      X Y X OR Y
      0 0 0
      0 1 1
      1 0 1
      1 1 1

除了这三个基本门,还有一个异或门(XOR Gate),它检测两个输入是否不同。如果不同,输出为1;如果相同,输出为0。公式:X XOR Y = X ⊕ Y

为了简化和优化逻辑电路设计,我们使用布尔代数定律。其中,德摩根定律(De Morgan‘s Laws) 非常有用,它描述了与、或、非运算之间的转换关系:

  • (X AND Y)' = X' OR Y'
  • (X OR Y)' = X' AND Y'

二进制数与整数表示 💻

理解了基本逻辑后,我们来看看计算机如何用二进制表示整数。我们的目标是用0和1表示一切。数字系统基于“基数”。例如,十进制数6715可以表示为:
6×10³ + 7×10² + 1×10¹ + 5×10⁰

将基数10替换为基数2,我们就得到了二进制表示。二进制只使用数字0和1。例如,二进制数1101转换为十进制是:
1×2³ + 1×2² + 0×2¹ + 1×2⁰ = 8 + 4 + 0 + 1 = 13

移位操作在二进制中非常高效:

  • 左移一位相当于乘以2。
  • 右移一位相当于除以2(向下取整)。

二进制加法遵循与十进制类似的规则,逐位相加并处理进位:

  1011  (11)
+ 0110  (6)
------
 10001  (17)

由于二进制表示较长,我们常用更紧凑的十六进制(Hexadecimal)八进制(Octal) 表示法。十六进制使用数字0-9和字母A-F(代表10-15),每四位二进制数对应一位十六进制数。

关于数据单位:

  • 比特(bit):一个二进制位,表示0或1。
  • 字节(Byte):通常由8个比特组成,是表示单个数字的常用单位。
  • 1 KB = 2¹⁰ = 1024 Bytes, 1 MB = 2²⁰ Bytes,依此类推。

一个n位无符号整数能表示的范围是0到2ⁿ - 1。例如,一个字节(8位)可以表示0到255之间的256个数字。

负数的表示:补码 🧮

计算机不仅需要处理正数,还需要处理负数。最直观的方法是使用符号-数值表示法,即用最高位表示符号(0正1负),其余位表示大小。但这种方法存在两个问题:存在+0和-0两种零;加法和减法电路需要分别设计。

实际计算机中广泛使用的是二进制补码(Two‘s Complement)。对于一个正数,其补码是其本身。对于一个负数(如-82),其补码通过以下步骤得到:

  1. 写出该数绝对值的二进制表示(82的二进制)。
  2. 按位取反(所有0变1,1变0)。
  3. 结果加1

补码表示法的优势在于,它使用同一套加法电路就能处理正数加法、负数加法以及正负数的减法,简化了硬件设计。在补码中,最高位同样指示符号(1为负),并且零只有一种表示(全0)。

浮点数表示 🌊

对于非整数的实数,计算机使用浮点数表示,遵循IEEE 754标准。以单精度(32位)浮点数为例,其格式为:

  • 1位:符号位(Sign)
  • 8位:指数位(Exponent) (采用偏移码表示)
  • 23位:尾数位(Mantissa) (表示小数点后的部分)

这种表示法可以覆盖极大和极小的数值范围。然而,由于总位数固定(32位),它在广袤的实数轴上必然存在间隔,无法精确表示所有实数,有时会产生舍入误差。

在机器学习等领域,为了追求更高的计算速度,常使用低精度浮点数(如FP16、BF16甚至FP8)或定点数(Fixed-Point)。定点数直接使用整数来表示一个预先确定好小数点位置的数,省去了复杂的指数处理单元,在面积和功耗上更具优势。

其他数据的编码 🎨

计算机还需要编码数字以外的数据。

文本(字符串)

  • ASCII:早期标准,用7位表示128个字符,主要用于英语。
  • Unicode:字符集,为全球所有字符分配唯一编号。
  • UTF-8:最常用的Unicode编码实现,变长编码(1-4字节),兼容ASCII。

图像

  • 位图(Bitmap):将图像离散化为像素网格。每个像素的亮度用一个数值表示(如0-255)。彩色图像常用RGB(红、绿、蓝)三个通道表示。
  • 矢量图(如SVG):用数学公式描述图形,缩放无损,适合图标和图形。

音频

  • 通过采样(Sampling)量化(Quantization) 将连续的声波转换为数字信号。
  • 两个关键参数:
    • 采样率(Sampling Rate):每秒采集样本的次数(如44.1 kHz)。
    • 位深度(Bit Depth):每个样本用多少位表示(如16位),决定动态范围。

总结 📚

本节课我们一起学习了信息编码的核心原理。我们从最基本的布尔逻辑(与、或、非)出发,探讨了如何使用二进制系统表示整数,并深入分析了表示负数的关键方法——补码。接着,我们了解了如何用浮点数表示实数及其在精度与效率间的权衡。最后,我们概览了文本、图像和音频等其他类型数据是如何被编码成二进制形式的。尽管二进制编码并非完美,但它足以以合理的精度表示模拟世界,并“欺骗”我们的感官。掌握这些编码基础,是理解后续计算机如何通过电路进行运算和存储的关键。

003:组合逻辑 🧠

在本节课中,我们将开始学习组合逻辑,并介绍如何设计一些基本的数字电路。

什么是组合逻辑?

组合逻辑本质上是一种数字电路,它可以根据输入生成输出。其输出取决于当前的输入,电路内部没有记忆功能,也没有反馈回路。这意味着,给定一组输入,电路总是会生成确定的输出,并且这个输出是即时产生的(在架构层面,我们通常忽略计算所需的时间)。

上一节我们介绍了数字电路的基础,本节中我们来看看组合逻辑的具体实现。

基本逻辑门

要实现布尔逻辑,我们需要使用逻辑门。以下是三种最核心的逻辑门:

  • 与门 (AND Gate):当所有输入都为1时,输出为1。
  • 或门 (OR Gate):当至少一个输入为1时,输出为1。
  • 非门 (NOT Gate):将输入取反,1变0,0变1。

除了这三种基本门,还有其他由它们组合而成的门:

  • 与非门 (NAND Gate):一个与门后接一个非门。NAND = NOT(AND)
  • 或非门 (NOR Gate):一个或门后接一个非门。NOR = NOT(OR)
  • 异或门 (XOR Gate):当两个输入不同时输出1,相同时输出0。XOR = (A AND NOT B) OR (NOT A AND B)

在CMOS电路设计中,与非门因其晶体管实现更简单,常被视为“通用门”,理论上仅用与非门就能构建任何逻辑功能。

缓冲器的作用

缓冲器并非逻辑门,但在底层电路设计中非常有用。它有两个主要作用:

  1. 信号放大:在长导线中,信号会衰减。加入缓冲器可以像中继器一样增强信号电压。
  2. 路径延迟匹配:在复杂电路中,不同路径的信号到达时间可能不同,这会导致错误。通过插入缓冲器,可以确保所有路径经过相同数量的门延迟,使信号同时到达。

缓冲器的真值表非常简单:输出始终等于输入。

从布尔表达式到电路

理解了基本门电路后,我们来看看如何将一个布尔表达式转化为实际的电路图。

假设我们有一个逻辑表达式:F = (A OR B) AND (A OR C) OR (A AND B) OR (B AND C) OR (NOT D)

以下是直接根据表达式绘制的电路图(可能不是最优设计):

  • 首先,分别计算 (A OR B)(A OR C),然后将它们的结果进行 AND 操作。
  • 同时,分别计算 (A AND B)(B AND C),然后将它们的结果进行 OR 操作。
  • 接着,将上一步的结果与 NOT D 进行 OR 操作。
  • 最后,将第一步和第三步的结果进行 OR 操作,得到最终输出 F

这种直接实现方式可能使用了过多的门,因此我们需要对表达式进行简化。

逻辑表达式简化

我们的简化目标是获得“积之和”或“和之积”形式,并尽可能减少运算符(即逻辑门)的数量。

对上述表达式 F = (A OR B) AND (A OR C) OR (A AND B) OR (B AND C) OR (NOT D) 进行逐步简化:

  1. 应用分配律展开:= A + AC' + AB + AB + BC + D' (其中 ' 表示 NOT
  2. 合并同类项并提取公因子:= A(1 + C' + B) + B(C' + C) + D'
  3. 利用布尔代数规则(任何值与1相或均为1,C' + C = 1):= A(1) + B(1) + D'
  4. 最终得到简化结果:F = A OR B OR (NOT D)

简化后的电路仅需三个门,远比原始设计高效。

从真值表到电路

有时我们并非从表达式开始,而是从一个定义好的真值表出发。以下是标准的设计流程:真值表 -> 布尔表达式 -> 电路实现

以设计一个异或门为例,其真值表要求:输入相异时输出1。

步骤1:根据真值表写出积之和表达式
观察输出为1的行:

  • X=0, Y=1 时,对应项为 X' AND Y
  • X=1, Y=0 时,对应项为 X AND Y'
    将这两项相或,得到表达式:F = (X' AND Y) OR (X AND Y')

步骤2:电路实现
根据表达式,使用两个与门、一个或门以及两个非门即可实现。

卡诺图简化法

对于包含3个或4个变量的表达式,手动简化可能比较困难。卡诺图提供了一种直观的图形化简化工具。其核心思想是:将真值表中相邻的、输出为1的单元圈在一起,每个圈可以合并并消去一个变化的变量。

卡诺图绘制与简化规则:

  1. 根据变量数画出网格,并确保相邻网格的编码只有一位变化。
  2. 根据真值表,在对应网格中填入输出值(1或0)。
  3. 开始画圈合并。画圈规则如下:
    • 必须覆盖所有输出为1的单元。
    • 每个圈内只能包含1、2、4、8...(2的幂次方)个单元。
    • 每个圈应尽可能大。
    • 圈的形状必须是矩形或正方形。
    • 卡诺图可以循环卷绕,即最左与最右相邻,最上与最下相邻。
    • 同一个“1”可以被多个圈包含。
  4. 根据每个圈写出最简项:圈内值保持不变的变量被保留,变化的变量被消去。如果该变量在圈内恒为1,则写原变量;若恒为0,则写反变量。
  5. 将所有圈对应的最简项相或,得到最简表达式。

组合逻辑构建模块

掌握了基本设计方法后,我们来看一些常用的、标准化的组合逻辑模块,它们像积木一样用于构建更复杂的系统。

半加器与全加器

  • 半加器:计算两个1位二进制数的和,产生一个和位与一个进位位。
    • 和位 Sum = A XOR B
    • 进位 C_out = A AND B
  • 全加器:计算两个1位二进制数及一个低位进位输入的和,产生一个和位与一个进位输出。
    • 可以用两个半加器和一个或门构建。
    • 和位 Sum = A XOR B XOR C_in
    • 进位 C_out = (A AND B) OR (B AND C_in) OR (A AND C_in)

多路选择器

多路选择器根据选择信号,从多个输入中选择一个送到输出。

  • 2选1多路选择器:输入 D0, D1,选择信号 S。当 S=0 时,Y=D0;当 S=1 时,Y=D1。其逻辑表达式为 Y = (S' AND D0) OR (S AND D1)
  • 可以扩展为4选1、8选1等,也可以扩展数据位宽(如32位宽的多路选择器)。

编码器与解码器

  • 编码器:将多个输入线(通常只有一条为有效)的状态,编码成位数更少的二进制输出。例如,4线-2线编码器。
  • 优先编码器:当多个输入同时有效时,根据预设的优先级进行编码。
  • 解码器:与编码器相反,将二进制输入解码,使对应的唯一输出线有效。例如,2线-4线解码器。

总结

本节课中,我们一起学习了组合逻辑的核心概念。我们了解到,组合逻辑是一种输出仅取决于当前输入的无记忆电路。我们从最基本的逻辑门(与、或、非)出发,学习了如何根据布尔表达式或真值表来设计电路,并掌握了使用代数法和卡诺图法对逻辑表达式进行简化的技巧。最后,我们认识了几种重要的组合逻辑构建模块:加法器、多路选择器、编码器和解码器。这些知识是理解复杂数字系统(如CPU)如何执行基本运算的基础。理论上,任何确定的输入/输出映射关系,都可以通过组合逻辑电路来实现。

004:时序逻辑 1

在本节课中,我们将开始学习时序逻辑。时序逻辑是数字电路设计中的核心概念,它允许电路拥有“记忆”能力,其输出不仅取决于当前输入,还取决于电路过去的状态。我们将从时序逻辑的基本构建模块——锁存器和触发器开始,并学习如何设计一个简单的同步时序电路。

从组合逻辑到时序逻辑

上一节我们介绍了组合逻辑,其特点是给定输入会立即产生确定的输出,电路本身没有记忆功能。然而,组合逻辑的能力是有限的,因为它只能执行固定的函数,且其功能受限于输入/输出引脚的数量。

本节中,我们来看看时序逻辑。时序逻辑电路引入了“状态”的概念。如下图所示,时序逻辑的核心仍然包含组合逻辑部分,但增加了存储元件(内存)和反馈回路。这使得电路的输出不仅取决于当前的输入状态,还取决于电路之前的状态。状态会随着时间的推移而更新,从而实现了更复杂的功能。

在大多数情况下,时序逻辑电路由一个时钟信号控制。时钟信号就像节拍器,以固定的频率(例如,CPU的几GHz)在高电平和低电平之间切换。在每个时钟周期内,组合逻辑完成一次计算,电路状态在时钟信号的特定边沿(如上升沿)进行更新。因此,组合逻辑的计算速度实际上限制了时钟可以运行的最快频率。

时序逻辑的构建模块:锁存器

组合逻辑的构建模块是逻辑门(如与门、或门、非门)。对于时序逻辑,我们则需要更复杂一些的组件,它们由多个门以特定方式连接而成,最基本的组件就是锁存器

在组合逻辑中,输入变化到输出稳定需要一段传播时间,在这段“瞬态”期间,输出可能会产生我们不希望的噪声波动。锁存器的作用就是“锁住”信号,使其在一段时间内保持不变,直到我们明确指示它更新。这就像门上的门闩,闩上时门就不会被打开。

SR锁存器

让我们从一个基本的锁存器——SR锁存器开始。它由两个或非门交叉耦合构成,有两个输入S(Set,置位)和R(Reset,复位),以及两个互补输出Q和Q'。

以下是其功能真值表:

S R Q (下一个状态) Q' (下一个状态) 模式
0 0 Q (保持原状态) Q' (保持原状态) 锁存/保持
0 1 0 1 复位 (Reset)
1 0 1 0 置位 (Set)
1 1 0 0 无效状态

  • 当 S=0, R=0 时:电路处于“保持”模式。输出Q和Q'将保持它们之前的状态不变。这是锁存功能的核心。
  • 当 S=0, R=1 时:电路“复位”,输出Q被强制设为0,Q'为1。
  • 当 S=1, R=0 时:电路“置位”,输出Q被强制设为1,Q'为0。
  • 当 S=1, R=1 时:这是一个无效状态,因为Q和Q'都变为0,违反了它们应互为反相的原则,在实际设计中应避免。

SR锁存器的问题在于需要两个控制信号(S和R),且输入模式不够直观。我们更希望有一个单独的控制信号来决定是“更新数据”还是“保持数据”。

D锁存器

为了解决上述问题,我们引入了D锁存器。D代表数据(Data)。它通过一个控制信号(通常称为使能端,或直接接时钟)和一个数据输入端D,来实现更简单的控制逻辑。

其目标真值表如下:

使能 D Q (下一个状态)
0 X Q (保持)
1 0 0
1 1 1

当使能信号为0时,无论D是什么,输出Q都保持不变(锁存模式)。当使能信号为1时,输出Q等于输入D(透明模式)。

我们可以通过一个组合逻辑电路,将“使能”和“D”信号转换为SR锁存器所需的S和R信号,从而用SR锁存器构建出D锁存器。推导出的逻辑方程为:

  • S = 使能 AND D
  • R = 使能 AND (NOT D)

对应的电路图如下:

在实际中,我们常将“使能”信号替换为时钟信号。这样,当时钟为低电平时,D锁存器处于锁存模式;当时钟为高电平时,它处于透明模式,Q跟随D变化。其波形如下图所示:

可以看到,在时钟高电平期间(透明期),Q随D变化;在时钟低电平期间(保持期),Q保持不变。D锁存器就像一个由时钟控制的开关:透明期如同导线连通,保持期则断开连接并保持之前的值。

从锁存器到触发器

D锁存器仍然存在问题:它在半个时钟周期(透明期)内输出是不稳定的,会随输入波动。我们希望在整个时钟周期内,输出都只在某个特定时刻变化一次,其余时间保持稳定。

解决方案是使用两个D锁存器级联,构成主从D触发器

  • 第一个锁存器称为“主”锁存器,它在时钟为低电平时透明。
  • 第二个锁存器称为“从”锁存器,它在时钟为高电平时透明,但它的输入来自主锁存器的输出。

由于两个锁存器的透明期被反相时钟错开,其效果是:输出Q只会在时钟的上升沿(或下降沿)瞬间采样输入D的值,并在此后整个时钟周期内保持该值不变,直到下一个边沿到来

其波形图清晰地展示了这一特性:

注意观察,输出Q的变化只发生在时钟的上升沿时刻。在上升沿,Q的值被更新为D在那一时刻的值。此后,无论D如何变化,Q都保持不变,直到下一个上升沿。触发器是构成计算机中寄存器的基础。一个触发器可以存储1位数据。在电路图中,我们通常用以下符号表示一个边沿触发的D触发器:

同步逻辑设计与状态机

现在,我们有了核心的存储元件——触发器(寄存器)。接下来看看如何用它们和组合逻辑来设计完整的时序电路。这种设计方法遵循同步逻辑设计原则:

  1. 所有电路元素要么是组合逻辑,要么是寄存器。
  2. 所有寄存器由同一个时钟信号驱动。
  3. 任何循环路径(反馈)必须至少包含一个寄存器。

寄存器在电路中扮演着多重角色:它是门卫,只允许信号在时钟边沿通过;它是1位存储器;它也是分隔符,将连续的计算过程划分为离散的时钟周期步骤。

设计同步时序电路的一个强大心智模型是状态机。状态机将系统行为描述为一系列“状态”,以及状态之间在特定输入条件下如何“转移”。这改变了我们的设计思路:组合逻辑不能“等待”,它总是立即根据当前输入产生输出。而寄存器则保存了“当前状态”,作为下一周期组合逻辑的输入。

设计实例:交通灯控制器

让我们通过一个简单的交通灯控制器例子,来实践从问题描述到电路实现的全过程。

1. 定义输入与输出

  • 输入:两个方向的道路传感器 TATB1表示有车,0表示无车。
  • 输出:两个方向的交通灯 LALB。每个灯有红(R)、黄(Y)、绿(G)三种状态。我们可以用2位编码表示:00=绿,01=黄,10=红。

2. 确定状态
根据交通灯的正常循环,我们可以定义四个状态:

  • S0: LA=绿, LB=红
  • S1: LA=黄, LB=红
  • S2: LA=红, LB=绿
  • S3: LA=红, LB=黄

3. 绘制状态转移图
基于交通规则(例如,绿灯方向在有车时保持,无车时经过黄灯切换),我们绘制出状态之间的转移条件和方向。

4. 创建状态转移表
将状态转移图转化为表格形式,列出当前状态、输入与下一个状态的对应关系。

当前状态 输入 TA, TB 下一状态
S0 1X S0
S0 0X S1
S1 XX S2
S2 X1 S2
S2 X0 S3
S3 XX S0

(X表示“无关项”)

5. 状态与输出编码

  • 状态编码:4个状态需要2位二进制编码。例如:S0=00, S1=01, S2=10, S3=11
  • 输出编码:如前所述,00=绿,01=黄,10=红。

将编码代入状态转移表,得到一张完全由0和1组成的真值表。

6. 推导逻辑方程
将编码后的状态转移表视为一个组合逻辑问题:输入是“当前状态位”和“传感器输入”,输出是“下一状态位”和“灯信号输出”。我们可以为每个输出位(如下一状态的bit0, bit1,以及LA, LB的各个信号位)推导出布尔表达式。

例如,通过卡诺图或观察简化,可能得到如下形式的方程(具体取决于编码和转移表):

  • 下一状态位1 = (当前状态位1) XOR (当前状态位0)
  • LA_绿 = NOT(当前状态位1) AND NOT(当前状态位0)
  • ...(其他输出位方程)

7. 绘制电路图
根据推导出的逻辑方程,我们可以绘制出最终的电路。电路核心包含:

  • 一组寄存器:用于存储当前状态(例如2个触发器)。
  • 下一状态组合逻辑:根据当前状态和输入TA/TB,计算下一状态的值,并反馈到寄存器的输入端。
  • 输出组合逻辑:根据当前状态,直接生成控制交通灯LA和LB的信号。

一个简化的电路框图如下所示:

当时钟上升沿到来时,下一状态的值被存入状态寄存器,成为新的当前状态。输出逻辑随即根据新状态更新交通灯显示。如此循环往复。

总结

本节课中我们一起学习了时序逻辑的基础知识。

  • 我们首先了解了时序逻辑与组合逻辑的根本区别:时序逻辑具有状态记忆功能
  • 然后,我们深入探讨了时序逻辑的基本构建模块:锁存器触发器。重点是D触发器,它只在时钟边沿采样输入,并在周期内保持输出稳定,其行为可以概括为:在时钟上升沿,Q = D
  • 最后,我们通过交通灯控制器的设计实例,学习了同步逻辑设计有限状态机的方法论。这个过程包括:定义输入输出、确定状态、绘制状态图、创建状态表、进行编码、推导逻辑方程并最终实现电路。

掌握从抽象问题描述到具体电路实现的设计流程,是理解复杂计算系统(如CPU)如何构建的关键第一步。在接下来的课程中,我们将看到更多时序逻辑的构建模块和复杂设计。

005:时序逻辑 2 🧠

在本节课中,我们将继续学习时序逻辑。我们将巩固上一讲的知识,并介绍一些时序逻辑的基本构件。最终,我们将能够使用这些时序逻辑元件来构建CPU核心或其他存储单元。

回顾:时序逻辑与组合逻辑

上一节我们介绍了时序逻辑的基本概念。本节中,我们来看看时序逻辑与组合逻辑的核心区别。

时序逻辑是一种数字电路,其输出取决于电路的先前状态。这个先前状态存储在存储器中,通常由寄存器实现。电路中有反馈信号,并且整个电路由时钟信号控制。

相比之下,组合逻辑电路没有记忆功能。对于任何输入,它都会产生一个预定的输出,并且这个计算过程被认为是瞬间完成的。

在时序逻辑中,寄存器是关键元件。它只在时钟信号的上升沿(或下降沿,取决于设计)响应,并在该时刻捕获输入数据。在时钟周期的其余时间里,寄存器会保持这个值不变,直到下一个时钟边沿到来。

使用有限状态机设计电路 🔄

有限状态机是设计数字电路的一个核心概念。它不是一个具体的硬件,而是一种模型,用于描述电路在不同状态下的行为以及状态之间的转换规则。

以下是使用FSM设计时序逻辑电路的一般步骤:

  1. 列出所有可能的状态:确定系统可能处于的所有情况。
  2. 列出所有可能的输出:明确每个状态下电路应产生的输出。
  3. 绘制状态转移图:用图形表示状态之间的转换关系及触发条件。
  4. 对状态进行编码:为每个状态分配一个唯一的二进制编码。
  5. 列出状态转移表:以表格形式详细列出当前状态、输入、下一状态和输出之间的关系。
  6. 推导逻辑表达式并设计电路:根据真值表,写出布尔表达式,简化后即可绘制出对应的组合逻辑电路。

接下来,我们通过一个更具体的例子来理解整个过程。

实例:电子门锁设计 🔐

假设我们要设计一个简单的电子门锁,密码为“1234”。用户需要按顺序输入四个数字,然后按确认键。如果密码正确,则开锁;如果错误,则提示错误。

1. 定义状态与输出

首先,我们定义系统可能的状态:

  • Idle (空闲):初始状态,等待输入。
  • C1, C2, C3 (正确状态):分别表示第1、2、3位密码输入正确。
  • E1, E2, E3 (错误状态):分别表示第1、2、3位密码输入错误。
  • Unlock (开锁):密码验证正确,执行开锁动作。

输出可以是控制锁舌的马达信号和错误提示灯。

2. 绘制状态转移图

基于以上状态,我们可以绘制出状态转移图。其核心逻辑是:

  • Idle 开始,输入第一位密码。
    • 若正确,进入 C1
    • 若错误,进入 E1
  • C1 状态,输入第二位密码。
    • 若正确,进入 C2
    • 若错误,进入 E2
  • E1 状态,无论输入什么,都进入 E2(因为第一位已经错了)。
  • 以此类推,直到输入第四位密码。
    • 如果在 C3 状态输入正确的第四位密码,则进入 Unlock 状态。
    • 否则,返回 Idle
  • Unlock 状态,执行开锁动作(如启动马达),完成后自动返回 Idle 状态。
  • 任何时候按下重置键,都应立即返回 Idle 状态。

3. 状态编码与电路实现

由于我们有8个状态,至少需要3位二进制数进行编码,例如:

  • Idle: 000
  • C1: 001
  • C2: 010
  • C3: 011
  • E1: 100
  • E2: 101
  • E3: 110
  • Unlock: 111

接着,我们可以根据状态转移图列出真值表,表中包含当前状态编码、输入信号、下一状态编码和输出信号。最后,为每一位下一状态和输出信号推导出布尔表达式,并设计出对应的组合逻辑电路,与寄存器相连,就构成了完整的时序逻辑系统。

关键点:在实际设计中,我们通常用时钟边沿来触发状态转换,而不是直接用不稳定的按键信号。这意味着在每个时钟周期,电路都会检查输入条件,并决定是否转换到下一个状态。

从电路反推有限状态机 🔍

理解了如何从设计到电路后,我们也可以反向操作:给定一个时序逻辑电路,推导出它实现的有限状态机。

假设有一个包含2位寄存器(状态 S1, S0)、2位输入(A1, A0)和1位输出(Unlock)的电路,其组合逻辑的布尔表达式为:

  • Unlock = S1
  • N1 = S0 AND (NOT A1) AND A0
  • N0 = (NOT S1) AND (NOT S0) AND A1 AND A0
    (其中 N1, N0 是下一状态输入)

推导步骤

  1. 列出所有输入组合:包括当前状态 S1S0 (00, 01, 10, 11) 和外部输入 A1A0 (00, 01, 10, 11)。
  2. 计算下一状态和输出:将每组 S1,S0,A1,A0 代入上述公式,计算出对应的 N1,N0Unlock,形成真值表。
  3. 状态解码:将二进制状态编码 S1S0 映射为有意义的符号,如 S0=00, S1=01, S2=10, S3=11
  4. 绘制状态转移图:分析真值表,找出每个状态在不同输入下会转换到哪个状态,以及输出是什么。例如,可能发现这是一个序列检测器,只有当输入序列为“3,1”时,才会进入 Unlock 状态(S1=1)。

通过这个练习,可以加深对时序逻辑电路行为与状态机描述之间等价关系的理解。

常用时序逻辑构件 ⚙️

掌握了FSM的设计方法后,我们来看看几种广泛使用的标准时序逻辑构件,它们可以看作是特定类型的FSM。

1. 计数器

计数器是最简单的时序逻辑电路之一。一个N位计数器包含一个N位寄存器和一个始终加1的加法器。

// N位计数器的核心行为(每时钟周期)
if (reset) {
    count <= 0;
} else {
    count <= count + 1;
}

它实现了一个状态循环为 0 -> 1 -> 2 -> ... -> (2^N -1) -> 0 的FSM。计数器是许多复杂功能的基础。

2. 分频器与数字控制振荡器

分频器用于降低时钟频率。例如,一个20位的计数器,其最高位的变化频率就是原时钟频率的 1/(2^20)。用这个信号驱动LED,就能实现约1秒闪烁一次的效果(假设原时钟为1MHz)。

数字控制振荡器则更为灵活。它通过改变计数器的递增值P(而非固定的1)来精确控制输出频率。

  • 公式F_out = (P / 2^N) * F_clock
    通过调整P和N,可以在低于原时钟频率的范围内产生近乎任意的输出频率。

3. 脉宽调制信号发生器

PWM信号通过固定频率、改变一个周期内高电平的占比(占空比)来传递信息,常用于控制电机速度、LED亮度等。

其核心电路包含:

  • 一个N位计数器:用于生成基础周期。
  • 一个比较器:将计数器的当前值与一个可调的阈值(由期望占空比决定)进行比较。
  • 输出逻辑:若计数值小于阈值,输出高电平;否则输出低电平。
    通过改变阈值,即可线性调节输出信号的占空比。

4. 按键消抖电路

机械按键在按下或释放时,会产生持续数毫秒的电压抖动,导致被误认为是多次按压。消抖电路用于滤除这些抖动,确保一次按压只产生一个干净的脉冲信号。

一种常见的消抖电路设计包含:

  • 一个寄存器:同步按键输入,将其变化对齐到时钟边沿。
  • 一个计数器:当检测到按键被按下(寄存器输出为1)时开始计数。
  • 一个比较器与反馈逻辑:只有当计数器值超过某个预设的消抖时间阈值(如对应20ms的计数值)时,才最终输出“按键已按下”的信号。同时,该输出信号会反馈回去,在按键保持按下期间禁用计数器,防止重复触发。当按键释放时,电路被复位。

存储器阵列 🗃️

寄存器虽然速度快,但每个比特需要多个晶体管(如6T SRAM单元),密度低、成本高,不适合做大容量存储。计算机中的主存采用另一种结构:存储器阵列

基本结构

存储器阵列的核心接口是:

  • 地址:指定要访问哪个存储单元。
  • 数据:要写入或读出的信息。
  • 控制信号:如读/写使能。
    其内部通常组织成字线位线交叉的矩阵。地址经过译码器选中某一行(字线),这一行上所有存储单元被激活,通过位线进行数据的读取或写入。

DRAM vs. SRAM

  • DRAM:每个存储单元由一个晶体管和一个电容组成。电容存储电荷代表数据。优点是结构简单、密度极高、成本低,是现代计算机主存的标准。缺点是需要定期刷新以防止电荷泄漏,且读取是破坏性的,读后需要重写。
  • SRAM:每个存储单元本质上是一个锁存器,通常由6个晶体管组成。优点是速度快、不需要刷新、非破坏性读取。缺点是占用面积大、功耗高、成本高。常用于CPU的高速缓存和寄存器文件。

ROM

只读存储器在制造时就将数据“固化”在电路中,只能读取,不能写入。一种简单的实现方式是在存储阵列的位线和字线交叉点,有选择地制造连接(代表1)或不连接(代表0)。当某条字线被选中时,连接了该字线的位线会被拉高,从而读出存储的数据。

总结 📚

本节课我们一起深入学习了时序逻辑。

  • 我们首先回顾了时序逻辑与组合逻辑的根本区别,明确了寄存器在存储状态和同步时钟中的核心作用。
  • 接着,我们系统性地学习了如何使用有限状态机来设计和分析时序逻辑电路,并通过电子门锁的实例演练了从需求到电路实现的完整流程。
  • 然后,我们介绍了几种关键的时序逻辑构件,包括计数器、分频器、PWM发生器和按键消抖电路,理解了它们作为特定FSM的应用。
  • 最后,我们探讨了存储器阵列的基本原理,对比了DRAM和SRAM这两种主要存储技术的特点与用途。

通过本讲,你应该对中大型数字系统(如CPU)如何由基本的时序和组合逻辑模块构建而成有了更坚实的认识。这些基础模块是构成复杂计算系统的基石。

006:时序逻辑 3

在本节课中,我们将继续探讨时序逻辑,并开始构建计算机的基本模块。我们将从计数器过渡到更复杂的组件——存储器阵列,并深入了解其工作原理、不同类型以及它们在计算机架构设计中的权衡。

上一节我们介绍了计数器,本节我们将深入探讨存储器阵列。

存储器阵列概述

存储器阵列是一种通用的存储结构。其输入是地址。若进行读操作,输入地址,输出数据。若进行写操作,输入包括地址和数据,此时没有输出。

一个存储器阵列有两个关键参数:深度宽度。宽度指每个存储单元的大小,例如寄存器文件通常是4字节,缓存可能是32、64或128字节,这取决于数据读取的粒度。深度指存储容量的大小,能存储大量数据还是少量数据,取决于芯片所能支持的尺寸。

存储器阵列的组织结构

存储器阵列通常按以下方式组织:拥有字线和位线。位线的数量代表每个单元的位数,即宽度。字线的数量决定了存储器阵列的深度。

基本上,如果拥有更多位线,从该存储器阵列获取数据的带宽就更大。每周期可以输出更多数据。

存储单元

存储单元是存储1位数据的电路。一个字线和一条位线的交叉点可以存储1位数据。

  • 读操作:首先将字线从0设置为1,以启用该位进行读取。设置字线为1实际上是广播到所有存储单元,因此所有单元被启用,其电压将传递到位线。通过感测位线,可以知道输出是0还是1。
  • 写操作:首先将位线从高阻态设置为要存储的值(0或1)。当启用字线时,由于位线上的电压强于存储单元中实际存储的电压,电压会流入存储单元并被存储在那里。

不同类型的存储技术

存在不同类型的存储单元和创建这些单元的不同技术。这始终是数据访问速度、存储容量和成本之间的权衡。如果存在一种“最佳”技术,所有人都会使用它。正因它们具有不同的特性与优势,我们在不同场景下使用不同类型的技术来构建存储器阵列。

以下是核心的存储技术对比:

  • DRAM:动态随机存取存储器。
    • 随机存取意味着可以读取和写入任何位置的数据,没有顺序限制。其对立面是顺序存取存储器,例如磁带。
    • 动态意味着如果不对存储单元持续刷新(写入),数据就会消失。其基本单元是一个晶体管加一个电容。
    • 读取数据会破坏数据,因此每次读取后必须重新写入相同数据。
    • 电容上的电荷会自行流失,因此需要定期刷新(例如每几百微秒到毫秒)。
    • 优点:密度高,成本低,容量大(可达数十GB)。
    • 缺点:速度较慢,访问延迟高(数百周期),需要刷新电路。
  • SRAM:静态随机存取存储器。
    • 静态意味着只要通电,数据就会一直保持,无需刷新。
    • 其基本单元通常由6个晶体管构成,形成两个交叉耦合的反相器,类似于SR锁存器。
    • 优点:速度极快,访问延迟低(1到几个周期)。
    • 缺点:密度低,成本高,功耗大,容量小(通常为KB到MB级)。
  • ROM:只读存储器。
    • 只能读取数据,数据由制造商写入。
    • 早期通过硬连线连接实现(连接代表1,断开代表0),例如阿波罗登月计算机使用的磁芯存储器。
    • 现代更多使用闪存等技术,但ROM的概念仍然存在,用于存储固件、BIOS等。

存储层次结构与性能权衡

计算机采用存储层次结构来平衡速度、容量和成本。典型层次如下(延迟递增,容量递增):

  1. 寄存器:位于CPU核心内,速度最快,容量最小(约1周期延迟)。
  2. L1缓存:通常由SRAM实现,容量小,速度快(<5周期延迟)。
  3. L2/L3缓存:由SRAM实现,容量较大,速度较慢(数十周期延迟)。
  4. 主存:由DRAM实现,容量大(GB级),速度慢(数百周期延迟)。
  5. 硬盘/SSD:容量极大,速度极慢(数千到数万周期延迟)。

考虑一个操作:A = B + C。这需要从DRAM读取B和C,相加,再将结果A写回DRAM。如果DRAM访问需100周期,加法只需1周期,那么计算单元的利用率仅为 1 / (100+100+100+1) ≈ 0.33%。这非常低效,因此必须使用SRAM缓存来存放频繁访问的数据,减少访问DRAM的次数。

寄存器文件的设计

寄存器文件是CPU核心的一部分,我们不仅关注其容量和延迟,还开始考虑并行性或并发性。我们需要它能够同时提供多个数据,以支持多个操作同时进行。

一个典型的寄存器文件设计具有多个读写端口。例如,一个支持“两读一写”的寄存器文件:两个读端口(A1->RD1, A2->RD2)和一个写端口(A3, WD3)。这样设计是因为许多指令(如加、减、乘)需要两个输入操作数和一个输出结果。

以下是三种寄存器文件的设计方式:

  1. 基于触发器的多端口设计

    • 使用触发器存储每一位数据(每个触发器约20个晶体管)。
    • 地址输入通过解码器选择对应的寄存器(字线)。
    • 读操作:被选寄存器的数据通过多路选择器路由到输出端口。
    • 写操作:通过选择器决定是写入新数据还是回写原有数据以保持值不变。
    • 优点:速度极快,延迟低。
    • 缺点:面积大,功耗高。适用于寄存器数量不多的CPU。
  2. 基于SRAM的多端口设计

    • 使用SRAM单元(6晶体管/位)作为存储单元。
    • 拥有独立的字线、位线用于读写端口,逻辑上几乎是三组独立的输入输出。
    • 通过“与”门等结构实现多路选择功能。
    • 优点:比触发器设计节省面积。
    • 缺点:布线更复杂。常用于对面积敏感的设计。

  1. 多体存储结构设计
    • 将寄存器文件分成多个体,每个体是单端口存储器(一次只能进行一次读或写)。
    • 寄存器通过交错方式分布到各个体(例如,寄存器0到体0,寄存器1到体1,寄存器2到体0,寄存器3到体1,依此类推)。
    • 优点:大幅减少全局连线,节省面积,扩展性好。
    • 缺点:可能发生体冲突。当需要同时访问映射到同一个体的两个寄存器时,访问必须被序列化,导致额外延迟。
    • 这是GPU中常见的设计,因为GPU拥有大量寄存器且对延迟相对不敏感。通过增加体的数量可以减少冲突概率。

只读存储器与逻辑阵列

ROM不仅可以存储数据,其固定连接的结构本身就能实现逻辑功能。可以将ROM看作一个预先定义好的查找表:对于任何输入,都对应一个确定的输出。

通过精心设计ROM内部的连接(在制造时决定),它可以实现“与或”逻辑。例如,提供输入的原变量和反变量,通过一个“与”平面(产生所有最小项)和一个“或”平面(选择所需的最小项进行或运算),可以实现任何组合逻辑函数。

这种思想是可编程逻辑阵列(PLA)和现场可编程门阵列(FPGA)的基础。FPGA本质上由大量可编程的查找表(LUT)和可编程的互连网络构成,可以配置实现任意数字电路。现代FPGA还集成了硬核乘法器、存储器块等,功能强大,常用于原型验证和特定加速计算。

总结

本节课我们一起完成了时序逻辑部分的探讨,实际上这五讲内容涵盖了本科数字设计课程的核心。我们学习了:

  • 时序逻辑和有限状态机,以及如何使用它们设计电路(如模式检测器)。
  • 计算机的基本构建模块:计数器、存储器阵列。
  • 存储器技术:SRAM、DRAM、ROM,及其在速度、密度、功耗上的权衡。
  • 存储层次结构的重要性,它平衡了延迟和容量。
  • 寄存器文件的多种设计方法,特别是多体结构及其体冲突问题。
  • 只读存储器如何作为查找表实现逻辑功能,并引出了FPGA的基本概念。

至此,我们将不再专注于绘制门级电路,而是站在更高抽象层次(如多路选择器、寄存器文件模块)来思考更复杂的系统设计。从下周开始,我们将暂时偏离电路设计,学习如何编写模拟器来仿真这些数字逻辑乃至整个处理器核心,为后续的CPU模拟实验打下基础。

007:计算机体系结构模拟 1

在本节课中,我们将要学习计算机体系结构模拟的基本概念、目的和方法。我们将探讨为什么需要模拟、不同类型的模拟器及其权衡,并初步了解 Akita 模拟器框架的基本原理。

概述:为什么需要模拟?

设计新的计算机硬件(如 CPU、GPU)成本极高。例如,制造一颗 5 纳米芯片的成本可能超过 5 亿美元。因此,在投入实际生产之前,研究人员和工程师必须使用模拟器来验证设计理念、分析性能瓶颈并进行设计空间探索。

模拟器允许我们:

  • 在设计早期验证想法:快速排除不可行的方案。
  • 进行瓶颈分析:找出程序运行中的性能限制因素。
  • 进行设计空间探索:测试不同配置(如缓存大小)对性能的影响。
  • 评估硬件算法:在不实际构建硬件的情况下测试新算法。

模拟方法的分类

上一节我们介绍了模拟的必要性,本节中我们来看看如何对模拟器进行分类。模拟器可以从多个维度进行区分。

模拟的抽象层级

根据对硬件细节的建模精度,模拟可以分为不同层级:

  • 模拟级模拟:关注晶体管级的电压、电流等物理特性,精度最高,但速度极慢。
  • 寄存器传输级(RTL)模拟:在时钟周期内对数字电路(组合逻辑和寄存器)的行为进行建模,关注比特级的准确性。通常使用 Verilog 或 VHDL 描述。
  • 周期级模拟:以时钟周期为步进单位模拟硬件行为,通常用高级语言(如 C++、Go)实现,不追求比特级精度,但追求时序准确性。
  • 事务级模拟:在更高的抽象层级(如“完成一次矩阵乘法需 1 秒”)上模拟,只关心时间估计,不关心具体功能,速度最快。

核心权衡公式模拟精度 ∝ 1 / 模拟速度

周期级模拟在精度和速度之间取得了较好的平衡,是本课程关注的重点。

功能模拟与时序模拟

根据模拟目标的不同,模拟器可分为两类:

  • 功能模拟(仿真):目标是精确复现硬件执行的功能和结果,确保程序输出与真实硬件一致。它不关心或无法准确反映执行时间。常用于软件测试、硬件功能验证(如游戏模拟器、量子计算模拟器)。
  • 时序模拟:目标是预测程序在特定硬件配置下的执行时间性能指标(如缓存命中率、能耗)。它需要周期级或更细粒度的建模。为了处理数据依赖性(如循环次数),时序模拟通常需要与功能模拟结合(执行驱动),或依赖预先采集的执行轨迹(轨迹驱动)。

事件驱动与周期驱动模拟

在时序模拟内部,根据时间推进机制,可分为:

  • 周期驱动模拟:在每个时钟周期,模拟器“唤醒”所有组件,更新其状态。实现简单直观,但难以处理多时钟域或长空闲时间,可能造成效率低下。
    // 伪代码示例:周期驱动循环
    for cycle := 0; cycle < totalCycles; cycle++ {
        for each component in allComponents {
            component.Tick(cycle) // 每个组件更新状态
        }
    }
    
  • 事件驱动模拟:模拟器维护一个未来事件队列。只有当事件预定发生时,相关的组件才会被激活和处理。时间可以“跳跃”到下一个事件发生点。效率高,能灵活处理多时钟域,但实现更复杂。
    // 伪代码示例:事件驱动循环
    while eventQueue not empty {
        event = eventQueue.popEarliestEvent()
        currentTime = event.scheduledTime
        event.handler.Process(event) // 处理该事件,可能生成新事件加入队列
    }
    

现代模拟器(如 gem5、Akita)通常采用混合策略,结合两者优点。

Akita 模拟器框架初探

了解了模拟的基本分类后,我们聚焦到本课程将使用的 Akita 框架。Akita 是一个用 Go 语言编写的、底层采用事件驱动的模拟框架,旨在平衡开发复杂度和模拟性能。

核心概念与接口

Akita 的核心围绕三个接口构建:

  1. 时间表示:使用 VTimeInSec 类型(本质是 float64)表示虚拟时间(单位:秒),与真实时间区分。
    type VTimeInSec float64
    
  2. 事件Event 接口定义了在特定时间 (Time()) 由特定处理程序 (Handler()) 执行的动作。
    type Event interface {
        Time() VTimeInSec
        Handler() Handler
    }
    
  3. 事件处理程序Handler 接口负责处理事件。
    type Handler interface {
        Handle(Event) error
    }
    
  4. 引擎Engine 接口是模拟器的心脏,负责管理当前时间、调度事件和运行模拟。
    type Engine interface {
        CurrentTime() VTimeInSec
        Schedule(ev Event) // 调度未来事件
        Run() // 运行模拟直至无事件可处理
    }
    

一个简单示例:细胞分裂模拟

让我们通过一个“细胞分裂”的例子,直观理解 Akita 模拟的工作流程。模拟规则:初始有 1 个细胞,每个细胞在诞生后的 1 到 2 秒之间会分裂成两个新细胞,模拟运行 10 秒。

以下是实现的关键步骤:

步骤 1:定义事件
我们定义一个 SplitEvent,表示细胞分裂事件。

type SplitEvent struct {
    fireTime VTimeInSec
    handler  Handler
}
// 实现 Event 接口的 Time() 和 Handler() 方法
func (e *SplitEvent) Time() VTimeInSec { return e.fireTime }
func (e *SplitEvent) Handler() Handler { return e.handler }

步骤 2:实现事件处理程序
CellSplitHandler 负责处理分裂事件:增加细胞计数,并为每个新细胞调度下一次分裂事件。

type CellSplitHandler struct {
    count int
}
func (h *CellSplitHandler) Handle(ev Event) error {
    h.count++ // 一个变两个,数量+1
    e := ev.(*SplitEvent)
    now := e.Time()
    // 为每个“新细胞”调度下一次分裂
    for i := 0; i < 2; i++ {
        nextSplitTime := now + 1 + rand.Float64() // 1-2秒后
        if nextSplitTime < 10 { // 只模拟10秒内的事件
            nextEv := &SplitEvent{
                fireTime: nextSplitTime,
                handler:  e.Handler(),
            }
            engine.Schedule(nextEv) // 关键:调度未来事件
        }
    }
    return nil
}

步骤 3:组装并运行模拟
创建引擎、处理程序,调度初始事件,然后启动引擎。

func main() {
    engine := NewSerialEngine() // 创建串行引擎
    handler := &CellSplitHandler{count: 1} // 初始1个细胞
    // 调度第一个分裂事件
    firstEv := &SplitEvent{
        fireTime: 1 + rand.Float64(),
        handler:  handler,
    }
    engine.Schedule(firstEv)
    engine.Run() // 开始模拟
    fmt.Printf("Cell count at time 10: %d\n", handler.count)
}

模拟引擎会不断从事件队列中取出最早的事件,调用其处理程序,从而链式地触发更多事件,直到 10 秒后不再有新事件,模拟结束。

引擎内部工作原理简介

最后,我们简要看看 Akita 引擎(以串行引擎为例)内部如何工作:

  1. 调度 (Schedule):将事件插入一个按时间排序的优先队列(最小堆)。确保事件时间不早于当前时间。
  2. 运行 (Run):引擎进入一个主循环。
    • 从队列中取出时间最早的事件
    • 将引擎的当前时间推进到该事件的时间。
    • 调用该事件的 Handler().Handle(event) 方法执行事件。
    • 处理程序执行过程中可能会调度新的未来事件。
    • 重复此过程,直到事件队列为空。

这种集中式事件队列的管理方式是 Akita 的设计特点之一。

总结

本节课中我们一起学习了计算机体系结构模拟的基础知识。我们了解了使用模拟器验证设计、探索设计空间的必要性。我们掌握了从抽象层级(模拟级、RTL、周期级、事务级)、模拟目标(功能vs时序)以及时间推进机制(周期驱动vs事件驱动)等多个维度对模拟器进行分类的方法。最后,我们初步接触了 Akita 模拟器框架,理解了其以事件驱动为核心的 EventHandlerEngine 核心接口,并通过一个细胞分裂的示例,看到了如何利用这些接口构建一个简单的动态模拟。在接下来的课程中,我们将更深入地学习如何使用 Akita 构建具体的计算机体系结构模型。

008:计算机体系结构模拟 2 🚀

在本节课中,我们将继续深入探讨计算机体系结构模拟,重点学习并行模拟引擎的实现原理、Akita模拟框架中的组件与端口设计,以及事件驱动与周期驱动模拟的差异与优化。

概述

上一节我们介绍了零引擎(Zero Engine)如何通过事件队列控制模拟的时序。本节中,我们将在此基础上,探讨如何实现并行模拟引擎,并深入了解Akita框架中组件、端口和连接的核心概念,最后初步接触“智能节拍”技术以优化模拟性能。


并行模拟引擎 🔄

在零引擎中,所有事件按时间顺序串行执行。然而,在数字电路中,许多组合逻辑可以在同一时钟周期内并行计算。因此,我们可以设计一个并行模拟引擎,让同一时刻调度的事件能够并行执行。

并行引擎遵循与零引擎相同的接口,包含五个核心方法。这样,从组件或用户的角度看,他们无需关心底层使用的是串行还是并行引擎,只需知道可以调度未来事件即可。

并行引擎的工作原理

并行引擎的核心思想是:将同一时刻(同一“轮次”)调度的事件分配到不同的线程中并行执行。这被称为保守并行离散事件模拟,它保证了事件的时间顺序与串行模拟完全一致,从而确保了准确性,但可能会限制可扩展性。

以下是并行引擎运行一轮事件的关键代码逻辑概述:

func (e *ParallelEngine) runRound(now akita.VTimeInSec) {
    // 1. 从队列池中取出所有队列,防止新事件在此期间被调度进来
    e.drainQChan()

    // 2. 并行执行当前时刻的所有事件
    var wg sync.WaitGroup
    for _, q := range e.queues {
        for event := q.Peek(); event != nil && event.Time() == now; event = q.Peek() {
            q.Pop()
            wg.Add(1)
            // 为每个事件启动一个协程(轻量级线程)执行
            go func(ev akita.Event) {
                e.runEventWithTempWorker(ev)
                wg.Done()
            }(event)
        }
        // 3. 将处理完的队列放回池中
        e.qChan <- q
    }
    // 4. 等待本轮所有事件执行完毕,再进入下一轮
    wg.Wait()
}

关键点

  • 队列池:使用多个队列代替单一队列,避免调度事件成为全局性能瓶颈。
  • 协程:利用Go语言的轻量级协程并行执行事件。
  • 同步:通过 sync.WaitGroup 确保同一轮次的所有事件完成后,模拟时间才能向前推进。

软件设计原则在Akita中的应用 🏗️

在构建像Akita这样复杂的模拟框架时,遵循良好的软件设计原则至关重要。这里我们重点介绍两个在Akita中广泛应用的原则。

依赖倒置原则

该原则要求:高层模块不应依赖低层模块,二者都应依赖于抽象。换句话说,要依赖于接口,而不是具体实现。

反面例子(硬依赖)

type MyComponent struct {
    engine *ZeroEngine // 直接依赖具体的 ZeroEngine
}

如果未来想换成 ParallelEngine,就需要修改所有组件的代码。

正确做法(依赖接口)

type MyComponent struct {
    engine akita.Engine // 依赖抽象的 Engine 接口
}

ZeroEngineParallelEngine 都实现 akita.Engine 接口。这样,组件可以在运行时灵活配置使用哪种引擎,提高了代码的灵活性和可维护性。依赖方向从“组件 -> 具体引擎”转变为“组件 -> 抽象接口 <- 具体引擎”,实现了“倒置”。

接口隔离原则

该原则要求:不应强迫客户端依赖它们不使用的方法。即,接口应该小而专一,而不是大而全。

潜在问题
组件通常只需要引擎的调度功能,但 akita.Engine 接口可能还包含 Run(), Pause() 等控制模拟流程的方法。这迫使组件依赖了它不需要的方法。

优化方向
可以定义一个更精细的 EventScheduler 接口,只包含调度相关的方法。然后让 Engine 接口继承或实现 EventScheduler。这样,只需要调度功能的组件就可以只依赖 EventScheduler 接口。


Akita 模拟框架的核心元素 ⚙️

组件

组件是Akita模拟的基本构建块,可以代表CPU核、缓存、内存控制器等任何逻辑单元。在代码中,Component 是一个聚合了多个小接口的大接口。

type Component interface {
    Named                     // 具有名称
    Hookable                  // 可插入钩子函数
    PortOwner                 // 拥有端口
    Handler                   // 能处理事件
    NotifyRecv                // 能接收通知
}

这种设计本身就体现了接口隔离原则,允许用户只关注他们需要的部分功能。

消息

组件之间通过消息进行通信。消息携带需要传递的数据和元信息。

type Msg interface {
    Meta() *MsgMeta          // 获取元数据(ID、源、目的、大小等)
    Clone() Msg              // 克隆消息(生成新ID)
}

关键点

  • 唯一ID:每条消息有唯一ID,用于追踪。
  • 单次使用:消息被处理后即失效,如需重用必须克隆。
  • 元数据:包含路由、计时所需的所有信息。

端口与连接

端口是组件与外界通信的端点,内部包含输入和输出缓冲区。连接则像导线一样链接两个或多个端口。

端口的关键方法

  • Send(msg):组件调用,将消息放入端口的输出缓冲区,并尝试通知连接。
  • Deliver(msg):连接调用,将消息放入端口的输入缓冲区,并通知组件有消息到达。
  • RetrieveIncoming():组件调用,从输入缓冲区取走消息进行处理。
  • NotifyAvailable():当缓冲区状态变化(如从满变为非满)时,通知对方可以继续发送。

连接

  • 一个连接可以关联多个端口。
  • 一个端口只能属于一个连接。
  • 连接负责在端口间搬运消息,可以模拟传输延迟。

从事件驱动到周期驱动 ⏱️

纯事件驱动模拟性能高,但开发复杂,需要处理各种事件类型和重试逻辑。周期驱动模拟(每个周期都调用组件的 Tick() 函数)逻辑简单,但性能较低,因为即使组件空闲也会“空转”。

智能节拍技术

Akita引入了智能节拍技术,旨在结合两者的优点。其核心思想是:让组件在需要工作时才被“唤醒”执行Tick

如何判断是否需要Tick?
如果一个组件的 Tick() 执行后没有产生任何进展,那么这次Tick就是不必要的,可以跳过。进展包括:

  1. 更新了内部状态。
  2. 成功发送出一条消息。

何时重新唤醒组件?
当导致组件停滞的外部条件改变时,应重新调度Tick事件:

  1. 从空闲到有任务:当端口的输入缓冲区有新消息到达时(NotifyRecv 被调用)。
  2. 从阻塞到可发送:当端口的输出缓冲区从满变为非满时(NotifyAvailable 被调用)。

智能Tick的组件Handle函数框架

func (c *MyComponent) Handle(ev akita.Event) {
    switch e := ev.(type) {
    case *akita.TickEvent:
        madeProgress := c.Tick() // Tick函数返回本次是否取得进展
        if madeProgress {
            c.Schedule(c.NextTickTime()) // 有进展,继续调度下一次Tick
        }
        // 无进展,则不调度,组件进入“睡眠”
    }
}

通过这种方式,组件在忙碌时像周期模拟一样连续工作,在空闲或阻塞时则自动跳过无用的周期,从而在保持开发简单性的同时,逼近事件驱动模拟的性能。


总结

本节课我们一起深入学习了计算机体系结构模拟的进阶内容。

我们首先剖析了并行模拟引擎的实现,了解了如何利用多队列和协程让同一时刻的事件并行执行,同时通过保守策略保证时序正确性。

接着,我们探讨了依赖倒置接口隔离这两个重要的软件设计原则在Akita框架中的应用,理解了它们如何提升代码的灵活性和可维护性。

然后,我们系统学习了Akita框架的三大核心元素:组件作为功能单元,消息作为通信载体,以及端口与连接构成的通信网络。我们通过一个Ping示例,直观展示了事件驱动的模拟流程。

最后,我们指出了纯事件驱动模拟的复杂性,并引入了智能节拍这一混合技术。它通过让组件在“有进展时才Tick”的机制,巧妙地在周期驱动的易用性和事件驱动的高性能之间取得了平衡。

在接下来的课程中,我们将继续探讨智能节拍的具体实现,并学习如何构建更复杂的模拟系统。

009:智能节拍与组件实现 🚀

在本节课中,我们将学习如何通过“智能节拍”方法优化基于事件的模拟,并深入探讨如何实现一个基于节拍的模拟器组件。我们将从纯事件驱动模拟的问题出发,介绍智能节拍的核心思想、实现机制,并通过两个具体示例来展示其实现模式。

纯事件驱动模拟的挑战

上一节我们讨论了使用纯事件驱动模拟方法。我们演示了一个包含两个事件和两条消息的简单示例。纯事件驱动模拟存在一些问题:它需要为组件可能执行的每种不同类型操作创建不同的事件,这要求开发者以非直观的方式思考,使得推理过程变得困难。特别是当多个事件同时发生时,它们之间的交互会成为一个难题。此外,如果需要重试事件(例如,由于缓冲区已满而无法发送消息,则必须在下一个周期调度另一个事件),这种方法的效率甚至可能低于简单的逐周期模拟。

智能节拍:概念与目标

为了解决上述问题,我们引入了称为“智能节拍”的方法。我们的目标是让用户能够编写基于周期的代码,即为每个组件编写一个 tick 函数,然后由模拟引擎决定何时执行节拍、何时跳过节拍。其机制非常简单,但能通过跳过所有不必要的节拍来显著提升性能。

首先,我们为每个组件创建一个节拍事件。这本质上是在事件驱动模拟引擎之上运行的基于周期的模拟。然而,这种方式在性能上严格劣于纯粹的逐周期模拟,因为除了在每个周期更新每个组件的状态外,我们还有创建事件、将事件加入事件队列以及从队列中弹出事件的额外开销。

智能节拍的核心机制

为了实现智能节拍,我们需要分析哪些节拍是不必要的。我们认为,如果一个组件的内部状态在某个节拍中没有更新,那么这个节拍就是不必要的。具体有两种情况:

  1. 组件完全空闲:例如,一个缓存单元没有内存请求通过。
  2. 出站缓冲区已满:组件无法发送任何消息,必须等待缓冲区有空闲槽位。

为了检测这两种情况,我们让 tick 函数返回一个布尔值 madeProgress,用于指示该周期内是否发生了状态更新。如果 madeProgressfalse,我们认为没有进展,并将组件“置为休眠”,不在下一个周期为其调度节拍事件。当外部环境发生变化时(例如,有新消息到达或某个端口的出站缓冲区从满变为非满),我们再“唤醒”组件,使其恢复节拍。

这个机制保证了模拟结果的精确性,因为我们跳过的所有节拍都保证不会引起状态更新。

实现细节与代码模式

智能节拍的实现代码非常简洁。一个“节拍器”接口只包含一个返回布尔值的 tick 方法。一个“节拍组件”需要实现 ticknotifyReceive(当收到消息时调用)和 notifyPortFree(当端口变为可用时调用)等方法。

以下是核心逻辑的伪代码表示:

func (c *Component) Handle(e Event) {
    if e.Type == TickEvent {
        madeProgress := c.Tick()
        if madeProgress {
            c.TickLater() // 调度下一个周期的节拍事件
        }
        // 如果没有进展,则不调度,组件休眠
    }
}

func (c *Component) NotifyReceive(msg Message) {
    c.TickLater() // 新消息到达,唤醒组件
}

func (c *Component) NotifyPortFree(port Port) {
    c.TickLater() // 端口可用,唤醒组件
}

通过这种方式,我们只在必要时执行节拍,从而大幅提升模拟性能,尤其是在模拟多GPU系统等场景下,其中许多组件可能长时间处于空闲状态。

回压与可用性反向传播

在计算机体系结构模拟中,“回压”是一个常见概念。它描述了当下游组件处理速度慢于上游组件的请求发送速度时,缓冲区压力会向上游传播的现象。在Akida中,我们使用消息、端口和缓冲区系统来建模回压。

可用性反向传播是实现高效回压处理的关键。当下游缓冲区释放一个槽位时,它会通过函数调用(而非发送消息)通知上游连接和组件。这个唤醒信号沿着与消息发送相反的方向(即向上游)传播,确保在缓冲区槽位可用时,正确的组件能够被及时唤醒以继续工作。

示例一:Ping组件实现

让我们通过一个简单的Ping组件示例来看智能节拍的具体实现模式。该组件周期性地发送Ping消息,并处理回复。

tick 函数通常遵循以下模式:

  1. 初始化 madeProgress = false
  2. 反向顺序(模拟硬件中寄存器更新的时序)处理各个阶段(如发送Ping、处理输入、倒计时、发送响应)。
  3. 在每个阶段,先检查执行条件(如缓冲区是否满、是否有待处理消息)。如果条件不满足,可能提前返回 false
  4. 一旦通过所有条件检查,到达“关键点”,则执行实际的状态更新操作(如修改计数器、从缓冲区移除消息),并将 madeProgress 设为 true
  5. 函数最后返回 madeProgress 的值。

这种模式确保了代码清晰且符合硬件行为的时间特性。

示例二:重排序缓冲区实现

第二个例子是一个真实用于模拟器的组件:重排序缓冲区。它用于确保内存请求的响应按发送顺序返回给核心,即使底层内存系统可能乱序返回。

ROB的实现同样遵循智能节拍的模式。它包含多个处理阶段(如自上而下传递请求、自下而上接收响应、从头部返回响应),每个阶段在 tick 函数中按反向顺序执行。每个阶段都包含条件检查、关键点操作和状态更新。通过这种方式,ROB能够高效地管理请求和响应,同时只在必要时消耗计算资源。

中间件模式与设计原则

在构建复杂组件时,我们引入了“中间件”模式来分离关注点。中间件类似于责任链模式中的处理器,每个中间件负责处理一类特定的逻辑(如缓存替换策略)。组件可以将消息传递给一系列中间件,直到有一个能处理它为止。这样做的好处是提高了代码的模块化和可替换性,符合软件设计中的“单一职责原则”。

总结

本节课我们一起学习了智能节拍模拟方法。我们从纯事件驱动模拟的局限性出发,探讨了智能节拍如何通过有选择地执行组件的节拍来提升性能,同时保证结果的精确性。我们详细介绍了其唤醒和休眠机制、回压的处理,以及通过可用性反向传播实现高效通信。通过分析Ping组件和重排序缓冲区两个示例,我们揭示了基于智能节拍的组件实现通用模式:条件检查、关键点操作和状态更新。最后,我们了解了如何使用中间件模式来构建更清晰、更易维护的复杂组件。掌握这些概念和模式,是构建高效、准确计算机体系结构模拟器的关键。

010:计算机体系结构模拟 4

在本节课中,我们将继续深入探讨计算机体系结构模拟,重点解决上一讲中未解决的问题,包括次级事件、构建器模式、设计原则以及钩子和追踪机制。这些概念是构建灵活、可扩展模拟器的关键。

次级事件

上一节我们介绍了智能滴答和如何为Akita编写常规组件。本节中我们来看看一个尚未解决的问题:次级事件。

次级事件并不复杂。其基本原因与我们之前讨论流水线实现时类似,即需要将某些操作安排在函数开始时执行。对于次级事件,考虑有三个滴答事件:两个组件(A和B)以及它们之间的连接。在硬件中,可以认为有三个组合逻辑控制着端口内的两组寄存器。在模拟器中,这些事件的执行顺序可以是任意的。

例如,如果我们按A滴答、连接滴答、B滴答的顺序执行。在周期1中,A滴答会在缓冲区中创建一条消息。接着连接滴答会将此消息移动到目的地。然后B滴答会接收并消费此消息。这里的问题是,我们在发送消息的同一周期就接收到了它。这在真实硬件中是不应该发生的,我们需要防止这种情况。

以下是解决此问题的方法:

  • 解决方案:我们可以简单地让传递消息的事件总是晚于消费消息的事件发生。
  • 实现:将连接的事件设置为次级滴答事件。如果事件被安排在同一时间执行,我们总是先执行主事件,然后再执行次级事件。
  • 效果:这样就能保证连接滴答总是晚于A和B的事件执行。假设A和B先滴答,A会创建消息,但连接不会立即传递它。之后,作为次级滴答的连接事件会在同一周期内传递消息。但由于B在同一周期已经滴答过,它无法再次消费。因此,B将在周期2的滴答中接收到消息,从而在发送消息后的下一个周期才收到,问题得以解决。

这就是我们需要次级事件的原因。它实际上会带来一些性能问题,特别是在并行模拟中,因为我们必须先执行主事件,再执行次级事件,这需要在主事件和次级事件之间添加实际的同步机制。

构建器模式

现在,我们来看看另一个未讨论的话题:构建器。在模拟器中,对于许多组件或事物,我们使用构建器来实例化类。

构建器是什么?以重排序缓冲区为例。ROBBuilder是一个结构体,用于构建或实例化真正的重排序缓冲区组件。它包含ROB的一些属性字段。

MakeBuilder是一个函数,用于实例化一个构建器。它不接受任何参数,但提供默认值。例如,频率默认为1 GHz,每周期请求数默认为4,缓冲区大小默认为128。如果不设置,这些将是ROB中使用的默认值。

MakeBuilder中,我们创建了许多以With开头的函数,例如WithFrequency。这些函数用于设置属性。最后,我们使用Build函数来设置必要的内容,例如初始化映射、分配参数以及实例化端口。

以下是使用构建器的示例代码:

rob := rob.MakeBuilder().
    WithEngine(engine).
    WithFrequency(1e9).
    WithBufferSize(128).
    WithReqPerCycle(4).
    Build("MyROB")

我们可以像阅读英语一样阅读这段代码:创建重排序缓冲区构建器,设置引擎、频率、缓冲区大小、每周期请求数,最后用此名称构建。通常我们会换行书写,使其更易于阅读和理解。

构建器本身是一种编程模式。这里我还想谈谈策略模式,这可能是我认为最重要、最喜欢的模式。

策略模式与依赖注入

什么是策略模式?例如,在重排序缓冲区中,我们有一个引擎。基本上,我们说引擎是一个依赖项。这与依赖倒置原则中的图相同。这是一种特定的模式,可以帮助我们遵循依赖倒置原则。

引擎是一个策略,ROB依赖于引擎接口。我们称ROB需要一个引擎策略。在这个策略类别下,我们有具体的策略,例如ZeroEngineParallelEngine。在配置时或调用构建器时,我们设置想要使用的具体策略。

策略模式相当容易理解,但实际上非常常用。如果你觉得这个例子有点难懂,让我介绍另一个例子。

例如,一个缓存需要一个替换策略。如果你从未学过编程模式或原则,你可能会这样写代码:

if policy == "LRU" {
    // 执行LRU逻辑
} else if policy == "Random" {
    // 执行随机逻辑
}

这是一种常见的写法,但这是一种不好的代码风格,我们称之为“代码异味”。通常,如果你看到这种代码,表明你的代码不够灵活,实际上违反了开闭原则。我们稍后会讨论这一点。

相反,我们应该这样做:

cache.ReplacementPolicy.Evict()

然后它会告诉我们该驱逐哪个,具体的实现在这个ReplacementPolicy内部。关键在于你想使用哪种策略,你应该使用构建器来设置你想要使用的驱逐策略。

因此,我们需要在构建代码时选择策略。这是另一种模式,这种模式不是传统模式,不在设计模式书中,但是一种常用的模式,称为依赖注入

依赖注入在Akita和MGPM中的使用方式,你可以看到我有两种不同的写法。第一种是NewCache,在实现缓存时我创建了一个策略并写入LRU策略。如果你写这种风格的代码,你甚至不需要策略。

在这里,我们说NewCache接受一个策略字段。我们首先创建一个策略,然后创建一个缓存,并将策略传递给它。那么什么是依赖?缓存依赖于替换策略。所以替换策略是缓存的依赖项。什么是注入?在这种方式中,我们称之为自定依赖,即我们提供自己的策略,或者缓存创建自己的依赖。另一方面,我们从外部提供依赖,这称为注入。注入意味着我们从外部提供并将其放入。在这种方式中,这个New函数或其他语言中的构造函数可以充当依赖注入器。

因此,构建器就是依赖注入器。我们使用构建器的Build函数来注入依赖。你也可以认为这是参数设置,但这是一个依赖注入器,用于提供组件想要使用的依赖。

依赖注入更灵活,因为我们可以轻松地从外部更改它。

开闭原则与代码分类

既然我们谈到了开闭原则和设计原则,那么开闭原则是最重要的原则。可以说,所有其他原则的存在都是为了实现开闭原则。

开闭原则是:软件实体应该对扩展开放,对修改封闭。或者用另一种易于理解的方式解释这个术语:一个软件实体(类、函数等)应该允许在不修改其源代码的情况下扩展其行为。

例如,如果你写这种实现,突然想写一个基于AI的策略,你该怎么做?你会继续通过提供另一个if分支来编写。这是在修改代码。

我们为什么不想修改代码?因为代码已经存在并正常工作,你不想修改它,修改很可能会破坏它。因此,你希望尽可能避免修改现有代码,但又想添加新功能。我们总是希望添加新功能。当我们想要添加功能时,我们希望编写新的代码片段,而不是修改已经存在的代码。

那么如何做到呢?策略模式可以帮助,其他模式也可以。例如,在策略模式中,我们有LRU和随机策略,但我们可以实现新的策略。如果我们编写新的策略并使用构建器通过依赖注入,那么我们就是在扩展行为而不修改代码。我们没有修改缓存代码,只是提供了另一个文件,这就足以支持这个功能。

遵循这种模式或风格,我通常将我的代码视为三部分:

  1. 数据:什么是数据?请求是数据,事件是数据。组件本身,如果你考虑中间件,我只说组件本身是数据,因为它记录了组件的状态。这些都是纯数据或组件状态的表示,是程序或模拟的某种状态的表示。我们应该尽可能减少行为,使其成为纯数据。
  2. 行为:行为可以认为引擎是一种行为。组件中间件是它们的行为。
  3. 粘合代码:粘合代码将一切组合在一起。什么是粘合代码?它们通常是像构建器、调用构建器的代码,甚至是主函数main。它们被认为是粘合代码,只是以你期望的方式将不同的事物组合在一起。

在MGPM中,对于每一段代码,我几乎可以肯定它可以归类为这三种类型之一。

中介者模式

我正在尝试尽快完成所有与Akita相关的模式。中介者模式是另一个非常重要的模式。

什么是中介者模式?中介者模式定义了一个对象,它是一个行为对象,控制所有其他状态在做什么。例如,考虑这是一条消息。我的中间件将从消息中读取状态和内容。如果这是一条读消息,那么我将改变组件的状态。同时,我将创建另一条消息作为响应并发送回去。

因此,中介者是纯行为代码,控制着其他组件的状态。这样,我们应该尽量避免中介者与另一个中介者一起工作。这个中介者应该只直接与状态交互,读取输入状态并生成输出状态。这就是中介者应该做的,这也映射回行为类型的代码,我认为它们是中介者。

这实际上非常有用,因为每当我进行单元测试时,我不需要测试消息,不需要测试组件状态,我只需要测试中介者。我可以检查,如果我以这种方式给它一个状态,它是否真的以期望的方式修改了这个组件的状态,以及它是否实际创建了响应消息。所以我只需要测试行为部分,不需要测试所有其他状态部分,也不需要测试粘合代码。对于单元测试,我尝试对行为部分实现全覆盖。

设计原则回顾

我们可以快速回顾一下我们学到的所有原则。我们还有一个模式要介绍。我们先说原则。

我们考虑SOLID原则,还有其他原则,如“不要重复自己”。这是一个相对容易实现的原则。

对于SOLID,我们学习了S、O、L、I、D。

  • S:单一职责原则。我们希望每个组件、每个类只有一个职责。例如,如果你将每个状态作为一个类,那么它自然只有一个职责。如果它是一个行为,那么它也将只有一个职责,特别是如果你尝试将策略与主要行为分离。那么每个策略,比如我们的LRU策略或随机策略,几乎自然而然地遵循了S原则。
  • O:开闭原则。我认为O原则是最重要的原则:对扩展开放,对修改封闭。你可以通过编写新代码来添加功能,而不是修改现有代码。
  • L:里氏替换原则。我们还没有讨论L。
  • I:接口隔离原则。为什么我们要进行接口隔离?我们希望每个接口尽可能小。这样,对于每个组件,每当你有一个依赖时,你只依赖于该依赖的最小部分。因为如果你依赖更少的函数,替换它更容易。如果你依赖许多甚至不使用的函数,你可能必须实现一些你的主对象不依赖的其他函数。
  • D:依赖倒置原则。依赖倒置几乎告诉你,永远不应该让一个中介者直接依赖于另一个中介者。你应该让一个中介者依赖于接口,然后由其他中介者实现该接口。这样,你总是可以替换你想要使用的策略。

最终的整体目标是O原则:我可以在不显著改变实际逻辑代码的情况下,替换任何我想要的东西,但我可以在粘合代码部分改变行为,在那里我构建整个组件,我可以改变成任何我想要的样子。

钩子与追踪

现在,你可能已经在代码中看到过关于钩子和追踪的内容。这可能是我们必须介绍的最大部分,也是Akita中很大的一部分。

为什么需要钩子?正如你在重排序缓冲区中所想,当我们实现重排序缓冲区甚至引擎时,我们专注于主要逻辑。我不想让你写很多在我们的程序中非必要的代码。有时我们想记录数据,例如记录执行了哪些事件、消息何时到达端口,或者在核心组件中执行了哪些指令。这些都是记录数据的任务。

我想记录什么数据?很难说。这次我想记录这个数据,下次我想记录那个数据。一个简单的方法是添加打印语句。但当你添加大量打印时,就违反了O原则。当你添加大量打印时,如果我想记录一些新数据,那么我就在修改我的代码。

有时一些临时的打印是可以的,但有时如果你想通过记录数据来正式支持某个功能,这会给你的代码增加复杂性,你的代码最终会变得混乱,函数会变得很长。你不希望这种情况发生。因此,我们使用钩子来分离记录指令的逻辑与指令执行本身,以及我们可以记录和追踪的许多其他事情。

有时我们想修改不属于常规逻辑的状态。例如,在计算机体系结构研究中一个非常特殊的研究类型叫做故障注入。我们讨论过宇宙射线可能击中你的CPU,并使1位从0翻转到1或从1翻转到0。在这种情况下,这绝对不是你想在代码中实现的真实逻辑,你不想写很多if语句。你想模拟它。我们能做什么?我们可以将故障注入实现为另一段代码,以某种方式操纵你的状态。这些都是我们想做的特殊情况,但不应该在主逻辑中实现。这时我们使用钩子。

考虑这是正在执行的主线程,钩子是如果我们想要,就挂载到它上面;如果我们不想要,就把它取下来。它可以附加到这个主线程上,并在某些事情发生时采取行动。

这是另一个模式。这可能是我们必须介绍的最后一个模式。有很多模式,但我们只介绍其中一部分:观察者模式,有时也称为订阅者模式。如果你想称之为钩子模式,也可以。人们通常能理解。

观察者模式很容易理解。这里有一个主体,主体是实现主要逻辑的主要组件。然后我们有一个观察者接口。有一些观察者实现这个接口。它们只需要实现一个Update函数,一个非常简单的Update函数,用于执行该观察者的操作。

然后,主体应该有一个观察者列表来维护谁是我的观察者。它应该允许注册观察者(提供一个观察者,只需将其添加到观察者列表)或移除观察者。在Akita中,我们甚至不需要移除它,一旦挂载,它就一直在上面。

这里最重要的函数是NotifyObservers。在适当的时候,我们需要通知所有观察者某些事情已经发生。

基本上,这些观察者允许我们在配置时定义此行为。如果我想进行故障注入,我可以附加这个钩子;如果我不想,就在配置时不附加它。

配置时间基本上是你启动一段程序时,首先构造你想要模拟的硬件和基准测试。当你调用Engine.Run时,你实际上是在进行模拟。所以Engine.Run是真正的模拟,开始部分是配置代码。

钩子的实现实际上非常简单。它接受一个钩子接口,你只需要实现一个叫做Func的东西,它接受一个参数,称为钩子上下文。

钩子上下文包含四样东西:

  1. :是可钩挂的组件,即主组件。
  2. 位置:位置。我们可以这样命名这些位置。这些有点像全局变量或全局常量。有时我可能会说这是事件处理前的钩子位置,事件处理后的钩子位置。这个位置会告诉我这是否是我可能感兴趣的东西。
  3. :是你关心的东西。例如,如果你关心一个事件,那么该事件就是项。如果你关心一条指令,那么该指令就是项。
  4. 详情:基本上是你想附加到此钩子的任何数据,以便钩子可能需要或不需要处理详情中的信息。你可以在那里附加任何你想要的数据。

可钩挂的是可以接受钩子的主组件。它有三个方法:AcceptHook(接受一个钩子)、Hooks(返回已注册的钩子),以及另一个重要函数InvokeHook。每当我们调用InvokeHook时,实现非常简单。基本上只是遍历所有钩子列表并调用钩子的Func函数。通知钩子发生了某事,然后如果你感兴趣,就做你想做的事。

我们如何调用钩子?你可以看到这是引擎的Run函数。当我们处理这个事件时,我们创建一个钩子上下文。在这个钩子上下文中,域是引擎本身,项是事件,位置是事件前的钩子位置,这是在事件发生前触发的。然后我们在此上下文中调用钩子。

事件被处理后,我们执行事件后的钩子位置,只是重用这个上下文,但改变位置,然后再次调用钩子。

因此,每当我写一个钩子时,我会称这个东西为可观察行为。在这一点上,这个事件的执行是可观察的,因为你可以附加一个钩子来观察这个行为。否则,你的模拟将一直运行,你无法从主运行线程中获取任何数据。

因此,当你编写一个组件时,你需要考虑哪部分是可观察的。如果你希望这个东西是可观察的,就在那里留一个钩子,最终它可以被钩子消费以提取这类信息。

通常,每当某些事情开始时,例如指令开始、指令结束、事务开始、事务结束,这些都是可观察的操作或事件。我们会留下钩子位置,以便钩子可以监视这些行为。

这段代码可能有一些性能开销。实际上,性能开销并不小,主要是因为调用钩子操作。但有时,Go与C或C++有点不同。在C或C++中,这通常是分配在栈上的,但Go可能将其分配在堆上。如果在堆上,可能需要垃圾回收。分配内存和垃圾回收会花费很多时间。这是我们可能寻求性能改进的机会之一。

追踪与任务

我们想看看事件记录器的一个例子。事件记录器是一个可以附加到引擎的钩子。它只对InvokeHook这个函数感兴趣,比如事件前的位置。只关心这个位置,每当我们执行这个钩子时,它只是简单地打印出来,告诉我正在执行什么事件。

让我们看看如何实现它。我们说这是一个事件记录器结构体,带有一个日志钩子基。在Func中,它接受一个上下文。在上下文中,我们首先检查这个位置是否是我们关心的。如果不是,我们返回,什么都不做。我们检查这个项是否是一个事件。这是Go中的向下转型语法。我们将项从接口类型转换为事件类型。如果它是一个事件,我们就打印出来。如果它不是事件,我们返回。这很简单。

我们如何将这个钩子附加到引擎上?我们这样实现:创建一个引擎,如果用户想使用并行引擎,就创建并行引擎,否则创建零引擎。然后我们附加一个钩子,实例化这个事件记录器并将其附加到引擎。这个引擎附加了这个钩子,所有事件都会被打印出来,你的整个程序执行会非常慢,因为打印有很大的性能开销。如果你打印到文件,可能还可以,特别是如果你打印到终端,会比不打印慢数千倍。但有时这只是一种调试机制。所以我通常会保留这行代码,但大多数时候,我只是注释掉它,因为我不想看到这类事件被打印出来。

追踪是在追踪包中实现的。基本上,在追踪包中,我们不关心事件,但关心任务。

什么是任务?你可以认为每个任务包含两个事件。一个任务事件几乎总是一个单一时间点发生的事情,没有持续时间,那是一个事件。而任务是一对事件:某事开始和某事结束。例如,我执行一条指令,当我开始执行这条指令和当我完成执行这条指令时。所以任务有开始时间和结束时间。

如果你看这里的字段,有任务的ID标识符,有描述任务种类的Kind,有一个Where字段(意味着一个任务只能发生在一个位置)。最重要的部分是开始时间和结束时间。Steps目前用作任务的标签。Detail是你可以附加到此任务的信息部分,以后可能有用或没用。

ParentTask是最有趣的部分。父ID字段或父任务,我们不一定需要同时启用两者。这取决于我们是否想序列化成JSON格式或存储到数据库中,我们可能只想保留父ID。如果我们想使用它,我们可能可以直接保留对父任务的引用。

每当我们有一个父任务,父任务可能有另一个父任务。所以你想,这个父任务,你的整个程序指令任务形成一棵树。如果你考虑一个根任务叫做“模拟”,它可能总是有一个ID为0。那样的话,那就是根。然后所有其他任务都是这个根的叶子。现在我们将整个执行理解为一棵需要在我们模拟中执行的整个任务图。

我们为任务处理提供特殊的API,但它们基本上是钩子调用的包装器。我们有一个StartTask,我们用一个ID、一个父任务ID、一个域(一个命名的可钩挂对象,是两个不同接口的组合)、一个种类/类型以及可选的详情来开始一个任务。当我们结束一个任务时,我们调用EndTask函数。EndTask非常简单,只需要ID和域,这样我们就可以映射到原始的开始任务。如果我们能映射到一个开始任务,我们知道结束时间,这就足够了,我们可以存储它,保存到某个地方。

所以这是EndTask。没有什么特别的。它们是调用钩子的包装器。将在此StartTaskEndTask函数调用中创建一个任务结构体。那些是项。域(命名的可钩挂对象)是上下文中的域,位置总是像TaskStartTaskEnd这样的东西。所以这不是专门的代码,只是包装器。

任务中最常见的一种特殊任务是当我们处理请求和响应时。考虑一个核心向缓存发送内存请求。现在,这是一个任务。这个任务在请求首次创建或首次发送出去时开始。我们通常认为当它被创建时开始,因为有时它可能在自己内部缓冲一会儿才发送出去。我们想记录最早可能的时间作为此请求相关的最早时间。所以开始时间是此请求创建时,结束时间是响应被接收时。我们认为这是一个任务。这是整个内存事务花费的时间,记录结束时间减去开始时间就是持续时间或此内存事务的延迟。

然后考虑这个请求被发送到一个接收者。这是在缓存端。在缓存端,当我们首次意识到有一个请求时,是当我们从端口检索时。所以这个时间是请求被接收时。它可能在此组件外部缓冲了一会儿,或者网络需要一些时间将此消息发送到目的地。所以父任务和子任务开始之间总是有一个小间隙。缓存可能需要一些时间来处理它,可能是缓存命中或未命中。最终这个任务完成,我们有了数据。现在我们可以将这些数据发送回请求者。这可能需要更多时间,可能在请求者端缓冲,或者网络可能需要一点时间来传递响应。但这是另一个关键时间,即请求完成时。

我们给它一个种类,这是“请求发出”,我们发出一个请求;这是“请求进入”,我们接收一个请求并处理它,响应这个请求。我们总是将父任务指向这个“请求进入”有一个父任务“请求发出”。所以一个请求,两个任务。因为一个请求有两个不同的位置。一个请求,两个任务,四个关键数据点。

我们提供专门的API来处理这个。我们称之为Initiate(首次相关时)、Receive(发送到目的地时)、Complete(完成时)和Finalize(最终完成时)。四个简单的API来记录每个请求的生命周期。它们仍然是StartTaskEndTask的包装器。所以两层包装器。这些API是钩子调用的包装器。

因此,我们提供这四个方便的API来处理“请求发出”和“请求进入”这类专门的任务。最后,追踪器是什么?追踪器是一个关心任务的钩子。每当我们调用那些API时,这些API最终是钩子调用的包装器。所以我们需要专门的钩子来关心那些任务。所以我们需要编写一个追踪器。追踪器也非常简单,只需要实现三个函数:StartTaskStepTaskEndTaskStepTask意味着在中间执行,如果有实际信息需要记录。我们目前只将其用于一个目的:在缓存中记录缓存命中或未命中,因为在开始时我们并不真正知道它是命中还是未命中,在结束时我们可能不关心它是命中还是未命中。所以只有在我们查找标签并检查是命中还是未命中后,我们才为此任务添加一个步骤,这样我们就知道是命中还是未命中。

因此,追踪器是关心任务的钩子。我们提供一系列第一方追踪器,这些是Akita提供的追踪器,可以使用。例如,总时间追踪器。大多数第一方追踪器接受一个过滤器。例如,如果你只关心一种类型的任务,你可以过滤掉,只专注于一种类型的任务。

总时间追踪器是什么?例如,如果我们在一个缓存上附加一个总时间追踪器,那就是该缓存处理所有传入请求的总时间。请求一花费一秒,请求二花费另一秒,我们加起来,总时间是两秒。我不使用它,因为它没用,因为我们需要考虑重叠。如果我们处理一个请求和处理另一个请求有重叠,把它们加起来没有意义。可能在一个地方我们关心这个,比如在ALU中计算ALU利用率,像开始计算和结束计算。所以那是总时间追踪器。

平均时间追踪器是广泛使用的。我们关心L1缓存级别的平均延迟、L2缓存级别的平均延迟、DRAM级别的平均延迟,以及从核心角度来看我的内存事务的平均延迟。这可以告诉我们很多信息。延迟越短越好。那是平均时间追踪器。

繁忙时间追踪器考虑重叠。如果有两个任务重叠,我们只考虑全局开始时间和结束时间。如果有间隙,我们也会丢弃间隙部分。基本上,这意味着该组件至少在处理某些事情。它的使用方式只在一个位置:在MG中的GPU级别,我们计算总时间,我们称之为GPU总内核时间。GPU执行内核,内核是GPU函数。我们计算GPU忙碌了多长时间。我们有一个繁忙时间追踪器应用于GPU级别,所以我们知道GPU至少运行一个内核、一个函数的总时间。那是繁忙时间追踪器。

回溯追踪器是什么?这是最近相对较新实现的。实际上,编写基于周期的或事件驱动模拟的一个非常烦人的事情是,当出现错误触发时,很难调试。当我的程序崩溃时,我的崩溃会打印一个回溯。回溯类似于:事件点、引擎点、我的核心滴答。仅此而已,只有两个级别。我没有太多信息,为什么滴答,它在做什么,为什么崩溃。有了栈追踪器,因为它是一棵树,现在我们可以告诉,当我们执行这个特定任务时,我们崩溃了。你会告诉我,这是MMU转换,一路是TLB、L2 TLB、L1和CU指令。什么指令?你可以告诉我与体系结构相关的细节,这样我就知道,哦,我们执行这条指令,这个东西失败了。至少它为我们提供了更多信息来调试问题。那是回溯追踪器。

步骤计数追踪器也广泛使用。记得我说过对于缓存,我们计算缓存命中百分比和缓存未命中百分比。所以我们计算每种类型的步骤。然后我们计算有多少事务命中,有多少事务未命中。那是步骤追踪器。

最后,是数据库追踪器。数据库追踪器相当简单。对于被追踪的任何指令、任何任务,我们都将其存储在数据库中,以便我们可以恢复整个执行的完整追踪。

演示与总结

这些是追踪器。我想向你展示这个追踪器在真实环境中的使用方式。让我们尝试执行一些程序,一些我的模拟,我想向你展示这是如何工作的。我可以基本上操作最后两张幻灯片,而不是给你幻灯片,我在这里做现场演示。我认为这更有趣。

(演示部分展示了如何运行模拟、生成报告、使用Dyson可视化工具分析追踪数据,以及使用Akita RTM进行实时监控。演示了如何从模拟中提取数据、查看组件性能指标、分析任务层次结构以及识别瓶颈。)

本节课中我们一起学习了计算机体系结构模拟的最后几个关键概念。我们探讨了次级事件如何解决硬件模拟中的时序问题,介绍了构建器模式和策略模式如何提高代码的灵活性和可扩展性,并深入理解了依赖注入和开闭原则的重要性。我们还学习了中介者模式以及如何将代码分为数据、行为和粘合代码三类。最后,我们详细介绍了钩子和追踪机制,包括观察者模式、任务的概念以及各种追踪器的用途。这些工具和原则共同构成了构建强大、可维护且易于调试的体系结构模拟器的基础。通过现场演示,我们还看到了这些概念在实际模拟和分析中的应用。至此,我们完成了模拟部分的学习。

011:RISC-V 指令集架构 1 🚀

在本节课中,我们将学习计算机体系结构的基础——指令集架构,并重点介绍现代、开源的RISC-V指令集。我们将从指令集架构的历史和重要性开始,逐步深入到可执行文件格式、汇编语言以及RISC-V的核心指令。

概述:指令集架构的诞生与发展

在早期编程时代,程序员使用穿孔卡编写程序。每个计算机厂商都有自己的硬件标准和指令集,这导致为不同机器编写的程序无法通用,即使是同一种高级语言也需要为每台机器重写编译器。这种重复性工作催生了指令集架构的概念。

1964年,IBM 360系列计算机的发布标志着指令集架构的诞生。其核心思想是定义一套软件与硬件之间的契约,使得为同一指令集编写的软件可以在不同硬件实现上运行,从而实现了软硬件设计的分离。

指令集架构与微架构

指令集架构定义了机器需要支持的操作集合,是软件与硬件之间的协议。例如,x86就是一种指令集架构。

微架构则是指令集架构的具体硬件实现。例如,Intel和AMD的x86处理器内部设计不同,但都能运行相同的Windows程序。这种分离使得软件生态得以长期延续,但也带来了向后兼容的历史包袱,例如x86从16位扩展到64位的过程。

主流指令集架构

目前主流的指令集架构包括:

  • x86-64: 由Intel和AMD共同主导,在服务器和桌面领域占主导地位。
  • ARM: 采用授权商业模式,在移动和嵌入式领域广泛应用。
  • RISC-V: 一个新兴的、开源的指令集架构,以其模块化和可扩展性著称。

CISC与RISC

历史上存在复杂指令集计算和精简指令集计算的争论。

  • CISC: 指令复杂,单条指令能完成较多工作。早期的x86是CISC代表。
  • RISC: 指令精简,每条指令只完成一小部分工作。现代处理器多为RISC设计。

如今,纯粹的CISC设计已不常见。例如,现代x86处理器内部使用微码将复杂的CISC指令翻译成一系列简单的RISC风格微操作来执行。

一个区分RISC的典型特征是加载/存储架构。在这种架构中,只有loadstore指令可以访问内存,所有计算操作都在寄存器之间进行。load指令将数据从内存读入寄存器,store指令将寄存器数据写回内存。

机器语言与可执行文件

计算机直接执行的是机器语言,即二进制格式的指令。为了方便人类阅读和编写,我们使用汇编语言,它是机器指令的助记符表示,可以通过汇编器转换成二进制文件。

在Linux系统中,可执行文件、目标文件(.o)和动态库(.so)通常采用ELF格式。这是一种标准的可执行与可链接格式。

以下是ELF文件的基本结构:

// ELF文件头结构示意
typedef struct {
    unsigned char e_ident[16]; // 魔数,如 0x7f 'E' 'L' 'F'
    uint16_t      e_type;      // 文件类型(可执行、共享库等)
    uint16_t      e_machine;   // 架构类型(如RISC-V)
    // ... 其他字段,如入口点地址、程序头表偏移等
} Elf64_Ehdr;

ELF文件头包含魔数、架构位数(32/64)、字节序(大端/小端)、文件类型和程序入口点等信息。字节序是指多字节数据在内存中的存储顺序,现代主流系统大多采用小端序

ELF文件中包含多个,例如:

  • .text节:存放程序的指令代码。
  • .data节:存放已初始化的全局变量。
  • 符号表:存放函数名、变量名等符号信息。

操作系统根据ELF文件中的信息,将程序加载到内存并执行。

RISC-V指令集架构介绍

RISC-V由加州大学伯克利分校于2010年设计,是一个开源的指令集架构。其核心特点包括:

  1. 模块化设计:基础整数指令集(RV32I/RV64I)很小,可通过标准扩展(如乘法除法M、单精度浮点F、原子操作A等)或自定义扩展来增加功能。
  2. 简洁性:基础指令格式规整,易于学习和硬件实现。

RISC-V有32个整数寄存器(x0-x31),每个寄存器有特定的约定用途和ABI名称,例如:

  • x0 (zero): 恒为零的寄存器。
  • x1 (ra): 返回地址寄存器。
  • x2 (sp): 栈指针寄存器。
  • x10-x17 (a0-a7): 函数参数/返回值寄存器。

RISC-V基础指令

上一节我们介绍了RISC-V的概况和寄存器,本节中我们来看看一些基础指令。

以下是常见的RISC-V整数指令示例:

逻辑与移位指令

and t0, t1, t2  # t0 = t1 & t2 (按位与)
andi t0, t1, 5  # t0 = t1 & 5 (与立即数)
slli t0, t1, 2  # t0 = t1 << 2 (逻辑左移)
srai t0, t1, 3  # t0 = t1 >> 3 (算术右移,符号位填充)

算术指令

add t0, t1, t2  # t0 = t1 + t2
sub t0, t1, t2  # t0 = t1 - t2
addi t0, t1, -4 # t0 = t1 + (-4) (加立即数)
mul t0, t1, t2  # t0 = t1 * t2 (低32位,需M扩展)

加载与存储指令

lw t0, 4(sp)    # 从内存地址(sp+4)加载一个字(4字节)到t0
sw t1, 8(sp)    # 将t1的值存储到内存地址(sp+8)

分支与跳转指令
控制流指令用于实现条件判断和循环。分支指令根据条件决定是否跳转,跳转指令则直接修改程序计数器PC

beq t0, t1, label # 如果 t0 == t1,则跳转到 label
bne t0, t1, label # 如果 t0 != t1,则跳转到 label
blt t0, t1, label # 如果 t0 < t1 (有符号),则跳转
j label           # 无条件跳转到 label
jal ra, func      # 跳转到函数func,并将返回地址保存在ra中

从高级语言到汇编

高级语言中的控制结构最终都会编译为分支和跳转指令的组合。

if 语句的实现
C语言代码 if (a == b) { a = a + 1; } 可能被编译为:

    bne a0, a1, L1  # 如果 a0 != a1,跳过if块
    addi a0, a0, 1  # if 块内的加法
L1:
    # if 语句后的代码

while 循环的实现
C语言代码 while (i < 10) { i++; } 可能被编译为:

    li t0, 0        # i = 0
    li t1, 10       # 循环上限
loop:
    bge t0, t1, done # 如果 i >= 10,跳出循环
    addi t0, t0, 1  # i++
    j loop          # 跳回循环开始
done:
    # 循环后的代码

数组访问
C语言代码 scores[i] = 10; 涉及地址计算。假设int为4字节,scores基地址在s0,索引is1

    slli t0, s1, 2   # t0 = i * 4 (计算字节偏移)
    add t0, s0, t0   # t0 = scores基地址 + 字节偏移
    li t1, 10
    sw t1, 0(t0)     # 将10存储到计算出的地址

算术强度:一个关键概念

在分析程序性能时,算术强度是一个重要指标。它定义为程序执行的操作数与访问的字节数之比。

AI = 操作数 / 字节数

在前面的数组存储例子中,我们进行了一次存储操作(4字节),但执行了多次算术运算(移位、加法等)。如果AI值很低,说明程序是内存密集型的,性能瓶颈往往在内存访问;如果AI值很高,说明是计算密集型的,性能瓶颈在计算单元。理解AI有助于优化程序和数据布局。

总结

本节课我们一起学习了计算机体系结构的基石——指令集架构。我们从其历史背景出发,理解了ISA作为软硬件契约的重要性。我们探讨了CISC与RISC的区别,并详细介绍了新兴的RISC-V ISA,包括其模块化设计、寄存器文件和基础指令。我们还分析了如何将高级语言的控制结构(如条件分支、循环)和数据结构(如数组)映射到汇编指令。最后,我们引入了算术强度的概念,作为分析程序性能特征的一个有用工具。在下一节课中,我们将继续探讨函数调用约定、栈管理以及操作系统如何加载和运行程序。

012:RISC-V 指令集架构 2

在本节课中,我们将深入学习 RISC-V 指令的二进制编码格式、函数调用约定以及程序在内存中的组织方式。这些知识是理解处理器如何执行程序以及后续编写模拟器的基础。

二进制指令编码

上一节我们介绍了 RISC-V 汇编语言,本节中我们来看看这些汇编指令是如何被编码成二进制格式的。RISC-V 使用 32 位(4 字节)来编码每条指令。为了在有限的位数内支持各种指令(有的需要立即数,有的不需要),指令格式被设计为几种不同的类型,类似于 C 语言中的 union 数据结构。

以下是 RISC-V 指令的几种主要类型:

  • R 类型:主要用于寄存器之间的操作。
  • I 类型:用于包含立即数的操作。
  • S 类型:用于存储(store)操作。
  • B 类型:用于条件分支(branch)操作。
  • U 类型J 类型:用于长立即数和跳转操作。

R 类型指令

R 类型指令的编码最为直接。其 32 位被划分为多个字段:

| funct7 (7 bits) | rs2 (5 bits) | rs1 (5 bits) | funct3 (3 bits) | rd (5 bits) | opcode (7 bits) |
  • opcode:指令的基本操作码。
  • funct3funct7:与 opcode 结合,共同确定具体的指令(例如,区分 addsub)。
  • rd:目标寄存器。
  • rs1rs2:源寄存器。

每个寄存器字段是 5 位,因此可以编码 32 个寄存器。解码时,需要通过查表(如 RISC-V 官方手册中的指令表)来根据 opcodefunct3funct7 确定具体的指令助记符。

I 类型、S 类型和 B 类型指令

这些类型的指令编码稍复杂,因为它们需要包含立即数。

  • I 类型:包含一个 12 位的立即数字段。这个立即数通常以二进制补码形式编码,可以是正数或负数。
  • S 类型:用于存储指令(如 sw)。它需要两个源寄存器(一个存数据,一个存地址)和一个立即数偏移量。这个立即数被拆分并放置在不同的位域中,以保持与其他指令字段的对齐一致性。
  • B 类型:用于条件分支指令(如 beq)。其立即数字段被更复杂地拆分和重组,主要是为了在编码中保留立即数的符号位,并优化硬件设计。

U 类型和 J 类型指令的编码相对简单,分别用于处理 20 位立即数和大范围跳转偏移量。

系统调用指令:ECALL

ECALL 指令用于向操作系统请求服务(如输入/输出)。它是一条非常简单的指令,其 opcode 是特定的值,其他字段通常为 0。具体的调用功能(例如,打印字符串或读取整数)由事先约定好的寄存器(如 a0)中的值来决定。在后续的模拟器作业中,我们将实现几个简化的 ECALL 功能来支持基本的 I/O。

函数调用与栈帧管理

理解了指令编码后,我们来看看程序运行时,函数调用是如何通过寄存器和内存(栈)来管理的。

当一个函数被调用时,它需要处理参数、返回地址、局部变量,并且可能还需要调用其他函数。RISC-V 定义了一套调用约定来规范这些行为。

调用约定概览

RISC-V 的调用约定主要规定了寄存器的用途:

  • a0-a7:用于传递函数参数和返回值。
  • ra (x1):存放返回地址。
  • sp (x2):栈指针,指向当前栈帧的底部。
  • s0-s11:被调用者保存寄存器。如果函数要修改它们,必须在使用前保存其值,并在返回前恢复。
  • t0-t6:临时寄存器。调用者应假设这些寄存器在被调用函数中会被修改。

栈帧布局

函数调用时,内存中的栈会动态增长和收缩。每个函数的活动记录称为一个“栈帧”。

一个典型的栈帧可能包含以下内容(从高地址向低地址生长):

  • 调用者的保存寄存器(如果需要)。
  • 返回地址。
  • 旧的栈帧指针。
  • 局部变量。
  • 调用其他函数时的参数空间。

示例:非叶子函数
如果一个函数(称为“非叶子函数”)内部还要调用其他函数,它就需要保存那些调用者希望保留的寄存器(通常是 ra 和某些 s 寄存器)到自己的栈帧中,并在返回前恢复它们。

// 函数入口
addi sp, sp, -16    // 分配栈空间
sw   ra, 12(sp)     // 保存返回地址
sw   s0, 8(sp)      // 保存被调用者保存寄存器
... // 函数体,可能调用其他函数
// 函数返回前
lw   s0, 8(sp)      // 恢复寄存器
lw   ra, 12(sp)
addi sp, sp, 16     // 释放栈空间
ret                 // 等同于 jalr zero, ra, 0

示例:叶子函数
如果一个函数(称为“叶子函数”)不调用其他函数,它可能完全不需要使用栈,只需使用寄存器即可,这样效率更高。

调用约定的目的是在调用者和被调用者之间明确责任,确保寄存器状态在函数调用前后保持一致,同时兼顾性能(尽量减少对慢速内存的访问)。

程序的内存布局与地址空间

最后,我们探讨一个程序从源代码到运行在内存中的完整视图。

从源码到可执行文件

  1. 预处理:处理 #include#define 等宏。
  2. 编译:将高级语言代码转换为汇编代码。此阶段检查语法错误。
  3. 汇编:将汇编代码转换为目标文件(.o 文件),其中包含机器码和未解析的符号(如外部函数)。
  4. 链接:将多个目标文件及库文件合并,解析符号地址,生成最终的可执行文件。链接错误常表现为“未定义的引用”。

进程内存布局

操作系统将可执行文件加载到内存中,形成一个进程。典型的进程地址空间布局如下:

高地址
+------------------+
|      栈         |  // 用于函数调用,动态增长(向低地址)
|      ...        |
+------------------+
|      堆         |  // 用于动态内存分配(如 `malloc`),向高地址增长
|      ...        |
+------------------+
|   未初始化数据   |
+------------------+
|   已初始化数据   |
+------------------+
|     代码段       |  // 存放程序指令
低地址
  • 代码段:存放程序的指令(RISC-V 汇编中的 .text 部分)。
  • 数据段:存放全局变量和静态变量。
  • :用于运行时动态分配的内存(如 malloc/new)。需要手动管理(释放)。
  • :用于函数调用,自动管理局部变量和调用信息。返回栈上局部变量的指针是危险的,因为该内存会在函数返回后被重用。

虚拟内存简介

现代操作系统使用虚拟内存为每个进程提供独立的地址空间。进程看到的地址(虚拟地址)需要通过页表翻译成实际的物理内存地址。

这种机制带来了关键好处:

  • 隔离性:不同进程可以使用相同的虚拟地址,但指向不同的物理地址,互不干扰。
  • 灵活性:物理内存可以比虚拟地址空间小,通过将不常用的“页”换出到硬盘(交换空间)来承载更大的程序。当访问被换出的页时,会触发“缺页异常”,由操作系统负责将其换入。

页的大小(如 4KB)是管理的基本单位,需要在翻译效率和换入换出开销之间取得平衡。

总结

本节课我们一起深入学习了 RISC-V 指令集的二进制编码格式,理解了如何通过不同的指令类型(R/I/S/B/U/J)在 32 位内编码各种操作。我们还探讨了 RISC-V 的函数调用约定,明确了寄存器的用途和栈帧的管理方式,这是编写正确且高效汇编程序的关键。最后,我们概述了程序从编译、链接到加载运行的全过程,以及操作系统如何通过虚拟内存管理为进程提供独立且可能超出物理内存大小的地址空间。这些知识构成了计算机体系结构中软件与硬件交互的核心基础,为后续学习处理器核心设计做好了准备。

013:CPU核心架构设计

在本节课中,我们将学习如何设计一个支持RISC-V ISA的简单CPU核心。我们将从基本组件开始,逐步构建数据通路,理解指令如何被获取、解码和执行。课程将涵盖单周期和多周期两种核心设计方法,并最终引入流水线设计的概念。

核心设计基础

上一节我们介绍了课程目标,本节中我们来看看设计CPU核心所需的基本构建模块。

一个简单的CPU核心需要支持RISC-V指令集。我们不会深入到门级或晶体管级设计,而是关注组件如何连接以及数据如何在它们之间流动。

以下是设计所需的基本组件:

  • 程序计数器:这是一个32位的寄存器,用于存储下一条要执行指令的内存地址。
  • 指令存储器:这是一个只读存储器,根据PC提供的地址输出32位的指令数据。
  • 数据存储器:这是一个可读写的存储器,包含地址、读数据、写数据和写使能端口。
  • 寄存器文件:这是一个多端口寄存器文件,包含两个读端口和一个写端口,用于访问32个通用寄存器。

单周期数据通路设计

了解了基本组件后,本节中我们来看看如何将它们连接起来,构建一个单周期数据通路。我们将以Load指令为例。

单周期设计的目标是在一个时钟周期内完成一条指令的所有操作。首先,我们需要获取指令。

指令获取
我们将PC寄存器的输出连接到指令存储器的地址输入。指令存储器的输出就是我们需要执行的32位指令。

获取指令后,我们需要对其进行解码,并执行相应的操作。让我们以Load指令(例如 lw x6, -4(x9))为例。

Load指令的数据通路

  1. 解码源寄存器:指令中的 rs1 字段(例如 x9)作为地址 A1 输入到寄存器文件,从而读取源操作数 RD1
  2. 符号扩展立即数:指令中的12位立即数(例如 -4)通过一个符号扩展单元扩展为32位值。
  3. 计算内存地址:将 RD1(基地址)和符号扩展后的立即数(偏移量)输入到算术逻辑单元进行加法运算。ALU的控制信号 ALU control 决定执行加法操作。公式表示为:有效地址 = RD1 + 符号扩展(立即数)
  4. 读取内存数据:将ALU计算出的有效地址连接到数据存储器的地址端口,从数据存储器中读取数据 Read Data
  5. 写回寄存器:将读取到的内存数据连接到寄存器文件的写数据端口 WD3。同时,将指令中的目标寄存器 rd 字段(例如 x6)连接到地址端口 A3,并设置写使能信号 RegWrite 为有效,从而将数据写回目标寄存器。

执行完当前指令后,我们需要为下一条指令做准备。

更新程序计数器
由于每条指令占4个字节,我们将PC的值加上4,作为下一条指令的地址。这个加法操作可以通过一个专用的加法器完成,公式为:PC = PC + 4

支持更多指令类型

我们已经构建了Load指令的通路,本节中我们来看看如何扩展设计以支持Store和R-type指令。

Store指令
Store指令(S-type)需要将寄存器数据写入内存。其数据通路与Load指令部分相似。

  1. 同样需要计算内存地址(RD1 + 符号扩展立即数)。
  2. 需要从寄存器文件中读取第二个源操作数 rs2 的值(RD2)。
  3. RD2 连接到数据存储器的写数据端口,并将计算出的地址连接到地址端口。
  4. 设置数据存储器的写使能信号 MemWrite 为有效,完成数据写入。

R-type指令
R-type指令(如 add x7, x8, x9)在两个寄存器之间进行运算。

  1. 从寄存器文件读取两个源操作数 RD1 (rs1) 和 RD2 (rs2)。
  2. 为了复用ALU的输入端口,我们需要一个多路选择器来选择ALU的第二个操作数来源。控制信号 ALUSrc 决定是选择 RD2 还是符号扩展后的立即数。对于R-type指令,选择 RD2
  3. RD1RD2 输入ALU进行指定的运算(由 ALU control 决定)。
  4. 运算结果通过多路选择器(由 MemtoReg 控制)选择后,写回寄存器文件的目标寄存器 rd

控制单元
为了协调不同指令的执行,我们需要一个控制单元。它根据指令的 opcodefunct3funct7 字段,生成所有必要的控制信号(如 RegWriteMemWriteALUSrcALU controlMemtoReg 等)。

单周期性能分析

设计好数据通路后,本节中我们分析一下单周期设计的性能。

程序的执行时间可以用以下公式衡量:
执行时间 = 指令数 × 每指令周期数 × 每周期时间

在单周期设计中,每条指令在一个周期内完成,所以 每指令周期数 = 1
然而,每周期时间 必须足够长,以容纳最复杂指令(通常是Load指令)所经过的最长数据通路延迟。这导致时钟周期很长,整体性能可能并不理想。

多周期数据通路设计

为了提升性能,本节中我们引入多周期设计。其核心思想是将一条指令的执行分解为多个较短的步骤,每个步骤在一个时钟周期内完成,从而缩短时钟周期。

在多周期设计中,我们通常使用统一的存储器来存储指令和数据(遵循冯·诺依曼结构)。为了在多个周期中保持指令和中间数据的稳定,我们需要在阶段之间插入临时寄存器。

多周期阶段示例(以Load指令为例)
以下是Load指令可能经历的五个阶段:

  1. 取指:用PC从内存读取指令,存入指令寄存器。
  2. 译码/读寄存器:解析指令,从寄存器文件读取 rs1 的值,并对立即数进行符号扩展。控制单元开始生成后续阶段所需的控制信号。
  3. 计算内存地址:使用ALU将 rs1 的值与立即数相加,计算结果存入ALU输出寄存器。
  4. 访问内存:用计算出的地址从内存读取数据,存入内存数据寄存器。
  5. 写回:将内存读取的数据写回寄存器文件的目标寄存器 rd

其他指令(如Store、R-type)会经历类似的阶段,但可能跳过某些步骤(如Store指令不需要写回寄存器阶段)。

性能权衡
多周期设计缩短了时钟周期,但完成一条指令需要多个周期(例如3-5个CPI)。虽然单周期时间变短,但CPI增加,整体性能提升有限,且控制逻辑更为复杂。然而,这种分阶段的设计是理解流水线的基础。

流水线设计原理

多周期设计的利用率不高,因为同一时刻只有部分硬件在工作。本节中我们看看如何通过流水线技术提高硬件利用率。

流水线类似于工厂的装配线。它将指令处理过程划分为若干个独立的阶段,并使多条指令在不同阶段重叠执行。

五级流水线
一个经典的划分是五级流水线:

  1. 取指
  2. 译码
  3. 执行
  4. 访存
  5. 写回

流水线实现
在单周期数据通路的基础上,我们在每个阶段之间插入流水线寄存器,用于锁存该阶段结束时的所有数据和控制信号,并将其传递到下一阶段。

流水线性能
理想情况下,流水线可以每个时钟周期完成一条指令(CPI ≈ 1),同时时钟周期由于阶段划分而缩短。因此,它能结合单周期(低CPI)和多周期(短周期)的优点,显著提升性能。公式表示为:吞吐量 ≈ 1 / (每阶段最大延迟)

流水线的挑战
流水线带来了新的挑战,主要是冒险

  • 结构冒险:硬件资源冲突(如单端口内存同时进行取指和访存)。解决方案是使用分离的指令缓存和数据缓存。
  • 数据冒险:后续指令需要用到前面指令尚未写回的结果(如 lw 后紧接 add 使用该加载结果)。这需要通过前递流水线停顿技术解决。
  • 控制冒险:分支指令改变程序流向,导致已取入流水线的指令无效。这需要通过分支预测等技术解决。

本节课中我们一起学习了CPU核心设计的基本方法。我们从基本组件出发,构建了单周期数据通路,并分析了其性能局限。为了改进,我们引入了多周期设计,将指令执行分步进行。最后,我们探讨了流水线设计的原理,它通过指令重叠执行极大地提高了硬件利用率和性能。理解这些基础设计是学习现代复杂处理器架构的关键。在接下来的课程中,我们将深入探讨流水线中遇到的数据冒险和控制冒险及其解决方案。

014:GPU架构1 🚀

在本节课中,我们将学习GPU架构的基础知识。我们将从GPU编程模型开始,了解其核心概念,然后深入到GPU汇编层面,探讨其与CPU架构的关键差异。课程内容将涵盖异构计算、CUDA编程模型、SIMD/SIMT执行模式以及线程组织等核心主题。


GPU编程模型

上一节我们介绍了课程概述,本节中我们来看看GPU编程的基本模型。GPU编程本质上是异构编程,即CPU(主机)和GPU(设备)协同工作。

在异构编程模型中,程序分为两部分:

  • 主机(Host)代码:运行在CPU上,负责控制流程、内存分配和数据传输。
  • 设备(Device)代码:运行在GPU上,称为内核(Kernel),负责执行大规模并行计算。

以下是编写一个简单向量加法(Vector Add)GPU程序的基本步骤:

  1. 在主机内存中分配和初始化数据:使用标准C库函数(如malloc)在CPU内存中为输入向量A、B和输出向量C分配空间并赋值。
  2. 在设备内存中分配空间:使用CUDA运行时API(如cudaMalloc)在GPU内存中为向量d_Ad_Bd_C分配空间。
  3. 将数据从主机复制到设备:使用cudaMemcpy函数,并指定方向为cudaMemcpyHostToDevice,将数据从CPU内存拷贝到GPU内存。
  4. 启动GPU内核进行计算:调用以__global__关键字修饰的内核函数(例如vector_add),并指定执行配置(网格大小和块大小)。
  5. 将结果从设备复制回主机:使用cudaMemcpy函数,并指定方向为cudaMemcpyDeviceToHost,将计算结果从GPU内存拷贝回CPU内存。
  6. 释放内存:释放主机和设备上分配的所有内存。

一个典型的内核函数示例如下,它被设计为每个GPU线程处理一个输出元素:

__global__ void vector_add(float *a, float *b, float *c, int size) {
    // 计算当前线程的全局索引
    int gid = blockIdx.x * blockDim.x + threadIdx.x;
    // 边界检查,防止越界访问
    if (gid < size) {
        c[gid] = a[gid] + b[gid];
    }
}

内核启动时的执行配置决定了线程的组织方式:

// 假设每个线程块有256个线程
int block_size = 256;
// 计算需要的线程块数量(向上取整)
int grid_size = (size + block_size - 1) / block_size;
// 启动内核
vector_add<<<grid_size, block_size>>>(d_A, d_B, d_C, size);

GPU线程组织与执行模型

理解了基本的编程模型后,本节我们深入探讨GPU如何组织海量线程并执行它们。GPU的线程是分层组织的:

  • 网格(Grid):一个内核启动的所有线程集合。
  • 线程块(Block):网格中的一组线程,共享一块快速的共享内存,块内线程可以同步。
  • 线程束(Warp):GPU调度和执行的基本单位。在NVIDIA GPU中,一个Warp包含32个线程;在AMD GPU中,一个Wavefront包含64个线程(CDNA架构)或32个线程(RDNA架构)。

GPU采用SIMT(单指令多线程)SIMD(单指令多数据) 的执行模式。这意味着一个Warp/Wavefront中的所有线程在同一周期内执行相同的指令,但操作不同的数据

公式SIMT: 1条指令 -> N个线程 -> N个数据

这种设计极大地降低了取指和译码的开销,使GPU能将更多晶体管资源用于计算单元,从而擅长处理数据并行任务。


GPU汇编指令初探

从高层编程到底层执行,本节我们通过一个简单的数据拷贝内核的汇编代码,来看看GPU指令集的特点。我们以AMD GPU汇编为例。

考虑一个简单的内核:

__global__ void simple_copy(float *in, float *out) {
    int gid = ...; // 计算全局ID
    out[gid] = in[gid] + 4;
}

其汇编代码的核心部分可能包含以下类型的指令:

  1. 标量(Scalar)指令与向量(Vector)指令

    • 标量指令(S指令):操作标量寄存器(S寄存器)。一个Warp内所有线程共享同一份标量数据。常用于计算地址、循环计数等。
    • 向量指令(V指令):操作向量寄存器(V寄存器)。一个Warp内每个线程都有自己独立的向量数据。用于进行实际的数据计算。
    • 例如,加载基地址可能使用标量加载指令s_load_dword,而从内存加载每个线程的数据则使用向量加载指令global_load_dword v4, [v0, v1]
  2. 内存同步指令

    • 由于GPU内存访问延迟很高,GPU采用异步内存操作。当发出加载指令后,硬件不会等待数据返回,而是继续执行后续不依赖该数据的指令。
    • 为了确保在需要使用数据之前它已就绪,编译器会插入等待计数(s_waitcnt) 指令。例如,s_waitcnt lgkmcnt(0) 表示等待所有先前的本地/全局/常量内存操作完成。
  3. 指令示例

    • v_lshlrev_b64 v[2:3], 2, v[2:3]:这是一个向量指令,将64位寄存器v[2:3]的值左移2位(即乘以4)。一个Warp中的32/64个线程同时执行此操作,但各自操作自己v[2:3]寄存器中的值。

条件执行与线程分化

在实际程序中,分支(如if-else)不可避免。本节我们看看GPU如何处理条件分支,以及由此引发的性能问题——线程分化。

GPU使用谓词执行(Predicated Execution)执行掩码(EXEC Mask) 来处理条件分支。核心思想是:Warp中的所有线程都遍历所有分支的代码路径,但通过一个掩码来控制哪些线程的执行结果真正生效。

以下是一个条件拷贝内核的示例:

__global__ void conditional_copy(double *in, double *out) {
    int gid = ...;
    if (gid > 0) {
        out[gid] = in[gid];
    }
}

其汇编处理流程大致如下:

  1. 所有线程加载数据in[gid]
  2. 所有线程执行比较指令(如v_cmp_gt_f64),将比较结果(真/假)存入一个特殊的掩码寄存器(如VCC),每个线程对应一位。
  3. 通过s_and_saveexec_b64等指令,将当前执行掩码(EXEC)与比较掩码(VCC)进行逻辑与操作,并保存旧的EXEC。结果是,只有满足条件(gid > 0)的线程对应的EXEC位为1,可以执行后续的存储指令。
  4. 执行存储指令global_store_dword。虽然所有线程都“执行”了该指令,但只有EXEC位为1的线程的存储操作会实际发生。
  5. 分支结束后,使用s_or_b64 exec, exec, saved_mask恢复之前的执行掩码,以便后续代码继续执行。

线程分化(Thread Divergence) 是指一个Warp中的线程因为条件分支而走上不同的执行路径。由于GPU以Warp为单位执行相同指令,如果Warp内部分线程走if分支,部分走else分支,GPU必须串行化地执行这两个分支(先执行一部分线程的if块,再执行另一部分线程的else块),导致硬件利用率下降。

优化目标:尽可能让一个Warp内的线程执行相同的代码路径,以减少线程分化带来的性能损失。


本节课中我们一起学习了GPU架构的基础知识。我们从GPU的异构编程模型入手,了解了主机与设备的协作方式。接着,我们探讨了GPU独特的线程层级组织(网格、块、线程束)和SIMT执行模型,这是其实现大规模并行的关键。通过分析GPU汇编代码,我们认识了标量与向量指令的区别,以及GPU如何通过异步内存操作和等待计数来隐藏内存延迟。最后,我们讨论了GPU处理条件分支的机制——谓词执行和执行掩码,并指出了线程分化这一重要的性能考量因素。这些概念是理解现代GPU工作原理和进行高效GPU编程的基石。

015:高级CPU核心架构 🚀

在本节课中,我们将开始探讨高级CPU核心设计。我们将从经典的五级流水线出发,深入分析流水线执行中可能出现的各种“冒险”问题,并学习解决这些问题的不同方法,包括硬件和软件层面的优化策略。课程将涵盖数据冒险、控制冒险以及现代高性能CPU设计中常见的超标量和乱序执行等核心概念。


从五级流水线出发

上一节我们回顾了计算机体系结构的基础。现在,我们以经典的五级流水线作为起点。这五个阶段通常包括:取指、译码、执行、访存和写回。在某些设计中,译码阶段也包含读取寄存器文件的操作。

在一个理想的执行场景中,指令可以完美地流过这些阶段,每个时钟周期完成一条指令。下图展示了一个没有冲突的理想流水线执行图:

时间(周期) -> 1   2   3   4   5
指令1 (add):  F   D   E   M   W
指令2 (sub):      F   D   E   M   W
指令3 (and):          F   D   E   M   W

然而,在实际执行中,指令之间可能存在依赖关系,这会破坏流水线的完美执行,导致错误的计算结果。这种情况被称为“冒险”。


理解数据冒险

当后续指令需要用到前一条指令尚未产生的结果时,就会发生数据冒险。具体来说,这通常是一种“读后写”冒险。

考虑以下指令序列:

add  s8, s1, s2  # 将 s1+s2 的结果写入 s8
sub  s2, s8, s3  # 需要读取 s8 的值

在五级流水线中,add 指令的结果在周期5的写回阶段才写入寄存器 s8。但是,sub 指令在周期3的译码阶段就需要读取 s8 的值。此时,s8 中的还是旧数据(垃圾值),这会导致 sub 指令计算出错误结果。

冒险被定义为:一种阻碍指令流水线产生正确结果,但又不会中断流水线执行的情况。数据冒险主要源于寄存器间的数据依赖。

以下是可能的数据依赖类型:

  • 读后读:多次读取同一寄存器,通常不构成冒险。
  • 读后写:在写入一个寄存器后读取它。这是我们上面例子中的情况,称为 RAW 冒险。
  • 写后读:在读取一个寄存器后写入它。
  • 写后写:连续两次写入同一寄存器。

在简单的五级流水线中,最常见的是 RAW 冒险。解决冒险主要有三种思路:牺牲性能、增加硬件复杂度,或者寻找更巧妙的折中方案。硬件设计必须在极短的周期(如1-2纳秒)内做出正确决策,因此算法必须极其简单高效。


解决冒险的简单方法

面对RAW冒险,最直观的解决方案是让流水线“停顿”。

方法一:插入空操作

编译器可以在有依赖的指令之间插入特殊的 nop 指令。nop 不执行任何操作,只是作为流水线的“填充物”,延迟后续指令的执行。

add s8, s1, s2
nop
nop
sub s2, s8, s3

通过插入两个 nopsub 指令的译码阶段被推迟到周期5,此时 s8 的值已经正确写回,从而可以读取到正确数据。

优点

  • 实现简单。
  • 是纯粹的软件方案,通过编译器即可实现,易于修改和调试。

缺点

  • 性能低下nop 指令占据了流水线阶段却不做有用工作。
  • 混合了体系结构与微体系结构nop 的数量取决于具体的流水线深度(微体系结构细节)。如果硬件将5级流水线改为20级,那么所需的 nop 数量也会改变,这限制了微体系结构设计的灵活性。

方法二:流水线冒泡

我们可以在硬件层面实现类似的效果,而无需修改软件。这种方法称为流水线冒泡

当硬件检测到RAW冒险时,它不会让处于译码阶段的指令继续进入执行阶段,而是将其“卡”在译码阶段。从流水线图上看,就像在流水线中插入了一个“气泡”,这个气泡会向后传递。

时间(周期) -> 1   2   3   4   5   6
add s8, s1, s2: F   D   E   M   W
sub s2, s8, s3:     F   D   D   D   E   M   W
                        ^   ^
                        |   |
                      (气泡)

硬件通过控制流水线寄存器的“使能”信号来实现停顿。这是纯粹的微体系结构方案

优点

  • 对软件透明,不依赖编译器。
  • 可以灵活适应不同深度的流水线设计。

缺点

  • 性能与插入 nop 相同,仍然较低。
  • 需要额外的硬件电路来检测冒险并控制流水线。

无论是插入 nop 还是产生气泡,本质上都是通过牺牲性能来保证正确性。随着流水线加深,这种性能损失会变得难以接受。


更优的解决方案:数据前递

我们重新审视问题:add 指令的结果在周期3结束时(执行阶段后)就已经计算出来了。而 sub 指令直到周期4开始时才需要这个结果。时间上并没有重叠,问题在于我们必须通过寄存器文件来传递这个数据。

如果我们能绕过寄存器文件,直接将执行阶段产生的结果通过额外的连线“前递”给需要它的执行阶段输入,问题就迎刃而解。这种方法称为数据前递

add s8, s1, s2: F   D   E   M   W
                    |       |
                    |<------(前递路径)
sub s2, s8, s3:     F   D   E   M   W

硬件上,我们需要在ALU的输出端和输入端之间添加额外的数据通路和多路选择器。当检测到后续指令的源操作数是前一条指令的目的操作数时,控制逻辑就选择前递过来的数据,而不是从寄存器文件读取的数据。

优点

  • 性能高:消除了由RAW冒险引起的停顿。
  • 对软件透明。

缺点

  • 硬件复杂度增加:需要额外的数据通路、多路选择器和更复杂的控制逻辑。
  • 并非能解决所有情况。例如,如果第一条指令是加载指令,数据要到访存阶段结束后才能获得,此时可能仍然需要一个周期的停顿。

数据前递是解决RAW冒险的高效方法,被现代处理器广泛采用。


控制冒险与更深流水线

上一节我们解决了数据冒险。本节我们来看看另一种冒险。为了追求更高的时钟频率,现代CPU采用了更深的流水线(如20-25级)。但这带来了新的问题:控制冒险

控制冒险主要由分支指令引起。考虑以下代码:

    beq s1, s2, L1  # 如果 s1 == s2,跳转到 L1
    add s3, s4, s5  # 下一条指令
    sub s6, s7, s8  # 再下一条指令
L1: or  s9, s10,s11

在五级流水线中,beq 指令要到执行阶段末尾(周期4)才能计算出是否跳转。但在此期间,其后的 addsub 指令已经被取指并进入流水线。如果最终分支跳转,那么这两条指令就不应该执行。

最简单的解决方法是停顿:在分支指令之后插入气泡,直到分支结果确定后再继续取指。但在深流水线中,这种停顿的代价极高。

更优方案:分支预测与推测执行

更积极的方法是分支预测。硬件在取指阶段就预测分支的走向(跳转或不跳转),并推测性地继续取指和执行预测路径上的指令。

  • 如果预测正确:性能无损,程序继续执行。
  • 如果预测错误:硬件必须清空在错误路径上已经执行的所有指令效果,这个过程称为“流水线冲刷”,然后从正确的地址重新开始取指。

分支预测器是核心部件。一个简单的预测器是两位饱和计数器,它为每个分支指令维护一个状态机,根据历史行为进行预测,对噪声不敏感,稳定性好。

状态: 强不跳转 -> 弱不跳转 <-> 弱跳转 -> 强跳转
      (预测不跳转)         (预测跳转)

现代预测器极其复杂,甚至出现了基于神经网络的预测器。然而,推测执行也引入了安全漏洞(如 Spectre 和 Meltdown),因为这些被错误推测执行但随后被冲刷的指令可能会在缓存等微体系结构状态中留下痕迹,被恶意程序通过侧信道探测到。


提升并行度:超标量与乱序执行

除了加深流水线,另一个提升单核性能的重要方向是加宽流水线,即让一个周期内能处理多条指令。这通过超标量设计实现。

超标量处理器在一个周期内可以发射多条指令到多个执行单元(如多个ALU、加载/存储单元等)。描述一个核心常使用“N发射,M级流水线”这样的术语。

指令级并行与依赖挑战

理想情况下,如果指令间没有依赖,它们可以并行执行。例如:

ld  s7, 0(s1)  # 加载
or  s8, s2, s3 # 逻辑或

这两条指令操作不同的寄存器,可以在同一周期发射执行。

但当指令间存在复杂依赖时,简单的按序发射会遇到瓶颈。考虑一个更复杂的序列,其中存在 WARWAW 冒险。在简单的按序超标量中,这些冒险会导致流水线停顿。

乱序执行与寄存器重命名

为了挖掘更多指令级并行,现代高性能CPU采用了乱序执行。关键思想是:硬件动态地分析指令间的真依赖,并重新排序指令的执行顺序,只要最终结果与按序执行一致即可。

然而,假依赖(名依赖)会阻碍重新排序。假依赖包括:

  • WAR:写后读冒险,源于寄存器重用。
  • WAW:写后写冒险,同样源于寄存器重用。

寄存器重命名 是消除假依赖的核心技术。硬件维护一个比架构寄存器(如RISC-V的32个)更大的物理寄存器堆。当遇到写操作时,硬件为其分配一个新的物理寄存器,并更新映射关系。这样,即使多条指令在代码中使用了相同的架构寄存器名,在硬件层面它们也被映射到不同的物理寄存器,从而解除了假依赖。

通过寄存器重命名和乱序执行,硬件可以将一个存在假依赖的指令序列,转化为多个独立的指令子链,从而更灵活、更充分地利用多个执行单元。


总结与展望

本节课我们一起深入探讨了高级CPU核心设计中的关键问题与解决方案。

  1. 冒险问题:我们首先识别了流水线中的数据冒险,特别是RAW冒险,并学习了三种解决方案:插入空操作(软件)、流水线冒泡和数据前递(硬件)。数据前递在性能和复杂度间取得了良好平衡。
  2. 控制冒险:在深流水线中,分支指令会带来严重的控制冒险。我们介绍了通过分支预测推测执行来隐藏分支延迟的方法,同时也提到了其带来的安全挑战。
  3. 提升并行度:为了突破每周期一条指令的限制,现代CPU采用超标量设计来加宽流水线。为了更有效地利用多个执行单元,引入了乱序执行技术,并通过寄存器重命名来消除阻碍指令重排序的假依赖。

这些技术(深流水线、超标量、乱序执行、分支预测、推测执行)的组合,构成了现代高性能CPU核心的复杂微体系结构。它们的目标都是在严格的功耗和面积约束下,最大限度地提升指令级并行度和单线程性能。在接下来的课程中,我们将把目光从CPU核心移开,开始探讨GPU的核心架构设计。

016:GPU 架构 2

在本节课中,我们将学习GPU硬件如何支持其软件模型。通过了解硬件的约束,我们能更好地理解软件为何如此设计,以及软件为适应硬件要求所做的权衡。我们将从GPU发展历史入手,然后深入探讨GPU的核心硬件架构,特别是计算单元的内部工作原理。

回顾GPU发展历史

上一节我们介绍了GPU的软件和汇编。本节中,我们来看看GPU硬件的发展历程。了解GPU产品的演进,有助于我们理解行业趋势和每一代产品的改进重点。

我们以AMD为例,通过一个表格来梳理其GPU产品线的发展。表格包含以下几个关键信息:发布日期、产品名称、芯片内部代号以及架构家族。

以下是AMD GPU产品线的关键节点:

  • 2008年:AMD通过收购ATI进入GPU市场,初期使用ATI的架构。
  • 2011年:AMD推出首款基于全新自研架构(GCN, Graphics Core Next)的GPU,芯片代号为 GFX 600。产品如Radeon HD 7970。
  • 2015年:推出 GCN 3 架构,芯片代号为 GFX 803。代表产品是 R9 Nano。这款GPU时钟频率为1 GHz,配置规整(多为2的幂次),非常适合作为教学和分析的基础架构。本节课将主要以此架构为例。
  • 2016-2017年:进入14纳米制程时代。产品如RX 480(仍为GFX 803芯片)。随后推出 GCN 5 架构(代号 GFX 900),代表产品为 Vega 64/56(数字代表计算单元数量)。
  • 2019年:推出全球首款7纳米制程GPU Radeon VII(芯片仍为GFX 900)。此后,AMD将产品线拆分为两条:
    • RDNA:面向游戏图形市场。
    • CDNA:面向高性能计算市场。
  • 2020年至今:RDNA架构迭代至RDNA 2、3代。CDNA架构则发展出MI100、MI200(首次采用Chiplet多芯片封装技术)和MI300(引入张量核心)等计算卡。CDNA在底层仍与GCN架构有继承关系。

NVIDIA的产品线发展也类似,有面向游戏的GeForce系列和面向计算的Tesla(如A100)/Hopper/Blackwell系列,脉络相对更清晰。

了解历史后,我们将聚焦于 GFX 803(R9 Nano) 这一代表性架构,深入其硬件组成和工作原理。

GPU硬件执行模型

上一节我们回顾了GPU的发展。本节中,我们来看看一个GPU程序在硬件上是如何被调度和执行的。

回忆运行时API的调用流程:主机(CPU)程序调用运行时库(如CUDA Runtime),后者通过驱动(Driver)与GPU通信。驱动负责将内核(Kernel)启动和内存拷贝等命令发送给GPU。

以下是GPU硬件执行的关键路径:

  1. 命令处理器(Command Processor):GPU上的一个专用处理器,负责接收通过PCIe总线从CPU驱动发来的命令。
  2. 任务类型
    • 内存拷贝:命令处理器将拷贝任务交给 DMA引擎。GPU通常有两个DMA引擎,可同时处理主机到设备和设备到主机的拷贝,以提高带宽。
    • 内核执行:命令处理器将内核交给 异步计算引擎(ACE) 处理。GPU可以有多个ACE,以实现多个内核同时执行。

内核的层次化执行与硬件映射

上一节介绍了命令如何到达GPU。本节中,我们来看看一个内核是如何在GPU的大量计算核心上展开执行的。

一个内核在硬件上被组织成多个层次:

  • 网格(Grid):一个启动的内核。对应AMD术语中的 NDRange
  • 线程块(Block):网格的子部分。对应AMD术语中的 工作组(Work-group)。一个块内的线程可以进行安全的同步。
  • 线程束(Warp):GPU调度和执行的基本单位,通常包含32或64个线程。对应AMD术语中的 波前(Wavefront)
  • 线程(Thread):最基本的执行单元。对应AMD术语中的 工作项(Work-item)通道(Lane)

关键限制与设计哲学:GPU拥有大量计算单元(CU或SM),但数量远少于可能产生的线程块(例如100万个)。GPU采用 批量执行、完成后释放 的策略,而非CPU的上下文切换。因此,不同线程块之间无法进行全局同步,因为某些块可能尚未被调度执行。同步只能安全地发生在一个线程块内部。

计算单元内部架构

上一节我们了解了内核在计算单元间的分发。本节中,我们深入一个计算单元(CU)内部,看看线程束是如何被调度和执行的。

一个计算单元能同时容纳多个线程束(波前)。在GCN架构中,这由以下资源和限制共同决定:

  • 波前槽(Wavefront Slots):每个CU有4个波前端(Pool),每个前端有10个槽位,共可容纳 40个 活跃波前。
  • 寄存器文件:包括标量寄存器和向量寄存器。每个线程有其私有寄存器。寄存器总量是有限的。
  • 本地数据共享(LDS):一个可被CU内所有线程访问的高速共享内存。

当ACE向CU分发线程块时,需要为该块中的所有线程分配上述资源。如果资源不足,分发就会受阻。因此,一个CU内能同时执行的线程块数量,取决于每个块所需的资源大小。例如,一个包含256个线程的块(在64线程/波前的架构中占4个波前),在拥有40个波前槽的CU中,最多同时执行10个这样的块。

性能提示:如果内核非常小(线程数少),可能会产生海量线程块,导致ACE成为分发瓶颈。优化方法是让每个线程完成更多工作,从而减少总线程数,提升整体效率。

指令发射与执行流水线

上一节我们讨论了计算单元的资源限制。本节中,我们来看看这些活跃的波前中的指令是如何被取出并执行的。

计算单元内部的指令执行流程如下:

  1. 取指(Fetch):取指仲裁器从40个活跃波前中选出一个,从其程序计数器(PC)指向的地址获取指令。指令可能来自指令缓存或指令缓冲区。
  2. 发射(Issue):发射仲裁器从所有准备好执行的波前中,选择最多 5条 指令,分派到5个不同的执行单元。执行单元包括:
    • 分支单元(Branch)
    • 标量单元(Scalar)
    • 向量内存单元(Vector Memory)
    • 向量ALU单元(Vector ALU, 即SIMD单元)
    • 本地数据共享单元(LDS)
  3. 仲裁规则
    • 规则1:如果波前已有指令在流水线中,则本次不选择它(简化冲突处理)。
    • 规则2:两个波前不能同时使用同一个执行单元(结构冲突)。
    • 策略:在满足规则的候选波前中,采用某种策略(如优先选择最近发射过的波前)选择5个进行发射。
  4. 执行(Execute):以向量ALU单元为例,每个CU包含4个SIMD单元。每个SIMD单元有16个通道(Lane)。一个包含64个线程的波前,需要 4个周期 才能在一个SIMD单元上完成一条指令(16通道 * 4周期 = 64线程)。4个SIMD单元并行工作,可以流水化地持续执行波前指令。

重要概念澄清:在讨论GPU性能时,需明确指令层级。硬件发射的是 波前级指令,一条这样的指令意味着对64个线程同时执行相同的操作。

理论峰值算力计算

上一节我们剖析了指令执行流水线。本节中,我们利用这些知识来计算GPU的理论峰值算力。

以R9 Nano(GFX 803)为例:

  • 频率 = 1 GHz = 10^9 周期/秒
  • 每个CU有 4个 SIMD单元
  • 每个SIMD单元每周期可完成 16次 浮点运算(16个通道)
  • GPU共有 64个 CU

计算过程

  1. 每周期总运算次数 = 64 CU * 4 SIMD/CU * 16 运算/SIMD = 4096 次运算/周期
  2. 每秒总运算次数 = 4096 运算/周期 * 10^9 周期/秒 = 4.096 TeraFLOPs(单精度)

若考虑乘加(FMA)指令计为2次操作,则理论峰值算力为 8.2 TeraFLOPs

相关术语

  • CUDA核心/流处理器:本质上对应一个通道(Lane),即一个线程的ALU执行上下文。R9 Nano的4096个“核心”即由此而来。
  • 超线程(Hyper-Threading):GPU可视为一个拥有大量(如40个波前级)硬件线程上下文(即“超线程”)的机器,调度器通过在这些上下文间快速切换来隐藏延迟。

本节课中,我们一起学习了GPU硬件架构的核心原理。我们从GPU产品发展历史入手,理解了其设计背景。然后,我们深入探讨了GPU的硬件执行模型,包括命令处理、内存拷贝和内核的层次化执行。我们重点剖析了计算单元的内部结构,了解了指令发射、仲裁规则以及执行流水线,并以此为基础计算了GPU的理论峰值算力。这些知识为我们理解GPU如何通过大规模并行和精细的硬件调度来获得极高计算吞吐量奠定了基础。下一讲,我们将探讨GPU的内存层次结构,这是理解其性能特性的另一个关键。

017:缓存设计 1 💾

在本节课中,我们将要学习计算机体系结构中的一个核心组件:缓存。我们将从内存系统的基本概念出发,探讨为什么需要缓存,并详细介绍缓存的工作原理、设计权衡以及关键概念,如局部性、缓存行和关联度。

内存系统与冯·诺依曼瓶颈

上一节我们介绍了处理器与内存系统的基本结构。在冯·诺依曼架构中,指令和数据都存储在内存中。处理器需要从内存中读取数据以进行计算,并将结果写回内存。

冯·诺依曼在设计时就预见到,内存系统的发展速度可能跟不上处理器核心的发展。处理器核心可以运行得非常快,但将数据从内存带到核心以及将数据存回内存会花费大量时间,这将成为性能瓶颈,即冯·诺依曼瓶颈

内存技术:SRAM 与 DRAM

为了理解缓存的作用,我们首先需要了解两种主要的内存技术:SRAM 和 DRAM。

以下是 SRAM 与 DRAM 的关键特性对比:

  • 基本单元:SRAM 通常由 6 个晶体管构成一个存储单元。DRAM 的基本单元是一个电容加一个晶体管(电容由晶体管本身构成),因此每个比特只需要 1 个晶体管。
  • 数据保持:SRAM 在通电期间可以一直保持数据。DRAM 需要定期刷新。
  • 速度:SRAM 非常快,访问延迟可以低至 1 个周期或最多几十个周期。DRAM 较慢,延迟通常在几十到几百个周期。
  • 密度与成本:SRAM 的密度低,占用更多晶体管和面积,因此无法构建非常大的存储,且每比特成本更高。DRAM 可以构建更大的存储,每比特成本更低。
  • 功耗:SRAM 功耗较高。DRAM 功耗较低。

没有一种技术适用于所有场景,总是存在权衡,我们需要在合适的位置选择最合适的技术。

缓存的引入与作用

随着处理器时钟频率达到数 GHz,需要大量数据来维持核心忙碌,此时 DRAM 的速度已无法满足需求,而 SRAM 又无法提供足够大的容量(通常只有几 KB 到几 MB)。因此,我们在核心和内存之间引入了缓存

这就像在家(缓存)存放最常用的物品,而在租用的大型仓库(主存)存放不常用的物品。缓存存储频繁使用的数据,以提高访问速度。

缓存对程序员是透明的,即程序无需为缓存系统修改地址访问方式。它只是一种性能优化方法。

缓存的主要作用包括:

  • 存储频繁使用的数据。
  • 提高进入核心的有效带宽(如果缓存命中率高)。
  • 将平均访问延迟从数百个周期降低到几个周期。

多级缓存层次结构

即使缓存由 SRAM 构建,其设计方式也不同,可以提供不同的带宽和延迟。因此,我们通常构建多级缓存。

以下是多级缓存层次结构的特点:

  • 越靠近核心:缓存容量越小,速度越快(延迟越低),带宽越高(在芯片内部)。
  • 越靠近内存(DRAM):缓存容量越大,但延迟也越长。
  • 最后一级缓存:最靠近 DRAM 的缓存(如 L3)通常称为最后一级缓存。它充当 DRAM 的代理,所有读写请求都需要经过它。从 DRAM 的角度看,它也是一个带宽加速器,如果命中率高,可以有效提升从最后一级缓存输出的带宽。

增加缓存层级会引入额外的查找和传输延迟,但也能通过提高命中率来降低潜在的平均延迟。缓存层级数量没有固定答案,取决于具体设计权衡。例如,AMD GPU 架构在几年内就从两级缓存过渡到了四级缓存。

局部性原理

在深入缓存实现之前,我们需要理解局部性这个概念,它描述了程序访问内存的模式。

局部性主要分为两类:

  • 时间局部性:如果程序访问了一个数据项,那么它很可能在不久的将来再次访问相同的数据项。例如,循环中反复访问同一个变量。
  • 空间局部性:如果程序访问了一个内存位置,那么它很可能在不久的将来访问附近的内存位置。例如,按顺序遍历数组。

大多数程序都遵循这些模式。缓存的设计正是为了利用这些模式:

  • 针对时间局部性,缓存会保留最近访问过的数据。
  • 针对空间局部性,当访问一个数据时,缓存会将其附近的一组数据(一个缓存行)一起取来保存。

缓存的基本操作与缓存行

现在,我们将缓存视为一个黑盒,看看其基本接口和行为。

当核心发起一个读请求,例如访问地址 A,缓存需要检查数据是否已在其中。缓存存储数据的基本单位称为缓存行。例如,数据总线宽度为 64 字节,那么即使核心只请求 4 字节数据,缓存也会传输整个 64 字节的缓存行。缓存行的大小(如 32、64、128 字节)是对齐的。

缓存需要知道里面存储了哪些地址的数据。为此,每个缓存行除了存储数据本身,还存储一个标签,用于记录该行数据对应的内存地址。

一次缓存访问可能有两种结果:

  1. 缓存命中:请求的数据在缓存中。缓存直接将该数据返回给核心,速度非常快。
  2. 缓存未命中:请求的数据不在缓存中。缓存需要向下一级存储(可能是更低级缓存或主存)发起读请求,等待数据返回。数据返回后,缓存会将其存入一个空闲位置,并设置好标签,然后将数据送给核心。如果缓存已满,则需要选择一个牺牲块将其替换出去。

缓存行大小的权衡

缓存行大小是一个重要的设计参数。

以下是不同缓存行大小的优缺点:

  • 大缓存行的优点
    • 更好地利用空间局部性。
    • 标签存储开销相对更小(因为标签数量不变,但每行数据更多)。
  • 大缓存行的缺点
    • 缓存中可存放的行数更少。
    • 带宽利用率可能降低(如果传输的数据中只有一小部分被用到)。
    • 可能增加延迟和能耗。

选择取决于程序的空间局部性强弱。数据总线宽度、缓存行大小、上下级接口宽度可以是相互独立的参数。例如,扇区缓存使用较大的缓存行但较小的数据总线,可以兼顾低开销和较好的带宽利用率。

缓存的组织方式:关联度

缓存内部如何根据地址找到数据?一种直观的设计是为每个缓存行配备一个比较器,将输入地址与所有标签同时比较。这称为全关联缓存。其优点是灵活,任何数据可以存放在任何位置,命中率高。缺点是硬件成本(比较器数量、电路复杂度)高。

为了降低硬件成本,引入了直接映射缓存。它将缓存划分为若干组,通过地址中的几位(索引位)直接确定一个数据块只能存放在哪个唯一的组里。这样只需要一个比较器。缺点是容易发生冲突,即多个频繁访问但索引相同的数据会互相踢出,即使缓存其他部分空闲。

折中的方案是组相联缓存。缓存被分为 S 个组,每个组有 W 个路。一个数据块可以存放在对应组内的任意一个路中。这需要 W 个比较器。W 被称为相联度

这是一个从直接映射到全关联的谱系:

  • 直接映射: 相联度 = 1。成本最低,冲突概率最高。
  • 组相联: 1 < 相联度 < 缓存总行数。在成本和性能间权衡。
  • 全关联: 相联度 = 缓存总行数。性能最好,成本最高。

选择哪种方式取决于性能需求与硬件预算。缓存未命中惩罚越高,越倾向于使用高相联度。现代 L1 缓存常用 4 路或 8 路组相联,最后一级缓存可能用到 16 路或 32 路。全关联缓存由于成本过高很少使用,但 TLB 等对未命中惩罚极高的特殊缓存可能会采用。

替换策略

当缓存已满且发生未命中时,需要从候选位置中选择一个牺牲块替换出去。常用的替换策略包括:

以下是几种常见的缓存替换策略:

  • 最近最少使用: 替换最长时间未被访问的块。理论上能提供较好的命中率,但实现成本较高,尤其是对于大缓存和多路组相联缓存。
  • 先进先出: 替换最早进入缓存的块。实现简单,性能尚可。
  • 随机替换: 随机选择一个块替换。实现非常简单,在某些访问模式下(如“计算一次”的数据),其性能可能优于 LRU。
  • 最近最多使用: 替换最近被访问过的块。这听起来反直觉,但在某些特定场景下(如缓存颠簸),它可能表现更好,因为它倾向于保护那些较早被访问但后续还会用到的数据(如卷积核)。

总结

本节课我们一起学习了缓存设计的基础知识。我们从冯·诺依曼瓶颈和内存技术差异出发,理解了引入缓存的必要性。缓存通过利用程序的时间局部性和空间局部性,存储频繁访问的数据,从而降低平均访问延迟,提升有效带宽。

我们探讨了多级缓存层次结构、缓存行的概念以及大小权衡。重点分析了缓存的组织方式,包括直接映射、组相联和全关联缓存,以及它们如何在硬件成本与性能之间取得平衡。最后,我们介绍了当缓存满时决定替换哪个数据块的几种策略。

下一节课,我们将继续探讨缓存设计的另一个重要方面:写操作的处理策略。

018:缓存设计 2

概述

在本节课中,我们将继续深入探讨缓存的设计,特别是缓存未命中状态保持寄存器(MSHR)的工作原理,以及不同的写策略(如写直达和写回)是如何影响缓存性能和一致性的。

缓存未命中状态保持寄存器(MSHR)

上一节我们介绍了缓存的基本接口和读操作流程。本节中,我们来看看如何通过一个名为未命中状态保持寄存器的组件来提升缓存性能。

MSHR并非标准必需组件,但能以较小的能耗和面积开销显著提升性能。让我们回顾缓存接口:当发生缓存命中时,数据直接返回给请求者(通常是核心)。当发生缓存未命中时,缓存需要向下一级存储器(可能是内存或另一级缓存)发起请求以获取数据,并在数据返回后,一方面将其存入本地存储,另一方面返回给请求者。

这里存在一个问题:如果请求A未命中,在等待A的数据返回期间,请求B到达了。如果B是命中,我们能否在处理A的同时处理B,以减少B的延迟?这需要缓存能够同时处理多个请求。

核心与缓存之间的接口由一组总线(导线)连接,主要包括地址总线、数据总线和控制信号(如availableready)。在典型的缓存未命中场景中,处理无法在一个周期内完成,需要多个周期。在此期间,缓存需要持续使用请求A留在接口总线上的信息(如地址)来生成最终的响应。这意味着,在A处理完成前,缓存无法告知核心“准备好”接收新请求,因此请求B必须等待,这阻碍了并发处理。

为了解决这个问题,我们可以在缓存侧引入一组寄存器,临时保存来自核心的请求信息(如地址、请求ID等)。这样,在向下一级存储器发送未命中请求A后,缓存可以立即释放接口,告知核心可以发送下一个请求B。当B到达时,如果是命中,就可以立即处理并返回,无需等待A。这个用于临时保存未命中请求状态的寄存器组就是MSHR

当数据从下一级存储器返回时,缓存控制逻辑会查询MSHR,找到对应的原始请求信息,然后将数据返回给正确的请求者,并写入本地存储。

以下是MSHR处理的几种典型情况:

  • 情况一:未命中后的命中
    请求A未命中,信息存入MSHR,并向下一级发起请求。此时请求B到达并命中,可以立即处理返回,实现了请求的重叠处理。

  • 情况二:对同一地址的双重未命中
    如果两个请求访问同一缓存行(地址相同),第二个请求在MSHR中会“命中”(即发现已有对该地址的未处理请求)。此时,缓存无需为第二个请求再次向下一级存储器发送请求,只需在MSHR中记录这个新的请求ID。当数据返回时,缓存会同时响应两个请求。这被称为MSHR命中自然合并,它减少了访存次数和能耗。

  • 情况三:对不同地址的双重未命中
    两个请求访问不同地址且均未命中。缓存可以为两者分别分配MSHR条目,并同时向下一级存储器发送两个请求,充分利用存储器带宽。如果MSHR条目已满,第三个未命中请求T3就必须等待,直到有MSHR条目被释放。这种等待会通过接口反向传递压力,导致核心端的流水线停滞,这被称为反压

MSHR是一种稀缺资源,因为其实现代价(寄存器和比较器)较高,所以数量有限(通常为4、16或32个)。虽然MSHR看起来是可选的,但在现代CPU和GPU的缓存设计中几乎都被采用。

写策略

之前我们主要讨论了读操作。现在,让我们来探讨更复杂的写操作策略。写策略决定了数据何时被写入缓存以及何时更新下一级存储器,这对性能和缓存一致性至关重要。

首先考虑一个架构层面的复杂性问题:当未命中数据从下一级返回并需要写入缓存块时,标签应在何时被更新?如果过早更新标签,在新数据实际到达前,另一个读请求可能误判为命中。此外,如果数据在返回途中,该缓存块又被选为牺牲块,该如何处理?这些底层细节在设计真实的缓存控制器时需要仔细考虑。

现在来看核心的写操作问题。假设缓存行大小为64字节,而写操作只写入4字节。

  • 如果写命中,我们更新本地缓存中的数据。但这会立即导致缓存中的数据(新版本)与下一级存储器中的数据(旧版本)不一致,我们称这个新数据为数据。在多核系统中,如果不加控制,其他缓存可能读到过时的数据,引发一致性问题。因此,脏数据最终必须写回下一级存储器。问题在于何时写回。

  • 如果写未命中,情况更复杂。我们只有4字节新数据,但缓存行需要完整的64字节才有效。我们该如何处理?

针对这些问题,业界有几种常用的写策略。

写直达

第一种策略是写直达。顾名思义,只要发生写操作(无论命中或未命中),数据都同时写入本地缓存和下一级存储器。

对于写命中,过程直接:更新本地缓存行,同时将数据写入下一级。

对于写未命中,缓存可以选择是否在本地分配一个缓存行来存放新数据。如果选择分配(称为写分配),由于我们只有4字节数据,缓存必须首先向下一级发起一个读请求,获取该地址完整的64字节数据。待数据返回后,将新的4字节与读回的60字节合并,再写入本地分配的缓存行中。同时,这4字节新数据也会被写入下一级。如果写操作恰好是完整的64字节,则可以省去读过程,直接分配并写入。

写直达缓存的牺牲块替换非常简单:如果要将一个有效块替换出去,只需将其标签标记为无效即可,无需将其数据写回下一级,因为根据写直达策略,它的数据已经是最新的。

写直达的优点在于易于维护一致性,因为下一级通常持有最新数据(尽管在写请求传输期间仍有短暂不一致窗口)。其缺点是每次写操作都会访问下一级存储器,如果下一级延迟很高,会显著增加写操作的延迟,甚至可能使缓存失去加速作用。

写绕回

第二种策略常被称为写绕回写非分配。这种策略的核心思想是:如果写操作未命中,则不在本地缓存中分配新行,而是直接将数据写入下一级存储器。

对于写命中,处理方式与写直达相同:同时更新本地和下一级。

对于写未命中,缓存不进行写分配,数据直接“绕过”本级缓存,写入下一级存储器。

写绕回的优点是避免了写未命中时昂贵的缓存分配和读-修改-写操作序列,减少了写延迟。缺点是数据不会载入本级缓存,如果后续很快又读取该数据,则会遭受未命中惩罚。

总结

本节课我们一起深入学习了缓存设计的两个高级主题。首先,我们探讨了MSHR如何通过保存未命中请求状态来支持请求的并发处理,从而提升缓存性能。我们分析了MSHR命中、自然合并和反压等概念。接着,我们转向写策略,分析了写直达写绕回两种主要策略的工作原理、优缺点及其在命中与未命中场景下的具体行为。理解这些机制对于设计高效、正确的存储层次结构至关重要。

019:GPU 架构 3

在本节课中,我们将继续探讨GPU架构,重点介绍GPU的内存系统。这是关于GPU架构的第三讲,也是最后一讲。我们将从回顾计算单元架构开始,逐步深入到内存层次结构、访存合并、缓存一致性等核心概念,帮助你全面理解GPU如何高效地处理数据。

计算单元架构回顾

上一节我们介绍了GPU的计算单元架构,本节中我们来看看其核心组件。一个计算单元(Compute Unit,CU)在NVIDIA的术语中也称为流式多处理器(Streaming Multiprocessor,SM)。其前端负责指令的获取与调度。

以下是计算单元前端的主要组成部分:

  • 波前池(Wavefront Pools):每个计算单元通常有4个波前池。
  • 波前槽(Wavefront Slots):每个波前池包含多个波前槽(例如10个),用于存放等待执行的波前(即线程束)。
  • 取指仲裁器(Fetch Arbiter):决定哪个波前可以获取指令。
  • 发射仲裁器(Issue Arbiter):决定哪个波前的指令可以进入后端执行流水线。

前端与后端通过发射阶段解耦。后端包含多个执行单元,用于实际执行指令。

以下是主要的执行单元类型:

  • 标量单元(Scalar Unit):处理整数运算和内核参数等标量数据。
  • 分支单元(Branch Unit):处理程序控制流跳转。
  • 向量单元(Vector Unit):执行SIMD向量运算,是计算能力的核心。
  • 向量内存单元(Vector Memory Unit):处理向量加载/存储请求,包含关键的访存合并逻辑。
  • 本地数据共享(LDS, Local Data Share):一种高速的片上共享内存。

在AMD架构中,向量单元通常被组织成多个SIMD单元(如SIMD1-4),每个SIMD单元包含多条车道(Lane),用于并行执行操作。车道数量根据运算精度而变化。

以下是不同精度下的典型车道数量配置:

  • 单精度浮点(FP32):16条车道(标准配置)。
  • 双精度浮点(FP64):0到16条车道(高性能计算GPU配置更多)。
  • 半精度浮点(FP16):可能达到32条车道。
  • 更低精度(如FP8/FP4):车道数量可能进一步翻倍,因为硬件单元面积更小。

流水线冒险与零开销上下文切换

在深入内存系统之前,我们需要理解GPU如何管理指令间的依赖关系。在CPU中,我们关注读后写(RAW)、写后读(WAR)、写后写(WAW)等数据冒险。GPU如何处理这些问题呢?

在经典的AMD GPU架构中,发射仲裁器有一个关键规则:如果一个波前已经有指令正在流水线中执行,就不会从该波前发射下一条指令。这意味着,从单个波前的视角看,其指令是顺序执行的,不存在上述数据冒险。这简化了硬件设计。

这种设计也带来了一个重要的特性:零开销上下文切换。在CPU中,切换线程需要保存和恢复上下文,开销较大。而在GPU中,流水线可以在每个周期切换执行不同波前的指令。因此,GPU的上下文切换是持续且无额外开销的。

关于指令共享,虽然所有波前执行的是同一个内核程序,但它们可能处于不同的执行进度。指令缓存(L1 I-Cache)被所有波前共享,具有极高的命中率,能快速提供指令流,因此指令获取通常不是性能瓶颈。

延迟隐藏

内存访问延迟很高,GPU需要有效隐藏这些延迟。CPU主要依靠指令级并行(ILP),通过乱序执行、寄存器重命名等技术从同一线程中寻找可并行执行的指令来填充流水线。

GPU则采用不同的策略,主要依靠线程级并行(TLP)。当一个波前因等待内存访问而停滞时,调度器可以立即从其他就绪的波前中选择指令来执行,从而保持计算单元忙碌,隐藏内存访问延迟。这种机制使得GPU能够容忍较高的内存延迟。

向量内存单元与访存合并

现在,我们进入本节课的核心——内存系统。首先从向量内存单元开始,其最关键的功能是访存合并(Coalescing)

当一组线程(例如一个波前中的64个线程)执行加载或存储操作时,它们可能访问多个内存地址。访存合并器的作用是将这些分散的访问请求合并成尽可能少的内存事务,以提升带宽利用率。

以下是几种典型的访存模式:

  • 完美合并:所有线程访问完全相同的地址。只需生成1个内存事务。
    • 示例:所有线程加载 0x40404040
  • 标准合并:线程访问连续地址,具有固定的步长(Stride)。可以合并成少量事务。
    • 示例:线程0加载0x40,线程1加载0x44,步长为4字节。64个线程访问256字节,在64字节缓存行下,需要4个内存事务。
  • 最差情况(无合并):线程访问的地址分散在不同的缓存行,甚至跨越大内存间隔。需要生成与线程数相当(或更多)的事务。
    • 示例:矩阵转置中,线程访问相隔4KB的地址,无法合并,需要64个事务。
    • 更坏情况:单个线程的访问跨越两个缓存行,可能产生超过线程数的事务。

早期的GPU合并器能力有限,可能只支持规则访问或少量线程的合并。现代GPU的合并器功能强大,能够处理复杂的访问模式,但合并操作本身可能需要多个周期来完成。

值得注意的是,早期的GPU(如2015年的AMD R9 Nano)L1缓存延迟异常高(约140周期),部分原因是其图形管线中集成了复杂的纹理寻址逻辑(如多级纹理金字塔Mipmapping)。随着通用计算(GPGPU)变得重要,这些图形专用功能被分离,使得现代GPU的L1缓存延迟显著降低(约20-60周期,取决于频率)。

GPU内存层次结构

接下来,我们俯瞰整个GPU的内存层次结构。计算单元之上是各级缓存。

在计算单元级别,通常以着色器阵列(Shader Array) 为单位组织资源。一个着色器阵列包含多个计算单元(例如4个),并共享以下L1缓存:

  • L1向量缓存(L1 V-Cache):缓存向量数据。
  • L1标量缓存(L1 S-Cache):缓存标量数据(如内核参数),通常是只读或极少写入。
  • L1指令缓存(L1 I-Cache):缓存指令。

共享这些缓存可以提高资源利用率和命中率。

所有L1缓存通过一个交叉开关(Crossbar)网络连接到多个L2缓存。每个L2缓存通常连接一个内存控制器(Memory Controller),负责访问一部分DRAM。

为了最大化内存带宽,GPU采用交错(Interleaved)寻址方式将地址空间分布到不同的L2缓存/内存控制器上。这样,连续的大块内存访问可以均匀分布到多个通道,实现高并行带宽。

这里有一个关键区别:

  • 核心端缓存(Core-side Cache):如L1缓存,必须能够覆盖整个地址空间,因为任何计算单元都可能请求任何地址。
  • 内存端缓存(Memory-side Cache):如L2缓存,通常只负责一个固定的地址区间。

随着GPU规模扩大(计算单元数量可达数百个),一个巨大的全局交叉开关在面积和功耗上变得难以扩展。解决方案是进行分区(Partitioning)。例如,将计算单元和L2缓存分成若干组,每组内部使用一个较小的交叉开关,组之间通过额外互联。这虽然可能增加跨组访问的延迟,但解决了可扩展性问题。NVIDIA GPU通常采用此类层次化互联设计。

缓存一致性与假共享

最后,我们讨论GPU中的缓存一致性问题。在CPU中,多核间保持缓存一致性是硬件的责任(如MESI协议)。GPU则采取了不同的哲学。

GPU通常不在不同计算单元的L1缓存之间维护硬件一致性。 如果一个计算单元修改了某个数据,另一个计算单元的L1缓存中可能仍是旧值。GPU将此责任交给了程序员。

程序员应遵循以下准则:

  1. 避免在同一内核中,让一个线程写入数据后,另一个线程读取该数据。
  2. 如果线程间需要通信,应使用原子操作(Atomic Operations) 或通过全局内存进行。
  3. 更常见的模式是:在一个内核中完成计算并写入全局内存,然后启动下一个内核来读取这些结果。在内核边界,GPU硬件会失效(Invalidate)所有L1缓存,确保下一个内核从L2缓存或全局内存中加载最新数据。

然而,有一个硬件必须处理的问题:假共享(False Sharing)。当两个线程修改同一缓存行中的不同部分时,虽然从程序逻辑上看没有共享数据,但硬件缓存行是共享的最小单位。

例如,线程A写入缓存行X的前半部分,线程B写入同一缓存行X的后半部分。如果没有保护机制,直接写透(Write-through)可能导致数据混乱。

GPU的解决方案是使用写掩码(Write Mask)。当执行非对齐或部分写操作时,内存请求会附带一个掩码,指示哪些字节是有效的。在L2缓存或更高级别,硬件可以利用这些掩码正确合并来自不同线程的对同一缓存行的部分写入,从而避免数据损坏。

总结

本节课中,我们一起学习了GPU架构的内存系统核心知识。我们回顾了计算单元架构和其零开销上下文切换、延迟隐藏的特性。重点探讨了向量内存单元中的访存合并机制,它对于提升内存带宽效率至关重要。我们剖析了GPU的多级缓存层次结构,包括核心端与内存端缓存的区别,以及通过交叉开关和交错寻址实现高带宽的设计。最后,我们了解了GPU独特的缓存一致性模型——将一致性责任赋予程序员,并讨论了硬件如何处理假共享问题。理解这些原理,有助于编写出更高效、能充分发挥GPU并行计算潜力的程序。

020:缓存一致性协议

在本节课中,我们将要学习缓存一致性协议。上一节我们介绍了缓存的组织结构和读写策略,本节中我们来看看在多核系统中,当多个缓存副本同时存在时,如何保证数据的一致性。我们将从最简单的协议开始,逐步理解其工作原理和局限性。

缓存层次结构与一致性问题

首先,我们需要理解缓存一致性问题出现的场景。在单核系统中,只有一个缓存,不存在一致性问题。但在多核系统中,每个核心通常拥有私有的L1缓存,并且可能共享L2缓存。

当多个核心的私有缓存(如L1)持有同一内存地址的数据副本时,如果其中一个核心修改了数据,其他核心的缓存副本就会变得过时。确保所有核心都能读取到最新数据,这就是缓存一致性协议需要解决的问题。

缓存一致性的定义

缓存一致性主要关注对单个内存位置(通常是缓存行)的访问。其核心原则是“单写者多读者”模型。

  • 在任何一个逻辑时刻,对于给定的内存位置,只允许两种情况之一发生:
    • 一个核心在写入数据。
    • 零个或多个核心在读取数据。
  • 不允许的情况是:一个核心在写入的同时,其他核心在读取或写入。

这个模型确保了读写操作不会在逻辑上重叠,从而避免了读取到陈旧数据的问题。需要注意的是,这里讨论的一致性内存一致性不同。内存一致性定义了不同内存地址的读写操作在所有核心看来应遵循的顺序,而缓存一致性只关心单个地址的数据副本是否正确。

解决一致性问题的思路

为了解决缓存一致性问题,我们引入了缓存一致性协议。这增加了系统的复杂性,但在多核成为主流的今天,这种复杂性是必要的。

协议通常基于两种主要架构实现:

  1. 侦听协议:所有缓存都连接到一个共享的“总线”上。任何缓存向内存发送的读写请求都会被总线上的其他缓存“侦听”到。每个缓存根据侦听到的消息更新自己缓存行的状态。
  2. 目录协议:在内存控制器侧维护一个“目录”,记录每个缓存行被哪些核心的缓存所持有。当发生写操作时,内存控制器只向持有该缓存行的核心发送失效消息,而不是广播给所有核心。

侦听协议实现简单,延迟低,适合小规模系统,但总线带宽有限,可扩展性差。目录协议减少了不必要的通信流量,扩展性更好,但需要额外的存储开销来维护目录信息。

VI协议:最简单的侦听协议

我们从最简单的VI协议开始,它只有两个状态:有效无效

  • 状态 V:缓存行中的数据是有效的(可能是最新的,也可能是共享的)。
  • 状态 I:缓存行中的数据无效(等同于该数据不在缓存中)。

以下是VI协议的核心操作:

  • 读未命中:核心请求的数据不在其缓存中(状态I)。缓存会发起一个总线读事务,从内存获取数据,并将该行状态置为V。
  • 写操作:当核心要写入一个状态为V的缓存行时,它必须发起一个总线写事务。这个事务有两个作用:
    1. 将数据写回内存(假设是写直达缓存)。
    2. 总线上的其他缓存侦听到这个“总线写”消息后,会将本地对应的缓存行状态置为I,从而使其副本失效。

VI协议的状态转换可以总结如下:

当前状态 | 事件(本地核心) | 总线动作       | 下一状态
---------|------------------|----------------|----------
 I       | 读               | 发起总线读     | V
 V       | 读               | 无             | V
 V       | 写               | 发起总线写     | V (或 I)
 I       | 写               | 发起总线写     | V (或 I)

当前状态 | 事件(侦听到总线) | 动作           | 下一状态
---------|---------------------|----------------|----------
 V       | 总线写             | 使本地副本失效 | I
 I       | 总线读/写          | 无             | I

VI协议的局限性
它强制所有写操作都更新内存(写直达),无法支持性能更好的写回缓存。每次写操作都会产生总线流量并无效化所有其他副本,即使该数据是某个核心独占写入的,也会产生不必要的通信开销。

MSI协议:支持写回缓存

为了支持写回缓存并减少写操作的总线流量,我们引入更常用的MSI协议。它包含三个状态:

  • 修改:缓存行中的数据已被修改(脏数据),与内存中的内容不同。该缓存是此数据的唯一有效副本。
  • 共享:缓存行中的数据是干净的(与内存一致),并且可能有其他缓存也持有该数据的副本。
  • 无效:缓存行中的数据无效。

MSI协议通过引入“修改”状态,允许脏数据暂时停留在缓存中,无需立即写回内存。同时,它引入了一种新的总线事务来处理写操作。

以下是MSI协议的关键操作逻辑:

  • 从“无效”状态读:发起总线读,从内存或其他缓存获取数据,状态转为共享
  • 在“共享”状态下写:核心想要修改一个处于共享状态的行。它不能直接写入,因为其他缓存可能有副本。此时,它需要发起一个总线读独占事务。这个事务通知其他缓存:“我要独占这个数据,请你们的副本失效”。其他缓存侦听到后,会将对应行状态置为无效。发起写的缓存则在获得数据后,状态转为修改,然后可以执行本地写入。
  • 在“修改”状态下写:核心可以直接写入本地缓存,无需任何总线通信,因为它是数据的唯一所有者。状态保持为修改
  • 在“修改”状态下读未命中(其他核心):当另一个核心试图读取一个正处于其他缓存“修改”状态的数据时,它会发起总线读。持有“修改”状态数据的缓存侦听到后,必须拦截这个读请求。它会将脏数据写回内存(或直接传给请求者),然后将自己缓存行的状态降级为共享(因为现在有了另一个读者)。

MSI协议的优势
它支持写回缓存,对于被反复修改的独占数据,可以避免频繁的总线通信,显著提升性能。

MSI协议的局限性
即使一个数据实际上是某个核心独占使用的(其他缓存根本没有副本),当该核心首次写入一个处于“共享”状态的行时,它仍然必须发出“总线读独占”事务来确认独占权,这会产生不必要的无效化流量。在实际系统中,很多单线程数据访问模式会受此影响。

总结

本节课中我们一起学习了缓存一致性的基本概念和两种经典的缓存一致性协议。

  • 我们首先明确了缓存一致性要解决的是多核私有缓存中数据副本的同步问题,其核心是保证“单写者多读者”的访问模型。
  • 接着,我们介绍了通过一致性协议来解决此问题,并区分了侦听目录两种实现方式。
  • 然后,我们深入分析了最简单的VI协议,它只有有效和无效两个状态,但强制写直达,限制了性能。
  • 最后,我们学习了更实用的MSI协议,它通过引入修改、共享、无效三个状态,有效支持了写回缓存,减少了不必要的内存写入和总线流量,但也存在对独占写入仍需通信的开销。

理解这些基础协议是掌握现代多核处理器中复杂一致性机制的关键。在后续课程中,我们可能会看到对这些协议的优化扩展(如MESI、MOESI协议),它们旨在进一步减少协议开销,提升系统性能。

021:虚拟寻址 🧠

在本节课中,我们将系统性地学习虚拟寻址这一核心概念。虚拟寻址是现代计算机系统中广泛采用的一项关键技术,它通过引入一层间接性,解决了内存管理中的多个核心问题。我们将从历史背景、基本概念、工作原理到具体实现细节,逐步展开讲解。


虚拟寻址概述

虚拟寻址试图通过增加一层额外的间接性来解决许多不同的问题。计算机科学中有一句名言:任何问题都可以通过增加一层间接性来解决,除非间接层太多。虚拟寻址就是被广泛接受并几乎在所有现代计算机系统(尤其是CPU)中使用的一层间接性。如今,GPU也使用虚拟内存,但一些特定领域的加速器可能并不真正使用虚拟寻址。

虚拟内存最早应用于名为“Atlas”的系统,该系统也被认为是第一台超级计算机,于1962年开发。当时,内存非常昂贵,容量远小于我们今天手机甚至手表上的内存。程序却变得越来越大。程序员通常需要手动管理内存,以避免使用过多内存,例如将部分内存存储到硬盘(当时可能是磁鼓等慢速设备)再读回。虚拟内存的引入,旨在让程序员无需手动控制,就能将部分内存“交换”到磁盘。

虚拟地址与物理地址

当我们谈论虚拟寻址时,总有一个对应的概念叫做物理寻址。

  • 物理地址 是硬件内存系统中实际使用的地址。
  • 虚拟地址 是程序可见(且始终可见)的地址。用户空间程序永远无法知道物理地址的任何信息,它只知道一个相对位置。

虚拟寻址系统的目标就是将物理地址和虚拟地址解耦。

虚拟寻址解决的问题

虚拟寻址这一层间接性解决了多个问题:

1. 内存隔离问题
在早期系统(如Windows 98)中,一个程序可以修改另一个程序的内存,这带来了安全风险。通过虚拟内存,进程1和进程2的虚拟地址即使相同,也指向完全不同的物理内存空间,它们无法通过直接读写内存来通信。例如,现代Chrome浏览器的每个标签页都在独立的进程中运行,以避免标签页之间相互干扰。

2. 为所有程序提供统一的地址管理
我们希望程序的内存布局(如栈从某地址开始,堆从某地址开始)是统一的,而无需担心系统中同时运行的其他程序。

3. 内存访问授权
通过地址转换过程,系统可以授权对每一块内存的访问。例如,页表是内存的专用部分,如果用户程序试图写入只读的程序代码段,这通常意味着程序可能被入侵。虚拟寻址允许我们在访问物理内存时进行授权检查,防止许多恶意行为的发生。

此外,它同样解决了内存交换(Swapping)问题。总之,这是一个简单的特性,解决了众多问题。

工作原理概述

从逻辑上理解,其工作原理如下:核心(CPU)生成内存地址,核心中的寄存器存储的始终是虚拟地址。当它向内存系统发出读写请求时,总是生成一个虚拟地址。这个地址需要经过一个称为“地址转换器”的逻辑单元(并非真实硬件单元),该单元将基于虚拟地址的事务转换为基于物理地址的事务,然后才能访问使用物理地址的内存系统。

地址转换器部分完全由硬件控制,没有软件可以直接控制内存,这保证了该部分的安全性,因为我们可以信任硬件,但不能信任系统中运行的其他软件。

页的概念

当我们谈论地址时,必须引入“页”的概念。页是虚拟地址空间和物理地址空间中连续的区块。每次进行地址转换时,我们只翻译页的起始地址(从虚拟地址到物理地址),然后该页内所有其余地址的转换会自动完成。

今天大多数系统使用4KB大小的页,这个标准已经使用了超过20年。后来我们会讨论页大小为何重要,特别是在GPU领域,有推动使用更大页面的趋势。

最初选择4KB页大小的原因是,当时系统定义磁盘的写入单元也是4KB。如果你想向硬盘写入数据,总是直接写入4KB。虚拟寻址最初就是为将内存交换到硬盘而设计的,因此选择了与磁盘块大小匹配的4KB,以便每次交换都能移入/移出完整的磁盘单元。

内存分配机制

接下来我们看看页是如何在系统中被分配和确定的。当我们调用mallocnew关键字时,系统中实际发生了什么?

我们编写的用户程序调用带参数的malloc。这进入了运行时空间(仍是用户空间)。标准库会维护一个内存池(在虚拟地址空间中)。无论你分配4KB还是4字节、8字节,标准库都会首先尝试从本地池中分配地址并返回给用户程序。此时,我们不需要新的页,因为这些页已经分配并存在于标准库的池中。

如果标准库的内存耗尽,或者你要分配跨越多个页的巨大数据块,标准库会使用一个系统调用。在Linux领域,这个系统调用是mmap。系统调用是调用操作系统服务的特殊调用。当你调用它时,操作系统开始处理虚拟内存和物理内存,并最终可以返回一个虚拟地址。

这里我们开始看到用户空间和特权空间(操作系统空间)的边界。当处理操作系统空间时,操作系统可以访问页表,它知道物理地址,因此知道如何处理。

操作系统的高层操作

操作系统在高层上维护两个地址空间:虚拟空间和物理空间。虚拟地址空间通常分为栈(向下增长)和堆(向上增长)等部分。

如果我们要在堆中分配一些空间,就在虚拟空间中标记该页为已分配。同时,在物理空间中,操作系统有一个记录哪些物理页已被使用的数据结构。物理地址空间由当前运行的所有程序共享。

然后,操作系统找到一个可用的物理内存页,并将虚拟页和物理页链接起来。链接的方式就是使用页表。我们将这些信息记录到页表中,包括进程ID(PID)和标志位(如该页是否可读、可写、是否有效等)。

页表的逻辑与实现

这只是一个页表的逻辑表示。实际上,我们不需要存储完整的物理地址、PID和虚拟地址。我们稍后会讨论为何不需要存储这些字段。

页表本身并不特殊,它只是存储在DRAM中的一些数据,只有你的操作系统可以访问这部分内存。用户程序无法读写这部分内存,否则会发生错误。

实际上,我们不需要存储完整的虚拟地址。就像在缓存中,一个缓存行是64字节,最后的6位是该行内的偏移量,我们不需要存储这部分数据以节省比特位。对于虚拟地址也是如此。虽然地址可能是64位长,但大多数时候48位就足够了。具体位数取决于硬件和操作系统。

对于4KB的页,最后12位是页内偏移量,我们不需要关心这些位。因此我们有两个特殊名称:VPN(虚拟页号)PFN(物理页帧号)。我们只存储这些编号,它们是地址转换唯一有用的部分。

地址转换过程

当执行程序时,地址转换是这样发生的:我们的程序在核心中运行,线程在核心中运行,寄存器只存储虚拟地址。因此,每次内存访问都需要进行转换。我们将虚拟地址发送给地址转换器(实际上是MMU,内存管理单元),MMU执行转换。

如果访问不被允许(例如,试图访问一个不存在的虚拟地址),MMU会触发一个“页错误”来通知操作系统。如果你编写过C程序,很可能见过“段错误”,这就是页错误最常见的情况之一,通常是因为试图访问空指针(地址0),而该地址是保留区域,任何程序都不应访问。

在大多数情况下,我们假设运行正确。MMU将物理地址返回,转换器将原始事务转换为物理地址并访问内存。

线程、进程与上下文

我们需要厘清线程和进程的概念。

  • 线程 是在你的核心中执行的一系列指令序列(如果不考虑超线程,一个核心运行一个线程)。它也是一组存储数据的实际寄存器。不同线程中的同名寄存器存储不同的数据。
  • 进程 是一个虚拟内存空间。一个程序可以有多个线程。只要线程属于同一个进程,它们就拥有相同的进程ID(PID),相同的虚拟地址指向相同的物理地址。这就是为什么在多线程程序中,你可以轻松地从一个线程写入,从另一个线程读取。
  • 两个不同进程中的相同虚拟地址指向不同的物理位置。因此,两个进程之间共享数据并不容易,除非使用一些高级技术(如进程间通信IPC,最常见的是使用套接字)。

上下文 在这里指的是上下文切换。在典型的CPU中,上下文切换意味着从一个线程切换到另一个线程。上下文指的是存储在CPU核心内的寄存器状态。如果我们换出一个线程并换入另一个线程,我们就在切换核心内执行的上下文。

上下文切换的性能影响在进程内线程切换和跨进程线程切换之间可能非常不同。

MMU的工作原理

MMU是进行地址转换的部分。给定一个虚拟地址和PID,它会返回一个物理地址。

假设我们的页表以这种逻辑方式实现:给定PID和VPN,我们需要运行一个循环,访问每个页表项,检查PID和VPN。如果找到,就返回;否则就是页错误。

这种方式的问题在于,地址转换发生在每次内存访问时。访问页表项本身就是一次内存访问。考虑一下页表可能有多大。如果我们只想访问一次内存,但这种O(N)复杂度的方式可能最终需要访问内存上百万次(对于一个4GB内存、4KB页的系统,有大约100万个页表项)才能找到地址。这完全不切实际,开销太大,100%的开销都花在了转换上,而没有进行真正的内存访问。

优化:多级页表

真实的系统永远不会这样设计。解决方案是使用多级页表。

首先,每个PID都有自己的页表。我们首先有一个“页表目录”(或类似结构),PID作为索引。页表本身只是存储在DRAM中的一段内存,我们可以将页表基地址存储在这个目录中。给定PID后,我们首先找到页表基地址,然后访问页表。

将O(N)操作降至O(1)操作的通用方法是使用哈希表。但由于粒度很细,我们可以简单地使用直接访问表:使用VPN 0, VPN 1等直接分配槽位。这样,如果我们知道VPN编号,可以立即定位到一个位置。通过一到两次内存访问(一次访问目录,一次访问页表),我们基本上就能找到地址,这是显著的性能提升。

代价是,每当我们启动一个进程时,都需要为它创建这个表。对于一个4GB内存、32位地址空间的系统,最大可能有约100万个页表项。假设每个条目4字节,那就是4MB的开销。对于4GB内存系统,这是0.1%的开销,可以接受。但要注意,这是每个PID的开销。如果你创建另一个进程,就需要另一个表。

当转向64位地址空间时,问题变得严重得多。虚拟地址空间变得巨大(虽然物理大小没变那么多),页表项数量激增,导致每个PID的页表大小变得不切实际。因此我们不能使用这种简单的单级方案。

解决方案是设计多级页表。我们已经有了一层间接。更进一步的间接是:我们有一个基地址,指向另一个表,这个表由VPN中的高几位索引。如果我们用这几比特直接访问这个元素,然后访问页表,在页表中使用低几位直接访问元素。

优势在于,如果我们使用整个地址空间,它仍然需要同样大的内存。但如果我们只使用一小部分内存(例如几KB),可能只需要一个子页表,甚至不需要创建其他子页表,因为我们的内存访问彼此接近,高位地址相同。这样,我们只创建页表的一个子集,而不是整个页表。

现实中,我们仍不满足于两级设计,认为32MB存储仍然太多。因此我们转向更多层的间接。在现代操作系统中,通常使用四层页表(支持48位地址)。在一些更强大的系统中,甚至增加了第五层页表。现代Intel/AMD CPU的标准是使用5层页表,支持57位地址系统。

层数越多,我们能支持的连续内存范围就越小(指单级表覆盖的范围)。这不仅关乎存储开销,也关乎缓存局部性。数据更容易从缓存中获取,而不是从DRAM中,从而减少转换延迟。

转换过程与开销

在五层页表中,我们首先识别PID,PID指向一个地址。在该地址内,我们取出9比特直接找到元素,这个元素是下一级表的基地址。然后使用下一个9比特找到下一级元素,如此反复,直到最后一级。最终,我们不需要在页表中存储PID和完整的虚拟地址,我们用它们来定位页表中的条目。

这样,每次转换最多需要五次内存访问(不考虑PID目录的那一次)。这虽然不理想,但比上百万次访问要好得多。考虑一下,对于一次内存访问,最坏情况下我们可能需要五次内存访问来进行转换。这仍然是很大的开销(80-90%)。如果全部由DRAM处理,延迟大约在250到300个周期(取决于DRAM技术,GPU使用GDDR或HBM,延迟可能更高)。

降低延迟:MMU缓存与TLB

我们需要降低这种延迟,方法是使用两种不同级别的缓存:MMU缓存和TLB。

MMU缓存 存储部分页表内容,实现方式类似于常规的组相联缓存,缓存行大小可以是64B、32B等。它被插入到内存层次结构中,甚至可以直接利用L1/L2/L3缓存,或者在它们之上添加一个小的MMU缓存。其目的是避免数据访问将页表数据逐出。

MMU缓存可以缓存页表的任何层级。我们可以使用LRU等替换策略。有时L1缓存和MMU缓存是并行的,核心一侧连接到MMU缓存(页缓存),另一侧连接到L1数据缓存,然后在L2缓存级别合并。

分析其延迟:最佳情况是所有访问都命中MMU缓存(或L0缓存),延迟可降至50周期以下。典型情况是部分命中不同级别缓存,延迟在60-70周期。如果全部访问DRAM,则可能需要数百周期。

虽然我们将开销从300周期降低到了50周期(减少了6倍),但考虑一下内存访问本身:如果是一次L1缓存命中,可能只需要不到10周期。最佳情况50周期仍然增加了大量开销(5倍),这并不理想。我们理想情况是接近1周期的翻译延迟。

转换后备缓冲器

实现接近单周期延迟的方法是使用转换后备缓冲器,更常被称为 TLB

TLB非常简单:它是一个小的页表,存储VPN、标志位和物理页帧号(PFN)。这次我们必须存储VPN。当核心提供一个VPN时,我们首先检查它是否等于TLB中已存储的某个VPN。如果命中,我们直接返回标志位和物理地址。

如果TLB设计为只有少数条目(如32、64条),一个小型缓存可以在一个周期内处理完毕,从而实现单周期翻译。

TLB,尤其是一级TLB,强烈倾向于使用全相联或高相联度设计,因为它希望尽可能提高命中率。由于其本身很小,即使增加大量比较器,也不会增加太多面积开销。TLB的覆盖范围可以很大,因此开销并不那么可怕。同时,TLB缺失的惩罚很高(需要访问内存,数百周期延迟),因此我们愿意付出一些能量和面积成本来尽可能提高命中率。

TLB也有多级设计。一级TLB的目标是尽可能降低命中延迟,瞄准单周期。二级(或三级)TLB的目标是尽可能大,以避免访问MMU。较低级别的共享TLB通常选择较低的相联度以换取更大容量。如果所有TLB都缺失,则转到MMU进行页表遍历。

考虑到这种分布式缓存,会存在一致性问题。但大多数时候,我们不需要复杂的协议,因为缓存条目被修改的情况并不频繁。更常见的设计是基于“冲刷”的简单方式,例如直接使整个TLB集合或所有条目无效。

TLB覆盖范围

TLB覆盖范围是一个重要概念,指TLB能够覆盖的内存大小。假设一个TLB有64个条目(对TLB来说算大的),每个页4KB,那么它能处理 64 * 4KB = 256KB 的内存。对于一个程序来说,有时只处理这个范围内的数据是足够的。

补充一点,对于GPU:GPU也进行地址转换,并遵循类似的设计。计算单元(核心)需要访问其L1 TLB,然后访问共享的、容量较大的L2 TLB。这里存在一个“GMMU”(GPU MMU)的概念。大多数时候,如果GPU只访问本GPU上的数据,GMMU可以处理所有地址转换。但有时,在支持GPU点对点直接访问的技术下,GPU可能需要访问另一个GPU上的数据。这时可能出现GMMU缺失,最终需要通过PCIe网络转到CPU侧的IOMMU(输入输出内存管理单元)来处理,这个过程非常缓慢。这还涉及到是否缓存远程数据等复杂问题,特别是在支持“统一内存”(允许页在设备间自动迁移)的技术中,会带来更多复杂性。

TLB与MMU缓存的区别

TLB和MMU缓存都需要,原因如下:

  • TLB 存储完整的地址转换(VPN -> PFN)。如果TLB命中,它可以在不访问MMU的情况下完成地址转换,显著降低翻译延迟至几个周期。
  • MMU缓存 存储页表多层结构中的数据。它仅在遍历页表、查找地址时有用。在TLB缺失的坏情况下,MMU缓存可以显著降低页表遍历的延迟。

缓存设计回顾:物理地址 vs 虚拟地址

现在回顾缓存设计(指数据缓存,而非TLB)。一个关键问题是:对于标签和索引部分,我们使用物理地址还是虚拟地址?

到目前为止,我们讨论的都是先进行地址转换,再访问缓存,即使用物理地址。但让我们思考一下GPU的设计。GPU的L1指令缓存有很高的命中率,因为GPU程序通常很小。它对带宽要求高,延迟容忍度低,期望每个周期都能向核心提供数据。如果我们先进行地址翻译,即使TLB命中也可能增加延迟,如果MMU繁忙则延迟更高。我们不想为每次访问都支付翻译开销。

那么,能否直接使用虚拟地址呢?从逻辑层次上看,地址翻译只需要在访问DRAM之前的某个地方发生即可。如果我们把地址转换器移到缓存层次的下方,就可以允许L1缓存使用虚拟地址。

使用虚拟地址的优势是:如果缓存命中,延迟更低;如果缓存未命中,再进行地址转换,延迟与使用物理地址的方案类似。

使用虚拟地址缓存的缺点在于:

  1. 别名问题:两个不同进程可能有相同的虚拟地址。如果核心同时运行两个进程(或进行上下文切换),缓存无法知道这两个虚拟地址实际上指向不同的物理位置。
  2. 重名问题:两个线程(可能属于不同进程)的虚拟地址可能映射到同一个物理地址(用于进程间通信)。缓存无法知道这两个东西实际上是一回事,这会使得一致性维护非常麻烦。

虚拟地址缓存的解决方案

对于更常见的第一个问题(进程间隔离),有两种主要方法:

  1. 上下文切换时冲刷:在进行跨进程上下文切换时,冲刷所有TLB(和虚拟地址缓存)。实现简单,但代价是切换后初期缓存缺失率会升高。
  2. 将PID作为标签的一部分:以前我们只比较地址本身,现在需要同时比较PID和虚拟地址。两者都匹配才认为是缓存命中。这会带来一些存储开销(例如,每个缓存行需要额外的字节存储PID)。

缓存的分类

根据索引和标签使用的地址类型,缓存可以分为几类:

  • PIPT(物理索引,物理标签):我们一直假设的方式。
  • VIVT(虚拟索引,虚拟标签):我们刚刚介绍的虚拟地址缓存。
  • VIPT(虚拟索引,物理标签):有时被使用。
  • PIVT(物理索引,虚拟标签):几乎没有优势,几乎从不使用。

VIPT的优势 在于两方面:

  1. 延迟重叠:我们可以将地址转换和缓存访问的部分过程重叠执行。在第一个周期,我们将虚拟地址同时发送给TLB和缓存。缓存可以用虚拟地址的索引部分来选择组,同时TLB进行地址翻译。当获得物理地址后,回来与缓存中的物理标签进行比较。这样并行执行可以小幅减少整体延迟。
  2. 易于管理一致性:由于存储的是物理标签,更容易管理缓存一致性,避免之前提到的别名和重名问题。

趋势总结

总的来说,我们看到一个趋势,特别是在GPU中。由于延迟要求,GPU的L1缓存越来越多地转向使用虚拟地址(VIVT或VIPT)。在GPU中,上下文切换通常发生在进程内,因此别名问题不严重,也不需要复杂的高级重映射功能。在没有这些复杂情况的前提下,GPU的L1缓存非常适合这两种类型的缓存。


本节课总结

本节课中,我们一起深入学习了虚拟寻址系统。我们从内存层次结构出发,探讨了地址翻译系统,了解了地址翻译是如何完成的、为何被使用,以及所有这些技术如何协同工作以降低延迟,使我们能够高效地使用虚拟地址。

今天,大多数系统都在使用虚拟寻址,似乎开销问题已经基本得到解决。然而,在学术研究中,我们仍然能看到一些论文致力于解决地址翻译带来的新挑战,特别是在异构计算、大内存和低延迟场景下。理解这些基本原理,是进一步探索高级体系结构优化的基础。

posted @ 2026-03-29 09:47  布客飞龙III  阅读(7)  评论(0)    收藏  举报