ETHZ-数字设计与计算机体系结构笔记-全-

ETHZ 数字设计与计算机体系结构笔记(全)

1:引言、基础、晶体管、逻辑门 (Spring 2025)

概述

在本节课中,我们将开始学习数字设计和计算机架构。我们将从最基础的概念出发,了解现代计算机是如何从底层构建起来的。课程将从晶体管作为抽象开关开始,逐步构建逻辑门、组合与时序逻辑,最终理解微处理器和现代计算系统的工作原理。


课程介绍与目标

欢迎来到数字设计和计算机架构的第一堂课。这门课程将探讨现代计算机的工作原理及其从零开始的构建过程。

我们将从晶体管作为抽象开关开始,然后在其基础上构建逻辑门。接着,我们将构建组合逻辑和时序逻辑、存储器,进而构建微处理器。之后,我们将讨论如何构建GPU、脉动阵列和机器学习加速器等当今的热门计算单元。

课程将涉及效率、性能、能耗以及如何构建更具可扩展性的微架构和系统等诸多议题。最终目标是利用这些知识,让世界变得更美好、更高效、更有效。


讲师与助教介绍

我是Onur Mutlu,是这里的教授。在加入苏黎世联邦理工学院之前,我曾任职于卡内基梅隆大学、微软研究院,并在谷歌、英特尔、AMD等公司有过工作经历。我的研究方向包括计算机架构、系统、硬件安全和生物信息学。

我的联合讲师是Mojtaba Sadeghi,他是我们团队的高级研究员和讲师。此外,我们的首席教学助理是Konstantina Mitropoulou,首席实验助理是Alibek Sailanbayev。他们以及许多学生助教将为大家提供课程支持。


研究背景与课程视角

我们的研究致力于从根本层面构建更好的计算机,提升其性能、效率、鲁棒性、安全性和可靠性。随着计算机在生活各个领域(包括安全关键领域)的应用日益深入,这一点变得至关重要。

本课程之所以重要,是因为它建立了计算机工作原理的基础。如果不打好基础,就无法在软件栈的更高层面进行改进,也无法改变底层可能存在的糟糕的安全或可靠性属性。

当前的计算系统构建了一个转换层次结构:从待解决的问题,到算法,再到编程语言、系统软件、硬件/软件接口(ISA)、微架构、逻辑电路,最终到基于物理原理(如电子)工作的器件。传统的计算机架构聚焦于硬件与软件之间的接口。

然而,今天要取得巨大收益变得越来越困难。更有效的方法是采用跨栈协同设计的扩展视角,即同时定制算法和底层硬件,使它们在中间相遇,从而获得更好的效率和性能。这种跨层设计方法在当今系统中已非常普遍,尤其是在机器学习等领域。

教学与研究是相辅相成的。教学推动研究,因为今天学到的知识可能在未来催生新的设计;研究也驱动教学,因为新的发现需要通过教学传播给社区。因此,本课程将同时强调基础知识和前沿研究。


课程内容与结构

本课程是计算机科学的基础课程。我们将学习计算机从底层向上的工作原理。课程结束时,我们将研究构成片上系统(SoC)的各种组件,包括通用核心、专用核心、存储器和存储基础。

课程重点在于基础、设计原则和先例。目标是让大家不仅了解现代计算机的工作原理,更能培养批判性思维,学会科学、系统地评估不同设计、不同想法之间的权衡利弊。

实验部分将引导大家设计并实现一个简单的微处理器,并使用FPGA进行实现和调试。通过动手实验,大家将掌握系统化调试复杂设计的能力。

无论你未来的方向如何,学习数字设计和计算机架构的原理都将非常有用。它可以帮助你设计更好的硬件、软件和系统,理解计算机复杂行为背后的原因,并培养并行思维和批判性思维。


课程组件与学习建议

课程组件包括讲座、阅读材料、作业、实验和考试。我们还会提供额外的加分作业。

学习建议是:专注于学习和理解,而不仅仅是获得分数。讲座时请专注于内容本身。作业旨在强化问题解决能力。历史上,本课程的通过率在80%-85%左右。我们会提供大量材料帮助大家备考,包括习题课、考试指导和往届试题。

学习是终身的,而考试终会结束。请选择最适合自己的学习方式,并利用好我们提供的各种资源。


计算机为何存在?如何工作?

我们拥有计算机是为了解决问题、进行计算、获取洞察,从而改善生活和未来。

在当今的主流技术中,我们通过操控电子来解决问题。由于无法直接“命令”电子去解决问题,我们构建了之前提到的转换层次结构:将问题转化为算法,用语言编程,通过系统软件在目标上运行,程序与系统软件被翻译成硬件/软件接口(ISA),ISA通过微架构结构实现,微架构由逻辑电路构建,逻辑电路又由晶体管等器件实现,器件则基于物理原理(电子)工作。

这就是计算机架构的狭义视图,也是本课程前期的重点,但我们也会关注扩展视图。


计算机架构的定义与目标

计算机架构是设计计算平台的科学与艺术。传统上,其核心焦点是ISA和微架构。但如今,它已扩展到系统软件和编程模型。

其目标是实现一系列设计目标,这些目标因系统而异。例如,可以是针对特定AI工作负载的极致性能,也可以是移动设备上的最长电池寿命,或者是通用计算机上最佳的平均性能与成本比。设计不同目标的系统需要不同的优化策略,但许多基本原理是相通的。

当今的计算机架构领域正处于一个范式转变时期,系统变得非常异构,包含多种计算单元、加速器和存储器。这为创新留下了巨大空间。


基础构建模块:晶体管

所有现代计算机都由大量微小且相对简单的结构——晶体管——构建而成。1971年,第一个通用微处理器仅包含约2300个MOS晶体管。如今,苹果M2 Max等芯片已包含超过670亿个晶体管。

在本课程中,我们将晶体管抽象为一个开关来理解其逻辑功能。这是我们将要讨论的最低抽象层级。

MOS晶体管由导体、金属、绝缘体、氧化物和半导体组合而成。它有一个源极、一个漏极和一个栅极。根据施加在栅极上的电压,源极和漏极之间可能导通(像导线)或断开(像开路)。

主要有两种类型的MOS晶体管:N型(NMOS)和P型(PMOS)。NMOS在栅极高电压时导通,低电压时断开。PMOS则相反,在栅极低电压时导通,高电压时断开。在数字设计中,我们将其简化为逻辑1(高电压)和逻辑0(低电压)。


从晶体管到逻辑门

了解了MOS晶体管的工作原理后,我们开始构建更高级的逻辑结构——逻辑门。逻辑门实现了简单的布尔函数。

现代计算机同时使用N型和P型晶体管,这称为CMOS(互补金属氧化物半导体)技术。让我们看看最简单的CMOS结构:反相器(NOT门)。

一个CMOS反相器由一个PMOS晶体管和一个NMOS晶体管组成。PMOS的源极接高电压(如3V),NMOS的源极接低电压(如0V)。两个晶体管的栅极连接在一起作为输入(A),漏极连接在一起作为输出(Y)。

其工作原理如下:

  • 当输入 A=0(低电压)时,PMOS导通(视为导线),NMOS断开。输出 Y 被上拉至高电压(逻辑1)。
  • 当输入 A=1(高电压)时,PMOS断开,NMOS导通。输出 Y 被下拉至低电压(逻辑0)。

因此,输出 Y 是输入 A 的逻辑反相,即 Y = ¬A。其真值表清晰地展示了这种关系。


构建更复杂的门电路:与非门(NAND)

现在,让我们构建一个更复杂的门电路:与非门(NAND)。它有两个输入A和B。

一个CMOS与非门由两个并联的PMOS晶体管(上拉网络)和两个串联的NMOS晶体管(下拉网络)构成。两个输入同时连接到两个PMOS和两个NMOS的栅极。

其工作原理是:

  • 上拉网络(PMOS并联):只要有一个PMOS导通(即对应输入为0),就能将输出上拉至高电压(逻辑1)。
  • 下拉网络(NMOS串联):只有两个NMOS都导通(即两个输入都为1),才能将输出下拉至低电压(逻辑0)。

因此,仅当A和B都为1时,输出为0;其他情况下,输出均为1。这正是与非门的逻辑:Y = ¬(A ∧ B)。


构建与门(AND)

那么如何构建与门(AND)呢?与门的逻辑是Y = A ∧ B,即仅当A和B都为1时输出为1。

最简单的方法是在一个与非门后面级联一个反相器。这样,与非门的输出(¬(A ∧ B))经过反相,就得到了(A ∧ B)。

你可能会想,能否用更少的晶体管直接构建与门?答案是在标准CMOS技术中,很难高效地直接实现。因为PMOS晶体管擅长上拉电压,而NMOS晶体管擅长下拉电压。直接构建非反相门(如AND、OR)通常需要额外的反相级。


CMOS门电路的一般结构

CMOS门电路的一般结构用于构建任何反相逻辑门(如NOT、NAND、NOR)。

  • 它包含一个PMOS上拉网络,连接到高电压。
  • 它包含一个NMOS下拉网络,连接到低电压。
  • 两个网络共享同一个输出节点。
  • 输入同时提供给两个网络。

上拉或下拉网络中的晶体管可以串联或并联:

  • 晶体管并联:只要有一个晶体管导通,网络即导通。
  • 晶体管串联:所有晶体管都必须导通,网络才导通。

PMOS晶体管用于上拉,NMOS晶体管用于下拉。利用这种互补结构,我们可以构建出各种复杂的逻辑功能。


总结

本节课我们一起学习了数字设计和计算机架构课程的总体介绍、目标与视角。我们探讨了计算机为何存在以及如何通过转换层次结构工作。我们深入了解了计算机架构的广义与狭义定义,并看到了当今异构计算系统的多样性。

在技术层面,我们从最基础的构建模块——晶体管开始,将其抽象为一个受电压控制的开关。我们学习了NMOS和PMOS晶体管的不同特性。在此基础上,我们构建了第一个逻辑门:CMOS反相器(NOT门),并分析了其工作原理。接着,我们构建了更复杂的与非门(NAND),并理解了其晶体管级结构。最后,我们了解了如何通过级联与非门和反相器来构建与门(AND),并认识了CMOS门电路的一般结构。

下节课,我们将继续学习布尔代数,并利用它来表征和优化组合逻辑电路。

2:组合逻辑(2025年春季)🚀

概述

在本节课中,我们将学习组合逻辑电路的设计基础。我们将从回顾晶体管和逻辑门开始,深入探讨布尔代数,并学习如何利用它来设计和最小化电路。最后,我们将构建一些关键的组合逻辑模块,如解码器、多路复用器和加法器。


回顾:晶体管与逻辑门

上一节我们介绍了晶体管作为数字开关的基本概念。本节中,我们将快速回顾并构建更多逻辑门。

晶体管作为数字开关,其核心操作是导通或关断。我们主要关注两种类型:N型和P型。

  • N型晶体管:当栅极施加高电压(逻辑1)时导通,相当于闭合的导线;施加低电压(逻辑0)时关断,相当于断开的导线。
  • P型晶体管:其操作与N型互补。当栅极施加低电压(逻辑0)时导通;施加高电压(逻辑1)时关断。

基于此,我们构建了互补金属氧化物半导体(CMOS)结构。一个CMOS逻辑门由两个网络组成:

  • 上拉网络(P型):连接到高电压(VDD),负责将输出拉高至逻辑1。
  • 下拉网络(N型):连接到低电压(GND),负责将输出拉低至逻辑0。

在任何时刻,必须确保只有一个网络导通,另一个网络关断。如果两个网络同时导通,会造成短路;如果两个网络同时关断,输出将处于未定义的“浮空”状态。

以下是几种基本逻辑门的CMOS实现:

  • 反相器(NOT)Y = NOT A
  • 与非门(NAND)Y = NOT (A AND B)
  • 或非门(NOR)Y = NOT (A OR B)

一个重要的设计原则是:P型晶体管擅长传递逻辑1(高电平),但不擅长传递逻辑0(低电平);N型晶体管则相反,擅长传递逻辑0,但不擅长传递逻辑1。因此,我们不能随意混合使用它们来构建任意门。例如,一个仅用4个晶体管构建的“与门”可能无法正常工作,因为N型晶体管无法有效地将输出上拉到稳定的高电平。这就是为什么一个CMOS“与门”通常由一个“与非门”后接一个“反相器”构成(共需6个晶体管)。


功耗与摩尔定律

在深入设计更复杂的电路之前,了解功耗和制造工艺的宏观趋势至关重要。

数字电路的功耗主要分为两部分:

  • 动态功耗:当电路中的信号发生切换(0→1或1→0)时,对电容进行充放电所消耗的功率。其计算公式为:
    P_dynamic = α * C * V^2 * f
    其中,C是负载电容,V是电源电压,f是切换频率,α是活动因子。降低电压对减少动态功耗效果显著。
  • 静态功耗:即使电路不进行任何切换,由于晶体管漏电流而持续消耗的功率。其计算公式为:
    P_static = V * I_leakage

摩尔定律预测了集成电路上可容纳的晶体管数量大约每两年翻一番,同时成本下降。这主要通过缩小晶体管尺寸来实现。然而,随着晶体管尺寸接近物理极限,维持这一定律面临着材料、制造精度和散热等方面的巨大挑战。持续的技术创新是计算能力得以指数级增长的基础。


布尔代数与电路优化

现在我们已经能够构建基本的逻辑门,下一步是如何用它们来构建实现特定功能的电路。布尔代数为我们提供了描述、分析和优化这些电路的数学工具。

布尔代数是在集合 {0, 1} 上定义的一套代数系统,主要操作包括与(AND,记作 · 或省略)、或(OR,记作 +)、非(NOT,记作 ¬ 或上划线 ¯)。

以下是布尔代数的一些基本定律和定理,它们对于简化逻辑表达式至关重要:

公理与基本定律

  • 同一律:A + 0 = A, A · 1 = A
  • 零元律:A + 1 = 1, A · 0 = 0
  • 重叠律:A + A = A, A · A = A
  • 互补律:A + ¬A = 1, A · ¬A = 0
  • 还原律:¬(¬A) = A

运算定律

  • 交换律:A + B = B + A, A · B = B · A
  • 结合律:(A + B) + C = A + (B + C), (A · B) · C = A · (B · C)
  • 分配律:A · (B + C) = (A · B) + (A · C), A + (B · C) = (A + B) · (A + C)

常用简化定理

  • A · B + A · ¬B = A
  • A + A · B = A (吸收律)
  • A + ¬A · B = A + B

德摩根定律
这是进行逻辑转换的关键定理,它说明了“与”和“或”操作之间的对偶关系:

  • ¬(A · B) = ¬A + ¬B
  • ¬(A + B) = ¬A · ¬B

德摩根定律的一个直观应用是“气泡推演”:逻辑门上的一个“气泡”(表示取反)可以沿着信号线移动,并改变所经过的逻辑门类型(与门变或门,或门变与门)。这有助于将电路全部转换为仅使用一种类型门(如全部使用与非门或或非门)的实现。


组合逻辑电路规范

一个组合逻辑电路由输入、输出和功能规范定义。其特点是无记忆性:当前的输出值仅由当前的输入值组合决定,与过去的输入历史无关。

功能规范通常使用以下方式描述:

  1. 布尔表达式:例如,F = A · B + ¬A · C
  2. 真值表:列出所有可能的输入组合及其对应的输出值。
  3. 逻辑图:用逻辑门符号连接而成的电路图。

为了系统地设计和优化电路,我们引入两种规范形式:

积之和形式
积之和形式是表达布尔函数的一种标准(规范)方法。

  • 最小项:是一个包含所有输入变量(或其反变量)的“与”项。对于一个n输入函数,有2^n个可能的最小项。
  • SOP形式:函数被表示为所有使输出为1的最小项的“或”运算。
    例如,一个三输入函数F,当输入为011, 100, 101, 110, 111时输出为1,则其SOP形式为:
    F = ¬A·B·C + A·¬B·¬C + A·¬B·C + A·B·¬C + A·B·C
  • 可以使用简写记号:F = Σm(3, 4, 5, 6, 7),表示函数F由最小项3,4,5,6,7组成。

和之积形式
和之积形式是另一种规范形式,与SOP对偶。

  • 最大项:是一个包含所有输入变量(或其反变量)的“或”项。
  • POS形式:函数被表示为所有使输出为0的最大项的“与”运算。
    对于同一个函数F,其POS形式为:
    F = (A+B+C) · (A+B+¬C) · (A+¬B+C)
  • 简写记号:F = ΠM(0, 1, 2),表示函数F由最大项0,1,2组成。

从真值表出发,我们可以直接写出SOP或POS形式的规范表达式。然后,利用前面介绍的布尔代数定律对规范表达式进行化简,从而得到更简单、成本更低的电路实现。


基本组合逻辑模块

为了管理复杂数字系统的设计,我们将逻辑门组合成功能明确的、更大的构建模块。以下是一些关键模块。

解码器

解码器是一个输入模式检测器。

  • 功能:具有n个输入,2^n个输出。对于每一个输入的二进制组合,有且仅有一个对应的输出被置为逻辑1,其余输出为0。
  • 应用:地址解码(在内存中选择特定位置)、指令解码(处理器识别操作码)。
  • 示例(2-4解码器)
    • 输入:A1, A0
    • 输出:Y0, Y1, Y2, Y3
    • 功能:Y0 = ¬A1·¬A0, Y1 = ¬A1·A0, Y2 = A1·¬A0, Y3 = A1·A0

多路复用器

多路复用器是一个数据选择器。

  • 功能:根据一组选择信号,从多个数据输入中选择一个连接到输出端。
  • 示例(2选1 MUX)
    • 数据输入:D0, D1
    • 选择信号:S
    • 输出:Y
    • 功能:Y = S ? D1 : D0 (当S=0时,Y=D0;当S=1时,Y=D1)
  • 扩展:可以用多个小MUX构建更大的MUX(如用3个2选1 MUX构建1个4选1 MUX)。

多路复用器的一个巧妙应用是实现任意逻辑函数。将函数的输入变量连接到MUX的选择端,将根据真值表确定的常量(0或1)连接到MUX的数据输入端,即可实现该函数。这种结构被称为查找表,是可编程逻辑器件(如FPGA)的核心组成部分。

加法器

加法器是执行二进制算术加法的基本组件。

一位全加器

  • 功能:计算两个加数位(A, B)和一个来自低位的进位(Cin)的和,产生一个和位(S)以及一个向高位的进位(Cout)。
  • 真值表:基于二进制加法规则。
  • 布尔表达式(经化简后)
    S = A ⊕ B ⊕ Cin (异或)
    Cout = A·B + A·Cin + B·Cin (多数函数)

多位加法器

  • 行波进位加法器:将n个一位全加器串联,低位全加器的Cout连接到相邻高位全加器的Cin。结构简单,但进位信号需要从最低位“波动”传递到最高位,导致速度较慢,延迟与位数成正比。
  • 超前进位加法器:通过额外的逻辑电路,提前计算出所有位的进位,从而大幅减少加法运算的总体延迟。这是通过将进位逻辑展开并并行计算来实现的。

可编程逻辑结构

最后,我们看看如何利用标准形式来实现灵活的可编程硬件。

可编程逻辑阵列
PLA直接实现SOP形式的二级逻辑结构。

  • 结构:第一级是一个可编程的“与”阵列,用于生成所需的乘积项(不一定是全部最小项);第二级是一个可编程的“或”阵列,用于对乘积项进行求和,产生最终输出。
  • 特点:通过熔丝、反熔丝或晶体管开关来配置“与”阵列和“或”阵列中的连接,从而实现不同的逻辑函数。非常灵活,但规模较大时效率较低。

PLA体现了布尔代数和SOP形式在硬件实现中的直接应用。通过编程连接,同一块PLA硬件可以实现多种不同的逻辑功能,包括我们之前讨论的全加器。


总结

本节课中,我们一起学习了组合逻辑电路的核心知识。我们从晶体管和CMOS逻辑门的基础出发,理解了数字电路的基本构建块。然后,我们深入学习了布尔代数,掌握了描述、转换和简化逻辑表达式的数学工具,这是优化电路面积、功耗和性能的关键。接着,我们探讨了组合逻辑的规范表示方法——积之和与和之积形式。最后,我们利用这些知识构建并分析了几种重要的组合逻辑模块:解码器、多路复用器和加法器,并简要介绍了可编程逻辑阵列的概念。这些模块是构成复杂数字系统(如CPU)的基石,为后续学习时序逻辑和计算机体系结构打下了坚实的基础。

3:时序逻辑 (Spring 2025)

概述

在本节课中,我们将首先完成组合逻辑部分的学习,然后进入时序逻辑。我们将能够构建有意义的电路,基于我们已经开始学习的组件。我们将从晶体管开始,构建逻辑门,并在此基础上构建更复杂的逻辑模块。

组合逻辑回顾与扩展

上一节我们介绍了组合逻辑的基本模块,如解码器、多路复用器和全加器。本节中,我们来看看逻辑完备性和更多有用的组合逻辑模块。

逻辑完备性

任何我们想要实现的逻辑功能都可以通过可编程逻辑阵列(PLA)来完成。这是因为PLA实现了积之和形式,而积之和形式是任何真值表的规范表示。

以下是逻辑完备性的核心概念:

  • 与、或、非门集合是逻辑完备的,因为我们可以构建任何电路来实现任何真值表的规范,而无需使用任何其他类型的门。
  • 与非门本身也是逻辑完备的。仅使用与非门就可以构建任何电路,因为你可以用与非门来表示与、或、非门。或非门本身也是逻辑完备的。

更多组合逻辑模块

以下是更多可能在后续课程和实验中用到的组合逻辑模块。

比较器

比较器用于比较两个值。让我们从构建一个检查两个值是否相等的电路开始。其思想是检查两个输入值是否在每一位上都完全相同。

对于一个4位比较器,其模块图如下所示。它有两个输入A和B,每个都是4位宽。如果A和B在每一位上都相等,则输出为1,否则为0。

以下是使用异或非门构建该电路的方法:

  • 检查每一位是否相等:a3 == b3a2 == b2a1 == b1a0 == b0
  • 每个异或非门在其两个输入相等时输出1。
  • 一个最终的与门接收所有异或非门的输出。只有当所有位都相等时,这个与门才输出1。

算术逻辑单元

算术逻辑单元(ALU)存在于所有处理器中,是核心执行单元。其基本思想是将各种算术和逻辑运算组合到一个单元中,但该单元一次只执行一个功能。

一个典型的ALU符号如下所示。它接收两个输入A和B(每个M位宽),一个输出(N位宽),以及一个功能输入F(例如3位)。功能输入指定要执行的操作,例如与、或、加法、减法等。

以下是ALU功能的一个例子:

  • 如果功能编码F[2:0]010,则输出Y = A + B(加法)。
  • 如果功能编码F[2:0]110,则输出Y = A - B(减法,通过二进制补码实现)。

三态缓冲器

三态缓冲器是一个有趣的组件,它允许将不同的信号选通到一根导线上。其真值表如下:

  • 如果使能输入为0,输出为高阻态(浮空),A未连接到Y。
  • 如果使能输入为1,A连接到Y,Y获得A的值。

浮空信号是指没有被任何电路驱动的信号。通过使用三态缓冲器,可以将多个组件连接到同一总线上,并通过控制逻辑确保在任何时候最多只有一个缓冲器被使能。

以下是三态缓冲器的应用场景:

  • 共享总线系统:例如,CPU和内存可以连接到同一总线上。通过设计控制逻辑,确保在任何给定时间,只有CPU或内存中的一个可以将值放到总线上。
  • 实现多路复用器:可以使用三态缓冲器构建多路复用器。例如,一个2选1多路复用器可以通过两个三态缓冲器实现,选择信号S控制哪个缓冲器使能。

时序逻辑简介

到目前为止,我们的电路有一个缺点:它们无法记住任何过去的信息。本节中,我们来看看能够存储信息的电路。

存储元件的基础:交叉耦合反相器

最基本的存储元件是交叉耦合反相器。它由两个反相器组成,其中一个的输出连接到另一个的输入,反之亦然。

这个电路有两个稳定状态:

  • 如果Q为1,则Q非为0。
  • 如果Q为0,则Q非为1。

然而,这个电路的问题是我们无法控制如何设置Q的值。它没有输入机制,因此不实用。

可控存储元件:RS锁存器

为了控制存储的值,我们引入了RS锁存器(复位/置位锁存器)。它由两个交叉耦合的与非门构成。

其操作如下:

  • 保持状态:当S和R都为1时,Q保持其先前值。
  • 置位:要设置Q为1,驱动S为0,同时保持R为1。
  • 复位:要重置Q为0,驱动R为0,同时保持S为1。
  • 禁止状态:S和R绝不应同时为0。这会导致Q和Q非都变为1,违反了布尔代数的基本假设,并可能导致亚稳态。

门控D锁存器

为了解决RS锁存器的问题,我们引入了门控D锁存器。它确保S和R不会同时为0。

其操作如下:

  • 写入使能为0:Q保持不变。
  • 写入使能为1:Q获取输入D的值。

门控D锁存器不违反任何逻辑原则,并且避免了亚稳态。

寄存器

要存储多位数据,可以将多个D锁存器并行放置。例如,一个4位寄存器由四个D锁存器组成,共享一个写入使能信号,以便同时写入一个4位值。

寄存器的模块级表示如下:它是一个具有数据输入、数据输出和写入使能信号的模块。

存储器阵列

存储器由多个可以写入或读取的位置组成。每个位置由唯一的地址索引。

让我们实现一个简单的存储器阵列:

  • 地址空间大小:2个位置(需要1位地址)。
  • 地址位宽:每个位置存储3位数据。
  • 组件:两个3位寄存器(用于位置0和1)、一个地址解码器和一个多路复用器。
  • 读取操作:地址解码器根据输入地址选择哪个寄存器的输出连接到数据输出。
  • 写入操作:地址解码器和写入使能信号共同决定将输入数据写入哪个寄存器。

通过增加地址解码器和寄存器的数量,可以扩展存储器的大小和位宽。

有限状态机概念

有限状态机(FSM)是状态系统的离散时间模型。它由以下部分组成:

  • 有限数量的状态。
  • 有限数量的外部输入。
  • 有限数量的外部输出。
  • 所有状态转换的明确规范。
  • 每个外部输出值确定方式的明确规范。

FSM的逻辑分为三部分:

  1. 下一状态逻辑:根据当前状态和输入,确定下一个状态的值。
  2. 状态寄存器:存储系统的当前状态。在时钟边沿,将下一状态的值锁存进来。
  3. 输出逻辑:根据当前状态(和/或输入)确定输出值。

时钟与同步设计

现代计算机使用同步电路,其中状态转换由时钟信号控制。时钟是一个周期性振荡的信号,它将时间划分为固定的间隔(时钟周期)。

在同步设计中:

  • 状态转换发生在时钟边沿(例如,上升沿)。
  • 在一个时钟周期内,状态保持不变,组合逻辑进行计算。
  • 在下一个时钟边沿,根据计算结果,状态可能转换到新状态或保持原状。

同步设计比异步设计更容易实现且更可靠,尽管它可能引入一些时钟开销。

D触发器

D锁存器不能直接用作状态寄存器,因为它在时钟为高电平期间是透明的(输出会随输入变化)。我们需要一个元件,其输出仅在时钟边沿变化,并在整个时钟周期内保持稳定。

解决方案是D触发器。它由两个级联的D锁存器构成,时钟信号以互补的方式连接。

其操作如下:

  • 当时钟为低电平时,第一个锁存器透明,将D值传递到第二个锁存器的输入,但第二个锁存器不透明,因此输出Q不变。
  • 当时钟从低电平跳变到高电平(上升沿)时,第二个锁存器变得透明,将之前传递过来的D值锁存到输出Q。
  • 因此,D触发器在时钟上升沿采样D输入,并在其他时间保持其先前值。

D触发器是边沿触发的状态元件,非常适合用作有限状态机中的状态寄存器。

总结

本节课中,我们一起学习了:

  1. 逻辑完备性:与或非门集合以及与非门、或非门本身都是逻辑完备的。
  2. 更多组合模块:包括比较器、算术逻辑单元(ALU)和三态缓冲器的原理与应用。
  3. 时序逻辑基础:从交叉耦合反相器开始,介绍了RS锁存器、门控D锁存器、寄存器和简单存储器阵列的构建。
  4. 有限状态机概念:了解了状态、状态转换以及FSM的基本组成部分。
  5. 同步设计与时钟:介绍了时钟信号的作用以及同步设计的优势。
  6. D触发器:作为边沿触发的存储元件,它是构建可靠状态寄存器的关键。

通过这些知识,我们已经为设计和分析具有记忆功能的数字电路打下了坚实的基础。下一节课,我们将利用D触发器开始构建完整的有限状态机。

4:时序逻辑 II、实验与 Verilog

在本节课中,我们将学习时序逻辑电路中的有限状态机,了解课程实验的基本结构,并初步接触硬件描述语言 Verilog。我们将从理论概念过渡到实际应用,为后续的动手实验打下基础。

有限状态机(FSM)回顾

上一节我们介绍了时序逻辑的基本元件——锁存器和触发器。本节中,我们来看看如何用这些元件构建更复杂的时序系统:有限状态机

有限状态机是描述系统行为的一种数学模型,它由一组状态、状态之间的转移以及伴随转移的输出组成。FSM 特别适合描述那些输出不仅取决于当前输入,还取决于过去输入序列的系统。

一个 FSM 通常包含以下核心部分:

  • 状态寄存器:由触发器组成,用于存储当前状态。
  • 次态逻辑:组合逻辑电路,根据当前状态当前输入计算下一个状态。
  • 输出逻辑:组合逻辑电路,根据当前状态(摩尔型)或当前状态和当前输入(米利型)产生输出。

其工作原理可以用以下流程描述:

  1. 在时钟边沿,状态寄存器从 D 端采样并更新为次态逻辑计算出的值,这个值成为新的当前状态
  2. 输出逻辑根据新的当前状态(和可能的输入)立即产生输出。
  3. 次态逻辑同时根据新的当前状态和外部输入,计算下一个时钟边沿将要进入的下一个状态

课程实验介绍

理解了 FSM 的理论后,我们将把知识应用到实践中。本课程的实验环节将引导你使用硬件描述语言来设计和实现数字电路。

实验的核心目标是让你掌握从规范到实现的完整设计流程。你将学习如何编写代码来描述电路,如何进行仿真以验证功能,以及如何将设计综合到实际的可编程逻辑器件上。

以下是实验环节通常会涵盖的几个关键阶段:

  • 设计输入:使用 Verilog 编写电路描述。
  • 功能仿真:在软件环境中测试代码逻辑是否正确。
  • 综合与实现:将高级描述转换为目标芯片(如 FPGA)上的实际电路网表。
  • 时序分析与下载:验证电路时序性能,并将设计文件下载到硬件板卡上运行。

Verilog 硬件描述语言简介

为了完成实验,我们需要一种工具来描述我们的电路设计。这就是硬件描述语言。本节中,我们来看看本课程将使用的 Verilog HDL

Verilog 是一种用于描述、设计和仿真数字系统的语言。你可以把它想象成电路的“蓝图”或“代码”,它允许你以文本形式定义逻辑门、寄存器以及它们之间的连接。

对于初学者,掌握几个基本概念至关重要:

  • 模块:Verilog 设计的基本构建块,代表一个电路单元。使用 module 关键字定义。
  • 端口:模块与外部环境的接口,包括输入、输出和双向端口。使用 input, output, inout 声明。
  • 数据类型:常用的有 wire(表示连线,用于组合逻辑信号)和 reg(表示寄存器,用于存储状态)。
  • 赋值:使用 assign 关键字进行连续赋值(描述组合逻辑),或在 always 块中使用过程赋值(描述组合或时序逻辑)。

下面是一个简单的 Verilog 代码示例,它描述了一个 2 输入与门:

module and_gate (
    input  a,    // 输入端口 a
    input  b,    // 输入端口 b
    output y     // 输出端口 y
);
    assign y = a & b; // 连续赋值,y 等于 a 和 b 的逻辑与
endmodule

在这段代码中,我们定义了一个名为 and_gate 的模块,它有两个输入 ab 和一个输出 yassign y = a & b; 这行代码描述了输出 y 始终等于输入 ab 进行位与操作的结果,这正是一个与门的功能。


本节课中我们一起学习了时序逻辑的核心——有限状态机的构成与工作原理,了解了课程实验从设计到实现的基本流程,并初步认识了用于描述电路的 Verilog 语言及其基本语法。这些概念是将数字设计理论转化为实际项目的基础,在接下来的课程和实验中,我们将深入运用这些知识。

4:时序逻辑 II、实验与 Verilog (Spring 2025) 🧠

在本节课中,我们将学习如何设计一个交通灯控制器,深入探讨有限状态机(FSM)的硬件实现,并了解摩尔(Moore)型与米利(Mealy)型状态机的权衡。此外,我们还将介绍本课程的实验安排、FPGA平台以及硬件描述语言Verilog的基础知识。


交通灯控制器设计 🚦

上一节我们介绍了有限状态机的基本概念,本节中我们来看看如何为一个实际的交通灯系统设计控制器。

设计系统时必须确保服务质量和公平性。例如,如果一条道路(A路)一直有车,而控制器始终让其绿灯通行,那么另一条道路(B路)将永远无法通行,这会导致不公平。在内存控制器等调度技术中,服务质量和公平性同样至关重要。

交通灯控制器可以看作一个黑盒,输入为TATB(表示A路和B路是否有车),输出为LALB(表示A路和B路的灯色)。此外,还需要时钟输入clk和复位信号reset。复位信号用于将FSM置于初始状态。

摩尔型状态机设计

以下是该控制器的摩尔型状态机设计。我们定义四个状态:

  • S0(初始状态):A路绿灯,B路红灯。只要A路有车(TA=1),就保持此状态。
  • S1:A路黄灯,B路红灯。当A路无车(TA=0)并持续5秒后,无条件进入此状态,然后再无条件进入S2。
  • S2:A路红灯,B路绿灯。只要B路有车(TB=1),就保持此状态。
  • S3:A路红灯,B路黄灯。当B路无车(TB=0)并持续5秒后,无条件进入此状态,然后再无条件回到S0。

这是一个摩尔型状态机,因为每个状态的输出(灯色)仅由当前状态决定。

状态转移表与电路实现

首先,我们需要根据状态图创建状态转移表。该表定义了基于当前状态和输入(TA, TB)的下一个状态。

以下是状态转移表示例(使用二进制编码S1 S0S0=00, S1=01, S2=10, S3=11):

当前状态 (S1 S0) 输入 (TA TB) 下一状态 (S1' S0')
00 1 X 00 (保持S0)
00 0 X 01 (转到S1)
01 X X 10 (转到S2)
10 X 1 10 (保持S2)
10 X 0 11 (转到S3)
11 X X 00 (转到S0)

X表示“无关项”(don‘t care),在逻辑化简时可以利用。

根据此表,可以推导出下一状态逻辑的布尔方程。例如,对于S1'(下一状态的高位):

S1' = (S1 ^ S0) + (S1 & ~S0 & ~TB) + (S1 & ~S0 & TB)

可以简化为:

S1' = S1 ^ S0

对于S0'(下一状态的低位):

S0' = (~S1 & ~S0 & ~TA) + (S1 & S0)

接着,需要设计输出逻辑。由于是摩尔型状态机,输出仅取决于当前状态。我们需要对灯色进行编码,例如:00=绿,01=黄,10=红。然后为每个输出位(LA1, LA0, LB1, LB0)写出基于状态编码S1 S0的方程。例如:

LA1 = S1
LA0 = ~S1 & S0

最后,将状态寄存器(两个触发器)、下一状态组合逻辑电路和输出组合逻辑电路连接起来,就构成了完整的控制器硬件电路。

时序图分析

在时序图中,我们可以看到时钟clk、复位reset、输入TA/TB、状态位以及输出信号的变化。复位信号通常是异步的,一旦有效,会立即将状态寄存器清零,而不等待时钟边沿。其他信号的变化则与时钟边沿同步,并会经过一定的逻辑门延迟。


状态编码方案 🔢

状态编码方式会影响设计所需的触发器数量以及组合逻辑的复杂度。主要有三种常见方案:

  1. 二进制编码(完全编码):使用最少的比特数(log2(状态数))对状态进行编码。这最小化了触发器数量,但下一状态和输出逻辑可能较复杂。
  2. 独热编码:使用与状态数相同的比特数,每个状态对应一个比特位为“热”(1)。这简化了设计过程(尤其是下一状态逻辑),并通常使输出逻辑更简单,但需要更多的触发器。
  3. 输出编码:仅适用于摩尔型状态机。将输出值直接编码到状态比特中,从而完全消除了输出逻辑。但这会限制状态编码的灵活性。

设计者需要根据面积、性能、功耗等约束,仔细选择编码方案以优化设计。


摩尔型与米利型状态机的权衡 ⚖️

我们通过一个“蜗牛微笑”的例子来对比两种模型:当蜗牛爬过的最后四位是1101时,它就会微笑。

  • 摩尔型FSM:需要5个状态。检测到序列1101后,进入一个独立的状态(如S4),在该状态下输出1(微笑)。输出仅与状态有关。
  • 米利型FSM:仅需4个状态。当处于已接收110序列的状态(S3)且当前输入为1时,直接在转移边上输出1。输出取决于状态和当前输入。

权衡比较

  • 摩尔型:通常状态数更多,但输出更稳定(由状态寄存器直接决定,不受输入毛刺影响)。组合逻辑路径更短(输入->下一状态, 状态->输出)。
  • 米利型:通常状态数更少,可能节省触发器。但输出可能因输入毛刺而不稳定,且输入到输出的组合路径更长,在级联时可能产生长组合逻辑链。

在现代设计中,由于触发器成本相对降低,且对稳定性的要求,摩尔型FSM更常被使用,除非有明确的面积优化需求。


FSM设计流程 📝

以下是设计有限状态机的一般步骤:

  1. 确定状态:根据文本描述,确定系统所有可能的状态。
  2. 绘制状态转移图:确定每个状态的输入和输出,以及状态之间的转移条件。
  3. 选择初始(复位)状态:这是一个自然的起点。
  4. 构建状态转移表
  5. 选择状态编码方案
  6. 推导下一状态和输出逻辑方程
  7. 绘制电路原理图

设计FSM类似于编程,但它描述的是硬件的并发控制流。


课程实验与FPGA介绍 🛠️

本节我们将了解本课程的实验安排、所使用的FPGA平台以及硬件描述语言Verilog的入门知识。

实验安排概览

实验是本课程的重要组成部分(占总分30%)。我们将使用Basys 3 FPGA开发板。以下是实验内容概览:

以下是各实验阶段的简要介绍:

  • 实验1:绘制基本比较器电路原理图。
  • 实验2:使用Verilog在FPGA上实现1位及4位全加器,并用开关和LED验证。
  • 实验3:设计组合逻辑电路,将加法器结果驱动到七段数码管显示。
  • 实验4:设计并实现一个FSM,控制LED按特定模式闪烁,并可调节闪烁速度。
  • 实验5:设计并实现一个算术逻辑单元(ALU),支持加、减、乘、比较及逻辑运算。
  • 实验6:学习使用测试平台(Testbench)对设计进行仿真和验证,掌握调试技巧。
  • 实验7:为我们的微处理器编写MIPS汇编程序。
  • 实验8:系统集成,完成一个完整的MIPS处理器设计,并运行程序(如贪吃蛇游戏)。
  • 实验9:分析处理器性能,并通过添加乘法、移位等指令进行优化。

什么是FPGA?

FPGA(现场可编程门阵列)是一种软件可配置的硬件基板。其核心由大量可编程逻辑块(通常基于查找表LUT)和可编程互连网络构成。用户可以通过配置比特流,在FPGA上实现任意数字电路,只要资源足够。

FPGA的优点:高灵活性、可重构性、开发成本低于定制芯片(ASIC)、上市时间短。
FPGA的缺点:性能、功耗效率通常低于专用ASIC;可重构性带来了面积、延迟和可靠性的开销。

FPGA设计流程与工具

手动配置FPGA资源是不现实的。我们使用计算机辅助设计(CAD)工具流程:

  1. 硬件描述:使用Verilog等HDL描述电路功能。
  2. 逻辑综合:将HDL代码转换为门级网表。
  3. 布局布线:将网表中的逻辑元件映射到FPGA的具体资源(LUT、触发器),并配置互连。
  4. 比特流生成:生成配置FPGA的二进制文件。
  5. 编程:将比特流下载到FPGA中。

本课程将使用Xilinx的Vivado工具链完成整个流程。


Verilog硬件描述语言入门 💻

面对包含数十亿晶体管的现代芯片,硬件描述语言(HDL)是管理复杂性的关键。Verilog和VHDL是最主要的两种HDL。本课程使用Verilog。

为什么需要专门的硬件描述语言?

HDL能够方便地描述硬件结构(如连线、门电路、寄存器、时钟边沿),并天然支持并发性(硬件中所有元件并行工作),而C/C++等顺序编程语言难以高效模拟这种并发行为。

模块:Verilog的基本构建块

Verilog采用层次化设计。模块(module) 是基本的构建单元。

一个模块的定义包括:

  • 模块名称
  • 端口列表(输入/输出)
  • 功能描述

以下是一个简单模块的示例:

module example (
    input a, b, c,
    output y
);
    assign y = a & b | c;
endmodule

多比特信号

可以使用范围来定义多比特的输入输出端口:

module multi_bit (
    input [31:0] data_in, // 32位输入,从data_in[31]到data_in[0]
    output [15:8] data_high, // 8位输出,从data_high[15]到data_high[8]
    input clk // 单比特输入
);
    // ... 功能描述
endmodule

[31:0]表示一个32位的值,最高有效位为31,最低有效位为0。这是一种常见的表示法。


总结 🎯

本节课我们一起学习了:

  1. 如何为一个交通灯系统设计摩尔型有限状态机,包括状态定义、转移表推导、逻辑方程化简以及电路实现。
  2. 状态编码的三种主要方案(二进制、独热、输出编码)及其权衡。
  3. 摩尔型与米利型状态机的区别与设计权衡,包括状态数、输出稳定性和逻辑复杂度。
  4. 本课程实验的总体安排、FPGA平台的基本原理以及使用Vivado工具的设计流程。
  5. Verilog硬件描述语言的基础,包括模块定义和多比特信号的表示方法。

这些知识为后续动手实现数字电路和处理器奠定了重要基础。下一周我们将更深入地学习Verilog,并开始我们的第一个实验。

4:时序逻辑II、实验与Verilog 🧠

概述

在本节课中,我们将完成时序逻辑设计的学习,并介绍硬件描述语言Verilog。我们将学习如何设计有限状态机,了解实验课程安排,并初步接触Verilog的基本语法。


时序逻辑设计回顾

上一节我们介绍了时序电路和有限状态机的基本概念。本节中,我们将更严谨地完成有限状态机的设计。

时序电路产生的输出由当前输入过去值共同决定,这类电路具有记忆功能。我们之前构建了存储元件来实现这一点。

有限状态机(FSM)的构成

一个有限状态机需要三个电路元件:

  1. 状态寄存器:用于记住当前所处的状态。
  2. 次态逻辑:决定如何从一个状态转移到另一个状态。
  3. 输出逻辑:根据输入和当前状态产生有限状态机的输出。

任何有限状态机都可以按此结构组织。在时钟周期开始时,次态被锁存到状态寄存器中,其数据在整个时钟周期内都可用,以便次态逻辑和输出逻辑有足够的时间进行计算和稳定。

D触发器与状态寄存器

我们之前设计的锁存器(Latch)是电平触发的,在时钟有效期间,输入变化会导致输出变化,这不满足我们的需求。我们需要的是边沿触发的状态元件,它只在时钟边沿捕获数据。

我们开发了D触发器来实现这一点。一个正边沿触发的D触发器在时钟上升沿对输入D进行采样。我们可以使用多个并行的D触发器来构建所需位数的状态寄存器。

一个D触发器可能包含约34个晶体管,相比之下,DRAM单元仅需1个晶体管,这体现了存储的代价。


有限状态机设计实例:交通灯控制器 🚦

现在,让我们基于所学知识构建一个具体的有限状态机。

我们将设计一个“智能”交通灯控制器,控制两条道路(A路和B路)。每条道路有一个交通传感器输入(TA, TB)和一个交通灯输出(LA, LB)。交通灯有红、黄、绿三种状态。

假设:状态每5秒可能改变一次。但如果某条道路的灯为绿色且该道路有交通流量,则状态保持不变(灯保持绿色)。这个设计存在“公平性”问题,即一条路可能长期占据绿灯。

状态图设计(Moore型)

这是一个Moore型有限状态机,因为输出仅取决于当前状态。

以下是状态定义:

  • S0: LA = 绿, LB = 红 (复位状态)
  • S1: LA = 黄, LB = 红
  • S2: LA = 红, LB = 绿
  • S3: LA = 红, LB = 黄

状态转移条件基于交通传感器 TATB。例如,在S0状态,如果 TA=1(A路有车),则保持在S0;如果 TA=0,则转移到S1。从S1到S2是无条件转移。

从状态图到电路

设计流程如下:

  1. 创建状态转移表:列出当前状态、输入与次态的对应关系。
  2. 状态编码:为每个状态分配二进制码。例如:S0=00, S1=01, S2=10, S3=11。
  3. 推导次态逻辑:将编码后的状态转移表视为真值表,用布尔代数或卡诺图化简,得到次态(S1‘, S0‘)的逻辑表达式。例如:
    S1‘ = S1’·S0 + S1·S0‘·TB
    S0‘ = S1‘·S0‘·TA‘ + S1·S0‘·TB‘
  4. 推导输出逻辑:对于Moore机,输出只与状态有关。同样需要将红黄绿编码为二进制(如00=红,01=绿,10=黄),然后根据状态推导每个输出位的逻辑表达式。例如:
    LA1 = S1 (LA的高位)
    LA0 = S1‘·S0 (LA的低位)
  5. 绘制电路图:将状态寄存器(两个D触发器)、次态逻辑电路(根据上述表达式用门电路实现)和输出逻辑电路连接起来。

时序考量 ⏱️

电路中的组合逻辑(次态和输出逻辑)存在传播延迟。时钟周期必须足够长,以确保在下一个时钟边沿到来之前,所有逻辑都能稳定输出正确值。否则,系统将无法正常工作。这引出了对电路进行时序验证的重要性。


状态编码与有限状态机类型

状态编码方式

有三种常见的状态编码方式,各有权衡:

  1. 二进制编码:用最少的触发器(log2(状态数))表示状态。节省硬件,但组合逻辑可能更复杂。
  2. 独热码编码:用n个触发器表示n个状态,每个状态只有一位为‘1’。增加了触发器数量,但通常能简化次态逻辑。
  3. 输出编码:将状态直接编码为输出值。这仅适用于Moore型机器,可以简化输出逻辑。

Moore型 vs. Mealy型

  • Moore型:输出取决于当前状态。输出写在状态圈内。
  • Mealy型:输出取决于当前状态和输入。输出写在状态转移箭头上。

Mealy型机器有时可以用更少的状态实现相同功能,但输出逻辑可能更复杂,且输出可能在输入变化后立即改变(与时钟不同步)。


实验课程介绍与FPGA基础 🔬

本节我们将了解实验课的安排和FPGA平台。

实验课安排

  • 形式:现场进行,两人一组。
  • 评分:共10个实验,占总成绩30分(考试占70分)。评分基于现场检查(70%)和实验报告(30%)。
  • 目标:最终在FPGA上实现一个能运行程序的32位微处理器。

以下是实验内容概览:

  • Lab 1-3:组合逻辑电路入门,从绘制原理图到在FPGA上实现加法器、七段数码管显示。
  • Lab 4:时序逻辑,实现一个有限状态机控制LED闪烁。
  • Lab 5:设计算术逻辑单元(ALU)。
  • Lab 6:学习使用仿真工具验证设计。
  • Lab 7:为我们的处理器编写汇编程序。
  • Lab 8:系统集成,完成MIPS处理器设计,并运行“贪吃蛇”程序。
  • Lab 9:分析并优化处理器性能。

什么是FPGA?

FPGA(现场可编程门阵列)是一种软件可重构的硬件芯片。它包含大量可编程逻辑单元(如查找表LUT)和可编程互连资源。用户通过编写硬件描述语言(如Verilog)来定义电路功能,再通过CAD工具将设计映射到FPGA资源上,生成配置比特流文件下载到芯片中。

优势:开发周期短,灵活性高,适合原型设计和特定算法加速。
劣势:在速度、功耗和面积上通常不如专门定制的ASIC芯片。

我们将使用Vivado作为主要的CAD工具,在实验室的电脑上进行FPGA设计和调试。


Verilog硬件描述语言入门 💻

现代芯片包含数十亿甚至数万亿晶体管,无法手动设计。硬件描述语言(HDL)应运而生,用于描述、仿真和综合复杂的数字电路。

为什么需要专门的HDL?

与C/C++等软件语言不同,HDL能更好地描述硬件的两个关键特性:

  1. 结构性:直接描述 wires、gates、registers、clocks 等硬件元素。
  2. 并行性:硬件电路本质上是并发执行的,HDL支持对并发行为的描述。

两种主流HDL是 VerilogVHDL。本课程重点学习Verilog。

Verilog设计方法:层次化

复杂系统采用层次化设计:

  • 自顶向下:先定义顶层模块(如处理器),再逐步分解为子模块。
  • 自底向上:先构建基本单元(如门电路、加法器),再组合成更大模块。
    实际设计中常结合两种方法。

Verilog模块基础

模块(module)是Verilog的基本构建块。以下是一个简单模块的示例:

module example (input a, input b, input c, output y);
    // 功能描述:实现 y = a & b | c;
    assign y = (a & b) | c;
endmodule

模块声明包括:

  1. 模块名(example)。
  2. 端口列表(a, b, c, y)及其方向(input, output)。
  3. 模块内部的功能描述。
  4. endmodule 结束。

多比特信号声明

可以使用范围来声明多比特(总线)信号:

module bus_example (
    input [31:0] data_in, // 32位输入, data_in[31]是最高位
    output [15:8] data_high, // 8位输出, data_high[15]是最高位
    input clk // 单比特输入
);
    // ... 模块功能
endmodule

[31:0] 表示一个从位31到位0的32位向量。位宽是 高位索引 - 低位索引 + 1


总结

本节课我们一起学习了:

  1. 完成了有限状态机的完整设计流程,包括状态图绘制、状态编码、次态与输出逻辑推导,并理解了Moore型与Mealy型状态机的区别。
  2. 了解了实验课程的目标、安排和FPGA平台的基本概念。
  3. 初步接触了Verilog硬件描述语言,学习了模块的基本结构和层次化设计思想。

掌握这些内容是后续设计复杂数字系统(包括处理器)的基础。下一讲我们将深入学习Verilog的更多细节。

5:HDL、Verilog II、时序与验证

概述

在本节课中,我们将继续学习硬件描述语言(HDL)和 Verilog,并初步探讨数字电路设计中的时序与验证概念。我们将了解如何用 Verilog 描述时序逻辑,并理解确保电路正确工作的时序约束的重要性。


硬件描述语言回顾

上一节我们介绍了硬件描述语言的基本概念。本节中,我们来看看其核心设计原则。

硬件描述语言(HDL)使我们能够轻松描述硬件结构。硬件中包含导线、门电路、触发器、时钟等众多组件,以及上升沿/下降沿等时序规范。硬件还具有高度并发性,使用通用软件语言描述这些特性并不容易,因此人们开发了专门的硬件描述语言。

关键设计原则:层次化与设计方法学

我们讨论了系统设计中的关键原则:需要构建层次结构。

我们探讨了自顶向下的设计方法学:从一个顶层模块开始,将其分解为必要的子模块,子模块可以进一步分解为叶单元或其他子模块。叶单元是无法再分割的电路组件,可以是逻辑门或原始单元库中的元素。

我们也讨论了自底向上的设计方法学:从叶单元开始,构建更大的模块,最终组合成顶层模块。在实际设计中,我们通常结合这两种方法:构思时采用自顶向下的方法,而实现时则遵循自底向上的方法。

这种层次化方法对测试和验证至关重要。在组合成更高级模块之前,必须确保每个子模块都经过验证。否则,错误会累积,使得在高层调试变得极其困难。

Verilog 模块定义

我们学习了如何在 Verilog 中定义模块:需要指定模块名、端口方向(输入/输出)以及端口名,然后描述模块的功能。

以下是模块定义的示例,包含三个输入 A、B、C 和一个输出 Y:

module example_module (input A, input B, input C, output Y)

我们也可以使用向量来表示多位信号,例如 [31:0] A 表示一个 32 位值。我们通常使用 [高位:低位] 的格式,而不是 [低位:高位]。后者通常用于定义数组。


Verilog 语法进阶

上一节回顾了模块定义,本节中我们来看看 Verilog 中一些重要的语法操作。

位操作

以下是 Verilog 中常用的位操作:

  • 位切片:允许赋值总线的一部分。
    short_bus = long_bus[2:5]; // 将 long_bus 的第 2 到第 5 位赋值给 short_bus
    
  • 拼接:将多个信号连接成一个更大的信号。
    y = {a2, a1, a0, a0}; // 将四个 1 位信号拼接成一个 4 位信号 y
    
  • 复制:重复一个信号多次。
    x = {4{a0}}; // 等价于 x = {a0, a0, a0, a0}
    

基本语法要点

  • Verilog 对大小写敏感。
  • 名称不能以数字开头。
  • 空格通常被忽略。
  • 注释使用 //(单行)或 /* ... */(多行)。

两种主要的 HDL 实现风格

Verilog(以及其他 HDL)主要有两种实现风格:

  1. 结构式描述:也称为门级描述。模块体包含电路的门级描述或模块实例化,描述模块之间如何互连。每个模块可以包含其他模块的实例以及它们之间的连接,从而形成一个层次化的模块结构。
  2. 行为式描述:模块体包含电路的功能描述,使用逻辑和数学运算符。这种方式抽象层次更高,通常更易于编写,但可能带来一些开销和优化问题。

实际设计中通常结合使用这两种风格。


结构式 Verilog 示例

理解了设计风格后,我们来看一个结构式描述的具体例子。

假设我们有一个顶层模块 top,它包含两个子模块 small 的实例。连接关系如下图所示(此处为文字描述:输入 A 和 C 进入第一个 small 实例,其输出 n1 与输入 B 一起进入第二个 small 实例,最终产生输出 Y)。

以下是相应的 Verilog 代码:

module top (input A, B, C, output Y)
    wire n1; // 定义内部连线

    // 实例化第一个 small 模块,命名为 first_inst
    small first_inst (.A(A), .B(C), .Y(n1));
    // 实例化第二个 small 模块,命名为 second_inst
    small second_inst (.A(n1), .B(B), .Y(Y));
endmodule

// 定义 small 模块(功能暂未定义)
module small (input A, B, output Y)
    // ... 模块功能描述 ...
endmodule

代码说明:

  • 使用 wire 关键字声明内部连线 n1
  • 实例化模块时,使用 模块名 实例名 (.端口名(连接线), ...) 的语法进行显式端口映射,这提高了代码的可读性和可维护性。
  • 也可以使用按顺序的端口映射(如 small first_inst (A, C, n1)),但不如显式映射可靠。

内置门级原语

Verilog 内置了基本逻辑门作为原语,如 and, or, not, nand, nor, xor, xnor。它们可以像模块一样被实例化,但无需额外定义。需要注意的是,这些原语的第一个端口是输出,其余是输入。

例如,一个 2 选 1 多路选择器的结构式描述如下:

module mux2to1 (input D0, D1, S, output Y)
    wire NS, Y1, Y2;

    not (NS, S);          // 非门:输出 NS,输入 S
    and (Y1, D0, NS);     // 与门:输出 Y1,输入 D0, NS
    and (Y2, D1, S);      // 与门:输出 Y2,输入 D1, S
    or (Y, Y1, Y2);       // 或门:输出 Y,输入 Y1, Y2
endmodule

行为式 Verilog 示例

上一节展示了结构式描述,本节中我们来看看更抽象的行为式描述。

行为式描述使用 assign 语句和逻辑运算符来描述功能。例如:

module example (input A, B, C, output Y)
    assign Y = (~A & ~B & ~C) | (A & B) | (B & C);
endmodule

综合工具会将此行为描述转换为具体的门级电路。现代综合工具会进行逻辑优化,但为了减少单个门的输入数量(大扇入门难以实现),优化可能会增加电路的逻辑深度,而不仅仅是追求两级最小化。

运算符与条件赋值

Verilog 支持丰富的运算符:

  • 按位运算符& (与), | (或), ^ (异或), ~ (非)。
  • 归约运算符:对向量的所有位进行操作,如 &A 表示 A 中所有位的与。
  • 条件赋值:使用三元运算符 ? :,可以简洁地描述多路选择器。
    assign Y = S ? D1 : D0; // 2选1 MUX
    // 4选1 MUX
    assign Y = S1 ? (S0 ? D3 : D2) : (S0 ? D1 : D0);
    
  • if-else 语句:必须在 always 块中使用,也可以用于描述组合逻辑。

数字表示

Verilog 中数字的表示格式为:<位宽>'<基数><数值>

  • 位宽:指定数字的二进制位数。
  • 基数:bB(二进制),hH(十六进制),dD(十进制),oO(八进制)。
  • 数值:对应的数字。可以用 _ 提高可读性。x 表示未知值,z 表示高阻态。

示例:

4'b1001     // 4位二进制数 1001
8'b0000_1001 // 8位二进制数 00001001,前导零自动补全
8'hF3       // 8位十六进制数 F3(即二进制 11110011)

三态缓冲器与线或逻辑

z 值用于表示高阻态,常用于三态缓冲器,以实现共享总线。例如,CPU 和内存通过三态门共享数据总线,同一时刻只能有一个驱动总线。


HDL 代码的综合与仿真

编写完 HDL 代码后,主要进行两个步骤:

  1. 综合:将 HDL 代码转换为门级网表,进而映射到晶体管并生成物理版图。现代综合工具可以根据面积、时序等约束进行优化,但无法保证绝对最优。
  2. 仿真:在不实际制造电路的情况下,通过软件模拟电路的行为,以验证其功能。这是极其重要的步骤,可以分层验证,确保每个模块正确后再进行集成。随着电路规模增大, exhaustive testing 变得不可能,需要智能地选择测试用例,这使得验证成为一项挑战。

重要提示:最常见的错误是将 HDL 视为普通程序,而非硬件描述。如果不清楚代码会综合成什么硬件,结果可能不如预期。应该在编写代码前,在纸上画出组合逻辑块、寄存器和状态机的草图及其连接关系。


编码风格与参数化模块

为了写出可维护的代码,应遵循良好的编码风格:

  • 使用一致的命名规范。
  • 向量定义推荐使用 [MSB:LSB] 格式。
  • 每个文件只定义一个模块,并使文件名与模块名一致。
  • 时刻记住 Verilog 描述的是硬件。

参数化模块

为了使代码可重用,可以使用参数来定义可变的位宽等。

module mux #(parameter WIDTH = 8) (input [WIDTH-1:0] D0, D1, input S, output [WIDTH-1:0] Y)
    assign Y = S ? D1 : D0;
endmodule

// 实例化时指定参数
mux #(12) my_mux (.D0(d0_bus), .D1(d1_bus), .S(sel), .Y(out_bus));

时序建模

可以在 Verilog 中添加延时信息,但这仅用于仿真(如功能仿真和后综合时序仿真),不能被综合。

assign #5 Z1 = ~A; // Z1 的变化比 A 晚 5 个时间单位

时序逻辑的 Verilog 描述

之前我们主要关注组合逻辑。本节中我们来看看如何用 Verilog 描述时序逻辑。

时序电路由组合逻辑和存储元件(如触发器)构成。描述时序逻辑需要新的结构,因为纯组合逻辑的 assign 语句无法表达记忆功能。

always

always 块是描述时序逻辑的关键。其语法为:

always @(sensitivity_list) begin
    // 语句
end

当敏感列表中的事件发生时,块内的语句被执行。

D 触发器示例

一个简单的 D 触发器描述如下:

module dff (input clk, input [3:0] D, output reg [3:0] Q)
    always @(posedge clk) begin
        Q <= D; // 在时钟上升沿,将 D 的值赋给 Q
    end
endmodule
  • posedge clk 表示对时钟 clk 的上升沿敏感。
  • <= 是非阻塞赋值符,常用于时序逻辑。
  • always 块中被赋值的信号必须声明为 reg 类型(但这不意味着它一定会被综合成寄存器)。

复位信号

触发器通常需要复位功能。

  • 异步复位:复位信号独立于时钟,立即生效。
    always @(posedge clk or negedge reset_n) begin
        if (!reset_n) Q <= 4'b0;
        else Q <= D;
    end
    
  • 同步复位:复位信号仅在时钟边沿生效。
    always @(posedge clk) begin
        if (!reset_n) Q <= 4'b0;
        else Q <= D;
    end
    

锁存器示例

D 锁存器在时钟有效电平期间透明。

module dlatch (input clk, input D, output reg Q)
    always @(clk or D) begin // 对 clk 和 D 的变化都敏感
        if (clk) Q <= D; // 如果 clk 为高,输出跟随输入
        // 否则 Q 保持原值(隐含的记忆行为)
    end
endmodule

always 块用于组合逻辑

always 块也可用于描述组合逻辑,但必须确保:

  1. 敏感列表包含所有右侧涉及的信号(或使用 always @(*))。
  2. 在所有可能的条件分支下,输出信号都被赋值。
    否则,可能会无意中综合出锁存器。

阻塞赋值与非阻塞赋值

  • 非阻塞赋值 (<=):块内所有赋值语句同时计算,在块结束时同时更新。这更符合硬件并行执行的特性,推荐在时序逻辑 always 块中使用
  • 阻塞赋值 (=):语句按顺序执行,立即更新。类似于软件编程,可用于描述组合逻辑

重要规则

  • 不要在多个 always 块或连续赋值语句中对同一个信号进行赋值。
  • 不要混合使用 regwire 类型来驱动同一个信号。

有限状态机 (FSM) 的 Verilog 实现

用 Verilog 描述 FSM 非常清晰。一个 FSM 通常分为三部分:

  1. 状态寄存器:时序部分,通常用 always @(posedge clk) 描述。
  2. 次态逻辑:组合部分,根据当前状态和输入计算下一个状态。
  3. 输出逻辑:组合部分,根据当前状态(摩尔机)或当前状态和输入(米利机)产生输出。

三分频电路 FSM 示例

以下是一个将时钟频率除以 3 的 FSM 示例:

module divide_by_3_fsm (input clk, reset_n, output reg Q)
    // 状态编码
    parameter S0 = 2'b00, S1 = 2'b01, S2 = 2'b10;
    reg [1:0] state, next_state;

    // 1. 状态寄存器
    always @(posedge clk or negedge reset_n) begin
        if (!reset_n) state <= S0;
        else state <= next_state;
    end

    // 2. 次态逻辑(组合)
    always @(*) begin
        case (state)
            S0: next_state = S1;
            S1: next_state = S2;
            S2: next_state = S0;
            default: next_state = S0; // 避免锁存器,确保安全
        endcase
    end

    // 3. 输出逻辑(摩尔型:仅依赖状态)
    always @(*) begin
        Q = (state == S0); // 仅在 S0 状态输出高电平
    end
endmodule

时序与验证导论

最后,我们简要引入数字电路设计中至关重要的主题:时序与验证。

设计权衡

数字设计需要在多个维度进行权衡:

  • 面积:与芯片成本直接相关。
  • 速度与吞吐量:高性能需求。
  • 功耗与能量:对移动设备至关重要。
  • 设计时间:时间就是市场,需要缩短上市时间。

时序的重要性

到目前为止,我们主要关注逻辑功能的正确性。但一个逻辑正确的设计,如果忽略时序特性,在实际硬件中可能无法工作。

  • 组合电路时序:传播延时、污染延时、毛刺的产生与消除。
  • 时序电路时序:建立时间、保持时间、时钟频率的确定。
  • 电路能运行多快?如何让它更快?过快会导致什么问题?

这些问题的答案对于设计稳定可靠的数字系统至关重要。

验证

验证是确保设计符合规范的过程。随着电路规模增大, exhaustive testing 变得不可能,需要采用系统化的验证方法,如仿真、形式验证等,这是保证芯片功能正确的关键,也是设计流程中耗时最长的环节之一。


总结

本节课中我们一起学习了:

  1. Verilog 语言更高级的特性,包括位操作、参数化模块和编码风格。
  2. 时序逻辑的 Verilog 描述方法,重点是 always 块、阻塞/非阻塞赋值以及复位策略。
  3. 如何使用 Verilog 清晰地将有限状态机 (FSM) 分解为状态寄存器、次态逻辑和输出逻辑三部分进行实现。
  4. 初步了解了数字电路设计中时序特性和验证的重要性,这是连接逻辑设计与物理实现的关键桥梁。

在下节课中,我们将深入探讨组合电路和时序电路的时序分析,以及相关的验证技术。

6:时序与验证 II (Spring 2025)

概述

在本节课中,我们将继续学习数字电路中的时序分析与验证。我们将深入探讨组合电路和时序电路的时序参数,理解如何确保电路在满足功能正确性的同时,也能满足严格的时序约束。此外,我们还将介绍电路验证的基本方法,包括如何编写测试平台来验证设计的逻辑功能。


组合电路时序

上一节我们介绍了逻辑功能,本节中我们来看看电路的时序特性,即电路的速度以及如何使其更快。

数字逻辑抽象非常方便,例如,我们假设输出能随输入立即改变。然而,现实并非如此,输出相对于输入存在延迟,因为晶体管切换需要有限的时间。

延迟从根本上是由电路中的电容和电阻引起的。任何影响这些物理量的因素都会改变延迟,例如:

  • 不同的输入会导致不同的延迟。
  • 环境变化(如温度或电源电压)会影响延迟。
  • 电路老化也会增加延迟。

因此,设计者需要处理从输入到输出的一系列可能延迟。为了建立一个良好的简单抽象,我们定义了两个关键延迟参数:

  • 污染延迟 t_cd:输出开始改变所需的时间。
  • 传播延迟 t_pd:输出完成改变所需的时间。

在波形图中,交叉阴影线表示数值正在变化。

我们需要计算电路中的最长和最短延迟路径。最长路径(关键路径)决定了电路的最大延迟,而最短路径则与污染延迟相关。

以下是计算示例:
对于一个包含多个逻辑门的电路,其传播延迟是关键路径上所有门传播延迟之和。而其污染延迟则是最短路径上门污染延迟之和。

核心概念公式

  • 最长路径延迟 = Σ (路径上门单元的 t_pd)
  • 最短路径延迟 = Σ (路径上门单元的 t_cd)

毛刺

毛刺是指一个输入转换导致输出发生多次转换的现象。这通常发生在驱动输出的路径有快慢之分时。

例如,考虑一个电路,其输入从 (A,B,C) = (0,1,1) 变为 (0,0,1)。由于信号通过不同路径到达输出门的时间不同,输出可能会短暂地从1跳变为0,然后再变回1。

毛刺的持续时间大约为 t_pd - t_cd

我们并不总是需要消除毛刺,因为:

  • 修复毛刺通常会增加芯片面积、功耗和设计工作量。
  • 无论是否有毛刺,电路最终都会收敛到正确的值。
  • 是否处理毛刺由设计者根据具体应用决定。

然而,毛刺会导致不必要的动态功耗。在时序电路设计中,摩尔型状态机通常比米利型更能避免毛刺传播,因为其输出仅取决于当前状态,而不直接受输入变化影响。


时序电路时序

现在,我们来看看更复杂的时序电路时序。首先回顾一下D触发器的关键特性:它在时钟有效边沿对数据D进行采样。

为确保可靠采样,数据必须在采样时刻保持稳定。这引出了两个关键参数:

  • 建立时间 t_setup:时钟边沿之前,数据必须保持稳定的时间。
  • 保持时间 t_hold:时钟边沿之后,数据必须继续保持稳定的时间。

两者之和称为孔径时间,即数据必须稳定的总时间窗口。如果违反这些时间要求,触发器可能进入亚稳态,其输出会停留在不确定的中间电压值,最终非确定性地稳定到0或1。

与组合电路类似,触发器本身也有延迟:

  • 时钟到Q的污染延迟 t_ccq:时钟边沿后,Q最早开始改变的时间。
  • 时钟到Q的传播延迟 t_pcq:时钟边沿后,Q最晚完成改变并保持稳定的时间。

时序约束分析

在典型的同步时序系统中,多个触发器通过组合逻辑连接,并由同一时钟驱动。我们必须确保每个触发器输入端的时序要求得到满足。

这意味着一对触发器之间的组合逻辑延迟既不能太长,也不能太短。

建立时间约束
为了防止建立时间违规,组合逻辑的传播延迟不能太长。这决定了时钟周期的最小值。

核心概念公式
T_clk >= t_pcq + t_pd_comb + t_setup

其中,t_pd_comb 是组合逻辑的传播延迟。t_pcqt_setup 被称为时序开销。如果建立时间违规,一个简单的解决方法是降低时钟频率(增大 T_clk),但这会牺牲性能。

保持时间约束
为了防止保持时间违规,组合逻辑的污染延迟不能太短。

核心概念公式
t_ccq + t_cd_comb > t_hold

注意,此约束与时钟周期无关。因此,如果发生保持时间违规,无法通过调整时钟频率来修复,通常需要增加逻辑延迟(例如插入缓冲器)来增加 t_cd_comb


时钟偏移

实际情况更复杂:时钟信号到达芯片不同部分的时间存在差异,这称为时钟偏移 t_skew

时钟偏移会影响时序约束:

  • 如果时钟较早到达后续寄存器,会加剧建立时间约束
  • 如果时钟较晚到达后续寄存器,会加剧保持时间约束

因此,在有时钟偏移的情况下,有效的建立时间和保持时间约束变为:

核心概念公式

  • 建立时间:T_clk >= t_pcq + t_pd_comb + t_setup + t_skew
  • 保持时间:t_ccq + t_cd_comb > t_hold + t_skew

时钟偏移增加了时序开销,减少了每个周期可用于有效计算的时间。设计者必须通过智能的时钟网络设计来尽量减小 t_skew


电路验证:功能验证

如何知道一个电路能正常工作?我们不仅需要验证功能正确性,还要验证其是否满足所有时序约束。

对于大型数字设计,测试是最耗时的阶段。我们通常采用分层验证策略:

  1. 在高级别(如C或HDL)进行功能验证,速度快,易于获得高覆盖率。
  2. 在低级别(电路级)进行时序、功耗等验证,并确保其与高级别模型功能等价。

功能验证的目标是检查设计的逻辑正确性,通常忽略时序。主要方法有逻辑仿真和形式验证。本课程主要使用逻辑仿真。

测试平台 是专门为测试设计而创建的模块。被测设计称为 DUT
测试平台的主要组成部分包括:

  1. 测试向量生成:为DUT提供输入激励。
  2. 响应检查:检查DUT的输出是否正确。

测试平台使用HDL编写,但不可综合,仅用于仿真。

常见的测试平台类型有以下三种:

1. 简单测试平台
输入生成和输出检查均为手动。

  • 优点:易于创建,适合测试少量特定用例。
  • 缺点:难以扩展,输出需在仿真波形中手动检查,类似printf调试。

2. 自检查测试平台
输入生成手动,但输出检查自动(在代码中与预期值比较)。

  • 优点:仿真器会在出错时自动打印,无需手动检查波形。
  • 缺点:仍难以扩展到海量测试用例,手动编写预期值容易出错。

可以结合测试向量文件来改进,从文件读取输入和预期输出。

3. 自动测试平台
输入生成和输出检查均自动进行。通常采用黄金模型策略。

  • 黄金模型:代表理想电路行为的参考模型,通常更简单、更易于验证。
  • 工作原理:将相同的输入同时施加给DUT和黄金模型,并自动比较两者的输出。

核心概念代码(比较逻辑):

if (dut_out !== golden_out) begin
    $display("Error at time %t: input=%b, dut_out=%b, golden_out=%b", $time, input_vector, dut_out, golden_out);
    error_count = error_count + 1;
end
  • 优点:检查完全自动化,高度可扩展,职责分离清晰。
  • 缺点:创建正确且完备的黄金模型和测试向量可能非常困难。

需要注意的是,对复杂电路进行穷举测试(如32位加法器的2^64种输入)是不可行的。因此,我们需要通过选择重要测试用例、随机测试等方法,在测试覆盖率和时间之间取得平衡。


电路验证:时序验证

时序验证确保设计满足建立时间和保持时间等约束。

方法

  1. 高层次仿真:在HDL中使用 #延迟 语句为门电路、触发器等添加时序标注,进行后综合仿真。
  2. 电路级仿真:使用SPICE等工具进行晶体管级仿真,精度更高,但速度慢。

对于FPGA或ASIC设计流程,通常使用EDA工具(如Vivado、Synopsys工具链)来自动进行时序分析和优化。

流程

  1. 设计者提供时序约束(如时钟频率、时钟偏移)。
  2. 综合、布局布线工具尽力满足这些约束。
  3. 工具生成时序报告,指出关键路径、最大工作频率以及任何时序违规。

如果工具无法满足时序约束,设计者可能需要:

  • 尝试不同的综合或布局布线选项。
  • 手动优化报告中的违规路径(如简化逻辑、拆分长组合路径、插入流水线寄存器)。
  • 对于保持时间违规,增加逻辑延迟。

设计原则

为了满足时序约束并获得高性能,应遵循以下设计原则:

  • 关键路径设计:最小化最大逻辑延迟,以最大化性能。
  • 平衡设计:平衡系统中不同部分之间的逻辑延迟,避免个别路径过长限制整体时钟频率。
  • 优化常见情况:针对最可能发生的场景进行优化,同时确保非常见情况不会破坏设计。

总结

本节课中我们一起学习了:

  1. 组合电路时序:污染延迟和传播延迟的定义与计算,以及毛刺的成因和处理。
  2. 时序电路时序:建立时间、保持时间的概念,以及如何推导和满足建立时间、保持时间约束。
  3. 时钟偏移:时钟偏移对时序约束的影响。
  4. 功能验证:测试平台的作用、类型(简单、自检查、自动),以及黄金模型策略。
  5. 时序验证:基本方法、EDA工具的作用以及修复时序违规的常用技术。
  6. 设计原则:关键路径优化、平衡设计和优化常见情况。

时序分析和验证是确保数字电路在实际硬件中正确可靠工作的关键环节。从下周开始,我们将进入计算机架构部分,学习微处理器的基础知识。

7:冯·诺依曼模型与指令集架构 (Spring 2025)

概述

在本节课中,我们将学习计算机架构的基础部分。我们将从数字设计层面向上提升,讨论冯·诺依曼计算模型,然后深入探讨指令集架构。我们将以LC3和MIPS这两种架构为例,理解计算机如何执行程序,并为后续学习微架构打下基础。


冯·诺依曼模型 🧠

上一节我们完成了数字设计部分的学习。本节中,我们将介绍一个更高层次的抽象——冯·诺依曼模型。这是现代通用计算机的基础模型。

冯·诺依曼模型由约翰·冯·诺依曼及其同事在1946年提出,包含五个核心组件:

  1. 内存:存储程序和数据。
  2. 处理单元:执行计算。
  3. 输入单元:从外部接收信息。
  4. 输出单元:向外部发送信息。
  5. 控制单元:协调所有操作,控制指令执行顺序。

所有现代通用计算机都基于此模型。它有两个关键特性:

  • 存储程序:指令和数据存储在同一个内存中,没有物理区分。
  • 顺序指令处理:指令通常按顺序一条接一条地执行。

内存详解 💾

我们已经了解了冯·诺依曼模型的整体框架。现在,让我们深入看看其中的第一个核心组件:内存。

内存存储着计算机运行所需的一切——程序指令和待处理的数据。在硬件层面,它就是一个由存储单元组成的阵列。

以下是关于内存的几个核心概念:

  • 地址空间:内存中可唯一寻址的位置总数。例如,LC3的地址空间是 2^16,MIPS是 2^32
  • 可寻址性:每个地址对应的存储单元能存储多少位数据。常见的是字节可寻址(每个地址存8位)或字可寻址(每个地址存一个字,如16位或32位)。
  • 字节序:当内存是字节可寻址,但数据以多字节(如字)形式存储时,字节的排列顺序就很重要。
    • 大端序:最高有效字节存储在最低地址。
    • 小端序:最低有效字节存储在最低地址。

内存访问通过两个基本操作完成:

  • 读/加载:从指定内存地址获取数据。
  • 写/存储:将数据写入指定内存地址。

在硬件实现上,通常使用内存地址寄存器内存数据寄存器 来辅助完成这些操作。


处理单元与寄存器 ⚙️

上一节我们介绍了内存,它是数据的仓库。本节我们来看看处理单元,它是数据的加工厂。

处理单元的核心是算术逻辑单元,它执行如加法、逻辑与等运算。ALU处理的数据单位通常是

然而,如果ALU每次运算都要从较慢的主内存中读取数据,效率会极低。因此,处理单元内部包含一个快速临时存储区,即寄存器文件

寄存器是少量、高速的存储单元,紧邻ALU,用于存放频繁使用的临时数据和中间计算结果。对寄存器的访问速度远快于对主内存的访问。

以下是两种架构的寄存器示例:

  • LC3:有8个通用寄存器(R0-R7),每个寄存器16位。
  • MIPS:有32个通用寄存器(R0-R31),每个寄存器32位。

指令可以直接对寄存器中的数据进行操作,这是编写高效程序的关键。


控制单元与指令处理周期 🎛️

我们已经了解了内存(存储)和处理单元(计算)。现在,我们需要一个“指挥家”来协调它们,这就是控制单元。

控制单元负责按顺序执行程序中的每一条指令。它通过两个关键寄存器来跟踪执行状态:

  • 程序计数器:存放下一条要执行的指令的内存地址。
  • 指令寄存器:存放当前正在执行的指令的编码。

指令的执行并非一蹴而就,而是分为一个循环的多个阶段,即指令处理周期。一个典型的周期包括以下阶段(并非所有指令都需全部阶段):

  1. 取指:根据PC从内存获取指令,放入IR,并更新PC。
  2. 译码:解析IR中的指令,确定要执行的操作和所需的操作数。
  3. 地址计算:(如果需要访问内存)计算要访问的内存地址。
  4. 取操作数:从寄存器或内存中获取指令所需的源数据。
  5. 执行:在ALU中执行指定的运算。
  6. 写回:将执行结果写回寄存器或内存。

完成一个周期后,控制单元便启动下一个指令的取指阶段,如此循环。


指令集架构基础 📜

前面我们介绍了计算机执行指令的模型和流程。现在,我们来具体看看指令本身——即指令集架构。

指令是计算机能理解和执行的最小工作单元。指令集架构 是计算机支持的所有指令的集合,它定义了软件与硬件之间的契约。

一条指令通常由两部分组成:

  • 操作码:指定指令要执行的操作(如加、减、加载)。
  • 操作数:指定操作的对象(如寄存器编号、内存地址、立即数)。

指令在计算机中以机器码(二进制串)的形式存储。为了方便人类读写,我们使用汇编语言,它是机器码的助记符表示。

例如,一条LC3的加法指令在汇编中可能写作 ADD R0, R1, R2,其机器码编码可能是 0001 000 001 000 010(二进制),其中 0001 是ADD的操作码,后续位分别指定了目标寄存器R0和源寄存器R1、R2。


指令类型与编码示例 🔤

ISA中的指令主要分为三大类,我们将逐一了解。

1. 运算指令

这类指令指示ALU执行算术或逻辑运算,操作数通常来自寄存器。

  • LC3示例ADD R0, R1, R2 (R0 ← R1 + R2)
    • 操作码 0001,后跟寄存器编号字段。
  • MIPS示例add $s0, $s1, $s2
    • 属于R型指令格式,操作码为 000000,具体操作由功能码字段指定。

2. 数据传送指令

这类指令用于在寄存器和内存之间移动数据。

  • 加载示例:从内存读取数据到寄存器。
    • LC3LDR R3, R0, #2 (计算地址 R0+2,从该内存地址加载数据到R3)
    • MIPSlw $s3, 8($s0) (计算地址 \(s0+8,从该内存地址加载一个字到\)s3。注意在字节可寻址的MIPS中,偏移量8对应第2个字)
  • 存储指令与之相反,将寄存器数据写入内存。

3. 控制流指令

这类指令改变程序正常的顺序执行流程,通过修改程序计数器的值来实现跳转。

  • 无条件跳转
    • LC3JMP R2 (PC ← R2)
    • MIPSj target (使用J型指令格式,跳转到目标地址)
  • 条件分支:(后续会详细讨论)根据条件判断是否跳转,如 BEQ(相等则分支)。

不同的指令类型有不同的指令格式(编码方式),例如MIPS有R型(寄存器)、I型(立即数)、J型(跳转)等格式,以适应不同的操作数需求。


总结

本节课我们一起学习了计算机架构的核心基础。

我们首先探讨了冯·诺依曼模型,理解了其五大组件(内存、处理单元、输入、输出、控制单元)和两个关键特性(存储程序、顺序执行)。

接着,我们深入剖析了内存的寻址方式与字节序,处理单元中ALU与高速寄存器的作用,以及控制单元如何通过指令处理周期来协调工作。

最后,我们学习了指令集架构的概念,了解了指令的组成(操作码与操作数),并认识了三种主要的指令类型:运算指令数据传送指令控制流指令,还通过LC3和MIPS的例子看到了指令的具体编码格式。

这些知识构成了我们理解计算机如何运行程序的基石。在接下来的课程中,我们将以此为基础,学习如何用汇编语言编程,并最终探索如何在微架构层面实现这些ISA。

8:指令集架构 II (Spring 2025) 🧠

在本节课中,我们将继续深入学习指令集架构。我们将回顾指令处理周期,并详细探讨操作指令、数据移动指令和控制流指令。我们还将讨论数据寻址模式、数据类型以及指令集设计中的关键权衡。


指令处理周期回顾

上一节我们介绍了冯·诺依曼模型和指令处理的基本概念。本节中,我们来看看指令执行的具体周期。

指令执行遵循一个固定的处理周期,包含多个阶段:

  1. 取指:从内存中读取下一条指令。
  2. 译码:解析指令,确定其操作类型和操作数。
  3. 计算地址:对于需要访问内存的指令,计算有效地址。
  4. 取操作数:从寄存器或内存中获取操作数。
  5. 执行:在ALU中执行指定的操作。
  6. 存储结果:将结果写回寄存器或内存。

完成一个指令后,程序计数器更新,指向下一条指令,循环重新开始。这种顺序执行是冯·诺依曼机器的核心特征。

在取指阶段,从内存中读取的值被解释为指令。而在取操作数阶段,从内存中读取的值则被解释为数据。内存地址可能相同,但解释方式取决于处理周期所处的阶段。


无条件跳转指令

控制流指令可以改变指令执行的顺序。我们首先来看无条件跳转指令。

无条件跳转指令(如LC-3中的JMP)会直接将程序计数器的值更新为指定寄存器中的值。其语义可以表示为:

PC ← BaseRegister

在微架构层面,这需要一条从寄存器文件到程序计数器的数据通路。


指令集架构详解

指令集架构是软件程序与硬件微架构之间的接口。它具体规定了以下内容:

  • 内存组织:地址空间和可寻址性(如按字或按字节寻址)。
  • 寄存器:通用寄存器的数量(如LC-3有8个,MIPS有32个)。
  • 指令集:由操作码、数据类型和寻址模式定义。
  • 指令格式:指令的长度、格式和编码。

操作码

操作码的数量是ISA设计中的一个关键权衡。

  • 复杂指令集:包含大量、功能强大的指令(如矩阵乘法)。这简化了编译器和编程,但增加了硬件设计的复杂性。
  • 精简指令集:只包含少量、基本的指令(如加、与、移位)。这简化了硬件设计,但可能增加软件(编译器或程序员)的负担,需要将高级操作分解为多条基本指令。

LC-3和MIPS主要包含三类指令:操作指令数据移动指令控制流指令

数据类型

数据类型定义了如何解释寄存器或内存中的数据。

  • LC-3:主要支持二进制补码整数。负数-x定义为~x + 1
  • MIPS:支持二进制补码整数、无符号整数和浮点数。

支持更多数据类型可以更好地将高级语言结构映射到硬件,减少代码大小,但同样会增加硬件的复杂性。这个概念与语义鸿沟相关:指令和数据类型越接近高级语言,语义鸿沟越小。

寻址模式

寻址模式指定了操作数所在的位置。以下是常见的寻址模式:

  • 立即数寻址:操作数直接编码在指令中。
  • 寄存器寻址:操作数在通用寄存器中。
  • PC相对寻址:操作数地址是PC + 偏移量
  • 间接寻址:指令中的地址指向一个内存单元,该单元的内容才是最终的操作数地址。
  • 基址+偏移寻址:操作数地址是基址寄存器 + 偏移量

更多的寻址模式可以更灵活地表达数据访问(如数组索引、指针追踪),减少指令条数,但同样会使硬件数据通路和控制逻辑变得更复杂。


操作指令详解

操作指令对数据进行算术或逻辑运算。

寄存器模式操作

例如,LC-3中的ADD指令格式为:

ADD DR, SR1, SR2

其操作是DR ← SR1 + SR2。硬件上,这需要从寄存器文件读取两个源操作数,经ALU计算后,写回目的寄存器。

立即数模式操作

许多指令支持立即数版本。例如,LC-3中通过指令中的一位“导向位”来区分是使用第二个寄存器还是立即数。

ADD DR, SR1, #5 ; 使用立即数5

硬件需要一个多路选择器,根据导向位选择来自寄存器文件或来自指令经符号扩展后的立即数作为ALU的第二个输入。

MIPS有专门的I型指令格式用于立即数操作,如ADDI

减法操作的实现

减法指令展示了指令复杂度的权衡。

  • MIPS:有专门的SUB指令,一条指令完成减法。
  • LC-3:没有SUB指令。减法需要通过取反加一来实现,即 A - B = A + (~B + 1)。这需要多条指令(NOT, ADD)。

复杂指令(如SUB)编码密度高,程序更紧凑。简单指令(NOT, ADD)硬件控制逻辑更简单,但程序需要更多指令。


数据移动指令详解

数据移动指令在寄存器和内存之间传输数据。

PC相对寻址

例如LC-3中的LD(加载)指令:

LD DR, Label ; DR ← M[PC + offset]

地址计算为PC + 符号扩展的9位偏移量。这种模式只能访问指令附近有限范围(-256到+255)的内存地址。

间接寻址

例如LC-3中的LDI(间接加载)指令:

LDI DR, Label ; DR ← M[M[PC + offset]]

它进行两次内存访问:首先计算地址A1=PC+offset,读取M[A1]得到地址A2,然后读取M[A2]得到最终数据。这对于指针追踪非常有用,但硬件上需要多个状态来执行两次内存读操作。

基址+偏移寻址

例如LC-3中的LDR和MIPS中的LW

LDR DR, BaseR, #6 ; DR ← M[BaseR + 6]
LW  Rt, 8(Rs)     ; Rt ← M[Rs + 8] (MIPS,字节寻址,偏移量需乘以4)

这种模式可以访问任意内存地址,只要将基地址加载到寄存器中即可。它是MIPS中主要的数据内存寻址模式。

加载有效地址

例如LC-3中的LEA指令:

LEA DR, Label ; DR ← PC + offset

它不访问内存,只是将计算出的地址(PC相对)直接加载到寄存器中,常用于初始化指针。

MIPS中使用LUI(加载高位立即数)和ORI指令组合来加载32位常数到寄存器。


控制流指令详解

控制流指令用于实现条件分支、循环和函数调用。

条件码

LC-3和x86等ISA使用条件码。每次写入通用寄存器时,会根据写入值的正负零设置三个1位的条件码寄存器(N, Z, P)。

条件分支指令(如BRz)检查特定的条件码:

BRz Label ; if (Z == 1) PC ← PC + offset

硬件上,需要根据指令中要测试的条件码位和当前条件码寄存器的值,生成一个“分支跳转”信号,以决定是否更新PC为目标地址。

寄存器比较分支

MIPS采用不同的方式。它使用像BEQ这样的指令,直接比较两个寄存器的值:

BEQ Rs, Rt, Label ; if (Rs == Rt) PC ← PC + offset

这需要硬件直接比较两个寄存器的值。虽然单条指令功能更强,但硬件上需要额外的比较电路。

实现相同的“如果寄存器相等则分支”功能,在LC-3中需要先用NOTADD指令计算差值并设置条件码,再用BRz指令,共需4条指令。这再次体现了复杂指令与简单指令之间的权衡。


总结

本节课我们一起深入探讨了指令集架构的多个核心方面。

我们回顾了指令处理周期,并详细学习了三类主要指令:操作指令(包括立即数模式)、数据移动指令(涵盖PC相对、间接、基址+偏移等多种寻址模式)以及控制流指令(包括条件码和寄存器比较两种分支实现方式)。

我们重点讨论了ISA设计中的关键权衡:操作码的复杂度支持的数据类型寻址模式的数量。这些选择直接影响语义鸿沟的大小,进而决定了硬件复杂度和软件复杂度之间的平衡。复杂指令集(CISC)倾向于更接近软件,简化编译但硬件复杂;精简指令集(RISC)倾向于更接近硬件,简化硬件设计但可能增加软件负担。

理解这些基本概念和权衡,是学习计算机架构中硬件-软件接口的关键。

9:ISA与微架构(Spring 2025)

概述

在本节课中,我们将学习指令集架构与微架构的核心概念、它们之间的区别与联系,以及计算机设计中的关键权衡。我们将探讨冯·诺依曼模型、数据流执行模型,并开始了解如何从硬件层面实现指令集。


ISA与微架构:核心概念与权衡

上一节我们介绍了指令集架构的基本元素。本节中,我们来看看ISA与微架构之间的根本区别。

指令集架构是软件与硬件之间约定的接口。它规定了程序员可见的状态和行为,包括指令、数据类型、寻址模式和寄存器等。ISA是抽象的规范,定义了计算机应如何运行程序。

微架构是ISA的具体硬件实现。它描述了底层硬件如何组织数据流、控制逻辑以及物理设计,以执行ISA定义的指令。微架构对软件程序员是不可见的。

关键权衡:指令复杂度

ISA设计中的一个核心权衡是指令的复杂度

  • 复杂指令集:指令功能强大,每条指令能完成较多工作(如矩阵乘法)。这缩小了高级语言与硬件之间的语义鸿沟,软件编程和编译更简单,但将优化负担转移给了硬件设计师。
  • 简单指令集:指令是接近硬件门电路的低级原语(如与、或、非操作)。这扩大了语义鸿沟,软件(编译器)需要做更多工作将高级操作映射到简单指令,但为硬件设计提供了极大的优化空间。

公式化描述:语义鸿沟 G = L_high - L_ISA,其中 L_high 代表高级语言抽象级别,L_ISA 代表ISA抽象级别。G 越小,软件负担越轻,硬件负担越重。

间接层级原则

任何计算机科学或软件工程中的问题,都可以通过增加一个间接层级来解决。

这个原则允许我们改变设计中的权衡。例如,可以设计一个硬件,它对外暴露一个复杂的ISA(如x86),但在内部,通过一个硬件辅助的软件翻译器,将复杂指令翻译成一套更简单的、内部的“微操作”来执行。这样,既保持了软件兼容性,又简化了硬件设计。Apple Silicon(M系列芯片)运行x86程序、以及历史上Transmeta公司的Crusoe处理器都采用了类似思想。

代码示例(概念性)

// 对外ISA:复杂指令
x86_instruction_t complex_instr = fetch_instruction();
// 内部翻译层
micro_op_t simple_ops[] = translate(complex_instr);
// 微架构执行简单微操作
for (op in simple_ops) {
    execute_micro_op(op);
}

超越冯·诺依曼:数据流执行模型

上一节我们固守了冯·诺依曼的顺序控制流模型。本节中,我们来看看一个完全不同的计算模型:数据流模型

冯·诺依曼模型的核心是顺序指令处理程序计数器。指令按控制流顺序(顺序或分支)获取和执行。

数据流模型则没有程序计数器。指令的执行顺序由数据可用性决定。一条指令在其所有输入操作数的数据都就绪(即收到“令牌”)时,才会被“触发”执行。执行结果作为新的数据令牌,传递给等待它的后续指令。

数据流示例:计算阶乘

考虑一个计算 n! 的数据流程序图。它由多个节点(指令)和弧(数据依赖)组成:

  1. 比较节点:接收 n,判断 n > 0,输出布尔令牌。
  2. 条件分支节点:根据布尔令牌,决定将 n 路由到乘法路径或终止路径。
  3. 乘法节点:在 n > 0 时,计算 accumulator * n
  4. 递减节点:在 n > 0 时,计算 n - 1 并反馈回比较节点。
  5. n <= 0 时,布尔令牌为假,当前累积结果被路由到输出。

这个模型天然支持并行。当多个指令的输入数据同时就绪时,它们可以同时触发执行。

控制流 vs. 数据流:微架构视角

在ISA层面,现代通用计算机几乎都采用冯·诺依曼模型(控制流),因为它对程序员更友好。

然而,在微架构层面,几乎所有高性能处理器都内部采用了类似数据流的执行方式。它们通过乱序执行等机制,动态分析指令间的数据依赖关系,只要操作数就绪就立刻执行,而不严格遵循程序顺序,从而极大提升了性能。这再次体现了间接层级原则:ISA承诺顺序执行,微架构内部采用数据流并行。


微架构设计入门

上一节我们比较了不同的执行模型。本节中,我们开始探讨如何具体实现一个ISA,即微架构设计。

微架构是在特定设计约束和目标下,对ISA的实现。设计约束可能包括性能、功耗、成本、上市时间等。

ISA vs. 微架构:属性划分

以下是判断一个属性属于ISA还是微架构的简单方法:

  • 属于ISA:程序员必须知道才能正确编写/调试程序的属性。

    • 指令操作码(如 ADD
    • 通用寄存器数量(如32个)
    • 程序计数器(PC)
    • 内存寻址模式
  • 属于微架构:不影响程序正确性,仅影响性能、功耗等的硬件实现细节。

    • ALU中使用的加法器类型(如超前进位加法器)
    • 执行乘法指令所需的时钟周期数
    • 寄存器文件的读写端口数量
    • 是否采用流水线技术

单周期与多周期微架构

我们考虑两种最基本的实现方式:

  1. 单周期微架构
    • 思想:每条指令在一个时钟周期内完成。从读取程序员状态(AS)到生成新状态(AS‘)的组合逻辑路径必须在单个周期内走完。
    • 关键路径公式T_cycle >= T_clk-q + T_combinational_max + T_setup
    • 缺点:时钟周期长度由最复杂指令(如乘法、除法)的组合逻辑延迟决定。为了执行一条慢指令,所有指令(包括简单的加法)都必须等待同样长的周期,效率极低。

  1. 多周期微架构
    • 思想:将一条指令的执行分解为多个步骤(阶段),每个步骤在一个较短的时钟周期内完成。例如,经典的5阶段流水线:取指、译码、执行、访存、写回。
    • 优点:时钟周期由最慢的阶段决定,而不是最慢的指令。简单指令可以快速通过某些阶段,复杂指令则占用多个周期。硬件利用率更高。
    • 核心原则:在指令执行过程中,可以更新内部的、程序员不可见的微架构状态,但程序员可见的架构状态(寄存器、内存)只在指令执行结束时才被更新,以遵守ISA语义。

代码示例(多周期概念)

// 状态寄存器
reg [31:0] PC, RegFile[31:0];
// 内部临时寄存器(微架构状态)
reg [31:0] IR, ALUOut, MDR;

always @(posedge clk) begin
    case (state)
        FETCH:  begin IR <= Memory[PC]; state <= DECODE; end
        DECODE: begin A <= RegFile[rs]; B <= RegFile[rt]; state <= EXECUTE; end
        EXECUTE: begin ALUOut <= A + B; state <= (is_load_store) ? MEM : WB; end
        MEM:    begin MDR <= Memory[ALUOut]; state <= WB; end
        WB:     begin RegFile[rd] <= (is_load) ? MDR : ALUOut; PC <= PC + 4; state <= FETCH; end
    endcase
end

总结

本节课中我们一起学习了:

  1. ISA与微架构的根本区别:ISA是软件可见的契约,微架构是实现契约的硬件细节。
  2. 关键的设计权衡,特别是指令复杂度带来的语义鸿沟问题,以及通过间接层级(如硬件翻译)来改变权衡。
  3. 数据流执行模型作为一种替代冯·诺依曼顺序模型的计算范式,它基于数据可用性触发指令,具有天然并行性,并且是现代高性能处理器微架构(乱序执行)的内部指导思想。
  4. 微架构设计的起点:单周期多周期实现的基本思想及其优缺点。多周期设计通过将指令执行分阶段,避免了慢指令拖累所有指令,是更实用高效的设计起点。

在接下来的课程中,我们将深入多周期微架构的设计,并逐步向流水线、乱序执行等更先进的微架构技术迈进。

9b:汇编语言编程

在本节课中,我们将学习汇编语言编程的基础知识。我们将探讨如何使用LC3和MIPS汇编语言来实现基本的编程结构,如条件语句、循环和函数调用。课程将涵盖从算法流程图到实际机器指令的转换过程,并介绍调试程序的基本方法。

从算法到汇编指令

上一节我们介绍了计算机的基本架构和指令执行周期。本节中,我们来看看如何将一个具体的算法转化为可执行的汇编程序。

我们以一个简单的任务为例:将存储在内存地址 0x31000x310B 的12个整数相加。解决此类问题的第一步是将问题转化为算法。

以下是该算法的流程图,它已经是一个相当底层的描述,因为我们即将用汇编语言编程:

如图所示,我们甚至预先分配了寄存器:

  • R1 存储第一个整数的起始地址 (0x3100)。
  • R2 存储剩余待加整数的数量。
  • R3 存储累加的最终结果。

算法流程如下:

  1. 初始化:设置起始地址、计数器(R2=12)和累加器(R3=0)。
  2. 条件检查:检查 R2 是否等于 0(即是否还有整数要加)。
  3. 循环体:如果 R2 不等于 0,则:
    • 根据 R1 中的地址加载一个整数到临时寄存器。
    • 将该整数值累加到 R3。
    • 将地址指针 R1 加 1,指向下一个整数。
    • 将计数器 R2 减 1。
  4. 循环:无条件跳转回步骤2的条件检查处。
  5. 结束:当 R2 等于 0 时,跳出循环,程序结束(例如,将结果输出到屏幕)。

翻译为 LC3 汇编程序

下一步是将流程图中的每一步翻译成目标指令集架构(ISA)中的具体指令。对于LC3,程序如下:

程序解释:

  • LEA R1, #-7LDW R1, R1, #0 共同作用,将地址 0x3100 加载到 R1。
  • AND R3, R3, #0AND R2, R2, #0 后跟 ADD R2, R2, #12 用于初始化 R3(累加器)和 R2(计数器)。
  • BRz #4 是条件分支指令,检查 R2 是否为零(Z标志)。如果为零,则跳过后续4条指令,直接到 HALT,结束循环。
  • 循环体内,LDB R0, R1, #0 从 R1 指向的地址加载一个字节到 R0。
  • ADD R3, R3, R0 进行累加。
  • ADD R1, R1, #1 递增地址指针。
  • ADD R2, R2, #-1 递减计数器。
  • BRnzp #-6 是无条件分支指令,跳回 BRz 指令处,开始下一次循环检查。

程序的关键在于正确使用条件分支来实现循环控制。你需要确保分支跳转到正确的位置(这里是循环的入口检查点,而非循环体中间)。

编程的基本结构

现在,让我们提高抽象层次,讨论编程本身。编程的核心是将任务分解为更小的子任务,并用编程结构来代表这些子任务。

有三种基本的编程结构:

  1. 顺序结构:子任务一个接一个顺序执行。
  2. 条件结构:根据某个条件的真假,选择执行两个子任务中的一个(或不执行)。这用于实现 if-elseswitch-case 语句。
  3. 迭代结构:只要某个条件为真,就重复执行一个子任务。这用于实现循环,如 forwhile 循环。

我们来看一个使用了所有三种结构的示例程序:统计一个文本文件中某个特定字符出现的次数。

程序概要:

  • 顺序结构:初始化计数器、指针等。
  • 迭代结构:循环读取文件中的每个字符,直到遇到表示文件结束的哨兵字符(EOT)。
  • 条件结构:在循环内,检查读取的字符是否与目标字符匹配,如果匹配则增加计数器。

程序还会使用 TRAP 指令 与操作系统交互,例如从键盘获取输入(TRAP x23)或将结果输出到显示器(TRAP x21)。TRAP 指令的操作码是 1111,其后的陷阱向量(Trap Vector)指定了请求的操作系统服务类型。

调试汇编程序

调试是编程中不可或缺的一环,对于汇编语言尤其重要。调试是发现和修正程序中错误的过程,通常涉及跟踪指令执行序列和检查每条指令的结果。

有效的调试策略包括:

  • 模块化检查:将程序分成模块,分别检查每个模块的结果。
  • 交互式调试操作
    • 设置/检查寄存器和内存值:在程序执行前或中断后,查看或修改状态。
    • 单步执行:一次执行一条指令,观察每条指令的影响。
    • 设置断点:在特定指令处暂停执行,以便检查此时的状态。
    • 运行至暂停:执行程序直到遇到 HALT 指令或断点。

考虑一个LC3中的乘法程序示例(因为LC3没有乘法指令)。假设一个程序意图计算 R4 * R5,初始值 R4=10,R5=3,但错误地输出了40。通过单步执行或设置断点,并记录每条指令执行后的寄存器状态,我们可以发现错误:循环的边界条件检查有误,导致多执行了一次迭代。正确的分支条件应该是检查 R5 是否为正(BRp),而不是检查是否为零(BRz)。此外,程序也未处理 R5 初始值为 0 的边界情况。

一个好的测试应涵盖各种边界情况(即程序员可能忽略的特殊值)。

MIPS 汇编中的条件语句和循环

现在,我们快速了解MIPS汇编中如何实现条件语句和循环,这在后续实验中会用到。其原理与LC3类似,但语法不同。

实现 if 语句
高级代码:if (i == j) f = g + h;
MIPS汇编的一种实现方式是使用 bne(Branch if Not Equal)指令来跳过 if 块:

    bne $s3, $s4, L1   # 如果 i != j,跳转到 L1 标签
    add $s0, $s1, $s2 # f = g + h (if 块)
L1: ...

实现 if-else 语句
需要结合条件分支和无条件跳转。条件分支跳过 if 块,执行 else 块;if 块末尾的无条件跳转则用于跳过 else 块。

    bne $s3, $s4, Else  # 如果 i != j,跳转到 Else
    add $s0, $s1, $s2   # if 块: f = g + h
    j Exit              # 跳过 else 块
Else:
    sub $s0, $s0, $s3   # else 块: f = f - i
Exit: ...

实现 while 循环
结构类似于LC3:在循环开始处检查条件,条件分支用于退出循环,循环体末尾的无条件分支用于跳回条件检查处。

Loop:
    beq $s0, $t0, Exit  # 检查循环条件,不满足则退出
    ...                 # 循环体
    j Loop              # 跳回循环开始
Exit: ...

实现 for 循环:与 while 循环在本质上非常相似。

MIPS 还提供了 slt(Set Less Than)这样的指令,可以将比较结果(真/假)直接设置到通用寄存器中,而不是条件码寄存器,这为实现条件判断提供了另一种灵活的方式。

数组访问与函数调用

数组访问
在MIPS中访问数组元素,需要先将数组基地址加载到一个寄存器中。由于MIPS指令中立即数位宽限制,加载32位地址通常需要两条指令:lui(Load Upper Immediate)和 ori(Or Immediate)。之后,通过基地址加偏移(偏移量 = 索引 * 每个元素占用的字节数)来计算元素地址,并使用 lw(Load Word)或 sw(Store Word)指令进行存取。

函数调用
函数(或过程)是代码复用和模块化的关键。调用函数的程序称为调用者,被调用的函数称为被调用者

为了使函数调用能正确、协作地工作,架构定义了调用约定:

  • 跳转与链接:MIPS使用 jal(Jump and Link)指令调用函数。该指令不仅跳转到目标函数,还将下一条指令的地址(返回地址)保存在专用寄存器 $ra 中。
  • 参数传递:前几个参数通常通过寄存器传递(例如MIPS的 $a0 - $a3)。
  • 返回值:返回值通常放在指定寄存器中(例如MIPS的 $v0)。
  • 返回:被调用函数执行完毕后,使用 jr $ra(Jump Register)指令跳回调用者。

栈的使用
当函数调用嵌套时,或者被调用函数需要使用调用者可能正在使用的寄存器时,就需要用来保存和恢复现场。栈是一种后进先出的内存区域。

  • 调用者保存:如果调用者希望在函数调用后某些寄存器的值保持不变,它需要在调用前将这些值压入栈中,调用后再恢复。
  • 被调用者保存:根据约定,某些寄存器(如MIPS的 $s0 - $s7)是被调用者必须保存和恢复的。如果被调用者要使用它们,必须先将旧值压栈,并在返回前弹栈恢复。寄存器 $ra 也属于此类,如果一个函数内部还要调用其他函数,它必须保存自己的 $ra
  • 栈指针:寄存器 $sp 作为栈指针,指向栈顶。

通过遵守这些关于寄存器用途和栈管理的约定,不同程序员编写的代码才能无缝协作。

总结

本节课中我们一起学习了汇编语言编程的核心概念。我们从将一个简单的求和算法转化为LC3汇编程序开始,理解了顺序、条件和迭代这三种基本编程结构在汇编层面的实现。我们探讨了调试汇编程序的重要性和基本方法。接着,我们对比学习了在MIPS汇编中实现条件语句和循环的语法。最后,我们介绍了函数调用的机制,包括调用约定、jal/jr 指令的作用,以及栈在保存寄存器现场、支持嵌套函数调用中的关键角色。这些知识是理解高级语言如何与底层硬件交互,以及进行系统级编程的基础。

10:微架构基础与设计 II (Spring 2025) 🧠

在本节课中,我们将继续学习微架构设计。我们将从单周期处理器开始,逐步构建其数据通路和控制逻辑,分析其性能瓶颈,并最终引出多周期处理器的核心思想。


概述

上一节我们介绍了微架构的基本概念。本节中,我们将深入探讨如何为一个简化的MIPS ISA子集设计和实现一个单周期处理器。我们将构建其数据通路,设计控制逻辑,并分析其性能。最后,我们将看到单周期设计的局限性,并引入多周期处理器的概念。


单周期处理器设计原理

单周期处理器意味着每条指令在一个时钟周期内完成执行。指令执行本身仅使用组合逻辑实现,没有中间的程序状态更新。

  • 架构状态:在每个时钟周期开始时,我们有一个架构状态(程序计数器PC、通用寄存器、内存)。
  • 指令处理:在一个时钟周期内,我们处理一条指令。
  • 状态更新:在周期结束时,我们得到更新后的架构状态,作为下一条指令的输入。

这种设计的时钟周期时间由处理给定指令的关键路径决定。最慢的指令决定了整个处理器的时钟周期时间,这通常会导致很长的周期时间,效率低下。


微架构的组成部分

一个指令处理引擎由两个主要部分组成:数据通路控制逻辑

数据通路

数据通路是处理数据信号的硬件元素集合。它包括:

  • 功能单元:对数据进行操作的硬件,例如算术逻辑单元。
  • 存储单元:存储数据的硬件,如寄存器文件、内存。
  • 连接元件:实现数据流动的硬件,如导线、多路选择器、解码器、三态缓冲器等。

控制逻辑

控制逻辑生成控制信号,告诉数据通路中的各个元件如何处理数据。例如,控制信号决定多路选择器选择哪个输入,或者是否向寄存器文件写入数据。

在单周期机器中,控制信号的生成和数据路径的操作发生在同一个时钟周期内。而在多周期机器中,我们可以在当前周期生成下一个周期所需的控制信号,从而实现一定程度的并行。


构建单周期数据通路

我们将为MIPS ISA的一个子集逐步构建数据通路。以下是构建过程中使用的基本模块符号:

  • 程序计数器:一个带写使能的寄存器,在每个时钟上升沿捕获输入值。
  • 寄存器文件:一个多端口存储结构。在我们的设计中,它可以同时读取两个寄存器(使用5位寄存器ID),并写入一个寄存器(32位数据)。写操作在时钟上升沿发生,由RegWrite信号控制。
  • 内存:我们假设有两种内存。
    • 指令内存:单端口,只读。输入32位地址,输出32位指令。
    • 数据内存:单端口,可读写。由MemReadMemWrite信号控制。输入地址和待写入的数据(32位),输出读取的数据(32位)。

我们假设这些存储单元都是“单周期”的,即在一个时钟周期内完成读写操作。这是一个简化且不现实的假设,但有助于我们理解基本设计。

指令处理步骤

一条指令的处理通常包含以下步骤,在单周期机中这些步骤在一个周期内顺序(或并行)完成:

  1. 取指:从程序计数器指向的地址获取指令。
  2. 译码:解析指令,确定操作类型和所需的寄存器。
  3. 读寄存器:从寄存器文件中读取操作数。
  4. 执行:在ALU中执行操作(如算术运算、计算内存地址)。
  5. 访存:如果需要,访问数据内存(加载或存储)。
  6. 写回:将结果写回寄存器文件。

为R型算术/逻辑指令构建数据通路

add指令为例:add $rd, $rs, $rt

  • 语义:将寄存器$rs$rt的值相加,结果存入寄存器$rd。同时,程序计数器PC增加4。
  • 数据通路需求
    1. PC作为地址输入指令内存,取出指令。
    2. 指令中的rsrt字段(位25-21和20-16)作为地址输入寄存器文件,读取两个操作数。
    3. 两个操作数送入ALU,ALU功能设置为“加”。
    4. ALU结果送入寄存器文件的写数据端口。
    5. 指令中的rd字段(位15-11)作为目的寄存器地址。
    6. 设置RegWrite = 1,在周期结束时将结果写入$rd
    7. 通过一个独立的加法器计算PC + 4,并在周期结束时更新PC。

为I型算术/逻辑指令扩展数据通路

addi指令为例:addi $rt, $rs, immediate

  • 语义:将寄存器$rs的值与符号扩展后的立即数相加,结果存入寄存器$rt
  • 数据通路修改
    • 需要一个符号扩展单元,将16位立即数扩展为32位。
    • 需要一个多路选择器,在ALU的第二个输入端口选择来自寄存器文件的数据(用于R型)或符号扩展后的立即数(用于I型)。由控制信号ALUSrc控制。
    • 目的寄存器地址来自指令的rt字段(位20-16),而非rd字段。因此需要另一个多路选择器来选择目的寄存器地址是来自rt字段(I型)还是rd字段(R型)。由控制信号RegDst控制。

为加载/存储指令扩展数据通路

lw(加载字)和sw(存储字)指令为例。

  • 地址计算lw $rt, offset($rs)sw $rt, offset($rs)都需要计算内存地址:地址 = $rs + SignExtend(offset)。这可以利用I型指令的ALU通路完成(ALUSrc选择立即数,ALU做加法)。
  • 加载指令
    1. 计算出的地址送入数据内存的地址端口。
    2. 设置MemRead = 1,从内存读取数据。
    3. 读取的数据需要写回寄存器$rt。因此,在寄存器文件的写数据输入端前需要增加一个多路选择器,选择数据是来自ALU结果(用于算术指令)还是来自内存(用于加载指令)。由控制信号MemtoReg控制。
  • 存储指令
    1. 同样计算内存地址。
    2. 需要将寄存器$rt的值写入内存。因此,从寄存器文件读取的第二个操作数(对应$rt)需要连接到数据内存的写数据端口。
    3. 设置MemWrite = 1
    4. 存储指令不写寄存器文件,因此RegWrite = 0

为跳转指令扩展数据通路

j(无条件跳转)指令为例。

  • 语义:将PC更新为跳转目标地址。目标地址由当前PC+4的高4位与指令中的26位立即数(左移2位并补0)拼接而成。
  • 数据通路修改
    • 需要硬件来拼接跳转目标地址。
    • PC的更新源不再仅仅是PC+4,还可能是跳转地址。因此,在PC的输入端需要一个多路选择器来选择是PC+4还是跳转目标地址。由控制信号PCSrc(或Jump)控制。
    • 执行跳转指令时,需确保对数据通路其他部分“无害”(不写寄存器,不读写内存)。

为条件分支指令扩展数据通路

beq(相等则分支)指令为例:beq $rs, $rt, offset

  • 语义:如果$rs == $rt,则PC = PC+4 + SignExtend(offset)<<2;否则PC = PC+4
  • 数据通路修改
    • 目标地址计算:需要另一个加法器来计算分支目标地址:PC+4 + (SignExtend(offset) << 2)
    • 条件判断:需要ALU比较$rs$rt是否相等(例如,通过减法并检查结果是否为零)。这需要扩展ALU的功能或增加额外的比较电路。比较结果产生一个Branch信号。
    • PC选择:PC输入端的多路选择器需要增加一个输入,即分支目标地址。最终的选择由Branch信号和条件判断结果共同决定(例如,使用一个与门:PCSrc = Branch & Zero,其中Zero来自ALU的比较结果)。

设计控制逻辑

在设计了数据通路之后,我们需要生成控制信号来驱动它。控制信号是指令操作码的函数。

以下是生成主要控制信号的方法:

  • RegDst:R型指令为1(选择rd),I型指令为0(选择rt)。
  • ALUSrc:需要立即数作为ALU输入的指令(如I型算术、加载/存储)为1,否则为0。
  • MemtoReg:加载指令为1(选择内存数据),其他写寄存器指令为0(选择ALU结果)。
  • RegWrite:需要写回寄存器的指令(如R型、I型算术、加载)为1,否则(如存储、分支、跳转)为0。
  • MemRead:仅加载指令为1。
  • MemWrite:仅存储指令为1。
  • Branch:条件分支指令为1。
  • ALUOp:这是一个多比特信号,指示ALU需要执行的操作(加、减、与、或等)。它由操作码和(对于R型指令)功能码共同决定。
  • Jump:无条件跳转指令为1。

控制逻辑可以设计为硬连线的组合逻辑电路(基于操作码的真值表),也可以使用微码(一个存储控制信号模式的内存)。单周期处理器通常使用硬连线控制。


单周期处理器性能分析

单周期处理器的性能由以下公式决定:
执行时间 = 指令数 × CPI × 时钟周期时间

在单周期设计中,CPI = 1时钟周期时间由最慢指令的执行时间决定

让我们基于一些乐观的假设来分析关键路径:

  • 内存访问:200 ps
  • ALU/加法器操作:100 ps
  • 寄存器文件读写:50 ps
  • 其他逻辑(多路选择器等):0 ps

不同指令的关键路径延迟:

  • 跳转指令:仅需取指。延迟 = 200 ps。
  • R型算术指令:取指 + 读寄存器 + ALU操作 + 写寄存器。延迟 = 200 + 50 + 100 + 50 = 400 ps。
  • 加载指令:取指 + 读寄存器 + ALU计算地址 + 读内存 + 写寄存器。延迟 = 200 + 50 + 100 + 200 + 50 = 600 ps。
  • 条件分支(成功):取指 + 读寄存器 + ALU比较 + 目标地址计算 + 多路选择。延迟 ≈ 200 + 50 + 100 + 100 = 450 ps。

因此,时钟周期必须至少为600 ps以适应最慢的加载指令。这意味着即使执行一条简单的跳转指令,也需要等待600 ps,效率极低。


单周期设计的缺点

  1. 性能低下:时钟周期由最不常见的慢指令决定,无法优化常见指令的性能。
  2. 硬件利用率低:必须为任何指令可能需要的最大资源量提供并行硬件。例如,因为加载指令需要两次内存访问,就需要两个独立的内存端口,而其他指令用不到。
  3. 不灵活:难以实现复杂指令,也无法针对常见指令进行优化。
  4. 违反设计原则
    • 关键路径设计:无法通过分割长路径来缩短周期时间。
    • 常见情况优先:无法为高频指令优化性能。
    • 平衡设计:硬件资源因需满足最坏情况而可能不平衡。

引入多周期处理器

为了克服单周期设计的缺点,我们引入多周期处理器

核心思想:让每条指令只占用它实际需要的时间。将指令执行分解为多个步骤(阶段),每个步骤在一个较短的时钟周期内完成。

优势

  • 可定制的时钟周期:时钟周期时间可以独立于任何指令的执行时间来设定。我们可以设定一个目标频率,然后设计数据通路来满足它。
  • 优化常见指令:可以优化状态机,让常见指令用更少的周期完成。
  • 硬件资源共享:同一个硬件资源(如ALU、内存端口)可以在指令执行的不同周期中被重复使用,减少了硬件开销。例如,单端口内存可以既用于取指又用于数据访问,只需在不同周期进行。

代价

  • 需要额外的状态寄存器:用于存储指令执行中间周期的结果。
  • 时序开销:每个时钟周期都有寄存器建立/保持时间的开销,多条指令累积起来可能增加总延迟。
  • 并发性有限:在任一时刻,只使用了处理器的一小部分硬件。

多周期处理器通常使用有限状态机来描述其控制逻辑,每个状态对应一个时钟周期内执行的操作。


总结

本节课中我们一起学习了单周期微架构的设计与实现。我们从零开始,为MIPS ISA的一个子集构建了数据通路和控制逻辑,并分析了其性能瓶颈。我们发现单周期设计因其僵化的时钟周期和低效的硬件使用而并不实用。最后,我们引入了多周期处理器的基本概念,它通过将指令执行分解为多个较短的周期,提供了优化时钟频率和硬件资源的机会。在下一讲中,我们将深入探讨多周期处理器的具体设计。

11:多周期与流水线处理器设计

概述

在本节课中,我们将继续学习微架构设计,重点是多周期处理器和流水线处理器的设计。我们将从回顾单周期设计的局限性开始,然后详细探讨如何构建一个多周期MIPS处理器,最后引入流水线设计的概念,分析其如何提高指令吞吐量。


多周期处理器设计回顾

上一节我们介绍了单周期处理器设计的局限性。本节中,我们来看看多周期处理器设计如何解决这些问题。

多周期微架构的核心目标是让每条指令只占用其真正需要的时间,而不是像单周期设计那样,由最坏情况的指令决定时钟周期时间。这可以通过构建一个状态机来实现,每个时钟周期执行指令处理的一个步骤,并在指令结束时更新架构状态。

多周期设计的优势与代价

以下是多周期设计的主要目标:

  • 更好的关键路径:降低时钟周期时间。
  • 优化状态机:针对最常见的指令和工作负载进行优化。
  • 平衡设计:仅提供真正需要的功能。

然而,多周期设计也需要付出代价:

  • 硬件开销:需要在每个时钟周期结束时存储中间结果。
  • 时序开销:每个时钟周期都会浪费一部分时间(如寄存器建立/保持时间)。
  • 有限的并发性:一次只能处理一条指令。

构建多周期数据通路

设计多周期微架构的步骤与单周期类似:设计数据通路,添加控制信号,然后设计控制逻辑。关键区别在于,我们需要将指令处理分解为多个时钟周期。

我们以MIPS的lw(加载字)指令为例,展示数据通路的构建思路:

  1. 取指阶段:使用程序计数器从内存读取指令,存入指令寄存器,并同时递增PC。
  2. 寄存器读阶段:从指令中解码出基址寄存器,并从寄存器文件中读取其值。
  3. 地址计算阶段:将基址寄存器值与符号扩展后的立即数相加,计算出内存地址。
  4. 内存访问阶段:使用计算出的地址访问内存,读取数据。
  5. 写回阶段:将读取的内存数据写回目标寄存器。

多周期设计的优势之一是硬件复用。例如,我们可以使用同一个ALU来完成地址计算和PC递增,使用同一块内存进行指令取指和数据访问(在不同周期)。

多周期控制逻辑

数据通路构建完成后,我们需要设计控制逻辑,即一个有限状态机。每个状态由在该状态下断言的控制信号定义,并决定下一个状态。

控制信号在每个时钟周期控制两件事:

  1. 数据通路应如何处理数据。
  2. 如何为下一个时钟周期生成控制信号。

通过为每条指令类型定义状态序列,并设置每个状态下所有控制信号的值(包括“无操作”信号),我们就完成了多周期处理器的设计。

微程序控制

一种更结构化、更灵活的多周期设计方法是微程序控制。其核心思想是:

  • 微指令:对应有限状态机的一个状态,包含该状态所需的所有控制信号。
  • 控制存储器:存储所有微指令,类似于一个程序。
  • 微序列器:决定下一条要执行的微指令。

这实际上是在微架构层面进行编程。微程序控制提供了显著的灵活性:

  • 可扩展性:可以通过更新微程序来支持新的指令。
  • 复杂性管理:可以将复杂指令实现为一串简单的微指令序列。
  • 现场修复:可以发布微代码补丁来修复硬件bug或安全漏洞。


流水线处理器设计

多周期设计提高了时钟频率并优化了硬件使用,但并发性仍然有限。本节中,我们来看看流水线设计如何通过提高硬件资源利用率来进一步提升性能。

流水线的基本思想

流水线的核心思想是重叠执行多条指令。我们将指令处理过程划分为多个独立的阶段(如取指、译码、执行、访存、写回),并在每个阶段设置专门的硬件资源。这样,当一条指令在使用某个阶段的资源时,其他指令可以使用其他空闲阶段的资源。

类比:洗衣流程

  • 非流水线(多周期):洗完、烘干、折叠完一件衣服的所有步骤后,再开始处理下一件。
  • 流水线:当第一件衣服在烘干时,第二件衣服可以开始清洗。

理想情况下,对于一个k级流水线,虽然单条指令的延迟(完成所需时间)没有减少,但处理器的吞吐量(单位时间完成的指令数)理论上可以提高k倍。

流水线的实现

要实现流水线,我们需要:

  1. 划分阶段:将单周期数据通路划分为多个耗时大致相等的阶段。
  2. 插入流水线寄存器:在阶段之间插入寄存器,用于保存前一阶段的结果,并将其作为下一阶段的输入。这确保了不同指令在不同阶段的数据是隔离的。
  3. 传播控制信号:为每条指令生成的控制信号需要与其数据一起,在流水线寄存器中逐级传递,并在正确的阶段被使用。

一个经典的5级MIPS流水线阶段包括:

  • IF:取指
  • ID:译码与读寄存器
  • EX:执行/地址计算
  • MEM:内存访问
  • WB:写回寄存器

理想与现实中的流水线

理想流水线假设:

  • 工作可以完美均匀地划分到各阶段。
  • 各阶段没有资源冲突。
  • 指令之间完全独立。

现实中,流水线面临诸多挑战,限制了其性能提升:

  • 流水线不平衡:各阶段工作量不同,最慢的阶段成为瓶颈,决定了时钟周期时间。公式:吞吐量 = 1 / (max(各阶段延迟) + 寄存器开销)
  • 硬件成本增加:需要额外的流水线寄存器。过度细分流水线会导致寄存器开销占比过大,收益递减。
  • 指令间相关:下一条指令可能依赖于上一条指令的结果,导致其无法立即进入流水线,产生“流水线停顿”。

流水线控制

流水线处理器的控制逻辑与单周期处理器相似。一种常见策略是:
在ID阶段,根据操作码生成该指令后续所需的所有控制信号。
将这些控制信号与指令数据一起,沿流水线向下传播。
每个阶段使用其对应的控制信号来控制本阶段的硬件操作。


总结

本节课中我们一起学习了:

  1. 多周期处理器设计:通过有限状态机将指令执行分解为多个周期,优化了时钟频率和硬件复用,但牺牲了部分并发性。
  2. 微程序控制:一种灵活的多周期实现方式,将控制信号“编程化”,便于扩展和修复。
  3. 流水线处理器设计:通过重叠执行多条指令来大幅提高吞吐量。其核心是在处理阶段之间插入寄存器,并妥善管理控制信号与数据流。
  4. 流水线的挑战:我们认识到理想流水线的假设在实践中难以满足,阶段不平衡、资源冲突和指令相关性问题都会影响流水线效率。

在接下来的课程中,我们将深入探讨流水线面临的主要挑战——数据相关与控制相关,并学习如转发、停顿、分支预测等技术来解决这些问题,以逼近流水线的理想性能。

12:流水线处理器设计 II (Spring 2025)

概述

在本节课中,我们将完成流水线处理器设计的基础知识学习。我们将重点探讨流水线中数据依赖和控制依赖的处理方法,理解如何检测依赖关系,以及如何通过暂停(Stall)和数据转发(Forwarding)等技术来保证流水线的正确执行。


流水线设计回顾

上一节我们介绍了多周期微架构,并开始构建流水线处理器。本节中,我们来看看流水线设计中的一些核心问题。

从单周期到流水线

流水线设计的起点通常是单周期微架构的数据通路。其设计过程与单周期和多周期处理器类似:首先设计数据通路,然后设计控制逻辑。

流水线数据通路设计 的本质是:

  1. 从单周期数据通路开始。
  2. 将其划分为多个阶段(例如,五级流水线:取指、译码、执行、访存、写回)。
  3. 添加流水线寄存器以分隔各个阶段。
  4. 确保数据和控制信号能正确传播到所需的流水线阶段。

流水线寄存器的作用是在每个时钟周期结束时,锁存当前阶段的结果,并将其作为下一个阶段的输入。这样,不同的指令可以同时处于不同的执行阶段。

流水线控制逻辑

控制信号本质上与单周期处理器相同。关键区别在于,控制信号需要被延迟到正确的流水线阶段才生效。

例如,写寄存器文件的控制信号 RegWrite 应该在指令处于写回阶段时生效,而不是在译码阶段。因此,在译码阶段生成的控制信号需要存储在流水线寄存器中,并随着指令一起向下传播,直到在正确的阶段被使用。


流水线中的问题

我们构建的流水线在理想条件下(指令相互独立,访存单周期完成)可以正确工作。然而,现实中的程序会引入依赖关系,导致流水线无法持续流动。

以下是导致流水线暂停(Stall)的三个主要原因:

  1. 资源冲突:两条指令同时需要同一个硬件资源。
  2. 数据依赖:后续指令需要前序指令的计算结果。
  3. 控制依赖:需要根据分支指令的结果来决定下一条要取指的指令。

资源冲突的处理

资源冲突发生在两条处于不同流水线阶段的指令需要同一资源时。

以下是两种主要的处理方法:

  • 消除冲突根源:复制资源(例如,使用独立的指令和数据存储器)或增加资源吞吐能力(例如,使用多端口存储器)。
  • 检测并暂停:如果无法复制资源,则检测到冲突时,暂停其中一个冲突阶段。通常优先让更“年长”(更早进入流水线)的指令继续执行。

一个常见的技巧是精心设计寄存器文件,使其能在同一个时钟周期的前半段进行写操作,在后半段进行读操作。这样,写回阶段的指令和译码阶段的指令可以“同时”访问寄存器文件,避免了冲突。

数据依赖的类型

数据依赖分为三种类型:

  1. 真数据依赖(写后读,RAW):指令A写入寄存器,指令B读取该寄存器。这是真正的数据流依赖,必须保证指令B读到的是指令A写入的值。
    • 公式表示InstrA: Rd = f(Rs1, Rs2)InstrB: ... = g(Rd, ...)
  2. 反依赖(读后写,WAR):指令A读取寄存器,指令B写入同一寄存器。这是一种“假”依赖,源于寄存器名称的复用。
  3. 输出依赖(写后写,WAW):指令A和指令B都写入同一寄存器。这也是一种“假”依赖。

反依赖和输出依赖之所以是“假”依赖,是因为如果架构有足够多的寄存器,编译器可以为这些指令分配不同的寄存器,从而消除依赖。它们的存在是由于架构寄存器数量有限。

在顺序流水线中,通过确保所有写操作都在流水线最后阶段(写回)按程序顺序完成,可以相对容易地处理反依赖和输出依赖。真数据依赖的处理则更具挑战性。


处理真数据依赖

处理真数据依赖有几种基本方法,我们将重点讨论硬件检测与暂停,以及数据转发。

检测依赖(互锁)

首先,硬件需要检测指令间的依赖关系,这个过程称为互锁(Interlocking)。

以下是两种硬件检测方法:

  • 记分牌:为每个寄存器设置一个有效位。当一条指令在译码阶段确定要写某个寄存器时,将该寄存器的有效位置为“无效”。后续指令在译码时,如果发现其源操作数寄存器无效,则暂停。当写操作在写回阶段完成后,再将有效位置为“有效”。这种方法简单,但会对所有类型的依赖(包括假依赖)都产生暂停。
  • 组合逻辑依赖检查:在译码阶段,用组合逻辑电路比较当前指令的源寄存器编号,与流水线中所有后续阶段指令的目的寄存器编号。如果发现匹配,且后续指令确实会进行写操作,则说明存在真数据依赖,需要暂停当前指令。这种方法更精确,只针对真依赖,但随着流水线加深和超标量设计,逻辑会变得复杂。

暂停流水线

一旦检测到无法立即解决的数据依赖(例如,加载指令后紧跟一条依赖其结果的指令),就需要暂停流水线。

暂停的含义是:让依赖指令等待,直到其所需的数据值可用。

  • 暂停所有上游阶段(即该指令之前的所有更“年轻”的指令),包括停止更新PC和取指。
  • 让所有下游阶段(即该指令之后的所有更“年老”的指令)继续执行并流出流水线。
  • 在被暂停的阶段插入“气泡”(Bubble),即相当于一个空操作(NOP)指令,以防止错误执行。

数据转发(旁路)

单纯的暂停会降低性能。观察发现,产生数据的指令在其执行阶段或访存阶段结束后,结果就已经计算出来并锁存在流水线寄存器中,而无需等到写回阶段才可用。

数据转发 的思想是:将数据生产者指令的结果,直接通过额外的路径(旁路)传递给正在执行阶段的数据消费者指令,而不是等待结果写回寄存器文件后再读取。

以下是在五级流水线中需要添加的转发路径:

  1. 从执行阶段末尾的流水线寄存器转发到执行阶段的ALU输入。
  2. 从访存阶段末尾的流水线寄存器转发到执行阶段的ALU输入。
  3. 利用寄存器文件的内部设计,实现写回阶段前半周期写,译码阶段后半周期读的“隐式”转发。

为了实现转发,需要在ALU输入前添加多路选择器,其选择信号由转发控制逻辑产生。该逻辑根据源寄存器编号与后续阶段目的寄存器编号的匹配情况,以及后续指令是否确实会写寄存器,来决定选择哪个数据源(来自寄存器文件、来自EX/MEM寄存器还是来自MEM/WB寄存器)。如果多个后续阶段都有匹配,则优先选择更“年轻”的指令(即离消费者更近的生产者)的结果。

必须暂停的情况:加载-使用冒险

数据转发并非万能。有一种特殊情况必须暂停:当一条加载指令(LW)后面紧跟着一条依赖该加载结果的指令时。

LW   R1, 0(R2)  # 在MEM阶段结束时得到数据
ADD  R4, R1, R3 # 在EX阶段开始时需要R1的数据

问题在于:ADD指令在时钟周期4开始时就需要R1的数据,而LW指令在周期4结束时才能从内存中读出数据。即使添加从访存阶段到执行阶段的转发路径,这条路径也包含了内存访问时间,会显著延长关键路径,违反流水线阶段均衡的设计原则。因此,对于这种“加载-使用”依赖,硬件必须插入一个周期的暂停。


控制依赖的处理

控制依赖是指下一条要取指的指令地址依赖于当前指令(主要是分支指令)的执行结果。

问题所在

在流水线中,我们在取指阶段就需要知道下一条指令的地址。但对于分支指令(如BEQ),其条件是否成立、目标地址是多少,需要到执行阶段(甚至更晚)才能计算出来。在此期间,处理器已经按照“分支不跳转”的预测取入了后续的指令(即分支延迟槽指令)。如果最终分支判断为跳转,那么这些已取入的指令就是无效的,必须作废。

分支预测与冲刷

最简单的策略是总是预测不跳转。即,假设所有分支都不执行跳转,继续顺序取指。

  • 如果预测正确(分支确实不跳转),流水线正常执行。
  • 如果预测错误(分支跳转),则在分支指令的执行结果出来后:
    1. 将正确的目标地址载入程序计数器(PC)。
    2. 冲刷(Flush)流水线中在分支指令之后取入的所有错误指令。冲刷意味着清空这些指令所在流水线寄存器的内容,将其变为“气泡”。

预测错误的代价是分支误预测惩罚,即被冲刷的指令数量所对应的时钟周期数。

减少惩罚:尽早解析分支

为了减少惩罚,可以尝试在流水线的更早阶段解析分支。例如,将分支地址计算和条件判断(比较两个寄存器是否相等)移到译码阶段。

  • 优点:误预测时只需冲刷一条指令(译码阶段的那条),惩罚减小。
  • 缺点:增加了译码阶段的硬件复杂度(需要额外的加法器和比较器),可能延长关键路径,从而降低时钟频率。同时,如果分支依赖的寄存器值尚未就绪(存在数据依赖),还需要为这些值添加转发路径,进一步增加复杂性。

更智能的预测

“总是预测不跳转”是一种静态预测策略。我们可以采用更智能的动态分支预测策略来提升准确率,例如:

  • 基于方向预测:对于向后跳转的分支(通常是循环结尾),预测为“跳转”,因为循环通常会执行多次。
  • 基于历史预测:使用一个分支历史表记录每个分支指令最近几次的执行结果(跳转/不跳转),并基于此历史进行预测。

更准确的分支预测可以降低误预测率,从而减少因冲刷指令带来的性能损失。现代处理器采用了非常复杂的动态分支预测器。


总结

本节课中,我们一起学习了流水线处理器设计中的关键挑战和解决方案:

  1. 流水线基础:理解了如何通过划分阶段和添加流水线寄存器来构建流水线数据通路和控制逻辑。
  2. 数据依赖:区分了真数据依赖和假依赖。学习了通过硬件互锁检测依赖,并通过暂停数据转发技术来解决真数据依赖,以在保证正确性的前提下尽可能提升性能。
  3. 控制依赖:认识到分支指令带来的控制依赖会导致流水线取指错误。学习了通过分支预测(如总是预测不跳转)和指令冲刷来处理分支误预测。了解了尽早解析分支和采用更智能预测策略以降低性能损失的思想。

我们目前构建的流水线仍假设内存访问是单周期的。下一周,我们将继续探讨流水线的其他问题,包括多周期操作的处理,并逐步向乱序执行的概念迈进。

13:精确异常处理 (Spring 2025)

概述

在本节课中,我们将学习处理器设计中的一个核心概念:精确异常处理。我们将探讨为什么在流水线处理器,特别是当指令执行时间不同或乱序完成时,保持冯·诺依曼模型的顺序语义至关重要。我们将重点介绍一种关键的微架构技术——重排序缓冲区,它如何帮助我们在提升性能的同时,确保异常和中断能够被精确地处理。


异常与中断的区别

上一节我们介绍了流水线处理器中的各种挑战。本节中,我们来看看当程序执行过程中出现意外事件时会发生什么。这些事件主要分为两类:异常中断

  • 异常:由正在执行的程序内部触发的事件。例如:

    • 除零错误:例如执行 DIV R1, R0(假设R0为0)。
    • 算术溢出:运算结果超出了寄存器能表示的位数范围。
    • 未定义操作码:处理器取到并尝试解码一个无效的指令编码。
    • 页错误/保护异常:程序试图访问没有权限或不在物理内存中的地址(将在虚拟内存章节详述)。
  • 中断:由外部硬件设备触发的事件,与当前运行的程序无关。例如:

    • I/O设备请求:如键盘输入、网络数据包到达。
    • 定时器中断:操作系统用于任务调度的周期性信号。
    • 电源故障:电池电量即将耗尽。

核心区别:异常与程序本身相关,通常需要立即处理;中断是外部事件,可以根据优先级和系统状态延迟处理。然而,在现代冯·诺依曼架构中,它们的处理机制非常相似。


什么是精确异常?

我们为什么需要关心指令完成的顺序?关键在于维护精确异常

定义:当异常(或中断)准备被处理时,处理器的架构状态(包括程序计数器PC、寄存器和内存)必须处于一个精确的、一致的状态

这意味着:

  1. 所有在异常指令之前(按程序顺序)的指令必须已经完成(退休/提交)。即,它们已经完整地更新了架构状态。
  2. 所有在异常指令之后的指令必须表现得如同从未执行过。即,它们不能更新任何架构状态。

公式化描述
设异常发生在指令 I_k。则精确状态要求:

  • ∀ I_i (i < k)I_i.state == COMMITTED
  • ∀ I_j (j > k)I_j.state == NOT_EXECUTED (从架构状态视角看)

为什么这很重要?

  • 软件调试:程序员可以准确知道异常发生时,哪些指令已生效,哪些没有,从而定位问题。
  • 易于恢复:操作系统或异常处理程序可以安全地保存现场,并在问题解决后从精确的断点重启程序。
  • 实现复杂指令:可以通过异常机制,用软件模拟器来处理硬件未实现的复杂指令(如某些浮点运算)。

多周期处理器中的异常处理

在单周期机器中,指令边界与时钟周期对齐,不存在顺序问题。在多周期处理器中,我们需要修改控制逻辑来支持异常。

基本思路是:在一条指令执行完毕后、取下条指令前,检查是否发生了异常或中断。如果发生,则转入特殊的处理状态。

以下是MIPS多周期数据通路为支持异常所做的修改示例:

// 新增的寄存器
EPC;    // 异常程序计数器,保存导致异常的指令地址
Cause;  // 原因寄存器,记录异常类型(如 0=溢出,1=未定义指令)

控制单元的状态机需要增加新的状态(例如“溢出处理”和“未定义指令处理”状态)。在这些状态中,硬件会:

  1. 将当前PC保存到EPC。
  2. 将异常类型编码写入Cause寄存器。
  3. 将PC设置为一个固定的异常处理程序入口地址(例如 0x80000180)。
  4. 跳转到该地址开始执行系统级的异常处理代码。

异常处理程序可以通过特殊的指令(如MIPS的mfc0)读取Cause寄存器,判断异常类型并做出相应处理。


乱序完成带来的挑战

当处理器采用更深的流水线,并且不同功能单元的执行周期数不同时(例如,加法需1周期,乘法需4周期,除法需40周期),问题变得复杂。

考虑以下指令序列,假设它们之间没有数据依赖

1: DIV R3, R1, R2  // 长延迟指令,假设需8周期
2: ADD R4, R5, R6  // 短延迟指令,1周期
3: ADD R7, R8, R9  // 短延迟指令,1周期

在简单的流水线中,指令2和3可能先于指令1完成执行并写回寄存器文件。这就违反了顺序语义。如果指令1随后发生异常(如除零),那么架构状态(R4, R7)已经被后续指令修改,导致无法进行精确的异常处理和恢复。

核心问题:如何支持指令的乱序完成以提升性能,同时又能保证按序提交以维持精确异常?


解决方案:重排序缓冲区

一种广泛使用的解决方案是引入一个称为重排序缓冲区的硬件结构。其核心思想是:允许指令乱序完成执行,但强制它们按程序顺序更新架构状态

ROB工作原理

ROB是一个在微架构层面实现的环形队列,跟踪所有已解码但尚未退休的指令。

ROB基本条目结构

struct ROB_Entry {
    bool valid;          // 该条目是否有效
    int dest_reg_id;     // 目标寄存器编号
    int dest_reg_value;  // 计算结果值
    bool value_ready;    // 结果值是否已就绪
    int pc;              // 该指令的PC(用于异常处理)
    bool exception;      // 该指令是否导致异常
    // ... 可能还有其他字段,如存储地址/数据
};

ROB工作流程

  1. 分配:当指令被解码时,按程序顺序在ROB尾部分配一个条目。记录其目标寄存器等信息,并标记该寄存器“未就绪”。
  2. 执行与写回:指令在功能单元中乱序执行。完成后,将结果写回它自己在ROB中的条目,并标记value_ready = true。此时不更新架构寄存器文件。
  3. 提交/退休:一个独立的控制器持续检查ROB头部(最旧的指令)。如果头部指令的value_ready = trueexception = false,则将其结果从ROB中取出,写入架构寄存器文件或内存。然后释放该ROB条目。
  4. 异常处理:如果头部指令的exception = true,则触发异常处理流程:清空流水线及ROB中所有后续指令,利用该指令条目中保存的PC等信息,跳转到异常处理程序。

通过这种方式,架构状态的更新永远是按程序顺序进行的,从而保证了精确异常。

数据转发与寄存器重命名

ROB还有一个重要作用:消除假数据依赖,并为乱序执行奠定基础。

问题:后续指令需要依赖前面尚未写回寄存器文件的结果怎么办?
方案:通过ROB进行数据转发。但直接在ROB中按寄存器号查找最新值需要复杂的内容可寻址存储器,成本高。

更优方案寄存器重命名

  • 在解码阶段,当一条指令要写目标寄存器(如R3)时,我们不仅为它在ROB中分配条目,还将架构寄存器R3映射到该ROB条目
  • 后续需要读R3的指令,会被告知“R3的最新值将在ROB条目X中产生”,然后它们直接监视那个特定的ROB条目。
  • 这样,多条指令写同一个架构寄存器(如R3)在微架构层面被重命名为写不同的物理位置(ROB条目),消除了写后写读后写这类假依赖。真正的数据流依赖(写后读)通过ROB条目的生产者-消费者链来维护。

这实质上为程序提供了比ISA所定义的更多的“物理寄存器”,是现代乱序执行处理器的基石。


总结

本节课我们一起学习了处理器设计中的关键机制——精确异常处理。

  1. 核心目标:维护冯·诺依曼顺序语义,确保异常/中断发生时架构状态精确,便于调试和恢复。
  2. 主要挑战:在追求高性能的流水线,特别是允许指令乱序完成的设计中,如何保证按序提交。
  3. 关键机制重排序缓冲区。它作为指令乱序完成与按序提交之间的缓冲,是实现精确异常的核心硬件结构。
  4. 额外收益:ROB结合寄存器重命名,可以消除指令间的假数据依赖,为后续要深入学习的动态乱序执行提供了基础。

通过ROB,我们能够在微架构层面灵活调度指令以提升性能,同时向软件层呈现出一个简洁、顺序执行的抽象模型,完美体现了计算机架构中硬件-软件协同设计的精髓。

14:乱序执行 (Spring 2025)

在本节课中,我们将要学习计算机架构中一个非常核心且激动人心的主题:乱序执行。这是一种不按照程序原始顺序执行指令的方法,旨在通过动态调度指令来挖掘指令级并行性,从而提升处理器性能。我们将从基本原理出发,逐步构建一个支持乱序执行的处理器模型。

回顾:顺序执行与寄存器重命名

上一节我们介绍了支持精确异常的顺序执行流水线,并引入了重排序缓冲区(ROB)和寄存器重命名的概念。本节中,我们来看看如何在此基础上实现乱序执行。

寄存器重命名通过将架构寄存器(如 R1)映射到微架构命名空间(如重排序缓冲区条目或物理寄存器),消除了由寄存器数量有限引起的假依赖(写后读和写后写依赖)。真正的依赖是流依赖(读后写),我们需要确保消费者指令能正确连接到生产者指令。

核心概念:寄存器重命名将架构寄存器ID映射到一个更大的物理命名空间。例如,指令 ADD R3, R1, R2 在执行时,R3 可能被重命名为一个物理寄存器编号 P100

乱序执行的核心思想与动机

顺序执行流水线面临的一个主要问题是:当一条指令因为其源操作数未就绪(例如,依赖一个长延迟操作,如乘法或未命中的加载)而无法派发时,后续所有指令都会被阻塞,即使它们是独立的。

乱序执行旨在解决这个问题。其核心思想是:将未就绪的指令移开,为独立的指令让路。这类似于高速公路上的休息区——需要停下的车辆驶入休息区等待,而不阻塞主路。

为了实现这一点,我们需要:

  1. 连接消费者与生产者:通过寄存器重命名实现。
  2. 缓冲指令直至就绪:需要一个硬件缓冲区来存放指令。
  3. 跟踪源操作数就绪状态:指令需要知道其源操作数何时可用。
  4. 在指令就绪时派发:当指令的所有源操作数都就绪时,将其发送到功能单元执行。

以下是实现乱序执行所需的四个关键组件:

  • 寄存器重命名:消除假依赖,为每个数据值关联一个唯一的标签(Tag)。
  • 保留站:作为指令的“休息区”,存放已重命名但未就绪的指令。
  • 唤醒与选择逻辑:当生产者指令完成并广播其标签和值时,等待该值的消费者指令被“唤醒”(标记为就绪)。当多个指令就绪时,需要仲裁逻辑选择派发哪一个。
  • 公共数据总线:用于广播已完成的指令所产生的标签和值,使得等待这些值的指令能够捕获数据。

Tomasulo 算法:一个经典的乱序执行实现

我们将通过一个经典的 Tomasulo 算法示例来具体理解乱序执行。该算法最初用于 IBM 360/91 浮点单元,是现代乱序执行处理器的基础。

我们假设一个简单的执行环境:一个加法器(延迟4周期)和一个乘法器(延迟6周期),它们都是流水化的。我们有以下代码片段需要执行:

MUL R3, R1, R2   # R3 = R1 * R2 (长延迟)
ADD R5, R3, R4   # R5 = R3 + R4 (依赖 MUL)
ADD R7, R2, R6   # R7 = R2 + R6 (独立)
ADD R10, R8, R9  # R10 = R8 + R9 (独立)
MUL R11, R7, R10 # R11 = R7 * R10 (依赖前两个 ADD)
ADD R5, R5, R11  # R5 = R5 + R11 (依赖第一个 ADD 和第二个 MUL)

模拟执行步骤

我们将一步步模拟 Tomasulo 算法的执行过程。关键数据结构包括:

  • 寄存器别名表:记录每个架构寄存器当前有效的值在哪里(在寄存器文件中,还是由某个保留站条目即将产生)。
  • 保留站:每个功能单元(加法、乘法)关联一组保留站条目,每个条目存储指令的操作码、源操作数标签/值、目的操作数标签等。

初始状态:所有寄存器值有效,保留站为空。

周期 1-2:解码 MUL 指令

  1. 取指并解码 MUL R3, R1, R2
  2. 分配一个乘法保留站条目(例如 RSX)。
  3. 读取 RAT:R1R2 的值都有效(假设为1和2)。将值和“有效”位复制到 RSX 的源操作数槽。
  4. 重命名目的寄存器 R3:将 RAT 中 R3 的条目标记为“无效”,并将其标签指向 RSX。这意味着 R3 的新值将由保留站 RSX 产生。
  5. 由于 RSX 的两个源操作数都已就绪,唤醒逻辑将其标记为可派发。

周期 3:派发并执行 MUL

  • RSX 中的乘法指令被派发到乘法器开始执行(需6周期)。
  • 同时,解码下一条指令 ADD R5, R3, R4

周期 3(续):解码 ADD 指令

  1. 分配一个加法保留站条目(例如 RSA)。
  2. 读取 RAT:R3 无效,其标签指向 RSX。因此,RSA 的第一个源操作数标记为“未就绪”,标签=RSXR4 有效,值被复制。
  3. 重命名目的寄存器 R5:将 RAT 中 R5 的条目标记为“无效”,标签指向 RSA
  4. 由于 RSA 的一个源操作数未就绪,它必须在保留站中等待。

关键观察:此时,即使第一条乘法指令需要长时间执行,且第二条加法指令依赖它,流水线并未完全阻塞。我们可以继续解码后续指令。

周期 4-6:继续解码与执行

  • 我们继续解码 ADD R7, R2, R6ADD R10, R8, R9。这两条指令的源操作数都立即可用,因此它们被分配到保留站(如 RSBRSC)后,很快就被标记为就绪并派发到加法器执行。
  • 它们先于前面那条等待 R3ADD 指令开始执行,这就是“乱序”的体现。

周期 8:乘法完成与广播

  • 乘法器完成计算 R1*R2=2
  • 它通过公共数据总线广播一个消息:标签=RSX, 值=2
  • 所有正在监听总线的部件(其他保留站、寄存器别名表)检查自己的源操作数标签。
  • RSA(等待 RSX 的加法指令)发现匹配,于是捕获值 2,并将其第一个源操作数标记为就绪。
  • RAT 中 R3 的条目也更新为有效,值=2。

周期 9:依赖链恢复执行

  • 现在 RSA 的两个源操作数都已就绪(值2和 R4 的值),它被唤醒并派发到加法器执行。
  • 与此同时,之前乱序执行的 ADD R7, R2, R6 可能已经完成,并广播其标签和值,进而唤醒依赖它的后续指令(如 MUL R11, R7, R10)。

后续周期:这个过程持续进行。每条指令完成后都广播其标签和值,唤醒依赖它的指令。指令在其所有操作数就绪后立即被派发,完全基于数据流依赖关系,而非程序顺序。

最终效果:独立指令无需等待长延迟指令,从而显著减少了总执行时间。在这个例子中,乱序执行将周期数从顺序执行的约25周期减少到约20周期。

支持精确异常的乱序执行

我们之前讨论的 Tomasulo 算法最初不支持精确异常,这在现代通用处理器中是不可接受的。为了支持精确异常,我们需要结合上一讲的概念:重排序缓冲区

修改后的设计包含两个关键部分:

  1. 前端寄存器文件:用于重命名和乱序执行。指令完成时立即更新它,并广播结果。
  2. 架构寄存器文件:代表程序的精确架构状态。只有当一条指令是重排序缓冲区中最旧的指令,且没有引发异常时,才将其结果从 ROB 提交到架构寄存器文件。

工作流程

  • 重命名/派发:指令被解码后,其目的寄存器被重命名到一个物理寄存器(或 ROB 条目),并存入保留站。
  • 乱序执行:指令在操作数就绪后乱序执行,结果写入物理寄存器文件,并广播标签。
  • 顺序提交:已完成的指令在 ROB 中按程序顺序排队。当一条指令到达 ROB 头部且状态为“完成无异常”时,将其结果从物理寄存器文件复制到架构寄存器文件,从而按程序顺序更新架构状态
  • 异常处理:如果任何指令发生异常,处理器清空流水线(刷新所有推测状态),并将架构寄存器文件的内容复制回前端寄存器文件,从而恢复到最后一个精确的架构状态。

这种设计分离了“推测执行状态”和“精确架构状态”,使得乱序执行和精确异常得以共存。

现代处理器优化:物理寄存器文件

在基本设计中,数据值被多次复制:保留站、ROB、前端寄存器文件都可能存有副本,这浪费了面积和功耗。

现代处理器采用物理寄存器文件来优化:

  • 创建一个统一的、大型的物理寄存器文件来存储所有数据值。
  • 寄存器别名表、保留站、ROB 中不再存储实际数据值,而是存储指向物理寄存器文件的指针(即物理寄存器编号)。
  • 指令完成后,将结果写入其分配的物理寄存器,并广播该物理寄存器的编号(标签)。
  • 依赖指令根据捕获的标签,在派发执行前,从物理寄存器文件中读取相应的源操作数值。

优点

  • 消除了数据值的冗余存储,节省芯片面积。
  • 广播的内容从宽数据总线(如64位值)变为窄标签总线(如10-12位物理寄存器编号),大幅降低了广播网络的功耗和复杂度。

总结与展望

本节课中我们一起学习了乱序执行的基本原理和实现方法。

  • 核心思想:通过动态调度,让指令在其操作数就绪后立即执行,而非死板遵循程序顺序,从而容忍长延迟操作,挖掘指令级并行。
  • 关键技术
    • 寄存器重命名:消除假依赖,建立生产者-消费者之间的标签化连接。
    • 保留站:作为指令等待操作数就绪的缓冲区。
    • 基于广播的唤醒机制:指令完成时广播标签,唤醒所有等待该结果的指令。
    • 重排序缓冲区:与乱序执行结合,确保指令结果按程序顺序提交,支持精确异常。
  • 现代实现:通常采用物理寄存器文件来集中管理数据值,以提高能效和面积效率。

乱序执行是现代高性能处理器的基石。虽然其硬件复杂度显著增加(大量的比较器、广播总线、仲裁逻辑),但它为提升单线程性能带来了巨大收益。在接下来的课程中,我们将探讨另一个关键性能技术:分支预测,它旨在解决另一个导致流水线停滞的主要问题——控制依赖。

15:数据流、超标量执行与分支预测 (S25)

概述

在本节课中,我们将要学习数据流执行模型、超标量处理器架构以及分支预测技术。我们将首先回顾乱序执行的核心概念,然后探讨数据流与超标量执行,最后深入讲解分支预测的重要性及其实现方法。

乱序执行回顾与总结

上一节我们介绍了乱序执行的基本原理。本节中,我们来总结其核心机制与权衡。

为了实现乱序执行,处理器需要具备以下四个关键能力:

  1. 链接生产者与消费者:将指令结果(生产者)与依赖该结果的后续指令(消费者)关联起来。
  2. 缓冲指令:将指令暂存起来,直到其操作数准备就绪。
  3. 跟踪操作数就绪状态:监控每条指令源操作数的就绪情况。
  4. 调度与分派:当指令操作数全部就绪时,将其分派到相应的功能单元执行。

以下是实现这些能力的关键技术:

  • 寄存器重命名:消除假数据依赖,实现生产者到消费者的链接。
  • 保留站:提供指令缓冲。
  • 标签与值广播:跟踪操作数就绪状态。
  • 唤醒与选择逻辑:实现指令调度与分派。

乱序执行可以被视为一种受限的数据流执行。它在一个有限的指令窗口内动态地构建数据流图,并基于数据就绪性来调度指令执行,从而在保持顺序编程模型语义的同时,挖掘指令级并行性。

乱序执行的主要优势在于延迟容忍发掘不规则并行性。它通过执行独立指令来掩盖长延迟操作(如内存访问)的等待时间,并能动态发现程序中隐藏的并行性。

然而,乱序执行也带来了一些挑战:

  • 更高的硬件复杂度:依赖检查、重命名、调度等控制逻辑非常复杂。
  • 可能延长关键路径:复杂的控制逻辑可能导致时钟周期变长。
  • 更多的硬件资源:增加了芯片面积和成本。

性能权衡的核心公式是:
程序执行时间 = 指令数 × 平均CPI × 时钟周期时间
乱序执行旨在降低平均CPI,但可能增加时钟周期时间。设计者必须仔细权衡,以在提升性能的同时控制能耗和复杂度。

数据流执行模型

上一节我们提到乱序执行是受限的数据流。本节中,我们来看看数据流执行模型的核心理念。

在数据流模型中,数据的可用性决定了指令的执行顺序。一个数据流节点(指令)在其所有源操作数就绪时“触发”执行。程序被表示为节点之间的数据流图。

指令集架构(ISA)层面,数据流模型并未取得广泛成功,主要原因是:

  • 编程与调试困难:对程序员不友好,难以调试。
  • 缺乏精确的状态语义:中断和异常处理复杂。

然而,在微架构层面,通过保持顺序编程语义来实现数据流(即乱序执行)则非常成功,现代高性能处理器普遍采用此技术。

此外,将数据流图映射到可重构硬件(如FPGA) 上也取得了成功,常用于加速机器学习等计算密集型应用。

数据流与顺序控制流在ISA层面的权衡涉及多个方面:

  • 易编程性:顺序编程对程序员更友好。
  • 易编译性:两者各有特点。
  • 并行性发掘:数据流模型能显式表达并行性,更易于硬件利用。
  • 硬件复杂度:数据流硬件可能因标签匹配等操作而更复杂。

超标量执行

我们了解了通过乱序执行挖掘指令级并行性。本节中,我们来看看另一种提升吞吐量的方法:超标量执行。

超标量执行的核心思想是每个周期取指、译码、执行和退休多条指令。例如,一个2路超标量处理器理想情况下每个周期能处理2条指令(IPC=2)。

超标量与乱序是正交的概念。处理器可以组合形成不同的设计:

  • 顺序标量
  • 顺序超标量
  • 乱序标量(不常见)
  • 乱序超标量(现代高性能处理器常见)

为了实现超标量执行,需要复制流水线前段资源(如取指、译码带宽)和后端资源(如多功能单元、多端口寄存器堆和数据缓存)。同时,硬件必须检查同时被取指指令之间的依赖关系

以下是一个顺序超标量处理器的数据通路示意图,展示了资源复用的情况:

[取指单元] -> [指令内存] (双端口取指)
         -> [寄存器堆] (双读口、双写口、双写使能)
         -> [功能单元1] [功能单元2]
         -> [数据内存] (双端口)
         -> [写回选择器] (双路)

依赖关系会限制性能提升。例如,如果连续两条指令存在数据依赖,第二条指令必须等待,导致资源无法充分利用。编译器可以通过指令重排来缓解这个问题,将独立指令放在一起以提高并行度。

超标量执行的优势在于更高的指令吞吐量。但其劣势也很明显:

  • 更复杂的依赖检查:需要检查同一周期内多指令间的依赖。
  • 更复杂的寄存器重命名逻辑:在乱序超标量中,重命名逻辑可能成为关键路径。
  • 可能增加时钟周期:复杂的逻辑可能降低时钟频率。
  • 更多的硬件资源:增加了成本。

现代处理器(如Intel、AMD、Apple的芯片)都是深度流水线、多路发射的乱序超标量设计,以同时挖掘指令级并行性和线程级并行性。

分支预测导论

前面我们讨论了通过超标量设计提升并行度。然而,控制依赖(尤其是分支指令)会严重阻碍流水线效率。本节中,我们开始学习解决此问题的关键技术:分支预测。

分支预测的目标是在取指阶段就猜测下一条指令的地址,以保持流水线充满。处理控制依赖的潜在方法有:

  1. 停顿流水线:直到知道确切地址。这会导致性能严重下降。
  2. 预测地址:即分支预测。
  3. 延迟分支:编译器在分支后填充独立指令(如MIPS)。效果有限。
  4. 细粒度多线程:在等待一个线程的分支结果时,执行其他线程的指令。用于GPU。
  5. 谓词执行:将条件分支转换为条件数据依赖,消除分支。
  6. 多路径执行:同时推测执行两个分支路径,事后丢弃错误路径。

细粒度多线程 通过在每个周期从不同线程取指来隐藏延迟。它需要为每个线程保存独立的上下文(PC、寄存器组)。优点是无须复杂的依赖检查和分支预测,提高了系统吞吐量;缺点是单线程性能下降,需要更多硬件资源存储上下文,且当线程数不足时效率会降低。

分支指令非常频繁(约占指令的15-25%)。在深度流水线超标量处理器中,一次分支预测失败会导致大量指令槽被浪费。分支预测的准确性至关重要

举例说明:假设一个20级流水线、5路超标量处理器,每5条指令有一条分支。如果分支预测100%准确,IPC可达5。如果准确率降为99%,IPC降至约4。如果准确率仅为90%,IPC会骤降至约1.6。因此,即使预测准确率看似很高,微小的下降也会对性能产生巨大影响。

静态分支预测

我们看到了分支预测准确性的重要性。本节中,我们先从最简单的静态预测方法开始。

最简单的预测是总是预测不跳转,即下一条指令地址总是 PC + 4。软件(编译器)可以通过剖析引导的代码布局来优化:将更可能执行的路径(通常是“不跳转”路径)安排在分支指令的顺序后继位置,从而提高这种简单预测的准确率。

另一种方法是尽量减少或消除分支,例如通过谓词合并将多个条件判断合并,或将控制依赖转换为数据依赖。

总是预测不跳转的实现简单,但准确率低(约30-40%)。总是预测跳转的准确率通常更高,因为循环中的向后分支经常被跳转。更进一步的启发式方法是向后跳转则预测跳转,向前跳转则预测不跳转

编译器可以进行基于剖析的静态预测。通过使用剖析数据,编译器可以判断每个分支在运行时的常见方向,并将此“提示”编码在分支指令中(需要ISA支持)。这种方法的准确率取决于剖析输入集的代表性,如果实际运行模式与剖析阶段不同,预测效果会变差。

当预测失败时,处理器必须清空在错误路径上已取指和部分执行的指令,并从正确地址重新开始取指,这会带来性能惩罚。

总结

本节课中我们一起学习了:

  1. 乱序执行的总结:其通过寄存器重命名、保留站等机制实现受限数据流,以容忍延迟和发掘并行性,但增加了硬件复杂度。
  2. 数据流模型:其以数据就绪性驱动执行,在微架构层面通过乱序执行实现很成功,但在ISA层面因编程调试困难而未普及。
  3. 超标量执行:通过每周期处理多条指令提升吞吐量,需要硬件复制和依赖检查,常与乱序执行结合。
  4. 分支预测的重要性:极小的预测准确率下降都会导致性能大幅降低,是保持流水线高效的关键。
  5. 静态分支预测:包括总是预测不跳转/跳转、基于方向的启发式方法以及编译器辅助的剖析预测,这些方法实现简单但准确率有限。

下节课我们将继续深入更复杂、更准确的动态分支预测技术。

16:高级分支预测 (Spring 2025)

概述

在本节课中,我们将深入学习高级分支预测技术。我们将从回顾静态预测方法开始,然后深入探讨动态运行时预测,包括单级预测器、两级预测器、混合预测器,以及现代处理器中使用的更复杂的算法,如感知器和几何历史长度预测器。目标是理解如何通过硬件机制动态地、高精度地预测分支方向,以保持流水线满载,从而提升处理器性能。


静态分支预测回顾

上一节我们介绍了分支预测的基本概念和重要性。本节中,我们来看看几种不需要复杂硬件的静态预测方法。这些方法在编译时或由程序员确定分支方向。

以下是几种常见的静态分支预测策略:

  • 总是预测不跳转:始终预测分支不跳转,取指 PC + 4。这种方法简单,但准确率通常只有30-40%,因为大多数条件分支(尤其是循环)是跳转的。
  • 总是预测跳转:始终预测分支跳转。这需要目标地址预测。准确率比“总是预测不跳转”高,但通常也只有60%左右。
  • 向后跳转则预测跳转:如果分支目标地址在代码中位于分支指令之前(即向后跳转),则预测跳转(这通常是循环分支);否则预测不跳转。这种方法比前两种稍好。

另一种静态方法是基于剖析的预测。编译器使用一组输入数据对程序进行剖析,根据运行结果决定每个分支的“可能方向”,并将这一位提示信息编码在指令中。硬件在执行时使用这个提示位进行预测。

基于剖析的预测的优点是每个分支可以独立设置预测方向。但其准确性严重依赖于剖析时使用的输入数据集是否能代表程序的实际运行情况。如果实际输入与剖析输入差异很大,预测准确率会显著下降。

程序员也可以通过编程语言中的编译指示(Pragma)来提供分支预测提示,例如在C语言中使用 if likely(x)if unlikely(error)。但这增加了程序员的负担,且需要编程语言、编译器和指令集架构的共同支持。

所有静态预测技术都有一个共同的缺点:它们无法适应分支行为的动态变化。分支在执行过程中其行为可能改变,而静态方法编码的单一方向无法应对这种变化。


动态(运行时)分支预测简介

由于静态预测的局限性,我们需要能够适应分支行为变化的动态预测技术。动态预测器利用硬件在运行时收集的信息进行预测。

动态预测的主要优势是:

  • 可以基于分支的执行历史进行预测。
  • 可以适应分支行为的动态变化。
  • 无需静态剖析,避免了输入集代表性的问题。

当然,其代价是需要额外的硬件来实现预测逻辑,并且追求高精度会使硬件变得非常复杂。

我们将从最简单的动态预测器开始构建。


单级预测器:上次结果预测器

最基本的动态预测器是“上次结果预测器”。其核心思想是:预测分支本次的方向与它上一次执行时的方向相同

实现上,我们需要为每个分支(通过程序计数器PC标识)记录一个位(bit),来存储它上次执行的结果(跳转Taken=1,不跳转Not Taken=0)。当再次取到该分支指令时,检查这个位,并按其指示进行预测。

对于一个循环分支,假设循环执行N次(前N-1次跳转,最后一次不跳转),使用上次结果预测器会在退出循环时和重新进入循环时各产生一次预测错误。因此,对于N次迭代的循环,预测准确率为 (N-2)/N。当N很大时,准确率接近100%;但当N很小时(例如N=2),准确率会降到0%。

这种预测器的问题在于它改变主意太快了。只要遇到一次相反的结果,它就会立刻改变预测。这类似于空调在温度刚好超过设定值时就立刻启动制冷,导致在阈值附近频繁开关。


改进:双位饱和计数器预测器

为了解决“改变主意太快”的问题,我们引入滞后(Hysteresis) 机制。最简单的方法是为每个分支使用一个两位饱和计数器,而不是单个位。

两位计数器有四种状态:

  • 强跳转(Strongly Taken, 11):强烈认为分支会跳转。
  • 弱跳转(Weakly Taken, 10):倾向于认为分支会跳转,但信心不强。
  • 弱不跳转(Weakly Not Taken, 01):倾向于认为分支不跳转,但信心不强。
  • 强不跳转(Strongly Not Taken, 00):强烈认为分支不会跳转。

预测规则是:当计数器处于“强跳转”或“弱跳转”状态时,预测跳转;处于“弱不跳转”或“强不跳转”状态时,预测不跳转。

状态转换规则是:

  • 如果预测正确(例如,预测跳转且实际跳转),则增加计数器(向“强跳转”方向饱和)。
  • 如果预测错误,则减少计数器(向相反方向移动)。

这样,一次相反的结果不会立即导致预测翻转(例如从“强跳转”到“弱跳转”),需要连续两次相反结果才能从“强跳转”变为“弱不跳转”并改变预测。这提供了所需的滞后效果。

对于之前N次迭代的循环,两位计数器预测器通常能减少一次预测错误(例如,从上次结果预测器的2次错误减少到1次),从而在小循环中提升准确率。

在20世纪80年代,这种预测器能达到85-90%的准确率。但随着现代工作负载越来越复杂,仅靠这种简单的历史信息已不足以满足高性能流水线的需求。


两级预测器:利用相关历史

人们发现,一个分支的结果不仅与它自身上次的结果相关,还可能与其他分支的结果或自身更早的历史相关。这引出了两级预测器的概念。

全局分支相关性

全局分支相关性是指当前分支的结果与之前执行的其他一系列分支的结果存在关联。

例如,考虑以下代码片段:

if (x < 1) { ... } // 分支B1
if (x > 1) { ... } // 分支B2

如果B1跳转(x<1为真),那么B2一定不跳转(x>1为假)。因此,知道B1的结果有助于预测B2。

为了利用这种全局相关性,我们引入一个全局历史寄存器(GHR)。GHR是一个位向量,例如16位,记录最近执行的16个分支的方向(跳转=1,不跳转=0)。每次执行一个分支后,将其结果移入GHR。

预测时,我们使用GHR的值作为索引,去查一张模式历史表(PHT)。PHT的每个表项可以是一个简单的位,也可以是一个两位计数器。该表项记录了上一次遇到相同的全局历史模式时,当前分支的结果。我们根据这个记录来预测当前分支。

这种预测器被称为两级全局历史分支预测器。它首次成功应用于Intel的Pentium Pro处理器,显著提升了预测精度。

Gshare预测器:结合PC与全局历史

一个改进版本是Gshare预测器。它不仅仅使用GHR,而是将分支的PC(程序计数器)的一部分与GHR进行异或(XOR),然后用结果作为索引去查找PHT。

这样做的优点是:

  1. 增加了预测上下文:结合了“哪个分支”和“全局发生了什么”两方面的信息。
  2. 更好地分散了索引:异或操作有助于将访问更均匀地分布到PHT的不同表项上,提高了硬件资源的利用率。

公式表示为:Index = (PC[bits] XOR GHR) mod (PHT_Size)

局部分支相关性

局部分支相关性是指当前分支的结果与其自身过去多次执行的结果序列存在关联,典型的例子是循环。

例如,一个循环结束分支的执行模式可能是 ... 1 1 1 0 1 1 1 0 ...(1表示跳转循环,0表示退出)。如果我们能记住该分支最近几次(例如4次)的执行结果(1 1 1 0),那么当再次看到这个模式时,我们就可以预测下一次执行将是跳转(1),从而完美预测循环结束。

为了实现局部历史预测,我们需要:

  1. 局部历史寄存器表(LHRT):以分支PC索引,每个表项记录该分支最近N次执行的历史(一个位向量)。
  2. 模式历史表(PHT):以从LHRT中取出的局部历史值作为索引,查找该历史模式对应的预测位(或计数器)。

这构成了两级局部历史分支预测器。它对于具有规律模式的循环分支非常有效。


混合与选择预测器

我们观察到,不同的分支具有不同的可预测性特征:

  • 有些分支用简单的两位计数器就能预测得很好。
  • 有些分支需要利用全局相关性(Gshare)。
  • 有些分支则依赖于自身的局部历史模式。

因此,没有一种“万能”的分支预测算法能适用于所有分支

混合预测器 的设计思想是:同时实现多种类型(异构)的预测器,并动态地为每个分支选择(或组合)最佳的预测结果。这需要一个额外的元预测器(或选择器) 来决定在特定时刻对当前分支使用哪个子预测器的输出。

例如,Alpha 21264处理器实现了一个著名的混合预测器,它包含:

  • 一个基于12位全局历史的预测器。
  • 一个基于10位局部历史的预测器。
  • 一个“选择预测器”,它根据全局历史来决策当前分支更信任哪个子预测器的结果。

混合预测器的优点是能获得更高的整体准确率,并能缓解长历史预测器“预热”慢的问题(在预热期使用简单的预测器)。缺点是硬件更复杂、延迟更高、功耗更大。


现代高级预测技术

随着对预测精度要求的不断提高,研究者引入了更复杂的机器学习方法。

感知器预测器

感知器预测器将分支预测视为一个二分类问题(跳转/不跳转)。其核心是一个单层神经网络(感知器)。

工作原理

  1. 输入向量(X):全局历史寄存器(GHR)的位,但用 +1(跳转)和 -1(不跳转)表示。
  2. 权重向量(W):每个输入位对应一个权重,表示该历史位与分支结果的相关性。权重在训练中更新。
  3. 预测计算:计算输出 y = W0 + Σ (Wi * Xi)W0是偏置项。如果 y >= 0,预测跳转;否则预测不跳转。
  4. 训练:根据预测是否正确,按照特定规则更新权重向量。

优势

  • 可以学习不同历史位与分支结果之间复杂的线性关系。
  • 支持很长的历史长度,因为其存储开销与历史长度成线性关系,而非指数关系(不像用历史直接索引的PHT)。
  • 在实践中被证明非常有效,已在AMD等公司的处理器中实现。

劣势

  • 需要硬件实现乘加运算,增加了复杂性和延迟。
  • 作为线性模型,无法学习非线性可分函数。

几何历史长度预测器

另一个观察是:不同的分支需要不同长度的历史才能达到最佳预测

几何历史长度(TAGE)预测器 的核心思想是:并行使用多个预测表,每个表使用不同长度的全局历史进行索引。这些历史长度通常按几何级数增长(例如,0, 2, 4, 8, 16, ...),因此得名。

工作流程

  1. 对于一个分支,同时用其PC和不同长度的GHR哈希后,查询多个预测表。
  2. 每个表返回一个预测结果和一个“有用性”标签。
  3. 选择器优先选择使用最长历史且其标签匹配的那个表的预测结果。这基于一个启发式:更长且匹配的历史通常提供更准确的上下文。
  4. 有一套复杂的机制来分配表项、更新预测和评估“有用性”。

TAGE预测器能够高效地为不同分支提供“恰到好处”的历史长度,是当前高性能处理器中主流的预测器架构之一,常与感知器等预测器结合形成多级预测结构。


其他重要概念

分支置信度估计

除了预测方向,还可以估计本次预测的置信度(即预测正确的可能性)。置信度估计器通常基于分支近期的预测正确/错误模式来判断。

置信度信息非常有用,可以用于:

  • 指导混合预测器的选择
  • 流水线门控:当连续取到多个低置信度分支时,暂停取指以节省功耗,因为继续推测执行的收益很可能很低。
  • 触发更复杂的恢复或预测机制。

间接分支预测

我们讨论的主要是条件分支的方向预测。对于间接分支(如跳转到寄存器指定的地址),其方向总是跳转,但目标地址是变化的。现代处理器也有专门的间接目标预测器,通常基于分支历史来预测下一次跳转的目标地址。


总结

本节课我们一起深入探讨了高级分支预测技术。我们从简单的静态预测和上次结果预测器出发,逐步构建了更复杂的机制:通过引入滞后(两位计数器)来稳定预测;通过利用全局和局部历史相关性(两级预测器、Gshare)来捕捉更丰富的模式;通过混合多种预测器来应对分支行为的异构性;最后,我们看到了机器学习方法(感知器)和精细化的历史长度管理(TAGE)如何将预测精度推向新的高度。现代处理器的分支预测单元已经成为一个极其复杂且关键的子系统,它融合了数十年来众多创新的思想,是维持高性能流水线效率的核心技术之一。尽管挑战依然存在,尤其是在新兴的复杂工作负载下,但分支预测领域持续的创新确保了处理器性能的不断提升。

15c:乱序执行中的加载-存储处理 (Spring 2025) 🧠

在本节课中,我们将要学习乱序执行引擎中最复杂、最棘手的部分:加载和存储指令的处理。我们将探讨内存操作与寄存器操作的根本区别,理解由此带来的“内存地址未知”问题,并介绍几种处理加载-存储依赖关系的基本方法。

概述:内存与寄存器的根本区别

上一节我们介绍了寄存器重命名和乱序执行的基本机制。本节中我们来看看内存操作带来的独特挑战。处理寄存器已经为系统增加了许多复杂性,但这实际上是相对容易的部分。我们将寄存器视为处理器状态的一部分。那么内存呢?内存与寄存器之间存在多个根本性的差异。

第一个差异非常有趣,因为它将导致设计中的大量复杂性和难题。寄存器依赖是静态的,意味着你查看一条指令时,它引用了寄存器R3,你就知道它的源和目的。在指令解码后,你可以在流水线前端轻松地进行重命名。

但内存操作则不同。你需要执行指令的一部分,才能获取到内存地址。因此,在流水线开始的解码阶段,你并不知道一条指令的内存地址。这正是我们将要面对的所有难题的根源。

除此之外,内存地址空间很大,而寄存器状态很小。我们通常处理32或64个寄存器,但内存状态是巨大的,地址范围非常广。这进一步加剧了问题的复杂性。

寄存器状态的另一个特点是,在多线程程序中,不同线程通常不共享寄存器,因此无需担心它们。但在当今主流的共享内存多处理器中,内存状态是在不同线程和处理器之间共享的。如果以乱序方式更新内存状态,可能会引发问题,不过我们本来就不希望乱序更新内存状态。我们不会深入探讨这个问题,但这是内存与寄存器的另一个区别。

前两个问题将导致乱序执行中的难题,最后一个问题或许可以处理,但需要学习高级架构课程才能深入理解,那些同样是非常棘手的问题。

核心问题:内存地址未知与依赖关系

那么,具体有哪些问题呢?基本上,在一个乱序执行的机器中,你乱序执行指令,不仅需要遵守寄存器依赖,还需要确保内存依赖关系被正确遵守。这意味着一个加载指令可能依赖于一个存储指令,你需要确保加载指令从正确的存储指令获取到正确的值。

我们通过寄存器重命名很好地处理了寄存器依赖。但内存地址不是寄存器地址。寄存器地址在流水线开始时就知道,所以我们可以轻松地重命名到另一个命名空间。而内存地址,直到我们执行指令时才知道。

我们需要在提供高性能的同时做到这一点。这就是问题的核心。关键观察和核心问题是:加载或存储指令的内存地址直到该指令执行时才知道。

第一个推论是:因此,内存重命名很困难。你无法在解码阶段进行重命名。如果你真想重命名,必须在指令执行后进行,但这太晚了,因为指令已经乱序执行了。重命名的美妙之处在于,当你进行重命名时,指令是按序的,因此我可以正确地将生产者与消费者联系起来。而对于内存,情况则是一团糟。

让我们通过一个例子来快速说明。假设我们有一段程序,有一条存储指令,它基于寄存器R5和某个偏移量计算出一个地址A,然后将R10的值写入该地址。接着有一条加载指令,基于寄存器R25计算其地址B,并将结果写入某个寄存器。

关键问题是:当加载指令执行时,它生成地址B;当存储指令执行时,它生成地址A。地址A和B是否相同?如果你知道地址A和B,那很好,你可以比较它们。但如果由于乱序执行,加载指令先执行了呢?因为存储指令可能依赖于某个需要1000个周期才能完成的操作,而加载指令可能只依赖于一个需要2个周期的操作。所以加载指令准备就绪,生成了它的地址B。而存储指令还在等待它的源寄存器(例如R5),你根本不知道存储指令的地址是什么,但你又想执行这个加载指令。

这就产生了问题。因为如果地址最终是相同的,你希望加载指令能从存储指令获取值。那么你该怎么办?这就是关键问题。

问题的推论与复杂性

本质上,我们刚才展示的情况引出了第二个推论:确定加载和存储指令之间的依赖或独立性,必须在它们部分执行之后才能处理。 我说“部分执行”,是因为加载指令需要生成地址,然后从该地址加载值到寄存器。地址生成只是它执行的一部分。下一步是访问内存获取该位置的值。但如果你不知道是否有存储指令要写入那个地址,那么你可能应该等待。

还有第三个推论,这也是我讨论过的:当一条加载指令的地址就绪时,机器中可能存在地址未知的更早的存储指令。反之亦然,当一条存储指令的地址就绪时,机器中可能存在地址未知的更晚的加载指令。当然,第一种情况更为严重。

让我们看看这个例子。基本上,这就是我展示的情况:当这条加载指令的地址已经就绪时,它不知道存储指令是否要写入。更糟糕的是,可能还有另一个存储指令,其地址也未就绪。而且这条加载指令可能读取,比如说,4个字节,其中1个字节来自这里,1个字节来自那里。所以实际情况可能比你最初想象的还要混乱。可能有很多存储指令写入这些位置。因此,你需要正确获取值。

我们不会解决整个问题。我将向你展示问题的复杂性以及一些处理方法。请相信,这是乱序执行引擎中最混乱的部分,也是可扩展性最差的部分。如果你曾认为标签广播逻辑是瓶颈,那你就错了,真正限制可扩展性的是这个逻辑。

关键挑战:何时调度加载指令?

那么,关键问题是:在乱序执行引擎中,何时调度一条加载指令? 正如所说,问题在于一条更晚的加载指令可能在其前面更早的存储指令地址未知之前,就已经地址就绪了。这也被称为内存歧义消除问题未知地址问题。我喜欢“未知地址问题”这个说法,因为它更直观。

处理这个问题有几种方法:

  • 保守方法:通常对性能非常不利。
  • 激进方法:与保守方法完全相反。
  • 智能方法:几乎所有现有机器都采用,本质上更智能。

以下是每种方法的简要介绍:

保守方法非常不利于性能。你阻塞加载指令,直到所有前面的存储指令都完成了地址计算。你可以这样做。在这个例子中,有一堆比这条加载指令更早的存储指令,你基本上等待直到它们都计算完地址。无论这需要1000个还是5000个周期,你只是等待。当它们都计算完地址后,现在你就知道了(当然,还需要做一些操作来确定依赖关系)。如果你更保守,甚至可以等到所有存储指令都离开机器(即都已提交并更新了内存)。但这需要更长时间。所以保守方法对性能非常不利。

激进方法则完全相反。它基本上是说:我是一条加载指令,我知道我的地址。我假设我独立于任何前面的存储指令。我立即调度这条加载指令。这是一种预测,希望加载指令不依赖于任何存储指令。当然,如果你这样做,之后需要检查预测是否正确。这是机器中预测的一个例子。这种方法通常比保守方法好,但也不是非常完美,并且需要额外的机制来检查。如果你预测错了,就需要重新执行这条加载指令以获取正确的值。如果错了,基本上需要刷新流水线。

智能方法是更智能的预测。正如所说,你使用更复杂的预测器来预测加载指令是否依赖于任何地址未知的存储指令。让我们更详细地看看这些方法。

处理加载-存储依赖的选项

正如所述,一条加载指令的依赖状态,直到所有前面存储指令的地址都可用时才知道。这里有两个问题:

  1. 如何检测一条加载指令对前面存储指令的依赖?
  2. 如何根据前面的存储指令来对待加载指令的调度?

对于第一个问题(检测依赖),有两种选择:

  • 选项A:你等待,直到所有前面的存储指令都提交。在这种情况下,无需检查地址匹配。逻辑简单。
  • 选项B:你在一个存储缓冲区(也称为存储队列)中保留一个待处理存储指令的列表,并检查加载地址是否与前面存储指令的地址匹配。

对于第二个问题(调度策略),有三种选择:

  • 选项1:假设加载指令依赖于所有前面的存储指令。
  • 选项2:假设加载指令独立于所有前面的存储指令。
  • 选项3:预测依赖关系。

让我们逐一分析调度策略:

选项1(假设依赖) 的优点是无需添加任何逻辑,你只需等待所有前面的存储指令完成。但这是真正的保守方法,因为它会不必要地延迟独立的加载指令。在这个例子中,如果这些存储指令需要1000个周期,而这条加载指令已就绪且是独立的,你基本上延迟了它1000个周期。

选项2(假设独立) 可能很简单,并且是常见情况。对独立的加载指令没有延迟。但是,如果预测错误,我们需要为恢复付出代价。首先,你需要发现你错了。所以需要一些额外的逻辑来检查:当你发送并执行了这条加载指令后,如果错了,需要某种检查机制。当存储指令计算地址时,它需要检查是否有一条加载指令曾假设独立于它。这很混乱。基本上,它需要恢复并重新执行加载指令。现有的机器在出错时通常会刷新流水线。

选项3(智能预测) 是智能选项。你基本上预测一条加载指令是否依赖于一个未完成的存储指令。我不告诉你具体如何做,但有相应的机制。实际上,这曾是一所大学(我不点名)和一些公司之间一场非常著名的法律诉讼的主题,该大学声称他们为这项工作申请了专利。这显然更准确,因为加载-存储依赖关系实际上会随时间持续存在。人们发现,如果一条存储指令写入一个位置,而一条加载指令要读取该位置,例如,当你在循环中回到相同的加载和存储时,这种情况会一次又一次地发生,因此你可以从过去的执行中学习。当然,如果你错了,仍然需要恢复和重新执行。有一些非常有趣的论文,例如关于Alpha 21264的(可选阅读材料),你会看到每当它执行一条加载指令时,最初假设它是独立的。然后判断对错。如果错了,下一次它就说:我不再假设它是独立的,我假设它是依赖的。所以它基本上会随时间学习。

这张图非常快速地展示了保守方法(无推测)、激进方法和理想方法的性能对比。Y轴是指令每周期数,X轴是20世纪90年代人们使用的一些工作负载。如你所见,保守方法确实很差,激进方法稍好一些,但理想方法有巨大的差距。这就是为什么你希望很好地处理这个问题。简单的预测器实际上可以实现大部分潜在性能。

加载-存储数据转发与存储队列

我们不会讨论如何进行预测,你可以留待想象。但让我们也谈谈加载和存储之间的数据转发。如果我们不能以乱序更新内存(显然我们甚至不能乱序更新寄存器,因为这会违反顺序语义),这意味着你需要缓冲所有存储和加载指令在指令窗口中。我们知道可以使用重排序缓冲区来实现。

现在,我暂时搁置地址未知的问题。假设我们知道所有前面存储指令的地址,问题仍然复杂。当我们生成加载指令的地址时,仍然有两个问题:

  1. 我们如何检查它是否依赖于一个存储指令?(假设你希望调度加载指令,而不想采用保守方法)
  2. 如果它依赖于一个存储指令,我们如何将数据转发给加载指令?

为此,你需要一个特殊的数据结构。这些特殊结构通常解耦为加载队列存储队列。也可以合并,例如英特尔的Pentium Pro有一个称为内存排序缓冲区的结构。所以,每当你想生成加载指令的地址时,你需要搜索存储队列,以检查是否有你依赖的存储指令,以便决定是否可以调度加载指令。而一条存储指令,当它完成执行(计算其地址)时,会搜索加载队列,看看是否有加载指令依赖于它。第二种情况是预测机制所需要的:如果你预测这条加载指令是独立的并执行了它,但后来存储指令的地址变得可用,那么存储指令需要检查是否有任何加载指令因为被预测为独立于它而获取了错误的值。

即使你不做预测,第一种情况也是需要的,你只是想能够判断这条加载指令是否依赖于某个存储指令,即使假设你知道所有前面存储指令的地址。

所以,当一条存储指令完成执行时,它将地址和数据写入其重排序缓冲区条目或存储队列条目。当一条更晚的加载指令生成其地址时,它基本上用其地址搜索存储队列。我们来看看这个搜索有多糟糕。它用地址访问(搜索)存储队列,并(希望)从写入该地址的最晚的、更早的指令那里接收一个值。

为了做到这一点,你需要复杂的搜索逻辑。还记得我在上一讲中介绍的内容可寻址存储器吗?这是你必须为乱序执行加载和存储而启用的最糟糕的内容可寻址存储器。内容是内存地址,但你还需要其他信息,如大小、年龄等。

存储到加载转发逻辑的复杂性

这被称为存储到加载转发逻辑。基本上,存储队列是一个按序排列的、机器中所有存储指令的列表。它是一个硬件队列,有头、有尾。每个条目需要包含:有效位、存储地址(可能是64位)、存储的数据值(如果可用,也可能是64位)、数据有效位。当然,你还需要有效位来指示存储地址是否有效、存储数据是否有效。这变得非常有趣和复杂,因为存储指令执行时,如果它还没有计算地址,其地址无效,但数据可能已经就绪(在乱序引擎中)。

假设存储队列中有一些条目,地址分别为A、B、C、D、X。我们有一条加载指令,计算其地址为C。关键问题是:这条加载指令应该从哪里获取值?它需要做的是将地址C与队列中所有地址进行比较。基本上,对于每个条目,你都需要一个比较器。这听起来已经很糟糕了,而且会变得更糟。这是一个64位比较器(假设地址是64位)。实际上我夸大了,普通机器不使用整个64位地址空间,假设是48位,更现实一些。所以是48位地址比较器。

但这还不够。我之前给过一个例子,一个存储写入这里的一个字节,另一个存储写入那里的另一个字节,而这条加载指令读取这两个字节。所以你可能在多个位置匹配。这是第一个观察结果:这不是单一匹配。你确实需要在多个位置匹配,并且需要获取写入这些位置的最新存储指令。此外,更复杂的是,你需要确保大小也匹配。基本上,这是一个基于加载地址和大小、以及更早存储指令地址和大小的范围搜索,因为你可能与地址部分重叠。这条加载指令可能访问字节8到12,而存储指令可能写入字节11到12。这是一个基于年龄的搜索,你想获取最后写入的值。

除此之外,数据可能来自多个存储指令。假设你加载地址Z、Z+1、Z+2、Z+3(一个4字节加载)。你需要确保找到写入每个位置的所有最新存储指令。你可能找到一个写入这里的,另一个写入那里的,第三个写入那里的。三个存储指令写入了这里,那第四个字节从哪里来?现在你需要从内存访问那个字节,因为没有存储指令写入它。所以,为了获取数据值,你需要搜索存储队列,同时还需要访问内存。这就是它成为最复杂部分之一的原因。当然,搜索时可能没有匹配,那么你当然需要访问内存来获取所有数据。因为可能没有任何存储指令写入你正在读取的位置。此时,你需要访问内存。所以这个搜索可能是无用的,但你需要进行搜索以确保为加载指令获取正确的值。

是的,所以存储队列不仅用于检查是否存在风险,也用于数据转发。它有两个功能:判断是否可以调度这条加载指令;以及如果加载指令确实依赖于某个存储指令,则提供值用于数据转发。

总结与扩展性限制

我已经讲过了,这是我给出的最后一个例子。有任何问题吗?清楚这个复杂性了吗?我不会在存储到加载转发逻辑上考你们,但你们应该知道,这确实是困难的部分。人们曾尝试扩展指令窗口的大小,他们可以扩展保留站,但扩展这个逻辑非常困难,规模更大、更复杂。我相信现有的处理器可能有一个24项的存储缓冲区,即使指令窗口大小可能是256左右。与其他部分相比,它非常小。你通常受限于可以在机器中放入多少存储指令,正是因为这个原因。所以,如果你在机器中放入大量存储指令,你可能会破坏机器的性能。因此,尽量不在你的机器中存储太多数据。原因基本上就是这个逻辑非常难以扩展,机器不能容纳很多存储指令。

本节课中我们一起学习了乱序执行中加载和存储处理的复杂性。我们了解了内存地址未知带来的核心挑战,探讨了保守、激进和智能三种处理加载-存储依赖的基本方法,并深入分析了实现数据转发所需的存储队列及其复杂的搜索逻辑。理解这部分内容是掌握现代高性能处理器设计关键瓶颈的基础。

17:VLIW与脉动阵列架构 (Spring 2025)

概述

在本节课中,我们将学习两种在现代计算机中实际使用的重要架构范式。第一种是超长指令字架构,这是一种通过让软件承担复杂性、硬件保持简单的原则性设计方法。第二种是脉动阵列架构,它是当今许多机器学习加速器的基础。我们将对比这两种范式,理解它们的设计哲学、优势与挑战。


VLIW架构:软件复杂,硬件简单

上一节我们回顾了超标量执行,这是一种硬件密集型的并行方法。本节中,我们来看看一种截然不同的方法:超长指令字架构。

VLIW的核心思想是将寻找指令级并行性的负担从硬件转移到软件。编译器负责分析代码,将多个相互独立的指令打包成一个“超长指令字”捆绑在一起。硬件则简单地获取这个长指令字,并直接将其中的指令分发给对应的功能单元并发执行,无需进行复杂的依赖检查。

以下是VLIW的关键特征:

  • 编译器主导调度:编译器负责静态地分析指令间的依赖关系,并将独立的指令安排到同一个VLIW指令束中。
  • 硬件简单:硬件无需进行动态依赖检查、乱序调度或寄存器重命名,从而简化了设计。
  • 指令束锁步执行:VLIW指令束中的所有指令被视为一个整体。如果其中任何一条指令(例如一个长延迟的访存操作)发生停顿,整个指令束都必须等待。

VLIW的优势与劣势

VLIW架构的优势主要源于其简单的硬件设计:

  • 硬件复杂度低:无需动态调度硬件,易于设计、验证,通常功耗更低,可能达到更高频率。
  • 无依赖检查开销:指令束内的独立性由编译器保证,硬件无需相关电路。
  • 指令直接分发:编译器知道硬件功能单元的位置,可将指令静态对齐,无需复杂的指令分发网络。

然而,VLIW也面临显著的挑战:

  • 编译器负担重:需要极其智能的编译器来发掘足够的指令级并行性。
  • 代码膨胀:当编译器无法找到足够的独立指令填满指令束时,必须插入空操作,导致代码体积增大。
  • 对延迟变化敏感:编译器难以静态预测可变延迟操作(尤其是可能发生缓存缺失的加载指令)的确切耗时。一旦长延迟操作发生停顿,整个指令束都会停滞,严重限制性能。
  • 软硬件紧耦合:编译出的代码与特定微架构(如功能单元数量、指令延迟)深度绑定。微架构的改动可能需要重新编译代码才能获得最佳性能。

VLIW的影响与现状

尽管VLIW在通用计算领域未能成为主流(主要受限于其对可变延迟操作的处理能力),但其思想产生了深远影响:

  • 编译器优化:为VLIW开发的许多编译器优化技术(如踪迹调度、超块调度)已被现代编译器广泛采用,用于提升超标量处理器的代码性能。
  • 专用领域成功:在数字信号处理、嵌入式系统和早期的图形处理器中,由于代码行为相对可预测,VLIW取得了成功。
  • 二进制翻译:Transmeta等公司曾尝试通过软件将x86等复杂指令集动态翻译成VLIW指令,在硬件上执行,以实现简单硬件与复杂软件的平衡。

脉动阵列架构:数据流驱动的专用计算

上一节我们探讨了VLIW这种通用但软硬件分工特殊的范式。本节中,我们转向一个更专用化的领域:脉动阵列架构。

脉动阵列的设计灵感来源于血液在心脏推动下流经全身的循环系统。其核心思想是构建一个由简单处理单元构成的规则阵列,数据像血液一样,在节奏性的“脉动”中从一个处理单元流向下一个,在流动过程中被逐步处理,最后写回存储器。

脉动阵列的基本原理

脉动阵列旨在解决传统处理器中计算与I/O带宽不平衡的问题。在传统模型中,处理器频繁从内存加载数据,处理后再存回,大量时间花在数据搬运上。脉动阵列通过让数据在处理单元间流动并接受多次处理,提高了每次内存访问所完成的计算量。

一个经典的例子是使用一维脉动阵列进行卷积运算。每个处理单元预存一个权重,执行乘加操作。输入数据 x 从阵列一端流入,部分结果 y 从垂直方向流动。通过精心安排数据输入的时间,当数据流经整个阵列后,就能完成完整的卷积计算。

对于更复杂的操作如矩阵乘法,可以使用二维脉动阵列。例如,在3x3的阵列中,一个矩阵的行从左向右流动,另一个矩阵的列从上向下流动。每个处理单元累加其接收到的两个元素的乘积。当数据流编排正确时,阵列最终会在每个处理单元中生成结果矩阵的一个元素。

脉动阵列的优势与适用场景

脉动阵列架构的优势非常突出:

  • 高计算密度与能效:消除了取指、译码等通用开销,将绝大部分资源和能量用于实际的数据计算。
  • 高效利用内存带宽:数据在被“重用”于多个处理单元后才返回内存,显著提升了内存带宽的有效利用率。
  • 规则化设计:由大量相同的处理单元规则连接,设计简单,易于扩展。

其劣势同样明显:

  • 专用性强:阵列结构和处理单元的功能是针对特定计算模式(如卷积、矩阵乘法)设计的,缺乏通用性。
  • 编程与映射复杂:需要将算法精心映射到阵列的数据流模式上,对编程和编译器提出了高要求。

脉动阵列的现代应用

尽管是几十年前提出的概念,脉动阵列在当今的机器学习时代焕发了新生。谷歌的TPU等机器学习加速器核心就采用了二维脉动阵列来高效执行大规模的矩阵乘法和卷积运算,这正是深度神经网络中最耗时的操作。

此外,脉动计算的思想可以推广为一种“流水线并行”的编程模型。将一个算法划分为多个阶段,每个阶段映射到一个处理单元(可以是一个通用核心),数据像流水一样依次通过各个阶段进行处理。这种模型在视频编码、流处理等应用中非常有效。


总结

本节课我们一起学习了两种重要的计算机架构范式。VLIW架构尝试通过复杂的编译器和简单的硬件来挖掘指令级并行性,其思想深刻影响了现代编译器技术,并在某些专用领域取得成功。脉动阵列架构则采用了一种数据流驱动的专用硬件设计,通过规则的处理单元阵列和精心编排的数据流动,在保持高能效的同时实现极高的计算吞吐量,已成为现代机器学习加速器的基石。这两种范式展示了计算机架构设计中“权衡”的艺术:在通用性与效率、硬件复杂度与软件复杂度之间寻找不同的平衡点。

18:SIMD架构 (Spring 2025)

概述

在本节课中,我们将要学习单指令多数据架构。这是计算机体系结构中一个非常基础且重要的主题。当今许多重要的架构,例如作为机器学习应用执行基石的GPU,实际上都在实现SIMD范式。我们将探讨SIMD的基本概念、其实现方式(如阵列处理器和向量处理器),并分析其优势与挑战。


回顾脉动阵列

上一节我们介绍了用于实现加速器的脉动阵列架构。我们以矩阵乘法为例,探讨了数据如何以预定的、有节奏的方式流经功能单元进行计算。

以下是脉动阵列架构的一些优点缺点

优点

  • 高能效:作为专用空间架构,能提供很高的效率。
  • 数据复用率高:每个数据项被多次使用,减少了对内存带宽的需求,更好地利用了内存带宽。
  • 高并发性和规整设计:便于实现。

缺点

  • 不擅长利用不规则并行性。
  • 专用性强:需要软件和编程支持才能变得更通用。
  • 如果问题不匹配,编程会变得困难。

现代实例包括Google的张量处理单元,它使用脉动阵列进行矩阵乘法。然而,研究表明,在运行大型机器学习模型时,超过90%的系统能耗花费在片外互连和DRAM上,这凸显了改进内存系统的重要性,并引出了以数据为中心的计算范式,即在整个系统的各个部分(如传感器、存储、内存)就近执行计算,以实现更平衡的设计。


SIMD架构:利用数据并行性

现在,让我们转向SIMD架构,它专注于开发数据并行性。SIMD代表单指令多数据。在深入之前,我们先了解Flynn对计算机的分类法,该分类法基于指令流和数据流的数量:

  • SISD:单指令流单数据流。类似于我们见过的标量处理器。
  • SIMD:单指令流多数据流。本节课的重点,阵列处理器和向量处理器是典型例子。
  • MISD:多指令流单数据流。最接近的形式是脉动阵列处理器。
  • MIMD:多指令流多数据流。例如多处理器和多线程处理器。

当今的片上系统通常结合了所有这些范式。

SIMD利用的是数据并行性,即对不同的数据片段执行相同的操作,例如向量加法。这与控制并行性(执行不同的控制线程)和数据流并行性(以数据驱动的方式执行不同操作)形成对比。SIMD也可以被视为一种指令级并行

SIMD的处理范式是:单条指令操作多个数据元素。这可以通过时间空间来实现:

  • 阵列处理器:指令在同一时间,使用不同的空间(多个处理单元)操作多个数据元素。
  • 向量处理器:指令在连续的时间步,使用相同的空间(流水化的功能单元)操作多个数据元素。

为了支持向量操作,我们需要向量数据寄存器,每个寄存器可以容纳N个M位的值。此外,还需要向量长度寄存器向量步长寄存器来控制操作。

以下是阵列处理器与向量处理器执行一段向量代码的对比示例:

  • 代码:加载向量 -> 向量加1 -> 向量乘2 -> 存储结果。
  • 阵列处理器:假设有4个处理单元,每个都能执行所有操作。可以在同一周期并行加载4个元素,下一周期并行执行加法,以此类推。
  • 向量处理器:假设功能单元是流水化的。第一周期加载元素0,第二周期加载元素1并对元素0执行加法,以此类推。在稳态下,也能达到高吞吐量。

向量处理器通常更易于实现,因此在早期硬件资源有限时更受青睐。而随着集成度提高,阵列处理器也变得流行。现代的GPU则巧妙地结合了这两种范式。


SIMD与VLIW的比较

SIMD阵列处理与超长指令字架构有相似之处,但也有关键区别:

  • VLIW:在一条长指令中打包多个独立的操作,编译器确保它们无依赖,然后发送到不同的处理单元。
  • SIMD:单条相同的操作应用于多个不同的数据元素。

在SIMD中,只需取指和译码一条指令,其开销可以分摊到众多数据元素上,因此效率更高。VLIW虽然更通用,但其并行性在编译时难以充分利用。对于向量运算等规则并行性应用,SIMD处理非常有效,这也是其如今成功的原因之一。


深入向量处理器

向量是一维的数字数组。向量处理器是其指令操作于向量而非标量值的处理器。基本要求包括:

  • 加载和存储向量 -> 需要向量寄存器
  • 操作不同长度的向量 -> 需要向量长度寄存器
  • 支持非连续内存访问 -> 需要向量步长寄存器

步长是指向量中连续元素在内存地址上的间隔。例如,在行主序存储的矩阵中,访问一行元素步长为1,访问一列元素步长则为列数。硬件需要支持不同的步长。

向量指令在连续周期内对每个元素执行操作。向量功能单元是流水化的。向量指令允许更深的流水线,因为向量内部没有数据依赖,也没有控制流,并且内存访问模式高度规则,便于地址计算。


向量处理器的性能与内存瓶颈

让我们通过一个具体的循环例子来比较标量实现和向量实现的性能。

标量代码(循环50次):

for (i=0; i<50; i++) {
    C[i] = (A[i] + B[i]) / 2;
}

在顺序标量处理器上执行,假设内存访问延迟为11周期,总共需要约1504个周期。

向量化代码

VL = 50        // 设置向量长度
VS = 1         // 设置步长
V0 = load(A)   // 向量加载
V1 = load(B)   // 向量加载
V2 = add(V0, V1) // 向量加
V3 = shr(V2, 1)  // 向量右移1位(除以2)
store(V3, C)   // 向量存储

动态指令数从标量的304条减少到向量的7条。在向量处理器上执行,假设无链接、单内存端口、16个存储体,执行时间减少到285个周期。

性能可以通过向量链接(类似数据前推)进一步提升。如果还假设有多个内存端口,执行时间可降至79个周期,相比标量代码有约19倍的性能提升。

然而,内存带宽很容易成为SIMD架构的瓶颈。一个向量加载指令可能请求海量数据,内存系统必须能提供足够的带宽来供给处理流水线,否则处理单元就会空闲等待。这凸显了平衡计算与内存操作的重要性。

为了维持每个周期一个元素的吞吐量,需要采用存储体交错的内存设计。将内存划分为多个可以独立访问的存储体,共享地址和数据总线。如果连续访问的元素分布在不同的存储体上,就可以实现并发访问。条件是:存储体数量 > 存储体访问延迟。否则会发生存储体冲突,降低效率。现代GPU的高带宽内存就采用了高度存储体化的设计。


处理复杂情况

在实际应用中,向量处理会遇到一些复杂情况:

  1. 向量长度 > 寄存器长度:采用条带挖掘技术。将循环分解,每次迭代处理一个向量寄存器长度的数据,最后一次迭代调整向量长度。

  2. 非连续内存访问(间接寻址):使用聚集散播操作。

    • 聚集:根据索引向量,从内存的非连续位置收集数据到向量寄存器。
    • 散播:将向量寄存器中的数据根据索引向量,散播到内存的非连续位置。
      这对于处理稀疏矩阵非常有用。
  3. 条件执行:使用向量掩码实现谓词执行。

    • 方法一:执行所有操作,但根据掩码值决定是否写回结果。实现简单,但可能浪费计算。
    • 方法二:先扫描掩码,只对掩码非零的元素执行操作。当掩码中零值较多时更高效。
      向量掩码寄存器用于控制哪些向量元素参与操作。

总结

本节课我们一起学习了SIMD架构的核心内容。我们了解到SIMD通过单条指令操作多个数据元素来开发数据并行性,主要有阵列处理器(空间并行)和向量处理器(时间并行/流水线)两种实现方式。我们探讨了向量处理器所需的硬件支持(如向量寄存器、长度/步长寄存器),并通过实例分析了其性能优势及内存带宽带来的挑战。我们还学习了如何处理向量长度溢出、不规则访问和条件执行等复杂情况。SIMD范式因其高效性,已成为现代许多高性能计算架构(如GPU)的基石。理解SIMD的原理,有助于我们更好地理解当今复杂的计算系统。

18b:解耦访存-执行架构 (Spring 2025) 🧠

在本节课中,我们将要学习一种名为“解耦访存-执行”的处理器设计范式。这种架构旨在通过分离内存访问和计算执行来提升性能,同时避免传统乱序执行带来的硬件复杂性。

概述 📋

解耦访存-执行架构的基本思想非常简单。其核心是将单一指令流拆分为两个独立的指令流:一个负责内存访问,另一个负责计算执行。这两个处理器通过指令集架构可见的队列进行通信。这种设计允许访存单元和计算单元异步工作,从而隐藏内存访问延迟,提高整体效率。

基本思想与动机 💡

上一节我们概述了DAE架构。本节中,我们来看看其具体思想和设计动机。

其动机源于Tomasulo算法过于复杂,难以在早期(如1980年代奔腾Pro之前)实现。人们希望系统不要如此复杂。VLIW架构也面临类似情况,其设计哲学与乱序执行截然不同。DAE架构拥有与VLIW相似的设计哲学,但并未在硬件上走到极致。

DAE架构的硬件改动相对简单。其核心思想是解耦操作访问(或内存访问)与执行(计算)。我们拥有两个独立的指令流,它们通过ISA可见的队列进行通信,这就是基本思想。

一个解耦的访存-执行系统看起来是这样的:你有一个访存处理器和一个执行处理器。访存处理器的任务仅仅是获取内存数据并供给执行处理器。执行处理器的任务则是将其所需的内存地址提供给访存处理器。它们基本上通过这些队列进行通信。

这在某种意义上非常巧妙,因为这是两种不同类型的任务。内存访问可能受限于内存带宽,而计算可能不会成为瓶颈。因此,你可以在等待内存的同时继续进行计算。反之亦然,有时你可能在等待长时间的计算,但可以继续进行内存访问。这样,无需实现完整的乱序执行,你就能在访存操作进行时,访存与执行处理器之间无需停顿,可以继续执行计算,反之亦然。这正是其美妙之处。

该架构由Jim Smith在1982年的开创性论文中提出,其基本原理至今仍应用于计算系统中,尽管形式不完全相同。

架构详解与优势 ⚙️

理解了基本思想后,我们深入探讨其具体实现和带来的好处。

首先,你可以看到ISA需要在此改变,因为通信通过这些队列进行。这些是FIFO队列,它们是指令集架构可见的。因此,队列的长度决定了你能容忍的内存端和执行端的延迟量。这些队列的好处在于它们具有很高的可扩展性,不像重排序缓冲区的标签匹配逻辑或保留站那样难以扩展。负载存储队列也难以扩展,而这里的队列是可扩展的,因为它们是FIFO队列。还有一个分支队列,因为你需要保持这些流的同步,但基本上所有这些都是FIFO队列。

本质上,基本思想是:与其拥有一个像这样的单一指令流(这是一个非常著名的循环,Livermore循环,用于科学计算),你基本上拥有两个指令流:访存流和执行流。它本质上在做同样的事情,但每当需要进行内存访问时,你在访存流中进行;每当需要进行操作执行和分支时,你在执行流中进行;每当需要将内存访问结果传递给执行处理器时,你需要将其放入“访存到执行”队列。你可以看到这些内存访问进入该队列,而执行引擎从该队列取出数据,并可能将结果放入“执行到访存”队列。通信就是通过这些队列进行的。

以下是DAE架构的主要优势:

  • 异步执行:执行流可以领先于访存流运行,反之亦然。如果访存处理器在等待内存,执行处理器可以执行有用的工作。如果访存处理器(例如)缓存命中且无需等待内存,它可以为落后的执行处理器提供数据。通常内存访问耗时更长,因此执行单元通常可以在访存处理器等待时,独立执行有用的指令。
  • 简化硬件:关键思想是队列减少了对大量寄存器的需求。这些不是寄存器,你不需要像乱序执行引擎那样拥有数千个寄存器或内部物理寄存器文件。通信通过这些FIFO队列进行。因此,你基本上获得了有限的乱序执行能力,但没有唤醒和选择逻辑的复杂性,也无需庞大的物理寄存器文件。

面临的挑战与编译器的角色 🔧

当然,任何设计都有其缺点。现在,编译器在这里变得非常重要。编译器对VLIW很重要,对Tomasulo算法很重要,对解耦访存-执行架构也很重要。

你需要编译器支持来划分程序和管理队列。这决定了你能获得多大程度的解耦。人们为此开发了许多有趣的编译技术,虽然不如VLIW那么多,也不如如今在脉动阵列上的工作那么多,但编译器仍然很重要。

另一个缺点是分支指令需要在访存处理器和执行处理器之间进行同步。因为你实际上是将一个单一指令流分离成两个指令流,那么分支会怎样?它们会在执行处理器中执行,但你需要通知访存处理器,以确保访存处理器不会永远走在错误的路径上,对吗?

还有一个缺点是多重指令流。基本上,你需要生成或编写两个指令流,这可能很繁琐。但后来的研究表明,这可以通过动态地将单一指令流分流到多个处理器来实现。

实例分析:Alewife处理器 🖥️

这是一个具体的例子。这是Alewife处理器,他们所做的是:拥有一个单一的指令取指单元,然后动态地将其分离成一个访存处理器(访存指令流水线)和一个执行处理器(执行指令流水线)。每个流水线都是顺序执行的,这一点非常重要。每个流水线内部都是简单的顺序执行,没有乱序执行。乱序执行的能力来自于一个指令流水线异步于另一个指令流水线工作,直到它需要来自那个流水线的数据。你可以看到有访存寄存器和执行寄存器,以及需要在两个流之间通信的队列。你还可以看到有多个指令流,还有一个复制单元,可以将操作从一个流水线复制到另一个流水线。他们添加了一些有趣的通信机制。存储和加载操作一如既往地存在问题,我不打算深入细节。但如果你真的感兴趣,这些论文实际上对解耦访存-执行范式提供了非常易懂且精彩的描述。你还可以看到一个重启/停止单元,用于处理分支。

循环展开:一项关键的编译技术 🔄

分支处理是一个大问题。因此,许多编译器使用循环展开来消除分支。循环展开在一个迭代内多次复制循环体。你可能已经学过循环展开,我不得不提一下,因为它是一项非常基本的编译器技术,旨在尽可能消除分支,因为分支在VLIW、解耦访存-执行以及脉动阵列中总是带来问题。你希望尽可能减少真正的分支。

循环展开的思想是在一个迭代内多次复制循环体,正如你在这里看到的。当然,现在你在一个原始迭代内做了四次迭代的工作,所以你需要确保正确地递增数值,这实际上会成为一个问题。但如果你这样做,你现在就不需要执行那么多分支,不需要执行那么多循环控制指令等等,从而减少了循环维护的开销。归纳变量的递增或循环条件测试会消失或减少(在这个例子中减少了四分之三)。

你扩大了基本块。现在我们这里有一个更大的基本块,而不是这里单一的指令集。这为代码优化和调度创造了机会。问题通常发生在迭代次数不是展开因子的倍数时。在这个例子中,展开因子是4,你将四次迭代放入一个原始迭代中。但如果n不是4的倍数,你就会遇到问题,需要额外的代码来检测和处理这种情况,这最终会增加代码大小。但循环展开是一项非常简单的基于编译器的技术,有助于我们今天讨论的所有处理器,解耦访存-执行是其中重要的一员。他们在讨论解耦访存-执行时也大量谈及循环展开,它提高了Alewife处理器的性能。对于未来,尤其是如果你对编译和硬件等主题感兴趣,思考这一点很重要,因为这是一项非常基本的编译机制。

现实影响与现代应用实例 🚀

现在,让我为你展示解耦访存-执行在真实处理器中的影响,然后我们就结束。基本上,正如所描述的,它并不完全以原样应用于现有处理器,但原则上,解耦访存-执行的思想在我所知的一些旧处理器中得到了应用。

例如,这是奔腾4处理器的内部结构。我不会详细讲解这里的所有内容,但我会指出这一部分:在指令被重命名并分配到重排序缓冲区(例如和寄存器)之后,它们会经过一个内存部分和一个执行部分。你可以看到这就是解耦。你有一个处理器的内存部分和一个执行部分,它们彼此解耦,使得内存部分专为内存操作定制,执行部分专为执行操作定制。即使是在像这样的乱序处理器中(这是一个乱序执行超标量处理器),你也能看到它解耦了访存和执行。这样,你可以在不同组件之间获得专业化,并且基本上在这些不同组件之间获得不同的乱序执行能力,它们不会相互干扰。

如果你想看奔腾4的简化视图,这实际上是另一种看待它的方式。你可以看到内存和整数执行的解耦。这来自我的论文,是对奔腾4中解耦访存-执行的一个更简单的看法。你实际上可以将这个概念扩展到不同类型的执行,例如浮点执行和整数执行。

总结与展望 🎯

本节课中,我们一起学习了三种主要的设计思想:VLIW、脉动阵列和解耦访存-执行。解耦访存-执行架构通过分离访存和计算流,利用队列进行通信,在简化硬件复杂性的同时,有效地隐藏了内存延迟。它需要编译器的重要支持,并面临分支同步等挑战。其思想在现代处理器(如奔腾4)的设计中仍有体现。思考这些架构在未来可能的应用场景也很有价值。

关于是否可以将DAE与VLIW结合使用,这是一个极好的问题。我的高层回答是:是的。基本上,如果你能结合VLIW的思想(当然需要放弃一些基本的VLIW原则),你可以将VLIW指令束的一部分作为内存束,另一部分作为执行束,绝对可以在VLIW指令的不同部分之间解耦访存和执行。这样,你摆脱了锁步执行,同时在VLIW引擎中也获得了部分乱序执行的好处。这正是我喜欢将这些主题放在一起讲解的原因,因为VLIW的一些缺点可以通过应用解耦访存-执行的原则来缓解。这样,你不会使硬件过于复杂(虽然需要稍微复杂一点,需要稍微偏离VLIW原则),但能获得显著更高的性能潜力。

希望你们喜欢我们讨论的这三种主要思想。下周我们将讨论另一个迷人且极具影响力的主题:向量处理器和GPU。届时再见,保重,注意安全。

19:GPU架构(2025春季)🎮

概述

在本节课中,我们将学习GPU架构。我们将从回顾单指令多数据(SIMD)处理开始,探讨其与内存系统的交互,然后深入探讨现代GPU如何将SIMD执行模型与单程序多数据(SPMD)编程模型相结合,以实现大规模并行计算。


回顾SIMD处理

上一节我们介绍了多种执行范式。本节中,我们来看看SIMD处理,它专注于利用规则的数据并行性。

SIMD的核心思想是:一条指令同时对多个数据元素执行相同的操作。我们之前讨论过两种主要的SIMD处理器类型:阵列处理器和向量处理器。

  • 阵列处理器:在同一时间、不同的处理单元上,对不同的数据元素执行相同的操作。其优势是并行度高。
  • 向量处理器:在同一空间(功能单元)、不同的时间点上,对数据元素执行相同的操作。其优势是硬件设计更节省。

现代GPU实际上是这两种范式的结合体。

内存系统的重要性

为了实现SIMD处理的高吞吐量,内存系统至关重要。无论是阵列处理器(同时发起多个内存访问)还是向量处理器(每个周期发起一个访问),都需要内存能够支持并发访问。

内存分体是解决这个问题的关键技术。通过将内存划分为多个可以独立访问的“体”,我们可以同时或流水线式地服务多个内存请求。

核心概念:为了维持每个周期一个数据元素的吞吐量,程序访问数据的步长和内存的体数量最好是互质的。否则,可能会发生体冲突,导致访问延迟增加。

示例:假设有16个体,步长为16。如果数据布局使得每第16个元素都映射到同一个体,那么所有访问都会冲突,无法实现高吞吐量。

以下是减少体冲突的一些方法:

  • 增加体的数量。
  • 改进数据布局以匹配访问模式。
  • 使用更优的地址到体的映射函数(例如,使用哈希或随机化)。

结合阵列与向量处理

现代SIMD处理器通常结合了阵列和向量处理的优点,在空间和时间两个维度上同时开发并行性。

考虑一个向量加法 C = A + B。假设我们有4个功能单元(即4个“通道”),向量长度为32。

执行过程

  1. 第一个周期,通道0-3分别计算元素0、1、2、3。
  2. 第二个周期,通道0-3分别计算元素4、5、6、7。
  3. 以此类推。

这样,我们在空间上(4个通道并行)和时间上(8个周期流水)都实现了并行。为了支持这种架构,向量寄存器文件通常被分区,每个通道只能访问分配给它的那部分寄存器元素。


SIMD在现代指令集架构中的应用

SIMD思想也被应用到了通用处理器中,通常以SIMD指令集扩展的形式出现,例如x86的MMX、SSE、AVX等。

这些指令允许将单个寄存器视为包含多个较小数据元素的“打包”寄存器,并对它们同时执行操作。

核心概念:例如,一条32位的打包加法指令,可以将寄存器视为4个8位整数,并同时完成4次加法。这非常适合于图像处理、多媒体等规则并行计算。

示例 - 图像合成:假设要将一幅人像(背景为蓝色)与另一幅花朵背景图像合并。使用SIMD指令可以高效地完成:

  1. 用打包比较指令,一次性比较多个像素是否等于蓝色,生成掩码。
  2. 用打包与指令,根据掩码从花朵图像中提取需要替换的像素。
  3. 用打包与非指令,从人像中提取需要保留的像素。
  4. 用打包或指令,将两部分像素合并。

这个过程可以一次性处理多个像素(例如8个),显著提升性能。GPU的思想就是将这种并行性扩展到极致。


GPU架构:SIMD与SPMD的结合

GPU是SIMD处理最成功的案例之一。但其独特之处在于,它将SIMD硬件执行模型与SPMD编程模型相结合。

编程模型 vs. 执行模型

首先,区分两个概念:

  • 编程模型:程序员如何表达代码(如顺序、多线程、数据并行)。
  • 执行模型:硬件如何执行代码(如乱序执行、向量处理)。

GPU的编程模型是单程序多数据(SPMD)。程序员编写一个内核函数,并指定启动成千上万个线程,每个线程处理不同的数据。硬件执行模型则是SIMD

线程、线程块与线程束

GPU的编程层次:

  1. 线程:最基本的执行单元。
  2. 线程块:一组线程,可以协作(通过共享内存)。
  3. 网格:由多个线程块组成。

硬件执行的关键是线程束。线程束是一组(例如32个)执行相同指令的线程,由硬件动态分组。一个线程块会被划分为多个线程束。

核心概念:GPU的着色器核心本质上是一个SIMD流水线。它从就绪的线程束池中选择一个线程束,取指、译码(只需一次,因为指令相同),然后将该指令分发到多个通道(SIMD单元)上执行,每个通道处理一个线程的数据。这实现了单指令多线程(SIMT) 的执行。

细粒度多线程与延迟隐藏

GPU使用细粒度多线程来隐藏访存等长延迟操作。

  • 当一个线程束因缓存未命中而停顿等待数据时,调度器会立即切换到另一个就绪的线程束执行。
  • 由于有大量线程束可供切换,计算单元得以保持忙碌,从而容忍了高访存延迟。

这使得GPU无需复杂的乱序执行或分支预测机制,就能实现高吞吐量。

与传统SIMD的对比

特性 传统SIMD GPU (Warp-based SIMD)
线程模型 单线程 多标量线程
执行方式 锁步执行 线程可独立处理(如分支)
编程模型 显式SIMD指令 SPMD(标量线程)
向量长度 软件需知晓/设置 无此概念,只有线程数
指令集 包含SIMD指令 标量指令集,SIMD由硬件动态形成

GPU模型的优势在于编程更简单。程序员只需思考如何将问题分解为大量并行线程,而无需关心底层硬件的SIMD宽度。硬件负责动态地将标量线程聚合成SIMD操作(线程束)来执行。


总结

本节课中我们一起学习了:

  1. SIMD处理的核心原理:通过单指令操作多数据来开发规则并行性,包括阵列与向量处理的区别与结合。
  2. 内存系统对SIMD的关键性:体冲突问题及其缓解方法。
  3. SIMD在通用处理器中的体现:SIMD指令集扩展及其应用。
  4. GPU架构的精髓:将SPMD编程模型(易于编程)与SIMD硬件执行模型(高效执行)相结合。通过线程束细粒度多线程,GPU能够动态组织大量线程,在简单的流水线上实现极高的吞吐量和延迟容忍度。

GPU的成功表明,通过巧妙的软硬件协同设计,将合适的编程模型映射到高效的计算范式上,可以极大地推动计算性能的发展。下一节,我们将继续探讨GPU中更复杂的部分,例如线程束如何处理分支。

20:GPU架构II与内存概述及技术

概述

在本节课中,我们将完成对GPU架构的讨论,并开始学习计算机系统中至关重要的内存部分。我们将首先探讨GPU如何高效执行具有分支的程序,然后理解为什么内存是现代计算系统中最主要的性能瓶颈。

GPU架构回顾与分支执行

上一节我们介绍了GPU的SPMD编程模型及其SIMD硬件执行方式。本节中,我们来看看当程序中出现条件分支时,GPU是如何处理的。

GPU将多个执行相同程序的线程(称为“线程束”或“warp”)分组,并一次性对它们执行相同的指令。然而,如果线程束中的线程在运行时遇到分支并走向不同的控制流路径,就会发生“线程发散”。

线程发散与执行

当线程束中的线程执行到分支指令时,可能出现三种情况:

  1. 所有线程都选择相同的路径(例如,都执行if块或都执行else块)。这是最理想的情况,硬件可以继续高效地执行整个线程束。
  2. 部分线程选择一条路径,另一部分线程选择另一条路径。此时,GPU硬件会串行化执行这些路径。
    • 硬件首先为选择第一条路径的线程启用执行,同时让选择另一条路径的线程空闲等待
    • 当第一条路径执行完毕后,硬件再切换为选择第二条路径的线程执行。
    • 这会导致硬件利用率下降,因为部分执行单元在等待期间是闲置的。
  3. 线程束中的线程走向多个不同的路径。这种情况效率最低,通常需要更复杂的调度或导致显著的性能损失。

核心概念示例

考虑一个简单的分支代码:

if (thread_id % 2 == 0) {
    // 路径 A:偶数线程执行
    result = data[thread_id] * 2;
} else {
    // 路径 B:奇数线程执行
    result = data[thread_id] + 10;
}

在一个包含偶数与奇数线程的线程束中,硬件会先执行所有偶数线程的路径A,然后执行所有奇数线程的路径B

内存的重要性与瓶颈

现在,让我们从GPU的计算核心转向另一个关键组件:内存。理解内存至关重要,因为它是现代计算系统中最主要的性能瓶颈

为什么内存是瓶颈?

处理器(CPU或GPU)的运算速度在过去几十年里飞速提升,但内存速度的提升却相对缓慢。这导致了“内存墙”问题:处理器经常需要停下来等待数据从内存中读取或写入,其等待时间可能远远超过执行计算本身所需的时间。

性能公式可以简化为:
程序执行时间 = 计算时间 + 数据访问延迟

在许多现代应用中,尤其是数据密集型的科学计算、图形处理和机器学习中,数据访问延迟占据了总执行时间的主导部分。

内存技术概述

为了缓解内存瓶颈,计算机系统采用了复杂的分层内存结构。以下是一些关键的内存技术层级:

  1. 寄存器:位于处理器内部,速度最快,容量最小,用于存储当前正在计算的临时数据。
  2. 高速缓存:分为多级(L1, L2, L3),速度接近处理器,用于存储处理器近期或即将用到的数据和指令。
  3. 主内存:通常指动态随机存取存储器,容量大,但速度比缓存慢得多。它是程序运行时数据的主要存放地。
  4. 存储设备:如固态硬盘和机械硬盘,用于永久存储数据和程序,速度最慢,但容量最大。

这个层次结构的目标是,以合理的成本,为处理器提供尽可能接近其速度的大容量数据存储。

总结

本节课中我们一起学习了两个核心内容。首先,我们深入了解了GPU执行包含分支的SPMD程序时的机制,特别是线程发散对执行效率的影响。其次,我们开始了对内存系统的学习,明确了内存访问延迟是当前计算系统最主要的性能瓶颈,并简要介绍了分层内存结构的基本概念。在接下来的课程中,我们将更详细地探讨各种内存技术及其工作原理。

21:内存组织、技术、层次结构与缓存(Spring 2025)

概述

在本节课中,我们将深入学习内存系统。我们将从内存的组织结构和技术细节开始,探讨动态随机存取存储器(DRAM)和静态随机存取存储器(SRAM)的工作原理。接着,我们将引入内存层次结构的概念,解释为什么现代计算机系统需要多级缓存,并详细讲解缓存的基本原理,包括如何利用时间局部性和空间局部性来提升性能。课程内容旨在让初学者能够理解内存系统的核心工作机制。


内存组织回顾

上一讲我们介绍了内存是计算系统的关键组件,并讨论了影响内存设计的多种指标。我们探讨了内存的基本原理、组织方式和技术。本节中,我们将快速回顾这些内容,为深入理解打下基础。

内存控制器通过总线通道连接到多个内存模块。每个模块包含内存芯片,每个芯片内部有多个称为“存储体”的阵列。每个存储体由一个二维的存储单元阵列构成。

从自上而下的视角看,一个双列直插内存模块(DIMM)包含多个“列”。每个列是一组芯片的集合。这些列共享地址、命令总线和数据总线,因此一次只能访问一个列。物理地址的一部分用于选择特定的列。

每个列由多个芯片组成。例如,一个64位数据总线可能由8个芯片提供,每个芯片提供8位数据。这种设计降低了单个芯片的引脚成本和复杂度,同时通过并发访问所有芯片来获得高带宽。

每个芯片内部包含多个存储体。将存储阵列划分为多个存储体可以降低延迟,并允许在不同存储体上并行发起访问,从而利用并行性。

每个存储体内部是一个二维存储单元阵列。访问时,首先将整行数据读取到“行缓冲区”(由灵敏放大器构成)中,然后通过列地址选择特定的字节输出。这类似于一种缓存机制。


DRAM 内部操作详解

上一节我们回顾了内存的宏观组织结构。本节中,我们将深入DRAM芯片内部,了解其具体的工作原理。

基本操作步骤

内存控制器要访问一个存储体,需要提供行地址和列地址。

  1. 激活行:控制器发送行地址。行解码器解码后,激活对应的字线。该行所有存储单元通过存取晶体管连接到各自的位线上。
  2. 数据感知与放大:灵敏放大器(即行缓冲区)感知位线上的微小电压变化,并将其放大为逻辑高电平(VDD)或低电平(0)。这个过程会将整行数据(例如2KB)暂存到行缓冲区中。
  3. 列选择与输出:控制器发送列地址。一个大型多路复用器根据列地址,从行缓冲区中选择出特定的字节(或字),并通过总线发送给处理器。

行缓冲区命中与冲突

  • 行缓冲区命中:如果处理器接下来要访问同一行中的不同列,数据已经在行缓冲区中。控制器只需发送新的列地址即可快速获取数据,无需再次激活行。这显著降低了访问延迟。
  • 行缓冲区冲突:如果处理器要访问不同行的数据,则当前打开的行必须被关闭。这需要三个步骤:
    1. 预充电:将当前行缓冲区中的数据写回存储阵列(如果需要),并将灵敏放大器复位到参考电压(如 VDD/2)。
    2. 激活新行:发送新行地址,激活新行,并将其数据读取到行缓冲区。
    3. 列访问:发送列地址,选择并输出数据。
      行缓冲区冲突的延迟远高于命中。例如,一次命中可能需25纳秒,而一次冲突可能需75纳秒。在5GHz的处理器中,75纳秒相当于375个处理器周期,凸显了内存延迟对性能的巨大影响。

子阵列结构

实际上,一个巨大的存储体在物理上会进一步划分为更小的子阵列。每个子阵列有自己的局部行缓冲器和更短的位线,这降低了互联延迟,提高了访问速度和可靠性。访问时,只激活目标子阵列中的行,数据被读取到局部行缓冲区,然后再通过更宽的内部互联输出。


内存技术:DRAM vs. SRAM vs. 新兴技术

我们了解了DRAM的组织和操作。现在,我们来比较不同的内存技术及其权衡。

DRAM(动态随机存取存储器)

  • 存储原理:利用电容存储电荷。充电状态代表1,放电状态代表0。
  • 单元结构:1个晶体管 + 1个电容(1T1C)。
  • 关键特性
    • 高密度,低成本/位:单元结构简单,面积小。
    • 需要刷新:电容会漏电,必须定期(例如每64毫秒)读取并重写数据以保持电荷。
    • 破坏性读取:读取操作会消耗电容电荷,读取后必须立即恢复(由灵敏放大器完成)。
    • 制造工艺特殊:需要制造电容,与标准逻辑CMOS工艺不兼容。

SRAM(静态随机存取存储器)

  • 存储原理:利用交叉耦合的反相器(锁存器)存储状态。
  • 单元结构:通常为6个晶体管(6T)。
  • 关键特性
    • 速度快:无需电容充放电,访问延迟低。
    • 无需刷新:只要供电,数据就能保持。
    • 低密度,高成本/位:晶体管数量多,单元面积大。
    • 制造工艺兼容:使用标准逻辑CMOS工艺,易于集成在处理器芯片上。所有片上缓存(Cache)均由SRAM构建。

技术对比总结

特性 DRAM SRAM
速度 较慢(纳秒级) (亚纳秒级)
密度/成本 高密度,低成本/位 低密度,高成本/位
刷新 需要 不需要
易集成性 困难(特殊工艺) 容易(标准CMOS)
主要用途 主内存(容量大) 处理器高速缓存(速度快)

新兴内存技术(简介)

除了DRAM和SRAM,还存在其他有潜力的内存技术,例如相变存储器(PCM)。

  • 原理:利用硫族化合物玻璃在晶态(低阻)和非晶态(高阻)之间的相变来存储数据。
  • 潜在优势:非易失性(断电不丢数据)、高密度、无需刷新。
  • 挑战:写入速度较慢(需要加热/冷却)、存在写耐久度限制、制造工艺不成熟。
    这些技术可能在未来改变内存层次结构,例如作为更高速的非易失性存储层。

缓存线访问与数据映射

理解了芯片内部操作后,我们来看处理器如何访问一块数据(缓存线)。

一个典型的缓存线大小为64字节。这64字节数据被映射到单个内存通道的单个列(Rank)中。

  • 该列由8个芯片组成,每个芯片提供8位数据。
  • 要获取64字节,需要连续进行8次访问,每次访问所有芯片并发工作,提供8字节(64位)数据。
  • 在每次访问中,内存控制器向所有芯片发送相同的行地址和列地址。每个芯片从其内部阵列中读取对应位置的一个字节,共同组成一个8字节的数据块。
  • 如果这64字节数据位于DRAM的同一行内,那么除了第一次访问需要激活行外,后续7次访问都是行缓冲区命中,可以快速完成。

这种访问模式充分利用了空间局部性:当处理器请求一个字节时,内存系统会将该字节所在的整个缓存线(及其相邻数据)取回,期望处理器很快就会访问这些相邻数据。


内存层次结构与缓存原理

单一类型的内存无法同时满足大容量、高速度和低成本的要求。因此,现代计算机系统采用内存层次结构

理想内存 vs. 现实权衡

理想内存应具备:零访问时间、无限容量、零成本、无限带宽、零能耗。这显然无法实现。
现实中的根本矛盾是:容量越大,速度越慢;速度越快,成本越高

层次结构解决方案

内存层次结构通过组合多种不同特性的存储设备来近似理想内存:

  1. 靠近处理器:使用容量小、速度快、成本高的存储(如SRAM缓存、寄存器)。
  2. 远离处理器:使用容量大、速度慢、成本低的存储(如DRAM主存、SSD、硬盘)。
  3. 关键思想:将处理器最常访问的数据保存在速度最快的层次中。通过利用程序的访问局部性,使得在大多数情况下,处理器都能从快速缓存中获取数据,从而获得接近最快存储的速度和接近最慢存储的容量。

一个典型的层次结构示例如下:

  • 寄存器:在CPU内部,速度最快,容量最小(KB级),由编译器或指令显式管理。
  • L1缓存:在CPU核心内,速度极快(1-4周期),容量较小(KB级)。
  • L2缓存:可能在核心内或核心间共享,速度较快(10-20周期),容量较大(MB级)。
  • L3缓存:在芯片上共享,速度较慢(30-50周期),容量更大(MB级)。
  • 主内存(DRAM):在芯片外,速度慢(100-300周期),容量大(GB级)。
  • 固态硬盘/机械硬盘:持久化存储,速度非常慢(微秒到毫秒级),容量极大(TB级)。

局部性原理

层次结构有效性的基础是程序的局部性原理

  1. 时间局部性:如果一个内存位置被访问,那么它很可能在不久的将来被再次访问。
    • 例子:循环中的计数器变量会被反复读写。
  2. 空间局部性:如果一个内存位置被访问,那么它附近的内存位置很可能在不久的将来被访问。
    • 例子:顺序执行的指令、遍历数组或矩阵元素。

缓存通过以下方式利用局部性:

  • 利用时间局部性:将最近访问过的数据块保留在缓存中。
  • 利用空间局部性:当处理器访问某个地址时,不仅读取该地址的数据,还将包含该地址的整个缓存块(例如64字节)取回缓存。期望处理器接下来会访问该块内的其他数据。

缓存管理:自动 vs. 手动

  • 自动管理(硬件缓存):硬件自动决定数据的存放和替换,对程序员透明。这是现代通用CPU的标准方式,简化了编程。
  • 手动管理(暂存存储器):程序员或编译器显式控制数据在快速存储中的移动。这在一些GPU和专用加速器(如AI芯片)中常见,可以提供更确定性的性能,但增加了编程复杂度。

历史视角:从磁芯内存到DRAM

早期计算机使用磁芯内存,它体积大、密度低、制造困难。20世纪70年代,半导体DRAM(如Intel 1103)的出现,以其更低的成本和更高的密度,迅速取代了磁芯内存,奠定了现代内存技术的基础。


总结

本节课我们一起深入学习了内存系统的核心知识。

我们首先回顾并深入剖析了DRAM的内存组织,从模块、列、芯片、存储体一直深入到子阵列和存储单元,详细解释了行缓冲区、命中与冲突的操作机制及其对性能的影响。

接着,我们比较了DRAM和SRAM这两种主要内存技术的原理、特性和权衡,并简要介绍了相变存储器等新兴技术。

然后,我们探讨了处理器如何访问一个缓存线,以及数据在内存系统中的映射方式。

最后,我们引入了内存层次结构这一核心概念,解释了为什么需要缓存,并详细阐述了缓存工作的理论基础——时间局部性和空间局部性原理。我们还区分了硬件自动管理的缓存和程序员手动管理的暂存存储器。

理解内存层次结构和缓存原理对于理解现代计算机如何克服“内存墙”问题至关重要。在接下来的课程中,我们将进一步学习缓存的具体组织结构和工作原理。

23:缓存 II 与预取 (Spring 2025)

概述

在本节课中,我们将继续学习缓存设计,探讨影响缓存性能的关键参数,并介绍一种重要的性能优化技术——预取。我们将了解如何通过硬件和软件手段减少内存访问延迟,提升程序执行效率。


缓存性能参数分析

上一节我们介绍了缓存的基本概念和必要性。本节中,我们来看看影响缓存性能的几个关键设计参数,包括缓存大小、块大小和关联度。

缓存大小的影响

缓存性能的一个关键指标是命中率。缓存大小直接影响命中率。

下图展示了缓存大小与命中率的一般关系。随着缓存容量增加,命中率起初会显著提升。但当缓存容量达到或超过工作集大小时,命中率的提升会变得非常有限,甚至不再增长。

工作集 是指程序在特定时间段内访问的数据集合。理想情况下,我们希望缓存大小能够容纳工作集,这是性能的“甜点区”。如果缓存太小,无法有效利用时间局部性和空间局部性,有用数据会被频繁替换。如果缓存太大,虽然命中率可能更高,但访问延迟和成本也会增加,得不偿失。

工作集大小因程序而异,不存在一个“放之四海而皆准”的最佳缓存大小。下图展示了不同工作负载下,缓存关联度(可视为缓存容量的一种体现)与每千条指令缺失数的关系。有些工作负载即使缓存容量大幅增加,缺失率也几乎不变;而另一些工作负载则在缓存容量增加到一定程度后,缺失率迅速降至接近零。

缓存块大小的影响

缓存块大小是缓存与内存之间数据传输的最小粒度,它直接影响对空间局部性的利用。

  • 块大小过小:无法有效利用空间局部性。例如,如果程序顺序访问数组,每次只取一个元素(如4字节),那么每次访问都可能引发缓存缺失,即使相邻数据很快就会被用到。同时,较小的块意味着相对更大的标签开销。
  • 块大小适中:能有效预取相邻数据,利用空间局部性。例如,一次取64字节,如果程序访问了其中某个数据,很可能也会访问同一块内的其他数据,从而减少后续访问的缺失。
  • 块大小过大:如果程序的空间局部性不强,过大的块会导致传输无用数据,浪费内存带宽、缓存空间和能量。例如,一个2MB的块被取入缓存,但程序只访问其中很少一部分数据,这会造成严重的资源浪费。

因此,选择块大小需要在利用空间局部性和避免资源浪费之间取得平衡。

缓存关联度的影响

关联度决定了缓存中每个索引位置可以存放的块数。

  • 高关联度:减少冲突缺失,因为同一个索引位置可以容纳更多地址不同的块。但高关联度意味着需要更多的比较电路,可能增加访问延迟、功耗和设计复杂度。
  • 低关联度:访问速度快,电路简单,但冲突缺失会增加。

一个常见的问题是:关联度必须是2的幂次吗?

答案是否定的。关联度并不需要是2的幂次。缓存索引通常使用地址的一部分,而关联度决定每个索引对应的“路”数,这两者是独立的。例如,一个四路组相联缓存,如果去掉一路的比较逻辑,就可以变成三路组相联。实际上,一些现代商业处理器(如Intel的Lime Cove微架构)就使用了非2的幂次(如12路)的关联度设计。


缓存缺失的类型与优化

理解缓存缺失的类型有助于我们针对性地进行优化。缓存缺失主要分为三类:

  1. 强制性缺失:程序第一次访问某个缓存块时必然发生的缺失。除非采用预测技术提前获取数据,否则无法避免。
  2. 容量缺失:由于缓存总容量不足,无法容纳所有活跃的工作集数据而导致的缺失。即使使用全相联和最优替换策略,只要缓存容量小于工作集,这种缺失依然会发生。
  3. 冲突缺失:在组相联或直接映射缓存中,由于多个内存块映射到同一个缓存组,导致有用数据被替换出去而引发的缺失。这是关联度有限导致的。

以下是针对不同类型缺失的优化思路:

  • 减少强制性缺失:主要依靠预取技术。通过预测程序未来的访问模式,提前将数据取入缓存。
  • 减少冲突缺失:增加缓存关联度是最直接的方法。其他技术还包括使用受害者缓存、随机化索引等。
  • 减少容量缺失:增加缓存容量,或者通过优化程序数据布局和访问模式来更高效地利用有限的缓存空间。

缓存性能优化的三个基本目标是:

  1. 降低缺失率。
  2. 降低缺失代价(解决一次缺失所需的时间)。
  3. 降低命中延迟(缓存命中的数据访问时间)。

然而,这些目标之间往往存在权衡。例如,增大缓存可以降低缺失率,但可能增加命中延迟;激进的预取可能降低缺失延迟,但会消耗更多带宽并可能造成缓存污染。


通过编程优化缓存性能

作为程序员,了解底层缓存机制可以帮助我们编写出对缓存更友好的高效代码。以下是几种常见的技术:

1. 重构数据访问模式(循环交换)

考虑一个按列主序存储的二维矩阵(同一列中相邻行的元素在内存中相邻)。如果我们按行优先遍历(先遍历行,再遍历列),内层循环访问的是内存中相距很远的元素,无法利用空间局部性。

优化前(行优先,缓存不友好)

for (i = 0; i < N; i++) {
    for (j = 0; j < M; j++) {
        sum += matrix[i][j];
    }
}

优化后(列优先,缓存友好)

for (j = 0; j < M; j++) {
    for (i = 0; i < N; i++) {
        sum += matrix[i][j];
    }
}

通过交换循环顺序,使内层循环访问内存中连续的数据,可以显著提高缓存命中率。

2. 分块

当处理的数据集大于缓存容量时,可以将计算分解成若干小块(Tile),使得每个小块的数据能放入缓存中。先完整处理一个小块,充分利用该块内的数据局部性,然后再处理下一个块。

这在矩阵乘法等计算密集型任务中非常有效。传统的矩阵乘法会按行或列访问整个大矩阵,导致缓存效率低下。分块矩阵乘法则将大矩阵分成小方块,每次只将参与计算的两个源矩阵块和目标矩阵块保留在缓存中,进行局部乘加运算,从而大幅提升缓存利用率。

3. 重构数据结构布局(结构体拆分)

考虑一个链表节点结构体,包含键值、下一个节点指针以及一些不常访问的辅助信息(如姓名、学校)。

struct Node {
    int key;
    struct Node* next;
    char name[256];
    char school[256];
};

遍历链表查找特定键值时,keynext被频繁访问,而nameschool只在找到匹配项后才被访问。如果将它们打包在一起,每次遍历节点时,不常访问的大字段也会被加载到缓存行中,浪费缓存空间和带宽。

优化后:将频繁访问字段和不常访问字段拆分成两个结构体。

struct Node {
    int key;
    struct Node* next;
    struct NodeData* data; // 指向包含name和school的结构体
};

struct NodeData {
    char name[256];
    char school[256];
};

这样,链表本身变得紧凑,遍历时缓存效率更高。只有在找到匹配节点后,才通过指针访问辅助数据。


内存级并行性与缓存策略

传统的缓存优化策略(如LRU)隐含假设减少缺失次数总能提升性能。然而,在现代支持乱序执行的处理器中,内存级并行性(MLP)的存在改变了这一假设。

MLP是指处理器能够同时发出多个内存访问请求的能力。如果多个缺失请求可以并行处理,那么它们对处理器造成的总停顿时间可能小于其各自延迟之和。

考虑一个例子:程序依次产生内存访问序列 P1, P2, P3, P4(可并行),然后是 S1, S2, S3(必须串行)。最优替换策略(Belady)可能会为了容纳串行访问的S系列数据,而替换掉即将被并行访问的P系列数据,导致P系列访问发生缺失。虽然总缺失次数较少,但这些缺失是串行暴露给处理器的,造成显著停顿。

而一个MLP感知的替换策略会倾向于保护那些会暴露给处理器、造成串行停顿的缓存行(如S系列),即使这意味着要牺牲一些可以并行处理的缺失(如P系列)。虽然总缺失次数可能增加,但由于许多缺失可以重叠处理,处理器实际经历的停顿周期反而更少。

这个例子表明,在设计缓存替换策略时,需要考虑缺失请求之间的并行性,而不仅仅是追求最低的缺失率。

另一种利用并行性隐藏延迟的技术是推测性内存访问。例如,如果硬件能够预测某个L1缓存访问将会一直缺失到主存,它可以在查询L2缓存的同时,就提前发起对主存的访问。这样,内存访问的延迟部分被后续缓存的查询过程所覆盖,从而减少了处理器感知到的停顿时间。


预取技术概述

强制性缺失是缓存无法避免的。预取技术通过预测程序未来的内存访问地址,提前将数据取入缓存或更靠近处理器的地方,从而隐藏内存访问延迟,甚至消除强制性缺失。

预取的核心思想是:在程序实际需求发生之前,提前获取数据。成功的预取需要满足两个条件:准确性(预测对)和及时性(在需要时数据已到位)。

预取不影响程序正确性。如果预测错误,最坏情况是浪费了带宽、缓存空间或能量,但不会导致程序执行错误。这使得我们可以设计更激进的预取器。

预取可以在多个层面实现:

  • 软件预取:编译器或程序员在代码中插入特殊的预取指令(如x86的PREFETCH系列指令)。
  • 硬件预取:处理器硬件自动监测访问模式,并透明地发起预取。
  • 基于执行的预取:使用一个辅助线程(“侦察兵”线程)提前执行程序代码或简化版本,为主线程生成预取请求。

设计一个预取器需要回答四个关键问题:

  1. 预取什么:预测下一个或未来几个需要的内存地址。这需要模式识别算法,如检测顺序流、固定步长、复杂历史模式等。
  2. 何时预取:预取时机至关重要。过早预取,数据可能在用到前就被替换;过晚预取,无法完全隐藏延迟。需要平衡“及时性”。
  3. 预取到哪里
    • 将数据放在缓存层次结构的哪一级?(L1, L2, LLC?)
    • 放在缓存内还是专用的预取缓冲区?
    • 不同的选择在污染、一致性、效率方面有不同权衡。
  4. 如何预取:采用何种机制实现预取(软件、硬件、基于执行)。

硬件预取器示例

  1. 流预取器:检测顺序访问模式。例如,IBM Power4处理器采用多级流预取,L1预取器看到访问块N后,会预取块N+1到L1;L2预取器看到块N+1被预取到L2后,会预取后续多个块(如N+5到N+8)到L2;以此类推,在内存访问路径上形成一个“预取前沿”,保持数据提前就位。
  2. 步长预取器:检测固定步长的访问模式(如A, A+s, A+2s...)。硬件记录特定加载指令(通过程序计数器PC识别)的历史访问地址,计算连续地址之间的差值(步长)。当检测到稳定的步长模式且置信度足够高时,就根据当前地址和步长预取未来的地址。为了提高及时性,可以预取多个步长 ahead 的地址。

对于更复杂的访问模式(如重复的 delta 序列),可以使用关联预取器基于机器学习的预取器。它们能够学习更长的历史访问模式,并进行更复杂的预测。

预取器的性能通常用以下指标衡量:

  • 准确率:被使用的预取请求数 / 发出的预取请求总数。
  • 覆盖率:被预取覆盖的缓存缺失数 / 总的缓存缺失数。
  • 及时性:在需要时已就绪的预取数 / 被使用的预取总数。
    此外,还需关注带宽消耗缓存污染等副作用。

总结

本节课我们一起深入探讨了缓存设计的性能权衡,包括缓存大小、块大小和关联度的影响。我们分析了三种主要的缓存缺失类型及其优化思路。作为程序员,可以通过重构数据访问模式、分块和优化数据结构布局来显著提升缓存利用率。

我们引入了内存级并行性的概念,指出传统的以缺失率为中心的缓存策略需要结合MLP进行优化。最后,我们重点介绍了预取技术,这是一种通过预测来隐藏内存延迟、减少强制性缺失的强大方法。我们了解了预取的基本原理、设计考量以及几种经典的硬件预取器设计。

理解这些底层机制,无论是对于硬件架构师设计高效的内存子系统,还是对于软件开发者编写高性能代码,都具有至关重要的意义。

24:虚拟内存 (Spring 2025) 🧠

在本节课中,我们将要学习虚拟内存的核心概念。虚拟内存是现代计算机系统中的一个关键抽象,它为程序员提供了巨大的、看似无限的地址空间,同时由硬件和操作系统自动管理有限的物理内存资源。我们将探讨其工作原理、优势、面临的挑战以及现代系统中的实现方式。

概述 📋

虚拟内存是计算机架构中一个至关重要的抽象层。它允许每个程序拥有自己独立的、庞大的地址空间(虚拟地址空间),而无需关心物理内存的实际大小和布局。操作系统和硬件(特别是内存管理单元,MMU)协同工作,将程序使用的虚拟地址动态映射到物理内存中的实际地址。这种机制不仅简化了编程,还提供了内存保护、进程隔离和高效共享等关键功能。

上一节我们介绍了内存层次结构和缓存的基本原理,本节中我们来看看如何将类似的“缓存”思想应用于整个主存和磁盘之间,从而构建出虚拟内存系统。

为什么需要虚拟内存?🤔

在直接使用物理地址的系统中,程序员面临诸多困难:

  1. 物理内存容量有限:程序可能需要比物理内存更大的空间。
  2. 多程序协调困难:多个程序需要协调使用物理内存,避免冲突。
  3. 缺乏保护和隔离:一个程序可能无意或恶意地访问或修改另一个程序的数据。
  4. 代码和数据难以重定位:程序中的地址是硬编码的,难以在内存中移动。
  5. 共享代码和数据复杂:需要在程序间显式协商共享内存区域。

虚拟内存通过引入一个间接层——地址转换——来解决这些问题。程序员在虚拟地址空间中工作,系统负责将虚拟地址映射到物理地址。

虚拟内存的基本概念 🧩

虚拟地址与物理地址

  • 虚拟地址 (Virtual Address):程序直接使用的地址。它属于一个巨大的、线性的地址空间(例如,在64位系统中可达16EB)。
  • 物理地址 (Physical Address):数据在物理内存(RAM)中的实际位置。
  • 地址转换 (Address Translation):将虚拟地址映射到物理地址的过程。这是由内存管理单元 (MMU) 在硬件支持下完成的。

页与页框

为了高效管理,虚拟地址空间和物理地址空间都被划分为固定大小的块。

  • 页 (Page):虚拟地址空间中的块。
  • 页框 (Frame):物理地址空间中的块。
  • 页大小 (Page Size):常见的尺寸有4KB、2MB、1GB等。页大小是系统设计的一个关键参数。

映射关系:一个虚拟页可以映射到一个物理页框(如果该页当前在物理内存中),也可以映射到磁盘上的一个位置(如果该页被“换出”)。

页表:虚拟到物理的映射字典

系统需要一个数据结构来记录所有虚拟页到物理页框(或磁盘位置)的映射关系,这个数据结构就是页表 (Page Table)。你可以将其想象成一个巨大的字典或查找表。

每个页表项 (Page Table Entry, PTE) 包含:

  • 有效位 (Valid Bit):指示该虚拟页是否在物理内存中。
  • 物理页框号 (Physical Frame Number):如果有效位为1,则指向对应的物理页框。
  • 保护位 (Protection Bits):指示该页的访问权限(如可读、可写、可执行)。
  • 其他状态位:如脏位(Dirty Bit,指示页是否被修改过)、访问位(Accessed Bit)等,用于页面替换算法。

地址转换过程示例
假设虚拟地址为 0x5F20,页大小为4KB(0x1000)。

  1. 计算虚拟页号 (VPN):0x5F20 >> 12 = 0x5
  2. 计算页内偏移 (Offset):0x5F20 & 0xFFF = 0xF20
  3. 以VPN为索引查找页表,找到对应的PTE。
  4. 从PTE中取出物理页框号 (PFN),例如 0x1
  5. 组合成物理地址:(PFN << 12) | Offset = (0x1 << 12) | 0xF20 = 0x1F20

虚拟内存的关键机制与挑战 ⚙️

1. 页错误处理

当程序访问一个有效位为0的虚拟页(即该页不在物理内存中)时,会触发一个页错误 (Page Fault) 异常。操作系统接管处理:

  • 次要页错误 (Minor Fault):页已在内存中(例如在文件缓存里),只需建立页表映射。处理较快。
  • 主要页错误 (Major Fault):页确实在磁盘上(如交换空间或文件),需要启动I/O操作,将页从磁盘读入一个空闲的物理页框,然后更新页表。处理很慢。

操作系统使用页面置换算法来选择被换出的页,为新的页腾出空间。常见的算法有最近最少使用(LRU)及其近似算法(如时钟算法)。

2. 页表过大问题与多级页表

对于64位地址空间,如果使用单级页表,页表本身就会大得无法装入内存。解决方案是使用多级页表 (Multi-level Page Table)

工作原理
将虚拟地址分成多个部分,每一级页表负责解析一部分。例如,一个四级页表:

  • 虚拟地址被划分为:L4索引 | L3索引 | L2索引 | L1索引 | 页内偏移。
  • CR3寄存器指向顶级页表(L4)的基址。
  • 通过逐级查表,最终找到叶子级的PTE,获得物理页框号。

优势

  • 节省空间:只为实际使用的虚拟地址区域分配页表子结构。未使用的地址区域对应的上级页表项标记为无效,其下级页表就无需分配。
  • 公式描述:假设虚拟地址 VA 被划分为 (a, b, c, d, offset),则物理地址 PA = PageTable[PageTable[PageTable[PageTable[CR3 + a] + b] + c] + d] + offset(概念示意)。

3. 加速地址转换:TLB

如果每次内存访问都需要多次访问内存来查询多级页表,性能将无法接受。因此,现代CPU使用转址旁路缓存 (Translation Lookaside Buffer, TLB) 来缓存最近使用过的虚拟页到物理页框的映射。

  • TLB命中:直接在TLB中找到翻译结果,速度极快(通常1-2个周期)。
  • TLB未命中:需要启动页表遍历 (Page Table Walk)。现代系统通常在硬件中实现一个页表遍历器,自动完成多级页表的查找,并将结果填入TLB。

4. 内存保护

页表项中的保护位使得操作系统可以为每个页设置访问权限。这实现了:

  • 进程隔离:一个进程不能访问另一个进程的页(除非显式共享)。
  • 代码保护:可以将代码页设置为只读/可执行,数据页设置为可读/写,防止代码被恶意修改或数据被当作代码执行(防范缓冲区溢出攻击)。

虚拟内存的性能开销与研究方向 🔬

尽管虚拟内存带来了巨大好处,但它也引入了性能开销:

  1. 地址翻译开销:对于具有巨大、不规则数据访问模式的工作负载(如图计算、稀疏机器学习),TLB未命中率很高,页表遍历成为主要性能瓶颈。
  2. 内存分配开销:对于短生命周期、频繁创建销毁的工作负载(如Serverless函数、LLM推理请求),在操作系统内核中分配和初始化内存页表结构的时间可能占其总执行时间的很大比例。

当前的研究方向包括:

  • 设计更高效的TLB结构和替换算法。
  • 探索大页(如1GB页)的使用以减少TLB压力。
  • 研究异构内存系统(如DRAM+NVM)下的虚拟内存管理。
  • 针对特定加速器(GPU、NPU)优化虚拟内存支持。
  • 重新思考虚拟内存抽象,以应对新兴负载的需求。

总结 🎯

本节课中我们一起学习了虚拟内存的核心原理。我们了解到:

  • 虚拟内存通过地址转换为程序员提供了巨大且独立的地址空间抽象。
  • 页表是存储虚拟页到物理页框映射的核心数据结构,多级页表解决了其空间占用过大的问题。
  • TLB作为页表的高速缓存,极大地加速了地址转换过程。
  • 虚拟内存机制天然提供了内存保护进程隔离
  • 页错误处理实现了按需调页,使得物理内存可以作为磁盘的高速缓存。
  • 尽管是成功的抽象,虚拟内存仍面临地址翻译内存分配方面的性能挑战,是计算机架构持续研究的重要领域。

虚拟内存是硬件/软件协同设计的典范,它深刻体现了通过复杂的系统底层支持来简化上层编程接口的设计哲学。理解虚拟内存是理解现代操作系统和计算机体系结构运行机制的关键。

23b:多核系统中的缓存问题 🧠

在本节课中,我们将要学习多核处理器系统中的缓存设计所面临的一系列独特挑战。我们将探讨缓存应设计为私有还是共享,分析资源共享的利弊,并初步了解确保多核间数据一致性的缓存一致性协议。

多核缓存概述

上一节我们介绍了缓存的基本原理。本节中我们来看看当系统拥有多个处理器核心时,缓存设计会变得多么复杂。正如我们所讨论的,现代系统都是多核的。例如,在这个系统中,L1缓存通常是私有的,L2缓存也可能是私有的,而L3缓存则可能在所有处理器核心之间共享。这本身就是一项关键的设计决策:决定哪些缓存由哪些核心共享,以及是否设计更高级别的共享缓存等。

你已经熟悉了这些架构图。这里我想特别提一下这个缓存,因为它非常有趣。这是一个位于不同芯片上的共享缓存。

基本上,设计者决定在处理器芯片之上额外封装了其他芯片。这些额外芯片的唯一目的就是提供缓存。这其中涉及许多有趣的技术问题,例如如何对齐两个芯片间的互连以实现高效通信。虽然我们现在认为这些是理所当然的,但这里存在许多制造和可靠性问题。这是两个堆叠封装在一起的不同芯片,第二个芯片的唯一功能就是为另一个芯片中的所有核心提供缓存。


多核系统中的缓存效率挑战

在多核或多线程系统中,缓存效率变得至关重要。如果只有一个线程访问缓存,管理起来很简单。但当有10个、20个甚至100个不同的线程访问同一个缓存时,你就需要追求更高的效率。内存带宽问题同样如此,甚至可能更为重要。

因此,针对多核系统的缓存研究非常多。内存带宽是稀缺资源,我们应尽量避免访问主存,理想情况是命中缓存。不幸的是,这并不总能实现。同时,缓存空间也是跨核心和线程的有限资源。因此,空间和带宽都成了问题。

那么,问题就变成了:我们如何设计多核系统中的缓存?这里实际上有许多有趣的设计决策。

以下是多核缓存设计面临的核心问题:

  • 共享与私有:缓存应该在核心或线程间共享还是私有?
  • 系统性能最大化:如何最大化整个系统的性能,而不仅仅是单个线程的性能?
  • 服务质量:如何为不同线程提供服务质量?如何提供可预测的性能?
  • 缓存管理算法:缓存管理算法是否需要感知访问它的不同线程?
  • 空间分配:在共享缓存中,应如何为不同线程分配空间?
  • 数据压缩:如果带宽和空间稀缺,是否应该压缩缓存中的数据?
  • 重用预测与管理:如何实现更好的缓存数据重用预测和管理?

可能还有其他问题,但让我们先关注其中一个维度:缓存应该是私有的还是在核心间共享。

私有缓存 vs. 共享缓存

一个私有缓存只属于一个核心,这意味着一个共享的数据块可能存在于多个缓存中。而一个共享缓存则由多个核心共用,正如你所见,这里的L2缓存由多个核心共享。如果一个数据块在这些不同核心间共享,它本质上不需要在不同缓存间复制。共享缓存的一个直接优势就是减少了不同私有缓存间的数据复制。

我们昨天讨论过,L1缓存与核心紧密耦合,因此设计上通常是私有的。而L2缓存是片外缓存,可能私有也可能共享。但L3缓存今天几乎总是共享的。这引出了资源共享的概念。

以下是资源共享的实例:

  • 私有缓存:将缓存资源专用于某个核心。
  • 共享缓存:允许多个硬件上下文(如核心或线程)共同使用同一硬件资源。
  • 功能单元与流水线:在细粒度多线程中,流水线和功能单元在不同线程间共享。
  • 总线、内存互连和存储:这些资源因复制成本过高,通常在不同线程间共享。

资源共享的利弊

为什么我们要共享资源?因为它能提高利用率和效率,从而带来更高的吞吐量和性能。当一个线程闲置某项资源时,另一个线程可以使用它。同时,也无需复制共享数据。

让我举个例子。假设有两个核心,核心1和核心2。如果它们各有64KB的私有缓存,一个核心可能只使用自己缓存的1KB,而另一个核心可能需要127KB。如果你静态地将64KB分区分配给每个核心,那么需要127KB的核心会不满意,而只需要1KB的核心则会浪费其缓存空间。如果为两者共享一个128KB的缓存,两个核心都会满意,不会浪费空间,并且在相同资源下不会增加执行时间。这就是资源共享的优势:它提高了利用率和效率,并有望最终提升性能。同时,也无需复制共享数据,这一点我们将在讨论缓存一致性时进一步探讨。

资源共享还有其他好处。它降低了通信延迟。多线程间共享的数据可以保存在同一个缓存中。例如,在共享缓存中,你不需要去其他缓存获取共享数据。这也与共享内存编程模型兼容。在共享内存模型中,不同线程通过读写指令进行通信:一个线程向某个地址写入,另一个线程从该地址读取。共享缓存与这个模型非常契合,因为该模型假设存在一个单一的内存空间,任何人都可以读写。而私有缓存本质上是在处理器本地复制了部分内存,从原理上讲,与这个模型并不完全兼容,当然,从软件角度看,这一切对程序员是透明的,他们仍然遵循共享内存模型。

然而,不幸的是,资源共享也有缺点。一个主要缺点是它会导致对共享资源的争用。当资源被一个线程占用时,另一个线程无法使用它。这适用于任何类型的资源:缓存空间、总线带宽、内存空间或存储空间。这会导致性能下降,可能降低每个线程或某些线程的性能。通常,线程的性能可能比它单独运行时更差。

另一个与性能下降不同的问题是,如果不加控制,资源共享会消除性能隔离。你获得的缓存空间量可能取决于在共享缓存中与你同时运行的其他程序。你可能在不同时间与不同的应用程序一起运行。你精心优化了你的程序,使其完美适配L2缓存。当你与应用程序A一起运行时,你获得了预期的缓存空间,性能很好。但当你与应用程序B一起运行时,该程序访问模式非常激进,会逐出你需要的所有缓存块,导致你的程序性能极差。这就消除了性能隔离。程序员在优化程序时假设会获得一定量的缓存,但由于资源在不同线程间共享且程序无法控制,导致所有优化都可能失效。因此,性能隔离非常重要,它能确保你在硬件上获得假定的资源,从而在不同运行中获得可预测的性能。

这也关系到服务质量。如果不控制这种共享,可能会导致不公平或饥饿现象。某些线程可能霸占资源,而其他线程可能被无限期延迟。我们可能在后面的课程中看到这样的例子。基本上,如果你要进行资源共享,就需要有机制来高效、公平地利用共享资源,以提供所需的服务质量和性能隔离。事实上,一些现代处理器已经集成了服务质量机制。例如,英特尔提供了在不同应用程序间划分缓存的方法,并对内存带宽划分提供了一定控制。

私有与共享缓存分析

现在,让我们从资源共享的角度分析私有缓存和共享缓存。如前所述,私有缓存属于一个核心,共享缓存由多个核心共享。

共享缓存的优势在于:

  • 高有效容量:任何人都可以利用大缓存,这很好。
  • 无碎片化:动态划分可用缓存空间,避免了静态分区可能产生的碎片。
  • 易于维护一致性:一个缓存块只存在于一个位置,这意味着如果一个处理器更新了它,每个人都能看到这个更新,因为它是共享的。

共享缓存的劣势在于:

  • 无法针对单个处理器定制:私有缓存可以紧密耦合核心,实现高吞吐量;而共享缓存需要某种网络在核心与缓存之间通信,最终导致访问速度变慢。
  • 核心间干扰:核心可能通过逐出彼此的缓存块而导致缺失。一些核心的访问模式可能会破坏其他核心的命中率。
  • 难以保证最低服务或公平性:由于共享,为每个核心提供多少空间和带宽变得难以确定。

让我举个例子。假设有两个核心。线程1运行在一个核心上,当它单独运行时,几乎需要整个L2缓存。线程2运行在另一个核心上,单独运行时只需要部分缓存。当它们一起运行时,线程1可能因其密集的访问模式而占据了大部分缓存,结果线程2得到的缓存远少于其获得高性能所需,甚至可能几乎得不到任何缓存。因为T1可能在相同时间内生成10个甚至100个内存请求,而T2只生成1个。因此,T2的性能可能因这种不公平的缓存共享而显著降低。这是现有系统中的真实问题,因此在设计共享资源时,需要在获得共享好处的同时,防止这种情况发生。

我可以继续讲下去,但遗憾的是我们无法深入探讨如何解决。基本上,资源共享和分区是相互矛盾的。共享提高了资源利用率,例如更好地利用空间;而分区则提供了性能隔离或可预测的性能,因为它将空间专用于单个线程或核心。关键问题是:我们能否同时获得两者的好处?思路是:在资源中设计共享,但在这些共享资源中设计机制,使其能够高效利用、可控且可分区。你需要有机制来实现受控的共享。这样做的希望是,既没有专用空间分区可能造成的资源浪费问题,又通过增加服务质量机制避免了纯共享可能带来的问题。当然,你无法获得两者的全部好处,但如果这样做,你将获得两者的大部分益处。

同样,我们没有时间深入讨论这些更高级的主题,但你可以查看未来的幻灯片或选修相关课程。

实际上,这里还有更多内容,我没有时间涵盖,但这些问题确实存在。只要你有共享资源,在现代系统中,核心内的资源在线程间共享,核心外的资源在某个节点之后的所有资源都被许多线程共享。想象一下,你有成千上万个线程,它们真正共享着有限的内存带宽,这就是我们今天在多核系统、GPU或机器学习加速器中看到的情况。因此,今天存在许多这样的问题。

缓存一致性简介

现在,让我介绍另一个维度:缓存一致性。有多少人学过缓存一致性?让我问同样的问题。有多少人没学过缓存一致性?有多少人可能学过但不记得了?没关系。让我们来学习缓存一致性,因为这很重要。

基本上,我们有共享内存编程模型。正如我们所讨论的,线程和并行程序通过共享内存进行通信。线程0向一个地址写入,线程1从该地址读取。这意味着两者之间存在某种通信。这是线程间(以及进程间)通信的一种方式。这是一个例子:这个线程运行在处理器0上,它向内存位置A写入;这个线程运行在处理器1上,它从内存位置A读取。正如你在这里看到的,它们之间需要进行一些通信。

如果这是模型,那么每次读取操作都应该接收到由任何处理器最后写入的值,否则程序就会出现不一致。线程之间需要进行适当的同步(这是一个更高层次的问题,程序员需要使用锁、屏障等来同步线程,这里我们不深入讨论)。我们将关注与缓存相关的问题。

基本上,如果内存位置A被任一处理器缓存,我们就会遇到问题。因为除非你做一些特殊处理(即所谓的缓存一致性),否则处理器可能看不到其他处理器的更新。

所以基本问题是:如果多个处理器缓存了同一个缓存块,它们如何确保对该缓存块都看到一致的状态?假设我们有两个处理器,处理器1和处理器2,它们有私有缓存Cache1和Cache2,并通过某个网络与主存通信。我们来看这个特定的缓存块X,其初始值为1000。

让我们看看这些处理器做了什么。假设处理器2将X加载到某个寄存器中,它加载的值是1000,这很好。X被缓存了。假设处理器1做了同样的事情,它将X加载到自己的寄存器R2中,这个位置也被缓存了。然后处理器1做了其他事情:它更新了位置X。这个更新反映在它的缓存中(假设是写回缓存)。现在你有了不一致:主存没有更新,而这个缓存(处理器2的缓存)也没有更新。那么问题是:当这个处理器(处理器2)加载X时,它应该加载什么值?它应该加载1000吗?直观的答案是不应该加载1000。你应该获得最新的值(假设程序以特定方式进行了同步)。这是直观的答案。还有一个更复杂的答案,需要你真正理解内存一致性模型,但这属于高级课程的内容。

基本思路是:你如何确保这个处理器不会加载错误的值?第一个想法是:每当这个处理器执行写操作时,它广播它将写入该缓存块。每个在其缓存中拥有该块的处理器都使该块失效。这是一个非常基本的缓存一致性协议,一个基于广播的协议。在确保每个人都已使该块失效之前,你不应该写入这个块。另一个答案是:当你写入该块时,你也更新所有其他缓存中的该块。基本上,你广播你正在写入的事实以及你正在写入的数据。

这就是基于广播的协议的思想:处理器或缓存将其对缓存块的写入或更新广播给所有其他处理器。另一个拥有该块的处理器或缓存要么使其本地副本失效,要么更新它。那么问题就变成了:是使其失效还是更新?我们不会深入讨论,这是一个更高级的问题。但如果使其失效,基本上就足够了。如果你确保没有竞态条件(即在写操作实际发生之前,没有其他人读取该块的旧值),那么这应该是可行的。这个想法很好,如果处理器通过一种媒介(本质上是一根导线,在这里是总线)连接,使得广播能立即被所有其他缓存看到,那么它工作得很好。但这通常限制了这些协议的可扩展性。

让我给你一个非常简单的缓存一致性方案,我不会深入细节。因为它实现了我所说的:每当一个处理器写入一个缓存块时,它会发送一条广播消息,说明它正在写入该缓存块,而其他所有拥有该块的缓存都使其失效。因此,所有缓存都以某种方式观察彼此的读写操作。如果一个处理器写入一个块,所有其他处理器使该块失效。

一个具有某些假设的简单协议(你可以自己研究)是:本地处理器可以对缓存块进行读或写,基于这些操作,总线读和总线写被广播到总线上。协议看起来是这样的,它有两个状态(因为它是直写缓存,没有脏位)。这是最简单的协议。基本上,你有一个有效状态或无效状态。如果一个处理器写入该缓存块,它必须(希望如此)发送一个总线写信号。每当处理器在其有效状态下看到对给定块的总线写操作时,它会使该块失效。这是一个你现在应该很熟悉的非常简单状态机。基本思想就是这样,在这些假设下,这是一个可行的一致性协议。你实际上并没有增加有效位中的位数。通常在一致性协议中,你可能需要增加位数来跟踪修改、独占等状态,但我们没有时间讨论所有这些。

有道理,对吧?让我再介绍另一种一致性协议,我们不会深入细节,但我必须涵盖它,因为现代协议是两者的结合:它们既基于广播,也基于目录。

目录协议的思想是有一个中间人来仲裁对缓存块的更新或请求,这个中间人称为目录。这个中间人在逻辑上是集中的,但当然,如果你想扩展系统,你不需要集中式组件,所以这个中间人通常在物理上是分布在不同内存中的。我们不会讨论这个。想象一个中央目录,它跟踪每个可能的缓存块位于哪些缓存中。每个缓存在对缓存块执行任何操作之前,都会咨询这个目录并请求许可。

基本上,处理器1想写入缓存块X。它可以这样做吗?它去目录(在这个例子中目录位于主存旁边,但目录也可以被缓存在缓存旁边,这非常有趣,你可以缓存提供缓存一致性的目录,缓存是如此强大)。每当你想写入X时,处理器1说我想写入X,目录请给我许可。目录说好的,让我看看还有谁有X,因为它有一个位向量来跟踪系统中哪些其他缓存实际拥有X。这意味着你需要为系统中的每个缓存块设置一个位向量或某种数据结构,来跟踪谁在其缓存中拥有该缓存块。假设你有这个数据结构,目录说,哦,我知道缓存2实际上有这个缓存块,我将发送一条消息给缓存2,要求缓存2使该缓存块失效。在它收到所有拥有该缓存块的缓存的响应(说它们已使缓存块失效)后,目录告诉缓存1或处理器1,继续,你可以写入它,你拥有唯一的许可或唯一的副本。

有道理吧?所以这基本上是一个中间人。这个中间人成本很高,因为你要经过一个中间人,存在间接性,因此这种方案的一致性延迟比我们之前讨论的广播方案要长得多。在广播方案中,一个处理器广播,每个人都看到,你不需要经过任何中间人,所以延迟要低得多。但广播方案不可扩展,因为如果你想连接20万个处理器到一个单一总线上,祝你好运。即使超过16个也不容易。不幸的是,今天我们有数以万计的多处理器相互连接,这就是为什么(假设你需要缓存一致性)我们有这样的连接方式。

我不会深入细节,但我描述的内容适用于一个示例机制,看起来像这样:对于每个缓存块,内存使用目录存储 P+1 位(P是处理器数量)。这成本很高。现在想象一下计算这个:如果你有数十TB的内存,缓存块大小为64字节,将10TB除以64字节再乘以P+1,这就是目录中的位数。这实际上比你的缓存大得多。但假设你以某种方式拥有了它。你为每个缓存设置一个位,指示该块是否在该缓存中。本地缓存或本地处理器(在这种情况下,处理器和缓存可以互换使用,因为我们假设一个处理器有私有缓存)还有一个独占位,表示其位被设置的缓存拥有该块的唯一副本,并且可以在不通知他人的情况下更新它。

因此,可以进行很多有趣的优化。基本上,目录可以授予请求更新缓存块的缓存许可,说:好的,你拥有这个缓存块,你可以更新它,直到我通知你为止。你不需要在更新时通知我,因为我保证没有其他缓存拥有这个块。所以,使用目录可以进行很多有趣的优化。

在读取时,缓存向目录发送消息,目录设置该块目录条目中的缓存位,并安排将块提供给缓存。例如,如果它被其他处理器更新过,它会从该处理器获取数据块并交给正在读取的处理器。

在写入时,目录确保使所有拥有该块的缓存中的该块失效,并重置该块目录条目中那些缓存的位,因为它将把缓存块交给某人,以便某人可以更新它。

我们在本地缓存中为每个缓存块设置了一个独占位,这样缓存就可以知道在更新独占块时无需通知目录。如果它从目录获得了对这个缓存块的许可,它会在标签存储中设置一个称为独占位的位。这意味着,我现在知道我是系统中唯一拥有该副本的缓存,我可以在不经过目录的情况下静默更新它,或者也可以在不经过目录的情况下读取它。

有道理吗?所以现在你知道了两种基本的一致性方法。我认为了解这些很重要。如果你想了解优化,你必须选修未来的课程。例如,这是在Pentium Pro中实现的每个缓存块的状态机,称为MESI协议。我之前展示给你的协议是有效/无效的。

实际上,对于目录,我们看了一个独占位。MESI协议有四个状态:无效、独占、共享和已修改。基本上,独占意味着我拥有缓存块的唯一副本,所以我可以写入它。

总结

本节课中我们一起学习了多核处理器系统中缓存设计的关键问题。我们探讨了私有缓存与共享缓存的权衡,分析了资源共享在提升利用率的同时带来的争用和性能隔离挑战。我们还初步了解了确保数据一致性的两种基本协议:基于广播的协议和基于目录的协议。理解这些概念是设计高效、可扩展多核系统的基础。

25:预取 II、内存内处理与课程总结 (Spr 2025) 🎓

概述

在本节课中,我们将继续探讨高级预取技术,包括基于强化学习的自适应预取器,以及一种名为“前瞻执行”的基于执行的预取方法。最后,我们将回顾整个课程的核心内容,并对计算机架构的未来进行展望。


基于强化学习的预取器 🤖

上一节我们介绍了预取的基本概念和挑战。本节中,我们将探讨一种更智能、更灵活的预取器设计方法——基于强化学习的预取器。

强化学习简介

强化学习是一种机器学习范式,其核心思想是:智能体在与环境交互的过程中,通过尝试不同的动作并根据获得的奖励(或惩罚)来学习在特定状态下应采取的最佳动作,以最大化长期累积奖励。

将预取问题形式化为强化学习问题

我们可以将预取器设计为一个强化学习智能体,其环境是处理器和内存子系统。其目标是学习何时、预取何地址的数据。

  • 状态:由一系列特征构成,例如:
    • 生成当前内存请求的指令的程序计数器。
    • 最近访问的地址之间的差值。
    • 最近观察到的多个差值序列。
  • 动作:选择一个偏移量进行预取。例如,对于当前地址 A,动作可以是预取 A + offset,其中 offset 可以是 -63 到 +63 之间的一个值,包括 0(表示不预取)。
  • 奖励:根据预取的效果给予智能体反馈。奖励函数需要精心设计,以平衡多个目标:
    • 准确性:预取的数据是否被实际使用。
    • 及时性:预取是否在处理器需要数据之前完成。
    • 系统影响:预取对内存带宽的占用是否过高。

自优化预取器 Pythia 的实现

Pythia 是一个基于 Q-Learning 的预取器。其核心组件是一个 Q 值表,存储了每个“状态-动作”对的预期质量值。

以下是其工作流程的简化描述:

  1. 观察与决策:当出现一个内存请求(地址 A)时,预取器提取当前状态特征 S
  2. 查询 Q 表:根据状态 S,查找 Q 表中所有可能动作(偏移量)对应的 Q 值。
  3. 选择动作:选择 Q 值最高的动作(偏移量 O)。
  4. 执行预取:生成预取请求 A + O 并发送至内存层次结构。
  5. 评估与学习:将此次“状态-动作”对记录在评估队列中。随后,监控处理器是否实际请求了该预取数据,以及请求的时机。根据这些观察结果计算奖励 R
  6. 更新 Q 表:使用奖励 R 更新 Q 表中对应的 (S, O) 值,公式遵循 Q-Learning 的更新规则(例如:Q(S,O) = Q(S,O) + α * [R + γ * max_a Q(S_next, a) - Q(S,O)],其中 α 是学习率,γ 是折扣因子)。

这种设计的优势在于其自适应性可配置性。预取器可以在线学习,适应不同的工作负载和动态的系统条件(如变化的带宽)。通过调整奖励函数,甚至可以在不修改硬件的情况下改变预取器的策略(例如,从激进变为保守)。


基于执行的预取:前瞻执行 🏃

除了基于历史模式预测的预取器,还有另一类思路:直接提前执行一小段程序代码,专门用于发现和预取数据。这就是基于执行的预取,其中“前瞻执行”是一个代表性技术。

动机与核心思想

现代乱序执行处理器依靠大的指令窗口来容忍内存访问延迟。然而,构建非常大的指令窗口会导致设计复杂、功耗高、周期时间长。

前瞻执行提出了一个不同的思路:当遇到一个导致长时间停滞的缓存缺失(例如 L2 缓存缺失)时,处理器保存当前的架构状态(检查点),然后进入一种特殊的前瞻模式。在这个模式下,处理器继续推测性地执行后续指令,但目的不是提交结果,而是为了:

  1. 发现后续的缓存缺失:提前执行到可能发生缓存缺失的加载指令。
  2. 发起预取:为这些发现的缺失地址提前发起内存请求。
  3. 避免因长延迟指令而阻塞:在前瞻模式中,遇到依赖未返回数据的指令(标记为无效)时,会跳过它们,从而为窗口中其他不依赖该数据的指令腾出空间,继续向前推进。

当最初导致进入前瞻模式的那个缓存缺失返回数据时,处理器恢复检查点,刷新流水线,并回到正常执行模式。此时,由于前瞻执行已经预取了一些数据,后续的缓存缺失可能已经部分或全部被解决,从而显著减少了处理器的停滞时间。

工作示例

假设程序顺序如下:

  1. LOAD X (导致 L2 缓存缺失,长延迟)
  2. COMPUTE ...
  3. LOAD Y (也可能导致缓存缺失)
  4. COMPUTE ...

没有前瞻执行LOAD X 缺失,处理器在指令窗口满后停滞,等待 X 返回。LOAD Y 的缺失只能在其被正常执行时才开始,两个缺失串行处理。
有前瞻执行LOAD X 缺失后,进入前瞻模式。推测性地执行后续 COMPUTELOAD YLOAD Y 的缺失被提前发现并发出请求。当 X 的数据返回,恢复检查点后重新执行 LOAD X(此时命中),不久后执行到 LOAD Y 时,其数据可能也已返回或即将返回。两个缺失的处理时间实现了重叠。

优势与挑战

优势

  • 高准确性:因为是真实执行程序,预取准确性极高(可达95%以上),尤其擅长处理不规则访问模式(如指针追逐)。
  • 实现相对简单:相比构建超大型指令窗口,对现有流水线改动较小。
  • 无额外硬件上下文:复用主线程资源。

挑战

  • 额外指令执行:执行了可能无用的推测指令。
  • 受分支预测精度限制:在前瞻模式下分支预测错误会导致走错路径。
  • 无法预取依赖的缺失:如果 LOAD Y 的地址依赖于 LOAD X 返回的数据,在前瞻模式下无法计算 Y 的地址。解决此问题需要值预测等更高级的技术。
  • 预取距离有限:前瞻执行的有效时间受最初那个缓存缺失的延迟限制。

前瞻执行的思想已被多家厂商(如 Sun/Oracle、IBM、NVIDIA)以不同形式采纳和实现,证明了其在实际系统中的价值。


课程总结与展望 🌟

本节课我们一起学习了两种高级的预取技术,并对整个《数字设计与计算机架构》课程进行了回顾。

核心要点回顾

  1. 从晶体管到完整系统:我们从最底层的晶体管开始,逐步构建了组合/时序逻辑、处理器数据通路与控制单元、流水线、内存层次结构(缓存、虚拟内存),直至系统软件交互。
  2. 理解设计权衡:贯穿课程的核心是权衡分析。无论是性能与面积、延迟与吞吐量、硬件复杂度与灵活性,还是缓存命中率与污染,都没有完美的设计,只有针对特定场景的合适选择。
  3. 多样的执行范式:我们探讨了多种处理器执行模型,包括:
    • 单周期/多周期
    • 流水线
    • 乱序执行与超标量
    • VLIW(超长指令字)
    • SIMD/向量处理
    • GPU 架构
    • 前瞻执行等
      每种范式都有其适用的场景和固有的权衡。
  4. 内存系统是关键:我们深入研究了计算机系统中最重要的性能瓶颈——内存墙。了解了缓存原理、优化技术、虚拟内存机制以及像预取这样的高级 latency tolerance 技术。

未竟议题与未来方向

计算机架构领域依然充满活力与挑战,本课程未及深入探讨的众多方向正是未来的研究热点:

  • 内存内处理:为了从根本上缓解数据移动的能耗与延迟问题,将计算单元嵌入内存内部(如 DRAM 芯片内)是一个重要趋势。这需要跨器件、电路、架构、系统的协同创新。
  • 硬件安全:诸如 Meltdown、Spectre、Rowhammer 等漏洞表明,现代处理器复杂的推测执行和共享资源机制引入了新的安全攻击面。设计安全、可靠、可信的架构至关重要。
  • 领域专用架构:随着摩尔定律放缓,针对特定领域(如机器学习、图形处理、生物信息学)定制高效能、高能效的加速器变得愈发重要。
  • 智能化架构:如本节课所见,利用机器学习(如强化学习)来优化架构组件(预取器、内存控制器、分支预测器等)的设计和运行时管理,是一个富有前景的方向。
  • 协同设计:未来的突破更依赖于硬件、软件、算法、甚至应用需求的紧密协同设计。

最后的思考

本课程的目标不仅是传授计算机如何工作的“事实”,更重要的是培养批判性思维权衡分析的能力。正如爱因斯坦所言:“教育的价值不在于学习大量事实,而在于训练大脑思考。” 在技术飞速变化的时代,这种能够分析问题、评估方案、并创造性解决问题的能力,将是你们最持久的财富。

希望大家能将课程中学到的原理和思维方法,应用于未来更广阔的学习、研究和工程实践中,去设计和创造更好的计算系统。

课程至此结束,祝大家考试顺利,未来在计算的世界里继续探索前行!

26:问题解决 I (Spring 2025)

在本节课中,我们将学习如何解决关于有限状态机、MIPS汇编代码、流水线设计和数据流机器的一系列问题。我们将从分析一个FSM的状态编码开始,然后编写和优化MIPS代码,接着探讨不同流水线配置下的性能,最后设计一个计算斐波那契数列的数据流引擎。


有限状态机分析

上一节我们介绍了课程概述,本节中我们来看看第一个问题:分析一个给定的有限状态机。

问题1:识别缺失组件

观察给定的FSM图,发现缺少一个关键组件:复位信号。复位信号用于确保FSM在启动时处于确定的初始状态,从而实现确定性的行为。

问题2:确定FSM类型

该FSM的输出仅取决于当前状态,而不依赖于输入。根据定义,这属于摩尔型状态机。

问题3:不同状态编码的优势

以下是三种常见状态编码方式的主要优势:

  • 独热编码:目标是减少次态逻辑的复杂度。每个状态由单个比特位表示,理想情况下可以简化组合逻辑。
  • 二进制编码:目标是使用最少的触发器来表示状态,从而减少所需的触发器数量
  • 输出编码:目标是使用最少的逻辑单元来编码输出,从而最小化输出逻辑电路

问题4:构建真值表与逻辑方程

首先,我们根据状态转移图构建一个高级别的真值表,其中状态用符号表示。

当前状态 TA TB 输出 (O1, O0) 次态
A X 0 1, 0 B
A X 1 1, 0 C
B 0 X 1, 1 C
B 1 X 1, 1 A
C X X 0, 1 D
D X 0 0, 0 B
D X 1 0, 0 D

接下来,我们将使用此表作为参考,推导不同编码下的逻辑方程。

4.1 独热编码

在独热编码中,我们用单个比特位表示每个状态。假设编码为:A=0001, B=0010, C=0100, D=1000

将编码代入真值表后,可以推导出次态和输出的逻辑方程。例如,次态位 N3 在特定条件下为1,这些条件由当前状态位和输入决定。

以下是推导出的逻辑方程示例(具体方程取决于完整的真值表推导):

  • N3 = C2 + ...
  • O1 = C0 + C1
  • O0 = C1 + C2

4.2 二进制编码

在二进制编码中,我们使用最少的比特位数表示状态。对于4个状态,需要2位。假设编码为:A=00, B=01, C=10, D=11

同样代入真值表,推导逻辑方程。例如:

  • N0 = (C1 & ~C0 & TB) | (C1 & C0) | ...
  • O1O0 的逻辑方程也会相应变化。

4.3 输出编码

输出编码旨在最小化输出逻辑。我们尝试将输出位直接映射到状态编码的某些位上。例如,设计状态编码使得 O1 仅由状态位 C1 决定,O0 仅由 C0 决定。

通过精心分配状态编码(例如 A=10, B=11, C=01, D=00),可以实现这一点,从而简化输出逻辑为:

  • O1 = C1
  • O0 = C0

次态逻辑需要根据新的编码重新推导。

问题5:选择最小化方案

通过比较三种编码方案所需的逻辑门数量(考虑次态逻辑和输出逻辑)以及触发器数量,可以得出结论:在本例中,输出编码在最小化整体FSM面积方面表现更优,因为它直接优化了输出逻辑,同时保持了合理的次态逻辑复杂度。


MIPS汇编编程

上一节我们分析了FSM的设计,本节中我们来看看如何用MIPS汇编实现具体算法。

问题1:斐波那契数列计算

我们需要将计算第n个斐波那契数的C代码转换为MIPS汇编。C代码如下:

a = 0; b = 1; c = a + b;
while (n > 1) {
    c = a + b;
    a = b;
    b = c;
    n--;
}

假设参数 n 存储在 $a0 ($4) 中,结果 c 存储在 $v0 ($2) 中。

以下是MIPS汇编实现的核心思路:

  1. 函数序言:保存调用者保存的寄存器(如 $s0, $s1, $s2)到栈中。
  2. 初始化:将 $a0 复制到临时寄存器作为计数器,初始化 a=0, b=1
  3. 循环条件:检查计数器是否大于1。
  4. 循环体:计算 c = a + b,然后更新 a = b, b = c,递减计数器。
  5. 函数尾声:恢复保存的寄存器,返回结果。

问题2:实现 rep movsb 指令

rep movsb 是x86指令,用于将指定数量的字节从源数组复制到目标数组。我们需要用MIPS指令模拟它。

假设:ECX(计数)在 $1ESI(源指针)在 $2EDI(目标指针)在 $3

MIPS实现思路:

  1. 检查 $1(计数)是否为0,若为0则结束。
  2. 循环体:从 $2 指向的地址加载一个字节,存储到 $3 指向的地址。
  3. 递增 $2$3,递减 $1
  4. 跳回步骤1。

代码大小比较:这条MIPS循环大约需要7条指令,每条4字节,共28字节。而原始的x86 rep movsb 指令只有2字节。

执行指令数分析

  • ECX = 25,则循环体执行25次。每次循环执行6条指令,加上首次的条件判断指令,总执行指令数为 6*25 + 1 = 151 条。
  • ECX = 0,则仅执行1条条件分支指令,不进入循环。

流水线机器性能分析

上一节我们编写了MIPS汇编,本节中我们来看看不同流水线配置对程序性能的影响。

我们有一段包含加载、存储、加法和分支指令的MIPS代码循环。需要分析在不同机器配置下执行所需的周期数。

机器配置

  • 机器1:无互锁,依赖编译器调度指令或插入空操作,寄存器文件内部转发,预测分支总是成功。
  • 机器2:硬件实现数据前递(对于加载指令,只能从回写级前递;对于其他指令,可从访存或回写级前递),寄存器文件内部转发,预测分支总是成功。

分析步骤

  1. 识别依赖:分析指令间的数据依赖(RAW)。
  2. 绘制流水线时空图:根据机器配置(是否有前递、功能单元数量),确定每条指令每个阶段开始的周期。
  3. 计算总周期数:统计所有指令执行完毕所需的周期数,考虑循环迭代次数和最后的排空周期。

结论:通过对比发现,在给定的代码和配置下,尽管机器2具有硬件前递,但由于加载指令前递延迟的限制,其最终执行总周期数与经过编译器优化调度的机器1可能相同或相近。这凸显了编译器优化与硬件支持之间需要权衡。


数据流机器设计

最后,我们探讨如何为斐波那契函数设计一个数据流引擎。数据流机器基于令牌传递和节点触发执行。

可用节点

  • ADD:加法
  • GT:比较(大于)
  • COPY:复制数据
  • BRANCH:条件分支(根据布尔输入选择输出路径)
  • 常量输入(如 0, 1, -1

设计思路

数据流图需要实现斐波那契数列的迭代计算:F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2) (n>1)。

  1. 基础框架
    • 输入令牌 n
    • 首先用 GT 节点比较 n > 0,结果控制后续流程。
  2. 处理特殊情况 n=0
    • n>0 为假,通过 BRANCH 节点将常量 0 路由到输出。
  3. 处理 n>=1 的情况
    • n>0 为真,通过一个 BRANCH 节点将 n 路由到 ADD 节点与 -1 相加,得到 n-1,用于后续迭代。
    • 同时,初始化两个流:F(n-2)(初始为0)和 F(n-1)(初始为1)。它们作为加法器的输入。
  4. 循环迭代
    • 加法器计算 F(n) = F(n-1) + F(n-2)
    • 使用 COPYBRANCH 节点将本次的结果反馈回去,更新 F(n-2)F(n-1) 为下一次迭代做准备。
    • 每迭代一次,n-1 减1。当 (n-1) > 0 不再为真时,触发另一个 BRANCH 节点,将当前计算出的 F(n) 值路由到输出。

关键点

  • 令牌流动驱动计算,节点在所有输入令牌就绪时触发。
  • BRANCH 节点根据布尔输入选择将数据令牌发送到“真”或“假”输出路径,从而控制数据流向。
  • 通过巧妙地连接节点、复制令牌和使用常量流,可以构建出实现迭代算法的数据流网络。

总结

本节课中我们一起学习了多个核心主题:

  1. 有限状态机:分析了状态编码(独热、二进制、输出编码)的特点、优势,并实践了从状态图到逻辑方程的推导。
  2. MIPS汇编:实现了斐波那契数列计算和复杂x86指令的模拟,理解了汇编编程、函数调用约定以及指令级效率的差异。
  3. 流水线性能分析:通过绘制流水线时空图,深入分析了数据依赖、前递技术和资源冲突对程序执行周期的影响,比较了不同硬件配置的性能。
  4. 数据流计算:初步接触了数据流计算模型,设计了实现斐波那契函数的数据流引擎,理解了基于令牌触发的异步计算模式。

这些练习涵盖了数字系统设计与计算机架构中从底层逻辑设计到高层架构权衡的关键技能。

29:问题解决 IV (Spring 2025)

概述

在本节课中,我们将一起解决去年考试中的12道题目。我们将按照它们在去年考试中出现的顺序进行讲解,涵盖布尔逻辑电路、Verilog代码、有限状态机、性能评估、流水线、Tomasulo算法、GPU/CUDA、分支预测、缓存逆向工程、前瞻执行和脉动阵列等多个核心主题。


问题 1:布尔逻辑电路

1.1 从最小项推导布尔表达式

我们被给定了一些四输入的最小项,需要写出并简化其布尔表达式。我们将使用变量 A(最高有效位)到 D(最低有效位)。

根据给定的最小项,布尔表达式如下:

F = A B C D + A B C D' + A B' C' D + A' B C D + A' B C' D' + A' B' C' D'

1.2 简化布尔表达式

我们可以通过观察或使用卡诺图来简化表达式。通过寻找公共项,我们可以进行如下简化:

首先,观察到 BD 项与 AA' 组合出现。其次,AC 项也频繁出现。通过提取公因子,表达式可以简化为:

F = B' C' D' + A C + A B'

这可以进一步简化为:

F = B' C' D' + A (C + B')

核心概念:布尔表达式的简化可以通过代数运算或卡诺图完成。公式 F = Σ m(...) 表示最小项之和。

1.3 转换为仅使用“或非”门的电路

给定一个布尔表达式,我们需要将其转换为仅使用“或非”门的形式。转换步骤通常包括:简化、应用德摩根定律、添加双重否定,或与自身进行“与”/“或”操作以方便转换。

给定表达式:(B C)' + (A C')',我们目标是消除所有“与”和“或”操作,只保留“或非”操作。

转换过程

  1. 观察到 (B C)'(A C')' 已经是“或非”形式(即 B NOR CA NOR (NOT C)),但 C' 需要处理。
  2. 处理 A C':可以应用德摩根定律:A C' = (A' + C)',但这不是“或非”。我们选择添加双重否定:A C' = ( (A C')' )'(A C')' 已经是 A NOR C'
  3. 现在需要实现 C'C' = C NOR C
  4. 类似地,B C = (B' + C')'(德摩根),而 B' = B NOR BC' = C NOR C
  5. 将所有这些部分用“或非”门连接起来,最终得到一个仅由“或非”门组成的电路。

核心概念:任何布尔函数都可以仅用“或非”门实现。代码表示:NOR(A, B) = (A + B)'


问题 2:Verilog 代码补全

概述

本题涉及一个Verilog模块,其中有五个空白需要填写,以使其功能正确。

代码分析与填空

模块有两个 always 块:一个组合逻辑块计算 n_val,一个时序逻辑块在时钟沿更新 out_data

  1. 第一个空(out_data 声明)out_data 在第二个 always 块中被赋值,且必须保持其值,因此它必须是一个寄存器类型的输出。正确选择是 output reg [31:0] out_data
  2. 第二个空(n_val 声明)n_val 在第一个 always 块中被赋值,并在第二个块中使用,因此它必须是一个寄存器。正确选择是 reg [31:0] n_val
  3. 第三个空(case 语句的 defaultcase 语句列出了 op 的可能值 2'b002'b012'b10。对于未列出的值(如 2'b11),应使用 default 分支。其他选项的语法或数值表示是错误的。
  4. 第四和第五个空(非阻塞赋值):在时钟触发的 always 块中,为了正确建模时序逻辑,应使用非阻塞赋值 <=,而不是阻塞赋值 ===(比较操作)。因此,两个空都应填入 <=

核心概念:在Verilog中,reg 用于在 always 块中存储值,output reg 声明输出寄存器。时序逻辑块应使用非阻塞赋值 <=


问题 3:Verilog 代码功能分析

概述

我们需要分析一段Verilog代码,并计算在16个连续时钟周期内输出 out 的值。

代码执行分析

该设计包含两个时钟触发的 always 块和一个 assign 语句。

  • 第一个块:根据 out 信号(&out 表示 out 的所有位相与)来切换 state
  • 第二个块:根据 state 递增或递减 my_reg
  • 输出 out 直接连接到 my_reg

通过逐步模拟时钟周期,我们得到 out 的值序列(十进制):0, 1, 2, 3, 0, 3, 2, 3, 0, 3, 2, 3, ... 这是一个在0, 2, 3之间循环的模式,其中0和3交替出现,中间穿插着2。

核心概念:Verilog的 always @(posedge clk) 块描述时序逻辑。非阻塞赋值 <= 确保在时钟沿后同时更新。


问题 4:有限状态机

4.1 简化FSM

给定的有限状态机包含不可达状态。状态C没有进入边,因此不可达,可以移除。移除C后,状态D也变得不可达,接着状态E也不可达。移除这些状态后,剩下的状态A和B由于在输入0时输出不同(A输出0,B输出1),因此不能再合并。简化后的FSM只包含状态A和B及其之间的转移。

4.2 执行FSM

给定输入比特流(从最低有效位到最高有效位):00110101。从状态A开始:

  • 前三个0:保持在A,输出0。
  • 遇到第一个1:转移到B,输出1。
  • 后续位:在B状态下,输出是输入的取反。
    因此,输出序列为:0 0 0 1 1 0 1 0

4.3 设计摩尔型FSM

需要设计一个摩尔机,当输入的无符号二进制数(从最高有效位到最低有效位读取)能被8整除时输出1。能被8整除意味着最后三位必须为0。

状态设计

  • S0:当前读取的序列能被8整除(输出1)。接收0则保持,接收1则转到 S1(输出0)。
  • S1:序列以1结尾(输出0)。根据输入转移到 S2(以10结尾)或保持。
  • S2:序列以10结尾(输出0)。根据输入转移到 S3(以100结尾)或回到 S1
  • S3:序列以100结尾(输出0)。接收0则回到 S0(输出1),接收1则回到 S1(输出0)。

核心概念:摩尔机的输出仅依赖于当前状态。FSM设计的关键是定义足够的状态来记忆相关的输入历史。


问题 5:性能评估

5.1 计算CPI

处理器P1的指令延迟:Load: 6 cycles, Store: 6 cycles, ALU: 2 cycles, Branch: 2 cycles。
应用A的指令混合比:Load: 40%, Store: 20%, ALU: 30%, Branch: 10%。
平均CPI = (0.46) + (0.26) + (0.32) + (0.12) = 4.4

5.2 新处理器P2的CPI

P2时钟频率加倍,但所有指令延迟增加4个周期。指令混合比不变。
新CPI = (0.4(6+4)) + (0.2(6+4)) + (0.3(2+4)) + (0.1(2+4)) = 8.4

5.3 比较性能

执行时间公式:Time = (Instructions * CPI) / Clock Frequency
设P1的频率为f,P2的频率为2f,指令数相同。
P1时间 ∝ 4.4 / f
P2时间 ∝ 8.4 / (2f) = 4.2 / f
因此,P2更快,速度是P1的 4.4 / 4.2 ≈ 1.048 倍。

5.4 选择优化方案

有两种优化方案:1) IU优化,将ALU和分支指令延迟减半;2) LSU优化,将Load延迟减半,但Store延迟加倍。
使用阿姆达尔定律计算加速比:

  • IU优化:可加速部分比例 = 0.3 + 0.1 = 0.4,加速倍数 = 2。
    加速比 = 1 / ((1-0.4) + 0.4/2) = 1 / (0.6 + 0.2) = 1.25
  • LSU优化:可加速部分比例 = 0.4 + 0.2 = 0.6,但Load加速2倍,Store减速2倍(即加速比0.5)。需要计算整体加速比。
    加速比 = 1 / ((1-0.6) + (0.4/2 + 0.2/0.5)) = 1 / (0.4 + (0.2 + 0.4)) = 1 / 1.0 = 1.0
    因此,应选择IU优化。

核心概念:CPI是平均每条指令的周期数。阿姆达尔定律:Speedup = 1 / ((1 - p) + p/s),其中p是可优化部分比例,s是该部分的加速比。


问题 6:流水线

概述

分析在两种不同流水线机器(A和B)上运行一段循环代码的性能。机器A需要编译器插入空操作来处理数据冒险,机器B有硬件互锁和转发。

6.1 为机器A重排代码

机器A无硬件互锁,需编译器插入 nop。分析原始代码的数据依赖:

  • add 依赖前两条 lw 的结果。lw 结果在WB阶段才可用,因此 add 需要至少两个 nop
  • sw 依赖 add 的结果,同样需要两个 nop
  • sub 无依赖。
  • bne 依赖 sub 的结果,需要两个 nop
    此外,由于分支预测为“总是采取”,且下一条PC在Decode阶段后可用,因此每条分支指令后实际上需要一个气泡(相当于一个 nop)。
    重排后的代码需要在关键指令间插入 nop

6.2 机器A的时间线

绘制第一个循环迭代的时间线,考虑上述 nop 插入点,可以计算出一次迭代需要13个周期。

6.3 机器A的总周期数

循环执行100次。总周期数 = 100 * 13 + 排空流水线的3个周期 = 1303 cycles。

6.4 机器B的时间线与总周期数

机器B有硬件互锁和转发。lw 结果只能从WB阶段转发。

  • add 需要等待第二个 lw 的WB阶段,因此停顿1周期。
  • sw 能直接从 add 的MEM阶段获得转发,无停顿。
  • bne 依赖 sub,需等待其WB阶段,停顿1周期。
    分析表明,一次迭代需要8个周期。
    总周期数 = 100 * 8 + 3 = 803 cycles。

核心概念:数据冒险可通过转发或停顿解决。加载-使用冒险需要至少一个周期的停顿。分支误预测会导致性能损失。


问题 7:Tomasulo算法

概述

给定一个Tomasulo算法的状态快照(保留站和寄存器别名表),我们需要推导出已取指指令的数据流图及其程序顺序。

7.1 构建数据流图

从初始RAT和保留站快照反向推导:

  1. 保留站中,有些条目操作数就绪(如值82和1),对应初始寄存器R1和R2。它们产生结果,用于更新某个寄存器(如R8)。
  2. 其他条目有依赖标签(如标签F),表明它们等待之前指令的结果。
    通过追踪这些依赖关系,可以构建出一个数据流图,节点表示操作(加法/乘法),边表示数据流,边上标有目标寄存器名和保留站标签。

7.2 推断指令序列

根据数据流图中的依赖关系,可以推断出五条指令的程序顺序。例如:

  1. add R3, R4, R7 // 产生用于后续乘法的值
  2. add R8, R1, R2 // 独立的加法
  3. mul R5, R3, R2 // 使用第一条指令的结果
  4. mul R4, R5, R4 // 使用第三条指令的结果
  5. mul R9, R6, R3 // 使用第一条指令的结果
    顺序可能不唯一,但需满足数据依赖。

核心概念:Tomasulo算法使用保留站实现乱序执行。寄存器别名表跟踪寄存器值的生产者(保留站)。


问题 8:GPU/CUDA

概述

本题涉及GPU的线程束调度和SIMD利用率计算。分析两段代码在给定GPU配置下的行为。

8.1 计算线程束数量

代码有1024次迭代,每个迭代分配给一个线程。GPU线程束大小为32。
所需线程束数 = 1024 / 32 = 32 warps。

8.2 代码段1的SIMD利用率(第一次内循环)

对于代码段1,当 j=0 时,S=1。条件 (i % (2*S)) == 0 即检查 i 是否为偶数。

  • 在线程束中,一半线程(偶数ID)执行if块(4条指令),另一半(奇数ID)跳过if块(执行3条指令)。
  • 有效指令比例 = (164 + 163) / (32*4) = 7/8 = 87.5%。

8.3 代码段2的SIMD利用率(第一次内循环)

对于代码段2,当 j=0 时,S=512。条件 i < S

  • 前16个线程束(线程ID 0-511)的所有线程都满足条件,执行4条指令。
  • 后16个线程束的所有线程都不满足条件,执行3条指令。
  • 没有线程束内部出现分歧,因此SIMD利用率为100%。

8.4/8.5 通用SIMD利用率公式

对于代码段1,S = 2^(j+1)。当 j<5S<=32)时,每个线程束内都有活跃和非活跃线程。利用率公式为 [4/(2^(j+1)) + 3*(1 - 1/(2^(j+1)))] / 4。当 j>=5 时,有些线程束完全活跃,有些完全非活跃,有些则混合,公式更复杂。
对于代码段2,S = 512 / 2^j。当 j<=4S>=32)时,利用率100%。当 j>4 时,利用率开始下降,公式类似但模式不同。

8.6 相同利用率的迭代

j=9 时,对于两段代码,S 都变为1(代码段1)或1(代码段2),条件本质相同,因此SIMD利用率相同。

8.7 性能比较

通常,SIMD利用率更高的代码段运行更快,因为它更有效地使用了硬件资源。代码段2在大多数迭代中利用率更高,因此预计更快。

核心概念:SIMD利用率衡量硬件计算单元的活跃比例。线程束内分支分歧会降低利用率。


问题 9:分支预测

概述

分析三段代码在三种不同分支预测器下的误预测率。

9.1 机器A(总是采取)

  • B1(循环条件):1000次采取,最后1次不采取 → 1次误预测。
  • B2(偶数判断):1000次执行,50%采取 → 500次误预测。
  • B3(i<250):前250次采取,后750次不采取。预测总是采取,因此后750次误预测。
  • B4(i<500)和B5(i>=500):各500次误预测。
    总误预测数 = 1 + 500 + 750 + 500 + 500 = 2251。总分支数 = 1000*5 + 1 = 5001。误预测率 = 2251/5001 ≈ 0.45。

9.2 机器B(全局2位饱和计数器)

预测器初始为“弱采取”。需要模拟分支历史对全局状态的影响。

  • 分析不同 i 值范围(如0-249, 250-499, 500-999)下,B1-B5的交互如何改变全局预测器状态。
  • 由于全局历史,误预测模式复杂。通过详细模拟可计算出总误预测数约为1875,误预测率约为0.375。

9.3 机器C(局部每分支2位饱和计数器)

每个分支有自己的预测器,初始为“弱不采取”。

  • B1:除最后一次外都采取,很快变为“强采取”,最后1次误预测。
  • B2:在“采取”与“不采取”间振荡,导致大量误预测(约1000次)。
  • B3:前250次采取,之后不采取。预测器从“强采取”逐渐变为“强不采取”,期间有误预测(约2次状态转换)。
  • B4和B5:模式类似B3,各有约2次状态转换误预测。
    总误预测数 ≈ 1 + 1000 + 2 + 2 + 2 = 1007。误预测率 ≈ 1007/5001 ≈ 0.201。

核心概念:分支预测器试图猜测分支方向。局部预测器跟踪单个分支的历史,全局预测器使用所有分支的历史。饱和计数器用于记录预测强度。


问题 10:缓存逆向工程

概述

通过设计内存访问序列并观察命中率,推断未知缓存的参数:块大小、相联度、替换策略和总容量。

10.1 确定块大小

给定访问序列1:0, 16, 24, 25, 1024, 255, 1100, 305。命中率观测为2/8。

  • 假设块大小为16字节:地址0和16属于不同块,24和25与16在同一块(命中),其他地址均首次访问(未命中)。命中数=2,符合。
  • 块大小为8字节:只有25命中(1次),不符合。
  • 块大小为32字节或更大:命中数会更多(>=3),不符合。
    因此,块大小为16字节。

10.2 确定相联度与替换策略

已知块大小16B。需测试相联度(2,4,8路)和容量(4KB,8KB)以及替换策略(LRU, FIFO)。

  • 使用序列1,2,3的观测命中率进行检验。
  • 通过模拟发现:
    • 8路不符合任何容量/策略组合下的观测命中率。
    • 4路在特定组合下可能符合序列1和2,但不符合序列3的命中率。
    • 2路相联,配合FIFO替换策略,且缓存容量为4KB时,能完全符合三个序列的观测命中率(2/8, 3/8, 2/3)。
      因此,相联度为2路,替换策略为FIFO,缓存大小为4KB。

10.3 确定缓存大小(4KB vs 8KB)

在已知其他参数(2路,16B块,FIFO)后,需设计两个额外的访问地址来区分4KB和8KB缓存。

  • 思路:选择一个在4KB缓存中会映射到Set 0并导致冲突替换,但在8KB缓存中会映射到其他组的地址。
  • 例如,选择地址 2048。在4KB缓存中,它映射到Set 0,根据FIFO会替换掉Set 0中的一个旧块。在8KB缓存中,它可能映射到Set 1。
  • 紧接着访问被替换块的地址(如地址 0)。
  • 在4KB缓存中,对 0 的访问将未命中(因为它被替换了)。在8KB缓存中,对 0 的访问可能命中(如果它仍在缓存中)。
    通过观察这两个访问的命中/未命中情况,即可确定缓存大小。

核心概念:缓存参数(大小、相联度、块大小、替换策略)决定了访问模式下的命中率。通过精心设计的访问序列可以逆向推导这些参数。


问题 11:前瞻执行

概述

分析一个存在bug的前瞻执行处理器:在前瞻模式下,每隔一条指令就被丢弃。判断其对程序执行的影响。

问题分析

  1. Buggy处理器能正确且更快地完成程序吗? 可能。即使丢弃一半指令,如果丢弃的不是关键路径上的指令,或者错误地提前发现了更多可并行的内存操作,buggy处理器可能偶然地比正确的前瞻处理器更快找到提交点。
  2. Buggy处理器能正确但更慢地完成程序吗? 可能。如果丢弃的指令包含很多本可用于发现依赖内存操作的机会,那么buggy处理器的前瞻效率会降低,导致更慢完成。
  3. Buggy处理器会导致不正确执行吗? 不会。前瞻执行的关键原则是,在前瞻模式下执行的所有操作都是试探性的,在确认正确之前不会提交(写回架构状态)。因此,即使硬件有bug,只要提交阶段逻辑正确,程序的最终结果仍然是正确的。

核心概念:前瞻执行是一种推测性优化,其操作结果在确认正确前不会永久化(不提交)。这提供了容错性。


问题 12:脉动阵列

概述

将卷积操作映射到4x4的脉动阵列上。卷积在输入矩阵I与四个不同的权重矩阵(A, B, C, D)之间进行。

操作映射

脉动阵列的每个处理单元执行乘累加操作。数据(输入I和权重)从顶部和左侧流入,结果在内部累加,并沿对角线方向传播。

  • 权重:可以将四个不同的权重矩阵(A, B, C, D)的值预先加载到阵列的不同行或列,或者在时间上多路复用。
  • 输入:输入矩阵I的元素以滑动窗口的方式从顶部流入阵列。
  • 计算:每个PE计算其接收到的I元素和权重元素的乘积,并累加到部分和中。经过多个时钟周期,完整的卷积结果将从阵列底部或右侧输出。

具体步骤

  1. 在第一个周期,将 I[0][0]A[0][0] 送入左上角的PE,计算乘积。
  2. 下一个周期,I[0][0] 向下传播,A[0][0] 向右传播。同时,新的元素 I[0][1]A[0][1](或 I[1][0])被送入相应PE。
  3. 通过精心调度输入和权重数据的流入节奏,可以在阵列中同时计算多个卷积窗口。最终,每个PE会输出卷积结果矩阵中的一个元素。

核心概念:脉动阵列是一种规则的处理单元网络,数据在单元间以流水线方式同步流动,非常适合计算密集型、规则的操作如卷积。数据复用和流水线是高效的关键。


总结

本节课我们一起系统地解决了去年考试中涵盖数字设计和计算机架构广泛主题的12个问题。我们从基本的布尔逻辑和Verilog开始,逐步深入到流水线冒险、动态调度(Tomasulo)、GPU并行计算、分支预测、缓存体系结构以及前瞻执行等高级概念。希望这次练习能帮助你巩固理解,并为应对考试做好准备。

30:问题解决 V (Spring 2025)

概述

在本节课中,我们将一起学习如何解决数字设计和计算机架构课程中的典型问题。我们将涵盖布尔电路最小化、Verilog代码分析、有限状态机设计、ISA与微架构概念、性能评估、流水线逆向工程、GPU SIMD利用率、缓存逆向工程、分支预测以及VLIW调度等多个核心主题。课程内容由浅入深,旨在帮助初学者掌握关键概念和解题技巧。


布尔电路最小化 🧮

上一节我们介绍了课程的整体结构,本节中我们来看看如何对布尔电路进行最小化。这是考试中常见的简单题型。

问题一:仅使用与非门(NAND)重写表达式

我们需要将给定的布尔表达式重写为仅使用与非门(NAND)操作的形式。一个有效的策略是使用双重否定(not not)和德摩根定律。

原始表达式F = (A B) + (C' D')

化简步骤

  1. 应用德摩根定律:F = ( (A B)' (C' D')' )'
  2. 注意 (C' D')' = C + D,但我们的目标是NAND。实际上,我们可以直接写成:F = ( (A B)' (C D) )',因为 (C' D')' = (C D)(再次应用德摩根)。
  3. 最终,整个表达式是一个NAND操作,其输入是 (A B) 的NAND和 (C D) 的NAND。

核心公式
F = NAND( NAND(A, B), NAND(C, D) )

问题二:使用布尔等式化简函数

给定一个由最小项之和表示的函数,我们需要通过提取公因式来化简它。

原始函数
F = A'B'C'D' + A'B C'D' + A'B C'D + A B'C'D + A B C'D

化简步骤

  1. 找出公因子。前两项有公因子 A' C' D',提取后得到 A' C' D' (B' + B) = A' C' D'
  2. 类似地,可以分组其他项。最终化简结果为:F = C' D' + B C' D
  3. 可以进一步提取 C'F = C' (D' + B D) = C' (D' + B)

核心公式
F = C' * (B + D')


Verilog代码分析 💻

上一节我们处理了组合逻辑的化简,本节中我们来看看如何分析Verilog代码,判断其生成的电路类型和行为。

问题一:组合电路还是时序电路?

分析以下代码,判断其生成的是组合电路还是时序电路。

module example(input [3:0] data, output reg [6:0] segments);
always @(*) begin
    case(data)
        4'd0: segments = 7'b1111110;
        // ... 其他5个case ...
        default: // 没有赋值
    endcase
end
endmodule

答案:时序电路。
解释segments 信号在 always 块中被赋值,但 case 语句并未覆盖所有可能的 data 值(4位有16种,只列出了5种)。对于未覆盖的情况,segments 需要保持之前的值,这会导致综合工具生成锁存器,因此是时序电路。

问题二:实现三分频电路

以下代码旨在实现一个输出信号 Q,使其在每个时钟周期中,只有一个周期为高电平,每三个周期重复一次。代码是否正确?如果不正确,请以最小改动修复。

module divide_by_three(input clock, reset, output reg Q);
    reg [1:0] current_state, next_state;
    parameter S0=2'b00, S1=2'b01, S2=2'b10;
    always @(*) begin // 组合逻辑部分
        case(current_state)
            S0: next_state = S1;
            S1: next_state = S2;
            S2: next_state = S0;
            default: next_state = S0;
        endcase
        Q = (current_state == S0);
    end
    // 缺少将 next_state 锁存到 current_state 的时序逻辑
endmodule

问题:代码缺少状态寄存器。current_state 没有被时钟驱动更新。
修复:需要添加一个时序 always 块,在时钟边沿将 next_state 赋值给 current_state,并处理复位。

always @(posedge clock or posedge reset) begin
    if (reset)
        current_state <= S0;
    else
        current_state <= next_state;
end

修正后行为:复位后 current_state=S0, Q=1。下一个时钟 current_state=S1, Q=0;再下一个 current_state=S2, Q=0;然后回到 S0, Q=1,如此循环。

问题三:非阻塞赋值与敏感列表

分析以下代码,在给定输入序列下,输出 out 和寄存器 temp 的值。

module test(input select, A, B, C, output reg out);
    reg temp = 0;
    always @(select) begin
        if (select)
            temp <= A & B;
        else
            out <= temp ^ C;
    end
endmodule

初始化:所有输入和寄存器为0。
步骤1:select 从0变为1。
步骤2:select 保持为1,B 从0变为1。

分析

  • always 块的敏感列表只有 select,因此仅在 select 变化时执行。
  • 步骤1select 变为1,执行 if 块。temp 被非阻塞赋值 A & B = 0 & 0 = 0(新值在块结束后更新)。out 未被赋值,保持0。块结束后,temp 更新为0。所以步骤1后:temp=0, out=0
  • 步骤2B 变化,但 select 未变,always 块不执行。所有值保持不变:temp=0, out=0

问题四:语法与语义错误检查

判断以下代码语法是否正确,是否会导致所有信号有确定值。如有错误,请解释。

module bad_design(input [1:0] in1, in2, input op, output reg [1:0] Z, output reg S);
    wire TMP;
    always @(in1, in2) begin
        TMP = in1 & in2; // 错误1:对 wire 型变量在 always 块内赋值
    end
    assign Z = TMP & op; // 可能没问题,取决于TMP的驱动
    always @(in1, in2) begin
        TMP = in1 | in2; // 错误2:多个驱动源(多驱动)
    end
    assign S = Z[0] | Z[1]; // 错误3:对 reg 型变量使用 assign 语句
endmodule

错误列表

  1. 类型错误TMP 声明为 wire,但它在 always 过程块中被赋值。wire 应使用 assign 连续赋值,或在 always 块中赋值的变量应声明为 reg
  2. 多驱动错误:两个 always 块都对同一个信号 TMP 进行赋值,形成了多个驱动源,这在物理上是冲突的。
  3. 类型错误S 声明为 reg,但使用 assign 语句赋值。reg 型变量应在 alwaysinitial 块中赋值。

有限状态机设计 🗺️

上一节我们分析了代码中的常见错误,本节中我们转向硬件设计的核心——有限状态机。

问题一:设计摩尔型FSM检测序列“011”

设计一个摩尔型有限状态机,检测输入序列“011”。当检测到该序列时,输出 y 置为1。假设初始输入比特为0。

设计思路:状态代表最近的历史输入模式。

  • S0:当前序列以 ...0 结尾。输出 y=0
  • S1:当前序列以 ...01 结尾。输出 y=0
  • S2:当前序列以 ...011 结尾(检测到目标)。输出 y=1

状态转移

  • S0: 输入 x=0 -> 保持在 S0;输入 x=1 -> 转移到 S1
  • S1: 输入 x=0 -> 回到 S0(模式被破坏);输入 x=1 -> 转移到 S2(完成“011”)。
  • S2: 输入 x=0 -> 回到 S0(新序列开始);输入 x=1 -> 保持在 S2(连续‘1’不影响已检测到的序列结尾?需明确:通常检测到后,下一个比特开始新序列。若要求重叠检测,则 x=1 应转到 S1?)。根据摩尔机特性,输出只与状态有关,在 S2y=1

问题二:简化给定的米利型FSM

判断给定状态机是摩尔型还是米利型,并尝试用最少数量的状态简化它。

类型判断:输出标记在状态转移弧上(如 S2 -> S3 的弧上标有 1/0,表示输入为1时输出为0),这意味着输出取决于当前状态输入,因此是米利型FSM。

简化观察:状态 S0 没有进入的转移弧,可以从图中移除。观察剩余状态 S1, S2, S3

  • S2S3 在相同输入下的输出和下一状态是否相同?S2:输入0时到 S2 输出0,输入1时到 S3 输出0。S3:输入0时到 S2 输出1,输入1时到 S3 输出0。两者行为不同,不能合并。
  • 检查 S1S3S1:输入0到 S2 输出1,输入1到 S3 输出1。S3:输入0到 S2 输出1,输入1到 S3 输出0。在输入为1时输出不同,不能合并。
  • 实际上,该FSM的功能是检测输入的变化(边沿检测):当输入比特与前一个不同时输出1。状态 S2 代表上一个输入是0,S3 代表上一个输入是1。S1 是初始或复位状态。这已经是最简形式(3个状态)。

简化结果:原始图可简化为一个3状态米利机(移除未使用的 S0),其功能是边沿检测器。


ISA 与微架构手册 📚

上一节我们设计了状态机,本节中我们探讨处理器设计中指令集架构与微架构的界限。

假设有两本手册:《ISA手册》(100万法郎)和《微架构手册》(1000万法郎)。你只能买一本。对于以下每个问题,决定哪本手册更可能提供答案。

以下是各问题对应的手册选择及其简要解释:

  1. 整数乘法算法(ALU使用):微架构手册。算法实现细节对程序员透明。
  2. 程序计数器宽度:ISA手册。决定地址空间大小,对程序员可见。
  3. 分支预测错误惩罚:微架构手册。以周期数衡量,是微架构优化细节。
  4. 操作系统刷新TLB的能力:ISA手册。通常通过ISA定义的特定指令或寄存器触发。
  5. 乱序CPU中重排序缓冲区大小:微架构手册。内部实现细节,对ISA不可见。
  6. 超标量CPU的取指宽度:微架构手册。决定每周期取指数量,是微架构参数。
  7. SIMD指令支持:ISA手册。指令支持在ISA中定义。
  8. 内存映射设备地址:ISA手册。驱动程序开发需要知道这些地址。
  9. CPU中不可编程寄存器的数量:微架构手册。内部使用的寄存器,ISA不描述。
  10. L1数据缓存的替换策略:微架构手册。缓存管理策略,对程序员透明。
  11. 内存控制器的调度算法:微架构手册。内部内存管理细节。
  12. 加载指令的目的寄存器所需位数:ISA手册。指令格式的一部分,在ISA中定义。
  13. 寄存器间乘除法支持描述:ISA手册。描述指令集是否包含这些操作。
  14. 发起系统调用的机制:ISA手册。例如,使用陷阱指令,这在ISA中定义。
  15. 可寻址内存大小:ISA手册。定义系统的地址空间范围。

性能评估 ⚡

上一节我们区分了ISA和微架构的概念,本节中我们学习如何定量评估处理器的性能。

问题:计算CPI和比较性能

处理器P1的指令延迟:加载=10周期,存储=8周期,算术=4周期,分支=4周期。
应用程序A的指令混合比:加载20%,存储20%,算术50%,分支10%。

A) P1运行程序A的CPI
CPI = (10 * 0.2) + (8 * 0.2) + (4 * 0.5) + (4 * 0.1) = 2 + 1.6 + 2 + 0.4 = 6.0

B) 处理器P2,时钟频率是P1的两倍,但指令延迟增加:加载+2,存储+2,算术+2,分支+1。使用相同编译器,P2的CPI是多少?
指令混合比不变。
CPI_P2 = (12 * 0.2) + (10 * 0.2) + (6 * 0.5) + (5 * 0.1) = 2.4 + 2.0 + 3.0 + 0.5 = 7.9

C) 哪个处理器更快?快多少?
执行时间公式:Time = (指令数) * CPI * (时钟周期) = (指令数) * CPI / (时钟频率)
设P1的时钟频率为 f,指令数为 N
Time_P1 = N * 6 / f
Time_P2 = N * 7.9 / (2f) = N * 3.95 / f
比较:Time_P2 / Time_P1 = (3.95 / f) / (6 / f) = 3.95 / 6 ≈ 0.658
因此,P2比P1快,速度是P1的 1 / 0.658 ≈ 1.52 倍。

D) 优化选择:更快的分支单元还是更快的内存设备?
使用阿姆达尔定律计算加速比。

  • 分支单元:将分支指令延迟减少为原来的1/4。分支指令占比10%。
    Speedup_branch = 1 / ((1 - 0.1) + (0.1 / 4)) = 1 / (0.9 + 0.025) = 1 / 0.925 ≈ 1.08
  • 内存设备:将加载/存储操作延迟减少为原来的1/2。加载和存储共占比40%。
    Speedup_mem = 1 / ((1 - 0.4) + (0.4 / 2)) = 1 / (0.6 + 0.2) = 1 / 0.8 = 1.25
    选择加速比更高的更快内存设备

流水线逆向工程 🔧

上一节我们评估了整体性能,本节中我们深入流水线内部,通过执行轨迹逆向推断其结构。

给出一段汇编代码及其在某个流水线处理器上的执行周期表。代码是一个循环,对R1从1加到100。

A) 列出必要的数据前推路径
通过分析执行表中的停顿周期,可以推断出需要以下前推路径来避免更长的停顿:

  1. 从指令2(mov R2, #1)的执行阶段到指令3(add R1, R1, R2)的执行阶段,前推R2的值。
  2. 从指令3(add)的执行阶段到指令4(mul)的执行阶段,前推R1的值。
  3. 从指令5(sub)的执行阶段到指令6(jnz)的执行阶段,前推条件码结果。

B) 该机器使用硬件互锁还是软件互锁?
执行表中没有出现NOP指令,所有的停顿都由处理器自动插入,因此使用的是硬件互锁

C) 改为软件互锁,重写执行时间线
需要将硬件互锁导致的停顿周期替换为显式的NOP指令插入到代码中。根据原始执行表,在指令3前插入2个NOP,在指令5前插入1个NOP,在指令6前插入2个NOP。

D) 计算总周期数T
已知当前R1=98,且正在取指add R1, R1, #1指令(即第98次迭代的开始)。需要计算从程序开始到当前的总周期数。

  1. 计算第一次迭代的周期数(包含初始化):根据修改后的软件互锁时间线,假设为11个周期。
  2. 计算第2到第97次迭代的周期数:每次迭代可能包含7条指令(add, mul, sub, jnz及必要的NOP),假设为7个周期。
  3. 第98次迭代刚开始,尚未消耗周期。
    总周期数 T = 11 + 96 * 7 = 11 + 672 = 683 周期。

E) 计算动态指令数N
当前正在取指第98次迭代的第一条指令(add)。

  1. 初始化指令:2条 (mov)
  2. 已完成97次迭代,每次迭代4条有效指令 (add, mul, sub, jnz):97 * 4 = 388
  3. 当前迭代的 add 指令正在被取指,计入动态指令数。
    总动态指令数 N = 2 + 388 + 1 = 391


GPU SIMD 利用率 🎮

上一节我们剖析了流水线,本节中我们看看GPU中如何利用SIMD并行性。

一段在GPU上运行的循环代码,共4096次迭代(即4096个线程)。GPU warp大小为64线程,SIMD通道数为64(即一个warp执行一条指令只需1周期)。每个线程执行循环体内的一组指令。

代码概要

for(i=0; i<4096; i++) {
    if (B[i] < 8888)     // 指令1
        A[i] = C[i] + 5; // 指令2,3,4 (假设为3条算术指令)
    if (B[i] > 8888)     // 指令5
        A[i] = 20;       // 指令6
}

假设数组A, B, C已载入寄存器,忽略内存访问延迟。

A) 总warp数量
总warp数 = 线程总数 / warp大小 = 4096 / 64 = 64

B) 测得SIMD利用率为34/320,推断数组B的情况
利用率 = 实际执行的操作数 / (指令数 * warp数 * warp大小)。
分母 320 = 5 * 64。推测整个程序平均每个warp执行了5条指令。
只有执行了第一个 iftrue 分支(3条指令)和第二个 iffalse 分支(不执行指令6),总共执行指令1,2,3,4,5,共5条指令,才会出现这个情况。
这意味着在每个warp中,所有64个线程都执行了指令1和5(条件判断),但只有一部分线程(设为k个)执行了指令2,3,4。设每个warp中执行指令2,3,4的线程数为k。
总操作数 = 64*2 + k*3
利用率为 (128 + 3k) / (5*64*64) = 34/320
解方程得 k = 2
结论:在每个warp的64个连续B元素中,有2个小于8888,其余62个等于8888(因为若大于8888则会执行指令6,增加操作数)。

C) 达到100%利用率所需条件
100%利用率要求所有线程在同一时刻执行相同的指令,即无分支分歧。有三种情况:

  1. B的所有元素都小于8888:所有线程执行指令1,2,3,4,5。
  2. B的所有元素都大于8888:所有线程执行指令1,5,6。
  3. B的所有元素都等于8888:所有线程执行指令1,5(两个条件都不成立)。

D) 可能的最低利用率及对应条件
利用率最低发生在分支分歧最大时。让每个warp中,仅1个线程满足第一个条件,仅另1个线程满足第二个条件,其余62个线程两个条件都不满足(即等于8888)。
则每个warp执行的操作数:指令1和5(64线程),指令2,3,4(1线程),指令6(1线程)。
总操作数 = 64*2 + 1*3 + 1*1 = 132
总可能操作数 = 6条指令 * 64线程 = 384。
最低利用率 = 132 / 384 = 11/32


缓存逆向工程 🗃️

上一节我们计算了GPU的利用率,本节中我们通过访问序列来推断缓存参数。

给定一个缓存,已知:

  • 按字节寻址。
  • 可能块大小:8, 16, 32, 64, 128字节。
  • 可能关联度:1, 2, 4, 8路。
  • 可能容量:4KB, 8KB。
  • 替换策略:LRU或FIFO。

给定两个访问序列(地址列表),测量得到命中率分别为 Sequence1: 1/2, Sequence2: 3/8。缓存初始为空,两个序列连续执行。

A) 块大小
分析Sequence1的地址:0, 32, 73, 128, 196, 256, 8K, 16K。
若块大小小于64字节,相邻地址(如0和32)不在同一块,不会有命中。实测有4次命中,排除8,16,32字节。
若块大小为64字节,地址0和32在同一块(0-63),地址128和196在同一块(128-191),最多2次命中,与4次不符。
若块大小为128字节,地址0、32、73都在块0(0-127)中,访问32和73时会命中块0。地址128和196都在块1(128-255)中,访问196时会命中块1。此外,地址256是新区块。8K和16K是独立块。总共命中4次(访问32,73,196,以及?需要检查序列:访问0(冷启动未命中), 32(命中), 73(命中), 128(未命中), 196(命中), 256(未命中), 8K(未命中), 16K(未命中) -> 3次命中?与给定的1/2命中率(4次命中)不符。需要精确匹配。
实际上,应通过尝试不同块大小,计算每个序列的命中次数,看哪个与实测命中率匹配。经过计算(过程略),块大小为128字节时,Sequence1的命中模式能产生4次命中。

B) 关联度
首先确定Sequence1运行后缓存的内容(块地址):0, 128, 8192, 16384(假设4路,足够存放这些不同索引的块)。
接着运行Sequence2。分析Sequence2的地址在128字节块下的块地址,并计算在不同关联度下的命中次数。
通过模拟发现,只有4路组相联能使得Sequence2产生3次命中(给定的命中率)。1路和2路会导致更多或更少的命中,8路会导致更多命中。

C) 替换策略
在确定块大小128B、4路组相联后,模拟LRU和FIFO策略下Sequence2的命中情况。
模拟结果表明,LRU策略能产生3次命中,而FIFO策略会产生4次命中。因此替换策略是LRU。

D) 确定缓存大小(4KB or 8KB)
设计一个由三个地址组成的探测序列:[8K, X, Y]
目标是通过检查访问Y是命中还是未命中,来判断缓存大小。
原理:缓存大小影响索引位数。设块大小128B,则块内偏移7位。

  • 对于4KB缓存:有 4KB / 128B = 32 个块。需要5位索引(2^5=32)。索引位对应地址位 [7:11]
  • 对于8KB缓存:有64个块。需要6位索引(2^6=64)。索引位对应地址位 [7:12]
    设计X和Y,使其与8K地址映射到同一个缓存组(即索引相同),但标记不同。在LRU策略下,如果缓存是4KB,访问X可能会驱逐8K所在的缓存行,导致访问Y未命中;如果是8KB,则可能不会驱逐,访问Y命中。
    例如,设 X = 8K + 128 * n(n为奇数,使其标记位变化),Y = 8K。具体值需要计算以确保在4KB和8KB下索引行为不同。

分支预测 🤔

上一节我们逆向工程了缓存,本节中我们分析分支预测器对性能的影响。

A) 确定分支解析阶段
一个15级流水线处理器,仅因条件分支停顿。一个包含20万条动态指令的程序执行了4514个周期,其中500条是条件分支。
设每条分支指令导致 B 个气泡(停顿周期)。
总周期数公式:总周期 = 15 + (指令数 - 1) + 500 * B
代入:4514 = 15 + 19999 + 500B => 500B = 2500 => B = 5
分支在流水线中解析后,后续指令才能继续,因此停顿周期数等于分支指令之后直到流水线末尾的级数。B=5 意味着分支结果在第10级产生(因为15级流水线,分支后还有5级需要冲刷/停顿)。

B) 分析循环代码
给定一个小型循环代码,在新处理器上运行了136个周期。新处理器使用未知分支预测器,但分支解析阶段同A(第10级)。

  1. 总动态指令数:通过分析循环结构(两层嵌套循环,各循环5次),统计所有指令执行次数,结果为98条。
  2. 条件分支指令数:统计所有条件分支(beq)的执行次数,结果为36条。

C) 推断分支预测器类型
首先,计算总周期数公式:136 = 15 + (98 - 1) + 4 * M,其中 M 是分支错误预测次数,4 是错误预测惩罚(解析阶段在第10级,导致后续4条指令被取指并需要冲刷?实际上,惩罚周期数 B=5,但公式中用的是4?需要根据前文推导的B=5调整)。根据给定公式计算,解得 M=6。即该预测器在此程序上发生了6次错误预测

现在判断哪种预测器配置能产生恰好6次错误预测。

  • 静态预测器
    • “总不采纳”:对于该程序模式(分支多数不采纳,最后采纳),恰好产生6次错误预测(每个循环的最后一次采纳分支预测错误)。可能
    • “总采纳”:会产生大量错误预测。不可能
  • 上次结果预测器:无论局部还是全局,初始方向如何,由于循环模式是NNNNNT,上次结果预测器在每次循环的T之后,下一次遇到N时会错误预测为T,导致错误预测次数大于6。不可能
  • 向后采纳向前不采纳:该策略对向后跳转(循环分支)预测采纳,对向前跳转(条件分支)预测不采纳。在该程序中,向前分支(beq)多数不采纳,最后采纳,会产生错误预测;向后分支(循环尾部的无条件跳转?这里没有典型的向后条件分支)不适用。分析具体模式会导致错误预测数远大于6。不可能
  • 2位饱和计数器预测器:需要模拟。从“强不采纳”状态开始,对于NNNNNT的模式,在最后一次T之前,预测一直是不采纳(正确),遇到T时错误预测,计数器变为“弱不采纳”。下一次循环开始,第一个N预测为不采纳(正确),计数器可能回到“强不采纳”。这样,每个内层循环和外层循环的最后一次T都会导致错误预测。总共恰好6次错误预测。可能(需特定初始状态)。

因此,可能的预测器是:静态“总不采纳”,或初始状态为“强不采纳”或“弱不采纳”的2位饱和计数器预测器


VLIW 调度 🚀

最后一节,我们学习超长指令字架构下的指令调度。

一个VLIW CPU,每个长指令包含4个短指令槽,分别只能用于:内存指令、整数指令、控制指令、浮点指令。各功能单元完全流水化,延迟固定。

A) VLIW设计目标
选择题:以下哪些是VLIW的设计目标?

  1. 简化代码编译?,实际上编译器负担更重。
  2. 简化应用开发?
  3. 降低整体硬件复杂度?
  4. 简化硬件指令间依赖检查?(依赖检查交给编译器)。
  5. 降低处理器取指宽度?,VLIW取指宽度通常更宽。
    正确答案:3和4

B) 手动优化调度
给出一段短指令序列(12条指令),需要将其调度到VLIW指令表中,以最小化执行周期。
调度过程需考虑指令依赖和功能单元延迟。例如:

  • 加载指令(延迟3周期)可背靠背发射(流水化)。
  • 整数加法(延迟2周期)需等待其操作数就绪。
  • 浮点加法(延迟4周期)延迟较长。
    通过仔细调度,可以将12条指令填入较少的VLIW指令中。最终优化的调度表显示,总共需要13个周期

C) 计算槽利用率
槽利用率 = 已使用的指令槽总数 / (总周期数 * 每周期槽数)。
从调度表中统计,已使用的槽数为12个。
总周期为13,每周期4槽。
利用率 = 12 / (13 * 4) = 12 / 52 ≈ 0.23123.1%


总结

本节课中我们一起学习了数字设计和计算机架构中多个核心问题的解决方法。我们从布尔逻辑化简开始,逐步深入到Verilog代码分析、有限状态机设计、ISA/微架构区分、性能评估(CPI、阿姆达尔定律)、流水线逆向工程、GPU SIMD利用率计算、缓存参数推断、分支预测器分析,最后是VLIW指令调度。掌握这些问题的解决思路和技巧,对于理解和设计计算机系统至关重要。希望本教程能帮助你巩固这些知识。

31:问题解决 VI (Spring 2025) 🧠

在本节课中,我们将一起解决去年考试中的一系列问题,涵盖布尔代数、有限状态机、微架构概念、Verilog代码分析、内存系统、性能评估、Tomasulo算法、GPU计算、分支预测、预取器和缓存逆向工程等多个核心主题。我们将逐一拆解每个问题,确保初学者也能理解其背后的原理和解题步骤。


布尔代数与逻辑简化 🔢

首先,我们来看第一个关于布尔代数和真值表的问题。题目要求我们根据描述完成一个真值表,然后对输出表达式进行简化。

真值表构建

问题描述了一个4位输入的系统,有两个输出:factoriald4

  • factorial输出:仅当输入数字是所有小于等于该输入的正整数的乘积(即阶乘数)时为1。根据定义,只有输入0、1、2满足条件。
  • d4输出:仅当输入的4位数能被4整除时为1。即输入为0、4、8、12时输出为1。

基于此,我们可以构建出真值表。

表达式简化

接下来,我们需要根据真值表写出d4输出的积之和表达式,并进行简化。

  1. 写出积之和表达式:找出所有使d4输出为1的输入组合,每一项对应一个最小项。
    d4 = A'B'C'D' + A'B'C'D + A'BC'D' + ABC'D'

  2. 应用布尔代数简化:合并具有最多公共项的乘积项。

    • 合并前两项:A'B'C'D' + A'B'C'D = A'B'C'(D' + D) = A'B'C'
    • 合并后两项:A'BC'D' + ABC'D' = C'D'(A'B + AB) = C'D'B (因为 A'B + AB = B)
    • 因此,d4 = A'B'C' + BC'D'
    • 进一步观察,A'B'C' = C'(A'B'),而BC'D' = C'(BD')。可以提取公因子C',但A'B'BD'无法直接合并为1。检查卡诺图或继续使用代数法:
      d4 = C'(A'B' + BD')。注意B可以再次提取:d4 = C'[B(A' + D') + A'B']?更简单的方法是直接观察原始四项,或者使用卡诺图,可以得到最简形式:d4 = C'D'。因为从真值表可知,只要C=0D=0(即输入的低两位为00),d4就为1,这与能被4整除的条件一致(二进制低两位为00)。

核心公式
d4 = C'D'

仅用或非门实现

最后,问题要求仅使用或非门来实现factorial输出。我们首先简化factorial表达式。

  1. 简化factorial表达式
    factorial = A'B'C'D' + A'B'C'D + A'B'CD'
    合并前两项:A'B'C'(D' + D) = A'B'C'
    所以 factorial = A'B'C' + A'B'CD' = A'B'(C' + CD')
    根据布尔代数定理 X' + XY = X' + Y,这里 C' + CD' = C' + D'
    因此,factorial = A'B'(C' + D')

  2. 转换为仅用或非门
    利用双重否定和德摩根定律将表达式转换为或非形式。
    F = A'B'(C' + D')
    双重否定:F = [ (A'B'(C' + D'))' ]'
    应用德摩根定律到内层:F = [ (A'B')' + (C' + D')' ]'
    再次应用德摩根定律:F = [ (A + B) + (C D) ]'
    现在,整个表达式是一个大的或非门:F = NOR( (A+B), (C D) )
    但是(A+B)(C D)还不是基本输入。我们需要用或非门来构建它们:

    • (A+B) = (A' B')' = NOR(A', B')。而A' = NOR(A, A)B' = NOR(B, B)
    • (C D) = (C' + D')' = NOR(C', D')。而C' = NOR(C, C)D' = NOR(D, D)

    因此,最终的电路由多个或非门构成,其核心思想是利用NOR(X, X)来获得X',并组合实现所需的与、或逻辑。

本节总结:我们学习了如何根据功能描述构建真值表,使用布尔代数或卡诺图简化逻辑表达式,以及如何利用双重否定和德摩根定律将任意逻辑电路转换为仅由或非门(或与非门)构成的形式。


有限状态机设计 🚂

上一节我们处理了组合逻辑问题,本节中我们来看看时序逻辑,具体是一个有限状态机的设计问题。题目要求为一个双门摆渡车系统设计控制器。

摩尔型FSM

问题首先要求绘制一个摩尔型FSM。摩尔型FSM的输出仅与当前状态有关。

  1. 确定状态:根据问题描述,状态已经给出:Idle0, Idle1, Transit, Unload2, Unload1, Emergency。状态中的数字通常代表车内的乘客数量。
  2. 确定输出:门A和门B的控制信号。根据描述:
    • Idle状态:门A打开(A=1),门B关闭(B=0)。
    • Transit状态:两门都关闭(A=0, B=0)。
    • Unload状态:门A关闭(A=0),门B打开(B=1)。
    • Emergency状态:两门都打开(A=1, B=1)。
  3. 确定状态转移:根据输入信号(乘客进入、离开、到站)进行转移。
    • 输入格式:[enter, leave]10表示进入,01表示离开,11表示到站,00表示无事发生。
    • 关键规则:乘客数达到2时从Idle进入Transit;在Transit中收到11(到站)进入Unload2;在Unload状态,乘客离开(01)减少乘客数;乘客数超过2(例如在Transit中有人进入)则进入EmergencyEmergency为吸收态。
  4. 绘制状态图:需要画出所有状态节点,标明每个状态的输出(A,B),并根据规则用带输入标签的箭头连接状态。未明确指出的转移可视为自循环。

米利型FSM

第二部分要求基于之前的FSM,设计一个米利型FSM来控制铃铛。铃铛在门打开或关闭时响起,在紧急状态下常响。

  1. 理解差异:米利型FSM的输出与当前状态当前输入有关,体现在转移边上。
  2. 复用状态逻辑:可以保留完全相同的状态和转移边。
  3. 确定边上的输出:遍历每条转移边,判断该转移是否引起门状态变化(对比源状态和目标状态的输出)。如果有变化,则该边输出Bell=1。此外,在Emergency状态下,所有转移边(包括自循环)输出都应为Bell=1

本节总结:我们实践了摩尔型和米利型有限状态机的设计,理解了它们输出方式的不同(状态 vs. 转移边),并学会了如何根据自然语言规范来定义状态、输出和转移条件。


指令集架构与微架构 🏗️

现在,我们来区分计算机系统中的指令集架构和微架构概念。

以下是问题列表及判断:

  1. 两级全局分支预测器:微架构。这是实现细节,对程序员不可见。
  2. add指令中目标寄存器标识位的位置:ISA。指令格式是ISA的一部分。
  3. 每个周期取指的指令数:微架构。涉及流水线宽度等实现。
  4. 浮点与整数通用寄存器的数量比例:ISA。寄存器数量是编程模型的一部分,比例可由此算出。
  5. 整数算术逻辑单元的数量:微架构。执行资源的具体数量。
  6. 处理器的指令发射宽度:微架构。流水线微架构特性。
  7. cmov指令的支持:ISA。这是一条具体的指令。
  8. L3缓存替换策略:微架构。通常对软件透明。
  9. 到内存的数据总线宽度:微架构。硬件互连细节。
  10. 程序可寻址内存的大小:ISA。由地址总线位数等决定,影响编程。
  11. 执行一条add指令所需的周期数:微架构。取决于具体实现和流水线。
  12. 通过操作系统内核选择特定缓存替换策略的能力:ISA。因为这提供了一个可编程的接口。
  13. 物理寄存器文件的读端口数量:微架构。寄存器重命名实现细节。
  14. 程序员可编程预取器配置寄存器中每个位的功能:ISA。因为是可编程的。
  15. L3缓存体的数量:微架构。硬件组织结构。

本节总结:我们明确了指令集架构是软件与硬件之间的契约,定义了编程模型;而微架构是硬件实现的具体方式,旨在高效地执行ISA定义的指令。


Verilog代码分析 ⚙️

本节我们将分析一段Verilog代码,理解其行为并完成相关题目。

代码行为分析

给定一个模块,有时钟clk、使能enable、输入in1in2,以及输出out。内部有一个寄存器变量var1
in1是16位十六进制常数0x648cin2是8位常数8'b10011010
我们需要根据波形图(一系列时钟沿)确定out信号的值。

解题步骤

  1. 理解代码逻辑
    • always @(posedge clk) 块在时钟上升沿执行。
    • 如果enable为0,out保持不变。
    • 如果enable为1,且var1等于某个值,则检查in2的某一位(由var1索引)。如果该位为1,则out加上in1的一个8位片段;如果为0,则out减去该片段。
    • in1[var1*8 +: 8] 是位选择语法,表示从索引var1*8开始,选择宽度为8位的向量。
    • 每个周期,var1递增。
  2. 逐步仿真
    • 从初始状态(out=0, var1=0)开始,根据每个时钟沿的enablein2的对应位,计算out的新值。
    • 注意var1是3位,当从7递增到8时会回绕到0。
    • 按照波形图依次计算即可得到out在每个时钟沿后的值。

语法填空

第二部分是一个Verilog模块的代码填空,需要根据上下文选择正确的语法。

解题技巧

  1. 根据用法推断类型:例如,一个信号在always块中被赋值,它应该是reg型;如果在assign语句中被赋值,它应该是wire型。
  2. 理解常数的表示2'b11表示2位二进制数3,'d3表示十进制数3。需要注意位宽匹配。
  3. 识别归约运算符&data表示对data的所有位进行与操作,结果是一位布尔值。|data表示或归约。
  4. 区分按位运算符和逻辑运算符!是逻辑非,~是按位取反。|是按位或,||是逻辑或。

通过仔细阅读代码上下文和每个选项的含义,可以选出正确的填空内容。

本节总结:我们练习了阅读和分析Verilog时序逻辑代码的能力,并通过仿真理解了其行为。同时,我们也复习了Verilog的基本语法元素,包括数据类型、常数表示和运算符。


内存系统判断题 🧠

这一节我们通过判断题来回顾内存系统的关键概念。

以下是问题列表及判断:

  1. 主存访问通常比寄存器文件访问消耗更少能量错误。寄存器文件更小、更靠近处理器,访问能耗低得多。
  2. 通过增加字线和位线长度来构建更大的内存阵列会增加成本,但不会增加DRAM访问时间错误。更长的导线会增加RC延迟,从而增加访问时间。
  3. 激活DRAM单元会暂时破坏其中存储的值正确。激活时电荷共享,之后通过读出放大器恢复。
  4. DRAM每比特成本远高于SRAM错误。DRAM结构更简单,密度更高,每比特成本更低。
  5. 典型计算机系统的内存层次结构包含不同的内存技术正确。例如,缓存用SRAM,主存用DRAM,存储用Flash/SSD。
  6. 最近访问的数据应保存在内存层次结构的底层错误。应保存在顶层(如缓存)以实现快速访问。
  7. 无分支的程序在其指令内存引用中具有很高的时间局部性错误。顺序执行且无分支的指令流只会访问新的指令地址,不会重复访问旧地址,因此没有时间局部性。
  8. 块大小等于内存访问指令字大小的缓存无法利用空间局部性正确。因为每次只取回一个字,相邻字不在同一个缓存块中。
  9. 内存体技术允许并发访问内存结构正确。可以同时激活不同体中的行,重叠操作。
  10. 在DRAM中,访问同一体中的不同行比访问同一行更快错误。访问同一行是行命中,直接从行缓冲器读取,速度很快。访问不同行需要先预充电,再激活新行,延迟高。
  11. PCM是非易失性的正确。相变存储器断电后能保持数据。
  12. 如果一个假设系统不受芯片面积、内存成本、能耗限制,DRAM将是该系统的最佳内存技术错误。此时应追求性能,SRAM更快,或使用其他更快的技术。
  13. 整个页表通常存储在物理内存中错误。多级页表允许部分页表项仅在需要时才驻留内存。
  14. 虚拟到物理地址转换位于内存访问的关键路径上正确。必须先完成地址转换,才能访问物理内存。
  15. 虚拟内存使程序员和架构师的任务都更容易错误。虚拟内存简化了程序员的内存管理,但增加了架构师设计地址翻译硬件(如TLB)的复杂性。

本节总结:我们回顾了内存层次结构中关于不同内存技术特性、缓存原理、DRAM操作和虚拟内存的重要概念,澄清了一些常见的误解。


系统性能评估 📊

本节我们学习如何计算和解释计算机系统的性能指标。

我们评估两个系统:基线系统和一个名为AwesomeMem的优化系统。我们运行了单线程和双核多程序测试,并收集了数据。

计算IPC

首先,计算每个应用在基线系统上单独运行时的IPC。
公式IPC = 执行指令数 / 执行周期数
根据表格中的数据直接计算即可。

计算共享IPC

接着,计算应用在混合负载下并发执行时的IPC(IPC_shared)。
方法相同:IPC_shared = 应用在混合负载下的指令数 / 应用在混合负载下的周期数
需要从表格的“多程序”部分查找对应应用在特定配置和混合负载下的数据。

计算加权加速比

然后,计算每个混合负载的加权加速比。该指标衡量系统吞吐量。
公式Weighted Speedup = Σ (IPC_shared_i / IPC_alone_i),其中i遍历混合负载中的所有应用。
关键点:必须先分别计算每个应用的IPC_shared / IPC_alone比值,然后再求和。不能先对IPC求和再计算比值。

推断优化技术

最后,基于性能数据表格,推断AwesomeMem系统可能采用了哪种优化技术。

  • 技术A:增加L1缓存容量:查看L1缓存失效率,如果AwesomeMem的失效率显著低于基线,则可能。
  • 技术B:内存请求重映射以减少DRAM体冲突:查看DRAM体冲突次数,如果AwesomeMem的冲突次数减少,则可能。
  • 技术C:采用完美分支预测器:查看分支误预测率,如果AwesomeMem的误预测率没有降至0%,则不可能(完美预测器误预测率为0%)。
  • 技术D:采用高效硬件预取器:查看L1缓存失效率,如果失效率降低,则可能。

通过对比表格中AwesomeMem和基线在各项指标上的差异,可以判断哪些技术是可能的,哪一项不可能。

本节总结:我们掌握了IPC、加权加速比等关键性能指标的计算方法,并学会了如何通过分析性能计数器数据来推断系统所采用的微架构优化技术。


Tomasulo算法与数据流图 🌀

现在,我们探讨一个关于Tomasulo算法和乱序执行的问题。题目给出了一个快照,包括保留站和寄存器别名表的状态,要求我们推断出正在执行的指令序列及其数据流图。

解题步骤

  1. 理解快照信息
    • 保留站中有四个条目(标签Z, X, Y, T),代表四个正在执行的操作。
    • 寄存器别名表显示了每个寄存器的状态:是就绪(有值)还是未就绪(依赖于某个标签)。
  2. 从简单依赖开始
    • 找出目的寄存器已知且源操作数都已就绪的指令。例如,标签Z对应目的寄存器R8,且它的两个源操作数值(28和1)都已就绪(对应R1和R2)。因此,第一条可确定的指令是ADD R8, R1, R2
  3. 逐步回溯
    • 对于依赖其他标签的指令,需要找出产生该标签的指令。例如,寄存器R5依赖于标签X,且一个源操作数值为1(R2)。另一个源操作数是标签X对应的值?不,看保留站:标签X的操作是乘法,它的两个源:一个是寄存器值50,一个是寄存器值1(R2)。所以,产生标签X的指令是MUL R5, Rx, R2。但Rx的值50从哪里来?查看寄存器表,R3的值为50,且是就绪的。但初始时R3=3,所以必须有一条指令计算了R3 = ... = 50。通过观察,可能是ADD R3, R4, R7(因为4+46=50?需要看初始值)。根据初始寄存器值推断出正确的源寄存器。
    • 以此类推,分析标签Y和T的依赖关系。
  4. 考虑已完成的指令
    • 快照中只有4个在执行的指令,但题目说有5条指令。因此,最早的一条指令已经执行完毕并写回,其目的寄存器(例如R3)的值现在已就绪,并且被后续指令使用。
  5. 绘制数据流图
    • 将每条指令表示为一个节点,节点内标明操作(ADD/MUL)和目的寄存器。
    • 用有向边表示数据流,从源寄存器节点指向使用该寄存器作为源的指令节点。
  6. 确定程序顺序
    • 根据数据依赖关系,确定指令被取指、译码的顺序。虽然执行是乱序的,但程序顺序是固定的。依赖关系决定了顺序约束。

本节总结:我们深入理解了Tomasulo算法如何通过保留站和寄存器别名表实现乱序执行。通过分析硬件状态的快照,我们学会了如何逆向推导出正在执行的指令序列以及它们之间的数据依赖关系,这是调试和优化高性能处理器的关键技能。


GPU计算与SIMD利用率 🎮

本节我们研究GPU上线程的执行效率,特别是计算核心的利用率。

给定一个GPU内核代码,其循环次数为1024,每个线程处理一次迭代。warp大小为32线程。

计算Warp数量

总线程数 / warp大小 = 1024 / 32 = 32个warps。

计算条件块大小以达成特定利用率

问题给定了条件判断(i % 16 == 0),并问条件块内的指令数K为多少时,核心利用率是11/32。

  1. 分析warp执行
    • 在一个warp中,只有线程ID满足i % 16 == 0的线程(即ID为0和16的线程)会执行条件块内的K条指令和其后的3条指令。
    • 其他30个线程会跳过条件块,只执行其后的3条指令(条件判断指令、c=...指令等)。
  2. 建立利用率方程
    • 核心利用率 = 实际活跃的线程周期数 / 理想情况下的线程周期数。
    • 理想情况:所有32个线程都执行(K+3)条指令。
    • 实际情况:2个线程执行(K+3)条,30个线程执行3条。
    • 因此,方程如下:
      [2*(K+3) + 30*3] / [32*(K+3)] = 11/32
  3. 求解K
    • 交叉相乘并求解方程即可得到K的值。

更复杂的条件判断

当条件变为(i % 16 == 0) && (i < 512)时,需要更仔细地分析。

  1. 分析warp分组
    • 线程ID i >= 512 的warps(即后16个warps),所有线程都不满足条件(因为i < 512为假),因此所有线程都只执行3条指令,利用率理论上是100%执行这些指令,但计算整体利用率时需考虑。
    • 线程ID i < 512 的warps(即前16个warps),每个warp中仍有2个线程(ID 0和16)满足条件,执行(K+3)条指令;其余30个线程执行3条指令。
  2. 建立整体利用率方程
    • 总线程周期数(实际) = 前16warps * [2(K+3) + 303] + 后16warps * [32 * 3]
    • 总线程周期数(理想) = 32warps * [32 * (K+3)]?这里需要注意,理想情况是假设所有线程在所有warps中都执行(K+3)条指令。但题目中“理想情况”通常指所有线程都执行当前条件下可能的最大指令数?对于后16个warps,其条件决定了它们最多只能执行3条指令。因此,更合理的“理想”基线可能是:每个线程都执行其在该条件下实际需要执行的全部指令,但硬件资源被完全占满。然而,题目给定的利用率定义是“活跃线程的比例”,通常基于所有线程都执行完整指令流(包含条件块) 的理想情况来计算。所以理想周期数仍然是 32*32*(K+3)
    • 将实际周期数和理想周期数代入利用率公式,并令其等于给定值,即可求解K

本节总结:我们学习了如何分析GPU上包含条件分支的代码的执行模式,理解了warp内线程发散对核心利用率的影响,并掌握了通过建立和求解方程来计算或优化利用率的方法。


分支预测器分析 🔮

本节我们分析一个流水线处理器,并通过其执行周期数来推断流水线结构和分支预测行为。

确定流水线深度和分支停顿周期

给定一个简单的循环代码,对于不同的初始R1值,测量得到不同的执行周期数。

  1. 建立模型
    • 设流水线深度为D(阶段数)。
    • 设分支指令导致的停顿周期为P
    • 程序执行的动态指令数为I
    • 分支指令数为B
    • 总周期数公式:Cycles = D + (I - 1) + B * P。其中D是第一条指令充满流水线的时间,(I-1)是后续指令每周期完成一条的理想时间,B*P是所有分支造成的额外停顿。
  2. 代入数据
    • 对于R1=2R1=4,从题目中可以得到对应的IB和测量到的Cycles
    • 建立两个方程,求解未知数DP

评估不同的分支预测器

在已知新的分支预测器设计下,对于R1=4的输入,程序执行了77个周期(而原始无预测或静态预测下是83个周期)。

  1. 分析结果
    • 周期减少了6个。
    • 对于R1=4,循环执行4次,共有4个条件分支指令。
    • 周期减少6个,意味着避免了6/P个分支的停顿。如果P=10(从上一步求出),那么意味着有6/10个分支被正确预测?这不对,因为避免的停顿应该是P的整数倍。更合理的解释是:新的预测器正确预测了其中1个分支,从而避免了1次长度为P的停顿。因为83 - 77 = 6 = P?需要检查之前求出的P值。
    • 假设P=6,那么意味着新的预测器正确预测了1个分支(避免了6周期停顿),错误预测了3个分支(各停顿6周期)。
  2. 判断预测器可能性
    • 静态“总是不采纳”预测器:对于这个循环(前三次分支采纳,最后一次不采纳),该预测器会正确预测最后一次分支,错误预测前三次。符合“1正确,3错误”的模式。可能
    • 上一次结果预测器:根据上一次分支的结果来预测当前分支。需要模拟其行为。从第一次分支开始(假设初始状态未知或默认值),看是否能产生“1正确,3错误”的模式。通常很难刚好产生这个精确结果。不可能
    • 后向采纳、前向不采纳预测器:对于后向跳转(循环)预测为采纳,前向跳转预测为不采纳。这个循环是后向跳转,因此预测为采纳。这样会正确预测前三次采纳的分支,错误预测最后一次不采纳的分支。结果是“3正确,1错误”,不符合。不可能(除非初始状态或特定模式导致不同结果,但标准解释不符)。
    • 后向不采纳、前向采纳预测器:预测结果与上一个相反。对于这个循环,会预测为不采纳。这样会错误预测前三次,正确预测最后一次。符合“1正确,3错误”。可能

本节总结:我们学会了如何通过程序的执行时间反推处理器的微架构参数(如流水线深度、分支惩罚)。同时,我们也掌握了如何根据分支结果序列来评估不同分支预测策略的有效性。


预取器设计与评估 🚀

本节我们研究数据预取器,并计算其覆盖率和带宽开销。

给定一个固定的内存访问模式:A, A+1, A+9, A+10, A+17, A+18, ...,即 stride-1 和 stride-8 交替。

基于历史记录的预取器

一个预取器观察最近3次访问的地址,并尝试检测固定的步长。

  • 分析:给定的访问模式中,连续地址之间的步长在1和8之间交替变化,不是固定的。因此,该预取器无法检测到稳定步长,不会发出任何预取请求。
  • 覆盖率:预取请求数 / 未预取时的内存访问数 = 0。

邻接预取器

对于每次内存访问地址X,预取接下来的N个缓存行,即X+1, X+2, ..., X+N

  • 当N=2时
    • 覆盖率:模拟访问模式。访问A时,预取A+1(有用)和A+2(无用)。访问A+1时,预取A+2(重复,无用)和A+3(无用)。访问A+9时,预取A+10(有用)和A+11(无用)…… 统计所有有用的预取访问(即后续真正被访问的地址)占总访问地址(原始访问+预取访问)的比例。注意,如果预取命中,则避免了实际的内存访问,但计算覆盖率时,通常用“有用的预取数 / 总内存访问数(无预取)”来衡量。需要仔细计算。
    • 带宽开销(内存访问次数(有预取)) / (内存访问次数(无预取))。注意,重复的地址只算一次访问。

实现100%覆盖率的邻接预取器

要预取到所有将来访问的地址,需要设置足够大的N,使得一次访问的预取范围能覆盖到下一个“stride-8”跳跃的目标。

  • 例如,在访问A时,需要预取到A+9。因为A+9是下一个会被访问的地址(在A+1之后)。因此,N至少需要为9。
  • 带宽开销:当N=9时,每次内存访问都会产生9个预取请求。但很多预取地址是重复的。需要模拟计算总的唯一内存访问次数与原始访问次数的比值。

本节总结:我们评估了不同预取策略(基于步长检测、邻接预取)在特定访问模式下的效果,学会了计算覆盖率和带宽开销这两个关键指标,并理解了预取器 aggressiveness(如N的大小)与收益/开销之间的权衡。


缓存逆向工程 🕵️♂️

最后,我们通过一个有趣的缓存逆向工程问题来结束本节课。我们已知缓存总块数、块大小和替换策略(FIFO),但不知道其关联度。通过精心设计的内存访问序列和观察命中率,我们可以推断出关联度。

设计探测访问序列

初始访问序列:2, 9, 16, 25, 33
缓存有8个块,块大小4字节。所以地址映射到块的方式是:地址除以4(取整)得到块地址。初始序列对应的块地址是:0, 2, 4, 6, 8(因为33/4=8余1)。
我们需要设计接下来的3个地址访问,使得在不同关联度下,这3次访问的命中率不同,从而唯一确定关联度。

解题思路

  1. 分析初始序列后的缓存状态:对于不同的关联度(1-way, 2-way, 4-way, 8-way),由于FIFO替换,初始序列会留下不同的缓存内容。我们需要推导出每种关联度下的缓存内容。
  2. 寻找“特征”访问序列:我们希望找到3个地址,使得:
    • 在1-way下,产生某种命中率(例如2次命中)。
    • 在2-way下,产生另一种命中率(例如1次命中)。
    • 在4-way下,产生又一种命中率(例如0次命中)。
    • 在8-way下,产生第三种命中率(例如3次命中,因为所有地址都在缓存中)。
  3. 通过试错或推理确定地址:通常选择那些在某种关联度下肯定在缓存中,而在另一种关联度下肯定被替换出去的地址。例如,选择地址0, 8, 16(对应的块地址为0, 2, 4)。然后模拟在不同关联度下,访问这三个地址的命中/未命中情况。
  4. 验证唯一性:确保这组地址产生的命中率模式能唯一对应一种关联度。

根据命中率判断关联度

如果使用上面设计的地址{0, 8, 16}进行探测:

  • 在8-way中,所有地址都在缓存,命中率=3/3=100%。
  • 在4-way中,可能所有地址都不在(因为FIFO替换),命中率=0/3。
  • 在2-way中,可能只有1个地址在,命中率=1/3。
  • 在1-way中,可能只有2个地址在,命中率=2/3。
    这样,通过观察到的命中率就能反推关联度。

在已知关联度下判断缺失

如果探测访问得到了100%命中率,那么关联度一定是8-way(全相联)。在此基础上,再给出一组新的访问地址,我们可以根据8-way缓存当前的内容(由之前所有访问决定)来判断哪些访问会命中,哪些会缺失。

本节总结:我们学习了如何利用缓存的行为特性(如块大小、替换策略)来设计特定的内存访问模式,从而像侦探一样推断出缓存的隐藏参数(如关联度)。这是一种重要的性能分析和逆向工程技能。


本节课总结:在本节课中,我们一起学习了计算机架构中多个核心领域的综合问题解决方法。我们从布尔代数和FSM的基础开始,逐步深入到ISA/微架构区分、Verilog分析、内存系统、性能评估、乱序执行、GPU计算、分支预测、预取器和缓存逆向工程等高级主题。通过解决这些来自真实考试的问题,我们不仅巩固了理论知识,也锻炼了将理论应用于实际问题解决的能力。希望这份教程能帮助你更好地准备考试,并加深对计算机系统工作原理的理解。

27:问题解决 II (Spring 2025) 🧠

在本节课中,我们将一起解决来自2023年春季课程的一系列问题,涵盖指令集架构与微架构、流水线、内存特性、有限状态机、向量处理、布尔逻辑电路、性能评估、脉动阵列、超长指令字和缓存等多个核心概念。


指令集架构与微架构 🏗️

本节我们将区分指令集架构和微架构的概念。指令集架构定义了软件可见的接口,而微架构则是这些接口的具体硬件实现。

以下是15个陈述,请判断每个陈述描述的是ISA还是微架构:

  1. add指令中立即数的宽度:由ISA定义。✅ ISA
  2. ALU执行乘法所用的算法:是硬件实现细节。✅ 微架构
  3. store指令中索引源寄存器所需的位数:由ISA定义。✅ ISA
  4. ALU缓存中的条目数:是硬件设计选择。✅ 微架构
  5. 数据缓存的组织方式:是硬件设计选择。✅ 微架构
  6. 编译器通过预取提示向硬件提供支持:这涉及指令集对编译器的支持。✅ ISA
  7. 算术和逻辑运算可用的数据类型:由ISA定义。✅ ISA
  8. 多核处理器中的缓存一致性协议:是硬件实现细节。✅ 微架构
  9. 处理器与主内存之间的数据宽度:是硬件互连设计。✅ 微架构
  10. 内存控制器的内存请求调度算法:是硬件实现细节。✅ 微架构
  11. 数据、控制流和分支指令的指令编码:由ISA定义。✅ ISA
  12. 寄存器重命名逻辑的设计:是硬件设计选择。✅ 微架构
  13. 超标量处理器中每个周期解码的指令数:是微架构特性。✅ 微架构
  14. L2缓存缺失延迟:取决于硬件实现。✅ 微架构
  15. 程序计数器的宽度:由ISA定义。✅ ISA

流水线逆向工程 🔄

上一节我们介绍了ISA与微架构的区别,本节中我们来看看如何通过执行时间线来逆向工程处理器的流水线微架构。

我们有一段汇编代码及其在特定时钟周期下的流水线阶段执行时间线。目标是利用这些信息回答关于处理器架构的问题。

数据前推路径

首先,我们需要列出流水线阶段之间的数据前推路径。数据前推允许在指令结果写回寄存器文件之前,就将其值传递给后续依赖的指令。

以下是识别出的关键数据依赖和前推路径:

  • 从执行阶段3到执行阶段1:当指令需要前一条指令在E3阶段刚计算出的R1值时发生。
  • 从访存阶段到执行阶段1:当指令需要前一条指令在M阶段刚计算出的R1值时发生。
  • 从执行阶段3到条件寄存器:用于解决条件分支指令对前一条指令计算结果的依赖。

核心概念:数据前推路径的通用形式可描述为:Result_Stage_X -> Operand_Stage_Y,其中X是产生结果的阶段,Y是使用该结果的阶段。

硬件互锁与软件互锁

接下来,判断该机器使用的是硬件互锁还是软件互锁。观察时间线,我们没有看到因数据依赖而插入的“气泡”或停顿。这表明微架构自身能检测数据依赖并暂停相关流水线阶段。

结论:该机器使用硬件互锁

循环迭代与周期计算

现在,假设代码中x=4, y=2,且分支预测总是正确。在某个时钟周期T,处理器正在取指动态指令 mul r4, r1, r1,且此时R1的值为1024。我们需要计算T

  1. 理解代码:这是一个循环,R1初始为4,每次迭代乘以R2(值为2),直到R1达到2048。
  2. 计算迭代次数R1值序列:4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048。当R1=1024时,是第9次迭代的开始(从第0次迭代计数)。
  3. 结合时间线计算T:分析时间线,考虑初始化、第一次迭代(因流水线填充不同)和后续迭代的周期数。计算可得 T = 2 + 9 + 8*10 = 91 周期。
  4. 计算动态指令数nn是到该取指时刻为止执行的动态指令总数。考虑初始化指令和循环体内的指令数,可计算出 n = 2 + 8*6 + 1 = 51

总执行时间

最后,计算整个汇编代码执行完成所需的总时钟周期数。循环需要执行直到R1=2048,即总共10次迭代。

结合时间线模型,总执行时间 = 初始化周期 + 第一次迭代周期 + 剩余9次迭代周期。计算可得总周期数。

总结:本节我们通过分析流水线时间线,逆向推导了处理器的数据前推机制、互锁策略,并练习了在给定条件下计算特定周期状态和总执行时间的方法。


内存组织与技术 ✅❌

上一节我们处理了流水线问题,本节我们来探讨关于内存组织和技术的一些陈述判断。

请判断以下关于内存组织和技术的陈述是正确还是错误:

  1. 主存访问通常比寄存器文件访问具有更大的延迟:正确。主存远离核心且访问路径长。
  2. SRAM通常用作现代计算机的主存:错误。主存通常使用DRAM。
  3. DRAM单元比SRAM单元需要更大的面积来存储数据:错误。SRAM单元因使用更多晶体管,静态面积通常更大。
  4. DRAM中读操作比写操作快:错误。DRAM的读写速度通常相似。
  5. 相变存储器中读操作比写操作快:正确。读操作测量电阻,写操作需要改变相态,耗时更长。
  6. DRAM阵列中的位线将所有DRAM单元连接到行解码器电路:错误。位线连接同一列的所有单元到灵敏放大器。
  7. 使用虚拟内存会降低内存访问延迟:错误。虚拟内存的主要目的不是降低延迟,且地址转换可能增加延迟。
  8. 相变存储器是非易失性的:正确。断电后数据不丢失,无需刷新。
  9. 如果一个假设的服务系统不受芯片面积、内存成本和能耗限制,PCM将是该系统中最好的内存技术:错误。PCM存在写入耐久度限制。
  10. 具有流式内存访问模式的程序会在最后一级缓存中产生很高的局部性:错误。流式访问模式缺乏时间局部性。
  11. 访问同一存储体中的不同行比访问不同存储体中的不同行更快:错误。利用存储体并行性,访问不同存储体可能更快。
  12. TLB是一种专用的指令缓存:错误。TLB缓存的是地址转换条目。
  13. 虚拟内存简化了软件设计:正确。程序员无需直接管理物理地址。
  14. 当TLB不包含指令所需的条目时会发生页错误:错误。页错误发生在页表中没有有效转换条目时。
  15. 一个全相联L1 TLB,仅存储4KB虚拟到物理映射,有1024个条目,最多可覆盖4MB内存:正确。4KB * 1024 = 4MB

有限状态机 🔄

上一节我们讨论了内存特性,本节我们转向有限状态机的简化和设计。

FSM简化

给定一个具有四个状态(A, B, C, D)的摩尔型FSM状态图。我们的目标是简化状态数。

步骤

  1. 构建状态转移表,列出每个状态在不同输入下的次态和输出。
  2. 观察发现状态A、B、D具有完全相同的输入/输出行为(输出是输入的反码)。
  3. 因此,状态A、B、D是等价的,可以合并为一个新状态(例如X)。
  4. 简化后的FSM只有两个状态:C和X。状态X在输入0或1时都保持为X,并输出输入的反码。状态C在输入0或1时都转移到X。

简化结果:状态数从4个减少到2个。

FSM设计

现在,设计一个摩尔型FSM,检测输入信号中从“重复0”到“重复1”的稳定转换。具体来说,仅当观察到输入序列“00, 11”时,输出才为1。

设计过程

  1. 定义状态:我们需要状态来记忆序列。
    • S_reset:复位状态,假设输入已长时间为高(1)。
    • S_1:最近看到单个1。
    • S_0:最近看到单个0。
    • S_00:已看到两个连续的0。
    • S_001:已看到序列“00, 1”。
    • S_0011:已检测到目标序列“00, 11”,输出1。
  2. 状态转移
    • S_reset开始,根据输入转移到S_1S_0
    • S_1,输入1则保持,输入0则转到S_0
    • S_0,输入0则转到S_00,输入1则转到S_1
    • S_00,输入1则转到S_001,输入0则保持(仍为两个0)。
    • S_001,输入1则转到S_0011(输出1),输入0则转到S_0(序列中断)。
    • S_0011,无论输入如何,都转到S_1(重新开始检测)。
  3. 输出:仅在S_0011状态时输出为1,其他状态输出为0。

这个FSM以最少的必要状态(6个)实现了所需功能。

总结:本节我们学习了如何通过识别等价状态来简化FSM,并从头设计了一个满足特定序列检测要求的摩尔型有限状态机。


向量处理 ⚡

上一节我们设计了FSM,本节我们进入向量处理领域,解决关于存储体、汇编编程和性能计算的问题。

存储体数量与步长

一个向量处理器有N个存储体。向量加载指令VLD和存储指令VSD的延迟为:行缓冲区命中50周期,缺失100周期。所有存储体初始时行都是关闭的。连续地址的元素交错存放在不同存储体中。

问题:为避免停顿,执行VLDVSD指令所需的最少存储体数量是多少?答案取决于访问步长。

分析:关键是要隐藏长延迟(100周期)。为了在每个周期都能发出新的内存请求(无停顿),我们需要足够的存储体,使得在同一个存储体准备好接受新请求之前,可以去访问其他存储体。

  • 奇数步长:访问模式会依次遍历所有存储体。需要至少100个存储体,以便在第100个周期可以重新访问第一个存储体。
  • 偶数步长:访问模式可能跳过某些存储体。需要至少101个存储体,以确保在100周期延迟内总有未被占用的存储体可用。

结论:最小存储体数 = 100(奇数步长)或 101(偶数步长)。

向量汇编编程

将以下C语言循环转换为向量汇编代码,以在所述向量机器上以最少的周期数执行。

for (i=0; i<46; i++) {
    if (a[i] == 0)
        c[i] = b[i];
    else
        c[i] = (a[i] * b[i]) + (a[i] >> 1);
}

假设向量指令集包括:SET(设置步长/长度)、VLD/VSD(向量加载/存储)、VCMP(比较)、LDM(加载掩码)、VNOT(按位非)、VMULVADDVSHR(右移)。

汇编代码思路

  1. SET 步长为1,向量长度为46。
  2. VLD 向量a到寄存器V1VLD 向量bV2
  3. VCMP V1与0,结果存V3a[i]==0的位置为1)。
  4. LDM V3 设置掩码,在掩码下VSDV2存储到c(处理if部分)。
  5. VNOT V3得到V3的反码(a[i]!=0的掩码)。
  6. LDM V3 设置新掩码。
  7. 在掩码下执行:
    • VSHR V1右移1位,结果存V4
    • VMUL V1V2,结果存V5
    • VADD V4V5,结果存V6
    • VSDV6存储到c(处理else部分)。

执行周期计算

基于上一问的汇编代码,假设ab位于不同的内存行,机器有8个存储体,计算总执行周期。

分析

  1. 内存访问延迟:每个存储体的行缓冲区大小为64位(2个元素)。访问46个元素,由于跨行访问,会产生多次行缺失。计算可得,一次完整的向量加载或存储需要约455个周期(包括初始延迟和流水线发射时间)。
  2. 指令时间线
    • SET指令很快(1周期)。
    • 第一个VLD开始后,需要455周期完成,期间不能向内存单元发出其他请求。
    • 第二个VLD必须等待第一个VLD的内存访问端口空闲。
    • 算术指令(VCMP, VNOT, VSHR, VMUL, VADD)可以与其他指令重叠执行,只要操作数就绪且功能单元空闲。
    • 存储指令VSD必须等待其源数据计算完成,并且内存端口可用。
  3. 关键路径:通常是内存访问链。总周期数大致为:2*SET + 2*VLD + 2*VSD的周期,加上必要的同步等待。经过详细调度分析,总执行时间约为 2 + 2*455 + 2*455 = 1822 周期。

总结:本节我们探讨了向量处理器中存储体数量与访问步长的关系,将标量循环转换为向量汇编,并估算了在特定硬件假设下向量代码的执行时间。


布尔逻辑电路 🔌

上一节我们处理了向量计算,本节我们回到组合逻辑电路的设计与优化。

真值表与逻辑表达式

设计一个组合电路,4位输入ABCD(A为最高位),两个1位输出FbG3

  • Fb=1 当输入数字是斐波那契数(0,1,2,3,5,8,13)。
  • G3=1 当输入数字大于3。

步骤

  1. 填充真值表(0-15),根据定义标记FbG3的输出值。
  2. 根据真值表,写出Fb的积之和表达式,列出所有使Fb=1的最小项。

布尔最小化

使用布尔代数规则简化Fb的表达式。

简化过程

  1. 提取公共因子。
  2. 利用互补律(如 C'D' + C'D + CD' + CD = 1)。
  3. 合并项后,得到简化表达式:Fb = A'B' + A'BC'D + AB'C'D' + ABC'D

使用与非门实现

为输出G3寻找仅使用两输入与非门的简化表示。

步骤

  1. 首先,写出G3的积之和表达式(有12个最小项)。
  2. 通过卡诺图或布尔代数简化,发现G3可简化为 A + B(因为当A或B为1时,数字至少为4)。
  3. A + B 转换为仅使用两输入与非门的形式:
    • A + B = ( (A NAND A) NAND (B NAND B) ) NAND ( (A NAND A) NAND (B NAND B) ) 的一种等价变形。实际上,A+B = (A' B')',而 A' = (A NAND A)B' = (B NAND B)。因此,G3 = ( (A NAND A) NAND (B NAND B) )。这需要3个两输入与非门。

总结:本节我们根据功能描述构建了真值表,推导并简化了逻辑表达式,最后实现了仅使用与非门的特定逻辑功能。


性能评估 ⏱️

上一节我们设计了逻辑电路,本节我们来评估不同处理器设计的性能。

计算CPI

处理器P1的指令周期数:加载6,存储6,算术2,分支2。
程序A的指令混合比:加载40%,存储20%,算术30%,分支10%。

P1的CPI
CPI_P1 = 0.4*6 + 0.2*6 + 0.3*2 + 0.1*2 = 4.4

处理器P2将时钟频率加倍,但所有指令延迟增加4个周期(加载10,存储10,算术6,分支6)。指令混合比不变。

P2的CPI
CPI_P2 = 0.4*10 + 0.2*10 + 0.3*6 + 0.1*6 = 8.4

速度比较

比较P1和P2的速度。

执行时间
Time = CPI * Instruction_Count * Clock_Period
设P1的时钟周期为T,则P2的时钟周期为T/2
Time_P1 = 4.4 * N * T
Time_P2 = 8.4 * N * (T/2) = 4.2 * N * T

加速比
Speedup = Time_P1 / Time_P2 = (4.4NT) / (4.2NT) ≈ 1.048
P2比P1快大约4.8%。

优化选择

在P1基础上,只能选择一种优化:

  • 优化ALU:将算术和分支指令的延迟减半(变为1周期)。
  • 优化LSU:将加载延迟减半(3周期),存储延迟加倍(12周期)。

计算两种优化后的CPI:

  • 优化ALU后CPI0.4*6 + 0.2*6 + 0.3*1 + 0.1*1 = 4.0
  • 优化LSU后CPI0.4*3 + 0.2*12 + 0.3*2 + 0.1*2 = 4.4

结论:应选择优化ALU,因为它能获得更低的CPI(4.0 < 4.4)。

总结:本节我们学习了如何根据指令混合比和延迟计算CPI,比较不同处理器设计的性能,并基于CPI评估优化方案的有效性。


脉动阵列 🧮

上一节我们进行了性能评估,本节我们研究用于矩阵乘法的脉动阵列。

2x2矩阵乘法

我们有一个2x2的处理单元阵列,每个PE执行乘累加操作:reg = reg + i1 * i2,并将输入i0i1直接传递到输出。

目标是用最少周期数计算2x2矩阵乘法 C = A x B

方法:通过精心安排输入a_{ij}b_{ij}进入阵列的时机,使每个PE在正确的时间收到正确的操作数对,从而计算出对应的c_{ij}

输入调度表示例

周期 H0 (输入) H1 (输入) V0 (输入) V1 (输入) 输出 (生成)
1 a00 - b00 - -
2 a01 a10 b10 b01 -
3 a11 - b11 - c00
4 - - - - c01, c10
5 - - - - c11

扩展到4x4矩阵

使用相同的2x2脉动阵列计算4x4矩阵乘法所需的最少周期数。

分析

  • 输入数量:4x4矩阵有32个输入元素,是2x2情况(8个输入)的4倍。
  • 计算模式:每个输出元素c_{ij}现在需要8次乘累加(而不是4次)。
  • 缩放估算:输入阶段可能需要约 3 * 4 = 12 周期。计算阶段,第一个输出可能在 2*2 + 1 = 5 周期后产生(因为计算深度加倍)。所有输出产生可能需要额外周期。

结论:经过详细推演,完成4x4矩阵乘法总共需要 19个周期

总结:本节我们学习了如何为脉动阵列调度数据以高效计算矩阵乘法,并理解了问题规模扩大时执行周期的估算方法。


超长指令字 📜

上一节我们探讨了脉动阵列,本节我们研究超长指令字处理器的指令调度。

VLIW指令调度

给定一个具有多个功能单元(3加载、1存储、1加法、1乘法、1分支)的VLIW处理器,以及一段汇编代码。目标是将这些汇编操作静态调度到VLIW指令中,填满一个时隙表。

调度策略

  1. 无依赖的指令可以放在同一VLIW指令中。
  2. 有真实数据依赖的指令必须被安排在不同的周期。
  3. 使用nop填充空闲时隙。

调度结果示例

周期 加载单元1 加载单元2 加载单元3 存储单元 加法单元 乘法单元 分支单元
1 load ... load ... load ... nop nop nop nop
2 nop nop nop nop nop mul ... nop
3 nop nop nop nop add ... nop nop
4 nop nop nop store ... nop nop nop
5 nop nop nop nop nop nop branch ...

利用率与执行时间

  • 有用操作比例:有用操作数(非nop)除以VLIW指令总数。本例中为 7个操作 / 5条指令 = 1.4
  • 循环执行时间:每个循环体调度后占5个周期。对于循环执行n次,总执行时间为 5 * n 个周期。

总结:本节我们实践了VLIW架构下的静态指令调度,计算了指令包的利用率,并推导了循环程序的执行时间公式。


缓存逆向工程 🕵️

上一节我们处理了VLIW调度,本节我们尝试通过巧妙的访问模式来逆向工程缓存的配置。

确定缓存结构

有一个4块、基于LRU的L1数据缓存,块大小1B。初始为空,然后依次访问地址0, 2, 4。
现在,恶意程序员只能再进行两次访问,通过观察这两次访问的命中率来确定缓存的组数和路数。

策略:我们需要选择两个地址(x, y),使得在不同的缓存配置(直接映射、2路组相联、4路全相联)下,访问序列 0, 2, 4, x, y 会产生不同的命中率模式。

解决方案:选择 x=0, y=2

  • 直接映射(4组1路):访问序列为 (0, 2, 4, 0, 2)。命中率 = 50%。
  • 2路组相联(2组2路):访问序列为 (0, 2, 4, 0, 2)。命中率 = 0%。
  • 4路全相联(1组4路):访问序列为 (0, 2, 4, 0, 2)。命中率 = 100%。

通过观察到的命中率,可以唯一确定缓存配置。

替换策略的影响

如果缓存使用MRU(最近最多使用)替换策略,而非LRU,是否还能用两次访问确定结构?

答案:不能。在MRU策略下,对于某些访问序列,不同的缓存配置可能表现出相同的命中行为,导致无法区分。例如,访问0后,在直接映射和2路组相联中,被替换的块可能相同,使得后续访问模式无法提供区分信息。

总结:本节我们学习了如何通过设计特定的内存访问序列,并根据命中率差异来推断缓存的相联度和组数。同时,我们也了解到替换策略(如MRU)可能会使这种逆向工程变得困难。


预取器识别 🤖

最后,我们通过分析覆盖率(Coverage)和准确率(Accuracy)指标,来识别机器使用的是哪种预取器。

有两种程序(A和B)在两台机器(M1和M2)上运行。预取器可能是:步长预取器、下一行预取器(步幅1)、下四行预取器(步幅4)。给出了每种程序在每台机器上的覆盖率和准确率。

分析关键

  • 准确率:M2的准确率是499/500,而M1是499/501。步长预取器需要观察两次连续访问才能建立模式,因此它发出的预取请求总数会比其他两种“总是预取”的预取器少一次。这正好与M2的准确率分母500(比其他少1)相符。
  • 覆盖率:结合程序B的访问模式(主要访问偶数地址),下一行预取器(总是预取a+1)的准确率会极低,因为预取的都是不会被访问的奇数地址。这与M1的高准确率矛盾,因此M1不是下一行预取器。

结论

  • 机器M1 使用的是下四行预取器
  • 机器M2 使用的是步长预取器

总结:本节我们通过分析预取器的覆盖率与准确率这两个关键指标,并结合程序的内存访问模式特征,成功推断出了两台机器所使用的预取器类型。


本节课中我们一起学习了计算机架构中多个核心主题的问题解决方法,包括ISA/微架构区分、流水线分析、内存系统、有限状态机、向量处理、逻辑设计、性能计算、脉动阵列、VLIW调度、缓存逆向工程和预取器识别。希望这些练习能帮助你巩固对数字设计和计算机架构的理解。

28:问题解决 III (Spring 2025)

概述

在本节课中,我们将学习如何分析和解决计算机架构中的几个核心问题,包括分支预测、脉动阵列、GPU执行效率、向量处理器、缓存层次结构以及预取机制。我们将通过具体的代码示例和实验数据,深入理解这些概念的工作原理和性能影响。


分支预测分析

上一节我们介绍了分支预测的基本概念,本节中我们来看看一个具体的代码示例,分析其中的局部相关和全局相关分支。

给定一段代码,它遍历一个包含 n 个元素的数组,每个元素的值是真正随机生成的。代码内部有三个独立的 if 语句,分别检查元素值是否为 2、3 或 6 的倍数。这些 if 语句不是 if-else 结构。

问题要求从四个分支(B1:for 循环,B2、B3、B4:三个 if 语句)中找出局部相关和全局相关的分支。

  • 局部相关:对于特定分支,如果知道前一次迭代的结果(该分支是否被采纳),能否决定当前迭代中该分支是否会被采纳。
  • 全局相关:在当前情况下,如果一个分支被采纳,能否推断出其他分支是否会被采纳。

以下是分析过程:

对于 B1(for 循环),其循环次数固定为 n。如果在第 n-1 次迭代,可以确定下一次迭代循环将结束(不采纳)。因此,B1 存在局部相关。

对于 B2、B3、B4,由于数组元素值完全随机,即使知道前一次迭代中某个分支(例如 B2)未被采纳,也无法预测当前迭代中它是否会被采纳。因此,B2、B3、B4 没有局部相关。

对于全局相关,分析分支间的逻辑关系:

  • 如果 B4(元素是 6 的倍数)被采纳,那么该元素也一定是 2 和 3 的倍数,因此 B2 和 B3 也必然被采纳。
  • 反之,如果 B2 和 B3 同时被采纳(元素是 2 和 3 的倍数),那么它也是 6 的倍数,因此 B4 必然被采纳。

因此,B4 与 B2、B3 是全局相关的。


全局分支预测器模拟

现在,我们将在配备全局分支预测器的处理器上运行这段代码。假设全局历史寄存器(GHR)只有 2 位,模式历史表(PHT)有 4 个条目,所有条目初始值为 0。预测规则是:如果分支被采纳,对应 PHT 条目值加 1;如果未被采纳,则减 1。

程序将循环执行 120 次(n=120)。问题:当 GHR 的第一条记录为“采纳-采纳”(对应两个分支被采纳)时,经过 120 次迭代后,PHT 中第一个条目的值是多少?

为了简化分析,我们假设数组元素值在 1 到 6 之间均匀随机出现,这个范围涵盖了判断 2、3、6 倍数所需的所有情况。

我们需要计算在 B1 和 B2 被采纳的条件下,B3 被采纳或不被采纳的贡献度(概率加权)。

  • B3 被采纳的概率:B2 被采纳意味着元素是 2 的倍数(即 2, 4, 6)。在这些数中,是 3 的倍数(即 6)的概率是 1/3。因此,贡献为 (3/6) * (1/3) = +1/6(因为采纳会加1)。
  • B3 不被采纳的概率:B2 被采纳的元素中,不是 3 的倍数(即 2, 4)的概率是 2/3。因此,贡献为 (3/6) * (2/3) * (-1) = -2/6(因为不采纳会减1)。

B3 对 PHT 条目值的总期望贡献为:(+1/6) + (-2/6) = -1/6

经过 120 次迭代,该条目的总变化期望为 120 * (-1/6) = -20。由于初始值为 0,最终值约为 -20(实际中,PHT 条目可能有饱和限制,但根据题目给定的加减规则,计算期望值即可)。


脉动阵列编程

上一节我们讨论了特定硬件结构,本节中我们来看看如何为脉动阵列编程以实现矩阵乘法。

我们有一个脉动处理单元(PE)阵列。每个 PE 有两个输入(M, N)、两个输出(P, Q)以及一个累加器寄存器 R。多个 PE 连接成网格。

目标是编程此阵列以计算矩阵乘法 C = A × B。在每个时钟周期,每个 PE 接收输入,执行计算,并产生输出。输入数据在特定周期被送入阵列。未连接的输入默认为 0,未使用的输出被忽略。计算完成后,每个 PE 的累加器 R 将保存结果矩阵 C 的一个元素。

首先,需要定义每个 PE 执行的操作:

  • 数据传递:输入 M 和 N 需要传递给相邻的 PE 以供后续计算。因此,P = M, Q = N
  • 累加计算:每个 PE 负责计算结果矩阵中一个元素的点积的一部分。每个周期,它将收到的两个输入相乘,并加到累加器上。因此,R = R + (M * N)

接下来,需要安排输入数据(矩阵 A 和 B 的元素)进入阵列的时序和位置,确保每个 PE 在正确的周期收到正确的数据对进行乘积累加。

通过分析数据流和计算依赖关系,可以推导出输入调度方案。例如,矩阵 A 的行元素可以水平输入,矩阵 B 的列元素可以垂直输入,通过巧妙的延迟对齐,使得每个 PE 能够顺序接收到计算对应 C 元素所需的所有 A 行和 B 列元素对。


GPU 执行利用率分析

现在,我们转向 GPU 架构,分析其 SIMD 通道的利用率。

SIMD 利用率定义为程序运行期间,保持有活跃线程工作的 SIMD 通道所占的比例。我们有一段在 GPU 上运行的代码,每个线程执行循环的一次迭代。假设 warp 大小为 32 线程,GPU 有 32 条 SIMD 通道。

部分 A:计算执行该程序所需的 warp 数量。总迭代次数为 N,所以 warp 数量为 ceil(N / 32)

部分 B:在给定数组 A 和 B 的特定值模式(A:24个1后跟8个0;B:48个0后跟64个1)下,计算程序的 SIMD 利用率。代码中包含一个 if 语句,条件为 a % 3 == 0。由于 A 的模式,只有读取到 A 中值为 0 的线程才会执行 if 体内的指令。

因此,对于每个 warp:

  • 指令1(if 判断)被所有 32 个线程执行。
  • 指令2(if 体内)只被那些 a[i] % 3 == 0 的线程执行。根据 A 的模式,每 32 个连续元素中,有 8 个 0,因此平均有 8 个线程执行指令2。

SIMD 利用率 = (执行指令1的线程周期 + 执行指令2的线程周期) / (总线程周期)。
假设每个指令消耗一个周期,对于大量 warp 平均:利用率 = (32 + 8) / (32 + 32) = 40 / 64 = 62.5%

部分 C:程序能否达到 100% 的 SIMD 利用率?答案是肯定的。需要满足的条件是关于数组 A 的:对于每个连续的 32 个元素(即一个 warp 访问的数据),要么全部都能通过 if 条件(都执行指令2),要么全都不能通过(都不执行指令2)。这样,warp 内所有线程执行路径一致,没有分歧,从而可以实现 100% 的利用率。


向量处理器内存访问优化

本节分析向量处理器的内存系统设计,以优化加载/存储操作的性能。

假设一个向量处理器,其向量加载操作具有 100 周期的延迟,但流水化执行,可以每个周期启动一个新的加载。存储操作类似。内存由多个交叉存取的存储体(bank)组成,连续地址的元素分布在不同 bank 中。

部分 A:为避免执行跨度为 1 的加载/存储操作时发生停顿,最少需要多少个存储体?
由于单个加载操作会占用一个 bank 100 个周期,为了能够每个周期启动一个新的加载(访问新的 bank),需要至少 100 个 bank。这样,在第 100 个周期,可以重新访问第一个 bank,而此时它已经完成了第一个加载请求。

部分 B:如果访问跨度为 2,需要多少 bank?
跨度为 2 时,连续访问的地址位于相隔的 bank。例如,有 100 个 bank 时,访问序列可能是 bank 0, 2, 4, ...。问题在于,当访问到第 50 个元素(bank 0? 这里需要具体计算)时,可能 bank 0 仍被占用。为了避免停顿,bank 数量需要与跨度互质,或者足够大以消除冲突。经过分析,101 个 bank 可以避免跨度为 2 时的停顿。

部分 C 和 D:在已知 bank 数量和特定代码序列下,计算程序的执行周期,或反推向量长度 M。这需要根据向量指令间的依赖关系、是否支持链式操作(chaining)以及流水线延迟来构建执行时间方程并求解。


缓存层次结构逆向工程

通过运行特定的微基准测试程序,我们可以逆向推导出未知计算机系统的缓存层次结构参数。

程序以随机顺序访问一个内存区域,通过改变访问“跨度”和区域“大小”,并测量访问延迟,可以绘制出延迟随区域大小变化的曲线。

分析曲线特征

  • 平坦段:当整个访问区域能完全放入某一级缓存时,所有访问都命中该缓存,延迟恒定,等于该级缓存的访问延迟。
  • 上升点:当区域大小超过该级缓存容量时,开始出现缓存失效,平均延迟上升。
  • 不同跨度曲线分离点:可以帮助确定缓存的相联度。例如,如果跨度为 16 时,在某个大小之前延迟保持低位,说明可以同时容纳多个跨距访问的元素而不冲突,从而推断出至少需要多少路组相联。

通过分析不同处理器(单级缓存和两级缓存)的测试曲线,我们可以确定:

  • L1 缓存大小、访问延迟、相联度。
  • L2 缓存大小、访问延迟(从上一级到该级的额外延迟)、相联度。
  • 主内存访问延迟。
  • 某些参数(如缓存行大小)可能无法从给定数据中确定。

预取器效果评估

预取器旨在预测并提前获取程序可能访问的数据。我们评估两种应用(A 和 B)在步长预取器下的表现。

应用 A 和 B 都访问数组,但索引计算方式不同:A 是 i * 4,B 是 i * 4 的某种变体(实际上是 i * 4 后接其他计算,导致访问地址不是连续的 4 的倍数)。假设缓存块大小为 4 字节。

  • 应用 A:访问地址为 0, 4, 8, 12,... 步长为 4。步长预取器可以学习到这个步长,并预取后续缓存块。预取准确率和覆盖率都可以很高(除了最开始的一两次访问用于学习步长)。
  • 应用 B:访问地址如 1, 16, 64, 256,... 没有固定的步长。步长预取器无法预测这种模式,因此准确率和覆盖率都为 0。

对于应用 A,下一行预取器(总是预取下一个缓存块)可能比步长预取器效果更好,因为它从一开始就能正确预取。
对于应用 B,常规预取器无效。可能需要更复杂的预取技术,如基于执行的预取或运行时提前执行(run-ahead execution),通过实际执行代码来生成未来地址。

是否对应用 A 使用运行时提前执行?通常不需要。因为应用 A 的访问模式非常规则,简单的步长或下一行预取器已经能以低开销获得高性能。运行时提前执行更适用于不规则、数据依赖强的访问模式,如应用 B。


缓存性能详细分析

通过编写更复杂的微基准测试代码,我们可以深入分析缓存特性,如块大小、相联度、替换策略等。

代码1 以固定步长遍历数组并测量每次访问延迟。
代码2 包含两个循环:第一个循环以某种方式“训练”缓存,第二个循环测试访问延迟。

部分 A:运行代码1(步长=1),观察延迟曲线。可以观察到三个不同的延迟层级(如 100, 300, 700 周期),分别对应 L1 命中、L2 命中、主存访问。通过分析延迟变化点对应的访问索引,可以推断出 L1 和 L2 的缓存块大小和总容量。

部分 B:使用代码2,通过调整访问区域大小(size1, size2)和步长,观察特定访问(如 latency[0])的延迟变化。这可以揭示缓存的相联度。例如,如果 size1 为某个值时 latency[0] 是 L2 命中,而 size1 稍小时是 L1 命中,说明 size1 的大小刚好使得访问序列占满了该组的所有路,导致第 0 个元素被替换出去。

部分 C:使用代码2 的不同步长组合,然后运行代码1 检查特定地址是否仍在缓存中。这可以推断缓存替换策略是 LRU 还是 FIFO。例如,如果访问序列使得某个地址在 LRU 策略下应该被保留,而在 FIFO 下应该被替换,那么通过实际测量结果就可以判断是哪种策略。

部分 D:连续运行两次代码1,第一次用于预热缓存,第二次测量平均延迟并绘制随访问区域大小变化的曲线。曲线上的平台和跃升点直接对应各级缓存的总容量。平台之间的过渡区域形状与缓存相联度和替换策略有关。


综合缓存设计推断

给定一个确定的内存访问序列和对应的缓存命中率,要求推断出缓存的设计参数,包括块大小、相联度、总容量和替换策略。

方法:采用假设验证法。

  1. 块大小:尝试不同的块大小(如 8B, 16B, 32B, 64B),模拟给定访问序列,计算命中率,看哪个与给定的命中率匹配。
  2. 相联度和容量:在确定块大小后,尝试不同的相联度(1-way, 2-way, 4-way, 8-way)和总容量(4KB, 8KB)。对于每种组合,计算访问序列中映射到同一缓存组的地址序列。分析这些地址在缓存中的驻留情况,看是否与给定的命中率匹配。需要考虑替换策略(LRU 或 FIFO)。
  3. 替换策略:在确定了块大小、相联度、容量后,使用不同的替换策略模拟,看哪种策略能产生与给定命中率一致的结果。

通过系统性地测试所有合理的参数组合,并与实验数据对比,可以唯一确定缓存的设计。


总结

本节课中我们一起学习了计算机架构中多个高级主题的实践分析方法。我们探讨了如何分析代码中的分支相关性,模拟了全局分支预测器的行为,设计了脉动阵列以实现矩阵乘法,计算了 GPU 线程执行的 SIMD 利用率,优化了向量处理器的内存访问,通过微基准测试逆向工程了缓存系统参数,评估了不同预取策略的效果,并综合运用缓存原理推断出了未知缓存的设计细节。这些技能对于理解和优化计算机系统的性能至关重要。

posted @ 2026-03-29 09:13  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报