剖析虚幻渲染体系(19)- 计算机硬件体系

目录



19.1 计算机概述

之前的很多篇文章已经大量涉及了各种各样的硬件和技术,本篇将更加全面、系统、深入地阐述计算机的硬件组成和体系架构,从而形成自上而下的计算机体知识体系。本篇主要阐述以下内容:

  • 计算机基础。
  • 电子电路基础。
  • 计算机硬件。
  • 计算机架构和组织。
  • 计算机硬件运行机制。
  • 计算机硬件的UE封装和实现。

19.1.1 计算机是什么

计算机(Computer)是什么?计算机是一种通用设备,可以编程处理信息,并产生有意义的结果。它是一种被广泛使用且使我们的工作变得轻松的设备,是被动型机器,需要我们输入指令或任务来执行,从而获得我们需要的结果。

一个基础的计算机。

简单的单处理器计算机如下,提供了传统单处理器计算机内部结构的分层视图。有四个主要结构部件:CPU、主内存、I/O、系统链接。

如下图所示,向用户提供应用程序时使用的硬件和软件可以以分层或分层的方式查看。这些应用程序的用户,即最终用户,通常不关心计算机的架构,因此终端用户根据应用来查看计算机系统。该应用程序可以用编程语言表示,并由应用程序程序员开发,将应用程序开发为一组完全负责控制计算机硬件的处理器指令将是一项极其复杂的任务。为了简化此任务,提供了一组系统程序。

其中一些程序被称为实用程序。这些实现了常用的功能,有助于程序创建、文件管理和I/O设备控制。程序员在开发应用程序时使用这些工具,应用程序在运行时调用实用程序来执行某些功能。最重要的系统程序是操作系统,操作系统向程序员隐藏了硬件的细节,并为程序员提供了使用系统的方便界面。它充当中介,使程序员和应用程序更容易访问和使用这些设施和服务。

硬件之中,最重要的部件是芯片,其制造工艺如下图。从硅锭切片后,将空白晶片经过20至40个步骤,以形成图案化晶片。然后用晶片测试仪对这些图案化的晶片进行测试,并绘制出良好零件的地图。然后将晶片切成小片,在该图中,一个晶片生产了20个管芯,其中17个管芯通过了测试(X表示模具不良)。在这种情况下,好模具的产量为17/20,即85%。然后将这些好的模具粘结到包装中,并在将包装好的零件运送给客户之前再次进行测试。最终测试中发现一个包装不良的零件。

19.1.2 计算机结构

典型的台式计算机有三个主要物理部件——CPU(中央处理器)、主存储器和硬盘。CPU通常也被称为处理器或简单的机器,是计算机的大脑,是计算机的主要部分,将程序作为输入并执行。主存储器用于存储程序在执行过程中可能需要的数据(信息存储),处理器本身的存储空间有限。当电源关闭时,处理器和主存储器会丢失所有数据,但硬盘代表永久存储,程序、数据、照片、视频和文档等数据都存储在硬盘中。

下图显示了三个组件的简化框图。除了这些主要组件之外,还有一系列与计算机相连的外围组件,例如键盘和鼠标连接到计算机。它们从用户处获取输入,并将其传递给处理器上运行的程序。

类似地,为了显示程序的输出,处理器通常将输出数据发送到监视器,监视器可以以图形方式显示结果,也可以使用打印机打印结果。最后,计算机可以通过网络连接到其他计算机。所有外围设备的方框图如下所示。

19.1.3 计算机运行机制

不管底层技术如何,我们需要理解的一个基本概念是,计算机从根本上来说是一台愚蠢的机器。与我们的大脑不同,它没有被赋予抽象的思想、理性和良知。至少在目前,计算机不能自己做出非常复杂的决定,能做的就是执行一个程序。尽管如此,计算机之所以如此强大,是因为它们非常擅长执行程序,每秒可以执行数十亿条基本指令。计算机与人脑的比较如下表所示。

特性 计算机 人脑
智力 愚蠢 智能
算力 超快
是否疲倦 绝不 一段时间后
是否厌倦 绝不 几乎总是

计算机无法理解人类语言,只能理解二进制数据,也就是0和1、True和False、On和Off,这些状态是通过晶体管(transistor)实现的。晶体管是用于存储2个值(1和0或开和关)的微型设备,如果晶体管打开,它的值为 1,如果它关闭,则值为 0。

例如,一个存储芯片包含数亿甚至数十亿个晶体管,每个晶体管都可以单独打开或关闭。当极少量的电流通过晶体管时,它保持状态1,当没有电流时,晶体管的状态为0。具体示例:

1 : 1
2 : 10
3 : 11
a : 01100001
A : 01000001
U : 01010101 

Hello        : 01001000 01100101 01101100 01101100 01101111 
Hello World! : 01001000 01100101 01101100 01101100 01101111 00100000 01010111 01101111 01110010 01101100 01100100 00100001

问题在于,以上二进制代码对于人类而言,太难以理解了,此时需要各类计算机软件做翻译的桥梁作用。软件是一组指令,告诉计算机要做什么、什么时候做以及如何做。下图显示了操作流程。第一步是用高级语言(C或C++)编写程序,第二步涉及编译它,编译器将高级程序作为输入,并生成包含机器指令的程序,该程序通常称为可执行文件或二进制文件。注意,编译器本身是一个由基本机器指令组成的程序。

假设要执行2+2的指令,那么我们必须给计算机指令:

  • 第1步:取2个值。
  • 第2步:存储该2值。
  • 第3步:使用 + 运算符对它们进行相加。
  • 第4步:存储结果。

为 + 运算符提供了单独的说明,以便计算机在遇到 + 符号时知道如何进行加法。那么谁来转换这段代码呢?答案是解释器(interpreter),它把我们的语言代码转换成计算机可以理解的机器语言。同理,输入和输出数据也需要依赖特定的软件和解释器。

就像任何语言都有有限的单词一样,处理器可以支持的基本指令/基本命令的数量也必须是有限的,这组指令通常称为指令集(instruction set),基本指令的一些示例是加法、减法、乘法、逻辑或和逻辑非。请注意,每条指令需要处理一组变量和常量,最后将结果保存在变量中。这些变量不是程序员定义的变量,是计算机内的内部位置。

19.1.4 计算机设计准则

计算机设计是组件相互关联的结构。设计者一次处理特定级别的系统,并且在不同级别存在不同类型的问题。在每一层,设计者都关心结构和功能,结构是相互关联的用于通信的各个组件的骨架,功能是系统中涉及的活动。 以下是计算机设计中的问题:

  • 无限速度的假设: 不能假设计算机的无限速度,因为假设无限速度是不切实际的,也给设计者的思维带来了问题。
  • 内存无限的假设: 就像计算机的速度一样,内存也不能假设为无限的,总是有限的。
  • 内存和处理器之间的速度不匹配: 有时内存和处理器的速度可能不匹配,可能是内存速度更快或处理器速度更快。内存和处理器之间的不匹配会导致设计中出现问题。
  • 处理错误和错误: 处理缺陷和错误是任何计算机设计者的巨大责任,缺陷和错误会导致计算机系统出现故障,有时这些错误可能更危险。
  • 多处理器: 设计具有多个处理器的计算机系统会导致管理和编程的巨大任务,是计算机设计中的一大问题。
  • 多线程: 具有多线程的计算机系统总是对设计者构成威胁,具有多个线程的计算机应该能够进行多任务和多处理。
  • 共享内存: 如果一次要执行多个进程,则所有进程共享相同的内存空间。应该以特定的方式对其进行管理,以免发生冲突。
  • 磁盘访问: 磁盘管理是计算机设计的关键,磁盘访问存在几个问题,系统可能不支持多磁盘访问。
  • 更好的性能: 始终是一个问题,设计者总是试图简化系统以获得更好的性能来降低功耗和降低成本。

19.1.5 实用机器架构

下面阐述一下不同类型的实用机器的设计及架构。

  • 哈佛架构

哈佛体系架构下图所示,有单独的结构来维护指令表和内存。指令表也被称为指令存储器,因为可以把它看作是专门为只保存指令而设计的专用存储器。内存保存程序所需的数据值,因此被称为数据存储器。处理指令的引擎分为两部分:控制和ALU,控制单元的工作是获取指令、处理指令并协调指令的执行。ALU代表算术逻辑单元,有专门的电路,可以计算算术表达式或逻辑表达式(and/or/NOT等)。

请注意,每台计算机都需要从用户/编程器处获取输入,并最终将结果传回编程器,可通过多种方法实现,例如我们如今使用的键盘和显示器。早期的计算机使用一组开关,最终结果打印在一张纸上。

  • 冯诺依曼架构

约翰·冯·诺依曼提出了通用图灵完全计算机的冯·诺伊曼体系结构,实际上,Eckert和Mauchly于1946年基于该架构设计了第一台通用图灵完全计算机(有个小限制),称为ENIAC(电子数字积分器和计算器),该计算机用于计算美国陆军弹道研究实验室的火炮环表,后来在1949年被EDVAC计算机取代,该计算机也被美国陆军的弹道研究实验室使用。

作为ENIAC和EDVAC基础的基本冯·诺依曼架构如下图所示。指令表保存在内存中,图灵机的处理引擎被称为CPU(中央处理单元),包含程序计数器,其工作是获取新指令并执行它们。它有专用的功能单元来计算算术函数的结果,在内存位置加载和存储值,以及计算分支指令的结果。最后,与哈佛体系结构一样,CPU连接到I/O子系统。

这台机器的创新之处在于指令表存储在内存中,使用通常存储在内存中的同一组符号对每条指令进行编码。例如,如果内存存储十进制值,则每条指令都需要编码为十进制数字串。冯·诺依曼CPU需要解码每条指令,这个想法的核心是,指令被视为常规数据(内存值)。这个简单的想法实际上是设计优雅计算系统的一个非常强大的工具,被称为存储程序概念。

存储程序概念(stored program concept):程序存储在内存中,指令被视为常规内存值。

存储程序概念极大地简化了计算机的设计。由于内存数据和指令在概念上是以相同的方式处理的,所以我们可以有一个统一的处理系统和一个以相同方式处理指令和数据的内存系统。从CPU的角度来看,程序计数器指向一个通用内存位置,其内容将被解释为编码指令的内容,很容易存储、修改和传输程序,程序还可以在运行时通过修改自身甚至其他程序来动态更改其行为。这构成了当今复杂编译器的基础,这些编译器将高级C程序转换为机器指令。此外,许多现代系统(如Java虚拟机)动态地修改它们的指令以实现效率。

冯·诺伊曼机器或哈佛机器不像图灵机器那样拥有无限的内存,严格地说,它们并不完全等同于图灵机,对于所有实用的机器都是如此,它们需要足够的资源。然而,科学界已经学会接受这种近似。


19.2 计算机硬件基础

19.2.1 汇编语言

汇编语言可以广泛地定义为机器指令的文本表示。在构建处理器之前,我们需要了解不同机器指令的语义,在这方面,对汇编语言的严格研究将是有益的。汇编语言专用于ISA和编译器框架,因此,汇编语言有许多优点。本节将描述不同汇编语言变体的基本原理,一些通用概念和术语。随后,将描述针对基于ARM的处理器的ARM汇编语言,描述针对Intel/AMD处理器的x86汇编语言。

19.2.1.1 为什么用汇编语言?

先从软件开发者的视角阐述之。

人类懂得自然语言,如中文、英语和西班牙语。通过一些额外的训练,人类还可以理解计算机编程语言,如C或Java。然而,如前面所述,计算机是一台愚蠢的机器,不够聪明,无法理解人类语言(如英语)或编程语言(如C)中的命令,它只能理解零和一。因此,要给计算机编程,必须给它一个0和1的序列。事实上,一些早期的程序员曾经通过打开或关闭一组开关来编程计算机,打开一个开关对应于1,打开它意味着0。对于今天的大规模数百万行程序来说,不是一个可行的解决方案,需要另寻更好的方法。

因此,我们需要一个自动转换器,它可以将用C或Java等高级语言编写的程序转换为一系列0和1,称为机器代码(machine code)。机器代码包含一组称为机器指令的指令,每个机器指令都是由零和一组成的序列,并指示处理器执行特定的操作。可以将用高级语言编写的程序转换为机器代码的程序称为编译器(见下图)。

编译过程。

请注意,编译器是一个可执行程序,通常在应该为其生成机器代码的机器上运行。可能出现的一个自然问题是——谁编写了第一个编译器?

第一,鉴于编译器的普遍存在,几乎所有的程序都是用高级语言编写的,编译器用来将它们转换为机器代码,但这一规则也有重要的例外。请注意,编译器的作用有两方面:首先,它需要正确地将高级语言的程序翻译成机器指令;其次,它需要生成不占用大量空间且速度快的高效机器代码。因此,多年来编译器中的算法变得越来越复杂,但并不总是能够满足这些要求,例如在某些情况下,编译器可能无法生成足够快的代码,或者无法提供程序员期望的某种功能:

  • 首先,编译器中的算法受到它们对程序执行的分析量的限制。例如,我们不希望编译过程极其缓慢,编译器领域的许多问题在计算上很难解决,因此很耗时。
  • 其次,编译器不知道代码中的广泛模式。例如,某个变量可能只取一组有限的值,在此基础上,可能进一步优化机器代码,编译器很难理解这一点。聪明的程序员有时可以生成比编译器更优化的机器代码,因为他们知道一些广泛的执行模式,可以胜过编译器。
  • 处理器供应商也可能在ISA中添加新指令。在这种情况下,用于旧版本处理器的编译器可能无法利用新指令,需要在程序中手动添加它们。流行的编译器(如gcc,GNU编译器集合)是相当通用的,它们不使用处理器在生成机器代码时提供的所有可能的机器指令。通常,操作系统和设备驱动程序(与打印机和扫描仪等设备接口的程序)需要大量遗漏的指令,因为它们需要对硬件的低级别访问,因此系统程序员有强烈的动机偶尔绕过编译器。

在所有这些情况下,程序员都有必要在程序中手动嵌入一系列机器指令。如上所述,这样做的两个主要原因是效率和额外的功能。因此,从系统软件开发人员的角度来看,有必要了解机器指令,以便他们在工作中更有效率。

现在,我们的目标是让现代程序员远离0和1的复杂细节。理想情况下,我们不希望程序员像50年前那样通过手动打开和关闭开关来编程,由此开发了一种称为汇编语言的低级语言。汇编语言是机器代码的一种人类可读形式,每个汇编语言语句通常对应于一条机器指令。此外,它通过不强迫程序员记住编码指令所需的0/1的确切序列,大大减轻了程序员的负担。

  • 低级编程语言(low level programming language)使用通常只对应于一条机器指令的简单语句,这些语言是ISA特有的。
  • 汇编语言(assembly language)是指一系列特定于每个ISA的低级编程语言,具有由一系列汇编语句组成的泛型结构。通常,每个汇编语句有两部分:
    • 一个指令代码,是基本机器指令的助记符。
    • 一个操作数列表。

从实际角度来看,可以编写独立的汇编程序,并使用称为汇编器的程序将其转换为可执行程序,也可以在高级语言(如C或C++)中嵌入汇编代码片段,后者更为常见。

汇编器(assembler)是将汇编程序转换为机器代码的可执行程序。

编译器确保能够将组合程序编译为机器代码。汇编语言的好处是多方面的:

  • 可读性。因为汇编代码中的每一行对应于一条机器指令,所以它和机器代码一样具有表达力。
  • 高效性。由于这种一对一的映射,我们不必通过在汇编中编写程序来提高效率。
  • 简化性。它是一种可读的、优雅的文本表示机器代码的形式,大大简化了使用它编写程序的过程,也可以在用C等高级语言编写的软件中清晰地嵌入汇编代码片段,它定义了一个高于实际机器代码的抽象级别。两个处理器可能与同一种汇编语言兼容,但实际上对同一条指令有不同的机器编码。在这种情况下,汇编程序将在这两个处理器上兼容。

再从硬件设计者的视角阐述之。

硬件设计师的职责是设计能够实现ISA中所有指令的处理器。他们的主要目标是设计一个在面积、功率效率和设计复杂性方面最佳的高效处理器,从他们的角度来看,ISA是软件和硬件之间的关键纽带。这回答了他们的基本问题——构建什么?因此,对他们来说,理解不同指令集的精确语义是非常重要的,这样他们就可以为它们设计处理器。将指令仅仅看作一个0和1的序列是很麻烦的,通过查看机器指令的文本表示,他们可以获得很多好处,很清晰地知道是一条怎样的汇编指令。

汇编语言专用于指令集和汇编器。本节使用流行的GNU汇编器的汇编语言格式来解释典型汇编语言文件的语法,请注意,其他系统具有类似的格式,并且概念大致相同。

19.2.1.2 汇编语言基础

机器模型

汇编语言不将指令存储器和数据内存视为不同的实体,假设一个抽象的冯·诺依曼机器增加了寄存器。

有关机器模型的图示,请参见下图。程序存储在主内存的一部分中,中央处理单元(CPU)逐条指令读出程序指令,并适当地执行指令,程序计数器(PC)跟踪CPU正在执行的指令的内存地址,大多数指令都希望从寄存器中获取其输入操作数。回想一下,每个CPU都有固定数量的寄存器(通常<64),然而大量指令也可以直接从内存中获取操作数。CPU的工作是协调主存和寄存器之间的传输,CPU还需要执行所有算术/逻辑计算,并与外部输入/输出设备保持联系。

大多数类型的汇编语言在大多数语句中都采用这种抽象机器模型。但由于使用汇编语言的另一个目的是对硬件进行更细粒度和侵入性的控制,因此有相当多的汇编指令可以识别处理器的内部。

这些指令通常通过改变一些关键内部算法的行为来修改处理器的行为,它们修改内置参数,如电源管理设置,或读/写一些内部数据。最后请注意,汇编语言不区分机器无关指令和机器相关指令。

每台机器都有一组寄存器,这些寄存器对汇编程序员是可见的。ARM有16个寄存器,x86(32位)有8个寄存器,而x86_64(64位)有16个。寄存器有名称,ARM将它们命名为r0、r1、...、r14、r15,x86将它们命名成eax、ebx、ecx、edx、esi、edi、ebp和esp,可以使用这些名称访问寄存器。

在大多数ISA中,返回地址寄存器用于函数调用。让我们假设一个程序开始执行一个函数,它需要记住执行函数后需要返回的内存地址,此地址称为返回地址。在跳转到函数的起始地址之前,我们可以将返回地址的值保存在这个寄存器中。通过将保存在返回地址寄存器中的值复制到PC上,可以简单地实现返回语句。在ARM和MIPS等汇编语言中,程序员可以看到返回地址寄存器,然而x86不使用返回地址寄存器,使用堆栈。

在ARM处理器中,程序员可以看到PC,它是最后一个寄存器(r15)。可以读取PC的值,也可以设置其值,设置PC的值意味着我们希望分支到程序中的新位置。然而x86的PC是隐式的,程序员不可见。

内存视图

内存可以看作是一个大的字节数组,每个字节都有一个唯一的地址,基本上就是它在数组中的位置。第一字节的地址是0,第二字节的地址为1,以此类推。我们没有一种方法来唯一地寻址给定的位,地址在32位机器中是32位无符号整数,在64位机器中则是64位无符号。

在冯·诺依曼机器中,我们假设程序作为字节序列存储在内存中,程序计数器指向将要执行的下一条指令。

假设内存是一个大的字节数组,如果我们所有的数据项都只有一个字节长,那么就可以了,像C和Java这样的语言有不同大小的数据类型:char(1字节)、short(2字节)、integer(4字节)和long integer(8字节)。对于多字节数据类型,必须在内存中为其创建一个表示,在内存中表示多字节数据类型有两种可能的方式——小端和大端。其次,我们还需要找到表示内存中数据数组或列表的方法。

  • 小端和大端表示

让我们考虑在位置0-3存储整数的问题。让整数为0x87654321,它可以分为四个字节:87、65、43和21。一个选项是将最重要的字节87存储在最低的内存地址0中,下一个位置可以存储65、43、21,这被称为大端(big endian)表示,因为我们从最大字节的位置开始。相比之下,我们可以先将最小的字节保存在位置0,然后继续将最大的字节存储在位置3,这种表示方式称为小端(big endian)。下图显示了差异。

大端和小端表示。

因此,没有理由选择一种代表而不是另一种代表,例如,x86处理器使用little-endian格式。早期版本的ARM处理器曾经是小端的,然而,现在它们是双端的,意味着ARM处理器可以根据用户设置同时作为小端和大端机器工作。传统上,IBM POWER处理器和Sun SPARC处理器都是大端的。

  • 表示数组

数组是一组线性有序的对象,其中对象可以是简单的数据类型(如整数或字符),也可以是复杂的数据类型。

int a[100];
char c[100];

让我们考虑一个简单的整数数组a。如果数组有100个条目,那么内存中数组的总大小等于100 4=400字节。如果数组的起始内存位置为loc,然后将[0]存储在位置(loc + 0)、(loc + 1)、(loc + 2)、(loc + 3)中。请注意,有大端和小端两种方法保存数据。下一个数组条目a[1]保存在位置(loc + 4) ... (loc + 7)中,依此类推,我们注意到条目a[i]保存在(loc + 4 x i) ... (loc + 4 x i + 3)中。

大多数编程语言都定义以下形式的多维数组:

int a[100][100];
char c[100][100];

它们通常在内存中表示为规则的一维数组,多维数组中的位置与等效的一维数组之间存在映射函数,我们可以扩展该方案以考虑维度大于2的多维数组。

我们观察到,通过以行优先(row major)方式保存二维数组,可以将其保存为一维数组,意味着数据按行保存。我们保存第一行,然后保存第二行,以此类推。同样,也可以以列优先(column major)的方式保存多维数组,其中保存第一列,然后再保存第二列,依此类推。

行优先(row major):数组按行保存在内存中。
列优先(column major):数组按列保存在内存中。

19.2.1.3 汇编语言语法

汇编文件的确切语法取决于汇编程序,不同的汇编程序可以使用不同的语法,尽管它们可能在基本指令及其操作数格式上达成一致。本节将解释GNU系列汇编语言的语法,它们是为GNU汇编程序设计的,是GNU编译器集合(gcc)的一部分。与所有GNU软件一样,该汇编程序和相关编译器可免费用于大多数平台,汇编程序可在gnu.org上找到。请注意,其他汇编程序(如NASM、MASM)都有自己的格式,但总体结构在概念上与本节描述的没有太大区别。

汇编文件结构

程序集文件是一个常规文本文件,后缀是.s。如果安装了GNU编译器gcc,则可以通过发出以下命令快速生成C程序的汇编文件,当然也可以使用在线GCC

gcc -S test.c

生成的程序集文件将命名为test.s。GNU程序集的结构非常简单,如下图所示。不同部分的示例包括文本(实际程序)、数据(具有初始化值的数据)和bss(初始化为0的通用数据)。每个段(section)以节标题开头,这是以“.”开头的节的名称符号,例如,文本部分以“.text”行开头。接着是汇编语言语句列表,每条语句通常以换行符结尾,同样,数据部分包含数据值列表。

汇编文件以包含格式为“.file <文件名>”的行的文件段开头。当我们使用gcc编译器从C程序生成程序集文件时,.file部分中的文件名通常与我们的原始C程序(test.C)相同。文本段是必填的,其余段是可选的,可能有一个或多个数据段,也可以使用.section指令定义新的节。本节我们主要关注文本部分,因为对学习指令集的本质感兴趣。

汇编语言文件结构。

汇编基本语句

一条基本的汇编语言语句指定了一条汇编指令,有两部分:指令及其操作数列表,如下图所示。该指令是实际机器指令的文本标识符,操作数列表包含每个操作数的值或位置。操作数的值是一个数值常量,也被称为立即值(immediate value),操作数位置可以是寄存器位置或内存位置。

汇编语言语句。

在计算机架构中,指令中指定的常数值也称为立即数(immediate)

假设有以下语句:

add r3, r1, r2

在这个ARM汇编语句中,add指令指定了我们希望将两个数字相加并将结果保存在某个预先指定的位置的事实。在这种情况下,加法指令的格式如下:<指令><目标寄存器><操作数寄存器1><操作数寄存器2>。指令的名称为add,目标寄存器为r3,操作数寄存器为r1和r2。指令的详细步骤如下:

1.读取寄存器r1的值。让我们将该值称为v1。

2.读取寄存器r2的值。让我们将该值称为v2。

3.计算v3=v1+v2。

4.将v3保存在寄存器r3中。

现在让我们再举一个以类似方式工作的两条指令的示例:

sub r3, r1, r2
mul r3, r1, 3

sub指令减去存储在寄存器中的两个数,mul指令将存储在寄存器r1中的一个数乘以数值常数3,这两条指令都将结果保存在寄存器r3中,它们的操作模式与加法指令类似。此外,算术指令(如add、sub和mul)也称为数据处理指令。还有其他几类指令,例如从内存加载或存储值的数据传输指令,以及实现分支的控制指令。

通用语句结构

汇编语句的一般结构如下图所示,它由三个字段组成:标签(指令的标识符)、键(汇编指令或汇编程序指令)和注释。这三个字段都是可选的,但是,任何汇编语句都需要至少具有其中一个。

汇编语句的通用结构。

语句可以选择以标签开头,标签是语句的文本标识符,换句话说,标签在汇编中唯一地标识汇编语句。请注意,我们不允许在同一汇编文件中重复标签,标签在执行分支指令时非常有用。

下面的示例代码中显示了一个标签的示例,这里标签的名称是“label1”,后面是冒号。在标签之后,我们编写了一条汇编指令,并给它一个操作数列表。标签可以由有效的字母数字字符[a-z] [A-Z] [0-9] 以及符号“.”、“_”和“$”。通常,我们不能以数字作为标签的开头。在指定标签之后,我们可以将该行保持为空,也可以指定键(汇编语句的一部分)。如果键以“.”开头,那么它是一个汇编程序指令,对所有计算机都有效,它指示汇编程序执行某个操作,此操作可以包括启动新节或声明常量。该指令还可以采用参数列表,如果键以字母开头,则它是一条常规的汇编指令。

label1: add r1, r2, r3

在标签、汇编指令和操作数列表之后,可以选择插入注释。GNU汇编程序支持两种类型的注释,我们可以在插入类似C或Java风格的注释。在ARM汇编中,通过在注释前面加上“@”字符,也可以有一个小的单行注释。

label1: add r1, r2, r3 @ Add the values in r2 and r3
label2: add r3, r4, r5 @ Add the values in r4 and r5
add r5, r6, r7 /* Add the values in r6 and r7 */

汇编语句可能只包含标签,而不包含键。在这种情况下,标签本质上指向一个空语句,不是很有用。因此,汇编程序假定在这种情况下,标签指向最近的包含键的后续汇编语句。

19.2.1.4 指令类型

按功能分类

按功能,可分为四种主要类型,说明如下:

  • 数据处理指令:数据处理指令通常是算术指令,如加法、减法和乘法,或按位或、异或计算的逻辑指令,比较指令也属于这个类型。
  • 数据传输指令:这些指令在两个位置之间传输值,位置可以是寄存器或内存地址。
  • 分支指令:分支指令帮助处理器的控制单元根据操作数的值跳转到程序的不同部分,在实现for循环和if-then-else语句时很有用。
  • 异常生成指令:这些专用指令有助于将控制权从用户级程序转移到操作系统。

我们将介绍数据处理、数据传输和控制指令。

按操作数分类

GNU汇编程序中的所有汇编语言语句都具有相同的结构,它们以指令的名称开头,后面是操作数列表。我们可以根据指令所需的操作数对其进行分类,如果一条指令需要n个操作数,那么通常称它是n地址格式,例如,不需要任何操作数的指令是0地址格式指令,如果它需要3个操作数,则它是3地址格式指令。

如果一条指令需要n个操作数(包括源和目标),那么我们称其为n地址格式指令。

在ARM中,大多数数据处理指令采用3地址格式,数据传输指令采用2地址格式。然而,在x86中,大多数指令都是2地址格式。我们想到的第一个问题是,3地址格式指令与2地址格式指令的逻辑是什么?这里一定有一些权衡。

让我们阐述一些一般的经验法则。如果一条指令有更多的操作数,那么它将需要更多的位来表示该指令,因此需要更多的资源来存储和处理指令。然而,这一论点有另一面,拥有更多的操作数也会使指令更加通用和灵活,将使编译器编写者和汇编程序员的生活变得更加轻松,因为使用更多操作数的指令可以做更多的事情。反向逻辑适用于占用较少操作数的指令,占用更少的存储空间,也不那么灵活。

让我们考虑一个例子。假设我们试图将两个数字3和5相加,得到结果8。用于添加的ARM指令如下所示:

add r3, r1, r2

此指令将寄存器r1(3)和r2(5)的内容相加,并将其保存在r3(8)中。然而,x86指令如下所示:

add edx, eax

此处假设edx包含3,eax包含5,执行加法,结果8存储回edx。因此,在这种情况下,x86指令采用2地址格式,因为目标寄存器与第一源寄存器相同。

19.2.1.5 操作数类型

现在让我们看看不同类型的操作数,在汇编语句中指定和访问操作数的方法称为寻址模式。

在汇编语句中指定和访问操作数的方法称为寻址模式(addressing mode)

指定操作数的最简单方法是将其值嵌入指令中,大多数汇编语言允许用户将整数常量的值指定为操作数,这种寻址模式被称为立即寻址模式(immediate addressing mode),此方法对于初始化寄存器或内存位置或执行算术运算非常有用。

一旦必需的常数集被加载到寄存器和内存位置,程序就需要通过对寄存器和内存进行操作来继续,这个空间有几种寻址模式。在介绍它们之前,让我们以寄存器转移符号(register transfer notation)的形式介绍一些额外的术语。

寄存器转移符号

这个符号允许我们指定指令和操作数的语义,让我们看看表示指令基本动作的各种方法:

\[r1 \leftarrow r2 \]

此表达式有两个寄存器操作数r1和r2,r1是目标寄存器,r2是源寄存器,我们正在将寄存器r2的内容转移到寄存器r1。我们可以用一个常数指定一个加法运算,如下所示:

\[r1 \leftarrow r2 + 4 \]

我们还可以使用此符号指定寄存器上的操作,将r2和r3的内容相加,并将结果保存在r1中:

\[r1 \leftarrow r2 + r3 \]

也可以使用此符号表示内存访问:

\[r1 \leftarrow [r2 + 4] \]

上述语句中,内存地址等于寄存器r2的内容加4,然后从该内存地址的内容开始提取整数,并将其保存在寄存器r1中。

操作数的通用寻址模式

让我们将操作数的值表示为V。在随后的讨论中,我们使用了\(V\leftarrow r1\)等表达式,并不意味着我们有一个新的称为V的存储位置,意味着操作数的数值由RHS指定(右侧)。让我们通过示例简要地看一下一些最常用的寻址模式:

  • 立即\(V\leftarrow imm\)。使用常量imm作为操作数的值。
  • 寄存器\(V\leftarrow r1\)。在此寻址模式下,处理器使用寄存器中包含的值作为操作数。
  • 寄存器间接\(V\leftarrow [r1]\)。寄存器保存包含该值的内存位置的地址。
  • 基准偏移量\(V\leftarrow [r1 + offset]\)。offset是一个常数,处理器从r1获取基本内存地址,将常量offset添加到该地址,然后访问新的内存位置以获取操作数的值。offset也称为位移。
  • 基址变址\(V\leftarrow [r1 + r2]\)。r1是基址寄存器,r2是索引寄存器,存储器地址等于(r1+r2)。
  • 基址变址偏移\(V\leftarrow [r1 + r2 + offset]\)。包含该值的内存地址为(r1+r2+offset),其中offset为常数。
  • 内存直接\(V\leftarrow addr\)。该值从地址addr开始包含在内存中,addr是一个常数。在这种情况下,存储器地址直接嵌入指令中。
  • 内存间接\(V\leftarrow [[r1]]\)。该值存在于存储器位置中,其地址包含在内存位置M中,M的地址包含在寄存器r1中。
  • PC相关\(V\leftarrow [PC + offset]\)。offset量是一个常数,内存地址计算为PC+offset,其中PC表示PC中包含的值。此寻址模式对分支指令很有用,注意此处的PC是指程序计数器。

让我们通过考虑基偏移寻址模式来引入一个称为有效内存地址(effective memory address)的新术语。内存地址等于基址寄存器的内容加上偏移量,计算出的内存地址称为有效内存地址。在内存操作数的情况下,我们可以类似地为其他寻址模式定义有效地址。

多种寻址模式。

x86寻址模式计算。

19.2.1.6 常见指令说明

本小节将以ARM为基准,阐述常见的RISC指令用法。

数据传输指令

数据传输指令包含以下几类:

  • LDR和STR

可加载寄存器和存储寄存器,包含32位字、8位无符号字节、半字、无符号字节、双字等。

LDR和STR都有四种可能的形式:零偏移量、预索引偏移、程序相关、后索引偏移。四种形式的语法顺序相同,分别为:

op{cond}{B}{T} Rd, [Rn]
op{cond}{B} Rd, [Rn, FlexOffset]{!}
op{cond}{B} Rd, label
op{cond}{B}{T} Rd, [Rn], FlexOffset

以上是针对32位字或8位无符号字节,如果需要双字则B改成D:

op{cond}D Rd, [Rn]
op{cond}D Rd, [Rn, Offset]{!}
op{cond}D Rd, label
op{cond}D Rd, [Rn], Offset

半字、有符号字节语法如下:

op{cond}        type Rd, [Rn]
op{cond}        type Rd, [Rn, Offset]{!}
op{cond}        type Rd, label
op{cond}        type Rd, [Rn], Offset

示例:

; 示例1
SUB R1, PC, #4 ; R1 = address of following STR instruction
STR PC, [R0]   ; Store address of STR instruction + offset,
LDR R0, [R0]   ; then reload it
SUB R0, R0, R1 ; Calculate the offset as the difference

; 示例2
LDRD    r6,[r11]
LDRMID  r4,[r7],r2
STRD    r4,[r9,#24]
STRD    r0,[r9,-r2]!
LDREQD  r8,abc4

; 示例3
LDREQSH r11,[r6]        ; (conditionally) loads r11 with a 16-bit halfword from the address in r6. Sign extends to 32 bits.
LDRH    r1,[r0,#22]     ; load r1 with a 16 bit halfword from 22 bytes above the address in r0. Zero extend to 32 bits.
STRH    r4,[r0,r1]!     ; store the least significant halfword from r4 to two bytes at an address equal to contents(r0) plus contents(r1). Write address back into r0.
LDRSB   r6,constf       ; load a byte located at label constf. Sign extend.
  • LDM和STM

LDM、STM加载和存储多个寄存器,寄存器r0到r15的任何组合都可以被传送。语法如下:

op{cond}mode Rn{!}, reglist{^}

示例:

LDMIA   r8,{r0,r2,r9}
STMDB   r1!,{r3-r6,r11,r12}
STMFD   r13!,{r0,r4-r7,LR}  ; Push registers including the stack pointer
LDMFD   r13!,{r0,r4-r7,PC}  ; Pop the same registers and return from subroutine
  • PLD

PLD缓存预加载。语法:

PLD [Rn{, FlexOffset}]

示例:

PLD [r2]
PLD [r15,#280]
PLD [r9,#-2481]
PLD [r0,#av*4]  ; av * 4 must evaluate, at assembly time, to an integer in the range -4095 to +4095
PLD [r0,r2]
PLD [r5,r8,LSL 2]
  • SWP

在寄存器和存储器之间交换数据,使用SWP实现信号量。语法:

SWP{cond}{B} Rd, Rm, [Rn]

通用数据处理指令

此类指令又包含以下几种:

  • 灵活第二操作数

大多数ARM通用数据处理指令都有一个灵活的第二操作数,在每条指令的语法描述中显示为Operand2。语法有两种形式:

#immed_8r
Rm{, shift}

示例:

ADD     r3,r7,#1020         ; immed_8r. 1020 is 0xFF rotated right by 30 bits.
AND     r0,r5,r2            ; r2 contains the data for Operand2.
SUB     r11,r12,r3,ASR #5   ; Operand2 is the contents of r3 divided by 32.
MOVS    r4,r4, LSR #32      ; Updates the C flag to r4 bit 31. Clears r4 to 0.
  • ADD、SUB、RSB、ADC、SBC和RSC

此类指令的语法如下:

op{cond}{S} Rd, Rn, Operand2

示例:

ADD     r2,r1,r3
SUBS    r8,r6,#240      ; sets the flags on the result
RSB     r4,r4,#1280     ; subtracts contents of r4 from 1280
ADCHI   r11,r0,r3       ; only executed if C flag set and Z flag clear
RSCLES  r0,r5,r0,LSL r4 ; conditional, flags set
  • AND、ORR、EOR和BIC

逻辑操作,语法如下:

op{cond}{S} Rd, Rn, Operand2

示例:

AND     r9,r2,#0xFF00
ORREQ   r2,r0,r5
EORS    r0,r0,r3,ROR r6
BICNES  r8,r10,r0,RRX
  • MOV、MVN、CMP、CMN、TST、TEQ、CLZ

移动、对比、测试、计数前导零指令,语法如下:

MOV{cond}{S} Rd, Operand2
MVN{cond}{S} Rd, Operand2

CMP{cond} Rn, Operand2
CMN{cond} Rn, Operand2

TST{cond} Rn, Operand2
TEQ{cond} Rn, Operand2

CLZ{cond} Rd, Rm

示例:

MOV     r5,r2
MVNNE   r11,#0xF000000B
MOVS    r0,r0,ASR r3

CMP     r2,r9
CMN     r0,#6400
CMPGT   r13,r7,LSL #2

TST     r0,#0x3F8
TEQEQ   r10,r9
TSTNE   r1,r5,ASR r1

CLZ     r4,r9
CLZNE   r2,r3

算术指令

算术指令包含大量的乘法指令,乘法的指令较多较复杂。常见算术指令如下表所示:

指令 语法 说明
MUL、MLA MUL{cond}{S} Rd, Rm, Rs
MLA{cond}{S} Rd, Rm, Rs, Rn
乘法和乘法累加(32位乘32位,取底部32位结果)
UMULL、UMLAL、SMULL、SMLAL Op{cond}{S} RdLo, RdHi, Rm, Rs 无符号和有符号长乘法和乘法累加(32位乘32位,64位累加或结果)。
SMULxy、SMLAxy、SMULWy、SMLAWy、SMLALxy SMLA{cond} Rd, Rm, Rs, Rn
SMULW{cond} Rd, Rm, Rs
SMLAW{cond} Rd, Rm, Rs, Rn
有符号乘法(16、32位乘16、32位,结果是32位或64位,部分指令有累积)。
MIA、MIAPH、MIAxy MIA{cond} Acc, Rm, Rs
MIA{cond} Acc, Rm, Rs
XScale协处理器0指令。

示例:

MUL     r10,r2,r5
MLA     r10,r2,r1,r5
MULS    r0,r2,r2
MULLT   r2,r3,r2
MLAVCS  r8,r6,r3,r8

UMULL       r0,r4,r5,r6
UMLALS      r4,r5,r3,r8
SMLALLES    r8,r9,r7,r6
SMULLNE     r0,r1,r9,r0 ; Rs can be the same as other registers

SMLAWB      r2,r4,r7,r1
SMLAWTVS    r0,r0,r9,r2

MIA     acc0,r5,r0
MIALE   acc0,r1,r9
MIAPH   acc0,r0,r7
MIAPHNE acc0,r11,r10
MIABB   acc0,r8,r9
MIABT   acc0,r8,r8
MIATB   acc0,r5,r3
MIATT   acc0,r0,r6
MIABTGT acc0,r2,r5

分支指令

分支语句的描述如下表:

指令 语法 说明
B、BL B/BL {cond} label 分支和带链接的分支
BX BX{cond} Rm 分支和交换指令集
BLX BLX{cond} Rm
BLX label
使用Link分支,并可选地交换指令集。本说明有两种可选形式:
1、链接到程序相对地址的无条件分支
2、与寄存器中保存的绝对地址链接的条件分支。

示例:

B       loopA
BLE     ng+8
BL      subC
BLLT    rtX

BX      r7
BXVS    r0

BLX     r2
BLXNE   r0
BLX     thumbsub

条件执行

几乎所有ARM指令都可以包含可选条件代码。这在语法描述中显示为{cond}。只有当CPSR中的条件代码标志满足指定条件时,才执行带有条件代码的指令。可以使用的条件代码如下表所示(部分)。

后缀 标记 含义
EQ Z设置 =
NE Z清除 !=
CS、HS C设置 >=(无符号)
CC、LO C清除 =(无符号)
MI N设置 负数
PL N清除 非负数
VS V设置 溢位
VC V清除 无溢位
HI C设置且Z清除 >(无符号)
LS C清除或Z设置 <=(无符号)
GE N和V一样 >=(有符号)
LT N和V不一样 <(有符号)
GT Z清除且N和V一样 >(有符号)
LE Z设置或N和V不一样 <=(有符号)
AL 任意 总是(通常省略)

几乎所有ARM数据处理指令都可以根据结果选择性地更新条件代码标志。要使指令更新标志,请包含S后缀,如指令的语法描述所示。
有些指令(CMP、CMN、TST和TEQ)不需要S后缀,它们的唯一功能是更新标志,总是更新标志。

标志将保留到更新,未执行的条件指令对标志没有影响,一些指令更新标志的子集,其他标志不受这些指令的影响。详细信息在说明说明中指定。可以根据另一条指令中设置的标志,有条件地执行指令,或者:

  • 紧接在更新标志的指令之后。
  • 在没有更新标志的任何数量的介入指令之后。

示例:

ADD     r0, r1, r2    ; r0 = r1 + r2, don't update flags
ADDS    r0, r1, r2    ; r0 = r1 + r2, and update flags
ADDCSS  r0, r1, r2    ; If C flag set then r0 = r1 + r2, and update flags
CMP     r0, r1        ; update flags based on r0-r1.

其它指令

ARM还有协调处理器、伪指令、杂项指令等其它指令,本文限于篇幅就不接受了。更多详情参见:

19.2.2 二进制位

计算机不像人类那样理解单词或句子,只理解0和1的序列,存储、检索和处理数十亿个0和1非常容易。其次,使用硅晶体管(silicon transistor)实现计算机的现有技术与处理0和1的概念非常兼容。基本硅晶体管是一种开关,可以根据输入将输出设置为逻辑0或1,硅晶体管是我们今天拥有的所有电子计算机的基础,从手机的处理器到超级计算机的处理器。十九世纪末制造的一些早期计算机处理十进制数字,本质上大多是机械的。首先让我们明确定义一些简单的术语:

位(bit):可以有两个值的变量:0或1。

字节(byte):8个位的序列。

19.2.2.1 逻辑操作

二进制变量(0或1)最早由乔治·布尔(George Boole)于1854年描述,他使用这些变量及其相关运算来描述数学意义上的逻辑,他设计了一个完整的代数,由简单的二元变量、一组新的运算符和基本运算组成。为了纪念乔治·布尔,二进制变量也称为布尔变量(Boolean variable),布尔变量的代数系统称为布尔代数(Boolean algebra)。逻辑位操作如下:

  • NOT

逻辑补码(logical complement)称为NOT运算符,任何布尔运算符都可以通过真值表来表示,真值表列出了所有可能的输入组合的运算符输出,NOT运算符的真值表如下表所示。

原始值 NOT操作后
0 1
1 0
  • OR

OR运算符表示任一操作数等于1的事实。例如,如果A=1或B=1,则A或B等于1。OR运算符的真值表如下所示。

A B A OR B
0 0 0
0 1 1
1 0 1
1 1 1
  • AND

AND运算符的操作是所有操作数为1,则结果才为1,其它则为0。例如,当A和B都为1时,A和B等于1。AND运算符的真值表如下所示。

A B A AND B
0 0 0
0 1 0
1 0 0
1 1 1
  • NANDNOR

另外的两个简单的运算符,即NAND和NOR非常有用。NAND是AND的逻辑补码,NOR是OR的逻辑补。它们的真值表如下所示。

A B A NAND B A NOR B
0 0 1 1
0 1 1 0
1 0 1 0
1 1 0 0

NAND和NOR是非常重要的运算符,因为它们被称为通用运算符,我们可以只使用它们来构造任何其他运算符。

  • XOR

XOR是异或运算符,当A和B相等时,值为0,否则为1。真值表如下所示。

A B A XOR B
0 0 0
0 1 1
1 0 1
1 1 0

19.2.2.2 布尔代数

让我们来看看NOT运算符的一些规则:

  • 定义:\(\overline{0}=1, \text { 且 } \overline{1}=0\),即NOT运算符的定义。
  • 双重否定:\(\overline{\overline{A}}=A\),非A的非等于A本身。

OR和AND运算符:

  • 恒等式:A+0=A,A.1=A,亦即如果计算布尔变量A与0的OR或与1的AND,结果等于A。
  • 相消性:A+1=1,A.0=0,亦即如果计算A OR 1,那么结果总是等于1。类似地,A AND 0总是等于0,因为第二个操作数的值决定了最终结果。
  • 幂等性:A+A=A,A.A=A,亦即计算A与自身的OR或AND的结果为A。
  • 互补性:A+\(\overline{A}\)=1,A.\(\overline{A}\)=0,亦即A=1或\(\overline{A}\)=1。在任何一种情况下,A+\(\overline{A}\)都有一个项,它是1,因此结果是1。同样,A.\(\overline{A}\)中的一个项是0,因此结果为0。
  • 交换性: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),亦即可以使用这个定律来打开括号并简化表达式。

我们可以使用这些规则以各种方式操作包含布尔变量的表达式,下面看看布尔代数中的一组基本定理。

  • 摩根定律(De Morgan's Laws)

有两个摩根定律可以通过构造LHS和RHS的真值表来验证。

\[\overline{A+B}=\overline{A} . \overline{B} \\ \overline{A B}=\overline{A}+\overline{B} \]

  • 逻辑门(Logic Gate)

现在让我们尝试实现电路来实现复杂的布尔公式,“逻辑门”定义为实现布尔函数的器件,可以由硅、真空管或任何其他材料制成。

逻辑门是实现布尔函数的设备。

给定一组逻辑门,我们可以设计一个电路来实现任何布尔函数,不同逻辑门的符号如下图所示。

逻辑门列表。

19.2.3 晶体管

硅是周期表中的第14种元素,有四个价电子,虽然与碳和锗属于同一组,但其化学反应性不如后两者。

90%以上的地壳由硅基矿物组成,二氧化硅是沙子和石英的主要成分,它供应充足,而且制造起来相当便宜。硅具有一些有趣的特性,使其成为设计电路和处理器的理想衬底。让我们考虑一下硅的分子结构,它有一个致密的结构,每个硅原子都与其他四个硅原子相连,紧密相连的一组硅原子结合在一起形成一个强晶格,其他材料(尤其是金刚石)具有类似的晶体结构。因此,硅原子比大多数金属更紧密。

由于缺乏自由电子,硅没有很好的导电性能,介于良导体和绝缘体之间,因此被称为半导体(semiconductor)。通过可控的方式添加一些杂质,可以稍微改变其性质,这个过程被称为掺杂(doping)

19.2.3.1 掺杂

通常,向硅中添加两种杂质以改变其特性:n型和p型。n型杂质通常由周期表中的V族元素组成,磷是最常见的n型掺杂剂,偶尔也会使用砷。添加具有价电子的V族掺杂剂的效果是,额外的电子从晶格中分离出来,并可用于传导电流。这种掺杂过程有效地提高了硅的导电性。

同样,可以向硅中添加III族元素,如硼或镓,以产生p型掺杂硅,会产生相反的效果,会在晶格中创建一个空隙,此空隙也称为孔(hole),孔表示没有电子。像电子一样,孔可以自由移动,也有助于传导电流。电子带负电荷,孔在概念上与正电荷相关。

现在我们已经制作了两种半导体材料:n型和p型,下面看看如果连接它们形成p-n结会发生什么。

19.2.3.2 P-N结

让我们考虑一个p-n结,如下图所示。p型区有过量的孔,n型区有过剩的电子。在结处,一些孔交叉并移动到n区,因为它们被电子吸引。类似地,一些电子越过并聚集在p区一侧。孔和电子的这种迁移称为扩散,见证这种迁移的交界处周围的区域被称为耗尽区。然而,由于电子和孔的迁移,在耗尽区中产生了与迁移方向相反的电场,这个电场感应出一种称为漂移电流的电流。在稳态下,漂移电流和扩散电流相互平衡,因此实际上没有电流流过结。

如果将p侧连接到正端子,将n侧连接到负端子,则这种配置称为正向偏置。在这种情况下,孔从结的p侧流向n侧,电子则反向流动。因此,该结传导电流。

如果我们将p侧连接到负端子,将n侧连接到正端子,则这种配置称为反向偏置。在这种情况下,孔和电子被拉离结。因此,没有电流流过结,并且在这种情况下p-n结不导电。所描述的简单p-n结被称为二极管(diode),它只在一个方向传导电流,即当它处于正向偏置时。

二极管(diode)是一种典型地由单个p-n结制成的电子器件,其仅在一个方向上传导电流。

19.2.3.3 NMOS晶体管

现在,让我们将两个p-n结相互连接,如下图(a)所示,这种结构被称为NMOS(负金属氧化物半导体)晶体管。在这张图中,有一个p型掺杂硅的中心衬底。两侧有两个小区域含有n型掺杂硅,这些区域分别被称为漏极和源极。注意,由于结构是完全对称的,这两个区域中的任何一个都可以被指定为源极或漏极,源极和漏极中间的区域称为通道。在沟道的顶部有一个通常由二氧化硅(SiO2)制成的薄绝缘层,它由金属或多晶硅基导电层覆盖,就是所谓的门(gate)

因此,典型的NMOS晶体管有三个端子:源极、漏极和栅极,它们中的每一个都可以连接到电压源。我们现在有两个栅极电压选项——逻辑1(\(V_{dd}\)伏)或逻辑0(0伏)。如果栅极处的电压为逻辑1(Vdd伏),则沟道中的电子被吸引到栅极。事实上,如果栅极处的电压大于某个阈值电压(在当前技术中通常为0.15V),则由于电子的积累,在漏极和源极之间形成低电阻导电路径。因此,电流可以在漏极和源极之间流动。如果沟道的有效电阻是R沟道,那么我们有\(V_{drain}=IR_{channel}+V_{source}\)。如果流经晶体管的电流量低,则由于低沟道电阻(R沟道),\(V_{drain}\)大致等于\(V_{source}\)。因此,我们可以将NMOS晶体管视为开关(见上图b)。当栅极电压为1时,它被打开。

现在,如果我们将栅极电压设置为0,那么由电子组成的导电路径就无法在沟道中形成。因此,晶体管将不能传导电流,将处于o状态。在这种情况下,开关关闭。

NMOS晶体管的电路符号如上图(c)所示。

19.2.3.4 PMOS晶体管

像NMOS晶体管一样,我们可以有一个PMOS晶体管,如下图(a)所示,源极和漏极是由p型硅构成的区域,晶体管操作的逻辑与NMOS晶体管的逻辑完全相反。在这种情况下,如果栅极处于逻辑0,则空穴被吸引到沟道并形成导电路径。然而,如果栅极处于逻辑1,则孔被沟道排斥,不形成导电路径。

PMOS晶体管也可以被视为开关(图b),当栅极电压为0时,它打开,当栅极处的电压为逻辑1时,它关闭。PMOS晶体管的电路符号如图(c)所示。

19.2.3.5 NAND和NOR门

下图显示了如何在CMOS技术中构建NAND门。两个输入端A和B连接到每个NMOS-PMOS对的栅极,如果A和B都等于1,则PMOS晶体管将关断,NMOS晶体管将导通,将输出设置为逻辑0。但是,如果其中一个输入等于0,则其中一个NMOS晶体管将关闭,其中一个PMOS晶体管将打开。因此,输出将设置为逻辑1。

请注意,我们使用AND运算的运算符“.”,这种符号在表示布尔公式时被广泛使用。同样,对于OR运算,使用“+”符号。

下图显示了如何构建NOR门。在这种情况下,两个输入端A和B也连接到每个NMOS-PMOS对的栅极。然而,与NAND门相比,拓扑结构有所不同。如果其中一个输入为逻辑1,则其中一个NMOS晶体管将导通,其中一个PMOS晶体管将截止,输出将设置为0。如果两个输入都等于0,则两个NMOS晶体将截止,两个PMOS晶体将导通,输出将等于逻辑1。

NAND门的一些用途如下:

NOR门的一些用途如下:

19.2.4 组合逻辑电路

19.2.4.1 XOR门

让我们实现异或(XOR)的逻辑函数,使用运算符进行XOR运算,如果两个输入不相等,则异或操作返回1,否则返回0。已知\(A \oplus B=A \cdot \overline{B}+\overline{A} \cdot B\),则真值表和实现异或门的电路如下所示。

基本逻辑门如下所示:

19.2.4.2 多路复用器和信号分离器

多路复用器(Multiplexer)的框图如下图左所示,采用n个输入位和log(n)个选择位,并根据选择位的值,选择一个输入作为输出(参见图中带箭头的线)。多路复用器在处理器设计中大量使用,我们需要从一组输入中选择一个输出。多路复用器也称为多路复用器。

信号分离器将log(n)位二进制数作为输入,1位输入,并将输入传输到n条输出线中的一条,参见下图右。多路分解器用于存储单元的设计,其中输入必须精确地反映在一条输出线中。

左:单个多路复用器结构图。中:4输入的多路复用器。右:信号分离器。

多路复用器输入至程序计数器。

19.2.4.3 编码器和解码器

解码器将log(n)位二进制数作为输入,并具有n个输出。根据输入,它将其中一个输出设置为1。

解码器的设计如下图左所示,具有两个输入和四个输出的2x4解码器的设计。假设输入是A和B。我们生成所有可能的组合:\(\overline{A B}, \overline{A} B, A \overline{B}, AB\)。这些布尔组合是通过计算A和B的逻辑“非”,然后将这些值路由到一组“与”门来生成的。

现在让我们考虑一个与解码器逻辑相反的电路,其框图如下图右所示。该电路有n个输入和log(n)个输出,n个输入中的一个假定为1,其余假定为0,输出位提供等于1的输入二进制编码。例如,在8输入、3输出编码器中,如果第f行等于1,则输出等于100(计数从0开始)。

左:2x4解码器的设计。右:n位编码器框图。

现在我们假设我们不存在只有一个输入行可以等于1的限制,假设有多个输入可以等于1。在这种情况下,我们需要报告具有最高索引(优先级)的输入行的二进制编码。例如,如果是第3行和第5行,那么我们需要报告第5行的二进制编码,和上图右一样。此外,4-2位编码器的电路图如下图所示。

用解码器实现解复用器:

时钟SR锁存器(下图左)和D锁存器(下图右):

J–K锁存器:

基本锁存器的比较:

19.2.5 时序逻辑电路

前面已经研究了在比特上计算不同函数的组合逻辑电路,本小节将讨论如何保存位以供以后使用,这些结构被称为顺序逻辑元件(sequential logic element),因为输出取决于过去的输入,这些输入在事件序列中较早出现。逻辑门的基本思想是修改输入值以获得所需的输出,在组合逻辑电路中,如果输入被设置为0,那么输出也被重置。为了确保电路存储一个值并在处理器通电时保持该值,需要设计一种具有某种“内置存储器”的不同类型的电路。让我们从制定一组要求开始:

1、电路应能自我维持,并在外部输入复位后保持其值。不应依赖外部信号来维持其存储的元件。

2、应该有一种方法来读取存储的值而不破坏它。

3、应该有一种方法将存储值设置为0或1。

确保电路保持其值的最佳方法是创建反馈路径,并将输出连接回输入,先看看最简单的逻辑电路:SR锁存器(SR latch)。

19.2.5.1 SR锁存器

下图显示了SR锁存器。有两个输入S(设置)和R(重置),有两个输出Q及其补码Q,包含了两个交叉耦合NAND门的电路。请注意,如果与非门的一个输入为0,则输出保证为1。然而,如果其中一个输入是1,另一个输入则为a,则输出为a。

用NOR门实现的SR锁存器:

19.2.5.2 时钟和信号

一个典型的处理器包含数百万或可能数十亿个逻辑门和数千个锁存器,不同的电路需要不同的时间,例如多路复用器可能需要1ns,解码器可能需要0.5ns。电路完成计算后,就可以转发输出了。如果没有全局时间的概念,很难在不同的单元之间同步通信,尤其是那些具有可变延迟的单元,导致难以设计、操作和验证处理器。由此需要时间概念,例如可以说加法器需要两个时间单位,在两个单元结束时,预期数据将在锁存器X中找到,其他单元可以在两个时间单元后从锁存器中获取值并继续计算。

考虑一个需要向打印机发送一些数据的处理器的例子。为了传输数据,处理器通过一组铜线发送一系列比特,打印机读取这些比特,然后打印数据。问题是,处理器什么时候发送数据?计算完成后,需要发送数据。我们可以问的下一个问题是,处理器如何知道计算何时结束?它需要知道不同单元的确切延迟,一旦计算的总持续时间过去,可以将输出数据写入锁存器,并设置用于通信的铜线的电压。因此,处理器确实需要时间概念。其次,设计者需要告诉处理器不同子单元所需的时间。与处理2.34ns和1.92ns等数字相比,处理1、2和3等整数要简单得多。这里的1、2、3表示时间单位,时间单位可以是任何数字,例如0.9333ns。

时钟信号(clock signal):发送到大型电路或处理器的每个部分的周期性方波。
时钟周期(clock cycle):时钟信号的周期。
时钟频率(clock frequency):时钟周期的倒数。

因此,大多数数字电路与时钟信号同步,该时钟信号在完全相同的时间向处理器的每个部分发送周期性脉冲。时钟信号为方波,如下图所示,大多数时间,时钟信号是由主板上的专用单元从外部生成的。让我们考虑时钟信号从1转变到0(向下/负边缘)的点作为时钟周期的开始,从时钟的一个向下沿到下一个向下边缘测量时钟周期,时钟周期的持续时间也称为时钟周期,时钟周期的倒数被称为时钟频率。

一个时钟信号。

电脑、笔记本电脑、平板电脑或移动电话通常会在其规格中列出频率。例如,规范可能会说处理器运行在3GHz,这个数字是指时钟频率。

典型的计算模型是:电路中执行所有基本动作所需的时间是按照时钟周期来测量的,如果生产者单元占用n个时钟周期,那么在n个时钟循环结束时,它将其值写入锁存器。其他用户单元知道此延迟,并且在第(n+1)个时钟周期开始时,它们从锁存器读取值。由于所有单元都与时钟明确同步,并且处理器知道每个单元的延迟,因此很容易对计算进行排序、与I/O设备通信、避免竞争条件、调试和验证电路。我们想向打印机发送数据的简单示例可以通过使用时钟轻松解决。

19.2.5.3 时钟SR锁存器

下图显示了SR锁存器,其增加了两个与非门,时钟作为输入之一,另外两个输入分别是S位和R位。如果时钟为0,则交叉耦合NAND门的两个输入都为1,将保持先前的值。如果时钟为1,则交叉耦合NAND门的输入分别为S和R,这些输入与基本SR锁存器相同。请注意,时钟锁存器通常称为触发器(flip-flop)

时钟SR锁存器图例。

触发器(flip-flop)是一个时钟锁存器,可以保存一位(0或1)。

通过使用时钟,我们部分解决了输入和输出同步的问题。在这种情况下,当时钟为0时,输出不受输入的影响。当时钟为1时,输出受输入影响。这种锁存器也称为电平敏感锁存器(level sensitive latch)

电平敏感锁存器(level sensitive latch)取决于时钟信号的值:0或1。通常,它只能在时钟为1时读取新值。

在电平敏感锁存器中,电路有半个时钟周期来计算正确的输出(当时钟为0时)。当时钟为1时,输出可见。最好有一个完整的时钟周期来计算输出,这需要一个边缘敏感锁存器(edge sensitive latch),边缘敏感锁存器仅在时钟的向下边缘反映输出端的输入。

边缘敏感锁存器(edge sensitive latch)仅在固定的时钟边缘(例如向下边缘,从1到0的转换)反映输出端的输入。

19.2.5.4 边缘敏感SR触发器

下图显示了边缘敏感SR触发器的结构图,连接了两个边缘敏感SR触发器,唯一的区别是第二个触发器使用了时钟信号组合。第一个触发器为主(master),而第二个为从(slave)。这种触发器也被称为主-从SR触发器。这就是这个电路的工作原理。

除了主从SR触发器,还有其它各种类型的触发器,如JK触发器、D触发器、主从D触发器等。

从上到下:JK触发器、D触发器、主从D触发器。

19.2.5.5 寄存器

我们可以通过使用一组n个主从D触发器来存储n位数据,每个D触发器连接到输入线,其输出端连接到输出线,这种n位结构被称为n位寄存器。我们可以并行加载n位,也可以在每个负时钟边沿并行读取n位。因此,这种结构被称为并行输入——并行输出寄存器。其结构如下图所示。

现在让我们考虑一个串行输入-并行输出寄存器,如下图所示,有一个输入被馈送到最左边的D触发器, 每个周期,输入都会移动到右侧的相邻触发器。因此,要加载n位将需要n个周期。 第一位将在第一个周期被加载到最左边的触发器中,它需要n个周期才能到达最后一个触发器。 到那时,其余的n - 1触发器将加载其余的n - 1位,然后我们可以并行读取所有 n 位(类似于并行并行输出寄存器)。 该寄存器也称为移位寄存器,用于实现高速I/O总线中使用的电路。

8位并行寄存器的结构图如下:

5位移位寄存器:

行波计数器:

19.2.6 内存

19.2.6.1 静态内存(SRAM)

SRAM是指静态随机存取存储器,基本SRAM单元包含两个交叉耦合的反相器,如下图所示。相比之下,基本SR触发器或D触发器包含交叉耦合的NAND门。设计如下所示。

SRAM单元的核心包含4个晶体管(每个反相器中有2个),这种交叉耦合布置足以节省单个比特(0或1)。然而,我们需要一些额外的电路来读取和写入值。此时,在锁存器中使用交叉耦合反相器到底是不是一个坏主意,它们毕竟需要更少的晶体管。我们将看到,实现用于读取和写入SRAM单元的电路的开销是非常重要的,开销不足以证明以SRAM单元为核心制作锁存器的合理性。

交叉耦合的反相器连接到每一侧(W1、W2)上的晶体管,W1和W2的栅极连接到被称为字线的相同信号,两个反相器W1和W2中的四个晶体管构成SRAM单元,它总共有六个晶体管。现在,如果字线上的电压低,则W1和W2关断,不可能读取或写入SRAM单元。然而,如果字线上的信号为高,则W1和W2导通,可以访问SRAM单元。

下图显示了一个典型的SRAM阵列,SRAM单元被布置为二维矩阵。一行中的所有SRAM单元共享字线,一列中的所有SRAM单元共享一对位线。要激活某个SRAM单元,必须打开其相关的字线,由解码器完成,获取地址位的子集,并打开适当的字线。一行SRAM单元可能包含100多个SRAM单元,通常,我们会对32个SRAM单元(在32位机器上)的值感兴趣。在这种情况下,列复用器/解复用器选择属于感兴趣的SRAM单元的位线,使用地址中的位的子集作为列选择位。这种设计方法也称为2.5D存储器组织。

SRAM单元阵列。

19.2.6.2 内容寻址内存(CAM)

下图是10晶体管CAM单元,如果SRAM单元中存储的值V不等于输入位\(A_i\),那么我们希望将匹配线的值设置为0。在CAM单元中,上半部分是具有6个晶体管的常规SRAM单元,下半部有4个额外的晶体管。现在让我们考虑晶体管T1,它连接到全局匹配线,晶体管T2。T1由存储在SRAM单元中的值V控制,T2由\(\overline{A_i}\)控制。假设V=\(\overline{A_i}\),如果两者都为1,则晶体管T1和T2处于导通状态,并且匹配线和地之间存在直接导电路径。因此,匹配线的值将设置为0。然而,如果V和\(\overline{A_i}\)都为0,则通过T1和T2的路径不导通。但是,在这种情况下,通过T3和T4的路径变得导通,因为这些晶体管的栅极分别连接到\(\overline{V}\)\(A_i\)。两个栅极的输入都是逻辑1,因此匹配线将被下拉到0。读取器可以反过来验证,如果V=\(A_i\),则不形成导通路径。因此,如果存储的值与输入位\(A_i\)不匹配,则CAM单元将匹配线驱动到逻辑0。

10晶体管CAM单元。

下图显示了CAM单元阵列。该结构主要类似于SRAM阵列。我们可以通过索引寻址一行,并执行读/写访问,此外可以将CAM单元的每一行与输入A进行比较。如果任何行与输入匹配,则相应的匹配线的值将为1。可以计算所有匹配线的逻辑OR,并确定CAM阵列中是否匹配,此外可以将CAM阵列的所有匹配线连接到优先级编码器,以查找与数据匹配的行的索引。

CAM单元阵列。

19.2.6.3 动态内存(DRAM)

现在来看看一种只使用一个晶体管来节省一点时间的存储器技术,它非常密集、面积大,而且能效高,但比SRAM和锁存器慢得多,适用于大型片外存储器。

基本DRAM(动态内存)单元如下图所示。单个晶体管的栅极连接到字线,从而启用或禁用它,其中一个端子连接到存储电荷的电容器。如果存储的位是逻辑1,则电容器带电,否则不带电。

一个动态内存的单元。

DRAM和SRAM单元的对比图。

因此,读取和写入值非常容易。我们需要首先设置字线,以便可以访问电容器。为了读取该值,需要感测位线上的电压。如果它处于地电位,则单元存储0,否则如果它接近电源电压,则存储1。类似地,要写入值,我们需要将位线(BL)设置为适当的电压,并设置字线,电容器将相应地充电或放电。

然而,就DRAM而言,并非一切都是免费的。让我们假设电容器被充电到等于电源电压的电压,实际上,电容器将通过电介质和晶体管逐渐泄漏一些电荷。该电流很小,但在长时间内电荷的总损失可能很大,最终会使电容器放电。为了防止这种情况,有必要定期刷新DRAM单元的值,亦即需要读取并写回数据值。这也需要在读取操作之后完成,因为电容器在对位线充电时会损失一些电荷。现在让我们尝试制作一个DRAM单元阵列。

我们可以用创建SRAM单元阵列的方法构建DRAM单元阵列(下图),有三点不同:

  • 存在一条位线而不是两条位线。
  • 有一个连接到位线的专用刷新电路。这在读取操作后使用,也会定期调用。
  • 在这种情况下,感测放大器出现在列复用器/解复用器之前。读出放大器还为整个DRAM行(也称为DRAM页)缓存数据。它们确保对同一DRAM行的后续访问是快速的,因为它们可以直接从读出放大器进行服务。

DRAM单元阵列。

接下来简述现代DRAM的时序方面。在过去的好日子里,DRAM内存是异步访问的,意味着DRAM模块没有做出任何时序保证。但现在每个DRAM操作都与系统时钟同步,因此,如今的DRAM芯片是同步DRAM芯片(SDRAM芯片)。

截至目前,同步DRAM存储器通常使用DDR4或DDR5标准,DDR代表双倍数据速率,使用最早标准DDR1的设备在时钟的上升沿和下降沿向处理器发送8字节的数据包,DDR也被称为双峰(double pump)操作。DDR1的峰值数据速率为1.6 GB/s,后续的DDR世代通过以更高的频率传输数据来扩展DDR1,例如,DDR2的数据速率是DDR1设备的两倍(3.2 GB/s),DDR3通过使用更高的总线频率将峰值传输速率进一步提高了一倍,自2007年开始使用(峰值速率为6.4GB/s)。

19.2.6.4 只读内存(ROM)

只读内存可分为普通ROM和PROM(可编程ROM),下面分别是它们的单元图例。

(a) 存储逻辑0的ROM单元;(b) 存储逻辑1的ROM单元。

PROM单元。

19.2.6.5 可编程逻辑阵列

事实证明,我们可以很容易地用类似于PROM单元的存储单元制作组合逻辑电路,这种器件被称为可编程逻辑阵列或PLA。PLA在实践中用于实现由数十或数百个小项(minterm)组成的复杂逻辑函数,相对于由逻辑门组成的硬连线电路的优势在于它是灵活的,我们可以在运行时更改PLA实现的布尔逻辑,相比之下,由硅制成的电路永远不会改变其逻辑。其次,PLA的设计和编程更简单,而且有很多软件工具可以设计和使用PLA。最后,PLA可以有多个输出,因此可以很容易地实现多个布尔函数。这种额外的灵活性是有代价的,代价是性能。

下图(a)所示的PLA单元原则上类似于基本PROM单元。如果栅极处的值(E)等于1,则NMOS晶体管处于导通状态。因此,NMOS晶体管的源极和漏极端子之间的电压差非常小。换句话说,可以简单地假设结果线的电压等于信号的电压,X. If (E = 0),NMOS晶体管处于截止状态。结果线是浮动的,并保持其预充电电压。在这种情况下,我们建议推断逻辑1。

现在让我们构建一行PLA单元,其中每个PLA单元在其源极端子处连接到输入线,如图(b)所示。输入编号为X1…Xn,所有NMOS晶体管的漏极连接到结果线,PLA单元的晶体管的栅极连接到一组使能信号E1…En。如果任何一个使能信号等于0,则该特定晶体管被禁用,我们可以将其视为从PLA阵列中逻辑移除。

一个PLA单元。

现在让我们创建一个PLA单元格数组,如下图所示,每行对应一个minterm。对于我们的3变量示例,每行由6列组成,每个变量有2列(原始和补充),例如,前两列分别对应于A和\(\overline{A}\)。在任何一行中,这两列中只有一列包含PLA单元,因为A和\(\overline{A}\)不能同时为真。在第一行,计算最小项\(\overline{ABC}\)的值,因此第一行包含对应于\(\overline{A}\)\(\overline{B}\)\(\overline{C}\)的列中的PLA单元。我们在其余行中为剩余的minterm进行类似的连接,PLA阵列的这一部分被称为AND平面,因为我们正在计算变量值(原始值或补码值)的逻辑AND。PLA阵列的AND平面独立于我们希望计算的布尔函数,给定输入,它计算所有可能的最小项的值。

PLA单元阵列。

典型的内存封装引脚和信号。

256 KB内存组织。

1MB内存组织。

DDR代次演进图。

非易失性RAM技术。


上:简化的DRAM读取时序;下:Signetics 7489 SRAM的脉冲串。

19.2.7 计算机算术

本节将设计算术运算的硬件算法,先阐整数运算的算法,如两个二进制数相加的基本算法,有很多方法可以完成这些基本操作,每种方法都有自己的优缺点。注意,二进制减法的问题在概念上与2的补码系统中的二进制加法相同。因此,我们不需要单独对待它。随后,我们将看到,n个数的相加问题与乘法问题密切相关,而且这是一个硬件上的快速操作。遗憾的是,整数除法并不存在非常有效的方法。然而,我们将考虑两种用于划分正二进制数的流行算法。

整数算术之后,我们将研究浮点(带小数点的数字)算术的方法,大多数整数算法稍作修改后都可以移植到浮点数领域。与整数除法相比,浮点除法可以非常有效地完成。

19.2.7.1 加法

让我们看看将两个1位数字a和b相加的问题,a和b都可以取两个值:0或1,因此,a和b有四种可能的组合,它们的二进制和可以是00、01或10。当a和b均为1时,它们的和将是10,两个1位数字的总和可能有两位长。让我们将结果的LSB称为和,将MSB称为进位,例如,把8和9相加,和是7,进位是1。

可以将和和进位的概念扩展到加三个1位数字。如果我们将三个1位数字相加,那么结果的范围是二进制的00到11之间。

和(sum):总和是两个或三个1位数字相加结果的LSB。
进位(carry):进位是两个或三个1位数字相加结果的MSB。

对于可以将两个1位数字相加的加法器,将有两个输出位:和s和进位c,将两个位相加的一个加法器称为半加法器(half adder)。半加法的真值表如下表所示。

a b s c
0 0 0 0
0 1 1 0
1 0 1 0
1 1 0 1

半加法器。

可以加3位的加法器称为全加法器(full adder),它的电子构造如下:

n位加法器被称为纹波进位加法器(ripple carry adder),其设计如下所示:

考虑将两个数字A和B相加的问题,首先将比特集划分为4比特的块,如下图所示,每个块包含一个a片段和一个B片段。通过考虑块的输入进位将这两个片段相加,并生成一组和位和一个进位,此进位是后续块的输入进位。

此外,还有超前进位加法器(Carry Lookahead Adder),其分为两个阶段,每个阶段都拥有复杂的电子构造。

19.2.7.2 乘法

现与加法类似,先看看两个十进制数相乘的最简单的方法,不妨尝试将13乘以9。在这种情况下,13被称为被乘数(multiplicand),9被称为乘数(multiplier),117是乘积(product)

下图(a)显示了十进制的乘法,(b)显示了二进制的乘法。请注意,两个二进制数相乘的方法与十进制数完全相同。我们需要考虑乘数从最小显著位置到最大显著位置的每一位。如果该位为1,那么我们将被乘数的值写在该行下方,否则我们将写0。对于每个乘数位,我们将被乘数向左移动一位。其原因是每个乘数位代表2的更高幂。我们将每个这样的值称为部分和(见图7.10(b))。如果乘法器有m位,那么我们需要将m个部分和相加以获得乘积。在这种情况下,乘积是十进制117,二进制1110101。读者可以验证它们实际上表示相同的数字。为了便于以后的表示,让我们定义另一个称为部分积的术语。它是部分和的连续序列的和。

(a)十进制乘法;(b)二进制乘法。

常规的乘法器是\(O(n^2)\),而改进版的Booth乘法器或Wallace树形乘法器(下图)可以做到\(O(log(n))\)的算法复杂度。

Wallace树形乘数。

19.2.7.3 除法

现在让我们看看整数除法。不幸的是,与加法、减法和乘法不同,除法是一个明显较慢的过程。任何除法运算都可以表示如下:

\[N=DQ+R \]

N是被除数,D是除数,Q是商,R是余数。假定除数和被除数为正,除法过程需要满足以下属性:

  • \(R < D\)\(R \ge 0\)
  • Q是满足上述等式的最大正整数。

如果我们想除掉负数,那么首先将它们转换成正数,进行除法,然后调整商和余数的符号,部分ISA试图确保余数始终为正。在这种情况下,需要将商减1,并将除数与余数相加,使其为正。

实现除法的方式有迭代除法、佘数恢复除法(Restoring Division)、非余数恢复除法(Non-Restoring Division)等。本文忽略这些算法的具体描述,有兴趣的童鞋可以自行查阅资料。

19.2.7.4 浮点数运算

浮点加法和减法的问题实际上是同一问题的不同方面。A-B可以用两种方式解释,可以说正在从A中减去B,也可以说在将-B加到A中。因此,与其单独看减法,不如将其视为加法的特例。浮点数的二进制表示、属性和特殊含义可以参见:17.2.2 浮点数

下图显示了一个示例,说明了如何将有效位解压缩,并将其放入普通浮点数的寄存器中。在32位IEEE 754格式中,尾数有23位,小数点前有0或1。因此,有效位需要24位,如果我们希望添加前导符号位(0),那么我们需要25位存储。让我们把这个号码保存在一个寄存器中,并称之为W。

展开有效位并放入寄存器。

IEEE 754格式。

浮点数的运算涉及舍入等考量,下图显示了两个浮点数相加的算法,考虑了0值。

累加两个浮点值的流程图。

浮点数相乘算法与泛型加法算法的形式完全相同,只需几步。让我们尝试乘以A x B以获得乘积C,乘法的流程图如下图所示。在乘法的情况下,我们不必对齐指数,如下初始化算法,将B的符号和装入寄存器W,W的宽度等于操作数大小的两倍,就可以容纳乘积。E寄存器初始化为\(E_A+E_B - bias\),因为在乘法的情况下,指数相加,减去bias以避免重复计数,计算结果的符号很简单。

两个浮点值相乘的流程图。

此外,还有Goldschmidt除法以及Newton-Raphson除法。

Newton-Raphson方法。


19.3 计算机架构和组织

19.3.1 计算机层级

计算机系统级层次结构是将计算机与用户连接起来并使用计算机的不同级别的组合,还描述了如何在计算机上执行计算活动,并显示了在不同级别的系统中使用的所有元素。通用的计算机系统级层次结构由7个级别组成:

层级 功能 举例 解析
层6 用户 可执行程序 包含用户和可执行程序。
层5 高级语言 C++、Java 高级语言包括 C++、Java、FORTRAN和许多其他语言,是用户发出命令的语言。
层4 汇编语言 汇编代码 汇编语言是计算机系统的下一个层次。机器只理解汇编语言,因此按照顺序,所有高级语言都在汇编语言中进行了更改,汇编代码是为它编写的。
层3 系统软件 操作系统 系统软件种类繁多,主要帮助操作进程,并建立硬件和用户界面之间的连接,可能包括操作系统、库代码等。
层2 机器 指令集架构(ISA) 在计算机系统中使用不同类型的硬件来执行不同类型的活动,包含指令集架构。
层1 控制层 微码(microcode) 控制是系统中使用微码的级别,控制单元包括在这一级别的计算机系统中。
层0 数字逻辑 电路、门 数字逻辑是数字计算的基础,提供了对计算机内电路和硬件如何通信的基本理解,由各种电路和门等组成。

当然,也存在另一种层级划分,从上到下分别是:游戏应用、游戏引擎、图形API、操作系统、设备驱动、硬件设备。

下图是更加详细的层级模块,其中操作系统(OS)处于图形API等第三方SDK和驱动之间,充当着承上启下的重要作用和通讯桥梁,是整个计算机层级架构极其重要的组成部分。

img

在底层,计算机硬件由处理器、内存和I/O组件组成,每种类型有一个或多个模块。这些组件以某种方式互连,以实现计算机的主要功能,即执行程序。有四个主要结构要素:

  • 处理器:控制计算机的操作并执行其数据处理功能。当只有一个处理器时,它通常被称为中央处理单元(CPU)。
  • 主存储器:存储数据和程序。易丢失,当计算机关闭时,内存中的内容会丢失。相反,即使计算机系统关闭,磁盘内存的内容也会保留。主存储器也称为实存储器或主存储器。
  • I/O模块:在计算机及其外部环境之间移动数据外部环境由各种设备组成,包括辅助存储器设备(如磁盘)、通信设备和终端。
  • 系统总线:提供处理器、主存储器和I/O模块之间的通信。

img

19.3.2 架构vs组织

计算机架构(Computer Architecture)是对计算机各个部分的需求和设计实现的功能描述,处理计算机系统的功能行为。在设计计算机时,它出现在计算机组织之前。

计算机组织(Computer Organization)出现在计算机体系架构之后,是操作属性如何链接在一起并有助于实现架构规范的方式,处理的是结构关系。

简单而言,架构是呈现给软件设计师的计算机视图,组织是计算机在硬件上的实际实现。

计算机的层级设计、硬件、软件和架构、组织的关系图。

数字计算机方框图。

计算机体系架构和计算机组织之间的详细区别如下表:

计算机架构 计算机组织
1 描述计算机的功能。 描述计算机是如何做到的。
2 处理计算机的功能行为。 处理计算机的结构关系。
3 在上图,很明显它处理的是高层级的设计问题。 在上图,也很明显它处理的是低层级的设计问题。
4 表明硬件。 表明性能。
5 作为程序员,可以将架构视为一系列指令、寻址模式和寄存器。 架构的实现称为组织。
6 对于设计一台计算机,它的架构是固定的。 为了设计一台计算机,它的组织根据其架构而定。
7 也被称为指令集架构 (ISA)。 通常被称为微体系架构(microarchitecture)。
8 包括逻辑功能,例如指令集、寄存器、数据类型和寻址模式。 由电路设计、外围设备和加法器等物理单元组成。
9 架构类别:冯诺依曼、Harvard、ISA、系统设计。 CPU组织根据地址字段的数量分为三类:单累加器组织、通用寄存器组织、堆栈组织。
10 使计算机的硬件可见。 提供了有关计算机性能的详细信息。
11 协调系统的硬件和软件。 处理系统中的网络段。
12 软件开发人员意识到它。 它逃脱了软件程序员的检测。
13 示例:Intel和AMD创建了x86处理器,Sun Microsystems和其他公司创建了SPARC处理器,Apple、IBM和摩托罗拉创建了PowerPC。 组织质量包括程序员看不到的硬件元素,例如计算机和外围设备的接口、内存技术和控制信号。

19.3.3 冯·诺伊曼架构

历史上有两种类型的计算机:

  • 固定程序计算机:它们的功能非常具体,不能重新编程,例如计算器。
  • 存储程序计算机:可以被编程以执行许多不同的任务,应用程序存储在它们上面,因此得名。

现代计算机基于John Von Neumann(约翰·冯·诺依曼)引入的存储程序概念。在这种存储程序的概念中,程序和数据存储在称为存储器的单独存储单元中,并被同等对待,意味着用这种架构构建的计算机将更容易重新编程。 其基本结构是这样的:

有着输入、处理、输出等概念和组成的计算机模型称为冯·诺伊曼架构,它是一种将程序指令存储器和数据存储器合并在一起的电脑设计概念结构,是一种实现通用图灵机的计算设备,以及一种相对于并行计算的序列式结构参考模型(referential model)。冯·诺伊曼隐式指导了将存储设备与中央处理器分开的概念,也被称为ISA(指令集架构)计算机。

冯·诺依曼1947年出版的《电子计算仪器问题的规划和编码》中的流程图。

冯·诺依曼结构的抽象组成如下:

更进一步地,它约定了用二进制进行计算和存储,还定义计算机基本结构为5个部分,分别是中央处理器(CPU)、内存、输入设备、输出设备、总线

结合上图,各部分结构的具体描述如下:

  • 存储器:代码跟数据在RAM跟ROM中是线性存储, 数据存储的单位是一个二进制位,最小的存储单位是字节。

  • 总线:总线是用于 CPU 和内存以及其他设备之间的通信,总线主要有三种:

    • 地址总线:用于指定 CPU 将要操作的内存地址。

    • 数据总线:用于读写内存的数据。

    • 控制总线:用于发送和接收信号,比如中断、设备复位等信号,CPU收到信号后响应,这时也需要控制总线。

  • 输入/输出设备:输入设备向计算机输入数据,计算机经过计算后,把数据输出给输出设备。比如键盘按键时需要和CPU进行交互,这时就需要用到控制总线。

  • CPU:中央处理器,类比人脑,作为计算机系统的运算和控制核心,是信息处理、程序运行的最终执行单元。它的结构如下所示:

    • 控制单元(CU):处理所有处理器控制信号,指导所有输入和输出流,获取指令代码,并控制数据在系统中的移动方式。
    • 算术逻辑单元 (ALU) :处理CPU可能需要的所有计算(如加法、减法、比较),执行逻辑运算、位移运算和算术运算。
    • 主存储器单元(寄存器):CPU用寄存器存储计算时所需数据,寄存器一般有以下几种:
      • 累加器(Accumulator):存储ALU的计算结果。
      • 程序计数器(PC):跟踪要处理的下一条指令的内存位置,然后PC将下一个地址传递给内存地址寄存器 (MAR)。
      • 内存地址寄存器(MAR):存储需要从内存中取出或存储到内存中的指令的内存位置。
      • 内存数据寄存器(MDR):存储从内存中获取的指令或任何要传输到内存并存储在内存中的数据。
      • 指令寄存器(IR):可分为两种:
        • 当前指令寄存器(CIR):在等待编码和执行时存储最近获取的指令。
        • 指令缓冲寄存器(IBR):不立即执行的指令放在指令缓冲寄存器IBR中。
      • 通用寄存器(GPR):存放需要进行运算的数据,比如需进行加法运算的两个数据。

在冯诺伊曼体系下计算机指令执行的简要过程如下:

  • CPU读取程序计数器获得指令内存地址,CPU控制单元操作地址总线从内存地址拿到数据,数据通过数据总线到达CPU被存入指令寄存器。
  • CPU分析指令寄存器中的指令,如果是计算类型的指令交给逻辑运算单元,如果是存储类型的指令交给控制单元执行。
  • CPU 执行完指令后程序计数器的值通过自增指向下个指令,比如32位CPU会自增4。
  • 自增后开始顺序执行下一条指令,不断循环执行直到程序结束。

冯诺依曼瓶颈(Von Neumann bottleneck)是无论做什么来提升性能,都无法摆脱这样一个事实,即一次只能执行一条指令,并且只能按顺序执行,这两个因素都阻碍了CPU的能力。我们可以为冯诺依曼处理器提供更多缓存、更多RAM或更快的组件,但如果要在CPU性能方面取得原始收益,则需要对CPU配置进行有影响力的检查。 这种架构非常重要,用于PC乃至超级计算机。

19.3.4 多核结构

下图是典型多核计算机主要部件的简化视图。大多数计算机,包括智能手机和平板电脑中的嵌入式计算机,以及个人计算机、笔记本电脑和工作站,都安装在主板上。印刷电路板(PCB)是一种刚性的平板,用于固定和互连芯片和其他电子部件,该电路板由通常为两到十层的层组成,这些层通过蚀刻到电路板中的铜路径将组件互连。计算机中的主要印刷电路板称为系统板或主板,而插入主板插槽的较小的印刷电路板则称为扩展板。主板上最突出的元素是芯片,芯片是一块半导体材料,通常是硅,在其上制造电子电路和逻辑门,所得产品称为集成电路。

多核计算机主要元件的简化视图。

下图左是IBM zEnterprise EC12大型计算机处理器芯片的照片,有27.5亿个晶体管,有六个内核(处理器),还有两个标记为L3缓存的大区域,由所有六个处理器共享,L3控制逻辑控制L3高速缓存和内核之间以及L3高速缓存与外部环境之间的流量。此外,在核心和L3缓存之间还有存储控制(SC)逻辑,内存控制器(MC)功能控制对芯片外部内存的访问,GX I/O总线控制访问I/O的通道适配器的接口。下图右则展示了单个核的内部结构,只是构成单个处理器芯片的硅表面区域的一部分。

晶片(Wafer)、芯片(Chip)和门(Gate)之间的关系如下:

QPI(QuickPath Interconnect)是Intel于2008年推出的点对点互连方法,QPI和其他点对点互连方案的重要特征是多个直接连接、分层协议架构和分组数据传输。

下图说明了QPI在多核计算机上的典型使用。QPI链路(由图中的绿色箭头对表示)形成了一个交换结构,使数据能够在整个网络中移动,可以在每对核心处理器之间建立直接QPI连接。如果图中的核心A需要访问核心D中的内存控制器,则它通过核心B或C发送请求,后者必须将该请求转发到核心D的内存控制器。同样,具有八个或更多处理器的大型系统可以使用具有三个链路的处理器构建,并通过中间处理器路由流量。

使用QPI的多核配置。

QPI层示意图。

不同的芯片组织。

多核组织备选方案。

19.3.5 嵌入式系统

术语嵌入式系统是指在产品中使用电子设备和软件,而不是通用计算机,如平板电脑或台式机系统。每年售出数百万台电脑,包括笔记本电脑、个人电脑、工作站、服务器、大型机和超级计算机,相比之下,每年生产数十亿个嵌入大型设备的计算机系统。如今,许多(也许是大多数)使用电力的设备都有嵌入式计算系统,在不久的将来,几乎所有这样的设备都将具有嵌入式计算系统。

具有嵌入式系统的设备类型几乎太多,无法列出,样例包括手机、数码相机、摄像机、计算器、微波炉、家庭安全系统、洗衣机、照明系统、恒温器、打印机、各种汽车系统(如变速器控制、巡航控制、燃油喷射、防抱死制动和悬挂系统)、网球拍、牙刷以及自动化系统中的多种类型的传感器和致动器。

通常,嵌入式系统与其环境紧密耦合,导致与环境交互的需要所施加的实时约束。约束条件(如所需的运动速度、所需的测量精度和所需的持续时间)决定了软件操作的时间,如果必须同时管理多个活动,会带来更复杂的实时约束。

下图概括地显示了嵌入式系统组织,除了处理器和内存之外,还有许多元素与典型的台式机或笔记本电脑不同:

嵌入式系统的可能组织。

  • 可能存在多种接口,使系统能够测量、操作和以其他方式与外部环境交互。嵌入式系统通常通过传感器和致动器与外部世界交互(感知、操纵和通信),因此通常是反应系统,反应系统与环境持续交互,并以该环境确定的速度执行。
  • 人机界面可以像闪光灯一样简单,也可以像实时机器人视觉一样复杂。在许多情况下,没有人机界面。
  • 诊断端口可用于诊断所控制的系统,而不仅仅用于诊断计算机。
  • 可使用专用现场可编程(FPGA)、专用(ASIC)或甚至非数字硬件来提高性能或可靠性。
  • 软件通常具有固定的功能,并且特定于应用程序。
  • 效率对于嵌入式系统至关重要。需针对能量、代码尺寸、执行时间、重量、体积以及成本进行了优化。

与通用计算机系统也有几个值得注意的相似之处:

  • 即使使用名义上固定功能的软件,现场升级以修复错误、提高安全性和添加功能的能力对于嵌入式系统来说也变得非常重要,而不仅仅是在消费类设备中。
  • 一个相对较新的发展是支持多种应用的嵌入式系统平台,良好例子是智能手机和音频/视频设备(如智能电视)。

19.3.5.1 微处理器与微控制器

早期的微处理器芯片包括寄存器、ALU和某种控制单元或指令处理逻辑。随着晶体管密度的增加,有可能增加指令集架构的复杂性,最终增加内存和多个处理器,现代微处理器芯片包括多个内核和大量的高速缓存。

微控制器芯片对可用的逻辑空间进行了实质上不同的使用,下图概括地显示了微控制器芯片上常见的元件。微控制器是包含处理器、用于程序的非易失性内存(ROM)、用于输入和输出的易失性内存(RAM)、时钟和I/O控制单元的单个芯片。微控制器的处理器部分具有比其他微处理器低得多的硅面积和高得多的能量效率。

也被称为“芯片上的计算机”,每年数十亿个微控制器单元被嵌入到从玩具到家电到汽车的各种产品中,比如单个车辆可以使用70个或更多个微控制器。通常,特别是对于更小、更便宜的微控制器,它们被用作特定任务的专用处理器,比如微控制器在自动化过程中被大量使用。通过提供对输入的简单反应,它们可以控制机器、打开和关闭风扇、打开和闭合阀门等,是现代工业技术的组成部分,是生产能够处理极其复杂功能的机械的最廉价的方法之一。

微控制器具有多种物理尺寸和处理能力,处理器的范围从4位到32位架构。微控制器往往比微处理器慢得多,通常工作在MHz范围,而不是微处理器的GHz速度。微控制器的另一个典型特征是它不提供人机交互,被编程用于特定任务,嵌入其设备中,并在需要时执行。

19.3.5.2 嵌入式与深度嵌入式系统

嵌入式系统的一个子集,以及相当多的子集,被称为深度嵌入式系统(Deeply embedded system)。尽管这个术语在技术和商业文献中被广泛使用,但你会在互联网上无法明确地寻找一个直截了当的定义。通常,我们可以说,一个深度嵌入式系统有一个处理器,其行为很难被程序员和用户观察到。深度嵌入式系统使用微控制器而不是微处理器,一旦设备的程序逻辑被烧录到ROM(只读存储器)中,就不可编程,并且与用户没有交互。

深度嵌入式系统是专用的、单用途的设备,可以检测环境中的某些东西,执行基本级别的处理,然后对结果进行处理。深度嵌入式系统通常具有无线能力,并以联网配置出现,例如部署在大面积(例如,工厂、农业领域)上的传感器网络,物联网在很大程度上依赖于深度嵌入式系统。典型地,深度嵌入式系统在内存、处理器大小、时间和功耗方面具有极端的资源限制。

19.3.6 ARM架构

ARM架构是指从RISC设计原则演变而来的处理器架构,用于嵌入式系统。本节将简述之。

ARM指令集是高度规则的,旨在高效实现处理器和高效执行。所有指令均为32位长,遵循常规格式,使得ARM ISA适合在广泛的产品上实现。

增强基本ARM ISA的是Thumb指令集,是ARM指令集的重新编码子集。Thumb旨在提高使用16位或更窄内存数据总线的ARM实现的性能,并允许比ARM指令集提供的代码密度更好的代码密度。Thumb指令集包含记录为16位指令的ARM 32位指令集的子集。前些年定义的版本是Thumb-2。

ARM Holdings许可了许多专用微处理器和相关技术,但其产品线的大部分是Cortex系列微处理器架构。有三种Cortex架构,方便地用缩写A、R和M标记。

  • Cortex-A和Cortex-A50:是应用处理器,适用于智能手机和电子书阅读器等移动设备,以及数字电视和家庭网关(如DSL和有线互联网调制解调器)等消费设备。这些处理器以更高的时钟频率(超过1GHz)运行,并支持内存管理单元(MMU),是全功能操作系统(如Linux、Android、MS Windows和移动操作系统)所需的。MMU是通过将虚拟地址转换为物理地址来支持虚拟内存和分页的硬件模块。这两种架构同时使用ARM和Thumb-2指令集,主要区别在于Cortex-A是32位机器,而Cortex-A50是64位机器。

  • Cortex-R:设计用于支持实时应用程序,其中需要通过对事件的快速响应来控制事件的定时。它们可以在相当高的时钟频率(例如200MHz到800MHz)下运行,并且具有非常低的响应延迟。Cortex-R包括对指令集和处理器组织的增强,以支持深度嵌入式实时设备。这些处理器中的大多数没有MMU,有限的数据需求和有限数量的同时处理消除了对虚拟内存的复杂硬件和软件支持的需求。Cortex-R确实具有专为工业应用设计的内存保护单元(MPU)、缓存和其他内存功能。MPU是一种硬件模块,它禁止内存中的一个程序意外访问分配给另一个活动程序的内存。使用各种方法,在程序周围创建一个保护边界,并且禁止程序内的指令引用该边界之外的数据。使用Cortex-R的嵌入式系统包括汽车制动系统、大容量存储控制器、网络和打印设备。

  • Cortex-M:主要是为微控制器领域开发的,在微控制器领域,快速、高确定性中断管理的需求与极低门计数和最低可能功耗的需求相结合。与Cortex-R系列一样,Cortex-M架构有一个MPU,但没有MMU。Cortex-M仅使用Thumb-2指令集,其市场包括物联网设备、工厂和其他企业使用的无线传感器/致动器网络、汽车车身电子设备等。Cortex-M系列包含Cortex-M0、Cortex-M0+、Cortex-M3、Cortex-M4等版本。下图是基于Cortex-M3的典型单片机芯片:

19.3.7 云计算

尽管云计算的一般概念可以追溯到20世纪50年代,但云计算服务在2000年代初首次出现,尤其是针对大型企业。从那时起,云计算已经扩展到中小型企业,最近还扩展到了消费者。苹果的iCloud于2012年推出,在推出一周内就拥有2000万用户,2008年推出的基于云的笔记和归档服务Evernote在不到6年的时间内就接近了1亿用户。本节将简要概述。

在许多组织中,越来越突出的趋势是将大部分甚至所有信息技术(IT)运营转移到称为企业云计算的互联网连接基础设施。与此同时,个人电脑和移动设备的个人用户越来越依赖云计算服务来备份数据、同步设备和使用个人云计算进行共享。NIST在NIST SP-800-145(NIST云计算定义)中对云计算的定义如下:

云计算(Cloud computing):是一种模型,用于实现对可配置计算资源(例如,网络、服务器、存储、应用程序和服务)的共享池的无处不在、方便的按需网络访问,这些资源可以通过最小的管理工作量或服务提供商交互快速调配和发布。

基本上,通过云计算,可以获得规模经济、专业网络管理和专业安全管理,这些功能对大小公司、政府机构以及个人电脑和移动用户都有吸引力。个人或公司只需支付所需的存储容量和服务费用,无论是公司还是个人,用户都无需设置数据库系统、获取所需的硬件、进行维护和备份数据,所有这些都是云服务的一部分。

理论上,使用云计算存储数据并与其他人共享数据的另一大优势是云提供商负责安全。客户并不总是受到保护,云提供商之间出现了许多安全故障,例如Evernote在2013年初成为头条新闻,当时它告诉所有用户在发现入侵后重置密码。

云网络是指必须具备的网络和网络管理功能,以支持云计算。大多数云计算解决方案都依赖于互联网,但这只是网络基础设施的一部分。云网络的一个示例是在提供商和订户之间提供高性能和/或高可靠性网络,在这种情况下,企业和云之间的部分或全部流量绕过互联网,使用云服务提供商拥有或租用的专用专用网络设施。更一般地说,云联网是指访问云所需的网络能力的集合,包括利用互联网上的专门服务、将企业数据中心链接到云,以及在关键点使用防火墙和其他网络安全设备来强制执行访问安全政策。

我们可以将云存储视为云计算的一个子集,本质上,云存储由远程托管在云服务器上的数据库存储和数据库应用程序组成,使小型企业和个人用户能够利用可根据其需求扩展的数据存储,并利用各种数据库应用程序,而无需购买、维护和管理存储资产。

云计算的基本目的是提供方便的计算资源租赁,云服务提供商(CSP)维护通过互联网或专用网络可用的计算和数据存储资源,客户可以根据需要租用这些资源的一部分。实际上,所有云服务都是使用三种模型之一提供的(下图):SaaS、PaaS和IaaS。

替代信息技术架构。

云计算元素。

云服务模型。


19.4 ISA

19.4.1 ISA定义

就像任何语言都有有限的单词一样,处理器可以支持的基本指令/基本命令的数量也必须是有限的,这组指令通常称为指令集(instruction set),基本指令的一些示例是加法、减法、乘法、逻辑或和逻辑非。请注意,每条指令需要处理一组变量和常量,最后将结果保存在变量中,这些变量不是程序员定义的变量,是计算机内的内部位置。我们将指令集架构定义为:

指令集架构(instruction set architecture,ISA)是处理器支持的所有指令的语义,包括指令本身及其操作数的语义,以及与外围设备的接口。

指令集架构是软件感知硬件的方式,我们可以将其视为硬件输出到外部世界的基本功能列表。Intel和AMD CPU使用x86指令集,IBM处理器使用PowerPC R指令集,HP处理器使用PA-RISC指令集,ARM处理器使用ARMR指令集(或其变体,如Thumb-1和Thumb-2)。因此,不可能在基于ARM的系统上运行为Intel系统编译的二进制文件,因为指令集不兼容,但在大多数情况下,可以重用C/C++程序。要在特定架构上运行C/C++程序,我们需要为该特定架构购买一个编译器,然后适当地编译C/C++程序。

19.4.2 基础指令

基本计算机具有16位指令寄存器 (IR),可以表示内存引用或寄存器引用或输入输出指令。一种简单的指令格式可以是如下形式:

基础指令可分为以下几类:

  • 内存引用

这些指令将内存地址称为操作数,另一个操作数总是累加器。下图为直接和间接寻址指定12位地址、3位操作码(111除外)和1位寻址模式。

示例:IR寄存器内容是0001XXXXXXXXXXXX,即ADD指令取指译码后发现是ADD操作的内存引用指令,因此:

DR ← M[AR]
AC ← AC + DR, SC ← 0
  • 寄存器引用

这些指令对寄存器而不是内存地址执行操作。下图的IR(14 – 12) 为 111(将其与内存引用区分开),IR(15) 为 0(将其与输入/输出指令区分开),其余12位指定寄存器操作。

示例:IR寄存器内容是0111001000000000,即CMA在取指和解码周期后发现它是补码累加器的寄存器引用指令,因此:

AC ← ~AC
  • 输入/输出

这些指令用于计算机和外部环境之间的通信。下图的IR(14 – 12) 为 111(将其与内存引用区分开来),IR(15) 为 1(将其与寄存器引用指令区分开),其余 12 位指定 I/O 操作。

示例:IR寄存器内容是1111100000000000,即INP经过取指和解码循环后发现它是用于输入字符的输入/输出指令。因此,来自外围设备的INPUT字符。


包含在16位IR寄存器中的指令集是:

  • 算术、逻辑和移位指令(与、加、补、左循环、右循环等)。
  • 将信息移入和移出内存(存储累加器,加载累加器)。
  • 带有状态条件的程序控制指令(分支、跳过)。
  • 输入输出指令(输入字符、输出字符)。

指令具体的描述如下表:

符号 16进制码 描述
AND 0xxx、8xxx 与任意字到AC
ADD 1xxx、9xxx 累加任意字到AC
LDA 2xxx、Axxx 加载内存字到AC
STA 3xxx、Bxxx 存储AC字到内存
BUN 4xxx、Cxxx 无条件分支
BSA 5xxx、Dxxx 分支并保存返回地址
ISZ 6xxx、Exxx 如果为0,则递增并跳过
CLA 7800 清理AC
CLE 7400 清除E(溢出位)
CMA 7200 补充AC
CME 7100 补充E
CIR 7080 右循环AC和E
CIL 7040 左循环AC和E
INC 7020 递增AC
SPA 7010 如果AC>0,跳过下一条指令
SNA 7008 如果AC<0,跳过下一条指令
SZA 7004 如果AC=0,跳过下一条指令
SZE 7002 如果E=0,跳过下一条指令
HLT 7001 停止计算机
INP F800 输入字符到AC
OUT F400 输出字符到AC
SKI F200 跳过输入标志
SKO F100 跳过输出标志
ION F080 中断开启
IOF F040 中断关闭

19.4.3 指令集设计准则

现在让我们开始为处理器设计指令集的艰难过程,可以将指令集视为软件和硬件之间的法律合同,双方都需要履行各自的合同。软件部分需要确保用户编写的所有程序都能成功有效地转译成基本指令,同样,硬件需要确保指令集中的所有指令都是有效实现的。双方都需要做出合理的假设,ISA需要具有一些必要的特性和一些有效性所需的特性。

  • 完整。ISA应能够实现所有用户程序,是绝对必要的要求,我们希望ISA能够代表用户为其编写的所有程序。例如,如果我们有一个ISA,只有一条ADD指令,那么我们将无法减去两个数字。为了实现循环,ISA应该有一些方法来一遍遍地重新执行同一段代码。如果没有这种对和while循环的支持,C程序中的循环将无法工作。

    请注意,对于通用处理器,我们正在查看所有可能的程序。然而,许多用于嵌入式设备的处理器功能有限,例如执行字符串处理的简单处理器不需要支持模拟点数(带小数点的数字)。我们需要注意的是,不同的处理器被设计用于做不同的事情,因此它们的ISA可能不同。然而,底线是任何ISA都应该是完整的,因为它应该能够用机器代码表达用户打算为其编写的所有程序。

  • 简明。指令集的有限大小,最好不要有太多的指示。实现一条指令需要相当多的硬件,执行大量指令将不必要地增加处理器中晶体管的数量并增加其复杂性。因此,大多数指令集都有64到1000条指令。例如,MIPS指令集包含64条指令,而截至2012年,Intel x86指令集大约有1000条指令。请注意,对于ISA中的指令数量,1000条被认为是相当大的数字。

  • 通用。指令应捕获通用案例,程序中的大多数常见指令都是简单的算术指令,如加法、减法、乘法、除法。最常见的逻辑指令是逻辑和、或、异或、和非。因此,为这些常见操作中的每一个指定一条指令是有意义的。

    很少使用的计算的指令不是一个好主意。例如,实现计算\(\sin^{-1}(x)\)的指令可能没有意义,可以提供使用现有的数学技术(如泰勒级数展开)实现的专用库函数来计算\(\sin^{-1}(x)\)。由于大多数程序很少使用此函数,因此如果此函数执行时间相对较长,它们不会受到不利影响。

  • 简单。指令应该尽量简单。假设有很多添加数字序列的程序,为了设计专门针对此类程序定制的处理器,我们有几个关于add指令的选项。我们可以实现一条将两个数字相加的指令,也可以实现一个可以获取操作数列表并生成列表和的指令。这里的复杂性显然存在差异,不能说哪种实现更快。前一种方法要求编译器生成更多指令,但是,每个添加操作都执行得很快。后一种方法生成的指令数量更少,但是,每条指令执行的时间更长。前一种类型的ISA称为精简指令集(Reduced Instruction Set),后一种ISA称为复杂指令集(Complex Instruction Set)

精简指令集计算机(reduced instruction set computer,RISC)实现具有简单规则结构的简单指令,指令的数量通常很小(64到128)。示例:ARM、IBM PowerPC、HP PA-RISC。

复杂指令集计算机(complex instruction set computer,CISC)实现高度不规则的复杂指令,采用多个操作数,并实现复杂功能。其次,指令的数量很大(通常为500+)。示例:Intel x86、VAX。

直到90年代末,RISC与CISC的争论一直是一个非常有争议的问题。然而,从那时起,设计师、程序员和处理器供应商一直倾向于RISC设计风格,共识似乎是采用少量相对简单的、具有规则结构和格式的指令。值得注意的是,这一点仍有争议,因为CISC指令有时更适合某些类型的应用。现代处理器通常使用混合方法,其中既有简单的指令,也有一些复杂的指令。然而,在底层,CISC指令被转译成RISC指令。因此,我们认为行业稍微偏向RISC指令,认为有简单的指示是一种可取的特性。

ISA需要完整、简洁、通用和简单,且必须完整,而其余属性是可取的(但附有争议)。

19.4.4 图灵机和指令完整性

如何验证ISA的完整性?这是一个非常有趣、困难且理论上深刻的问题。确定给定ISA对于给定程序集是否完整的问题是一个相当困难的问题,一般情况要有趣得多。我们需要回答这个问题:给定ISA,它能代表所有可能的程序吗?

假设有一个ISA,其中包含基本的加法和乘法指令,我们能用这个ISA运行所有可能的程序吗?答案是否定的,因为我们不能用现有的基本指令减去两个数字。如果我们将减法指令添加到指令库中,我们可以计算一个数的平方根吗?即使我们可以,是否可以保证我们可以进行所有类型的计算?要回答这些令人烦恼的问题,我们需要首先设计一台通用机器。

通用机器(universal machine)是可以执行任何程序的机器。

它是一台可以执行所有程序的机器,可以把这台机器的每一个基本动作都当作一条指令。通用机器的一组动作就是它的ISA,而这个ISA是完整的。当说ISA是完整的时,相当于说可以专门基于给定的ISA构建通用机器,可以通过解决通用机器的设计问题来解决ISA的完整性问题。它们是双重问题,就通用机器而言,推理更容易。

20世纪初,计算机科学家开始思考通用机器的设计,他们想知道什么是可计算的,什么不是,以及不同类别机器的能力。其次,能够计算所有可能程序结果的理论机器的形式是什么?计算机科学的这些基本结果构成了当今现代计算机体系结构的基础。

阿兰·图灵(Alan Turing)是第一个提出一种极其简单和强大的通用机器的人,这台机器恰如其分地以他的名字命名,被称为图灵机器(Turing machine)。这只是一个理论实体,通常用作数学推理工具,可以创建图灵机的硬件实现,然而极为困难,并且需要不成比例的资源。尽管如此,图灵机构成了当今计算机的基础,而现代ISA是从图灵机的基本动作中派生出来的。因此,非常有必要研究它的设计。

下图显示了图灵机的一般结构,它包含一个内部磁带,磁带是一个单元阵列,每个单元格可以包含有限字母表中的符号,有一个特殊符号$用作特殊标记,一个专用的磁带头指向磁带中的一个单元。在一组状态中,有一小块存储器可以保存当前状态,该存储元件称为状态寄存器。

图灵机的操作非常简单。在每一步中,磁带头从状态寄存器中读取当前单元中的符号及其当前状态,并查找一个表,该表包含每个符号和状态组合的操作集,这个专用表称为转换函数表或动作表。这个表中的每个条目都说明了三件事——是否将磁带头向左或向右移动一步、下一个状态及应写入当前单元格的符号。因此,在每一步中,磁带头都可以覆盖单元格的值,改变状态寄存器中的状态,并移动到新单元格。唯一的限制是新单元格必须位于当前单元格的最左边或最右边。形式上,它的格式为:

\[(state,\ symbol) \rightarrow (\{L, R\},\ new\_state,\ new\_symbol) \]

其中\(L\)代表左边,\(R\)代表右边。

示例:设计一个图灵机来判断字符串的形式是否为aaa...abb...bb。答案:让我们定义两个状态\(\left(S_{a}, S_{b}\right)\)和两个特殊状态——exit和error。如果状态等于退出或错误,则计算停止。图灵机可以开始从右向左扫描输入,开始于状态\((S_b)\)。动作表如下:

\[\begin{array}{||l||} \hline \hline\left(S_{b}, b\right) \rightarrow\left(L, S_{b}, b\right) \\ \hline\left(S_{b}, a\right) \rightarrow\left(L, S_{a}, a\right) \\ \hline\left(S_{b}, \$\right) \rightarrow(L, \text { error }, \$) \\ \hline\left(S_{a}, b\right) \rightarrow(L, \text { error }, b) \\ \hline\left(S_{a}, a\right) \rightarrow\left(L, S_{a}, a\right) \\ \hline\left(S_{a}, \$\right) \rightarrow(L, \text { exit}, \$) \\ \hline \hline \end{array} \]

以上只是图灵机的简单应用案例,但实际场景中,复杂程度远远不止于此。我们可以立即得出结论,为即使是简单的问题设计图灵机也是不可能的。因为动作表会包含很多状态,并且很快就会超出大小,但基线是可以用这个简单的设备解决复杂的问题。事实上,这台机器可以解决各种问题,如天气建模、金融计算和微分方程的求解!

Church-Turing论文捕捉到了这一观察结果,该论文说,任何物理计算设备都可以计算的所有函数都可以由图灵机计算。用外行的话说,任何可以在人类已知的任何计算机上用确定性算法计算的程序,也可以用图灵机计算。

这篇论文在过去的半个世纪里一直坚定不移。到目前为止,研究人员还无法找到比图灵机器更强大的机器,意味着没有程序可以由图灵机之外的另一种机器计算。有一些程序可能需要很长时间才能在图灵机上进行计算,但它们也会占用所有其他计算机上的无限时间。我们可以用所有可能的方式扩展图灵机,可以考虑多个磁带、多个磁带头或每个磁带中的多个磁道。可以看出,这些机器中的每一个都像一个简单的图灵机一样强大。

上面描述的图灵机不是通用机器,因为它包含一个动作表,该动作表特定于机器正在计算的函数。一个真正的通用机器将具有相同的动作表、符号以及每个功能的相同状态集。如果我们能设计一个能模拟另一个图灵机的图灵机,我们就能制造一个通用图灵机——通用且不会特定于正在计算的函数。

让被模拟的图灵机被称为M,通用图灵机则被称为U。让我们首先为M的动作表创建一个通用格式,并将其保存在U磁带上的指定位置,每个动作都需要5个参数——旧状态、旧符号、方向(左或右)、新状态、新符号。我们可以使用一组常见的基本符号,可以是10位十进制数字(0-9),如果一个函数需要更多的符号,那么我们可以考虑将一个符号包含在一组由特殊分隔符划分的连续单元中。让这样的符号称为模拟符号。同样,模拟动作表中的状态也可以编码为十进制数。对于方向,我们可以使用0表示左侧,1表示右侧。因此,单个动作表条目可能看起来像(@1334@34@0@1335@10@),其中“@”是分隔符,该条目表示,如果遇到符号34,我们将从状态1334移动到1335。我们向左移动(0),并写一个值10。因此,我们找到了一种对用于计算某个函数的图灵机的动作表、符号集和状态进行编码的方法。

类似地,我们可以指定磁带的一个区域来包含M的状态寄存器,称之为模拟状态寄存器。让M的磁带在U的磁带中有一个专用的空间,我们把这个空间称为工作区(work area)。这种组织如下图所示。

通用图灵机的布局。

磁带因此分为三部分,第一部分包含模拟动作表,第二部分包含模拟状态寄存器,最后一部分包含包含一组模拟符号的工作区。通用图灵机(U)有一个非常简单的动作表和一组状态,其思想是在模拟动作表中查找与模拟状态寄存器中的值和磁带头下的模拟符号相匹配的正确条目。然后,通用图灵机需要通过移动到新的模拟状态来执行相应的动作,并在需要时覆盖工作区中的模拟符号。为了做每一个基本动作,U需要做几十次磁带头运动。然而,结论是我们可以构造一个通用的图灵机。

可以构造一个通用的图灵机,它可以模拟任何其他的图灵机器。

自20世纪50年代以来,研究人员设计了更多类型的具有自己的状态和规则集的假想机器,这些机器中的每一台都已被证明至多与图灵机一样强大。所有机器和计算系统都有一个通用名称,它们都像图灵机一样具有表达力和功能。这种系统可以说是图灵完整的(Turing complete)。因此,任何通用机器和ISA都是图灵完整的。

任何等同于图灵机的计算系统都被称为图灵机。

因此,如果ISA是图灵完整的,我们需要证明ISA是完整的或通用的。

现在考虑一个更适合实际实现的通用图灵机的变体(下图),让它具有以下特性。请注意,这样的机器已经被证明是图灵完整的。

一种改进的通用图灵机

1、磁带为半无限(semi-infinite,仅在一个方向上延伸至无限)。

2、模拟状态是指向模拟动作表中的条目的指针。

3、每个状态的模拟动作表中有一个唯一的条目。在查找模拟动作表时,我们不关心磁带头下的符号。

4、一个动作指示磁带头访问工作区中的一组位置,并根据它们的值使用简单的算术函数计算一个新值。它将此新值写入工作区中的新位置。

5、默认的下一个状态是动作表中的后续状态。

6、如果磁带上某个位置的符号小于某个值,动作也可以任意改变状态,意味着模拟磁带头将开始从模拟动作表中的新区域提取动作。

这台图灵机建议采用以下形式的机器组织。有大量指令(动作表),这个指令数组通常被称为程序。有一个状态寄存器,用于维护指向数组中当前指令的指针,称为程序计数器,可以更改程序计数器以指向新指令。有一个大的工作区,可以存储、检索和修改符号,此工作区也称为数据区。指令表(程序)和工作区(数据)保存在我们改进的图灵机的磁带上。在实际的机器中,有限磁带可被看作内存。存储器是一个大的存储单元阵列,其中存储单元包含一个基本符号。内存的一部分包含程序,另一部分包含数据。

此外,每条指令都可以读取内存中的一组位置,计算它们上的一个小算术函数,并将结果写回内存,还可以根据内存中的值跳转到任何其他指令。有一个专用单元来计算这些算术函数,写入内存,并跳转到其他指令,被称为CPU(中央处理单元)。下图显示了该机器的概念组织。

基本指令处理器。

上面我们已经捕获了图灵机的所有方面:状态转换、磁带头的移动、重写符号以及基于磁带头下符号的决策。这种机器与冯·诺依曼机器非常相似,后者构成了当今计算机的基础。

现在,让我们尝试为改进的图灵机设计一个ISA,有可能有一个只包含一条指令的完整ISA,考虑一个与改进的图灵机兼容并且已经被证明是图灵完备的指令。

sbn a, b, c

sbn表示减法,如果为负数则分支,此指令从a中减去b(a和b是存储器位置),将结果保存在a中。如果a<0,则跳转到指令表中位置c处的指令,否则,控制转移到下一条指令。例如,我们可以使用此指令将保存在位置a和b中的两个数字相加。请注意,退出是程序末尾的一个特殊位置。

1: sbn temp, b, 2
2: sbn a, temp, exit

这里假设内存位置temp已经包含值0。第一条指令将\(-b\)保存在temp中,不管结果的值如何,它都跳到下一条指令。请注意,标识符(数字:)是指令的序列号。在第二条指令中,计算\(a = a + b = a - (-b)\)。因此,成功地相加了两个数字,现在可以使用这段基本代码将数字从1加到10。我们假设变量计数器初始化为9,索引初始化为10,一初始化为1,和初始化为0。

1: sbn temp, temp, 2   // temp = 0
2: sbn temp, index, 3  // temp = -1 * index
3: sbn sum, temp, 4    // sum += index
4: sbn index, one, 5   // index -= 1
5: sbn counter, one, exit // loop is finished, exit
6: sbn temp, temp, 7   // temp = 0
7: sbn temp, one, 1    // (0 - 1 < 0), hence goto 1

我们观察到,这个小的操作序列运行for循环。退出条件在第5行,循环返回发生在第7行。在每一次迭代中,它都计算\(-sum += index\)

有许多类似的单指令ISA已经被证明是完整的,例如,如果小于等于,则进行减法和分支,如果借用(borrow),则进行反向减法和跳过,以及具有通用内存移动操作的计算机。

用一条指令编写一个程序是非常困难的,而且程序往往很长。没有理由吝啬指令的数量,通过考虑大量的指令,可以使复杂程序的实现变得更加轻松。让我们尝试将基本的sbn指令分解为几个指令:

  • 算术指令。可以有一组算术指令,如加法、减法、乘法和除法。
  • 移动指令。可以有移动指令,在不同的内存位置移动值,允许将常量值加载到内存位置。
  • 分支指令。需要根据计算结果或存储在内存中的值来改变程序计数器以指向新指令的分支指令。

记住这些基本原则,我们可以设计许多不同类型的完整ISA。需要注意的是,我们只需要三种类型的指令:算术(数据处理)、移动(数据传输)和分支(控制)。

在任何指令集中,至少需要三种类型的指令:

1、需要算术指令来执行加法、减法、乘法和除法等运算。大多数指令集也有这类专门的指令来执行逻辑运算,如逻辑OR和NOT。

2、需要数据传输指令,可以在内存位置之间传输值,并可以将常量加载到内存位置。

3、需要能够根据指令操作数的值在程序中的不同点开始执行指令的分支指令。

寄存器机(register machine)是指包含无限数量的命名存储位置,这些存储位置称为寄存器。寄存器可以随机访问,所有指令都使用寄存器名作为操作数。CPU访问寄存器,获取操作数,然后处理它们。还存在混合机器,它们可以增加存储空间带有寄存器的标准Von Neumann机器。寄存器是可以保存符号的存储位置。

存储器通常是非常大的结构,在现代处理器中,整个内存可以包含数十亿个存储位置,这种大小的内存的任何实际实现在实践中都相当缓慢。硬件中有一个一般的经验法则,大则慢,小则快。因此,为了实现快速操作,每个处理器都有一组可以快速访问的寄存器,寄存器的数量通常在8到64之间。算术和分支操作中的大多数操作数都存在于这些寄存器中,由于程序倾向于在任何时间点重复使用一小组变量,因此使用寄存器可以节省许多内存访问。然而,有时需要将内存位置引入寄存器或将寄存器中的值写回内存位置。在这些情况下,我们使用专用的加载和存储指令,在内存和寄存器之间传输值。大多数程序都有大多数纯寄存器指令,加载和存储指令的数量通常约为已执行指令总数的三分之一。

假设我们要将数字的3次方加到存储位置b和c中,并将结果保存在存储位置a中。带有寄存器的机器需要以下指令,假设r1、r2和r3是寄存器的名称,没有使用任何特定的(通用的、概念性的)ISA。

1: r1 = mem[b] // load b
2: r2 = mem[c] // load c
3: r3 = r1 * r1 // compute b^2
4: r4 = r1 * r3 // compute b^3
5: r5 = r2 * r2 // compute c^2
6: r6 = r2 * r5 // compute c^3
7: r7 = r4 + r6 // compute b^3 + c^3
4: mem[a] = r7 // save the result

mem是表示内存的数组,需要首先将值加载到寄存器中,然后执行算术计算,然后将结果保存回内存。上面的代码通过使用寄存器来节省内存访问,如果增加计算的复杂性,将节省更多的内存访问,因此,使用寄存器的执行速度会更快。最终的处理器组织如下图所示。

很明显,安排计算在堆栈上工作是不可取的,将有许多冗余负载和存储。尽管如此,对于打算计算长数学表达式的机器,以及程序大小是一个问题的机器,通常会选择堆栈。很少有基于堆栈的机器的实际实现,如Burroughs Large Systems、UCSD Pascal和HP 3000(经典)。Java语言在编译过程中假设一台基于堆栈的机器,由于基于堆栈的机器很简单,Java程序实际上可以在任何硬件平台上运行。当我们运行编译后的Java程序时,Java虚拟机(JVM)会动态地将Java程序转换为另一个可以在带有寄存器的机器上运行的程序。

基于累加器的机器使用一个寄存器,称为累加器(accumulator)。每条指令都将单个内存位置作为输入操作数,例如,加法运算将累加器中的值与存储器地址中的值相加,然后将结果存储回累加器。早期无法容纳寄存器的机器曾经有累加器,累加器能够减少内存访问的次数并加速程序。

累加器的某些方面已经渗透到英特尔x86处理器组中,这些处理器是2012年台式机和笔记本电脑最常用的处理器。对于大数的乘法和除法,这些处理器使用寄存器eax作为累加器。对于其他通用指令,任何寄存器都可以指定为累加器。

19.4.5 常见ISA

目前市面上流行的指令集包含ARM指令集和x86指令集。ARM是高级RISC机器(Advanced RISC Machines),是一家总部位于英国剑桥的标志性公司,截至2012年,包括苹果iPhone和iPad在内的大约90%的移动设备都运行在基于ARM的处理器上。同样,截至2012年超过90%的台式机和笔记本电脑运行在基于Intel或AMD的x86处理器上。ARM是RISC指令集,x86是CISC指令集。

还有许多其他为各种处理器量身定制的指令集,移动计算机的另一个流行指令集是MIPS指令集,基于MIPS的处理器也用于汽车和工业电子中的各种处理器。

对于大型服务器,通常使用IBM(PowerPC)、Sun(如今的Oracle,UltraSparc)或HP(PA-RISC)处理器。每个处理器系列都有自己的指令集,这些指令集通常是RISC指令集,大多数ISA共享简单的指令,如加法、减法、乘法、移位和加载/存储指令。除了这个简单的集合,他们使用了大量更专业的指令。在ISA中选择正确的指令集取决于处理器的目标市场、工作负载的性质以及许多设计时间限制,下表显示了流行的指令集列表。

ISA 类型 年份 厂商 位数 字节顺序 寄存器数
VAX CISC 1977 DEC 32 little 16
SPARC RISC 1986 Sun 32 bi 32
SPARC RISC 1993 Sun 64 bi 32
PowerPC RISC 1992 Apple, IBM, Motorola 32 bi 32
PowerPC RISC 2002 Apple, IBM 64 bi 32
PA-RISC RISC 1986 HP 32 big 32
PA-RISC RISC 1996 HP 64 big 32
m68000 CISC 1979 Motorola 16 big 16
m68000 CISC 1979 Motorola 32 big 16
MIPS RISC 1981 MIPS 32 bi 32
MIPS RISC 1999 MIPS 64 bi 32
Alpha RISC 1992 DEC 64 bi 32
x86 CISC 1978 Intel, AMD 16 little 8
x86 CISC 1985 Intel, AMD 32 little 8
x86 CISC 2003 Intel, AMD 64 64 little 16
ARM RISC 1985 ARM 32 bi (little default) 16
ARM RISC 2011 ARM 64 bi (little default) 31

19.4.6 指令实现机制

有一小组基本逻辑组件,可以以各种方式组合起来存储二进制数据,并对该数据执行算术和逻辑运算。如果要执行特定的计算,则可以构造专门为该计算设计的逻辑组件的配置。我们可以将以所需配置连接各种组件的过程视为编程的一种形式。生成的“程序”是硬件形式的,称为硬连线程序(hardwired program)。

现在考虑这个替代方案。假设我们构造了算术和逻辑函数的通用配置,这组硬件将根据施加到硬件的控制信号对数据执行各种功能。在定制硬件的原始情况下,系统接受数据并产生结果(下图a)。使用通用硬件,系统接受数据和控制信号并产生结果,因此程序员只需要提供一组新的控制信号,而不是为每个新程序重新布线硬件。

硬件和软件方法。

如何提供控制信号?答案很简单,但很微妙。整个程序实际上是一系列步骤,在每个步骤中,对一些数据执行一些算术或逻辑运算。对于每个步骤,都需要一组新的控制信号。让我们为每一组可能的控制信号提供一个唯一的代码,并在通用硬件中添加一个可以接受代码并生成控制信号的段(上图b)。

编程现在更容易了。我们需要做的是提供一个新的代码序列,而不是为每个新程序重新布线硬件。实际上,每个代码都是一条指令,部分硬件解释每个指令并生成控制信号。为了区分这种新的编程方法,一系列代码或指令被称为软件。

19.4.6.1 存储指令

让我们考虑加载指令:ld r1, 12[r2],此处将内存地址计算为r2和数字12的内容之和。ld指令访问此内存地址,获取存储的整数并将其存储在r1中。假设计算的内存地址指向整数的第一个存储字节(即小端表示),所以内存地址包含LSB。详情如下图(a)所示。存储操作则相反,将r1的值存储到存储器地址(r2+12)中,如下图(b)所示。

19.4.6.2 函数

回顾一下实现一个简单函数的基本要求。假设地址为A的指令调用函数foo,在执行函数foo之后,需要立即返回A处指令之后的指令,该指令的地址为A+4(如果我们假设A处的指令长度为4字节)。这个过程被称为从函数返回,地址(a+4)被称为返回地址。

返回地址(Return address)是进程在执行函数后需要分支到的指令的地址。

因此,实现函数有两个基本方面:1、调用或调用函数的过程;2、涉及从函数返回。

函数本质上是一块汇编代码,调用一个函数本质上是让PC指向这段代码的开头。我们可以将标签与每个函数相关联,标签应该与函数中的第一条指令相关联,调用函数就像分支到函数开头的标签一样简单。然而,这只是故事的一部分,我们还需要实现返回功能。因此,我们不能使用无条件分支指令来实现函数调用。

因此,让我们提出一个专用的函数调用指令,它分支到函数的开头,同时保存函数需要返回的地址(称为返回地址)。让我们考虑下面的C代码,并假设每个C语句对应于一行汇编代码。

a = foo(); /* Line 1 */
c = a + b; /* Line 2 */

在这个小代码片段中,我们使用函数调用指令来调用foo函数,返回地址是第2行中指令的地址。调用指令必须将返回地址保存在专用存储位置,以便以后可以检索。大多数RISC指令集都有一个专用寄存器,称为返回地址寄存器(不妨称为ra),用于保存返回地址,返回地址寄存器由函数调用指令自动填充。当我们需要从函数返回时,我们需要分支返回地址寄存器中包含的地址。

如果foo调用另一个函数会发生什么?在这种情况下,ra中的值将被覆盖。我们稍后将讨论这个问题。现在让我们考虑将参数传递给函数并返回返回值的问题。

假设函数foo调用函数foobar。foo被称为调用者(caller),foobar被称为被调用者(callee)。请注意,调用方与被调用方的关系是不固定的。foo可以调用foobar,foobar也可以在同一个程序中调用foo。根据哪个函数调用另一个函数来决定单个函数调用的调用者和被调用者。

调用者和被调用者都看到相同的寄存器视图。因此,我们可以通过寄存器传递参数,同样也可以通过寄存器来传递返回值。然而,正如我们在下面列举的,在这个简单的想法中有几个问题(假设我们有16个寄存器)。

1、一个函数可以接受16个以上的参数,比我们现有的通用寄存器数量还要多,因此需要添加额外的空间来保存参数。

2、函数可以返回大量数据,例如C中的大型结构。这段数据可能不可能在寄存器中存储。

3、被调用者可能会覆盖调用者将来可能需要的寄存器。

因此,我们观察到,通过寄存器传递参数和返回值只适用于简单的情况,不是一个非常灵活和通用的解决方案。尽管如此,我们的讨论提出了两个要求:

  • 空间问题。我们需要额外的空间来发送和返回更多的参数。
  • 覆盖问题。我们需要确保被调用方不会覆盖调用方的寄存器。

为了解决这两个问题,需要更深入地了解函数是如何工作的。可以将函数foo想象成一个黑匣子,它接受一系列参数并返回一组值。要执行它的工作,foo可以花费一纳秒、一周甚至一年的时间。foo可以调用其他函数来完成它的工作、将数据发送到I/O设备以及访问内存位置。下图是函数foo的可视化。

总而言之,通用函数处理参数,根据需要从内存和I/O设备读取和写入值,然后返回结果。关于内存和I/O设备,目前我们并不特别关心,有大量可用内存,空间不是主要限制,读写I/O设备通常也与空间限制无关。主要问题是寄存器,因为它们供不应求。

让我们先解决空间问题,可以通过寄存器和内存传输值。为了简单起见,如果我们需要传输少量数据,我们可以使用寄存器,否则我们可以通过内存传输它们。类似地,对于返回值,我们可以通过内存传输值。如果我们使用内存传输数据,那么我们不受空间限制。然而,这种方法缺乏灵活性,因为调用者和被调用者之间必须就要使用的内存位置达成严格的协议。请注意,我们不能使用一组固定的内存位置,因为被调用方可以递归调用自己。

void foobar() 
{
    ...
    foobar();
    ...
}

精明的读者可能会认为,被调用方可以从内存中读取参数并将其转移到内存中的其他临时区域,然后调用其他函数。然而,这种方法既不优雅,也不十分有效。稍后将研究更优雅的解决方案。

因此可以得出结论,我们已经部分解决了空间问题。如果需要在调用者和被调用者之间传输一些值,或者反之亦然,可以使用寄存器。但是,如果参数/返回值不在可用寄存器集中,那么需要通过内存传输它们。对于通过内存传输数据,我们需要一个优雅的解决方案,它不需要调用者和被调用者之间就用于传输数据的内存位置达成严格的协议。

将寄存器保存在内存中并随后恢复的概念称为寄存器溢出(register spilling)

要解决覆盖问题,有两种解决方案:1、调用者可以将所需的寄存器集保存在内存中的专用位置,可以在被调用方完成后检索其寄存器集,并将控制权返回给调用方。2、让被调用方保存和恢复它需要的寄存器。这两种方法都如下图所示。这种将寄存器值保存在内存中,然后再检索的方法称为溢出。

调用方保存和被调用方保存的寄存器。

我们又遇到了同样的问题,即调用者和被调用者都需要就需要使用的内存位置达成严格的协议。现在让我们一起努力解决这两个问题。

我们简化了向函数传递参数和从函数传递参数,以及使用内存中的专用位置保存/恢复寄存器的过程。然而,该解决方案被发现是灵活的,对于大型现实世界程序来说,实现起来可能相当复杂。为了简化这个想法,让我们在函数调用中定义一个模式。

典型的C或Java程序从主函数开始。然后,该函数调用其他函数,这些函数可能反过来调用其他函数。最后,当主函数退出时,执行终止。每个函数定义一组局部变量,并对这些变量和函数参数执行计算,它还可以调用其他函数。最后,函数返回一个值,很少返回一组值(C中的结构)。请注意,函数终止后,不再需要局部变量和参数。因此,如果其中一些变量或参数保存在内存中,我们需要回收空间。其次,如果函数溢出了寄存器,那么这些内存位置也需要在它退出后释放。最后,如果被调用方调用另一个函数,则需要将返回地址寄存器的值保存在内存中,还需要在函数退出后释放此位置。

最好将所有这些信息连续保存在一个内存区域中,被称为函数的激活块(activation block),下图显示了激活块的内存映射。

激活块包含参数、返回地址、寄存器溢出区(对于调用方保存和被调用方保存的方案)和局部变量。一旦函数终止,就可以完全摆脱激活块。一个函数如果想要返回一些值,那么可以使用寄存器这样做,但是它如果想要返回一个大的结构,那么就可以将其写入调用方的激活块中,调用方可以在其激活块中提供一个可以写入该数据的位置。后面有可能更优雅地做到这一点,在解释如何做到这一点之前,需要了解如何在内存中安排激活块。

我们可以有一个存储区域,其中所有的激活块都存储在相邻的区域中。考虑一个例子,假设函数foo调用函数foobar,foobar又调用foobarbar。下图显示了4个内存状态:(a)在调用foobar之前,(b)在调用foobarbar之前,(c)在调用foobarbar之后,(d)在foobarar返回之后。

在这个内存区域中有一个后进先出的行为,最后调用的函数是要完成的第一个函数,这种后进先出的结构传统上被称为计算机科学中的堆栈(stack),因此专用于保存激活块的存储区域称为堆栈。传统上,堆栈被认为是向下增长的(向更小的内存地址增长),意味着主功能的激活块从非常高的位置开始,新的激活块被添加到现有激活块的正下方(朝向较低的地址)。堆栈的顶部实际上是堆栈中最小的地址,而堆栈的底部是最大的地址。堆栈的顶部表示当前正在执行的函数的激活块,堆栈的底部表示初始主函数。

堆栈(stack)是保存程序中所有激活块的内存区域,一般情况是向下增长的。在调用函数之前,我们需要将其激活块推送到堆栈中,当函数完成执行时,需要将其激活块弹出到堆栈中。

堆栈指针寄存器(stack pointer register)保存指向堆栈顶部的指针。

大多数架构将指向堆栈顶部的指针保存在一个称为堆栈指针的专用寄存器中,常被称为sp。请注意,对于许多架构,堆栈是纯软件结构。对于他们来说,硬件不知道堆栈。但对于某些架构(如x86),硬件知道堆栈并使用它来推送返回地址或其他寄存器的值。即使在这种情况下,硬件也不知道每个激活块的内容,结构由程序集程序员或编译器决定。在所有情况下,编译器都需要显式添加汇编指令来管理堆栈。

为被调用方创建新的激活块涉及以下步骤。

1、将堆栈指针减小激活块的大小。

2、复制参数的值。

3、如果需要,通过写入相应的内存位置来初始化任何局部变量。

4、如果需要,溢出任何寄存器(存储到激活块)。

从函数返回时,必须销毁激活块,可以通过将激活块的大小添加到堆栈指针来完成。

通过使用堆栈,我们解决了所有问题。调用方和被调用方不能覆盖彼此的局部变量,局部变量保存在激活块中,两个激活块不重叠。除了变量之外,还可以通过在激活块中显式插入保存寄存器的指令来阻止被调用方重写调用方的寄存器。实现这一点有两种方法:调用者保存的方案和被调用方保存的方案。其次,无需就将用于传递参数的内存区域达成明确协议,堆栈可以用于此目的,调用者可以简单地将参数推送到堆栈上,这些参数将被推送到被调用方的激活块中,被调用方可以轻松使用它们。同样,当从函数,被调用方可以通过堆栈传递返回值,需要先通过减少堆栈指针来销毁其激活块,然后才能将返回值推送到堆栈上。调用方将知道被调用方的语义,因此在被调用方返回后,可以假定其激活块已被被调用方有效地放大,返回值占用了额外的空间。

ARM使用B/BL/BX/BLX等语句调用函数和返回函数,而x86使用call等指令调用函数,此外,x86和ARM都可使用ret指令返回地址。下面是ARM的函数调用示例代码:

    .globl  main
    .extern abs
    .extern printf
    .text
output_str:
    .ascii     "The answer is %d\n\0"

    @ returns abs(z)+x+y
    @ r0 = x, r1 = y, r2 = z
    .align 4
do_something:
    push    {r4, lr}
    add     r4, r0, r1
    mov     r0, r2
    bl      abs  ; 调用abs
    add     r0, r4, r0
    pop     {r4, pc}

main:
    push    {ip, lr}
    mov     r0, #1
    mov     r1, #3
    mov     r2, #-4
    bl      do_something  ; 调用do_something
    mov     r1, r0
    ldr     r0, =output_str
    bl      printf
    mov     r0, #0
    pop     {ip, pc}

有趣的指令是pushpop和bl,只需获取提供的寄存器列表并将其推到堆栈上,或者将其弹出并放入提供的寄存器中。bl只不过是带链接的分支,分支后的下一条指令的地址被加载到链接寄存器lr中。

一旦我们正在调用的例程被执行,lr就可以被复制回pc,将使CPU能够在bl指令之后从代码中继续。在do_someting中,我们将链接寄存器推送到堆栈,这样就可以再次将其弹出返回,即使对abs的调用将覆盖链接寄存器的原始内容。程序存储r4,因为Arm过程调用标准规定在函数调用之间必须保留r4-r11(下图),并且被调用的函数负责该保留,意味着do_someting需要将r0+r1的结果保存在一个不会被abs破坏的寄存器中,并且我们还必须保存用于保存该结果的任何寄存器的内容。当然,在这种特殊情况下,我们可以只使用r3,但是需要考虑的。我们推送并弹出寄存器,尽管我们不必保留它,因为过程调用标准要求堆栈64位对齐。这在使用堆栈操作时提供了性能优势,因为它们可以利用CPU内的64位数据路径。

我们可以直接压入高地址的值,毕竟如果abs需要注册,那么这就是它保存值的方式。推送r4而不是我们知道需要的值有一个小的性能问题,但最有力的论点可能是,在函数的开始和结束时只推送/弹出所需的任何寄存器,就可以减少错误发生的可能性,提高代码的可读性。此外,“main”函数还压入和弹出lr的内容,因为虽然主代码可能是我的代码中要执行的第一件事,但它不是加载程序时要执行的第二件事。编译器将在调用main之前插入对一些基本设置函数的调用,并在退出时进行一些最终清理调用。

现在让我们尝试将每条指令编码为32位值。假设有0、1、2和3地址格式的指令,其次,有些指令采用即时值,因此需要将32位划分为多个字段。假设有21条指令,则需要5位来编码指令类型,常规指令中的每个指令的代码如下表所示。我们可以使用32位字段中最重要的位来指定指令类型,指令的代码也称为操作码(opcode)

指令 二进制码
add 00000
sub 00001
mul 00010
div 00011
mod 00100
cmp 00101
and 00110
or 00111
not 01000
mov 01001
lsl 01010
lsr 01011
asr 01100
nop 01101
ld 01110
st 01111
beq 10000
bgt 10001
b 10010
call 10011
ret 10100

现在,让我们尝试从0地址指令开始对每种类型的指令进行编码。

我们拥有的两条0地址指令是ret和nop。操作码由五个最重要的位指定,在这种情况下,ret等于10100,b等于10010(参见上表)。它们的编码如下图所示,我们只需要在MSB位置指定5位操作码,其余27位不需要。

编码ret指令。

我们拥有的1地址指令是call、b、beq和bgt,它们将标签作为参数。在编码指令时,我们需要指定标签的地址作为参数,标签的地址与它所指向的指令的地址相同。如果标签后的行为空,那么我们需要考虑下一条包含指令的汇编语句。

这四条指令的操作码需要5位,剩余的27位可用于地址。请注意,内存地址是32位长,不能用27位覆盖地址空间,但可以进行两个关键的优化。首先,可以假设PC相对寻址,可以假设27位指定了相对于当前PC的偏移量(正负)。现代程序中的分支语句是因为for/while循环或if语句而生成的,对于这些构造,分支目标通常在几百条指令的范围内。如果有27位来指定偏移量,并且假设它是2的补码,那么任何方向(正或负)的最大偏移量都是226,对于几乎所有的程序来说已足够。

还有另一个重要的观察。一条指令需要4个字节。如果假设所有指令都与4字节边界对齐,那么指令的所有起始内存地址都将是4的倍数,因此地址的至少两个有符号二进制数字将是00,没有理由在试图指定它们时浪费比特,可以假设27位指定包含指令的内存字(以4字节内存字为单位)地址的偏移量。通过这种优化,从PC的字节偏移量变为29位,即使是最大的程序,这个数字也应该足够。以防万一有极端的例子,其中分支目标距离超过228个字节,那么汇编程序需要将分支链接起来,这样一个分支将调用另一个分支,以此类推。这些指令的编码如下图所示。

1地址指令的编码(分支格式)。

请注意,1地址指令格式禁止使用0地址格式中未使用的位,可以将ret指令的0地址格式视为1地址格式的特例,1地址格式称为分支格式。以这种格式命名字段,将格式的操作码部分称为op,将偏移量称为offset。操作字段包含位置28-32的位,偏移字段包含位置1-27的位。

接下来考虑3地址指令:add、sub、mul、div、mod和或、lsl、lsr和asr。

考虑一个通用的3地址指令,它有一个目标寄存器、一个输入源寄存器和一个可以是寄存器或立即数的第二个源操作数。如果第二个源操作数是寄存器或立即数,需要将一位输入和输出。将其称为I位,并在指令中的操作码之后指定它。如果I=1,则第二个源操作数是立即数,如果I=0,则第二个源操作数是寄存器。

现在考虑将第二个源操作数作为寄存器(I=0)的3地址寄存器的情况。因为有16个寄存器,所以需要4位来唯一地指定每个寄存器。寄存器ri可以编码为i的无符号4位二进制等价物。因此,要指定目标寄存器和两个输入源寄存器,需要12位。结构如下图所示,此指令格式称为寄存器格式。像分支格式一样,不妨命名不同的字段:op(操作码,位:28-32)、I(立即数,位:27)、rd(目的寄存器,位:23-26)、rs1(源寄存器1,位:19-22)和rs2(源寄存器2,位:15-18)。

假设第二个源操作数是立即数,那么需要将I设置为1,接下来计算指定立即数所剩的位数。现在已经为操作码投入了5位,为I位投入了1位,为目标寄存器投入了4位,为第一个源寄存器投入了四位,总共花费了14位。因此,在32位中,剩下18位,可以使用它们来指定立即数。

建议将18位分为两部分:2位(修改器)+16位(立即数的常数部分),两个修改位可以取三个值:00(默认值)、01(“u”)和10(“h”)。当使用默认修改器时,剩余的16位用于指定16位2的补码数。对于u和h修改器,假设立即字段中的16位常量是无符号数。假设立即字段为18位长,具有修改部分和常量部分,处理器根据修改器将立即数内部扩展为32位值。

此编码如下图所示,可将此指令格式称为立即数格式。像分支格式一样,不妨命名不同的字段:op(操作码,位:28-32)、I(立即数,位:27)、rd(目标寄存器,位:23-26)、rs1(源寄存器1,位:19-22)和imm(立即数:1-18)。

用类似的方式,可以用下图所示的方式编码cmp、not和mov指令:

而加载指令的实现如下图:

19.4.7 ARM指令编码

ARM有四种类型的指令:数据处理(加/减/乘/比较)、加载/存储、分支和其他,需要2位来表示这些信息,这些位决定了指令的类型。下图显示了ARM中指令的通用格式。

对于数据处理指令,类型字段等于00,其余26位需要包含指令类型、特殊条件和寄存器。下图显示了数据处理指令的格式。


第26位称为I(立即数)位,类似于前面所述的I位。如果将其设置为1,则第二个操作数是立即数,否则是寄存器。由于ARM有16条数据处理指令,需要4位来表示它们,该信息保存在第22-25位。第21位保存S位,如果打开,则指令将设置CPSR。

其余20位保存输入和输出操作数。由于ARM有16个寄存器,需要4位来编码一个寄存器。第17-20位保存第一个输入操作数(rs)的标识符,要求是一个寄存器。第13-16位保存目标寄存器(rd)的标识符。

位1-12用于保存立即数或移位器操作数,下面看看如何最好地利用这12位。

ARM支持32位立即数,然而实际上只有12位来编码它们。不可能对所有\(2^{32}\)个可能的值进行编码,需要从中选择一个有意义的子集,想法是使用12位对32位值的子集进行编码,硬件预计将解码这12位,并在处理指令时将其扩展到32位。

现在,12位是一个相当不灵活的值,既不是1字节,也不是2字节。有必要想出一个非常巧妙的解决方案,想法是将12位分为两部分:4位常量(rot)和8位有效载荷(payload),参见下图。

假设12位中编码的实际数字为n,有:

\[n = payload \ \ ror \ \ (2 \cdot rot) \]

其中ror是右旋操作。通过将有效载荷右旋2倍于rot字段中的值,获得实际数字n。现在试着理解这样做的逻辑。

数字n是32位值。一个天真的解决方案是使用12位来指定n的最小符号位,高阶位可以是0。然而,程序员倾向于以字节为单位访问数据和内存,因此1.5个字节对我们毫无用处。更好的解决方案是使用1字节的有效载荷,并将其放置在32位字段中的任何位置,其余4位用于此目的,它们可以对0到15之间的数字进行编码。ARM处理器将该值加倍,以考虑0到30之间的所有偶数,将有效载荷向右旋转该量。这样做的好处是可以对更广泛的数字集进行编码,对于所有这些数字,有8位对应于有效载荷,其余24位均为零。rot位仅确定32位字段中的哪8位被有效载荷占用。

同样地,通过合理地思考,可以得到以下的位移指令格式图:

此外,加载、存储指令格式如下:

而分支指令如下:

ARM Endian支持使用E-Bit加载/存储字:

19.4.8 x86指令编码

x86是真正的CISC指令集,其编码过程更为规律,几乎所有的指令都遵循标准格式。其次,x86中的操作码通常有多种模式和前缀。先看看编码机器指令的广泛结构,下图显示了二进制编码指令的结构。

x86二进制指令格式。

x86指令格式细节。

第一组1-4字节用于编码指令的前缀,rep前缀就是其中一个例子,还有许多其他类型的前缀可以在第一组1-4字节中编码。

接下来的1-3个字节用于对操作码进行编码,整个x86 ISA有数百条指令,操作码还编码操作数的格式。例如,加法指令可以将其第一个操作数作为内存操作数,也可以将其第二个操作数用作内存操作数。此信息也是操作码的一部分。

接下来的两个字节是可选的。第一个字节被称为ModR/M字节,用于指定源寄存器和目标寄存器的地址,第二个字节被称作SIB(标度索引基)字节,该字节记录基本缩放索引和基本缩放索引偏移寻址模式的参数,存储器地址可以可选地具有32位的位移(在本书中也称为偏移量)。因此,我们可以选择在一条指令中多4个字节来记录位移值。最后,一些x86指令接受立即数作为操作数,立即数也可以大到32位,因此,最后一个字段(也是可选的)用于指定立即数操作数。

ModR/M字节有三个字段,如下图所示:

SIB字节的结构如下图所示:

x86数字数据格式如下:

x86 EFLAGS寄存器:

x86控制寄存器:

MMX寄存器到浮点寄存器的映射:


19.5 处理器

19.5.1 指令处理概览

我们可以将处理器的操作大致分为五个阶段,如下图所示。

指令处理的五个阶段:指令获取、操作数获取、执行、内存访问、寄存器写入。

指令的多时钟周期管线图。图中时间从左到右在页面上前进,指令从页面的顶部到底部前进。管线阶段的表示沿指令轴放置在每个部分,占据适当的时钟周期。图中显示了每个阶段之间的管线寄存器,数据路径以图形方式表示管线的五个阶段,但命名每个管线阶段的矩形也同样有效。

第1步是从内存中获取指令。机器的底层组织并不重要,该机器可以是冯·诺依曼机器(共享指令和数据存储器),也可以是哈佛机器(专用指令存储器)。提取阶段有逻辑元件来计算下一条指令的地址,如果当前指令不是分支,那么需要将当前指令的大小(4字节)添加到存储在PC中的地址。但如果当前指令是分支,那么下一条指令的地址取决于分支的结果和目标。此信息从处理器中的其他单元获得。

第2步是解码“指令并从寄存器中取出操作数。不同指令类型所需的处理非常不同,例如加载存储指令使用专用的存储单元,而算术指令则不使用。为了解码指令,处理器有专用的逻辑电路,根据指令中的字段生成信号,这些信号随后被其他模块用来正确处理指令。像Intel处理器这样的商用处理器有非常复杂的解码单元,解码x86指令集非常复杂。不管解码的复杂程度如何,解码过程通常包括以下步骤:

  • 提取操作数的值,计算嵌入的立即数并将其扩展到32或64位,以及生成关于指令处理的附加信息。
  • 生成关于指令的更多信息的过程包括生成处理器专用信号。例如,可以为加载/存储指令生成“启用内存单元”形式的信号,对于存储指令,可以生成一个禁用寄存器写入功能的信号。

第3步是执行算术和逻辑运算。它包含一个能够执行所有算术和逻辑运算的算术和逻辑单元(ALU),ALU还需要计算加载存储操作的有效地址,通常情况下处理器的这一部分也计算分支的结果。

ALU(算术逻辑单元)包含用于对数据值执行算术和逻辑计算的元素,通常包含加法器、乘法器、除法器,并具有计算逻辑位运算的单元。

第4步包含用于处理加载存储指令的存储单元。该单元与存储器系统接口,并协调从存储器加载和存储值的过程。典型处理器中的内存系统相当复杂,其中一些复杂性是在处理器的这一部分中实现的。

第5步是将ALU计算的值或从存储器单元获得的加载值写入寄存器文件。

单个指令所需的处理称为指令周期。使用前面给出的简化的两步描述,指令周期如下图所示,这两个步骤被称为获取周期和执行周期。只有在机器关闭、发生某种不可恢复的错误或遇到使计算机停止的程序指令时,程序执行才会停止。

基本指令周期。

考虑一个使用假设机器的简单示例,该机器包括下图中列出的特性。处理器包含一个称为累加器(AC)的数据寄存器,指令和数据都是16位长,因此使用16位字组织内存是方便的。指令格式为操作码提供4位,因此可以有多达\(2^4=16\)个不同的操作码,并且可以直接寻址多达\(2^{12}=4096\)(4K)个字的内存。

假想机器的特性。

下图说明了部分程序执行,显示了内存和处理器寄存器的相关部分。所示的程序片段将地址940处的存储字的内容添加到地址941处的内存字的内容,并将结果存储在后一位置。需要三条指令,可以描述为三个获取和三个执行周期:

1、PC包含300,即第一条指令的地址。该指令(十六进制值1940)被加载到指令寄存器IR中,PC递增。注意,此过程涉及使用内存地址寄存器和内存缓冲寄存器。为了简单起见,这些中间寄存器被忽略。

2、IR中的前4位(第一个十六进制数字)表示要加载AC,剩余的12位(三个十六进制数字)指定要加载数据的地址(940)。

3、从位置301获取下一条指令(5941),并且PC递增。

4、添加AC的旧内容和位置941的内容,并将结果存储在AC中。

5、从位置302取出下一条指令(2941),并且PC递增。

6、AC的内容存储在位置941中。

程序执行示例(内存和寄存器的内容为十六进制)。

特定指令的执行周期可能涉及对内存的不止一次引用。此外,指令可以指定I/O操作,而不是内存引用。考虑到这些额外的考虑因素,图下图提供了基本指令周期的更详细的视图,该图采用状态图的形式。对于任何给定的指令周期,某些状态可能为空,而其他状态可能被访问多次。


指令周期状态图。

状态描述如下:

  • 指令地址计算(iac):确定要执行的下一条指令的地址,通常需要在前一条指令的地址上添加一个固定的数字,例如如果每条指令的长度为16位,并且内存被组织为16位字,则在前一个地址上加1。相反,如果内存被组织为可单独寻址的8位字节,则在前一个地址上加2。
  • 指令获取(if):将指令从其内存位置读入处理器。
  • 指令操作解码(iod):分析指令以确定要执行的操作类型和要使用的操作数。
  • 操作数地址计算(oac):如果操作涉及对内存中的操作数的引用或通过I/O可用的操作数,则确定操作数的地址。
  • 操作数获取(of):从内存中获取操作数或从I/O中读取操作数。
  • 数据操作(do):执行指令中指示的操作。
  • 操作数存储(os):将结果写入内存或输出到I/O。

下图显示了包括中断周期处理的修订指令周期状态图:

下图左是非直接时钟周期,右是中断时钟周期:

下图通过指出每种模块类型的主要输入和输出形式,说明了所需的交换类型:

微处理器寄存器组织示例:

19.5.2 处理器结构

19.5.2.1 处理器单元

处理器内部包含了诸多单元,诸如获取单元、数据路径和控制单元、操作数获取单元、执行单元(分支单元、ALU)、内存访问单元、寄存器回写单元等等。

下图显示了获取单元电路的实现。在一个周期中需要执行两个基本操作:1、下一个PC(程序计数器)的计算;2、获取指令。

电路中有两种元件:

  • 第一类元件是寄存器、存储器、算术和逻辑电路,用于处理数据值。
  • 第二类元素是决定数据流方向的控制单元。处理器中的控制单元通常生成信号以控制所有多路复用器(multiplexer),被称为控制信号(control signal),因为其作用是控制信息流。

因此,我们可以从概念上认为处理器由两个不同的子系统组成:

  • 数据路径(data path)。它包含存储和处理信息的所有元素。例如,数据存储器、指令存储器、寄存器文件和ALU(算术逻辑单元)是数据路径的一部分。内存和寄存器存储信息,而ALU处理信息,例如,它将两个数字相加,并产生和作为结果,或者它可以计算两个数字的逻辑函数。
  • 控制路径(control path)。它通过生成信号来引导信息的正确流动,生成一个信号,指示多路复用器在分支目标和默认下一个PC之间进行选择。在这种情况下,多路复用器由信号isBranchTaken控制。

我们可以将控制路径和数据路径视为电路的两个不同元件,就像城市的交通网络一样。道路和红绿灯类似于数据路径,控制交通灯的电路构成了控制路径,控制路径决定灯光转换的时间。在现代智慧城市中,控制城市中所有交通灯的过程通常是集成的。如果有可能智能控制交通,使汽车绕过交通堵塞和事故现场。类似地,处理器的控制单元相当智能,它的工作是尽可能快地执行指令。现代处理器的控制单元已经非常复杂。

数据路径(data path):数据路径由处理器中专用于存储、检索和处理数据的所有元素组成,如寄存器、内存和ALU。

控制路径(control path):控制路径主要包含控制单元,其作用是生成适当的信号来控制数据路径中指令和数据的移动。

数据路径和控制路径之间的关系。

现在看看执行指令。首先将指令分为两种类型:分支和非分支。分支指令由计算分支结果和最终目标的专用分支单元处理,非分支指令由ALU(算术逻辑单元)处理。分支单元的电路如下图所示:

使用多路复用器在返回地址(op1)的值和指令中嵌入的branchT目标之间进行选择。isRet信号控制多路复用器,如果它等于1就选择op1,否则选择分支目标。多路复用器branchPC的输出被发送到提取单元。

下图显示了包含ALU的执行单元部分。ALU的第一个操作数(A)始终为op1(从操作数获取单元获得),但第二个操作数(B)可以是寄存器或符号扩展立即数,由控制单元生成的isImmediate信号决定,isImmediate信号等于指令中立即数位的值,如果是1,则图中的多路复用器选择immx作为操作数,如果为0,则选择op2作为操作数。ALU将一组信号作为输入,统称为aluSignals,aluSignals由控制单元生成,并指定ALU操作的类型。ALU的结果称为aluResult。

下图显示了ALU的一种设计。ALU包含一组模块,每个模块计算单独的算术或逻辑函数,如加法或除法。其次,每个模块都有一个启用或禁用它的专用信号,例如,当我们想执行简单的加法时,没有理由启用除法器。有几种方法可以启用或禁用单元,最简单的方法是为每个输入位使用一个传输门(transmission gate,见下下图),如果信号(S)打开,则输出反映输入值。否则,它将保持其以前的值。因此,如果启用信号关闭,则模块不会看到新的输入。因此,它不会耗散功率,并被有效禁用。

ALU。

传输门。

总之,下图展示了执行单元(分支单元和ALU)的完整设计。要设置输出(aluResult),需要一个多路复用器,可以从ALU中的所有模块中选择正确的输出,没有在图中显示此多路复用器。

执行单元(分支和ALU单元)。

下图显示了内存访问单元。它有两个输入{数据和地址,地址由ALU计算,它等于ALU的结果(aluResult),加载和存储指令都使用这个地址,地址保存在传统上称为MAR(内存地址寄存器)的寄存器中。

内存单元。

通过连接所有部分来形成整体。到目前为止,已经将处理器分为五个基本单元:指令获取单元(IF)、操作数获取单元(OF)、执行单元(EX)、内存访问单元(MA)和寄存器写回单元(RW)。是时候把所有的部分结合起来,看看统一的图片了(下图,省略了详细的电路,只关注数据和控制信号的流动)。

一个基础处理器。

19.5.2.2 控制单元

一个简单处理器的硬接线控制单元可以被认为是一个黑盒子,它以6位作为输入(5个操作码位和1个立即数位),并产生22个控制信号作为输出。如下图所示。

硬接线控制单元的抽象。

控制单元的结构图。

硬接线控制单元快速高效,这就是今天大多数商用处理器使用硬接线控制单元的原因,但硬接线控制单元并不十分灵活,例如在处理器出厂后,不可能更改指令的行为,甚至不可能引入新指令。有时如果功能单元中存在错误,需要更改指令的执行方式,例如如果乘法器存在设计缺陷,那么理论上可以使用加法器和移位单元运行布斯乘法算法。然而,我们需要一个非常复杂的控制单元来动态地重新配置指令的执行方式。

支持灵活的控制单元还有其他更实际的原因。某些指令集(如x86)具有重复指令给定次数的rep指令,它们还具有复杂的字符串指令,可以处理大量数据,支持此类指令需要非常复杂的数据路径。原则上,我们可以通过精心设计的控制单元来执行这些指令,而这些控制单元又有简单的处理器来处理这些指令,这些子处理器可以生成用于实现复杂CISC指令的控制信号。

数据路径和控制信号。

带内部总线的CPU。

19.5.2.3 基于微程序的处理器

前面已经研究了带有硬接线控制单元的处理器,设计了一个包含处理和执行指令所需的所有元素的数据路径。在输入操作数之间有选择的地方,添加了一个多路复用器,它由来自控制单元的信号控制。控制单元将指令的内容作为输入,并生成所有控制信号。现代高性能处理器通常采用这种设计风格。请注意,效率是有代价的,成本是灵活性。我们可能需要添加更多的多路复用器,并为每个新指令生成更多的控制信号。其次,在处理器交付给客户后,不可能向处理器添加新指令。有时候,我们渴望这样的灵活性。

通过引入将ISA中的指令转换为一组简单微指令的转换表,可以引入这种额外的灵活性。每个微指令都可以访问处理器的所有锁存器和内部状态元素。通过执行一组与指令关联的微指令,我们可以实现该指令的功能,这些微指令或微代码保存在微代码表中。通常可以通过软件修改该表的内容,从而改变硬件执行指令的方式。有几个原因需要这种灵活性,允许我们添加新指令或修改现有指令的行为。其中一些原因如下:

  • 处理器在执行某些指令时有时会出现错误。因为设计师在设计过程中犯下的错误,或者是由于制造缺陷,其中一个著名的例子是英特尔奔腾处理器中的除法错误,英特尔不得不召回它卖给客户的所有奔腾处理器。如果可以动态地更改除法指令的实现,那么就不必调用所有处理器。因此可以得出结论,处理器的某种程度的可重构性有助于解决在设计和制造过程的各个阶段可能引入的缺陷。
  • 英特尔奔腾4等处理器,以及英特尔酷睿i3和英特尔酷睿i7等更高版本的处理器,通过执行存储在内存中的一组微指令来实现一些复杂的指令。通常使用微码来实现数据串或指令的复杂操作,这些操作会导致一系列重复计算,意味着英特尔处理器在内部将复杂指令替换为包含简单指令的代码段,使得处理器更容易实现复杂的指令。我们不需要对数据路径进行不必要的更改,添加额外的状态、多路复用器和控制信号来实现复杂的指令。
  • 当今的处理器只是芯片的一部分,有许多其他元件,被称为片上系统(system-on-chip,SOC)。例如,手机中的芯片可能包含处理器、视频控制器、相机接口、声音和网络控制器。处理器供应商通常会硬连线一组简单的指令,而与视频和音频控制器等外围设备接口的许多其他指令都是用微码编写的。根据应用程序域和外围组件集,可以定制微码。
  • 有时使用一组专用微指令编写自定义诊断例程。这些例行程序在芯片运行期间测试芯片的不同部分,报告故障并采取纠正措施。这些内置自检(BIST)例程通常是可定制的,并以微码编写。例如,如果我们希望高可靠性,那么我们可以修改在CPU上执行可靠性检查的指令的行为,以检查所有组件。但是,为了节省时间,可以压缩这些例程以检查更少的组件。

因此,我们观察到,有一些令人信服的理由能够以编程方式改变处理器中指令的行为,以实现可靠性、实现附加功能并提高可移植性。因此,现代计算系统,尤其是手机和平板电脑等小型设备使用的芯片依赖于微码。这种微码序列通常被称为固件。

现代计算系统,尤其是手机、调制解调器、打印机和平板电脑等小型设备,使用的芯片依赖于微码。这种微码序列通常被称为固件(firmware)

因此,让我们设计一个基于微程序的处理器,即使在处理器被制造并发送给客户之后,它也能为我们提供更大的灵活性来定制指令集。需要注意,常规硬接线处理器和微编程处理器之间存在着基本的权衡。权衡是效率与灵活性,不能指望有一个非常灵活的处理器,它既快速又省电。

19.5.2.4 微编程数据路径

让我们为微程序处理器设计数据路径,修改处理器的数据路径。处理器有一些主要单元,如提取单元、寄存器文件、ALU、分支单元和内存单元。这些单元是用导线连接的,只要有可能有多个源操作数,我们就在数据路径中添加一个多路复用器。控制单元的作用是为多路复用器生成所有控制信号。

问题是多路复用器的连接是硬接线的,不能建立任意连接,例如,不能将存储单元的输出发送到执行单元的输入。因此我们希望有一个组件之间没有固定互连的设计,理论上任何单位都可以向任何其他单位发送数据。

最灵活的互连是基于总线的结构。总线是一组连接所有单元的普通铜线,支持一个写入,在任何时间点支持多个读者。例如,单元A可以在某个时间点写入总线,所有其他单元都可以获得单元A写入的值。如果需要,可以将数据从一个单元发送到另一个单元,或从一个装置发送到一组其他单元。控制单元需要确保在任何时间点,只有一个单元写入总线,需要处理正在写入的值的单元从总线读取值。

现在让我们继续设计我们为硬连线处理器引入的所有单元的简化版本,这些简化版本可以适当地用于我们的微程序处理器的数据路径。

让我们从解释微程序处理器的设计原理开始。我们为每个单元添加寄存器,这些寄存器存储特定单元的输入数据,专用输出寄存器存储单元生成的结果,这两组寄存器都连接到公共总线。与硬连线处理器不同的是,在不同的单元之间存在大量的耦合,微程序处理器中的单元是相互独立的。他们的工作是执行一组操作,并将结果返回总线。每个单元就像编程语言中的一个函数,它有一个由一组寄存器组成的接口,用于读取数据,通常需要1个周期来计算其输出,然后该单元将输出值写入输出寄存器。

根据上述原理,下图中展示了提取单元的设计,它有两个寄存器:pc(程序计数器)和ir(指令寄存器)。pc寄存器可以从总线读取其值,也可以将其值写入总线,没有将ir连接到总线,因为没有其他单位对指令的确切内容感兴趣,其他单位只对指令的不同字段感兴趣。因此,有必要解码指令并将其分解为一组不同的字段,由解码单元完成。

微程序处理器中的提取单元。

解码单元在功能上类似于操作数获取单元,但我们不在该单元中包含寄存器文件,而将其视为微程序处理器中的一个独立单元。下图显示了操作数获取单元的设计。

微程序处理器中的解码单元。

我们将解码单元和寄存器文件组合成一个单元,称为硬连线处理器的操作数获取单元,但更期望在微程序处理器中保持寄存器文件独立,因为在硬连线处理器中,它在解码指令后立即被访问。然而,微程序处理器可能不是这样——在指令执行期间,可能需要多次访问它。

微程序处理器中的寄存器文件。

ALU的结构如下图所示,有两个输入寄存器,A和B。ALU对寄存器A和B中包含的值执行操作,操作的性质由args值指定。例如,如果指定了加法运算,则ALU将寄存器A和B中包含的值相加。如果指定了减法运算,那么将从A中包含的数值减去B中的值,对于cmp指令,ALU更新标志。使用两个标志来指定相等和大于条件,分别保存在寄存器标志flags.E和标志flags.GT中,然后ALU运算的结果保存在寄存器aluResult中。此处还假设ALU在总线上指定args值后需要1个周期才能执行。

微程序处理器中的ALU。

内存单元如下图所示。与硬连线处理器一样,它有两个源寄存器:mar(内存地址寄存器)和mdr(内存数据寄存器),mar缓冲内存地址,mdr缓冲需要存储的值。还需要一组参数来指定内存操作的性质:加载或存储,加载操作完成后,ldResult寄存器中的数据可用。

微程序处理器中的存储单元。

综上,微程序处理器中的数据路径总览如下:

19.5.3 管线处理器

19.5.3.1 管线概述

假设前面介绍的硬连线处理器需要一个周期来获取、执行和将指令的结果写入寄存器文件或内存。在电气层面上,是通过从提取单元经由其他单元流到寄存器写回单元的信号来实现的,而电信号从一个单元传播到另一个单元需要时间。

例如,从指令存储器中获取指令需要一些时间。然后需要时间从寄存器文件读取值,并用ALU计算结果。内存访问和将结果写回寄存器文件也是相当耗时的操作。需要等待所有这些单独的子操作完成,然后才能开始处理下一条指令,意味着电路中有大量的空闲,当操作数获取单元执行其工作时,所有其他单元都处于空闲状态。同样,当ALU处于活动状态时,所有其他单元都处于非活动状态。如果我们假设五个阶段(IF、OF、EX、MA、RW)中的每一个都需要相同的时间,那么在任何时刻,大约80%的电路都是空闲的!这代表了计算能力的浪费,空闲资源绝对不是一个好主意。

如果能找到一种方法让芯片的所有单元保持忙碌,那么就能提高执行指令的速度。

管线流程

不妨类比一下前面讨论的简单单周期处理器中的空闲问题。当一条指令在EX阶段时,下一条指令可以在OF阶段,而后续指令可以在IF阶段。事实上,如果在处理器中有5个阶段,简单地假设每个阶段花费的时间大致相同,可以假设同时处理5条指令,每条指令在处理器的不同单元中进行处理。类似于流水线中的汽车,指令在处理器中从一个阶段移动到另一个阶段。此策略确保处理器中没有任何空闲单元,因为处理器中的所有不同单元在任何时间点都很忙。

在此方案中,指令的生命周期如下。它在周期n中进入IF阶段,在周期n+1中进入OF阶段,周期n+2中进入EX阶段,循环n+3中进入MA阶段,最后在周期n+4中完成RW阶段的执行。这种策略被称为流水线(pipelining,又名管线、管道),实现流水线的处理器被称为流水处理器(pipelined processor)。五个阶段(IF、OF、EX、MA、RW)的顺序在概念上一个接一个地布置,称为流水线(pipeline,类似于汽车装配线)。下图显示了流水线数据路径的组织。

流水线数据路径。

上图中,数据路径分为五个阶段,每个阶段处理一条单独的指令。在下一个周期中,每条指令都会传递到下一个阶段,如图所示。

性能优势

现在,让我们考虑流水线处理器的情况,假设阶段是平衡的,意味着执行每个阶段需要相同的时间,大多数时候,处理器设计人员都会尽可能最大程度地实现这个目标。因此可以将r除以5,得出执行每个阶段需要r/5纳秒的结论,可以将循环时间设置为r/5。循环结束后,流水线每个阶段中的指令进入下一阶段,RW阶段的指令移出流水线并完成执行,同时,新指令进入IF阶段。如下图所示。

流水线中的指令。

如果我们可以用5阶段流水线获得5倍的优势,那么按照同样的逻辑,应该可以用100阶段流水线得到100倍的优势。事实上,可以不断增加阶段的数量,直到一个阶段只包含一个晶体管。但情况并非如此,流水线处理器的性能存在根本性的限制,不可能通过增加流水线阶段的数量来任意提高处理器的性能。在一定程度上,增加更多的阶段会适得其反。

下图a描述了不使用流水线的指令序列的时序,显然是一个浪费的过程,即使是非常简单的流水线也可以大大提高性能。下图b显示了两阶段流水线方案,其中两个不同指令的I级和E级同时执行。流水线的两个阶段是指令获取阶段和执行指令的执行/内存阶段,包括寄存器到内存和内存到寄存器的操作,因此我们看到第二条指令的指令提取阶段可以与执行/存储阶段的第一部分并行执行。然而,第二条指令的执行/存储阶段必须延迟,直到第一条指令清除流水线的第二阶段。该方案的执行率可以达到串行方案的两倍,两个问题阻碍了实现最大加速。首先,我们假设使用单端口内存,并且每个阶段只能访问一个内存,需要在某些指令中插入等待状态。第二,分支指令中断顺序执行流,为了以最小的电路来适应这种情况,编译器或汇编器可以将NOOP指令插入到指令流中。

通过允许每个阶段进行两次内存访问,可以进一步改进流水线,产生了下图c所示的序列。现在最多可以重叠三条指令,其改进程度为3倍,同样,分支指令会导致加速比达不到可能的最大值,请注意数据依赖性也会产生影响。如果一条指令需要被前一条指令更改的操作数,则需要延迟,同样可以通过NOOP实现。

由于RISC指令集的简单性和规则性,分为三个或四个阶段的设计很容易完成。下图d显示了4阶段管线的结果,一次最多可以执行四条指令,最大可能的加速是4倍。再次注意,使用NOOP来解释数据和分支延迟。

19.5.3.2 管线阶段

流水线处理器使用的电子构造有所不同,下面是不同阶段的一种设计:

流水线处理器中的IF阶段。

流水线处理器中的OF阶段。

流水线处理器中的EX阶段。

流水线处理器中的MA阶段。

流水线处理器中的RW阶段。

现在通过下图显示的带有流水线寄存器的数据路径来总结关于简单流水线的讨论。注意,我们的处理器设计已经变得相当复杂,图表大小已经达到了一页,不想引入更复杂的图表。

流水线数据路径。

下图显示了流水线数据路径的抽象。该图主要包含不同单元的框图,并显示了四个流水线寄存器。我们将使用该图作为讨论先进流水线的基线。回想一下,rst寄存器操作数可以是指令的rs1字段,也可以是ret指令的返回地址寄存器。在ra和rs1之间选择多路复用器是基线流水线设计的一部分,为了简单起见,没有在图中显示它,假设它是寄存器文件单元的一部分。类似地,选择第二寄存器操作数(在rd和rs2之间)的多路复用器也被假定为寄存器文件单元的一部分,因此图中未示出,只显示选择第二个操作数(寄存器或立即数)的多路复用器。

下图显示了三条指令通过管道时的流水线图,每一行对应于每个流水线阶段,列对应于时钟周期。在示例代码中,有三条相互之间没有任何依赖关系的指令,将这些指令分别命名为:[1]、[2]和[3]。最早的指令[1]在第一个周期进入流水线的IF阶段,在第五个周期离开流水线。类似地,第二条指令[2]在第二个周期中进入流水线的IF阶段,在第六个周期中离开流水线。这些指令中的每一条都会在每个循环中前进到流水线的后续阶段,流水线图中每条指令的轨迹都是一条朝向右下角的对角线。请注意,在考虑指令之间的依赖性之后,这个场景将变得相当复杂。


流水线示意图。

下面是构建流水线图的规则:

  • 构建一个单元网格,该网格有5行和N列,其中N是希望考虑的时钟周期总数,每5行对应于一个流水线阶段。
  • 如果指令([k])在周期m中进入流水线,那么在第一行的第m列中添加一个与[k]对应的条目。
  • 在第(m+1)个周期中,指令可以停留在同一阶段(因为流水线可能会停顿,稍后将描述),也可以移动到下一行(OF阶段)。在网格单元中添加相应的条目。
  • 以类似的方式,指令按顺序从IF级移动到RW级,它从不后退,但可以在连续的周期中保持在同一阶段。
  • 一个单元格中不能有两个条目。
  • 在指令离开RW阶段后,最终将其从流水线图中删除。

根据以上规则举个简单的例子,假设有以下代码:

add r1, r2, r3
sub r4, r2, r5
mul r5, r8, r9

为上述代码段构建的流水线图如下(假设第一条指令在周期1中进入流水线):

条件分支对指令流水线操作的影响:

6阶段的CPU指令管线:

一个备选的管线描述:

下图a将加速因子绘制为在没有分支的情况下执行的指令数的函数。正如可能预期的,在极限(n趋近正无穷),有k倍的加速。图b显示了作为指令管道中级数函数的加速因子。在这种情况下,加速因子接近可以在没有分支的情况下馈送到管道中的指令数。因此,管线阶段数越大,加速的可能性越大。然而,作为一个实际问题,额外管线阶段的潜在收益会因成本增加、阶段之间的延迟以及遇到需要刷新管线的分支而抵消。

19.5.3.3 管线冲突

让我们考虑下面的代码片段:

add r1, r2, r3
sub r3, r1, r4

此处的加法指令生成寄存器r1的值,子指令将其用作源操作数,这些指令构建一个流水线图如下所示。

显示RAW(写入后读取)危险的流水线图。

显示了有一个问题。指令1在第f个周期中写入r1的值,指令2需要在第3个周期中读取其值。这显然是不可能的,我们在两条指令的相关流水线阶段之间添加了一个箭头,以指示存在依赖关系。由于箭头向左(时间倒退),我们无法在管道中执行此代码序列,被称为数据冲突(亦称数据危险,data hazard),冲突被定义为流水线中错误执行指令的可能性,这种特殊情况被归类为数据冲突,除非采取适当措施,否则指令2可能会得到错误的数据。

冲突(hazard)被定义为流水线中错误执行指令的可能性,表示由于无法获得正确的数据而导致错误执行的可能性。

这种特定类型的数据危险被称为RAW(写入后读取)冲突。上面语句的减法指令试图读取r1,需要由加法指令写入。在这种情况下,读取会在写入之后。

请注意,这不是唯一一种数据冲突,另外两种类型的数据危害是WAW(写入后写入)和WAR(读取后写入)冲突,这些冲突在我们的流水线中不是问题,因为我们从不改变指令的顺序,前一条指令总是在后一条指令之前。相比之下,现代处理器具有以不同顺序执行指令的无序(out-of-order)流水线。

在有序流水线(如我们的流水线)中,前一条指令总是在流水线中的后一条指令之前。现代处理器使用无序(out-of-order)流水线来打破这一规则,并且可以让后面的指令在前面的指令之前执行。

让我们看看下面的汇编代码段:

add r1, r2, r3
sub r1, r4, r3

指令1和指令2正在写入寄存器r1。按照顺序,流水线r1将以正确的顺序写入,因此不存在WAW危险。然而,在无序流水线中,我们有在指令1之前完成指令2的风险,因此r1可能会以错误的值结束。这便是WAW冲突的一个例子。读者应该注意,现代处理器通过使用一种称为寄存器重命名(register renaming)的技术确保r1不会得到错误的值。

让我们举一个潜在WAR冲突的例子:

add r1, r2, r3
add r2, r5, r6

指令2试图写入r2,而指令1将r2作为源操作数。如果指令2先被执行,那么指令1可能会得到错误的r2值。实际上,由于寄存器重命名等方案,这在现代处理器中不会发生。我们需要理解,冲突是发生错误的理论风险,但不是真正的风险,因为采取了足够的措施来确保程序不会被错误地执行。

本文将主要关注RAW危害,因为WAW和WAR危害仅与现代无序处理器相关。让我们概述一下解决方案的性质,为了避免RAW危险,有必要确保流水线知道它包含一对指令,其中一条指令写入寄存器,另一条指令按程序顺序稍后从同一寄存器读取。它需要确保使用者指令正确地从生产者指令接收操作数(在本例中为寄存器)的值,我们将研究硬件和软件方面的解决方案。

现在看看当我们在流水线中有分支指令时会出现的另一种危险,假设有下面的代码片段:

[1]: beq .foo
[2]: mov r1, 4
[3]: add r2, r4, r3
...
...
.foo:
[100]: add r4, r1, r2

下图展示了前三条指令的流水线图:

此处,分支的结果在循环3中被确定,并被传送到提取单元,提取单元从周期4开始提取正确的指令。如果执行了分支,则不应执行指令2和3。可悲的是,在周期2和周期3中,无法知道分支的结果。因此,这些指令将被提取,并将成为流水线的一部分。如果执行分支,则指令2和3可能会破坏程序的状态,从而导致错误,指令2和指令3被称为错误路径中的指令。这种情况称为控制冲突(control hazard)。如果分支的结果与其实际结果不同,则会执行的指令被认为是错误的。例如,如果执行分支,则程序中分支指令之后的指令路径错误。

控制冲突(control hazard)表示流水线中错误执行的可能性,因为分支错误路径中的指令可能会被执行并将结果保存在内存或寄存器文件中。

为了避免控制冲突,有必要识别错误路径中的指令,并确保其结果不会提交到寄存器文件和内存。应该有一种方法使这些指令无效,或者完全避免它们。

当不同的指令试图访问同一个资源,而该资源不能允许所有指令在同一周期内访问它时,就会出现结构冲突。让我们举个例子。假设我们有一条加法指令,可以从内存中读取一个操作数,它可以具有以下形式:

add r1, r2, 10[r3]

结构冲突(structural hazard)是指由于资源限制,指令可能无法执行。例如,当多个指令试图在同一周期内访问一个功能单元时,可能会出现这种情况,并且由于容量限制,该单元无法允许所有感兴趣的指令继续执行。在这种情况下,冲突中的一些指令需要暂停执行。

此处,有一个寄存器源操作数r2和一个内存源操作数10[r3],进一步假设流水线在OF阶段读取内存操作数的值。现在让我们来看一个潜在的冲突情形:

[1]: st r4, 20[r5]
[2]: sub r8, r9, r10
[3]: add r1, r2, 10[r3]

请注意,这里没有控制和数据冲突,尽管如此,让我们考虑流水线图中存储指令处于MA阶段时的一点。此时,指令2处于EX阶段,指令3处于OF阶段。请注意,在此循环中,指令1和3都需要访问存储单元。但如果我们假设内存单元每个周期只能服务一个请求,那么显然存在冲突情况,其中一条指令需要暂停执行。这种情况是结构冲突的一个例子。

由于具有典范性,后面我们把重点放在努力消除RAW和控制冲突上。

19.5.3.4 管线冲突解决

软件方案

现在,让我们找出一种避免RAW冲突的方法,假设有以下代码:

[1]: add r1, r2, r3
[2]: sub r3, r1, r4

指令2要求OF级中的r1值。然而,此时,指令1处于EX阶段,它不会将r1的值写回寄存器文件,因此不能允许指令2在流水线中继续。一个简单的软件解决方案是聪明的编译器可以分析代码序列并意识到存在RAW冲突,它可以在这些指令之间引入nop指令,以消除任何RAW冲突。考虑以下代码序列:

[1]: add r1, r2, r3
[2]: nop
[3]: nop
[4]: nop
[5]: sub r3, r1, r4

当子指令到达OF阶段时,加法指令将写入其值并离开流水线,因此子指令将获得正确的值。请注意,添加nop指令是一个成本高昂的解决方案,因为我们实际上是在浪费计算能力。在这个例子中,添加nop指令基本上浪费了3个周期。然而,如果考虑更长的代码序列,那么编译器可能会重新排序指令,这样就可以最小化nop指令的数量。任何编译器干预的基本目标都必须是在生产者和消费者指令之间至少有3条指令。

举个具体的例子,重新排序以下代码段,并添加足够数量的nop指令,以使其在流水线上正确执行:

add r1, r2, r3    ; 1
add r4, r1, 3     ; 2
add r8, r5, r6    ; 3
add r9, r8, r5    ; 4
add r10, r11, r12 ; 5
add r13, r10, 2   ; 6

答案是:

add r1, r2, r3    ; 1
add r8, r5, r6    ; 3
add r10, r11, r12 ; 5
nop
add r4, r1, 3     ; 2
add r9, r8, r5    ; 4
add r13, r10, 2   ; 6

我们需要理解这里的两个重要点:第一个是nop指令的能力,第二个是编译器的能力,编译器是确保程序正确性和提高性能的重要工具。在这种情况下,我们希望以这样一种方式重新排序代码,即引入最小数量的nop指令。

接下尝试使用相同的技术解决控制冲突。

如果再次查看流水线图,就会发现分支指令和分支目标处的指令之间至少需要两条指令,这是因为在EX阶段结束时得到分支结果和分支目标。此时,流水线中还有两条指令。当分支指令分别处于OF和EX阶段时,这些指令已被提取,它们可能执行错了路径。在EX阶段确定分支目标和结果之后,我们可以继续在IF阶段获取正确的指令。

现在考虑当不确定分支结果时提取的这两条指令。如果分支的PC等于p1,则它们的地址分别为p1+4和p1+8。如果不采取行动,它们不会执行错误路径。但是,如果执行分支,则需要从管道中丢弃这些指令,因为它们位于错误的路径上。

让我们看看一个简单的软件解决方案,其中硬件假设分支指令之后的两条指令没有在错误的路径上,这两条指令的位置称为延迟时隙(delay slot)。通常可以通过在分支后插入两条nop指令来确保延迟间隙中的指令不会引入错误,但这样做不会获得任何额外的性能,可以取而代之地对在分支指令之前执行的两条指令进行绑定,并将它们移动到分支之后的两个延迟时隙中。

请注意,我们不能随意将指令移动到延迟时隙,不能违反任何数据依赖约束,还需要避免RAW冲突,另外,我们不能将任何比较指令移动到延迟时隙中。如果没有适当的指令可用,那么我们总是可以返回到普通的解决方案并插入nop指令。也有可能我们只需要找到一条可以重新排序的指令,然后只需要在分支指令之后插入一条nop指令。延迟分支方法是一种非常有效的方法,可以减少需要添加以避免控制冲突的nop指令的数量。

在简单流水线数据路径中,分支后获取的两条指令的PC分别等于p1+4和p1+8(p1是分支指令的PC)。由于编译器确保这些指令始终在正确的路径上,而不管分支的结果如何,因此我们不会通过获取它们来提交错误。在确定分支的结果之后,如果不执行分支,则获取的下一条指令的PC等于p1+12,或者如果执行分支,PC等于分支目标。因此,在这两种情况下,在确定分支的结果后都会获取正确的指令,可以得出结论,软件解决方案在流水线版本的处理器上正确执行程序。

总之,软件技术的关键是延迟时隙的概念。在分支之后需要两个延迟时隙,因为不确定后续的两条指令,它们可能在错误的路径上。然而,使用智能编译器,可以设法将执行的指令移动到延迟时隙,而不管分支的结果如何。因此可以避免在延迟时隙中放置nop指令,从而提高性能。这种分支指令被称为延迟分支指令(delayed branch instruction)

如果处理器假定在其结果确定之前获取的所有后续指令都在正确的路径上,则分支指令称为延迟分支(delayed branch)。如果处理器在提取分支指令的时间与确定其结果之间提取n条指令,那么我们就说我们有n个延迟时隙。编译器需要确保正确路径上的指令占用延迟时隙,并且不会引入额外的控制或RAW冲突。编译器还可以在延迟时隙中引入nop指令。

现在举个例子。重新排序下面的汇编代码,以便在具有延迟分支的流水线处理器上正确运行,假设每个分支指令有两个延迟时隙。

add r1, r2, r3  ; 1
add r4, r5, r6  ; 2
b .foo          ; 3
add r8, r9, r10 ; 4

答案:

b .foo          ; 3
add r1, r2, r3  ; 1
add r4, r5, r6  ; 2
add r8, r9, r10 ; 4

硬件方案

上面研究了消除RAW和控制冲突的软件解决方案,但编译器方法不是很通用,原因是:

  • 首先,程序员总是可以手动编写汇编代码,并尝试在处理器上运行它。在这种情况下,错误的可能性很高,因为程序员可能没有正确地重新排序代码以消除冲突。
  • 其次,还有可移植性问题。为一条流水线编写的一段汇编代码可能无法在遵循相同ISA的另一条流水线上运行,因为它可能具有不同数量的延迟时隙或不同数量的阶段。如果我们的汇编程序不能在使用相同ISA的不同机器之间移植,那么引入汇编程序的主要目标之一就失败了。

让我们尝试在硬件层面设计解决方案,硬件应确保无论汇编程序如何,都能正确运行,输出应始终与单周期处理器产生的输出相匹配。为了设计这样的处理器,需要确保指令永远不会接收错误的数据,并且不会执行错误的路径指令。可以通过确保以下条件成立来实现:

  • 数据锁定(Data-Lock)。我们不能允许指令离开OF阶段,除非它从寄存器文件接收到正确的数据,意味着需要有效地暂停IF和OF阶段,并让其余阶段执行,直到OF阶段中的指令可以安全地读取其操作数。在此期间,从OF阶段传递到EX阶段的指令需要是nop指令。
  • 分支锁定(Branch-Lock)。我们从不在错误的路径上执行指令,要么暂停处理器直到结果已知,要么使用技术确保错误路径上的指令不能将其更改提交到内存或寄存器。

在流水线的纯硬件实现中,有时需要阻止新指令进入流水线阶段,直到某个条件停止保持。停止流水线阶段接受和处理新数据的概念称为流水线暂停(pipeline stall)流水线互锁(pipeline interlock)。其主要目的是确保程序执行的正确性。

如果我们确保数据锁定和分支锁定条件都成立,那么流水线将正确执行指令。请注意,这两种情况都要求管道的某些阶段可能需要暂停一段时间,这些暂停也称为流水线互锁。换言之,通过保持流水线空闲一段时间,可以避免执行可能导致错误执行的指令。下表是纯软件和硬件方案实现流水线的整个逻辑的利弊。请注意,在软件解决方案中,我们尝试重新排序代码,然后插入最小数量的nop指令,以消除冲突的影响。相比之下,在硬件解决方案中,我们动态地暂停部分流水线,以避免在错误的路径中执行指令,或使用错误的操作数值执行指令。暂停流水线相当于让某些阶段保持空闲,并在其他阶段插入nop指令。

属性 软件 硬件(互锁)
可移植性 仅限于特定处理器 程序可以在任何处理器上运行
分支 通过使用延迟时隙,可能没有性能损失 本文需要暂停流水线2个周期
RAW冲突 可以通过代码调度消除它们 需要暂停流水线
性能 高度依赖于程序的特性 带联锁的流水线的基本版本会比软件慢

我们观察到,软件解决方案的效率高度依赖于程序的性质,可以对某些程序中的指令重新排序,以完全隐藏RAW冲突和分支的有害影响。然而,在某些程序中,我们可能没有找到足够的可以重新排序的指令,因此被迫插入大量nop指令,会降低性能。相比之下,一个遵守数据锁和分支锁条件的纯硬件方案,只要检测到可能错误执行的指令,就会暂停流水线。这是一种通用方法,比纯软件解决方案慢。

现在可以将硬件和软件解决方案结合起来,重新排序代码,使其尽可能对流水线友好,然后在带有互锁的流水线上运行它。注意,在这种方法中,编译器不保证正确性,只是将生产者指令和消费者指令尽可能分开,并在支持它们的情况下利用延迟分支。这减少了我们需要暂停流水线的次数,并确保了两全其美。在设计带互锁的流水线前,下面借助流水线图来研究互锁的性质。

现在绘制带有互锁的流水线图,考虑下面的代码片段。

add r1, r2, r3
sub r4, r1, r2

带气泡的流水线图。

指令[1]写入寄存器r1,指令[2]从r1读取,显然存在RAW依赖关系。为了确保数据锁定条件,我们需要确保指令[2]仅在读取了指令[1]写入的r1值时才离开OF阶段,仅在循环6中可行(上图),然而指令[2]在周期3中到达OF阶段。如果没有冲突,则理想情况下它会在周期4中进入EX阶段。由于我们有互锁,指令[2]也需要在周期4、5和6中保持在OF阶段中。问题是,当EX阶段在周期4、5和6中没有处理有效指令时,它会做什么?类似地,MA阶段在周期5、6和7中不处理任何有效的指令。我们需要有一种方法来禁用流水线阶段,这样我们就不会执行多余的工作,标准方法是将nop指令插入阶段。

再次参考上图。在循环3结束时,知道需要引入互锁,因此在周期4中,指令[2]保留在OF阶段,将nop指令插入EX阶段,该nop指令在周期5中移动到MA级,在周期6中移动到RW级。该nop命令称为流水线气泡。气泡是由互锁硬件动态插入的nop指令,在类似于正常指令的流水线阶段中移动。同样,在循环5和6中,我们需要插入管线气泡。最后,在周期7中,指令[2]可以自由地进入EX和后续阶段。气泡不起任何作用,因此当阶段遇到气泡时,没有任何控制信号打开。要注意的另一个微妙的点是,不能在同一个周期内对同一个寄存器进行读写,需要优先选择写入,因为它是较早的指令,而读取需要暂停一个周期。

实现气泡有两种方法:

  • 可以在指令包中有一个单独的气泡位。每当位为1时,该指令将被解释为一个气泡。
  • 可以将指令的操作码更改为nop的操作码,并将其所有控制信号替换为0。这种方法更具侵入性,但可以完全消除电路中的冗余工作。在前一种方法中,控制信号将打开,由其激活的单元将保持运行。硬件需要确保气泡不能对寄存器或内存进行更改。

流水线气泡(pipeline bubble)是由互锁硬件动态插入流水线寄存器中的nop指令,气泡以与正常指令相同的方式在流水线中传播。

总之,通过在流水线中动态插入气泡,可以避免数据冲突。

接下来阐述div和mod指令等慢指令的问题。在大多数流水线中,这些指令很可能需要n(n>1)个周期才能在EX阶段执行。在n个周期的每个周期中,ALU完成div或mod指令的部分处理。每个这样的循环被称为T状态(T State),通常一个阶段具有1T状态,但慢指令的EX阶段有许多T状态。因此,为了正确实现慢指令,需要暂停IF和OF阶段(n-1)个周期,直到操作完成。

为了简单起见,我们将不再讨论这个问题,相反,继续进行简单的假设,即所有流水线阶段都是平衡的,并且需要1个周期来完成它们的操作。现在看看控制冲突,首先考虑以下代码片段。

[1]: beq .foo
[2]: add r1, r2, r3
[3]: sub r4, r5, r6
....
....
.foo:
[4]: add r8, r9, r10

如果分支被执行,可以在流水线中插入气泡,而不是使用延迟分支,否则不需要做任何事情。假设分支被去掉,这种情况下的流水线图如下所示。

在这种情况下,指令[1]的分支条件的结果在循环3中决定。此时,指令[2]和[3]已经在流水线中(分别在IF和of阶段)。由于分支条件求值为take,我们需要取消指令[2]和[3],否则它们将被错误执行。因此将它们转换为气泡,如上图所示,指令[2]和[3]在循环4中转换为气泡。其次,在循环4从正确的分支目标(.foo)中提取,因此指令[4]进入流水线。两个气泡都经过所有流水线阶段,最后分别在循环6和7中离开流水线。

因此可以通过在流水线中动态引入气泡来确保这两个条件(数据锁定和分支锁定)。下面更详细地看看这些方法。

为了确保数据锁定条件,需要确保OF阶段中的指令与后续阶段中的任何指令之间没有冲突,冲突被定义为可能导致RAW冲突的情况。换句话说,如果后续阶段的指令写入由OF阶段的指令读取的寄存器,则存在冲突。因此需要两个硬件来实现数据锁定条件,第一步是检查是否存在冲突,第二步是确保流水线停止。

首先看看冲突检测硬件。冲突检测硬件需要将OF阶段中的指令的内容与其他三个阶段(即EX、MA和RW)中的每个指令的内容进行比较,如果与这些指令中的任何一条发生冲突,可以声明有冲突。让我们关注检测冲突的逻辑,简要介绍一下冲突检测电路的伪代码,设OF阶段中的指令为[A],后续阶段中的一条指令为[B]。检测冲突的算法伪代码如下所示:

Data: Instructions: [A] and [B]
Result: Conflict exists (true), no conflict (false)

1 if [A].opcode 2 (nop,b,beq,bgt,call) then
/* Does not read from any register */
2 return false
3 end

4 if [B].opcode 2 (nop, cmp, st, b, beq, bgt, ret) then
/* Does not write to any register */
5 return false
6 end

/* Set the sources */
7 src1   [A]:rs1
8 src2   [A]:rs2
9 if [A].opcode = st then
10 src2   [A]:rd
11 end

12 if [A].opcode = ret then
13 src1   ra
14 end
/* Set the destination */
15 dest   [B]:rd
16 if [B].opcode = call then
17 dest   ra
18 end

/* Check if the first operand exists */
19 hasSrc1   true
20 if [A].opcode 2 (not,mov) then
21 hasSrc1   false
22 end

/* Check the second operand to see if it is a register */
23 hasSrc2   true
24 if [A].opcode =2 (st) then
25 if [A]:I = 1 then
26 hasSrc2   false
27 end
28 end

/* Detect conflicts */
29 if (hasSrc1 = true) and (src1 = dest) then
30 return true
31 end
32 else if (hasSrc2 = true) and (src2 = dest) then
33 return true
34 end
35 return false

用硬件实现上述算法很简单,只需要一组逻辑门和多路复用器,大多数硬件设计者通常用硬件描述语言(如Verilog或VHDL)编写类似于上述算法的电路描述,并依靠智能编译器将描述转换为实际电路。

我们需要三个冲突检测器(\(\mathrm{OF} \leftrightarrow \mathrm{EX}, \mathrm{OF} \leftrightarrow \mathrm{MA}, \mathrm{OF} \leftrightarrow \mathrm{RW}\))。如果没有冲突,则指令可以自由地进入EX阶段,但如果至少有一个冲突,则需要暂停IF和OF阶段。一旦指令通过OF阶段,它就保证拥有所有的源操作数。

现在来看看流水线的暂停。我们基本上需要确保在发生冲突之前,没有新的指令进入IF和OF阶段,这可以通过禁用PC和IF-OF流水线寄存器的写入功能来简单地确保。因此,它们不能接受时钟边缘(clock edge)上的新数据,将继续保持它们以前的值。

其次,还需要在流水线中插入气泡,例如从OF传递到EX阶段的指令需要是无效指令或气泡,可以通过传递nop指令来确保。因此确保数据锁定条件的电路是直接的,需要一个连接到PC的冲突检测器和IF-OF寄存器。在发生冲突之前,这两个寄存器将被禁用,无法接受新数据。我们强制OF-EX寄存器中的指令包含nop,流水线的增强电路图如下图所示。

带互锁的流水线的数据路径(实现数据锁定条件)。

接下来阐述分支锁定条件。

假设流水线中有一条分支指令(b、beq、bgt、call、ret)。如果有延迟时隙,那么数据路径与上图所示的相同,不需要做任何更改,因为执行的整个复杂性已经加载到了软件中。然而,将流水线暴露于软件有其利弊,如果在管线中添加更多阶段,那么现有的可执行文件可能会停止工作。为了避免这种情况,让我们设计一个不向软件暴露延迟时隙的流水线。

有两个设计选项:

  • 第一种:可以假设在确定结果之前不采取分支。我们可以继续在分支之后获取这两条指令并进行处理,一旦在EX阶段决定了分支的结果,就可以根据结果采取适当的行动。如果未执行分支,则在分支指令之后获取的指令位于正确的路径上,无需再执行任何操作。但是,如果执行了分支,则必须取消这两条指令,并用流水线气泡(nop指令)替换它们。
  • 第二种:暂停流水线,直到分支的结果被确定,而不管结果如何。

显然,第二种设计的性能低于假设不采用分支的第一种替代方案,例如,如果一个分支30%的时间没有被占用,那么对于第一个设计,30%的时间都在做有用的工作。然而,对于第二个选项,我们在获取分支指令后的2个周期中从未做过任何有用的工作。

因此,让我们从性能的角度考虑第一个设计,只有在分支被占用时,才取消分支后的两个指令,这种方法为预测不采用(predict not
taken),因为实际上是在预测不采取的分支。稍后,如果发现此预测错误,则可以取消错误路径中的指令。

如果分支指令的PC等于p,那么选择在接下来的两个周期中在p+4和p+8处获取指令。如果分支没有被执行,那么将继续执行。但是,如果分支被执行,那么将取消这两条指令,并将它们转换为流水线气泡。

我们不需要对数据路径进行任何重大更改,需要一个小型分支冲突单元,从EX阶段接收输入。如果执行分支,则在下一个周期中,它将If-OF和OF-EX阶段中的指令转换为流水线气泡。带有分支互锁单元的扩展数据路径如下图所示。

带互锁的流水线的数据路径(实现数据锁定和分支锁定条件)。

接下来阐述带转发(Forwarding)的流水线。

上面已经实现了一个带有互锁的流水线。互锁确保流水线正确执行,而不管指令之间的依赖性如何。对于数据锁定条件,我们建议在流水线中添加互锁,在寄存器文件中有正确的值之前,不允许指令离开操作数获取阶段。然而,下面将看到,不必总是添加互锁。事实上,在很多情况下,正确的数据已经存在于流水线寄存器中,尽管不存在于寄存器文件中。可以设计一种方法,将数据从内部流水线寄存器正确地传递到适当的功能单元。考虑以下代码:

add r1, r2, r3
sub r4, r1, r2

下图仅包含这两条指令的流水线图,(a)显示了带互锁的流水线图,(b)显示了无互锁和气泡的流水线图。现在尝试论证不需要在指令之间插入气泡。

(a)带互锁的流水线图,(b)无互锁和气泡的流水线图。

让我们深入查看上图(b)。指令1在EX阶段结束时产生其结果,或者在周期3结束时产生结果,并在周期5中写入寄存器文件。指令2在周期3开始时需要寄存器le中的r1值,显然是不可能的,因此建议添加流水线互锁来解决此问题。

让我们尝试另一种解决方案,允许指令执行,然后在循环3中,[2]将获得错误的值,允许它在周期4中进入EX阶段。此时,指令[1]处于MA阶段,其指令包包含正确的r1值。r1值是在前一个周期中计算的,存在于指令包的aluResult字段中,[1] 的指令包在周期4中位于EX-MA寄存器中。现如果在EX-MA的aluResult字段和ALU的输入之间添加一个连接,那么可以成功地将r1的正确值传输到ALU。我们的计算不会出错,因为ALU的操作数是正确的,因此ALU运算的结果也将被正确计算。

下图显示了我们在流水线图中的操作结果,将指令[1]的MA阶段添加到指令[2]的EX阶段。由于箭头不会在时间上倒退,因此可以将数据(r1的值)从一个阶段转发(forward)到另一个阶段。

在流水线中转发的示例。

转发(Forwarding)是一种通过阶段之间的直接连接在不同流水线阶段中的指令之间传输操作数值的方法,不使用寄存器文件跨指令传输操作数的值,从而避免昂贵的流水线互锁。

我们刚刚研究了一种非常强大的技术,可以避免管线中的停顿,称为转发。本质上,我们允许操作数的值在指令之间流动,方法是跨阶段直接传递它们,不使用寄存器文件跨指令传输值。转发的概念允许我们背靠背地(以连续的周期)执行指令[1]和[2],不需要添加任何暂停周期。因此,不需要重新排序代码或插入nop。

为了在指令[1]和[2]之间转发r1的值,我们在MA级和EX级之间添加了一个连接,上图9中通过在指令[1]和[2]的相应阶段之间画一个箭头来显示这种联系。这个箭头的方向是垂直向上的,由于它没有在时间上倒退,有可能转发该值,否则是不可能的。

现在让我们尝试回答一个一般性问题——可以在所有指令对之间转发值吗?注意,不需要是连续的指令,即使生产者和消费者ALU指令之间有一条指令,我们仍然需要转发值。现在尝试考虑管线中各阶段之间的所有可能的转发路径。

广泛遵循的转发基本原则如下:

  • 在后期和早期之间添加了转发路径。
  • 在管线中尽可能迟地转发一个值。例如,如果给定阶段不需要给定值,并且可以在稍后阶段从生产者指令中获取该值,那么我们等待在稍后阶段获取转发的值。

请注意,这两个基本原则都不影响程序的正确性,它们只允许消除冗余的转发路径。现在,系统地看看管道中需要的所有转发路径:

  • RW --> MA:MA阶段需要来自RW阶段的转发路径,考虑下图所示的代码片段,指令[2]需要MA阶段(周期5)中的r1值,而指令[1]在周期4结束时从内存中获取r1值。因此,它可以在周期5中将其值转发给指令[2]。

  • RW --> EX:下图所示的代码段显示了一条加载指令,它在周期4结束时获取寄存器r1的值,以及一条后续的ALU指令,它需要周期5中的r1值。因为不会在时间上倒退,所以可以转发该值。

  • MA --> EX:下图所示的代码段显示了一条ALU指令,该指令在周期3结束时计算寄存器r1的值,以及一条连续的ALU指令在周期4中需要r1的数值。在这种情况下,还可以通过在MA和EX级之间添加互连(转发路径)来转发数据。

  • RW --> OF:通常OF阶段不需要转发路径,因为它没有任何功能单元,不需要立即使用值,可以稍后根据原则2转发价值。然而,唯一的例外是从RW阶段转发,无法稍后转发该值,因为指令将不在管线中。因此有必要添加从RW到OF级的转发路径,需要RW --> OF转发的代码段示例如下图所示。指令[1]通过在周期4结束时从内存中读取r1的值来生成r1值,然后它在周期5中将r1值写入寄存器文件。同时,指令[4]尝试在周期5的OF阶段读取r1值,不幸的是,这里存在冲突。因此,我们建议通过在RW和OF阶段之间添加转发路径来解决冲突。因此,禁止指令[4]读取r1值的寄存器文件。相反,指令[4]]使用RW --> OF转发路径从指令[1]获取r1值。

不需要添加以下转发路径:MA-->OF和EX-->OF,因为我们可以使用以下转发路径(RW-->EX)和(MA-->EX)。根据原则2,需要避免冗余转发路径,因此不添加从MA和EX级到OF级的转发路径。我们不向IF阶段添加转发路径,因为在这个阶段,还没有解码指令,不知道其操作数。

现在又衍生了一个问题:转发是否完全消除了数据冲突?

现在回答这个问题。先考量ALU指令,它们在EX阶段产生结果,并准备在MA阶段前进,任何后续的使用者指令都需要前一条ALU指令在EX阶段最早生成的操作数的值。此时可以实现成功的转发,因为操作数的值在MA阶段已经可用。如果生产者指令已离开流水线,则任何后续指令都可以使用任何可用的转发路径或从寄存器文件获取值。如果生产者指令是ALU指令,那么总是可以将ALU运算的结果转发给消费者指令。为了证明这一事实,需要考虑所有可能的指令组合,并判断是否可以将输入操作数转发给使用者指令。

唯一显式生成寄存器值的其他指令是加载指令。请记住,存储指令不会写入任何寄存器。让我们看看加载指令,加载指令在MA阶段结束时产生其值,因此它准备在RW阶段转发其值。考虑下图中的代码片段及其流水线图。

加载-使用冲突。

指令[1]是写入寄存器r1的加载指令,指令[2]是使用寄存器r1作为源操作数的ALU指令,加载指令在周期5开始时准备好转发。不幸的是,ALU指令在周期4开始时需要r1的值,故而需要在流水线图中绘制一个箭头,该箭头在时间上向后流动。因此,在这种情况下,转发是不可能的。

加载-使用冲突(Load-Use Hazard)是指加载指令将加载的值提供给在EX阶段需要该值的紧随其后的指令的情况。即使有转发,管线也需要在加载指令之后插入一个暂停周期。

这是需要在管线中引入暂停循环的唯一情况,这种情况被称为加载-使用冲突,加载指令将加载的值提供给在EX阶段需要该值的紧随其后的指令。消除加载-使用冲突的标准方法是允许管线插入气泡,或者使用编译器重新排序指令或插入nop指令。

总之,具有转发的管线确实可能需要互锁,唯一的特殊情况是加载-使用冲突。

请注意,如果在存储加载值的加载指令之后有一个存储指令,那么我们不需要插入暂停循环,因为存储指令需要MA阶段的值。此时,加载指令处于RW阶段,可以转发该值。

如果要实现管线的转发,需要根据不同的管线阶段来实现。

支持转发的OF阶段如下图所示,基线管道中没有转发的多路复用器用较浅的颜色着色,而为实现转发而添加的附加多路复用器被着色为较深的颜色。

下图显示了修改后的EX阶段。EX级从OF级获得的三个输入是A(第一个ALU操作数)、B(第二个ALU运算数)和op2(第二寄存器操作数)。对于A和B,我们添加了两个复用器M3和M4,以在OF级中计算的值和分别从MA和RW级转发的值之间进行选择。对于可能包含存储值的op2字段,我们不需要MA --> EX转发,因为在MA阶段需要存储值,因此我们可以使用RW --> MA转发,从而减少一条转发路径。因此,多路复用器M5具有两个输入(默认值和从RW级转发的值)。

下图显示了具有额外转发支持的MA阶段。内存地址在EX阶段计算,并保存在指令包的aluResult字段中,内存单元直接使用该值作为地址。然而,在存储的情况下,需要存储的值(op2)可以从RW阶段转发,因此添加了多路复用器M6,它在指令包中的op2字段和从RW级转发的值之间进行选择。电路的其余部分保持不变。

下图显示了RW阶段。因为是最后一个阶段,所以它不使用任何转发值。但是,它将写入寄存器le的值分别发送到MA、EX和OF阶段。

下图将所有部分放在一起,并显示了支持转发的管线。总之,我们需要添加6个多路复用器,并在单元之间进行一些额外的互连,以传递转发的值。我们设想一个专用的转发单元,它为多路复用器(图中未示出)计算控制信号。除了这些小的更改,不需要对数据路径进行其他重大更改。

带转发的流水线数据路径(简图)。

我们在讨论转发时使用了一个简图(上图)。需要注意的是,实际电路现在变得相当复杂。除了对数据路径的扩展,还需要添加一个专用转发单元来为多路复用器生成控制信号。详细图片如下图所示。

现在将互锁逻辑添加到管线中,需要数据锁定和分支锁定条件的互锁逻辑。请注意,现在已经成功处理了除加载-使用冲突以外的所有RAW冲突。在加载-使用冲突的情况下,只需要停止一个周期,大大简化了数据锁定电路。如果EX阶段有加载指令,就需要检查加载指令和OF阶段的指令之间是否存在RAW数据依赖关系,不需要考虑的唯一RAW冲突是加载-存储依赖性,即加载写入包含存储值的寄存器,我们不需要暂停,因为可以将要存储的值从RW转发到MA阶段。对于所有其他数据依赖性,需要通过引入气泡将管线暂停1个周期,此举可以解决加载-使用冲突,确保分支锁定条件的电路保持不变。还需要检查EX阶段中的指令,如果它是一个执行的分支,需要使if和OF阶段的指令无效。最后应注意,互锁始终优先于转发。

19.5.3.5 性能标准和测量

本节讨论流水线处理器的性能。

需要首先在处理器的上下文中定义性能的含义。大多数时候,当我们查找笔记本电脑或智能手机的规格时,会被大量的术语淹没,比如时钟频率、RAM和硬盘大小,遗憾的是,这些术语都不能直接表示处理器的性能。计算机标签上从未明确提及性能的原因是“性能”一词相当模糊,处理器的性能一词总是指给定的程序或汇编,因为处理器对不同程序的性能不同。

给定一个程序P,让我们尝试量化给定处理器的性能。如果P在A上执行P的时间比在B上执行P所需的时间短,那么处理器A比处理器B性能更好。因此,量化给定程序的性能非常简单,测量运行程序所需的时间,然后计算其倒数,这个数字可以解释为与处理器相对于程序的性能成正比。

首先计算运行程序P所需的时间:

\[\begin{aligned} \tau &=\# \text { seconds } \\ \\ &=\frac{\# \text { seconds }}{\# \text { cycles }} \times \frac{\# \text { cycles }}{\# \text { instructions }} \times(\# \text { instructions }) \\ \\ &=\underbrace{\frac{\# \text { seconds }}{\# \text { cycles }}}_{1 / f} \times \underbrace{\frac{\# \text { cycles }}{\# \text { instructions }}}_{C P I} \times(\# \text { instructions }) \\ \\ &=\frac{ \text { CPI } \times(\# \text { instructions })}{f} \\ \end{aligned} \]

  • 每秒的周期数是处理器的时钟频率(f)。

  • 每个指令的平均周期数称为CPI(Cycles per instruction),其逆数(每个周期的指令数)称为IPC(Instructions per cycle)

  • 最后一项是指令数(缩写为#insts)。注意,是动态指令的数量,或者处理器实际执行的指令数量,不是程序可执行文件中的指令数。

静态指令是程序的二进制或可执行文件包含指令列表里的每条指令。

动态指令是静态指令的实例,当指令进入流水线时由处理器创建。

我们现在可以将性能P定义为与时间\(\tau\)成反比的量(称为性能等式):

\[P \propto \frac{I P C \times f}{\# \text { insts }} \]

因此可以得出结论,处理器相对于程序的性能与IPC和频率成正比,与指令数成反比。

现在看看单周期处理器的性能。对于所有指令,其CPI都等于1,性能与\(\cfrac{f}{\text{instsCount}}\)成正比,是一个相当微不足道的结果。当增加频率时,单周期处理器会按比例变快。同样,如果能够将程序中的指令数量减少X倍,那么性能也会增加X倍。让我们考虑流水线处理器的性能,分析更为复杂,见解也非常深刻。

下面阐述性能方程中的三个项:

  • 指令数量。程序中指令的数量取决于编译器的智能,真正智能的编译器可以通过从ISA中选择正确的指令集并使用智能代码转换来减少指令。例如,程序员通常有一些归类为死代码的代码,此代码对最终输出没有影响,聪明的编译器可以删除它能找到的所有死代码。附加指令的另一个来源是溢出和恢复寄存器的代码,编译器通常对非常小的函数执行函数内联,这种优化动态地移除这些函数,并将它们的代码粘贴到调用函数的代码中。对于小函数,是一个非常有用的优化,可以摆脱溢出和恢复寄存器的代码。还有许多编译器优化有助于减少代码大小,本节假设指令的数量是常数,只关注硬件方面。

  • 计算周期总数。假设一个理想的管线不需要插入任何气泡或停滞,它将能够每个周期完成一条指令,因此CPI为1。假设一个包含n条指令的程序,并让流水线有k个阶段,让我们计算所有n条指令离开流水线所需的周期总数。

    第一条指令在周期1中进入流水线,在周期k中离开流水线,每个周期都会有一条指令离开流水线。在(n-1) 周期,所有指令都会离开流水线,循环总数为n+k- 1。CPI等于:

    \[CPI = \cfrac{n+k-1}{n} \]

    请注意,CPI趋于1,因为n趋于正无穷。

  • 与频率的关系。让指令在单周期处理器上完成执行所需的最大时间为\(t_{max}\),也称为算法工作总量。我们在计算\(t_{max}\)时忽略了流水线寄存器的延迟。现在将数据路径划分为k个流水线阶段,需要添加\(k-1\)个流水线寄存器。设流水线寄存器的延迟为\(l\),如果假设所有流水线阶段都是平衡的(做同样的工作,花费同样的时间),那么最慢的指令在一个阶段完成工作所需的时间等于\(\cfrac{t_{max}}{k}\)。每阶段的总时间等于电路延迟和流水线寄存器的延迟:

    \[t_{stage} = \cfrac{t_{max}}{k} + 1 \]

    现在,最小时钟周期时间必须等于流水线阶段的延迟,因为设计流水线时的假设是每个阶段只需要一个时钟周期。因此,最小时钟周期时间(\(t_{clk}\))或最大频率(\(f\))等于:

    \[t_{c l k}=\frac{1}{f}=\frac{t_{\max }}{k}+l \]

下面计算管线的性能。简单地假设性能等于(f/CPI),因为指令数是常数(n)。

\[\begin{array}{l} P=\frac{f}{C P I}\\ =\frac{\frac{1}{\frac{t_{m a x}}{k}+l}}{\frac{n+k-1}{n}}\\ =\frac{n}{\left(t_{\max } / k+l\right) \times(n+k-1)}\\ =\frac{n}{\left((n-1) t_{\max } / k+\left(t_{\max }+l n-l\right)+l k\right.} \end{array} \]

尝试通过选择正确的k值来最大化性能,有:

\[\begin{aligned} & \frac{\partial\left((n-1) t_{\max } / k+\left(t_{\max }+l n-l\right)+l k\right)}{\partial k}=0 \\ \Rightarrow &-\frac{(n-1) t_{\max }}{k^{2}}+l=0 \\ \Rightarrow & k=\sqrt{\frac{(n-1) t_{\max }}{l}} \end{aligned} \]

需要在CPI方程中纳入停顿的影响,假设指令(n)的数量非常大。让理想的CPI是\(CPI_{ideal}\),在本例,\(CPI_{ideal}=1\),有:

\[CPI = CPI_{ideal} + stallRate \times stallPenalty \]

为了最大化性能,需要将分母最小化,得到:

\[\begin{aligned} & \frac{\partial\left((n-1) t_{\max } / k+\left(r c n t_{\max }+t_{\max }+l n-l\right)+l k(1+r c n)\right)}{\partial k}=0 \\ \Rightarrow &-\frac{(n-1) t_{\max }}{k^{2}}+l(1+r c n)=0 \\ \Rightarrow & k=\sqrt{\frac{(n-1) t_{\max }}{l(1+r c n)}} \approx \sqrt{\frac{t_{\max }}{l r c}} \quad(\text { as } n \rightarrow \infty) \end{aligned} \]

为了确定管线阶段的最佳数量的性能,假设n是正无穷,因此\((n + k- 1) / n\)趋近于1,因此有:

\[\begin{aligned} P_{\text {ideal }} &=\frac{1}{\left(t_{\max } / k+l\right) \times(1+r c k)} \\ &=\frac{1}{t_{\max } / k+l+r c t_{\max }+l r c k} \\ &=\frac{1}{t_{\max } \times \sqrt{\left(\frac{l r c}{t_{\max }}\right)}+l+r c t_{\max }+l r c \times \sqrt{\left(\frac{t_{\max }}{l r c}\right)}} \\ &=\frac{1}{r c t_{\max }+2 \sqrt{l_{r c t} \max }+l} \\ &=\frac{1}{\left(\sqrt{r c t_{\max }}+\sqrt{l}\right)^{2}} \end{aligned} \]

大多数时候,我们不会衡量处理器对一个程序的性能。考虑一组已知的基准程序,并测量处理器相对于所有程序的性能,以获得统一的图形。大多数处理器供应商通常总结其处理器相对于SPEC(Standard Performance Evaluation Corporation)的性能基准,发布用于测量、总结和报告处理器和软件系统性能的基准套件。

计算机架构通常使用SPEC CPU基准套件来衡量处理器的性能。SPEC CPU 2006基准有两种程序类型:整数算术基准(SPECint)和浮点基准(SPECfp)有12个用C/C++编写的SPECint基准测试。基准测试包含C编译器、基因测序器、AI引擎、离散事件模拟器和XML处理器的部分,在类似的线路上,SPECfp套件包含17个程序,解决了物理、化学和生物学领域的不同问题。

大多数处理器供应商通常计算SPEC分数,代表处理器的性能,建议的过程是采用基准测试在参考处理器上花费的时间与基准测试在给定处理器上花费时间的比率,SPEC分数等于所有比率的几何平均值。在计算机体系结构中,当我们报告平均相对性能(如SPEC分数)时,通常使用几何平均值。对于报告平均执行时间(绝对时间),可以使用算术平均值。

有时报告的不是SPEC分数,而是平均每秒执行的指令数,而对于科学程序,则是平均每秒浮点运算数,这些指标提供了处理器或处理器系统的速度指示。通常使用以下术语:

  • KIPS:每秒千(\(10^3\))条指令。
  • MIPS:每秒百万(\(10^6\))条指令。
  • MFLOPS:每秒百万(\(10^6\))次浮点运算。
  • GFLOPS:每秒千兆(\(10^9\))次浮点运算。
  • TFLOPS:每秒万亿(\(10^{12}\))次浮点运算。
  • PFLOPS:每秒千万亿(\(10^{15}\))次浮点运算。

现在通过查看性能、编译器设计、处理器架构和制造技术之间的关系来总结讨论。再次考虑性能等式:

\[P=\cfrac{f \times I P C}{\# i n s t s} \]

如果最终目标是最大化性能,那么需要最大化频率(f)和IPC,同时最小化动态指令(#insts)的数量。有三个变量在我们的控制之下,即处理器架构、制造技术和编译器。请注意,此处使用术语“构架”来指代处理器的实际组织和设计,然而文献通常使用体系结构来指ISA和处理器的设计。下面详细阐述每个变量。

  • 编译器

通过使用智能编译器技术,可以减少动态指令的数量,也可以减少暂停的数量,将改善IPC。下面示例通过重新排序add和ld指令来删除一个暂停周期。在类似的行中,编译器通常会分析数百条指令,并对它们进行最佳排序,以尽可能减少暂停。

; -----示例1-----
; 在不违反程序正确性的情况下重新排序以下代码,以减少暂停。
add r1, r2, r3
ld r4, 10[r5]
sub r1, r4, r2

;答案
ld r4, 10[r5]
add r1, r2, r3
sub r1, r4, r2
; 没有加载-使用冲突,程序的逻辑保持不变。

; -----示例2-----
; 在不违反程序正确性的情况下重新排序以下代码,以减少暂停。假设有2个延迟时隙的延迟分支.
add r1, r2, r3
ld r4, 10[r5]
sub r1, r4, r2
add r8, r9, r10
b .foo

; 答案
add r1, r2, r3
ld r4, 10[r5]
b .foo
sub r1, r4, r2
add r8, r9, r10
; 消除了加载-使用风险,并最佳地使用了延迟时隙。
  • 架构

我们使用流水线设计了一个高级架构。请注意,流水线本身并不能提高性能,由于暂停,与单周期处理器相比,流水线减少了程序的IPC。流水线的主要好处是它允许我们以更高的频率运行处理器,最小周期时间从单循环流水线的\(t_{max}\)减少到k级流水线机器的\(t_{max}/k+l\)。由于每个周期都完成一条新指令的执行,除非出现暂停,所以可以在流水线机器上更快地执行一组指令,指令执行吞吐量要高得多。

流水线的主要好处是以更高的频率运行处理器,可以确保更高的指令吞吐量(更多的指令每秒完成执行)。与单周期处理器相比,流水线本身减少了程序的IPC,也增加了处理任何单个指令所需的时间。

延迟分支和转发等技术有助于提高流水线机器的IPC,我们需要专注于通过各种技术提高复杂管线的性能。需要注意的重要一点是,架构技术影响频率(通过流水线阶段的数量)和IPC(通过转发和延迟分支等优化)。

  • 制造工艺

制造工艺影响晶体管的速度,进而影响组合逻辑块和锁存器的速度,晶体管越小,速度越快。因此总算法工作量(\(t_{max}\))和锁存延迟(l)也在稳步减少,可以在更高的频率下运行处理器,从而提高性能。制造技术只影响我们运行处理器的频率,对IPC或指令数量没有任何影响。

总之,可以用下图总结这一段的讨论。

性能、编译器、架构和技术之间的关系。

请注意,总体情况并不像本节描述的那么简单,还需要考虑功率和复杂性问题。通常,由于复杂性的增加,实现超过20个阶段的流水线非常困难。其次,大多数现代处理器都有严重的功率和温度限制,这个问题也称为功率墙(power wall,下图)。通常不可能提高频率,因为我们无法承受功耗的增加,根据经验法则,功率随频率的立方而增加,将频率增加10%会使功耗增加30%以上,非常之大。设计者越来越避免以非常高的频率运行的深度流水线设计。

英特尔x86微处理器的时钟频率和功耗超过八代30年。奔腾4在时钟频率和功率上有了戏剧性的飞跃,但在性能上没有那么出色。Prescott的热问题导致了奔腾4系列的报废。Core 2系列恢复为更简单的流水线,具有更低的时钟速率和每个芯片多个处理器。Core i5管道紧随其后。

关于性能,最后要进一步讨论的是功率和温度的问题。

功率和温度问题在这些年变得越来越重要。高性能处理器芯片通常在正常操作期间消耗60-120W的功率。,如果在一台服务器级计算机中有四个芯片,那么将大致消耗400W的功率。一般来说,计算机中的其他组件,如主存储器、硬盘、外围设备和风扇,也会消耗类似的电量,总功耗约为800W。如果增加额外的开销,例如电源、显示硬件的非理想效率,则功率需求将达到约1KW。一个拥有100台服务器的典型服务器场将需要100千瓦的电力来运行计算机。此外还需要冷却装置(如空调),通常为了去除1W的热量,需要0.5W的冷却功率,因此服务器农场的总功耗约为150千瓦。相比之下,一个典型的家庭的额定功率为6-8千瓦,意味着一个服务器农场消耗的电力相当于20-25个家庭使用的电力,非常显而易见。请注意,包含100台机器的服务器场是一个相对较小的设置,实际上,有更大的服务器农场,包含数千台机器,需要兆瓦的电力,足以满足一个小镇的需求。

现在考虑真正的小型设备,比如手机处理器,由于电池寿命有限,功耗也是一个重要问题。所有人都会喜欢电池续航很长的设备,尤其是功能丰富的智能手机。现在考虑更小的设备,例如嵌入身体内部的小型处理器,用于医疗应用,通常在起搏器等设备中使用小型微芯片。在这种情况下,不想强迫患者携带重型电池,或经常给电池充电,从而给患者带来不便。为了延长电池寿命,重要的是尽可能减少耗电。

此外,温度是一个非常密切相关的概念。结合下图中芯片的典型封装图,通常有一个200-400平方毫米的硅管芯(silicon die),管芯是指包含芯片电路的矩形硅块。由于这一小块硅耗散60-100W的功率(相当于6-10个CFL灯泡),除非采取额外措施冷却硅管芯,否则其温度可能会升至200摄氏度。首先在硅管芯上添加一块5cm x 5cm的镀镍铜板,就是所谓的扩散器,扩散器通过传播热量,从而消除热点,有助于在模具上形成均匀的温度分布。需要一个扩散器,因为芯片的所有部分都不会散发相同的热量,例如ALU通常耗散大量热量,而存储元件相对较冷。其次,散热取决于程序的性质,对于整数基准测试,浮点ALU是空闲的,会更冷。为了确保热量正确地从硅管芯流到扩散器,通常添加一种导热凝胶,称为热界面材料(TIM)。

大多数芯片都有一种结构,即散热器顶部的散热器。它是一种铜基结构,具有一系列鳍片,如上图所示。添加了一系列鳍片以增加其表面积,确保处理器产生的大部分热量可以散发到周围的空气中。在台式机、笔记本电脑和服务器中使用的芯片中,有一个风扇安装在散热器上,或者安装在计算机机箱中,将空气吹过散热器,确保热空气被驱散,而来自外部的冷空气流过散热器。散热器、散热器和风扇的组合有助于散热处理器产生的大部分热量。

尽管采用了先进的冷却技术,处理器仍能达到60-100摄氏度。在玩高度互动的电脑游戏时,或者在运行天气模拟等大量数据处理应用程序时,芯片上的温度最高可达120摄氏度,足以烧开水、煮蔬菜,甚至在冬天温暖一个小房间,我们不需要买加热器,只需要运行一台计算机!请注意,温度有很多有害影响:

  • 片上铜线和晶体管的可靠性随着温度的升高呈指数下降。由于一种被称为NBTI(负偏置温度不稳定性)的效应,芯片往往会随着时间老化,老化有效地减缓了晶体管的速度。因此,有必要随着时间的推移降低处理器的频率,以确保正确的操作。
  • 一些功耗机制(如泄漏功率)取决于温度,意味着随着温度的升高,总功率的泄漏分量也随之升高,进一步提高了温度。

总之,为了降低电费、降低冷却成本、延长电池寿命、提高可靠性和减缓老化,降低芯片上的功率和温度非常重要。现在让我们快速回顾一下主要的功耗机制。主要关注两种机制,即动态和泄漏功率(leakage power),泄漏功率也称为静态功率。

先阐述动态功率。

可以把芯片的封装看作一个封闭的黑盒子,有电能流入,热量流出。在足够长的时间段内,流入芯片的电能的量完全等于根据能量守恒定律作为热量耗散的能量的量。此处忽略了沿I/O链路发送电信号所损失的能量,但与整个芯片的功耗相比,该能量可以忽略不计。

任何由晶体管和铜线组成的电路都可以被建模为具有电阻器、电容器和电感器的等效电路。电容器和电感器不散热,但电阻器将流经电阻器的一部分电能转换为热量,这是电能在等效电路中转化为热能的唯一机制。

现在考虑一个小电路,它有一个电阻器和一个电容器,如下图所示,电阻器代表电路中导线的电阻,电容器表示电路中晶体管的等效电容。需要注意的是,电路的不同部分,例如晶体管的栅极,在给定的时间点具有一定的电势,意味着晶体管的栅极起着电容器的作用,从而存储电荷。类似地,晶体管的漏极和源极具有等效的漏极电容和源极电容。通常不会在简单的分析中考虑等效电感,因为大多数导线通常很短,并且它们不起电感器的作用。

具有电阻和电容的电路。

耗散的功率与频率和电源电压的平方成正比,请注意,该功耗表示由于输入和输出中的转变而引起的电阻损耗,它被称为动态功率。因此有:

\[\mathcal{P}_{d y n} \propto \sum_{i=1}^{n^{\prime}} \alpha_{i} C_{i} V^{2} f \]

动态功率(dynamic power)是由于电路中所有晶体管的输入和输出转变而消耗的累积功率。

下面阐述静态功率(泄露功率)。

请注意,动态功耗不是处理器中唯一的功耗机制,静态或泄漏功率是高性能处理器功耗的主要组成部分,大约占处理器总功率预算的20-40%。

到目前为止,我们一直假设晶体管在截止状态时不允许任何电流流过,电容器的端子之间或NMOS晶体管的栅极和源极之间绝对没有电流流过,所有这些假设都不是严格正确的。在实践中,没有任何结构是完美的绝缘体,即使在关闭状态下,也有少量电流流过其端子。可以在理想情况下不应该通过电流的其他接口上有许多其他泄漏电源,这种电流源统称为泄漏电流,相关的功率耗散称为泄漏功率。

泄漏功率耗散有不同的机制,如亚阈值泄漏和栅极诱导漏极泄漏。研究人员通常使用BSIM3模型中的以下方程计算泄漏功率(主要捕获亚阈值泄漏):

\[\mathcal{P}_{l e a k}=A \times \nu_{T}^{2} \times e^{\frac{V_{G S}-V_{t h}-V_{o f f}}{n \times \nu_{T}}}\left(1-e^{\frac{-V_{D S}}{\nu_{T}}}\right) \]

其中:

变量 定义
$A $ 面积相关比例常数
$ \nu_{T}$ 热电压
$k $ 波耳兹曼常数
$q $ \(1.6 \times 10^{-19}\)
$T $ 温度
$V_{G S} $ 栅极和源极之间的电压
$ V_{t h}$ 阈值电压,还取决于温度
$V_{o f f} $ 残余电压
$n $ 亚阈值摆动系数
$ V_{D S}$ 漏极和源极之间的电压

注意,泄漏功率通过变量\(\nu T=k T / q\)取决于温度。为了显示温度相关性,可以简化方程以获得以下方程:

\[\mathcal{P}_{l e a k} \propto T^{2} \times e^{A / T} \times\left(1-e^{B / T}\right) \]

上述公式中,A和B是常数。大约20年前,当晶体管阈值电压较高时(约500 mV),泄漏功率与温度呈指数关系,因此温度的小幅度升高将转化为泄漏功率的大幅度增加。然而,如今的阈值电压在100-150 mV之间,因此温度和泄漏之间的关系变得近似线性。

需要注意的是,泄漏功率始终由电路中的所有晶体管耗散,泄漏电流的量可能很小,但是当考虑数十亿晶体管的累积效应时,泄漏功率耗散的总量是相当大的,甚至可能成为动态功率的很大一部分。由此,设计人员试图控制温度以控制泄漏功率。总功率由下式给出:

\[\mathcal{P}_{\text {tot }}=\mathcal{P}_{\text {dyn }}+\mathcal{P}_{\text {leak }} \]

下面来建模温度。

对芯片上的温度建模是一个相当复杂的问题,需要大量的热力学和传热背景知识,这里陈述一个基本结果。让我们把硅管芯的面积分成一个网格,将网格点编号为1 ... m,功率向量Ptot表示每个网格点耗散的总功率,类似地,让每个网格点的温度由向量T表示。对于大量网格点,功率和温度通常由以下线性方程关联:

\[T-T_{a m b}=\Delta T=A \times \mathcal{P}_{t o t} \]

  • \(T_{amb}\)被称为环境温度,是周围空气的温度。
  • A是m * m矩阵,也称为热阻矩阵。根据公式,温度(T)的变化和功耗彼此线性相关。

请注意,在\(\mathcal{P}_{\text {tot }}=\mathcal{P}_{\text {dyn }}+\mathcal{P}_{\text {leak }}\)\(\mathcal{P}_{\text {leak }}\)是温度的函数,和上述公式形成反馈回路。因此,我们需要假设温度的初始值,计算泄漏功率,估计新的温度,计算泄漏功耗,并不断迭代直到值收敛。

19.5.4 高级技术

本节将简要介绍实现处理器的高级技术。请注意,本节绝不是独立的,其主要目的是为读者提供额外学习的指导。本节将介绍几个大幅度提高性能的广泛范例,这些技术被最先进的处理器采用。

现代处理器通常使用非常深的流水线(12-20阶段)在同一周期内执行多条指令,并采用先进技术消除流水线中的冲突。让我们看看一些常见的方法。

19.5.4.1 分支预测

让我们从IF阶段开始,看看如何做得更好。如果在管线中有一个执行的分支,那么IF阶段尤其需要在管线中暂停2个周期,然后需要开始从分支目标中提取。随着我们添加更多的线线阶段,分支惩罚(branch penalty)从2个周期增加到20多个周期,使得分支指令非常昂贵,会严重限制性能。因此,有必要避免管线暂停,即使对于已采取的分支也是如此。

如果可以预测分支的方向,也可以预测分支目标,那会怎么样?在这种情况下,提取单元可以立即从预测的分支目标开始提取。如果在稍后的时间点发现预测错误,则需要取消预测错误的分支指令之后的所有指令,并将其从管线中丢弃,这种指令也称为推测指令(speculative instruction)

现代处理器通常根据预测执行大量指令集,例如预测分支的方向,并相应地从预测的分支目标开始提取指令,稍后执行分支指令时验证预测。如果发现预测错误,则从管线中丢弃所有错误获取或执行的指令,这些指令称为推测指令(speculative instruction)。相反,正确获取并执行的指令,或其预测已验证的指令称为非推测指令。

请注意,禁止推测性指令更改寄存器文件或写入内存系统是极其重要的,因此需要等待指令变得非推测性,然后才允许它们进行永久性的更改。第二,不允许它们在非推测之前离开管线,但如果需要丢弃推测指令,那么现代管线采用更简单的机制,通常会删除在预测失败的分支指令之后获取的所有指令,而不是选择性地将推测指令转换为管线气泡。这个简单的机制在实践中非常有效,被称为管线刷新(pipeline flush)

现代处理器通常采用一种简单的方法,即丢弃管线中的所有推测指令。它们完全完成所有指令的执行,直到出现预测失误的指令,然后清理整个管线,有效地删除在预测失误指令之后获取的所有指令。这种机制称为管线刷新(pipeline flush)

现在概述分支预测中的主要挑战:

  • 如果一条指令是一个分支,需要在提取阶段首先读取,如果是分支,需要读取分支目标的地址。
  • 接下来,需要预测分支的预期方向。
  • 有必要监测预测指令的结果。如果存在预测失误,那么需要在稍后的时间点执行管线刷新,以便能够有效地删除所有推测指令。

在分支的情况下检测预测失误是相当直接的,将预测添加到指令包中,并用实际结果验证预测。如果它们不同,那么安排管线刷新。主要的挑战是预测分支指令的目标及其结果。

现代处理器使用称为分支目标缓冲器(BTB)的简单硬件结构,它是一个简单的内存阵列,保存最后N(从128到8192不等)条分支指令的程序计数器及其目标。找到匹配的可能性很高,因为程序通常表现出一定程度的局部性,意味着它们倾向于在一段时间内重复执行同一段代码,例如循环,因此BTB中的条目往往会在很短的时间内被重复使用。如果存在匹配,那么也可以自动推断该指令是分支。

要有效地预测分支的方向要困难得多,但可以利用后面阐述的模式。程序中的大多数分支通常位于循环或if语句中,其中两个方向的可能性不大,事实上,一个方向的可能性远大于另一个方向,例如循环中的分支占用了大部分时间。有时if语句仅在某个异常条件为真时才求值,大多数情况下,与这些if语句关联的分支都不会被执行。类似地,对于大多数程序,设计者观察到几乎所有的分支指令都遵循特定的模式,它们要么对一个方向有强烈的偏倚,要么可以根据过去的历史进行预测,要么可以基于其它分支的行为进行预测。当然,这种说法没有理论依据,只是处理器设计者的观察结果,因此他们设计了预测器来利用程序中的这种模式。

本节讨论一个简单的2位分支预测器。假设有一个分支预测表,该表为表中的每个分支分配一个2位值,如下图所示。

如果该值为00或01,则预测该分支不会被执行。如果它等于10或11,那么预测分支被执行。此外,每次执行分支时,将相关计数器递增1,每次不执行分支时将计数器递减1。为了避免溢出,不将11递增1以产生00,也不将00递减以产生11。我们遵循饱和算术的规则,即(二进制):\(11+1=11\)\(00-1=00\)。这个2位值被称为2位饱和计数器,其状态图如下图所示。

预测分支有两种基本操作:预测和训练。为了预测分支,我们在分支预测表中查找其程序计数器的值,在特定情况下,使用pc地址的最后n位来访问2n个条目的分支预测表。读取2位饱和计数器的值,并根据其值预测分支,当得到分支的实际结果时,我们训练通过使用饱和算法递增或递减计数器的值。

现在看看这个预测器的工作原理,考虑一段简单的C代码及其等效的汇编代码:

void main()
{
    foo();
    ...
    foo();
}

int foo() 
{
    int i, sum = 0
    for(i=0; i < 10; i++) 
    {
        sum = sum + i;
    }
    return sum;
}
.main:
    call .foo
    ...
    call .foo

.foo:
    mov r0, 0 /* sum = 0 */
    mov r1, 0 /* i = 0 */

.loop:
    add r0, r0, r1 /* sum = sum + i */
    add r1, r1, 1 /* i = i + 1 */
    cmp r1, 10 /* compare i with 10 */
    bgt .loop /* if(r1 > 10) jump to .loop */
    ret

让我们看看循环中的分支语句bgt .loop,对于除最后一次之外的所有迭代,都会执行分支。如果在状态10下启动预测器,那么第一次,分支预测正确(采取),计数器递增并等于11,对于后续的每一次迭代,都会正确地预测分支。然而,在最后一次迭代中,需要将其预测为未采取,这里有一个错误的预测,因此,2位计数器递减,并设置为10。现在考虑一下再次调用函数foo时的情况,2位计数器的值为10,并且分支bgt .loop被正确预测为采用。

因此,2位计数器方案在预测方案中增加了一点延迟(或过去的历史)。如果分支历史上一直在一个方向,那么一个异常不会改变预测。这个模式对于循环非常有用,正如在这个简单的例子中看到的那样,循环最后一次迭代中分支指令的方向总是不同的,但下一次进入循环时,分支的预测是正确的,正如本例所示。请注意,这只是其中一种模式,现代分支预测程序可以利用更多类型的模式。

程序优化提示:

  • 为编译器提供尽可能多的有关正在执行的操作的信息。
  • 尽可能使用常量和局部变量。如果语言允许,请定义原型并声明静态函数。
  • 尽可能使用数组而不是指针。
  • 避免不必要的类型转换,并尽量减少浮点到整数的转换。
  • 避免溢出和下溢。
  • 使用适当的数据类型(如float、double、int)。
  • 考虑用乘法代替除法。
  • 消除所有不必要的分支。
  • 尽可能使用迭代而不是递归。
  • 首先使用最可能的情况构建条件语句(例如if、switch、case)。
  • 在结构中按尺寸顺序声明变量,首先声明尺寸最大的变量。
  • 当程序出现性能问题时,请在开始优化程序之前对程序进行概要分析。(评测是将代码分成小块,并对每一小块进行计时,以确定哪一块花费的时间最多的过程。)
  • 切勿仅基于原始性能放弃算法。只有当所有算法都完全优化时,才能进行公平的比较。
  • 过程内联(procedure inlining),用函数体替换对函数的调用,用调用者的参数替换过程的参数。
  • 循环转换(loop transformation),可以减少循环开销,改善内存访问,并更有效地利用硬件。
    • 循环展开(loop-unrolling)。在执行多次迭代的循环中,例如那些传统上由For语句控制的循环,循环展开loop-unrolling的优化通常有用。循环展开包括进行循环,多次复制身体,并减少执行转换后的循环的次数。循环展开减少了循环开销,并为许多其他优化提供了机会。
    • 复杂的循环转换,如交换嵌套循环和阻塞循环以获得更好的内存行为。
  • 局部和全局优化。在专用于局部和全局优化的过程中,执行以下优化:
    • 局部优化在单个基本块内工作。局部优化过程通常作为全局优化的先导和后续运行,以在全局优化前后“清理”代码。
    • 全局优化跨多个基本块工作。
    • 全局寄存器分配为代码区域的寄存器分配变量。寄存器分配对于在现代处理器中获得良好性能至关重要。
    • 更具体地,有子表达式消除(Common subexpression elimination)、削减强度(Strength reduction)、常量传播(Constant propagation)、拷贝传播(Copy propagation)、死存储消除(Dead store elimination)等操作。

如果使用两个位,则可以使用它们来记录相关指令执行的最后两个实例的结果,或者以其他方式记录状态。下图显示了一种典型的方法,假设算法从流程图的左上角开始。只要执行遇到的每个后续条件分支指令,决策过程就预测将执行下一个分支,如果单个预测错误,则算法继续预测下一个分支被执行。只有在没有采取两个连续分支的情况下,算法才会转移到流程图的右侧,随后该算法将预测在一行中的两个分支被取下之前不会取下分支,因此该算法需要两个连续的错误预测来改变预测决策。

分支预测流程图。

预测过程可以用有限状态机更紧凑地表示,如下图所示,许多文献通常使用有限状态机表示。

分支预测状态图。

下图将该方案与从未采取的预测策略进行了对比。使用前一种策略,指令获取阶段总是获取下一个顺序地址。如果执行了分支,处理器中的某些逻辑会检测到这一点,并指示从目标地址提取下一条指令(除了刷新管道之外)。分支历史表被视为缓存,每个预取都会触发分支历史表中的查找。如果未找到匹配项,则使用下一个顺序地址进行提取,如果找到匹配,则根据指令的状态进行预测:下一个顺序地址或分支目标地址被馈送到选择逻辑。

为了弥补依赖性,已经开发了代码重组技术,首先考虑分支指令。延迟分支(Delayed branch)是一种提高流水线效率的方法,它使用的分支在执行以下指令后才生效(因此称为延迟),紧接在分支之后的指令位置被称为延迟槽(delay slot)。这个奇怪的过程如下表所示。在标记为“正常分支”的列中,有一个正常的符号指令机器语言程序。执行102之后,下一条要执行的指令是105。为了规范流水线,在这个分支之后插入一个NOOP。但是,如果在101和102处的指令互换,则可以实现提高的性能。

下图显示了结果。图a显示了传统管线方法。JUMP指令在时间4被获取,在时间5,JUMP指令与指令103(ADD指令)被获取的同时被执行。因为发生了JUMP,它更新了程序计数器,所以必须清除流水线中的指令103,在时间6,作为JUMP的目标的指令105被加载。

图b显示了典型RISC组织处理的相同管线,时间是一样的,但由于插入了NOOP指令,不需要特殊的电路来清除管线,NOOP简单地执行而没有效果。

图c显示了延迟分支的使用。JUMP指令在ADD指令之前的时间2获取,ADD指令在时间3获取。但请注意,ADD指令是在执行JUMP指令有机会改变程序计数器之前获取的。因此,在时间4期间,在获取指令105的同时执行ADD指令,保留了程序的原始语义,但执行需要两个更少的时钟周期。

19.5.4.2 延迟加载

类似延迟分支的策略称为延迟加载(delayed load),可以用于加载指令。在LOAD指令中,将成为加载目标的寄存器被处理器锁定。然后,处理器继续执行指令流,直到它到达需要该寄存器的指令为止,此时它将空闲,直到加载完成。如果编译器可以重新排列指令,以便在加载过程中完成有用的工作,那么效率就会提高。

19.5.4.3 循环展开

另一种提高指令并行性的编译器技术是循环展开(loop unrolling)。展开会多次复制循环体,称为展开因子(u),并按步骤u而不是步骤1进行迭代:

  • 减少环路开销
  • 通过提高流水线性能提高指令并行性
  • 改进寄存器、数据缓存或TLB位置

下图在一个示例中说明了所有三种改进。循环开销减少了一半,因为在测试之前执行了两次迭代,并在循环结束时分支。由于可以在存储第一次赋值的结果和更新循环变量的同时执行第二次赋值,因此提高了指令并行性。如果将数组元素分配给寄存器,寄存器局部性将得到改善,因为在循环体中使用了两次a[i]和a[i+1],从而将每次迭代的加载次数从三次减少到两次。

指令流水线的设计不应与应用于系统的其他优化技术分离,例如流水线的指令调度和寄存器的动态分配应该一起考虑,以实现最大的效率。

19.5.4.4 顺序管线的问题

在简单管线中,每个周期只执行一条指令,但并非绝对必要。我们可以设计一个处理器,比如最初的英特尔奔腾,它有两条并行管线。该处理器可以在一个周期内同时执行两条指令。这些管道具有额外的功能单元,因此两条管线中的指令都可以在没有任何重大结构冲突的情况下执行。该策略增加了IPC,但也使处理器更加复杂。这样的处理器被称为包含多个顺序执行管线,因为可以在同一个周期内向执行单元发布多条指令。每个周期可以执行多条指令的处理器也称为超标量处理器(superscalar processor)

其次,这种处理器被称为有序处理器(in-order processor),因为它按程序顺序执行指令,程序顺序是指令的动态实例在程序中出现时的执行顺序。例如,单周期处理器或流水线处理器按程序顺序执行指令。

每个周期可以执行多条指令的处理器称为超标量处理器(superscalar processor)

有序处理器按程序顺序执行指令。程序顺序被定义为指令的动态实例的顺序,与顺序执行程序的每条指令时所感知的顺序相同。

超标量组织与普通标量组织的比较。

超标量和超流水线方法的比较。

超标量处理的概念描述。

现在,我们需要寻找两条管线的依赖性和潜在冲突。其次,转发逻辑也要复杂得多,因为结果可以从任一管线转发。英特尔发布的原始奔腾处理器有两条管线,即U管线和V管线。U管线可以执行任何指令,而V管线仅限于简单指令。指令作为2-指令束(2-instruction bundle)获取,指令束中的前一条指令被发送到U管线,后一条指令则被发送到V管线。这种策略允许并行地执行这些指令。

让我们尝试在概念上设计一个简单的处理器,它采用了原始奔腾处理器的两条流水线:U和V。我们设想了一个组合的指令和操作数获取单元,它形成2-指令束,并被发送到两个流水线以同时执行。但如果指令不满足某些约束,则该单元形成1-指令束并将其发送到U流水线。无论何时,我们生成这样的束,都可以广泛遵守一些通用规则,应该避免具有RAW依赖性的两条指令,在这种情况下,管线将暂停。

其次,需要特别注意内存指令,因为它们之间的依赖关系在EX阶段结束之前无法发现。假设指令束中的第一条指令是存储指令,第二条指令是加载指令,并且它们碰巧访问相同的内存地址。需要在EX阶段结束时检测这种情况,并将值从存储转发到加载。对于相反的情况,当第一条指令是加载指令,第二条指令是存储到相同地址时,需要暂停存储指令,直到加载完成。如果指令束中的两条指令都存储到同一地址,那么前面的指令是冗余的,可以转换为nop。因此,需要设计一个符合这些规则的处理器,并具有复杂的互锁和转发逻辑。

下面展示一个简单的例子。为以下汇编代码绘制一个流水线图,假设流水线中存在2个问题。

[1]: add r1, r2, r3
[2]: add r4, r5, r6
[3]: add r9, r8, r8
[4]: add r10, r9, r8
[5]: add r3, r1, r2
[6]: ld r6, 10[r1]
[7]: st r6, 10[r1]

此处,流水线图包含每个阶段的两个条目,因为两个指令可以同时在一个阶段中。我们首先观察到可以并行执行指令[1]和[2],但不能并行执行指令[3]和[4],因为指令[3]写入r9,而指令[4]将r9作为源操作数。我们不能在同一个周期内执行这两条指令,因为r9的值是在EX阶段产生的,也是EX阶段需要的。我们继续并行执行[4]和[5],在指令[4]的情况下,可以使用转发来获得r9的值。最后,我们不能并行执行指令[6]和[7],它们访问相同的内存地址,加载需要在存储开始之前完成,因此插入了另一个气泡。管线图如下:

19.5.4.5 EPIC和VLIW处理器

现在,我们可以用软件(而不是用硬件)准备指令束。编译器对代码的可见性要高得多,并且可以执行广泛的分析以创建多指令束。英特尔和惠普设计的安腾处理器是一款基于类似原理的非常经典的处理器。让我们首先从定义术语开始:EPIC和VLIW。

VLIW(Very Long Instruction Word,超长指令字):编译器创建的指令束之间没有依赖关系,硬件并行执行每个包中的指令,正确性的全部责任在于编译器。

EPIC(Explicitly Parallel Instruction Computing,显式并行指令计算):这种范例扩展了VLIW计算,但在这种情况下,无论编译器生成什么代码,硬件都会确保执行正确。

EPIC/VLIW处理器需要非常聪明的编译器来分析程序并创建指令包,例如如果一个处理器有4条流水线,那么每个束包含4条指令。编译器创建束,以便束中的指令之间不存在依赖关系。设计EPIC/VLIW处理器的更广泛的目标是将所有的复杂性转移到软件上,编译器以这样一种方式排列束,可以最大限度地减少处理器中所需的互锁、转发和指令处理逻辑。

但事后看来,这类处理器未能兑现承诺,因为硬件无法像设计者最初计划的那样简单。高性能处理器仍然需要相当复杂的硬件,并且需要一些复杂的架构特性。这些特性增加了硬件的复杂性和功耗。

19.5.4.6 乱序管线

到目前为止,我们一直在主要考虑有序管线,这些管线按照指令在程序中出现的顺序执行指令,但并不是绝对必要的。考虑下面的代码片段:

[1]: add r1, r2, r3
[2]: add r4, r1, r1
[3]: add r5, r4, r2
[4]: mul r6, r5, r2
[5]: div r8, r9, r10
[6]: sub r11, r12, r13

上面代码中,由于数据依赖性,我们被限制按顺序执行指令1到4。然而,可以并行执行指令5和6,因为它们不依赖于指令1-4。如果无序执行指令5、6,不会牺牲正确性,例如如果可以在一个周期内发出两条指令,那么可以一起提交(1,5),然后提交(2,6),最后提交指令3和4。在这种情况下,可以通过在前两个周期内执行2条指令,在4个周期中执行6条指令的序列。这种可能在每个周期执行多条指令的处理器正是超标量处理器。

可以按照与其程序顺序不一致的顺序执行指令的处理器称为乱序(Out-Of-Order,OOO,亦称无序)处理器

超标量指令执行和完成策略。

具备乱序完备的乱序执行组织。

乱序(OOO)处理器按顺序获取指令,在提取阶段之后,它继续解码指令。大多数真实世界的指令需要一个以上的解码周期,这些指令同时按程序顺序添加到称为重新排序缓冲区(reorder buffer,ROB)的队列中。解码指令后,需要执行一个称为寄存器重命名(register renaming)的步骤。大致思路是:由于执行的指令是无序的,可能会有WAR和WAW冲突。考虑下面的代码片段:

[1]: add r1, r2, r3
[2]: sub r4, r1, r2
[3]: add r1, r5, r6
[4]: add r9, r1, r7

如果在指令[1]之前执行指令[3]和[4],那么就有潜在的WAW冲突,因为指令[1]可能会覆盖指令[3]写入的r1的值,将导致错误的执行。因此,我们尝试重命名寄存器,以便消除这些冲突。大多数现代处理器都设计了一组架构寄存器(architectural register),这些寄存器与暴露于软件(汇编程序)的寄存器相同。此外,它们还有一组仅在内部可见的物理寄存器(physical register),重命名阶段将架构寄存器名转换为物理寄存器名,以消除WAR和WAW冲突。上面代码中仅存的冲突是RAW冲突,表明存在真正的数据依赖性。因此,重命名后的代码段将如下所示,假设物理寄存器的范围为p1 … p128。

[1]: add p1, p2, p3 /* p1 contains r1 */
[2]: sub p4, p1, p2
[3]: add p100, p5, p6 /* r1 is now begin saved in p100 */
[4]: add p9, p100, p7

我们通过将指令3中的r1映射到p100,消除了WAW冲突,唯一存在的依赖关系是指令之间的RAW依赖关系[1] --> [2] 和[3] --> [4],重命名后的指令进入指令窗口。请注意,到目前为止,指令一直在按顺序处理。

指令窗口或指令队列通常包含64-128个条目(参见下图),每条指令都监视其源操作数,只要一条指令的所有源操作数都准备好了,该指令就可以提交到其相应的功能单元。指令不必总是访问物理寄存器文件,还可以从转发路径中获取值。指令完成执行后,将结果的值广播给指令窗口中的等待指令。等待结果的指令,将其相应的源操作数标记为就绪,此过程称为指令唤醒(instruction wakeup)。现在,有可能在同一周期内准备好多条指令,为了避免结构冲突,指令选择单元选择一组指令执行。

我们需要另一种用于加载和存储指令的结构,称为加载-存储(load-store)队列,它按程序顺序保存加载和存储列表,允许加载通过内部转发机制获取其值,如果同一地址有较早的存储。

指令完成执行后,我们在重新排序缓冲区中标记其条目,指令按程序顺序离开重新排序缓冲区。如果一条指令由于某种原因不能快速完成,那么重新排序缓冲区中的所有指令都需要暂停。回想一下,重新排序缓冲区中的指令条目是按程序顺序排序的,指令需要按程序顺序保留重新排序缓冲区,以便我们能够确保精确的异常。

综上所述,无序处理器(OOO)的主要优点是它可以并行执行指令,这些指令之间没有任何RAW依赖关系。大多数程序通常在大多数时间点都有这样的指令集。此属性称为指令级并行(instruction level parallelism,ILP),现代OOO处理器旨在尽可能地利用ILP。

19.5.4.7 微操作

在执行程序时,计算机的操作由一系列指令周期组成,每个周期有一条机器指令。由于分支指令的存在,这个指令周期序列不一定与组成程序的指令序列相同,这里所指的是指令的执行时间序列。

每个指令周期都由一些较小的单元组成,其中一种方便的细分是获取、间接、执行和中断,只有获取和执行周期总是发生。然而,要设计控制单元,需要进一步细分描述,而进一步的细分是可能的。事实上,我们将看到每个较小的周期都涉及一系列步骤,每个步骤都涉及处理器寄存器。这些步骤称为微操作(micro-operation)

前缀micro指的是每个步骤都非常简单,完成的很少,下图描述了各种概念之间的关系。总之,程序的执行包括指令的顺序执行,每个指令在由较短子周期(如获取、间接、执行、中断)组成的指令周期内执行。每个子周期的执行涉及一个或多个较短的操作,即微操作。

程序执行的组成要素。

微操作是处理器的功能操作或原子操作。本节将研究微操作,以了解如何将任何指令周期的事件描述为此类微操作的序列。将使用一个简单的示例,并展示微操作的概念如何作为控制单元设计的指南。

先阐述获取周期(Fetch Cycle)。

获取周期发生在每个指令周期的开始,并导致从内存中获取指令,涉及四个寄存器:

  • 内存地址寄存器(MAR):连接到系统总线的地址线。它为读或写操作指定内存中的地址。
  • 内存缓冲寄存器(MBR):连接到系统总线的数据线。它包含要存储在内存中的值或从内存中读取的最后一个值。
  • 程序计数器(PC):保存要获取的下一条指令的地址。
  • 指令寄存器(IR):保存获取的最后一条指令。

让我们从获取周期对处理器寄存器的影响的角度来看获取周期的事件序列。下图中显示了一个示例,在提取周期开始时,要执行的下一条指令的地址在程序计数器(PC)中,其中地址是1100100。

  • 第一步是将该地址移动到内存地址寄存器(MAR),因为这是连接到系统总线地址线的唯一寄存器。
  • 第二步是引入指令,所需地址(在MAR中)被放置在地址总线上,控制单元在控制总线上发出READ命令,结果出现在数据总线上,并被复制到内存缓冲寄存器(MBR)中。我们还需要将PC增加指令长度,以便为下一条指令做好准备。这两个动作(从内存中读取字,递增PC)互不干扰,所以可以同时执行它们以节省时间。
  • 第三步是将MBR的内容移动到指令寄存器(IR),将释放MBR,以便在可能的间接循环期间使用。

事件顺序,获取周期。

因此,简单的提取周期实际上由三个步骤和四个微操作组成。每个微操作都涉及将数据移入或移出寄存器。只要这些动作不相互干扰,一步中就可以进行几个动作,从而节省时间。象征性地,我们可以将这一系列事件写成如下:

其中I是指令长度。需要对这个序列做几点评论,假设时钟可用于定时目的,并且它发出规则间隔的时钟脉冲。每个时钟脉冲定义一个时间单位,因此所有时间单位都具有相同的持续时间。每个微操作可以在单个时间单位的时间内执行,符号(t1、t2、t3)表示连续的时间单位。换句话说,我们有:

  • 第一时间单位:将PC的内容移动到MAR。
  • 第二时间单位:将MAR指定的内存位置的内容移动到MBR。按I递增PC的内容。
  • 第三时间单位:将MBR的内容移动到IR。

注意,第二和第三微操作都发生在第二时间单位期间,第三个微操作可以与第四个微操作分组,而不影响提取操作:

微操作的分组必须遵循两个简单的规则:

  • 必须遵循正确的事件顺序。因此,(MAR <--(PC))必须在(MBR <-- Memory)之前,因为内存读取操作使用MAR中的地址。
  • 必须避免冲突。不应试图在一个时间单位内对同一寄存器进行读写,因为结果是不可预测的,例如微操作(MBR dMemory)和(IR dMBR)不应在同一时间单位内发生。

最后一点值得注意的是,其中一个微操作涉及相加。为了避免电路重复,可以由ALU执行此相加。ALU的使用可能涉及额外的微操作,取决于ALU的功能和处理器的组织。

此外,在非直接周期(Indirect Cycle)、中断周期(Interrupt Cycle)、执行周期(Execute Cycle)、指令周期(Instruction Cycle)等也涉及了类似的机制和原理。

指令周期流程图。

19.5.5 商业处理器案例

现在来看看一些真正的处理器的设计,这样就可以将迄今为止所学的所有概念放在实际的角度。后面将研究ARM、AMD和Intel三大处理器公司的嵌入式(用于小型移动设备)和服务器处理器。本节的目的不是比较和对比三家公司的处理器设计,甚至是同一家公司的不同型号。每一个处理器都是针对特定的细分市场进行优化设计的,并考虑到某些关键业务决策。因此,本节的重点是从技术角度研究设计,并了解设计的细微差别。

在对RISC机器的最初热情之后,人们越来越认识到:

  • RISC设计可能会从包含一些CISC功能中受益。
  • CISC设计可能从包含一些RISC功能中获益。结果是,较新的RISC设计,特别是PowerPC,不再是“纯”RISC,而较新的CISC设计,尤其是奔腾II和更高版本的奔腾型号,确实包含了一些RISC特性。

下表列出了一些处理器,并对它们进行了多个特性的比较。为了进行比较,以下是典型的RISC:

  • 单个指令大小。
  • 该大小通常为4字节。
  • 少量数据寻址模式,通常少于五种,很难确定。在表中,寄存器和文字模式不计算在内,具有不同偏移量大小的不同格式分别计算在内。
  • 无需进行一次内存访问以获取内存中另一个操作数的地址的间接寻址。
  • 没有将加载/存储与算术相结合的操作(如从内存添加、添加到内存)。
  • 每条指令不超过一个内存寻址操作数。
  • 不支持加载/存储操作的数据任意对齐。
  • 内存管理单元(MMU)对指令中的数据地址的最大使用次数。
  • 整数寄存器说明符的位数等于或大于5,意味着一次至少可以显式引用32个整数寄存器。
  • 浮点寄存器说明符的位数等于或大于4,意味着一次至少可以显式引用16个浮点寄存器。

19.5.5.1 ARM处理器

ARM最初是Acorn计算机的处理器,因此其原名为Acorn RISC Machine,伯克利RISC论文影响了其架构。最重要的早期应用之一是16位微处理器AM 6502的仿真,该仿真旨在为Acorn计算机提供大部分软件。由于6502有一个可变长度的指令集,是字节的倍数,因此6502仿真有助于解释ARMv7指令集中对移位和屏蔽的强调。它作为一款低功耗嵌入式计算机的流行始于它被选为命运多舛的Apple Newton个人数字助理的处理器。

虽然Newton并没有苹果希望的那么受欢迎,但苹果的祝福让早期的ARM指令集变得引人注目,随后它们在包括手机在内的几个市场上流行起来。与Newton的经历不同,手机的非凡成功解释了2014年出货120亿ARM处理器的原因。ARM历史上的一个重大事件是称为版本8的64位地址扩展,ARM借此机会重新设计了指令集,使其看起来更像MIPS,而不像早期的ARM版本。

ARM处理器(通常称为ARM内核)最重要的一点是ARM设计处理器,然后将设计许可给客户。与英特尔或IBM等其他供应商不同,ARM不生产硅片。相反,德州仪器(Texas Instruments)和高通(Qualcomm)等供应商购买了使用ARM内核设计的许可证,并添加了额外的组件。然后,他们将合同交给半导体制造公司,或使用自己的制造设施在硅上制造整个SOC(片上系统)。

ARM的最新(截至2012年)ARMv8架构有三条处理器线:

  • 第1系列处理器被称为ARM Cortex-M系列。这些处理器主要设计用于医疗设备、汽车和工业电子等嵌入式应用中的微控制器,其设计背后的主要关注点是功率效率和成本。本节将描述具有三阶段流水线的ARM Cortex-M3处理器。
  • 第2系列处理器被称为ARM Cortex-R系列。这些处理器是为实时应用而设计的,主要重点是可靠性、高速和实时响应。它们不适用于智能手机等消费电子设备,不支持运行使用虚拟内存的操作系统。
  • 第3系列处理器被称为ARM Cortex-A系列。这些处理器设计用于在智能手机、平板电脑和一系列高端嵌入式设备上运行常规用户应用程序。这些ARM内核通常具有复杂的管线,支持向量操作,并且可以运行需要虚拟内存硬件支持的复杂操作系统。

ARM Cortex-M3

让我们从主要为嵌入式处理器市场设计的ARM Cortex-M系列处理器开始。对于这样的嵌入式处理器,能效和成本比原始性能更重要。因此,ARM工程师设计了一个3执行的流水线,没有非常复杂的功能。

Cortex-M3支持ARMv7-M指令集的基本版本,通常使用ARM AMBA总线连接到其他组件,如下图所示。

连接到AMBA总线的ARM Cortex-M3以及其他组件。

ARM Cortex-M3架构图。

AMBA(高级微控制器总线体系结构)是一种由ARM设计的总线体系结构,它用于将ARM内核与基于SOC的系统中的其他组件连接。例如,智能手机和移动设备中的大多数处理器使用AMBA总线通过桥接设备连接到高速内存设备、DMA引擎和其他外部总线。一种这样的外部总线是APB总线(高级外围总线),用于连接到外围设备,例如键盘、UART控制器(通用异步收发器协议)、计时器和PIO(并行输入输出)接口。

下图显示了ARM Cortex-M3的流水线。它有三个阶段:获取(F)、解码(D)和执行(E)。提取阶段从内存中提取指令,是所有三个阶段中最小的阶段。

ARM Cortex-M3的流水线。

解码阶段(D阶段)有三个不同的子单元,如上图所示。D阶段有一个指令解码和寄存器读取单元,解码指令,并形成指令包,同时读取指令中嵌入的操作数的值,也从寄存器文件中读取值。AGU(地址生成单元)提取指令中的所有字段,并在流水线的下一阶段调度加载或存储指令的执行。它在处理ldm(加载多个)和stm(存储多个)指令时扮演着特殊的角色。这些指令可以同时读取或写入多个寄存器,AGU使用管线中的单个ldm或stm指令创建多个操作。分支单元用于分支预测,它预测分支结果和分支目标。

执行阶段在功能方面相当繁重,有些指令需要2个周期才能执行,先看看常规ALU和分支指令。ARM指令可以有一个移位器操作数,其次,从其12位编码计算32位立即数的值本质上是移位(旋转是移位的一种类型)操作,这两种操作都由具有称为桶形移位器的硬件结构的移位单元执行。一旦操作数就绪,它们就被传递给ALU和分支单元,后者计算分支结果/目标和ALU结果。

ARM有两种分支:直接分支和间接分支。对于直接分支,分支目标与当前PC的偏移量嵌入指令中,比如到标签的分支是直接分支的示例,可以在解码阶段计算直接分支的分支目标。ARM还支持间接分支,其中分支目标是ALU或内存指令的结果,例如,指令ldr-pc, [r1, #10]是间接分支的一个示例,此处的分支目标的值等于加载指令从内存加载的值,通常很难预测间接分支的目标。在Cortex-M3处理器中,每当出现分支预测失误(目标或结果)时,在分支后提取的两条指令都会被取消,处理器开始从正确的分支目标获取指令。

除了基本的ALU,Cortex-M3还有一个乘除单元,可以执行有符号和无符号、乘法和除法。Cortex-M3支持两条指令sdiv和udiv,分别用于有符号和无符号除法。除了这些指令外,它还支持乘法和乘法累加操作。

加载和存储指令通常需要两个周期,它们具有地址生成阶段和内存访问阶段。加载指令需要2个周期才能执行,注意,在第二个周期中,其他指令不可能在E阶段执行。管线因此停滞一个周期,这种特殊特性降低了管线的性能,ARM在其高性能处理器中消除了这一限制。存储指令也需要2个周期才能执行,但访问内存的第二个周期不会使管线停止。处理器将值写入存储缓冲器(类似于写入缓冲器),然后继续执行。还可以发出背靠背(连续循环)存储和加载指令,其中加载读取存储写入的值。管线不需要为加载指令暂停,因为它从存储缓冲区读取存储写入的值。

ARM Cortex-A8

与Cortex-M3(嵌入式处理器)相比,Cortex-A8被设计为可以在复杂的智能手机和平板电脑处理器上运行的全边缘处理器。A代表应用程序,ARM的意图是使用该处理器在移动设备上运行常规应用程序。其次,这些处理器被设计为支持虚拟内存,并且还包含专用浮点和SIMD单元。

Cortex-A8内核流水线的设计特点是它是一个双问题超标量处理器,但并不是一个完全乱序的处理器。问题的逻辑是无序的Cortex-A8有一个13阶段整数流水线,带有复杂的分支预测逻辑。由于它使用深度管线,因此可以以比其他具有较浅管线的ARM处理器更高的频率对其进行计时。Cortex-A8内核的时钟频率在500MHz和1GHz之间,在嵌入式领域是相当快的时钟速度。

除了整数管线,Cortex-A8还包含一个专用浮点和SIMD单元。浮点单元实现ARM的VFP(矢量浮点)ISA扩展,SIMD单元实现ARM NEON指令集。该单元也是流水线式的,有10个阶段。此外,ARM Cortex-A8处理器具有单独的指令和数据缓存,可以选择连接到大型共享二级缓存。

下图显示了ARM Cortex-A8处理器的流水线设计。提取单元在两个阶段之间进行流水线处理,主要目的是获取指令并更新PC,它还内置了指令预取器、ITLB(指令TLB)和分支预测器,指令随后传递到解码单元。

ARM Cortex-A8处理器的流水线。

ARM Cortex-A8架构图。

解码单元在5阶段之间进行流水线处理。Cortex-A8处理器中的解码单元比Cortex-M3更复杂,因为它有额外的责任检查指令之间的相关性,并一起发出两条指令。因此,转发、暂停和互锁逻辑要复杂得多。让我们对两个指令执行槽0和1进行编号,如果解码阶段找到两个不具有任何相关性的指令,那么它会用指令填充两个执行槽,并将它们发送到执行单元。否则,解码阶段只发出一个时隙。

执行单元跨6个阶段进行流水线处理,它包含4个独立的流水线,有两个ALU管道,两个指令都可以使用。它有一个乘法管线,只能由插槽0中发出的指令使用。最后,它有一个加载/存储管线,可以再次被两个发布槽中发出的指令使用。

NEON和VFP指令发送到NEON/VFP单元,NEON/VFP指令的解码和调度需要三个周期,NEON/VFP单元从包含32个64位寄存器的NEON寄存器文件中取出操作数。NEON指令还可以将寄存器文件视为十六个128位寄存器,NEON/VFP单元有六个6级流水线用于算术运算,还有一个6阶段管道用于加载/存储操作,加载矢量数据是SIMD处理器中非常关键的性能操作。因此,ARM在NEON单元中有一个专用的加载队列,用于通过从L1缓存加载数据来填充NEON寄存器文件。为了存储数据,NEON单元将数据直接写入L1缓存。

每个一级缓存(指令/数据)的块大小为64字节,关联性为4,可以是16KB或32KB。其次,每个L1缓存有两个端口,每个周期可以为NEON和浮点操作提供4个字。需要注意的是,NEON/VFP单元和整数管线共享L1数据缓存。L1缓存可选地连接到大型L2缓存,它的块大小为64字节,是8路集关联的,最大可达1MB,二级缓存分为多个存储库。可以同时查找两个标记,数据数组访问并行进行。

ARM Cortex-A8 NEON和浮点管线。

ARM Cortex-A15

ARM Cortex-A15是2013年初发布的最新ARM处理器,面向高性能应用。

Cortex-A5处理器比Cortex-M3和Cortex-A8复杂得多,功能也更强大。它没有使用乱序内核,而是使用了3执行的超标量无序内核。它还有一条更深的管线,具体来说,它有一个15阶段整数管线和一个17-25阶段浮点管线。更深的管线允许它以更高的频率(1.5-2.5GHz)运行。此外,它在内核上完全集成了VFP和NEON单元,而不是将它们作为单独的执行单元。与服务器处理器一样,它被设计为访问大量内存,可以支持40位物理地址,意味着它可以使用支持系统级一致性的最新AMBA总线协议来寻址多达1 TB的内存。Cortex-A15旨在运行现代操作系统和虚拟机,虚拟机是可以帮助在同一处理器上同时运行多个操作系统的特殊程序,用于服务器和云计算环境,以支持具有不同软件需求的用户。Cortex-A15采用了先进的电源管理技术,可在不使用处理器时动态关闭部分处理器。

Cortex-A15处理器的另一个标志性特点是它是一个多核处理器,为每个集群组织4个核心,每个芯片可以有多个集群。Snoop控制单元提供集群内的一致性,AMBA4规范定义了跨集群支持缓存和系统级一致性的协议,AMBA4总线还支持同步操作。内存系统也更快、更可靠,Cortex-A15的内存系统使用SECDED(单错误纠正,双错误检测)错误控制代码。

下图显示了Cortex-A15内核的管线概览。有5个提取阶段,fetch更复杂,因为Cortex-A15有一个复杂的分支预测器,可以处理多种类型的分支指令。解码、重命名和指令分派单元在7个阶段之间进行流水线处理。寄存器重命名单元和指令窗口对无序处理器的性能至关重要,它们的作用是在给定的周期内发出准备执行的指令集。

ARM Cortex-A15处理器的概览。

ARM Cortex-A15 MPCore芯片结构图。

Cortex-A15有几个执行管线。整数ALU和分支管线各需要3个周期,但乘法和加载/存储管线更长。与其他将NEON/VFP单元视为物理独立单元的ARM处理器不同,Cortex-A15将其集成在内核上,是乱序管线的一部分。现在更详细地看一下管线(参见下图)。


ARM Cortex-A15处理器的管线。

Cortex-A5内核(Cortex-A8的分支预测器也具有相同的功能)分支预测器包含直接分支预测器、间接分支预测器和预测返回地址的预测器。间接分支预测器尝试基于分支指令的PC来预测分支目标,有一个256个条目,由给定分支的历史及其PC索引。实际上不需要复杂的分支预测逻辑来预测返回指令的目标,一个更简单的方法是每当调用函数时记录返回地址,并将其推送到堆栈(称为返回地址堆栈,RAS)。由于函数调用表现出后进先出的行为,因此需要简单地弹出RAS并在从函数返回时获取返回地址的值。最后,为了支持更宽的问题宽度,提取单元被设计为一次从指令缓存中提取128位。

循环缓冲区(也存在于Cortex-A8中)是解码阶段的一个非常有趣的补充。假设在循环中执行一组指令,在任何其他处理器中,都需要在循环中重复获取指令,并对其进行解码,这个过程浪费了能量和内存带宽。可以通过将所有解码的指令包保存在循环缓冲区中来优化此过程,以便在执行循环时完全绕过提取和解码单元,寄存器重命名阶段因此可以从解码单元或循环缓冲器获得指令。

内核维护一个包含所有指令结果的重新排序缓冲区(ROB),ROB中的条目是按程序顺序分配的。重命名阶段将操作数映射到ROB中的条目(在ARM文档中称为结果队列),例如,如果指令3需要一个将由指令1产生的值,则相应的操作数被映射到指令1的ROB条目。所有指令随后进入指令窗口,等待其源操作数就绪。一旦准备就绪,它们就会被发送到相应的管线。Cortex-A15具有2个整数ALU、1个分支单元、1个乘法单元和2个加载/存储单元,NEON/VFP单元每个周期可接受2条指令。

加载/存储单元具有4阶段管线。为了确保精确的异常存储,只有当指令到达ROB的头部时(管线中没有更早的指令),才会向内存系统发出存储。同时,对管线中相同地址执行存储操作的任何加载操作都会通过转发路径获取其值。两个L1缓存(指令和数据)通常各为32KB。

Cortex-A15处理器支持大型二级缓存(高达4 MB),它是一个带有主动预取器的16路集合关联缓存。L1缓存和L2缓存是缓存一致性协议的一部分,Cortex-A55使用基于目录的MESI协议,二级缓存包含一个窥探标记数组,该数组维护一级所有目录的副本。如果I/O操作希望修改某一行,则二级缓存使用窥探标记数组来查找该行是否位于任何一级缓存中。如果任何一级缓存包含该行的副本,则该副本无效。同样,如果存在DMA读取操作,则L2控制器从包含其副本的L1缓存中取出该行。此外,还可以扩展此协议以支持L3缓存和一系列外围设备。

Cortex-A53是一个可配置内核,支持ARMv8指令集架构,作为IP(知识产权)核心交付。IP核心是嵌入式、个人移动设备和相关市场的主要技术交付形式,数十亿的ARM和MIPS处理器已经从这些IP核中创建出来。

注意,IP核与Intel i7多核计算机中的核不同。IP核(其本身可能是多核)被设计为与其他逻辑结合(因此它是芯片的“核心”),包括专用处理器(例如视频编码器或解码器)、I/O接口和存储器接口,然后被制造为针对特定应用优化的处理器。虽然处理器内核在逻辑上几乎相同,但最终产生的芯片有很多不同。一个参数是二级缓存的大小,它可以变化16倍。

SPEC2006整数基准的ARM Cortex-A53上的CPI。

19.5.5.2 AMD处理器

现在来研究AMD处理器的设计。AMD处理器实现x86指令集,为移动设备、上网本、笔记本电脑、台式机和服务器制造处理器。本节将研究设计频谱两端的两个处理器。AMD Bobcat处理器适用于移动设备、平板电脑和上网本,它实现了x86指令集的一个子集,其设计的主要目标是功率效率和可接受的性能水平。AMD Bulldozer处理器处于另一端,专为高端服务器设计,它针对性能和指令吞吐量进行了优化,也是AMD的第一个多线程处理器,使用了一种称为联合内核的新型内核来实现多线程。

AMD Bobcat

Bobcat处理器设计为在10-15W功率预算内运行,在这个功率预算内,Bobcat的设计者能够在处理器中实现大量复杂的架构特性,如Bobcat使用了一个相当复杂的2执行乱序管线。Bobcat的流水线使用了一个复杂的分支预测器,设计用于在同一周期内获取2条指令。它随后可以以该速率解码它们,并将它们转换为复杂的微操作(Cop)。AMD术语中的复杂微操作是一种类似CISC的指令,可以读取和写入内存,这组Cop随后被发送到指令队列、重命名引擎和调度器。

调度器按顺序选择指令,并将它们分派给ALU、内存地址生成单元和加载/存储单元。为了提高性能,加载/存储单元还无序地向内存系统发送请求。因此很容易地得出结论,Bobcat支持弱内存模型,除了复杂的微架构功能外,Bobcat还支持SIMD指令集(最高SSE 4)、自动将处理器状态保存在内存中的方法以及64位指令。为了确保处理器的功耗在限制范围内,Bobcat包含大量节能优化,其中一个突出的机制被称为时钟门控( clock gating),对于未使用的单元,时钟信号被设置为逻辑0,确保了在未使用的单元中没有信号转换,因此没有动态功率的耗散。Bobcat处理器还尽可能使用指向数据的指针,并尽量减少在处理器中不同位置复制数据。

下图显示了AMD Bobcat处理器的流水线框图。Bobcat处理器的一个显著特点是相当复杂的分支预测器,需要首先预测指令是否是分支,因为在x86 ISA中没有办法快速解决这个问题。如果一条指令被预测为分支,需要计算其结果(执行/未执行)和目标。AMD使用基于高级模式匹配的专有算法进行分支预测,在分支预测之后,提取引擎一次从I缓存中提取32个字节,并将其发送到指令缓冲区。

AMD Bobcat处理器的流水线。

解码器一次考虑22个指令字节,并试图划分指令边界。这是一个缓慢且计算密集的过程,因为x86指令长度可能具有很大的可变性。较大的处理器通常缓存该信息,以便第二次解码指令更容易。由于Bobcat的解码吞吐量仅限于2条指令,因此它没有此功能。现在,大多数x86指令对在22字节内,因此解码器可以在大多数时间提取这两个x86指令的内容。解码器通常将每个x86指令转换为1-2个Cop,对于一些不常用的指令,它用微码序列替换指令。

随后,Cop被添加到56条目重新排序缓冲区(ROB)中。Bobcat有两个调度程序,整数调度器有16个条目,浮点调度器有18个条目。整数调度器在每个周期选择两条指令执行,整数管线有两个ALU和两个地址生成单元(1个用于加载,1个用于存储),浮点管线也可以在每个周期执行两次Cop,但有一些限制。

处理器中的加载存储单元将值从存储转发到流水线中的加载指令。Bobcat拥有32KB(8路关联)的L1 D和I缓存,它们连接到512 KB的二级缓存(16路集合关联),总线接口将二级缓存连接到主内存和系统总线。

现在考虑一下管线的时间调度。Bobcat整数管道分为16个阶段,由于管线较深,可以在1-2GHz之间的频率对核心进行时钟控制。Bobcat管线有6个提取周期和3个解码周期,最后3个提取周期与解码周期重叠,重命名引擎和调度程序需要4个周期。对于大多数整数指令,需要1个周期读取寄存器文件,1个周期访问ALU,以及1个周期将结果写回寄存器文件。浮点流水线有7个附加阶段,加载存储单元需要3个附加阶段用于地址生成和数据缓存访问。

AMD Bulldozer

顾名思义,Bulldozer核心位于频谱的另一端,主要用于高端台式机、工作站和服务器。除了是一个激进的无序机器之外,它还具有多线程功能。Bulldozer实际上是多核、单粒度多线程处理器和SMT的组合,其核心实际上是一个“联合核心”,由两个共享功能单元的较小核心组成。

两个Bulldozer线程共享获取引擎(参见下图)和解码逻辑。管线的这一部分(称为前端)在两个线程之间每一个周期或几个周期切换一次,然后将整数、加载存储和分支指令分派到两个内核中的一个。每个内核包含一个指令调度器、寄存器文件、整数执行单元、L1缓存和一个加载存储单元,每个内核可被视为一个没有指令获取和解码功能的自我支持的内核。两个内核共享在SMT模式下运行的浮点单元,它有专用的调度器和执行单元Bulldozer处理器设计用于在3-4GHz下运行服务器和数字工作负载,最大功耗限制为125-140W。

Bulldozer处理器概览。

现在考虑下图中处理器的更详细视图。

Bulldozer处理器的读取宽度是Bobcat处理器的两倍。它每个周期最多可以提取和解码4条x86指令,与Bobcat类似,Bulldozer处理器具有复杂的分支预测逻辑,可以预测指令是否为分支、分支结果和分支目标。它有一个多级分支目标缓冲区,可以保存大约5500条分支指令的预测分支目标。

解码引擎将x86指令转换为Cop。AMD中的一个Cop是CISC指令,尽管有时比原始的x86指令简单,大多数x86指令只转换为一个Cop。然而,有些指令被转换为多个Cop,有时需要使用微码内存进行指令转换。解码引擎的一个有趣的方面是它可以动态地合并指令以生成更大的指令,例如,它可以将比较指令和后续分支指令合并到一个Cop中。这被称为宏指令融合。

随后,整数指令被分派到内核执行。每个内核都有一个重命名引擎、指令调度器(40个条目)、一个寄存器文件和一个128个条目的ROB。核心的执行单元由4个独立的管线组成,两个管线具有ALU,另外两个管道专用于内存地址生成。加载存储单元协调对内存的访问,将存储之间的数据转发给加载,并使用跨步预取器执行积极的预取。跨步预取器可以自动推断数组访问,并从将来最可能访问的数组索引中提取。

两个内核共享一个64KB的指令缓存,而每个内核都有一个16KB的一级写缓存,每次加载访问需要4个周期。L1缓存连接到不同大小的L2缓存,在核心之间共享,具有18个周期的延迟。

浮点单元在两个内核之间共享,不仅仅是一个功能单元,可被看作是一个SMT处理器,同时调度和执行两个线程的指令。它有自己的指令窗口、寄存器文件、重命名和唤醒选择(无序调度)逻辑。Bulldozer的浮点单元有4条处理SIMD指令(整数和浮点)和常规浮点指令的流水线。前两条管线具有128位浮点ALU,称为FMAC单元,FMAC(浮点乘法累加)单元可以执行形式(a <-- a+b*c)的运算,以及常规浮点运算。最后两条管线具有128位整数SIMD单元,另外,最后一条管线还用于将结果存储到内存中。浮点单元有一个专用的加载存储单元来访问内核中的缓存。

19.5.5.3 Intel处理器

前些年的英特尔处理器在笔记本电脑和台式机市场占据主导地位,本节将介绍两种设计非常不同的英特尔处理器的设计。第一个处理器是Intel Atom,专为手机、平板电脑和嵌入式计算机设计。另一端是Sandy Bridge多核处理器,它是Intel Core i7系列处理器的一部分,这些处理器用于高端台式机和服务器。这两个处理器都有非常不同的业务需求,导致了两种截然不同的设计。

x86的祖先是1972年开始生产的第一批微处理器。Intel 4004和8008是极其简单的4位和8位累加器式架构,Morse等人将8086从上世纪70年代末的8080演变为具有更好吞吐量的16位架构。当时几乎所有微处理器的编程都是用汇编语言完成的,内存和编译器都很短缺。英特尔希望保持8080用户的基础,因此8086被设计为与8080“兼容”。8086从来都不是与8080兼容的目标代码,但其架构足够接近,可以自动完成汇编语言程序的翻译。

1980年初,IBM选择了一个带有8位外部总线的8086版本,称为8088,用于IBM PC。他们选择了8位版本以降低体系结构的成本,这一选择,加上IBM PC的巨大成功,使得8086体系结构无处不在。IBM PC的成功部分归因于IBM开放了PC的体系结构,并使PC克隆产业蓬勃发展。80286、80386、80486、奔腾、奔腾Pro、奔腾II、奔腾III、奔腾4和AMD64扩展了体系结构并提供了一系列性能增强。

虽然68000被选择用于Macintosh,但Mac从未像PC那样普及,部分原因是苹果不允许基于68000的Mac克隆,68000也没有获得8086所享受的软件。摩托罗拉68000在技术上可能比8086更重要,但IBM的选择和开放架构策略的影响主导了68000在市场上的技术优势。

一些人认为,x86指令集的不雅是不可避免的,是任何架构取得巨大成功所必须付出的代价。我们拒绝这种观点。显然,没有一个成功的架构可以抛弃以前实现中添加的特性,随着时间的推移,一些特性可能会被视为不可取的。x86的尴尬始于8086指令集的核心,并因8087、80286、80386、MMX、SSE、SSE2、SSE3、SSE4、AMD64(EM64T)和AVX中发现的架构不一致扩展而加剧。

一个反例是IBM 360/370体系结构,它比x86老得多,主宰了大型机市场,就像x86主宰了PC市场一样。毫无疑问,由于有了更好的基础和更兼容的增强功能,该指令集在首次实现50年后比x86更有意义。将x86扩展到64位寻址意味着该体系结构可能会持续几十年,未来的指令集人类学家将从这样的架构中一层又一层地剥离,直到他们从第一个微处理器中发现人工制品。鉴于这样的发现,他们将如何判断当今的计算机架构?

Intel核心微架构

尽管超标量设计的概念通常与RISC架构相关联,但同样的超标量原理也可以应用于CISC机器,其中值得注意的例子是Intel x86架构。Intel系列中超标量概念的演变值得注意,386是一种传统的CISC非管线机器,486引入了第一个管线x86处理器,将整数运算的平均延迟从两到四个周期减少到一个周期,但仍限于每个周期执行一条指令,没有超标量元素。最初的奔腾有一个适度的超标量组件,由两个独立的整数执行单元组成,奔腾Pro推出了全面的超标量设计,乱序执行。随后的x86型号改进并增强了超标量设计。

下图显示了x86管线架构的版本。Intel将流水线架构称为微架构(microarchitecture),微架构是机器指令集体系结构的基础和实现,也称为Intel核心微架构。它在Intel core 2和Intel Xeon处理器系列的每个处理器核上实现,还有一个增强型Intel核心微架构。两种微架构之间的一个关键区别是,后者提供了第三级缓存。

Intel核心微架构。

Intel Core i7-990X结构图。

Intel 8085 CPU结构图。

下表显示了缓存架构的一些参数和性能特征。所有缓存都使用写回更新策略,当指令从内存位置读取数据时,处理器会按以下顺序在缓存和主内存中查找包含此数据的缓存行:

  • 发起核心的L1数据缓存。
  • 其他核心的L1数据缓存和L2缓存。
  • 系统内存。

基于Intel Core微架构的处理器的缓存/内存参数和性能。

仅当缓存行被修改时,缓存行才会从另一个内核的一级数据缓存中取出,而忽略二级缓存中的缓存行可用性或状态。上表b显示了从内存集群中获取不同位置的前四个字节的特性,延迟列提供访问延迟的估计值,然但实际延迟可能会因缓存负载、内存组件及其参数而异。

Intel Core微架构的管线包含:

  • 一种有序提交前端,从内存中获取指令流,具有四个指令解码器,将解码后的指令提供给无序执行核心。每个指令都被翻译成一个或多个固定长度的RISC指令,称为微操作(micro-operation或micro-ops)
  • 一个无序的超标量执行核心,每个周期最多可以发出6个微操作,并在源就绪且执行资源可用时重新排序微操作以执行。
  • 一种有序的引退单元,确保微操作的执行结果得到处理,架构状态和处理器的寄存器集根据原始程序顺序进行更新。

实际上,Intel核心微架构在RISC微架构上实现了CISC指令集架构。内部RISC微操作通过至少14阶段的流水线,在某些情况下,微操作需要多个执行阶段,从而导致更长的管线,与早期Intel x86处理器和Pentium上使用的5阶段流水线形成了鲜明对比。

接下来阐述前端(Front End)。

先阐述分支预测单元。前端需要提供解码的指令(微操作),并将流维持到一个6阶段的无序引擎,该引擎由三个主要部件组成:分支预测单元(BPU)、指令提取和预译码单元、指令队列和译码单元。

分支预测单元此单元通过预测各种分支类型(条件、间接、直接、调用和返回),帮助指令获取单元获取最可能执行的指令,BPU为每种分支类型使用专用硬件,分支预测使处理器能够在决定分支结果之前很久就开始执行指令。

微架构使用基于最近执行分支指令的历史的动态分支预测策略。维护分支目标缓冲区(BTB),该缓冲区缓存关于最近遇到的分支指令的信息。每当在指令流中遇到分支指令时,都会检查BTB,如果BTB中已经存在条目,则指令单元在确定是否预测分支被采取时由该条目的历史信息引导。如果预测到分支,则与该条目关联的分支目标地址用于预取分支目标指令。

一旦指令被执行,相应条目的历史部分被更新以反映分支指令的结果。如果此指令未在BTB中表示,则将此指令的地址加载到BTB中的条目中,如果需要,将删除较旧的条目。

前两段的描述大体上适用于原始奔腾机型以及后来的奔腾机型(包括后续的Intel机型)上使用的分支预测策略,但在奔腾的情况下,使用了相对简单的2位历史方案。后来的型号具有更长的流水线(Intel核心微架构为14阶段,奔腾为5阶段),因此预测失误的惩罚更大。所以后面的模型使用了更复杂的分支预测方案,具有更多的历史比特,以降低预测失误率。

根据以下规则,使用静态预测算法预测在BTB中没有历史的条件分支:

  • 对于与指令指针(IP)无关的分支地址,如果分支是返回,则预测执行,否则不执行。
  • 对于IP相对后向条件分支,请进行预测。此规则反映了循环的典型行为。
  • 对于IP相对前向条件分支,预测不执行。

再阐述指令获取和预译码单元。指令获取单元包括指令翻译后备缓冲器(ITLB)、指令预取器、指令缓存和预译码逻辑。

指令获取是从一级指令缓存执行的。当发生一级缓存未命中时,有序前端将新指令从二级缓存一次64字节送入一级缓存。默认情况下,指令是按顺序提取的,因此每个二级缓存行提取都包含下一条要提取的指令,经由分支预测单元的分支预测可以改变该顺序提取操作。ITLB将给定的线性IP地址转换为访问二级缓存所需的物理地址,前端的静态分支预测用于确定下一个要获取的指令。

预译码单元接受来自指令高速缓存或预取缓冲器的十六个字节,并执行以下任务:

  • 确定指令的长度。
  • 解码与指令相关的所有前缀。
  • 标记解码器指令的各种属性(如“is branch”)。

预编码单元每个周期最多可将六条指令写入指令队列。如果一个提取包含六条以上的指令,则预解码器在每个周期继续解码多达六条指令,直到提取中的所有指令都写入指令队列。后续提取只能在当前提取完成后进入预编码。

最后阐述指令队列和解码单元。提取的指令被放置在指令队列中,从那里,解码单元扫描字节以确定指令边界,由于x86指令的长度可变,是一个必要的操作。解码器将每个机器指令翻译成一到四个微操作,每个微操作都是118位RISC指令。请注意,大多数纯RISC机器的指令长度仅为32位,需要更长的微操作长度来适应更复杂的x86指令。尽管如此,微操作比它们派生的原始指令更容易管理。

一些指令需要四个以上的微操作,这些指令被传送到微码ROM,其中包含与复杂机器指令相关的一系列微操作(五个或更多),例如一个字符串指令可以转换为一个非常大(甚至数百个)的重复微操作序列。因此,微码ROM是一个微程序控制单元。

生成的微操作序列被传递到重命名/分配器模块。

上面阐述完前端的三个部件,接下来阐述乱序执行逻辑。

处理器的这一部分对微操作进行重新排序,以允许它们在输入操作数就绪时尽快执行。分配阶段分配执行所需的资源,它执行以下功能:

  • 如果在一个时钟周期内到达分配器的三个微操作中的一个无法使用所需的资源(如寄存器),则分配器会暂停管线。
  • 分配器分配一个重新排序缓冲区(ROB)条目,该条目跟踪随时可能正在进行的126个微操作之一的完成状态。
  • 分配器为微操作的结果数据值分配128个整数或浮点寄存器项中的一个,并且可能分配一个用于跟踪机器流水线中48个加载或24个存储之一的加载或存储缓冲区。
  • 分配器在指令调度器前面的两个微操作队列之一中分配一个条目。

ROB是一个循环缓冲区,最多可容纳126个微操作,还包含128个硬件寄存器。每个缓冲区条目由以下字段组成:

  • 状态:指示此微操作是否计划执行、是否已调度执行,或是否已完成执行并准备好退出。
  • 内存地址:生成微操作的奔腾指令的地址。
  • 微操作:实际操作。
  • 别名寄存器:如果微操作引用了16个架构寄存器中的一个,则此条目将该引用重定向到128个硬件寄存器中的其中一个。

微操作按顺序进入ROB,然后微操作从ROB无序地发送到调度/执行单元,调度的标准是适当的执行单元和此微操作所需的所有必要数据项可用,最后微操作按顺序从ROB中退出。为了实现有序清理,在每个微操作被指定为准备清理后,微操作首先被清理。

重命名阶段将对16个架构寄存器(8个浮点寄存器,外加EAX、EBX、ECX、EDX、ESI、EDI、EBP和ESP)的引用重新映射到一组128个物理寄存器中。该阶段消除了由有限数量的架构寄存器引起的错误依赖,同时保留了真实的数据依赖(写入后读取)。

在资源分配和寄存器重命名之后,微操作被放置在两个微操作队列中的一个队列中,在那里它们被保存,直到调度器中有空间为止。两个队列中的一个用于内存操作(加载和存储),另一个用于不涉及内存引用的微操作。每个队列都遵循FIFO(先进先出)规则,但队列之间不保持顺序。也就是说,相对于另一个队列中的微操作,微操作可能会被无序地从一个队列读取。此举为调度器提供了更大的灵活性。

调度器负责从微操作队列中检索微操作,并分派这些微操作以供执行。每个调度程序查找状态指示微操作具有其所有操作数的微操作,如果该微操作所需的执行单元可用,则调度器获取该微操作并将其分派给适当的执行单元。一个周期内最多可调度六个微操作,如果给定的执行单元有多个微操作可用,那么调度器会从队列中按顺序分派它们。这是一种FIFO规则,有利于按顺序执行,但此时指令流已被依赖项和分支重新排列,基本上已乱序。

四个端口将调度器连接到执行单元,端口0用于整数和浮点指令,但分配给端口1的简单整数操作和处理分支预测失误除外。此外,MMX执行单元在这两个端口之间分配,其余端口用于内存加载和存储。

接下来阐述整数和浮点执行单元。

整数和浮点寄存器文件是执行单元挂起操作的源,执行单元从寄存器文件以及L1数据缓存中检索值。单独的管道阶段用于计算标志(如零、负),通常是分支指令的输入。

随后的管线阶段执行分支检查,此函数将实际分支结果与预测结果进行比较。如果分支预测被证明是错误的,那么在处理的各个阶段都存在必须从管道中删除的微操作。然后,在驱动阶段将正确的分支目标提供给分支预测器,该阶段将从新的目标地址重新启动整个管线。

Intel Atom

Intel Atom处理器一开始就有一套独特的要求。设计者必须设计一个非常节能的内核,具有足够的功能来运行商业操作系统和web浏览器,并且完全兼容x86。一种降低功耗的粗略方法是实现x86 ISA的一个子集,这种方法将导致更简单和更节能的解码器。由于已知解码逻辑在x86处理器中是耗电的,因此降低其复杂性是降低功耗的最简单方法之一,但完全的x86兼容性排除了此选项。

因此,设计师不得不考虑非常节能且不影响性能的新颖设计,决定简化管线,只考虑两个问题。乱序管线具有复杂的结构,用于确定指令之间的依赖关系,以及无序执行指令。其中一些结构是指令窗口、重命名逻辑、调度器和唤醒选择逻辑,它们增加了处理器的复杂性,并增加了其功耗。

其次,大多数英特尔处理器通常将CISC指令转换为类似RISC的微操作,这些微操作像流水线中的普通RISC指令一样执行。指令翻译过程消耗大量的能量,Intel Atom的设计者决定放弃指令翻译,Atom管线直接处理CISC指令。对于一些非常复杂的指令,Atom处理器确实使用微码ROM将它们转换为更简单的CISC指令。然而,这更多的是一种例外,而不是一种常态。

与RISC处理器相比,CISC处理器的提取和解码阶段更加复杂,因为指令具有可变的长度,划分指令边界是一个乏味的过程。其次,解码的过程也更加复杂。Atom将其16阶段流水线中的6个阶段用于指令获取和解码,如下图所示,其余阶段执行寄存器访问、数据缓存访问和指令执行等传统功能。除了更简单的流水线之外,Intel Atom处理器的另一个显著特点是它支持双向多线程,现代移动设备通常运行多任务操作系统,用户同时运行多个程序。多线程可以支持这一需求,实现额外的并行性,并减少处理器管线中的空闲时间。管线中的最后3个阶段专门用于处理异常、处理与多线程相关的事件以及将数据写回寄存器或内存。与所有现代处理器一样,存储指令不在关键路径上。通常,不遵守顺序一致性的处理器将其存储值写入写入缓冲区并继续执行后续指令。

Intel Atom处理器的流水线。

现在更详细地描述其设计。从提取和解码阶段开始(见下图)。在提取阶段,Atom处理器预测分支的方向和目标,并将一个字节流提取到指令预取缓冲区。下一个任务是在获取的字节流中划分指令,x86指令的边界是这部分流水线执行的最复杂的任务之一,因此Atom处理器有一个2级预解码步骤,在第一次解码指令后,在指令之间添加1位标记,该步骤由ILD(指令长度解码器)单元执行,然后将指令保存在I缓存中。随后,从I缓存中提取的预解码指令可以绕过预解码步骤,并直接进入解码步骤,因为其长度是已知的。保存这些附加标记会减少I缓存的有效大小,I缓存的大小为36KB,但在添加标记之后,它实际上是32KB。解码器不会将大多数CISC指令转换为类似RISC的微操作。但对于一些复杂的x86指令,有必要通过访问微码内存将它们转换为更简单的微操作。

Intel Atom处理器内部结构图。

随后,整数指令被分派到整数执行单元,FP指令被分派给FP执行单元,Atom有两个整数ALU、两个FP ALU和两个用于内存操作的地址生成单元。为了支持多线程,需要有两个指令队列副本(每个线程1个),以及两个整数和FP寄存器文件副本。英特尔没有像指令队列那样创建硬件结构的副本,而是采用了不同的方法,例如在Atom处理器中,32个条目的指令队列被分成两个部分(每个部分有16个条目),每个线程都使用其部分的指令队列。

现在讨论一下关于多线程的一般观点。多线程通过减少芯片上的资源闲置时间来提高其利用率,因此理想情况下,多线程处理器应该具有更高的功率开销(因为活动更高),并且具有更好的指令吞吐量。需要注意的是,除非处理器设计得很明智,否则吞吐量可能无法预测地增加。多线程增加了共享资源(如缓存、TLB和指令调度/调度逻辑)中的争用,特别是,缓存在线程之间进行分区,预计未命中率会增加,TLB的情况也类似。另一方面,流水线不需要在二级未命中的阴影下或在程序的低ILP(指令级并行性)阶段保持空闲。因此,多线程有其优点和缺点,只有当好的效果(性能提高效果)大于坏的效果(争用增加效果)时,才能获得性能优势。

Intel Sandy Bridge

现在讨论一款名为Sandy Bridge处理器的高性能Intel处理器的设计,该处理器是市场上Intel Core i7处理器的一部分。设计Sandy Bridge处理器的主要目的是支持新兴的多媒体工作负载、数字密集型应用程序和多核友好并行应用程序。

Sandy Bridge处理器最显著的特点是它包含一个片上图形处理器。图形处理器装有专门的单元,用于执行图像渲染、视频编码/解码和自定义图像处理,CPU和GPU通过大型共享片上L3缓存进行通信。Sandy Bridge处理器概览如下图所示。

随着芯片上组件的增加,CPU也进行了大量修改。Sandy Bridge处理器完全支持新的AVX指令集,AVX指令集是一个256位SIMD指令集,对于每个SIMD单元,可以同时执行4个双精度操作或8个单精度操作。由于添加了这么多高性能功能,因此也有必要添加许多节能功能。对于未使用的单元,诸如DVFS(动态电压频率缩放)、时钟门控(关闭时钟)和电源门控(关闭一组功能单元的电源)等技术已然常见。此外,Sandy Bridge的设计人员修改了核心的设计,以尽可能减少单元之间的复制值(AMD Bobcat的设计人员也做出了类似的设计决策),并对一些核心结构的设计进行了基本更改,如分支预测器和分支目标缓冲器,以提高功率效率。

需要注意的是,Intel Sandy Bridge等处理器设计为支持多核(4-8),每个内核支持双向多线程,因此可以在8核机器上运行16个线程,这些线程可以在彼此、三级缓存组、GPU和片上北桥控制器之间进行主动通信。有这么多通信实体,需要设计灵活的片上网络,以促进高带宽和低延迟通信。

Sandy Bridge的设计者选择了基于环的互连,而不是传统的总线。Sandy Bridge处理器设计用于32纳米1半导体工艺,它的继任者是Intel Ivy Bridge处理器,该处理器具有相同的设计,但设计用于22纳米工艺。

现在考虑下图中Sandy Bridge核心的详细设计。它有一个32KB的指令缓存,每个周期可以提供4条x86指令。解码x86指令流的第一步是划定它们的边界(称为预译码),一旦4条指令被预先编码,它们就被发送到解码器。Sandy Bridge有4个解码器,其中三个是简单的解码器,一个解码器被称为使用微程序内存的复杂解码器,所有解码器都将CISC指令转换为类似RISC的微操作。Sandy Bridge有一个用于微操作的L0缓存,可以存储大约1500个微操作,L0微操作缓存在性能和功耗方面都具有性能优势。如果分支目标处的指令在L0缓存中可用,则可以减少分支预测失误延迟。由于程序中的大多数分支都在分支附近,因此预计L0缓存的命中率会很高,还可以节省电力。如果一条指令的微操作在L0缓存中可用,就不需要再次获取、预编码和解码该指令,从而避免了这些耗电的操作。

Sandy Bridge处理器的内部结构。

带有内存组件的Core i7管线。

一个有趣的设计决策是设计者针对分支预测器做出的,代表了计算机体系结构中的许多类似问题:应该用复杂的条目设计一个小结构,还是应该用简单的条目设计大结构?例如,应该使用4路16 KB关联缓存,还是2路32 KB关联缓存?一般来说,这种性质的问题没有明确的答案,它们高度依赖于目标工作负载的性质。对于Sandy Bridge处理器,设计者有一个选择,可以选择具有2位饱和计数器的分支预测器,或者具有更多条目的预测器和1位饱和计数器。发现后一种设计的功率和性能权衡更好,因此他们选择了1位计数器。

随后,4个微操作被发送到执行无序调度的重命名和调度单元。在早期的处理器(如Nehalem处理器)中,正在运行的指令的临时结果保存在ROB中,一旦指令完成,它们就被复制到寄存器中,该操作涉及复制数据,从功率的角度来看效率不高。Sandy Bridge避免了这一点,并将结果直接保存在物理寄存器中,类似于高性能RISC处理器。当一条指令到达重命名阶段时,检查重命名表中的映射,并查找包含源操作数值的物理寄存器的ID,或者在将来的某个时间点应该包含这些值。随后,要么读取物理寄存器文件,要么等待生成它们的值。使用物理寄存器文件是一种比使用其他方法更好的方法,这些方法将未完成指令的结果保存在ROB中,然后将结果复制回寄存器文件,使用物理寄存器是快速、简单和节能的。通过在Sandy Bridge处理器中使用这种方法,ROB得到了简化,在任何时间点都有可能有168条正在运行的指令。

Sandy Bridge处理器有3个整数ALU、1个加载单元和1个加载/存储单元,整数单元从160个入口寄存器文件读取和写入其操作数。为了支持浮点运算,它有一个FP加法单元和一个FP乘法单元,它们支持AVX SIMD指令集(对单精度和双精度数字集执行256位操作)。此外,为了支持256位操作,Intel在x86 AVX ISA中添加了新的256位矢量寄存器(YMM寄存器)。

要实现AVX指令集,必须支持来自32KB数据缓存的256位传输。Sandy Bridge处理器可以执行两个128位加载,每个周期执行一个128位存储。在加载YMM(256位)寄存器的情况下,两个128位加载操作被融合为一个(256位的)加载操作。Sandy Bridge有一个256 KB的二级缓存和一个大的(1-8 MB)三级缓存,三级缓存分为多个组,三级组、内核、GPU和北桥控制器使用基于单向环的互连进行连接。注意,单向环的直径是(N-1),因为只能在一个方向上发送消息。为了克服这个限制,每个节点实际上连接到环上的两个点,这些点彼此正好相反,因此有效直径接近N=2。

Sandy Bridge处理器有一个独特功能,称为turbo(涡轮)模式,想法如下。假设处理器有一段静止期(活动较少),所有芯的温度将保持相对较低。再假设用户决定执行计算密集型活动,需要数字密集型的计算,也需要大量的功率。每个处理器都有额定热设计功率(TDP),是处理器允许消耗的最大功率。在turbo模式下,允许处理器在短时间内(20-25秒)消耗比TDP更多的功率,亦即允许处理器以高于标称值的频率运行所有单元。一旦温度达到某个阈值,turbo模式将自动关闭,处理器将恢复正常运行。需要注意的主要点是,在短时间内消耗大量电力不是问题,但即使在短时间里也不允许出现非常高的温度,因为高温会永久损坏芯片,比如如果一根电线熔化,整个芯片就会被破坏。由于处理器需要几秒钟才能加热,因此可以利用这一效应,使用高频率和高功率的相位来快速完成零星的工作。请注意,turbo模式对于耗时数小时的长时间运行作业不适用。

Intel CPU架构

Intel架构指令扩展的软件编程接口以及未来几代Intel处理器可能包含的功能。指令集扩展涵盖了各种应用领域和编程用途。512位
SIMD矢量SIMD扩展,称为Intel Advanced vector extensions 512(Intel AVX-512)指令,与矢量扩展(IntelAVX)和IntelAdvanced vector Extension(IntelAVX2)指令相比,可提供全面的功能和更高的性能。

512位SIMD指令扩展的基础称为Intel AVX-512 Foundation指令,包括Intel AVX和Intel AVX2系列SIMD指令的扩展,但使用新的编码方案编码,支持512位矢量寄存器、64位模式下最多32个矢量寄存器以及使用opmask寄存器的条件处理。Intel处理器相关的特性包含但不限于:高级矩阵扩展(AMX)、ENQCMD/ENWCMDS指令和虚拟化支持、TSX挂起加载地址跟踪、线性地址转换、架构最后分支记录(LBRS)、非回写锁定禁用架构、总线锁定和VM通知功能、资源管理器技术、增强型硬件反馈接口(EHFI)、线性地址屏蔽(LAM)、基于Sapphire Rapids微架构的处理器的机器错误代码、IPI虚拟化等等。关于它们的具体描述可参阅Intel的说明文档:Intel Architecture Instruction Set Extensions and Future Feature

Xeon E5-2600/4600的芯片架构如下:

2021年的Intel架构支持标量、向量、矩阵和空间的混合计算:

单个包中集成了混合计算集群:

新架构的基础组件如下,包含了各类高性能且节能的核心:

对于高效的x86核心而言,其具备高度可扩展的体系结构,以满足下一个计算十年的吞吐量效率需求:

专为吞吐量而设计,为现代多任务实现可伸缩的多线程性能。针对功率和密度高效吞吐量进行了优化,具有:

  • 深度前端,按需长度解码。
  • 具有许多执行端口的宽带后端。
  • 针对最新晶体管技术的优化设计。

对于指令控制,带有按需指令长度解码器的大型指令缓存(64KB)加快了现代工作负载的速度,占用了大量代码。通过深度的分支历史和大结构尺寸精确预测分支。

双三宽乱序解码器,每个周期最多可解码6条指令,同时保持功率和延迟:

对于数据执行,五宽分配,八宽回收,256项乱序窗口发现数据并行性,17个执行端口执行数据并行。

数据执行涉及的硬件部件、数量和布局如下:

在内存子系统方面:

  • 双重加载+双重存储。
  • 高达4MB的L2缓存:四个核心之间共享L2,在17个延迟周期内具有64字节/周期带宽。
  • 高级预取器:在所有缓存级别检测各种流。
  • 深度缓冲(Deep buffering,注意不是Depth Buffer):支持64次未命中。
  • 英特尔资源管理器(Resource Director)技术:使软件能够控制内核之间以及不同软件线程之间的公平性。

具备现代的指令级:

  • 安全。
  • 浮点乘法累加(FMA)指令,吞吐量为2倍。
  • 添加关键指令以实现整数AI吞吐量(VNNI)。
  • 宽矢量。指令集架构。
  • 支持Intel VT-rp(虚拟化技术重定向保护)。
  • 高级推测执行验证方法。
  • Intel Control flow Enforcement Technology旨在提高防御深度。
  • 支持具有AI扩展的高级矢量指令。

每个晶体管的功率和性能效率:

  • 高度关注功能选择和设计实现成本,以最大限度地提高面积效率,从而实现核心数量的扩展。
  • 每个指令的低切换能量可最大化受功率限制的吞吐量,是当今吞吐量驱动工作负载的关键。
  • 在扩展性能范围的同时,降低了所有频率传输功率所需的工作电压。

处理器功率计算公式。

在延迟性能方面,ISO功率下提升40%以上的性能,在ISO性能下降低功率40%以上。

在吞吐量性能方面,提升80%的性能,降低80%的功率。

专为吞吐量而设计,为现代多任务实现可伸缩的多线程性能。针对功率和密度高效吞吐量进行了优化,具有:

  • 深度前端,按需长度解码。
  • 宽后端,具有许多执行端口。
  • 针对最新晶体管技术的优化设计。

接下来阐述x86核心的性能。架构的目标是:

  • 提供通用CPU性能的步进函数。
  • 使用新功能改进Arch/uArch,以适应不断变化的工作负载模式的趋势。
  • 在人工智能性能加速方面实现创新。

新的x86架构专为速度而设计,通过以下方式突破低延迟和单线程应用程序性能的限制:

  • 更宽。
  • 更深。
  • 更智能。

使用大代码占用和大数据集加速工作负载,通过协处理器实现矩阵乘法的新型AI加速技术,用于细粒度电力预算管理的新型智能PM控制器。

前端获取指令并将其解码到\(\mu\)级别的操作:

  • 更大的代码:从128提升到256个4K iTLB,从16提升到32个2M/4M iTLB。
  • 更宽:解码长度从16B提升到32B。
  • 更智能:提高分支预测精度,更智能的代码预取机制。
  • \(\mu\)队列:每线程条目从70提升到72,单个线程从70提升到144。
  • \(\mu\)操作:2.25K提升到4K,提升命中率,提升前端带宽。

乱序引擎跟踪\(\mu\)操作依赖性并将准备好的\(\mu\)操作分派给执行单元:

  • 更宽:分配宽度从5提到到6,执行端口从10提升到12。
  • 更深:512项重新排序缓冲区和更大的调度器大小。
  • 更智能:在重命名/分配阶段“执行”更多指令。

整数执行单元添加了第5代整数执行端口/ALU,所有5个端口上的1周期LEA也用于算术计算:

向量运算单元具有新的快速加法器(FADD):节能、低延迟,以及FMA单元支持FP16数据类型:FP16添加到Intel AVX512,包括复数支持。

L1缓存和内存子系统的特性有:

  • 更宽:加载端口从2提升到3:3x256 bit的加载,2x512 bit的加载。
  • 更深:更深入的加载缓冲区和存储缓冲区暴露了更多的内存并行性。
  • 更智能:降低有效负载延迟,更快的内存消除分支。
  • 更大数据:DTLB(数据快表)从64提升到96,L1数据缓存从12提升到16且具有增强型预取器,页面遍历器(page walker)从2提升到4。

L2缓存和内存子系统的特性有:

  • 更大:1.25M(客户端)或2M(数据中心)。
  • 更快:最大需求未命中从32提升到48。
  • 更智能:基于L2缓存模式的多路径预取器,基于反馈的预取限制(throttling),全线写入预测带宽优化以减少DRAM读取。

通用性能与第11代Intel Core,ISO频率下性能提高19%:

英特尔高级矩阵扩展(英特尔AMX),分块矩阵乘法加速器——数据中心,VNNI从256 int8提升至AMX的2048 int8,提升了8倍的操作、时钟周期、核心。

AMX架构有两个组件:

  • 分块(Tile):
    • 新的可扩展2D寄存器文件——8个新寄存器,每个1Kb:T0-T7。
    • 寄存器文件支持基本数据运算符——加载/存储、清除、设置为常量等。
    • 分块声明状态并由XSAVE体系结构管理操作系统。
  • TMUL:
    • 矩阵乘法指令集,TILE上的第一个运算符。
    • MAC计算网格计算数据的“分块”。
    • TMUL使用三个分块寄存器(T2=+T1*T0)执行矩阵加法乘法(C=+A*C)。
    • TMUL要求分块已被呈现。

英特尔高级矩阵扩展(英特尔AMX)架构如下:

x86核心是下一个计算十年CPU架构性能的阶梯函数,在高功率效率下显著提高IPC,具备更宽、更深、更智能的特点。更好地支持大型数据集和大型代码占用应用程序,机器学习技术:Intel AMX的分块乘法,增强的电源管理可提高频率和功率。所有这些都采用量身定制的可扩展架构,可为从笔记本电脑到台式机再到数据中心的全系列产品提供服务。

Intel标量架构的路线图如下:

单线程/延迟和多线程性能/吞吐量的二维关系如下:

Intel线程管理器(Thread Director):

  • 调度的目标是:软件透明、实时自适应、从移动端到桌面的可扩展性。
  • 直接内置于核心的智能,基于热设计点、运行条件和功率设置动态调整指导,无需任何用户输入。
  • 向操作系统提供运行时反馈,以便为任何工作负载或工作流做出最佳调度决策。
  • 以纳秒级精度监控每个线程的运行时指令组合以及每个内核的状态。

Thread Director调度案例如下:

1:在P核上调度的优先任务。

2:在E核上调度的后台任务。

3:新的AI线程就绪。

4:AI线程优先调度于P核。

5-7:自旋等待从P核移动到E核。

接下来介绍多核架构Alder Lake。Alder Lake的特点是:

  • 单一可扩展SoC架构。所有客户端段,从9W至125W,基于Intel 7进程构建。
  • 全新核心设计。与Intel线程控制器的性能混合。
  • 业界领先的内存和I/O。支持DDR5、PCIe Gen5、Thunderbolt 4、Wi-Fi 6E等。

可扩展的客户端架构:

架构积木:


Alder Lake的核心和缓存数据如下:

  • 高达16核,8性能核,8效率核。
  • 高达24线程,每个P核2个线程,每个E核1个线程。
  • 高达30M缓存,非包容性,LL缓存。

Alder Lake的内存引领行业向DDR5过渡,支持所有四种主要内存技术,动态电压频率缩放,增强的超频支持。

PCIe引领行业向PCIe Gen5过渡,与Gen4相比,带宽高达2倍,最高64GB/s,x16通道。

内部连线(Interconnect)的特点:

  • 计算结构:高达1000 GB/s,动态延迟优化。
  • I/O结构:高达64GB/s,实时、基于需求的带宽控制。
  • 内存结构:高达204 GB/s,动态总线宽度和频率。

Intel的集成图形芯片\(X^e\) HPG的Render Slice架构如下图,包含核心、采样器、光线追踪单元各4个,几何处理、光栅化、HiZ等各1个,像素后端2个:

\(X^e\) HPG具有可扩展的图形引擎,包含8个Render Slice,其性能和功率关系如下:

基于\(X^e\) HPC的GPU的计算构建块:

向量引擎和矩阵引擎的参数及所处的位置如下:

单个切片结构如下:

多个切片组成的栈(stack)如下:

当然多个栈还可以组成更大的结构。连接具有高度伸缩性,支持2点互联、4点互联、6点互联甚至8点和互联:

Sapphire Rappids是下一代Intel Xeon可扩展处理器,数据中心体系结构的新标准,专为微服务和AI工作负载设计,开创性的高级内存和IO转换。Intel Xeon的节点性能表现在标量性能、数据并行性能、缓存和内存子系统架构,以及套接字内/套接字间缩放等方面:

Intel Xeon的数据中心性能表现在整合和业务流程、性能一致性、弹性和高效的数据中心利用率、基础设施和框架开销等方面:

通过模块化架构,利用单片CPU的现有软件范例(如Ice Lake),提供可扩展、平衡的架构(如Sapphire Rapids):

Sapphire Rapids是多片、单CPU架构,每个线程都可以完全访问所有分块的缓存、内存、IO…在整个SoC中提供一致的低延迟和高横截面的带宽。Sapphire Rapids的SoC的物理架构如下:

Sapphire Rapids的关键构建块如下:

为数据中心构建的性能核心,主要微架构和IPC改进,改进了对大代码/数据占用的支持,高频自动/快速PM。

性能核心,DC工作负载和使用的架构改进:

  • AI:英特尔高级矩阵扩展-AMX,用于推理和训练加速的平铺矩阵运算。
  • 连接设备:加速器接口架构-AiA,用户级的高效调度、信令和同步。
  • FP16:半精度,支持更高的吞吐量和更低的精度。
  • 缓存管理:CLDEMOTE,主动放置缓存内容。

Sapphire Rapids加速引擎,通过无缝集成的加速引擎实现共模任务的卸载以提高核心的效率,原生调度、来自用户空间的信令和同步、加速器接口架构,核心与加速引擎之间的相干共享存储空间,可并发共享的进程、容器和VM。

Intel Data Streaming加速引擎优化流数据移动和转换操作,每个套接字最多4个实例,低延迟调用,无内存固定开销,DSA卸载后获得额外39%的CPU核心周期。

Intel Quick Assist Technology加速引擎加快加密和数据消除/压缩,高达400Gb/s对称加密,高达160Gb/s压缩+160Gb/s解压缩,融合操作,QAT卸载后增加98%的工作负载容量。

Sapphire Rapids的I/O高级特性:

  • Compute ePressLink(CXL)1.1简介,数据中心中的加速器和内存扩展。
  • 通过PCIe 5.0和连接扩展设备性能,通过Intel®Ultra Path Interconnect(UPI)2.0改进多插槽扩展。
  • 最多4 x24 UPI链路以16 GT/s的速度运行,新的8S-4UPI性能优化拓扑,改进的DDIO和QoS功能。

Sapphire Rapids的内存和最后一级缓存:

  • 增加的共享最后一级缓存(LLC),在所有核心中共享的最大容量超过100 MB LLC。
  • 通过DDR 5内存提高带宽、安全性和可靠性,4个内存控制器,支持8个通道。
  • Intel Optane永久内存300系列。

Sapphire Rapids具有高带宽内存:

  • 与8通道DDR 5的基线Xeon SP相比,内存带宽显著提高。
  • 增加容量和带宽,某些用途可以完全消除对DDR的需求。
  • 2种模式:
    • HBM扁平模式。带HBM和DRAM的扁平内存区域。
    • HBM缓存模式。DRAM备份缓存。

Sapphire Rapids为弹性计算模型构建微服务,80%以上的新云原生和SaaS应用程序预计将作为微服务构建。其目标是:实现更高的吞吐量,同时满足延迟要求,并减少执行、监控和协调数千个微服务的基础架构开销;提高性能和服务质量,减少基础设施开销,更好的分布式通信。微服务性能的对比如下:

Ponte Vecchio具有:

  • 新的验证方法。
  • 新的SOC架构。
  • 新的IP架构。
  • 新内存体系结构。
  • 新的I/O体系结构。
  • 新的包装技术。
  • 新的电力输送技术。
  • 新的互连。
  • 新的信号完整性技术。
  • 新的可靠性方法。
  • 新的软件。

Ponte Vecchio SoC拥有1000亿以上晶体管,47活动分块,5个进程节点。其内部结构图如下:

具有不同加速比的加速计算系统:

克服分离的CPU和GPU软件堆栈,囊括了CPU优化堆栈和GPU优化堆栈:


19.6 内存系统

到目前为止,我们已经将内存系统视为一个大的字节阵列,这种抽象对于设计指令集、学习汇编语言,甚至对于设计具有复杂流水线的基本处理器来说,都已经足够好了。然而,从实际角度来看,这种抽象需要进一步重新定义,以设计一个快速内存系统。在前面章节介绍的基础流水线中,假设访问数据和指令内存需要1个周期,在本章并不总是正确的。事实上,需要对内存系统进行重大优化,以接近1个周期的理想延迟,需要引入“缓存”和分层内存系统的概念,以解决具有大内存容量和低延迟的双重问题。

其次,到目前为止,一直假设只有一个程序在系统上运行,但大多数处理器通常在分时基础上运行多个程序,例如,如果有两个程序A和B,现代台式机或笔记本电脑通常会运行程序A几毫秒,执行程序B几毫秒,然后来回切换。事实上,系统运行着许多其他程序,比如网页浏览器、音频播放器和日历应用程序。一般来说,用户不会感知到任何中断,因为中断发生的时间尺度远低于人脑所能感知的时间尺度。例如,一个典型的视频每秒显示30次新图片,或者每隔33毫秒显示一张新图片。人脑通过将图片拼接在一起,产生一个平稳移动的物体的错觉。如果处理器在33毫秒之前完成处理视频序列中下一张图片的工作,那么它可以执行另一个程序的一部分。人脑将无法分辨差异。这里的重点是,在我们不知情的情况下,处理器与操作系统合作,在多个程序之间每秒切换多次。操作系统本身就是一个专门的程序,可以帮助处理器管理自己和其他程序。Windows和Linux是流行操作系统的示例。

我们需要内存系统中的特殊支持来支持多个程序,如果没有这种支持,那么多个程序会覆盖彼此的数据,这是不希望的行为。第二,我们一直假设拥有无限的内存,这也不是事实,我们拥有的内存量是零,而且它可能会被大型内存密集型程序耗尽。因此,我们应该有一个机制来继续运行这样大的程序,将引入虚拟内存的概念来解决这两个问题:运行多个程序和处理大型内存密集型程序。

计算机系统中的内存层次结构。

如果根据内存系统的关键特性对其进行分类,那么计算机内存这一复杂的主题就更容易管理。其中最重要的列于下表。

表中的术语位置是指内存是计算机内部还是外部,内部内存通常等同于主内存,但也有其他形式的内部内存。处理器需要自己的局部存储器,以寄存器的形式,处理器的控制单元部分也可能需要其自己的内部存储器,缓存是内存的另一种形式。外部内存由外围存储设备(如磁盘和磁带)组成,处理器可以通过I/O控制器访问这些设备。

内存的一个明显特征是它的容量。对于内部内存,通常以字节(1字节=8位)或字表示,常见的字长为8、16和32位。外部内存容量通常以字节表示。

一个相关的概念是传输单位(unit of transfer)。对于内部内存,传输单位等于进出存储模块的电线数量,可能等于字长,但通常更大,例如64、128或256位。为了阐明这一点,请考虑内部内存的三个相关概念:

  • 字(Word):内存组织的“自然”单位,字的大小通常等于用于表示整数的位数和指令长度。不幸的是,有很多例外,例如CRAY C90(一种老式CRAY超级计算机)具有64位的字长,但使用46位整数表示。Intel x86体系结构具有多种指令长度,以字节的倍数表示,字大小为32位。
  • 可寻址单元(Addressable unit):在某些系统中,可寻址单元是字,但许多系统允许在字节级别寻址。在任何情况下,地址的位A长度与可寻址单元的数量N之间的关系是\(2^A=N\)
  • 传输单位(Unit of transfer):对于主内存,是一次从内存中读出或写入的位数,传输单位不必等于字或可寻址单位。对于外部内存,数据通常以比字大得多的单位传输,这些单位被称为块。

内存类型的另一个区别是访问数据单元的方法,其中包括以下内容:

  • 顺序存取:内存被组织成数据单元,称为记录,访问必须按照特定的线性顺序进行,存储的寻址信息用于分离记录并协助检索过程。使用共享读写机制,必须将其从当前位置移动到所需位置,传递和拒绝每个中间记录,因此访问任意记录的时间是高度可变的。
  • 直接访问:与顺序访问一样,直接访问涉及共享的读写机制,但单个块或记录具有基于物理位置的唯一地址。访问是通过直接访问来实现的,以到达一般邻近位置,再加上顺序搜索、计数或等待到达最终位置。同样,访问时间是可变的。
  • 随机访问:内存中的每个可寻址位置都有一个独特的物理连线寻址机制,访问给定位置的时间与先前访问的顺序无关,并且是恒定的。因此,可以随机选择任何位置,并直接寻址和访问。主内存和一些缓存系统是随机访问的。
  • 关联(Associative):是一种随机存取类型的内存,使我们能够对一个字内的所需位位置进行比较,以获得指定的匹配,并同时对所有字进行比较。因此,一个字是基于其内容的一部分而不是其地址来检索的。与普通随机存取内存一样,每个位置都有自己的寻址机制,检索时间与位置或先前的存取模式无关。高速缓存内存可以采用关联访问。

从用户的角度来看,内存的两个最重要的特性是容量和性能,性能涉及三个参数:

  • 访问时间(延迟):对于随机存取内存,是执行读取或写入操作所需的时间,即从地址呈现到内存的那一刻到数据被存储或可供使用的那一瞬间的时间。对于非随机存取内存,存取时间是将读写机制定位在所需位置所需的时间。

  • 内存周期时间:主要应用于随机存取内存,包括存取时间加上第二次存取开始前所需的任何额外时间。如果信号线上的瞬变消失(die out)或数据被破坏性读取,则可能需要额外的时间来重新生成数据。注意,内存周期时间与系统总线有关,与处理器无关。

  • 传输速率:是数据可以传输到内存单元或从内存单元传输出去的速率。对于随机-访问内存,它等于1/(循环时间),对于非随机存取内存,以下关系成立:

    \[T_{n}=T_{A}+\frac{n}{R} \]

    其中:

    • \(T_n\)=读取或写入n位的平均时间。
    • \(T_A\)=平均访问时间。
    • \(n\)=位数。
    • \(R\)=传输速率,单位为比特/秒(bps)。

    已经阐述了各种物理类型的内存。当今最常见的是半导体内存、用于磁盘和磁带的磁表面存储器、光学和磁光存储器。

19.6.1 内存系统概论

19.6.1.1 快速内存系统需求

现在看看构建快速存储系统的技术要求。我们可以用四种基本电路设计内存元件:锁存器、SRAM单元、CAM单元和DRAM单元。这里有一个权衡,锁存器和SRAM单元比DRAM或CAM单元快得多,但与DRAM单元相比,锁存器、CAM或SRAM单元的面积要大一个数量级,而且功耗也要大得多。锁存器被设计为在负时钟边沿读取和读出数据,是一个快速电路,可以在时钟周期的一小部分内存储和检索数据。另一方面,SRAM单元通常被设计为与解码器和感测放大器一起用作SRAM单元的大阵列的一部分。由于这种额外的开销,SRAM单元通常比典型的边缘触发锁存器慢。相比之下,CAM单元最适合与内容相关的存储器,而DRAM单元最适合容量非常大的存储器。

现在,管线假定内存访问需要1个周期,为了满足这一要求,需要用锁存器或SRAM单元的小阵列构建整个内存。下表显示了截至2012年的典型锁存器、SRAM单元和DRAM单元的尺寸。

单元类型 面积 典型延迟
主从D触发器 0.8 \(\mu m^2\) 一个时钟周期内的分数
SRAM单元 0.08 \(\mu m^2\) 1-5时钟周期
DRAM单元 0.005 \(\mu m^2\) 50-200时钟周期

典型的锁存器(主从D触发器)比SRAM单元大10倍,而SRAM单元又比DRAM单元大约16倍,意味着,给定一定量的硅,如果使用DRAM单元,可以保存160倍的数据,但也慢200倍(如果考虑DRAM单元的代表性阵列)。显然,容量和速度之间存在权衡,但我们实际上需要两者。

让我们首先考虑存储能力问题。由于技术和可制造性方面的若干限制,截至2012年,无法制造面积超过400-500平方毫米的芯片,因此在芯片上拥有的内存总量是有限的,但用专门包含存储单元的附加芯片来补充可用存储器的数量是完全可能的。请记住,片外存储器速度较慢,处理器访问此类内存模块需要数十个周期。为了实现1周期内存访问的目标,我们需要在大多数时间使用相对更快的片上内存,但选择也是有限的,无法承受只由锁存器组成的存储系统。对于大量程序,无法将所有数据存储在内存中,例如,现代程序通常需要数百兆字节的内存,一些大型科学程序需要千兆字节的内存。其次,由于技术限制,很难在同一芯片上集成大型DRAM阵列和处理器,设计者不得不将大型SRAM阵列用于片上存储器。如上表所示,SRAM单元(阵列)比DRAM单元(数组)大得多,因此容量小得多。

但是,延迟要求存在冲突。假设我们决定最大化存储,并使内存完全由DRAM单元组成,访问DRAM的等待时间为100个周期。如果假设三分之一的指令是内存指令,那么完美的5阶段处理器管线的有效CPI计算为:\(1+1/3 \times (100-1)=34\)。需要注意的是,CPI增加了34倍,完全不能接受!

因此,我们需要在延迟和存储之间进行公平的权衡,希望存储尽可能多的数据,但不能以非常低的IPC为代价。不幸的是,如果假设内存访问是完全随机的,那么就没有办法摆脱这种情况。如果内存访问中存在某种模式,那么会做得更好,这样就可以做到两全其美:高存储容量和低延迟。

19.6.1.2 内存访问模式

内存访问模式有两种:

  • 时间局部性(Temporal Locality)。如果某个资源在某个时间点被访问,那么很可能会在很短的时间间隔内再次被访问。
  • 空间局部性(Spatial Locality)。如果某个资源在某个时间点被访问,那么很可能在不久的将来也会访问类似的资源。

内存访问中是否存在时间和空间局部性?

如果存在一定程度的时间和空间局部性,那么可以进行一些关键的优化,以帮助解决大内存需求和低延迟这两个问题。在计算机架构中,通常利用诸如时间和空间局部性之类的特性来解决问题。

19.6.1.3 指令访问的时空局部性

解决这一问题的标准方法是在一组具有代表性的项目中测量和描述局部性,如SPEC基准。可将将内存访问分为两大类:指令和数据,指令访问更容易进行非正式分析,因此先来看看它。

一个典型的程序有赋值语句、分支语句(if、else)和循环,大型程序中的大部分代码都是循环的一部分或一些通用代码。计算机架构中有一个标准的经验法则,它表明90%的代码运行10%的时间,10%的代码运行90%的时间。对于一个文字处理器,处理用户输入并在屏幕上显示结果的代码比显示帮助屏幕的代码运行得更频繁。同样,对于科学应用,大部分时间都花在程序中的几个循环中。事实上,对于大多数常见的应用程序,我们使用这种模式。因此,计算机架构师得出结论,指令访问的时间局部性适用于绝大多数程序

现在考虑指令访问的空间局部性。如果没有分支语句,那么下一个程序计数器是当前程序计数器加上ISA的4个字节(常规ISA而言)。如果两个访问的内存地址彼此接近,我们认为这两个访问“相似”,很明显,此处具有空间局部性。程序中的大多数指令是非分支的,空间局部性成立。此外,在大多数程序中,分支的一个很好的模式是,分支目标实际上并不远。如果我们考虑一个简单的If-else语句或for循环,那么分支目标的距离等于循环的长度或语句的If部分。在大多数程序中,通常是10到100条指令长,而不是数千条指令长。因此,架构师得出结论,指令内存访问也表现出大量的空间局部性

数据访问的情况稍微复杂一些,但差别不大。对于数据访问,我们也倾向于重用相同的数据,并访问类似的数据项。

19.6.1.4 时间局部性特征

让我们描述一种称为堆栈距离(stack distance)方法的方法,以表征程序中的时间局部性。

我们维护一个访问的数据地址堆栈。对于每个内存指令(加载/存储),在堆栈中搜索相应的地址,找到条目的位置(如果找到)称为“堆栈距离”。距离是从堆栈顶部开始测量的,堆栈顶部的距离等于零,而第100个条目的堆栈距离等于99。每当我们检测到堆栈中的条目时,我们就会将其移除,并将其推到堆栈顶部。

如果找不到内存地址,那么创建一个新条目并将其推到堆栈的顶部。通常,堆栈的深度是有界的,它的长度为L。如果由于添加了一个新条目,堆栈中的条目数超过了L,那么需要删除堆栈底部的条目。其次,在添加新条目时,堆栈距离没有定义。注意,由于我们考虑有界堆栈,因此无法区分新条目和堆栈中的条目,但必须将其删除,因为它位于堆栈的底部。因此,在这种情况下,我们将堆栈距离设为等于L(以堆栈深度为界)。

请注意,堆栈距离的概念为我们提供了时间局部性的指示。如果访问具有高的时间局部性,那么平均堆栈距离预计会更低。相反,如果内存访问具有低的时间局部性,那么平均堆栈距离将很高,因此可以使用堆栈距离的分布来衡量程序中的时间局部性

我们可以使用SPEC2006基准测试Perlbench进行了一个简单的实验,它运行不同的Perl程序,我们维护计数器以跟踪堆栈距离。第一百万次内存访问是一个预热期(warm-up period),在此期间,堆栈保持不变,但计数器不递增。对于接下来的一百万次内存访问,堆栈将保持不变,计数器也将递增。下图显示了堆栈距离的直方图,堆栈的大小限制为1000个条目,足以捕获绝大多数的内存访问。

堆栈距离分布图。

以上可知,大多数访问具有非常低的堆栈距离,0-9之间的堆栈距离是最常见的值,大约27%的所有访问都在这个箱中。事实上,超过三分之二的内存访问的堆栈距离小于100。超过100,分布逐渐减少,但仍然相当稳定。堆栈距离的分布通常被称为遵循重尾分布(heavy tailed distribution),意味着分布严重偏向于较小的堆栈距离,但大的堆栈距离并不少见。对于大的堆栈距离,分布的尾部仍然是非零的,上图显示了类似的行为。

研究人员试图使用对数正态分布来近似堆栈距离:

\[f(x)=\frac{1}{x \sigma \sqrt{2 \pi}} e^{-\frac{(\ln (x)-\mu)^{2}}{2 \sigma^{2}}} \]

19.6.1.5 空间局部性特征

关于堆栈距离,我们定义了术语地址距离,第i个地址距离是第i次内存访问的存储器地址与最后K次内存访问集合中最近的地址之间的差,内存访问可以是加载或存储。以这种方式消除不良地址距离有一个直观的原因,程序通常在同一时间间隔内访问主内存的不同区域,例如,对数组执行操作,访问数组项,然后访问一些常量,执行操作,保存结果,然后使用For循环移动到下一个数组条目。这里显然存在空间局部性,即循环访问的连续迭代接近数组中的地址。但为了量化它,需要搜索最近K次访问中最近的访问(以内存地址表示),其中K是封闭循环每次迭代中的内存访问数。在这种情况下,地址距离被证明是一个小值,并且表示高空间局部性。但K需要精心选择,不应该太小,也不应该太大,根据经验,K=10对于一组大型程序来说是一个合适的值。

总之,如果平均地址距离很小,意味着程序具有较高的空间局部性,该程序倾向于在相同的时间间隔内以高可能性访问附近的内存地址。相反,如果地址距离很高,则访问彼此相距很远,程序不会表现出空间局部性。

使用SPEC2006基准Perlbench重复前面描述的实验,为前100万次访问提供了地址距离分布,见下图。

四分之一以上的访问的地址距离在-5和+5之间,三分之二以上的访问地址距离在-25和+25之间。超过\(\pm 50\),地址距离分布逐渐减小。从经验上看,这种分布也具有重尾性质。

19.6.1.6 利用时空局部性

前面小节展示了示例程序的堆栈和地址距离分布。用户在日常生活中使用的数千个程序也进行了类似的实验,包括计算机游戏、文字处理器、数据库、电子表格应用程序、天气模拟程序、金融应用程序和在移动计算机上运行的软件应用程序。几乎所有这些都表现出非常高的时间和空间局部性,换句话说,时间和空间的局部性是人类的基本特征,无论我们做什么(如拿取书本或编写程序)都会保持不变。请注意,这些只是经验观察,依然可以编写一个不显示任何形式的时间和空间局部性的程序,在商业程序中也可以找到不显示这些特征的代码区域。但这些是例外,不是常态。我们需要为常规而不是特例设计计算机系统,这就是我们如何提高大多数用户期望运行的程序的性能。

从现在起,将时间和空间的局部性视为理所当然,看看可以做些什么来提高内存系统的性能,而不影响存储容量。让我们先看看时间局部性。

利用时间局部性:分层内存系统

我们可以为内存设计一个存储位置,称之为缓存,缓存中的每个条目在概念上都包含两个字段:内存地址和值,并定义一个缓存层次结构,如下图所示。

内存层次。

主内存(物理内存)是一个大型DRAM阵列,包含处理器使用的所有内存位置的值。

L1缓存通常是一个小型SRAM阵列(8 - 64KB),L2缓存是一个更大的SRAM阵列(128KB - 4 MB)。一些处理器(如Intel Sandybridge处理器)有另一级缓存,称为L3缓存(4MB+)。在L2/L3高速缓存下面,有一个包含所有内存位置的大型DRAM阵列,被称为主内存或物理内存。在L1缓存中维护L2缓存的值子集更容易,以此类推,称为具有包含式缓存(inclusive cache)的系统。因此,对于包含性缓存层次结构,我们有:\(\text { values }(L 1) \subset \text { values }(L 2) \subset \text { values }(\text { main memory })\)。或者,我们可以使用独占缓存(exclusive cache),其中较高级别的缓存不一定包含较低级别缓存中的值子集。到目前为止,所有处理器都普遍使用非独占缓存,因为其设计的简单性、简单性和一些微妙的正确性问题。然而,截至2012年,通用处理器的实用性尚未确定。

第n级缓存中包含的一组存储器值是第(n+1)级缓存中所有值的子集的内存系统称为包含式缓存(inclusive cache)层次结构。不遵循严格包含的内存系统称为独占缓存(exclusive cache)层次结构。

现在再次查看上图所示的缓存层次结构。由于L1缓存较小,所以访问速度更快,访问时间通常为1-2个周期。L2缓存更大,通常需要5-15个周期才能访问。主内存由于其大尺寸和使用DRAM单元,速度要慢得多,访问时间通常非常高,在100-300个周期之间。内存访问协议类似于作者访问书籍的方式。

内存访问协议如下。每当有内存访问(加载或存储)时,处理器都会首先检查一级缓存。请注意,缓存中的每个条目在概念上都包含内存地址和值。如果数据项存在于一级缓存中,则缓存命中(cache hit),否则缓存未命中(cache miss)。如果存在缓存命中,并且内存请求是读取,那么只需将值返回给处理器,如果内存请求是写入,则处理器将新值写入缓存条目。然后,它可以将更改传播到较低级别,或恢复处理。后面章节会讨论不同的写入策略和执行缓存写入的不同方法。但是,如果存在缓存未命中,则需要进一步处理。

缓存命中(cache hit):当缓存中存在内存位置时,该事件称为缓存命中。

缓存未命中(cache miss):当缓存中不存在内存位置时,该事件称为缓存未命中。

在一级缓存未命中的情况下,处理器需要访问二级缓存并搜索数据项。如果找到项目(缓存命中),则协议与一级缓存相同,由于本文考虑了包含性缓存,所以有必要将数据项提取到一级缓存。如果存在L2未命中,则需要访问较低级别,较低级别可以是另一个L3缓存,也可以是主内存。在最低级别(即主内存),我们保证不会发生未命中,因为我们假设主内存包含所有内存位置的条目。

处理器使用分层内存系统来最大化性能,而不是使用单一的平面存储系统,分层存储系统旨在提供具有理想单周期延迟的大内存的错觉。

举个具体的例子,查找以下配置的平均内存访问延迟。

级别 未命中率(%) 延迟
L1 10 1
L2 10 10
主内存 0 100

内存系统配置1。

级别 未命中率(%) 延迟
主内存 0 100

内存系统配置2。

答案:让我们先考虑配置1,90%的访问都发生在一级缓存中,这些命中的内存访问时间是1个周期。请注意,即使在一级缓存中未命中的访问仍会导致1个周期的延迟,因为我们不知道访问是否会在缓存中命中或未命中。随后,90%到二级缓存的访问都会在缓存中命中,它们会产生10个周期的延迟。最后,剩余的访问(1%)命中了主内存,并导致了额外的延迟。因此,平均内存存取时间(T)为:

\[T = 1 + 0.1 \times (10+0.1 \times 100) = 1+1+1 = 3 \]

因此,配置1的分层内存系统的平均内存延迟是3个周期。

配置2是一个平面层次结构,使用主内存进行所有访问,平均内存访问时间为100个周期。因此,使用分层内存系统可以将速度提高\(100/3=33.3\)倍。

上面的示例表明,使用分层内存系统的性能增益是具有单层分层结构的平面内存系统的33.33倍,性能改进是不同缓存的命中率及其延迟的函数。此外,缓存的命中率取决于程序的堆栈距离分布和缓存管理策略,同样,缓存访问延迟取决于缓存制造技术、缓存设计和缓存管理方案。在过去二十年中,优化缓存访问一直是计算机体系结构研究中的一个非常重要的主题,研究人员在这方面发表了数千篇论文。本文只讨论其中的一些基本机制。

利用空间局部性:缓存块

现在考虑空间局部性,上上图揭示了大多数访问的地址距离在\(\pm 25\)字节内。地址距离分布表明,如果将一组内存位置分组到一个块中,并从较低级别一次性获取,那么可以增加缓存命中数,因为访问中存在高度的空间局部性。

因此,几乎所有处理器都创建连续地址块,缓存将每个块视为一个原子单元。一次从较低级别获取整个块,如果需要,也会从缓存中逐出整个块。缓存块也称为缓存行,一个典型的缓存块或一行是32-128字节长,为了便于寻址,它的大小必须是2的严格幂。

缓存块(cache block)缓存行(cache line)是一组连续的内存位置,被视为缓存中的原子数据单元。

因此,我们需要稍微重新定义缓存条目(cache entry)的概念,没有为每个内存地址创建一个条目,而是为每个缓存行创建一个单独的条目。请注意,本文将同义地使用术语缓存行和块,还要注意,L1高速缓存和L2高速缓存中不必具有相同的高速缓存行大小,它们可以不同。然而,为了保持高速缓存的包容性,并最小化额外的内存访问,通常需要在L2使用与L1相同或更大的块大小。

迄今所学到的要点:

  • 时间和空间局部性是大多数人类行为固有的特性,它们同样适用于阅读书籍和编写计算机程序。
  • 时间局部性可以由堆栈距离量化,空间局部性可以通过地址距离量化。
  • 我们需要设计内存系统,以利用时间和空间的局部性。
  • 为了利用时间局部性,我们使用由一组缓存组成的分层内存系统。L1高速缓存通常是一种小而快的结构,旨在快速满足大多数存储器访问。较低级别的缓存存储的数据量较大,访问频率较低,访问时间较长。
  • 为了利用空间局部性,我们将连续的存储器位置集合分组为块(也称为行),块被视为缓存中的原子数据单元。

前面已经定性地研究了缓存的需求,后面将继续讨论缓存的设计。

19.6.2 缓存

19.6.2.1 缓存综述

高速缓存的设计是为了将昂贵的高速内存的内存访问时间与较便宜的低速内存的大内存大小相结合,如下图a所示。下图b描述了多级缓存的使用,L2高速缓存比L1高速缓存慢且通常更大,而L3高速缓存比L2高速缓存慢并且通常更大。

下图描述了缓存/主内存系统的结构。主内存由多达\(2^n\)个可寻址字组成,每个字具有唯一的n位地址。出于映射的目的,该内存被认为由多个固定长度的块组成,每个块包含K个字,也就是说,主内存中有\(M=2^n/K\)个块。缓存由m个块组成,称为行(line),每行包含K个字,加上几个位的标记。每一行还包括控制位(未示出),例如指示该行自从被加载到缓存中以来是否已被修改的位,行的长度(不包括标记和控制位)是行大小。

下图说明了读取操作。处理器生成要读取的字的读取地址(RA),如果该单词包含在缓存中,则将其传递给处理器。否则,包含该字的块被加载到缓存中,并且该字被传递到处理器。图中显示了并行发生的最后两个操作,并反映了下下图所示的当代缓存组织的典型。在这种组织中,缓存通过数据、控制和地址线连接到处理器。数据和地址线还连接到数据和地址缓冲区,这些缓冲区连接到系统总线,从该总线可以访问主内存。当缓存命中时,数据和地址缓冲区将被禁用,并且只有处理器和缓存之间的通信,没有系统总线通信。当发生缓存未命中时,所需的地址被加载到系统总线上,数据通过数据缓冲区返回到缓存和处理器。在其他组织中,缓存物理地插入处理器和主内存之间,用于所有数据、地址和控制线。在后一种情况下,对于缓存未命中,所需的字首先被读取到缓存中,然后从缓存传输到处理器。

缓存读取操作。

典型的缓存组织。

当使用虚拟地址时,系统设计者可以选择在处理器和MMU之间或MMU和主内存之间放置缓存(下图)。逻辑缓存(也称为虚拟缓存)使用虚拟地址存储数据,处理器直接访问缓存,而不经过MMU,物理缓存则使用主内存物理地址存储数据。

下图a显示了缓存的直接映射方式,其中前m个主内存块的映射,每个主内存块映射到缓存的一个唯一行中,接下来的m个主内存块以相同的方式映射到缓存中。

缓存的直接和关联映射。

直接映射缓存组织。

完全的关联映射缓存组织。

此处之外,还存在K路(如2、4、8、16路)的缓存映射方式,参见下图。

k路集关联缓存组织。

不同映射方式随着缓存大小改变关联性的曲线如下:

下图是曾经风靡一时具有代表性的奔腾4的结构图,清晰地展示了缓存的结构:

19.6.2.2 基本缓存操作

让我们将缓存视为一个黑盒子,如下图所示。在加载操作的情况下,输入是内存地址,如果缓存命中,输出是内存位置的值。我们设想缓存有一个状态行,指示请求是命中还是未命中。如果操作是存储,则缓存接受两个输入:内存地址和值。如果缓存命中,则缓存将值存储在与内存位置相对应的条目中,否则,表示缓存未命中。

作为黑盒子的缓存。

现在看看实现这个黑盒子的方法,将使用SRAM阵列作为构建块。

为了启发设计,考虑一个例子,有一个块大小为64字节的32位机器,在这台机器中,有\(2^{26}\)个块。一级缓存的大小为8 KB,包含128个块。因此,可以在任何时间点将一级缓存视为整个内存地址空间的非常小的子集,最多包含\(2^{26}\)个块中的128个。为了确定一级缓存中是否存在给定的块,需要查看128个条目中是否有任何一个包含该块。

假设一级缓存是内存层次结构的一部分,内存层次结构作为一个整体支持两个基本请求:读和写。然而,在缓存级别需要许多基本操作来实现这两个高级操作。

类似于内存地址,将块地址定义为内存地址的26个MSB位。第一个问题是判断缓存中是否存在具有给定块地址的块,需要执行一个查找(lookup)操作。如果块存在于缓存中(即缓存命中),则返回指向该块的指针,需要两个基本操作来服务请求,即数据读取(data read)数据写入(data write)——读取或写入块的内容,并需要指向块的指针作为参数。

如果有缓存未命中,那么需要从内存层次结构的较低级别获取块并将其插入缓存。从内存层次结构的较低级别获取块并将其插入缓存的过程称为填充(fill)操作,填充操作是一个复杂的操作,并使用许多原子子操作。需要首先向较低级别的缓存发送加载请求以获取块,然后插入L1缓存。

插入过程也是一个复杂的过程。首先需要检查在一组给定的块中是否有空间插入一个新块,如果有足够的空间,那么可以使用插入(insert)操作填充其中一个条目。但是,如果要在缓存中插入块的所有位置都已经被占用,那么需要从缓存中移除一个已经存在的块。因此,需要调用一个替换(replace)操作来结束需要收回的缓存块。一旦找到了合适的替换候选块,需要使用逐出(evict)操作将其从缓存中逐出。

总之,实现缓存广泛地需要这些基本操作:查找、数据读取、数据写入、插入、替换和逐出。填充操作只是内存层次结构不同级别的查找、插入和替换操作的序列,同样,读取操作主要是查找操作,或者是查找和填充操作的组合。

19.6.2.3 缓存查找和缓存设计

假设为32位系统设计一个块大小为64字节的8KB缓存。为了进行高效的高速缓存查找,需要找到一种高效的方法来查找高速缓存中128个条目中是否存在26位块地址。存在两个问题:第一个问题是快速找到给定的条目,第二个问题是执行读/写操作。与其使用单个SRAM阵列来解决这两个问题,不如将其拆分为两个阵列,如下图所示。

缓存结构。

完全关联(FA)缓存

在典型的设计中,缓存条目保存在两个基于SRAM的阵列中,一个称为标记阵列的SRAM阵列包含与块地址有关的信息,另一个称称为数据阵列的SRRAM阵列包含块的数据。标记数组包含唯一标识块的标记,标记通常是块地址的一部分,并取决于缓存的类型。除了标签和数据阵列之外,还有一个专用的高速缓存控制器,用于执行高速缓存访问算法。

首先考虑一种非常简单的定位块的方法。我们可以同时检查缓存中128个条目中的每一个,以查看块地址是否等于缓存条目中的块地址。此缓存称为完全关联缓存(fully associative cache)内容可寻址缓存(content addressable cache),“完全关联”表示给定块可以与缓存中的任何条目关联。

因此,全关联(FA)缓存中的每个缓存条目都需要包含两个字段:标签(tag)和数据(data)。在这种情况下,可以将标记设置为等于块地址,由于块地址对于每个块都是唯一的,因此它适合标记的定义。块数据指的是块的内容(在这种情况中为64字节)在我们的示例中,块地址需要26位,块数据需要64字节。搜索操作需要跨越整个缓存,一旦找到条目,我们需要读取数据或写入新值。

先看看标签数组。在这种情况下,每个标签都等于26位块地址,内存请求到达缓存后,第一步是通过提取26个最重要的位来计算标签,然后,需要使用一组比较器将提取的标签与标签数组中的每个条目进行匹配。如果没有匹配,可以声明缓存未命中并进一步处理,如果有缓存命中,需要使用与标记匹配的条目编号来访问数据条目,例如,在包含128个条目的8KB缓存中,标签数组中的第53个条目可能与标签匹配。在这种情况下,高速缓存控制器需要在读取访问的情况下从数据阵列中取出第53个条目,或者在写入访问的情况中写入第53个条目的。

有两种方法可以在完全关联缓存中实现标记数组,或者可以将其设计为一个普通的SRAM阵列,其中缓存控制器迭代每个条目,并将其与给定的标记进行比较,或者可以使用每行都有比较器的CAM阵列。它们可以将标签的值与存储在行中的数据进行比较,并根据比较结果生成输出(1或0)。CAM阵列通常使用编码器来计算与结果匹配的行数,完全关联缓存的标记数组的CAM实现更为常见,主要是因为顺序迭代数组非常耗时。

下图说明了这一概念。通过将对应的字线设置为1来启用CAM阵列的每一行,随后,CAM单元中的嵌入式比较器将每一行的内容与标签进行比较,并生成输出。我们使用OR门来确定是否有任何输出等于1,如果有任何输出为1,则表示缓存命中,否则表示缓存未命中。这些输出线中的每一条还连接到编码器,该编码器生成匹配行的索引,我们使用此索引访问数据数组并读取块的数据。在写入的情况下,我们写入块,而不是读取它。

完全关联缓存。

全关联缓存对于小型结构(通常为2-32)条目非常有用,但不可能将CAM阵列用于更大的结构,比较和编码的面积和功率开销非常高。也不可能顺序地遍历标签阵列的SRAM实现的每个条目,非常耗时。因此,我们需要找到一种更好的方法来定位更大结构中的数据。

直接映射(DM)缓存

在完全关联的缓存中,可以将任何块存储在缓存中的任何位置。这个方案非常灵活,但是,当缓存有大量条目时,它不能使用,主要是因为面积和电源开销太大。我们不允许将块存储在缓存中的任何位置,而是只为给定块指定一个固定位置。可以按如下方式进行。

在示例中,有一个8 KB的缓存,包含128个条目,不妨限制64字节块在缓存中的位置。对于每个块,在标记数组中指定一个唯一的位置,在该位置可以存储与其地址对应的标记,可以生成这样一个独特的位置,如下所示。让我们考虑块a的地址和缓存中的条目数(128),并计算a%128,%运算符计算a除以128的余数,由于a是二进制值,128是2的幂,因此计算余数非常容易。我们只需要从26位块地址中提取7个LSB位,这7位可用于访问标签阵列,就可以将保存在标记数组中的标记值与根据块地址计算的标记值进行比较,以确定是否命中或未命中。

还可以稍微优化它的设计,而不是像完全关联缓存那样将块地址保存在标记数组中。块地址中的26位中有7位用于访问标签阵列中的标签,意味着所有可能被映射到标签数组中给定条目的块都将有其最后7位共用,因此这7位不需要明确地保存为标签的一部分,只需要保存块地址的剩余19位,这些位可以在块之间变化。因此,高速缓存的直接映射实现中的标签只需要包含19位。

下图以图形方式描述了这一概念。将32位地址分成三部分,最重要的19位包括标记,接下来的7位称为索引(标记数组中的索引),其余6位指向块中字节的偏移,访问协议的其余部分在概念上类似于完全关联缓存。在这种情况下,我们使用索引来访问标记数组中的相应位置,读取内容并将其与计算标记进行比较。如果它们相等,则我们声明缓存命中,否则,缓存未命中。在缓存命中的情况下,使用索引访问数据数组,使用缓存命中/未命中结果来启用/禁用数据阵列。

直接映射缓存。

到目前为止,我们已经研究了完全关联和直接映射缓存:

  • 全关联缓存是一种非常灵活的结构,因为块可以保存在缓存中的任何条目中,但具有更高的延迟和功耗。由于给定的块可能被分配到缓存的更多条目中,因此它的命中率高于直接映射缓存。
  • 直接映射缓存是一种速度更快、功耗更低的结构。一个块只能驻留在缓存中的一个条目中,此缓存的预期命中率小于完全关联缓存的命中率。

在完全关联和直接映射缓存之间,在功率、延迟和命中率之间存在折衷。

集合相联缓存

全关联缓存更耗电,因为需要在缓存的所有条目中搜索块。相比之下,直接映射缓存更快、更高效,因为只需要检查一个条目,但命中率较低,也是不可接受的。因此,尝试将这两种范式结合起来。

让我们设计一个缓存,其中一个块可以潜在地驻留在缓存中多个条目的集合中的任何一个条目中,将缓存中的一组条目与块地址相关联,像完全关联缓存一样,必须在声明命中或未命中之前检查集合中的所有条目,这种方法结合了完全关联和直接映射方案的优点。如果一个集合包含4或8个条目,那么就不必使用昂贵的CAM结构,也不必依次迭代所有条目,可以简单地从标记数组中并行读取集合的所有条目,并将所有条目与块地址的标记部分进行并行比较。如果存在匹配,那么我们可以从数据数组中读取相应的条目。由于多个块可以与一个集合相关联,因此将此设计称为集合关联(set associative)缓存。集合中的块数称为缓存的关联性,集合中的每个条目都被称为一个路(way)。

关联性(Associativity):集合中包含的块数定义为缓存的关联性。

路(Way):集合中的每个条目都被称为路。

让我们描述一个简单的方法,将缓存项分组为集合,考虑了一个32位内存系统,其中有一个8 KB的缓存和64字节的块。如下图所示,首先从32位地址中删除最低的6位,因为它们指定了块中字节的地址,剩余的26位指定块地址,8-KB缓存总共有128个条目。如果想创建每个包含4个条目的集合,那么我们需要将所有缓存条目分成4个条目,将有32(\(2^5\))个这样的集合。

在直接映射缓存中,我们使用26位块地址中的最低7位来指定缓存中条目的索引,现在可以将这7个比特分成两部分,如上图所示。一部分包含5个比特并指示集合的地址,而第二部分包含2个比特则被忽略,指示集合地址的5位组称为集合索引。

在计算集合索引\(i\)之后,需要访问标签数组中属于集合的所有元素。可以按有以下方式排列标签数组,如果一个集合中的块数是S,那么可以对属于一个集合的所有条目进行连续分组。对于第\(i\)个集合,我们需要访问元素\(iS\)、$(iS+1) $ ... $(iS+S - 1) $在标签数组中。

对于标记数组中的每个条目,需要将条目中保存的标记与块地址的标记部分进行比较。如果有匹配,那么我们可以宣布命中。集合关联缓存中标记的概念相当棘手。如上图所示,它由不属于索引的位组成,是块地址的\((21=26-5)\)MSB位,用于确定标签比特数的逻辑如下。

每个集合由5位集合索引指定,这5个比特对于可能映射到给定集合的所有块都是公用的,需要使用其余的比特\((21=26-5)\)来区分映射到同一集合的不同块。因此,集合关联高速缓存中的标签的大小介于直接映射高速缓存(19)和完全关联高速缓存(26)的大小之间。

下图显示了集合关联缓存的设计。首先从块的地址计算集合索引,使用位7-11,使用集合索引来使用标签数组索引生成器生成标签数组中相应四个条目的索引。然后,并行访问标记数组中的所有四个条目,并读取它们的值。此处无需使用CAM阵列,可以使用单个多端口(多输入、多输出)SRAM阵列。接下来,将每个元素与标记进行比较,并生成一个输出(0或1)。如果任何一个输出等于1(由或门确定),缓存命中,否则缓存未命中。我们使用编码器对匹配的集合中的标记进行索引,因为假设4路关联缓存,编码器的输出在00到11之间。随后,使用多路复用器来选择标签数组中匹配条目的索引,这个索引可以用来访问数据数组,数据数组中的相应条目包含块的数据,可以读或写它。

可以对读取操作进行一个小优化。请注意,在读取操作的情况下,对数据数组和标记数组的访问可以并行进行。如果一个集合有4路,那么当计算标签匹配时,可以读取与该集合的4路对应的4个数据块。随后,在缓存命中的情况下,在计算了标记数组中的匹配条目之后,我们可以使用多路复用器选择正确的数据块。在这种情况下,实际上将从数据数组读取块所需的部分或全部时间与标记计算、标记数组访问和匹配操作重叠。

集合关联缓存。

总之,集合关联缓存是目前最常见的缓存设计,即使是非常大的缓存,它也具有可接受的功耗值和延迟。集合关联缓存的关联性通常为2、4或8,关联性为K的集合也称为K路关联缓存。

在设计集合关联缓存时,我们需要回答一个深刻的问题。设置索引位和忽略位的相对顺序应该是什么?被忽略的位应该朝向索引位的左侧(MSB),还是朝向索引位右侧(LSB)?上上图选择了MSB,背后的逻辑是什么?

答案:

如果索引位的左边(MSB)有被忽略的位,那么相邻的块映射到不同的集合。然而,对于被忽略的位在索引位的右侧(LSB)的相反情况,连续块映射到同一集合。前一种方案称为非CONT,后一种方案为CONT,在设计中选择了非CONT。

考虑两个数组A和B,让A和B的大小明显小于缓存的大小,让它们的一些组成块映射到同一组集合。下图显示了存储CONT和NON-CONT方案数组的缓存区域的概念图。我们观察到,即使缓存中有足够的空间,也不可能使用CONT方案同时保存缓存中的两个数组。它们的内存占用在缓存的一个区域中重叠,因此不可能在缓存中同时保存两个程序的数据。然而,NON-CONT方案试图将块均匀分布在所有集合上。因此,可以同时将两个阵列保存在缓存中。

这是程序中经常出现的模式。CONT方案保留缓存的整个区域,因此不可能容纳映射到冲突集的其他数据结构。然而,如果将数据分布在缓存中,那么可以容纳更多的数据结构并减少冲突。

举个具体的示例,在32位系统中,缓存具有以下参数:

参数
尺寸 \(N\)
关联性 \(K\)
块尺寸 \(B\)

那么,标签的尺寸是多数?答案:

  • 指定块内的字节所需的位数为\(\log(B)\)
  • 块数等于\(N=B\),集合数等于\(N/(BK)\)
  • 设置的索引位数等于:\(\log(N)-\log(B)-\log(K)\)
  • 剩余的位数是标签位,等于:\(32-(\log (N)-\log (B)-\log (K)+\log (B))=32-\log (N)+\log (K)\)

19.6.2.4 数据读取和写入操作

一旦我们确定给定的块存在于缓存中,我们就使用基本的读取操作从数据数组中获取内存位置的值。如果查找操作返回缓存命中,将确定缓存中是否存在块。如果缓存中存在未命中,则缓存控制器需要向较低级别的缓存发出读取请求,并获取块。数据读取操作可以在数据可用时立即开始。

第一步是读取数据数组中对应于匹配标记条目的块,然后从块中的所有字节中选择合适的字节集,可以使用一组多路复用器来实现之。在查找操作之后,不必严格开始数据读取操作,可以在两次行动之间有明显的重叠,例如,可以并行读取标记数组和数据数组。在计算出匹配标记之后,可以使用多路复用器选择正确的值集合。

在写入值之前,需要确保整个块已经存在于缓存中,这点非常重要。请注意,我们不能断言,因为正在创建新数据,所以不需要块的前一个值。原因是:对于单个内存访问,通常写入4个字节或最多8个字节,但一个块至少有32或64字节长,块是缓存中的原子单元,因此不能在不同的地方拥有它的不同部分。例如,不能将一个块的4个字节保存在一级缓存中,其余的字节保存在二级缓存中。其次,为了做到这一点,需要维护额外的状态,以跟踪已被写入更新的字节。因此,为了简单起见,即使希望只写入1个字节,也需要用整个块填充缓存。

之后,需要通过启用适当的一组字行(word line)和位行(bit line)将新值写入数据阵列,可以使用一组解复用器的电路来简单实现。

执行数据写入有两种方法:

  • 直写(write-through)。直写是一种相对简单的方案,在这种方法中,每当将值写入数据数组时,也会向较低级别的缓存发送写操作。这种方法增加了缓存流量,但实现缓存更简单,因为不必在将块放入缓存后跟踪已修改的块。因此,如果需要,可以无缝地从缓存中逐出一行。缓存收回和替换也很简单,但以写入为代价。如果L1缓存遵循直写协议,那么很容易为多处理器实现缓存。
  • 回写(write-back)。此方案明确地跟踪已使用写操作修改的块,可以通过在标记数组中使用额外的位来维护此信息,该位通常称为修改位,每当从内存层次结构的较低级别获得一个块时,修改位为0,然而,当进行数据写入并更新数据数组时,将标记数组中的修改位设置为1。逐出一行需要额外的处理。对于写回协议,写入成本较低,逐出操作成本较高。这里的折衷与直写缓存中的折衷相反。

带有附加修改位的标签数组中的条目结构如下图所示。

带有修改位的标签数组中的条目。

19.6.2.5 插入操作

本节将讨论在缓存中插入块的协议。当块从较低级别到达时,将调用此操作。需要首先查看给定块映射到的集合的所有方式,并查看是否有空条目。如果存在空条目,那么可以任意选择其中一个条目,并用给定块的内容填充它。如果没有结束任何空条目,需要调用替换和逐出操作来从集合中选择和删除一个已经存在的块。

需要维护一些额外的状态信息,以确定给定条目是空的还是非空的。在计算机体系结构中,这些状态也分别被称为无效(invalid)和有效(valid)。只需要在标记数组中存储一个额外的位,以指示块的状态,被称为有效位(valid bit)。使用标签数组来保存关于条目的附加信息,因为它比数据数组更小,通常更快。添加了有效位的标签数组中的条目结构如下图所示。

标签数组中的一个条目,包含修改后的有效位。

缓存控制器需要在搜索无效条目时检查每个标签的有效位。缓存的所有条目最初都是无效的,如果发现无效条目,则可以用块的内容填充数据数组中的相应条目,该条目随后生效。但是,如果没有无效条目,那么需要用需要插入缓存的给定块替换一个条目。

19.6.2.6 替换操作

这里的任务是在集合中查找一个可以被新条目替换的条目。我们不希望替换频繁访问的元素,因为会增加缓存未命中的数量。理想情况下,希望替换将来被访问的可能性最小的元素,但是,很难预测未来的事件。需要根据过去的行为做出合理的猜测,可以有不同的策略来替换缓存中的块。这些被称为替换方案或替换策略。

缓存替换方案(replacement scheme)替换策略(replacement policy)是用新条目替换集合中的条目的方法。

常用的替换策略有:

  • 随机替换策略。此策略最简单和普通,随机选取一个块并替换它。但是,它在性能方面并不是很理想,因为没有考虑程序的行为和内存访问模式的性质。该方案最终经常替换非常频繁访问的块。

  • FIFO替换策略。FIFO(先入先出)替换策略的假设是,在最早的时间点被带入缓存的块在将来被访问的可能性最小。为了实现FIFO替换策略,需要在标记数组中添加一个计数器。每当引入一个块时,都会给它分配一个等于0的计数器值,为其余的块增加计数器值。计数器越大,块越早进入缓存。

    此外,要查找替换的候选项,我们需要查找计数器值最大的条目,一定是最早的区块。不幸的是,FIFO方案并不严格符合时间局部性原则。它会惩罚缓存中长期存在的块,而实际上,这些块也可能是非常频繁访问的块,不应该首先被逐出。

    现在考虑实施FIFO替换策略的实际方面。计数器的最大大小需要等于集合中元素的数量,即缓存的关联性。例如,如果缓存的关联性为8,则需要有一个3位计数器,需要替换的条目应具有最大的计数器值。

    请注意,在这种情况下,将新值引入缓存的过程相当昂贵,我们需要增加集合中除一个元素外的所有元素的计数器。然而,与缓存命中相比,缓存未命中更为罕见。因此,开销实践上并不显著,并且该方案可以在没有较大性能开销的情况下实现。

  • LRU替换策略。LRU(最近最少使用的)更换策略被认为是最有效的方案之一。LRU方案直接从堆栈距离的定义开始,理想情况下,我们希望替换将来被访问的机会最低的块,根据堆栈距离的概念,未来被访问的概率与最近访问的概率有关。如果处理器在n次(n不是很大的数目)访问的最后一个窗口中频繁地访问一个块,那么该块很有可能在不久的将来被访问。然而,如果上一次访问一个块是很久以前的事了,那么它很快就不太可能被访问了。

    在LRU更换策略中,我们保留块最后一次访问的时间,选择在最早的时间点最后访问的块作为替换的候选。此策略为每个块维护一个时间戳,每当访问一个块时,它的时间戳都会被更新以匹配当前时间。要查找合适的替换候选项,需要查找集合中时间戳最小的条目。

    实现LRU策略最大的问题是需要为对缓存的每次读写访问做额外的工作,对性能产生重大影响,因为通常三分之一的指令是内存访问。其次,需要专用比特来保存足够大的时间戳,否则需要频繁地重置集合中每个块的时间戳,此过程导致缓存控制器的进一步减速和额外复杂性。因此,实现尽可能接近理想的LRU方案且没有显著的开销是一项艰巨的任务。

    可以尝试设计使用小时间戳(通常为1-3位)并大致遵循LRU策略的LRU方案,称为伪LRU(pseudo-LRU)方案。下面概述实现基本伪LRU的简单方法。与其尝试显式标记最近最少使用的元素,不如尝试标记最近使用的元素。未标记的元素将自动分类为最近最少使用的元素。

    让我们从将计数器与标记数组中的每个块相关联开始。每当访问一个块(读/写)时,都递增计数器,一旦计数器达到最大值,就停止递增。例如,如果使用一个2位计数器,那么避免将计数器递增到3以上。为了实现与每个块关联的计数器将最终达到3并保持值,可以周期性地将集合中每个块的计数器递减1,甚至可以将它们重置为0。随后,一些计数器将再次开始增加。

    此举确保大多数情况下,可以通过查看计数器的值来识别最近最少使用的块。与计数器的最低值相关联的块是最近最少使用的块之一,最有可能、最近最少使用的块。请注意,这种方法确实涉及每次访问的一定活动量,但是递增一个小计数器几乎没有额外开销。其次,它在计时方面不在关键路径上,可以并行执行,也可以稍后执行。寻找替换的候选项包括查看一组中的所有计数器,并查找计数器值最低的块。用新块替换块后,大多数处理器通常会将新块的计数器设置为最大可能值。这向高速缓存控制器指示,相对于替换的候选,新块应该具有最低优先级。

19.6.2.7 逐出操作

如果缓存遵循直写策略,则无需执行任何操作,该块可以简单地丢弃。然而,如果缓存遵循写回策略,那么需要查看修改后的位。如果数据没有被修改,那么它可以被无缝地收回。但是,如果数据已被修改,则需要将其写回较低级别的缓存。

19.6.2.8 综合操作

缓存读取操作的步骤序列如下图所示,从查找操作开始,可以在查找和数据读取操作之间有部分重叠。如果有缓存命中,则缓存将值返回给处理器或更高级别的缓存(无论是哪种情况)。但是,如果缓存未命中,则需要取消数据读取操作,并向较低级别的缓存发送请求。较低级别的缓存将执行相同的访问序列,并返回整个缓存块(不仅仅是4个字节)。然后,高速缓存控制器可以从块中提取所请求的数据,并将其发送到处理器,同时缓存控制器调用插入操作将块插入缓存。如果集合中有一个无效的条目,那么可以用给定的块替换它,但如果集合中的所有方法都有效,则需要调用替换操作来查找替换的候选。该图为该操作附加了一个问号,因为该操作并非一直被调用(仅当集合的所有路径都包含有效数据时)。然后,需要收回块,如果修改了行,可能会将其写入较低级别的缓存,并且正在使用写回缓存。然后缓存控制器调用插入操作,这次肯定会成功。

读取操作。

下图显示了回写缓存的缓存写入操作的操作序列,操作顺序大致类似于缓存读取。如果缓存命中,将调用数据写入操作,并将修改后的位设置为1,否则将向较低级别的缓存发出块的读取请求。块到达后,大多数缓存控制器通常将其存储在一个小的临时缓冲区中,此时将4个字节写入缓冲区,然后返回。在某些处理器中,缓存控制器可能会等待所有子操作完成。写入临时缓冲区后(上图中的写入块操作),调用插入操作来写入块的内容(修改后),如果此操作不成功(因为所有方法都有效),那么将遵循与读取操作相同的步骤顺序(替换、逐出和插入)。

写操作(回写缓存)。

下图显示了直写缓存的操作序列。第一个不同点是,即使请求在缓存中命中,也会将块写入较低级别。第二个不同点是,在将值写入临时缓冲区之后(在未命中之后),还将块的新内容写回较低级别的缓存。其余步骤类似于为回写缓存所遵循的步骤序列。

写入操作(直写缓存)。

19.6.3 内存系统机制

我们已经对缓存的工作及其所有组成操作有了一个合理的理解,使用缓存层次结构构建内存系统。内存系统作为一个整体支持两种基本操作:读取和写入,或者加载和存储。

有两个最高级别的缓存:数据缓存(也称为一级缓存)和指令缓存(也称I缓存)。几乎所有时候,它们都包含不同的内存位置集。用于访问I缓存和L1缓存的协议相同,为了避免重复,后面只关注一级缓存。我们只需要记住,对指令缓存的访问遵循相同的步骤顺序

处理器通过访问一级缓存启动。如果存在L1命中,则它通常在1-2个周期内接收该值。否则,请求需要转到二级缓存,或者甚至更低级别的缓存,如主内存。在这种情况下,请求可能需要数十或数百个周期。本节将从整体上看缓存系统,并将它们视为一个称为内存系统的黑盒子。

如果考虑包含性缓存,那么内存系统的总大小等于主内存的大小。例如,如果一个系统有1 GB的主内存,那么内存系统的大小等于1 GB。内存系统内部可能有一个用于提高性能的缓存层次结构,但不会增加总存储容量,因为只包含主内存中包含的数据子集。此外,处理器的存储器访问逻辑还将整个存储器系统视为单个单元,概念上被建模为一个大的字节阵列。这也称为物理内存系统或物理地址空间。

物理地址空间包括高速缓存和主内存中包含的所有存储器位置的集合。

19.6.3.1 内存系统的数学模型

性能

内存系统可以被认为是一个只服务于读写请求的黑盒子,请求所用的时间是可变的,取决于请求到达的内存系统的级别。管线在内存访问(MA)阶段连接到内存系统,并向其发出请求。如果回复不在一个周期内,那么需要在5阶段顺序流水线中引入额外的气泡。

设平均内存访问时间为AMAT(以周期测量),加载/存储指令的分数为\(f_{mem}\),那么CPI可以表示为:

\[\begin{aligned} C P I &=C P I_{\text {ideal }}+\text { stall\_rate } * \text { stall\_cycles } \\ &=C P I_{\text {ideal }}+f_{\text {mem }} \times(A M A T-1) \end{aligned} \]

\(CPI_{ideal}\)是假定完美的存储器系统对所有访问具有1个周期延迟的CPI。请注意,在5阶段顺序流水线中,理想的指令吞吐量是每个周期1条指令,内存级分配1个周期。在实践中,如果一次内存访问需要n个周期,那么我们有n-1个暂停周期,它们需要上述公式来解释。在此公式中,我们隐式地假设每次内存访问都会经历AMAT-1个周期的暂停。实际上,情况并非如此,因为大多数指令将在一级缓存中命中,并且一级缓存通常具有1个周期的延迟。因此,命中一级缓存的访问不会停止。但是,L1和L2缓存中的访问失败会导致长的暂停周期。

尽管如此,上述公式仍然成立,因为我们只对大量指令的平均CPI感兴趣,可以通过考虑大量指令,对所有内存暂停周期求和,并计算每条指令的平均周期数来推导出这个方程。

平均内存访问时间

在上述公式中,\(CPI_{ideal}\)由程序的性质和管线的其他阶段(MA除外)的性质决定,\(f_{mem}\)也是处理器上运行的程序的固有属性。我们需要一个公式来计算AMAT,可以用类似于上面公式的方法计算它。假设一个具有L1和L2缓存的内存系统,有:

\[\begin{aligned} \text { AMAT } &=L 1_{\text {hittime }}+L 1_{\text {miss rate }} \times L 1_{\text {miss penalty }} \\ &=L 1_{\text {hittime }}+L 1_{\text {miss rate }} \times\left(\text { L2 } _{\text {hittime }}+L 2_{\text {miss rate }} \times L 2_{\text {miss penalty }}\right) \end{aligned} \]

所有内存访问都需要访问L1缓存,而不管命中还是未命中,因此它们需要产生等于L1命中时间的延迟。一部分访问(\(L1_{\text {miss rate}}\))将在L1缓存中丢失,并移动到L2缓存。此外,无论命中还是未命中,都需要产生\(L2_{\text {hit time}}\)周期的延迟。如果L2缓存中有一部分访问(\(L2_{\text {miss rate}}\))未命中,则需要继续访问主存储器。我们假设所有的访问都发生在主内存中。因此,\(L2_{\text {miss penalty}}\)惩罚等于主存储器访问时间。

假设有一个n级存储器系统,其中第一级是L1缓存,最后一级是主存储器,那么可以使用类似的公式:

\[\begin{aligned} \text { AM AT } &=L 1_{\text {hittime }}+L 1_{\text {miss rate }} \times L 1_{\text {miss penalty }} \\ L 1_{\text {miss penalty }} &=L 2_{\text {hittime }}+L 2_{\text {miss rate }} \times L 2_{\text {miss penalty }} \\ L 2_{\text {miss penalty }} &=L 3_{\text {hit time }}+L 3_{\text {miss rate }} \times L 3_{\text {miss penalty }} \\ \cdots &=\ldots \\ L(n-1)_{\text {miss penalty }} &=L n_{\text {hit time }} \end{aligned} \]

需要注意的是,这些等式中针对某一级别\(i\)使用的未命中率等于该级别未命中的访问数除以该级别的访问总数,被称为局部未命中率(local miss rate)。相比之下,我们可以定义第\(i\)级的全局未命中率(global miss rate),它等于第\(i\)级未命中数除以内存访问总数。

局部未命中率(local miss rate):它等于第\(i\)级缓存中的未命中数除以第\(i\)级的访问总数。

全局未命中率(global miss rate):它等于\(i\)级缓存中的未命中数除以内存访问总数。

可以通过降低未命中率、未命中惩罚或减少命中时间来提高系统的性能。后面先看看未命中率。

19.6.3.2 缓存未命中

缓存未命中分类

让我们首先尝试对缓存中不同类型的未命中进行分类。

  • 强制未命中或冷未命中。当数据第一次加载到缓存中时,由于数据值不在缓存中,必然会发生此类未命中。
  • 容量未命中。当一个程序所需的内存量大于缓存大小时,会发生容量未命中。例如,假设一个程序重复访问数组的所有元素,数组的大小等于1 MB,二级缓存的大小为512 KB。在这种情况下,二级缓存中会有容量未命中,因为它太小,无法容纳所有数据。
  • 冲突未命中。程序在一个典型的时间间隔内访问的一组块称为其工作集,也可以说,当缓存的大小小于程序的工作集时,会发生冲突未命中。请注意,工作集的定义有点不精确,因为间隔的长度是主观考虑的。然而,时间间隔的内涵是,与程序执行的总时间相比,它是一个很小的间隔。只要它足够大,以确保系统的行为达到稳定状态,此类未命中发生在直接映射和集关联缓存中。例如,考虑一个4路集合关联缓存,如果一个程序的工作集中有5个块映射到同一个集,必然会有缓存未命中,因为访问的块的数量大于可以作为集合一部分的最大条目数。

程序在短时间间隔内访问的内存位置包括程序在该时间点的工作集。

由此,可将未命中分为三类:强制、容量和冲突,也称为三“C”。

降低未命中率

为了维持高IPC,有必要降低缓存未命中率。我们需要采取不同的策略来减少不同类型的缓存未命中。

让我们从强制性失误开始。我们需要一种方法来预测未来将访问的块,并提前获取这些块。通常,利用空间局部性的方案是有效的预测因素。因此,增加块大小对于减少强制未命中的数量应该是有益的。然而,将块大小增加到超过某个限制也会产生负面后果。它减少了可以保存在缓存中的块的数量,其次,额外的好处可能是微不足道的。最后,从内存系统的较低级别读取和传输较大的块将需要更多的时间。因此,设计师避免过大的块尺寸。32-128字节之间的任何值都是合理的。

现代处理器通常具有复杂的预测器,这些预测器试图根据当前的访问模式预测将来可能访问的块的地址。他们随后从内存层次结构的较低级别获取预测的块,试图降低未命中率。例如,如果我们按顺序访问一个大数组的元素,那么可以根据访问模式预测未来的访问。有时我们访问数组中的元素,其中的索引相差固定值。例如,我们可能有一个访问数组中每四个元素的算法。在这种情况下,也可以分析模式并预测未来的访问,因为连续访问的地址相差相同的值。这种单元被称为硬件预取器。它存在于大多数现代处理器中,并使用复杂的算法来“预取”块,从而降低未命中率。请注意,硬件预取器不应非常激进。否则,它将倾向于从缓存中移出比它带来的更有用的数据。

硬件预取器(hardware prefetcher)是一个专用的硬件单元,它预测在不久将来的内存访问,并从内存系统的较低级别获取它们。

先阐述容量未命中。唯一有效的解决方案是增加缓存的大小,不幸的是,本文介绍的缓存设计要求缓存的大小等于2的幂(以字节为单位),使用一些高级技术可能会违反这一规则。然而,大体上,商业处理器中的大多数缓存的大小都是2的幂。因此,增加缓存的大小等于至少使其大小加倍,将缓存的大小加倍需要两倍的面积,使其速度减慢,并增加功耗。如果明智地使用预取,也会有所帮助。

减少冲突未命中数的经典解决方案是增加缓存的关联性,但也会增加缓存的延迟和功耗,设计者有必要仔细平衡集合关联缓存的额外命中率和额外延迟。有时,缓存中的一些集合中会出现冲突未命中,在这种情况下,可以用一个小型的全关联缓存,称为牺牲缓存(victim cache)和主缓存。从主缓存移位的任何块都可以写入牺牲缓存,缓存控制器需要首先检查主缓存,如果有未命中,则需要检查牺牲缓存,然后再继续进行下一级。因此,级别i的牺牲缓存可以过滤掉一些到达级别(i+1)的请求。

注意,与硬件技术一起,以“缓存友好”的方式编写程序是可行的,可以最大化时间和空间的局部性,编译器也可以优化给定内存系统的代码。其次,编译器可以插入预取代码,以便在实际使用之前将块预取到缓存中。

现在快速提及两条经验法则,这些规则被发现在经验上大致成立,并且在理论上并非完全正确。

第一个被称为平方根规则(Square Root Rule),它表示未命中率与缓存大小的平方根成正比:

\[miss\ rate \propto \frac{1}{\sqrt{\text { cachesize }}} \ \ \ \ \ [Square Root Rule] \]

哈特斯坦等人[Hartstein等人,2006]试图为该规则找到理论依据,并利用概率论的结果解释该规则的基础。根据他们的实验结果,得出了该规则的通用版本,该规则表示平方根规则中缓存大小的指数从-0.3到-0.7不等。

第二个规则被称为关联性规则(Associativity Rule),它表明将关联性加倍的效果几乎与将缓存大小与原始关联性加倍相同,例如64 KB 4路关联缓存的未命中率几乎与128 KB 2路关联缓存相同。

注意,关联性规则和平方根规则只是经验规则,并不完全成立,仅仅用作概念辅助工具,我们总是可以构造违反这些规则的示例。

减少命中时间和未命中不利

还可以通过减少命中时间和未命中不利(Miss Penalty)来减少平均内存访问时间。为了减少命中时间,需要使用小而简单的缓存,但也增加了未命中率。

现在讨论一下减少不利的方法。请注意,级别i处的未命中不利等于从级别(i+1)开始的存储器系统的存储器延迟。传统的减少命中时间和未命中率的方法总是可以用于在给定的水平上减少未命中不利,现在研究专门针对减少未命中不利的方法。首先看看一级缓存中的写入未命中。在这种情况下,必须将整个块从L2高速缓存带入高速缓存,需要时间(>10个周期),其次,除非写入完成,否则管线无法恢复。处理器设计人员使用一个称为写缓冲区的小集合关联缓存,如下图所示。处理器可以将值写入写缓冲区,然后恢复,或者,只有在L1缓存中未命中时(假设),它才可以写入写缓冲。任何后续读取都需要在访问一级缓存的同时检查写入缓冲区,该结构通常非常小且快速(4-8个条目)。

一旦数据到达一级缓存,就可以从写缓冲器中删除相应的条目。注意,如果写缓冲区中没有可用的空闲条目,则管线需要暂停。其次,在从较低级别的高速缓存服务写入未命中之前,可能存在对同一地址的另一次写入,可以通过写入写入缓冲区中给定地址的分配条目来无缝处理。

现在看看读取未命中。处理器通常只对每次内存访问最多4个字节感兴趣,如果提供了这些关键的4字节,管线可以恢复。然而,在操作完成之前,内存系统需要填充整个块,块的大小通常在32-128字节之间。因此,如果内存系统知道处理器所需的确切字节集,则可以在此引入优化。在这种情况下,内存系统可以首先获取所需的内存字(4字节),随后或者并行地获取块的其余部分。这种优化被称为关键词优先(critical word first)。然后,可以将这些数据快速发送到管线,以便恢复其操作。这种优化被称为提前重启(early restart)。实现这两种优化增加了内存系统的复杂性。然而,关键词语优先和提前重启在减少未命中不利方面相当有效。

内存系统优化技术概述

下表总结了我们为优化存储系统而引入的不同技术。请注意,每种技术都有一些负面影响,如果一种技术在一个方面改进了内存系统,那么在另一个方面是有害的。例如,通过增加缓存大小,我们可以减少容量未命中的数量,但也增加了面积、延迟和功率。

技术 应用 劣势
大块尺寸 强制未命中 减少缓存的块数
预获取 强制未命中、容量未命中 额外的复杂性和从缓存中替换有用数据的风险
大缓存大小 容量未命中 高延迟、高功率、更大面积
关联性增强 冲突未命中 高延迟、高功率
牺牲缓存 冲突未命中 额外复杂性
基于编译器的技术 所有类型的未命中 不是很通用
小而简单的缓存 命中时间 高未命中率
写入缓冲器 未命中不利 额外复杂性
关键词优先 未命中不利 额外的复杂性和状态
提前重启 未命中不利 额外复杂性

总而言之,必须非常仔细地设计内存系统。目标工作量的要求必须与设计师设置的限制和制造技术的限制仔细平衡,需要最大化性能,同时注意功率、面积和复杂性限制。

19.6.4 虚拟内存

一个处理器可以通过在不同的程序之间快速切换来运行多个程序。例如,当用户玩游戏时,他的处理器可能正在接收电子邮件,之所以感觉不到任何中断,是因为处理器在程序之间来回切换的时间尺度(通常为几毫秒)比人类所能感知的要小得多。

到目前为止,我们假设程序所需的所有数据都驻留在主内存中,这种假设是不正确的。在过去,主内存的大小曾经是几兆字节,而用户可以运行需要数百兆字节数据的非常大的程序。即使现在,也可以处理比主内存量大得多的数据。用户可以通过编写一个C程序来轻松验证这一语句,该程序创建的数据结构大于机器中包含的物理内存量。在大多数系统中,此C程序将成功编译并运行。

本节通过对内存系统进行少量更改,可以满足以上要求。阅读本节需要一定的操作系统知识(如进程、线程、内存等),可参阅剖析虚幻渲染体系(18)- 操作系统

19.6.4.1 内存的虚拟视图

因为多个进程在同一时间点处于活动状态,有必要在进程之间划分内存,如果不这样做,那么进程可能最终会修改彼此的值,同时也不希望程序员或编译器知道多个进程的存在。否则,会引入不必要的复杂性,其次,如果给定的程序是用某个内存映射编译的,那么它可能不会在另一台具有重叠内存映射的进程的机器上运行,更糟糕的是,不可能运行同一程序的两个副本。因此,每个程序都必须看到内存的虚拟视图,在该视图中,它假定自己拥有整个内存系统。

这两个要求出现了相互的矛盾——内存系统和操作系统希望不同的进程访问不同的内存地址,而程序员和编译器不希望知道这一要求。此外,程序员希望根据自己的意愿布局内存映射。事实证明,有一种方法可以让程序员和操作系统都感到满意。

我们需要定义内存的虚拟和物理视图。在内存的物理视图中,不同的进程在内存空间的非重叠区域中操作。然而,在虚拟视图中,每个进程都访问它希望访问的任何地址,并且不同进程的虚拟视图可以重叠。解决方案是分页。内存的虚拟视图也称为虚拟内存,它被定义为一个假设的内存系统,其中一个进程假定它拥有整个内存空间,并且没有任何其它进程的干扰。

虚拟内存系统被定义为一个假设的内存系统,其中一个进程假定它拥有整个内存空间,并且没有任何其他进程的干扰。内存的大小与系统的总可寻址内存一样大。例如,在32位系统中,虚拟内存的大小为\(2^{32}\)字节(4 GB)。虚拟内存中所有内存位置的集合称为虚拟地址空间。

下图显示了32位Linux操作系统中进程的内存映射的简化视图。让我们从底部(最低地址)开始。第一段包含标头,从进程、格式和目标机器的详细信息开始。标头包含内存映射中每段的详细信息,例如,它包含包含程序代码的文本部分的详细信息,包括其大小、起始地址和其他属性。文本段从标头之后开始,加载程序时,操作系统将程序计数器设置为文本段的开始。程序中的所有指令通常都包含在文本段中。

Linux操作系统中进程的内存映射(32位)。

文本段后面是另外两个段,用于包含静态变量和全局变量。可选地,一些操作系统也有一个额外的区域来包含只读数据,如常量。文本段之后通常是数据部分,包含所有由程序员初始化的静态/全局变量。让我们考虑以下形式的声明(在C或C++中):

static int val = 5;

与变量val对应的4个字节保存在数据段中。

数据段后面是bss段,bss段保存程序员未明确初始化的静态变量和全局变量。大多数操作系统,所有与bss段对应的内存区域都为零。为了安全起见,必须这样做。让我们假设程序A运行并将其值写入bss段,随后程序B运行。在写入bss段中的变量之前,B总是可以尝试读取其值。在这种情况下,它将获得程序A写入的值,但这不是理想的行为,程序A可能在bss段保存了一些敏感数据,例如密码或信用卡号。因此,程序B可以在程序A不知情的情况下访问这些敏感数据,并可能滥用这些数据。因此,有必要用零填充bss段,这样就不会发生此类安全错误。

bss段后面是一个称为堆的内存区域,堆区域用于在程序中保存动态分配的变量。C程序通常使用malloc调用分配新数据,Java和C++使用new运算符。让我们看看一些例子:

int *intarray  = (int*) malloc(10 * sizeof(int)); // [C]
int *intarray  = new int[10];                     // [C++]
int[] intarray = new int[10];                     // [Java]

请注意,在这些语言中,动态分配数组非常有用,因为它们的大小在编译时是未知的。堆中有数据的另一个优点是它们可以跨函数调用生存。堆栈中的数据仅在函数调用期间保持有效,随后被删除。然而,堆中的数据会在程序的整个生命周期中保留,它可以由程序中的所有函数使用,指向堆中不同数据结构的指针可以跨函数共享。请注意,堆向上增长(朝向更高的地址)。其次,在堆中管理内存是一项相当困难的任务,因为在高级语言中,堆的区域动态地被分配了malloc/new调用,并被释放了free/delete调用。一旦释放了分配的内存区域,就会在内存映射中形成一个孔洞。如果孔的大小小于孔的大小,则可以在孔中分配一些其他数据结构。在这种情况下,将在内存映射中创建另一个较小的孔。随着时间的推移,随着越来越多的数据结构被分配和取消分配,孔洞的数量往往会增加,这就是所谓的碎片化。因此,有必要拥有一个高效的内存管理器,以减少堆中的孔数。下图显示了带有孔和分配内存的堆的视图。

堆的内存映射。

下一段用于存储与内存映射的文件和动态链接的库相对应的数据。大多数情况下,操作系统将文件的内容(如音乐、文本或视频文件)传输到内存区域,并将文件的属性视为常规数组,该存内存区域被称为内存映射文件。其次,程序可能偶尔会动态读取其他程序(称为库)的内容,并将其文本段的内容传输到内存映射中,这种库称为动态链接库(dll)。这种内存映射结构的内容存储在进程的内存映射中的专用段中。

下一段是堆栈,它从内存映射的顶部开始向下增长(朝向更小的地址),堆栈根据程序的行为不断增长和收缩。请注意,上上图未按比例绘制。如果我们考虑32位内存系统,那么虚拟内存的总量是4 GB,但程序可能使用的内存总量通常限制在数百兆字节。因此,在堆的开始部分和堆栈部分之间的映射中有一个巨大的空区域。

请注意,操作系统需要非常频繁地运行,需要为设备请求提供服务,并执行进程管理。从一个进程到另一个进程更改内存的虚拟视图稍微有些昂贵,因此,大多数操作系统在用户进程和内核之间划分虚拟内存,例如,Linux为用户进程提供了较低的3GB,为内核保留了较高的1GB。类似地,Windows为内核保留较高的2GB,为用户进程保留较低的2GB。当处理器从用户进程转换到内核时,不需要更改内存视图。其次,这种小的修改不会极大地降低程序的性能,因为2GB或3GB远远超过程序的典型内存占用量。此外,这个技巧也与虚拟内存概念不冲突,一个程序只需要假设它的内存空间减少了(在Linux的情况下,从4GB减少到3GB),参考下图。

Linux和Windows的用户、内核的内存映射。

19.6.4.2 重叠和尺寸问题

我们需要解决两个虚拟内存的问题:

  • 重叠问题。程序员和编译器编写程序时假定他们拥有整个内存空间,并且可以随意写入任何位置。不幸的是,所有同时处于活动状态的进程都做出了相同的假设。除非采取措施,否则它们可能会在不经意间写入对方的内存空间并破坏对方的数据。事实上,考虑到它们使用相同的内存映射,在粗略的系统中发生这种情况的可能性非常高,硬件需要确保不同的进程相互隔离。这就是重叠问题。
  • 尺寸问题。有时我们需要运行需要比可用物理内存更多内存的进程。如果其他存储介质(例如硬盘)中的一些空间可以重新用于存储进程的内存占用,便是理想的,这就是所谓的尺寸问题。

任何虚拟内存的实现都需要有效地解决尺寸和重叠问题。

19.6.4.3 分页

本小节涉及到的概念解析如下:

  • 虚拟地址:程序在虚拟地址空间中指定的地址。
  • 物理地址:地址转换后呈现给内存系统的地址。
  • 页(page):是虚拟地址空间中的一块内存。
  • 帧(frame):是物理地址空间中的一块内存,页和帧大小相同。
  • 页表(page table):是一个映射表,将每个页面的地址映射到帧的地址。每个进程都有自己的页表。

为了平衡处理器、操作系统、编译器和程序员的需求,需要设计一个转译系统,将进程生成的地址转译成内存系统可以使用的地址。通过使用转译器,可以满足需要虚拟内存的程序员/编译器和需要物理内存的处理器/内存系统的需求。转译系统类似于现实生活中的翻译人员,例如,如果我们有一个俄罗斯代表团访问迪拜,那么我们需要一个能将俄语翻译成阿拉伯语的翻译。然后双方都可以说自己的语言,从而感到高兴。转译系统的概念图如下图所示。

考虑一个32位内存地址,现在可以把它分成两部分。如果考虑一个4KB的页面,那么低12位指定页面中字节的地址(原因:\(2^{12}\)=4096=4KB),称为偏移,高20位指定页码(下图)。同样,可以将物理地址分为两部分:帧号和偏移。下图的转换过程首先将20位页码替换为等效的20位帧号,然后将12位偏移量附加到物理帧号。

将虚拟地址转换为物理地址。

在实现的方案中,按照页表的层级,有1级和2级页表,它们的示意图如下:

上:1级页表;下:2级页表。

一些处理器,如英特尔安腾和PowerPC 603,对页表使用不同的设计。它们不是使用页码来寻址页表,而是使用帧号来寻址页。在这种情况下,整个系统只有一个页面表。由于一个帧通常被唯一地映射到进程中的一个页面,所以这个反向页面表中的每个条目都包含进程id和页码。下图(a)显示了反转页表的结构,其主要优点是不需要为每个进程保留单独的页表。如果有很多进程,并且物理内存的大小很小,可以节省空间。

反转页表。

反向页表的主要困难在于查找虚拟地址。扫描所有条目是一个非常缓慢的过程,因此不实用。因此,需要一个哈希函数,将(进程id,页码)对映射到哈希表中的索引,哈希表中的这个索引需要指向反向页表中的一个条目。由于多个虚拟地址可以指向哈希表中的同一条目,因此有必要验证(进程id、页码)与存储在反向页表中的条目中的匹配。

上图(b)中展示了一种使用反向页表的方案。在计算页码和进程id对的哈希之后,访问一个由哈希内容索引的哈希表,哈希表条目的内容指向可能映射到给定页面的帧f。然而,我们需要验证,因为哈希函数可能将多个页面映射到同一帧。随后访问反向页表,并访问条目f。反向页表的一个条目包含映射到给定条目(或给定帧)的页码、进程id对。如果发现内容不匹配,就继续在随后的K个条目中搜索页码、进程id对。这种方法被称为线性探测(linear probing),在目标数据结构中不断搜索,直到找到匹配。如果没有在K个条目中找到匹配项,就可能会得出页面没有映射的结论。然后,需要创建一个映射,方法是逐出一个条目(类似于缓存),并将其写入主内存中的一个专用区域,该区域存储从反向页表中逐出的所有条目,需要始终确保哈希表指向的条目和包含映射的实际条目之间的差异不超过K个条目。如果没有找到任何空闲插槽,那么就需要收回一个条目。

有人可能认为,可以直接使用哈希引擎的输出来访问反向页表。通常,我们添加访问哈希表作为中间步骤,因为它允许更好地控制实际使用的帧集。使用此过程,可以禁止某些帧的映射,这些帧可用于其他目的。最后需要注意的是,维护和更新哈希表的开销大于拥有全系统页面表的收益。因此,反转页表通常不用于商业系统

虚拟内存还涉及TLB、空间替换、MMU、页面错误等概念和机制,这些可在虚拟内存中寻得支持,本文不再累述。

地址转译过程。

19.6.4.4 分页系统高级功能

事实证明,我们可以用页表机制做一些有趣的事情,下面看看几个例子。

  • 共享内存

假设两个进程希望在彼此之间共享一些内存,以便它们交换数据,每个进程都需要让内核知道这一点。内核可以将两个虚拟地址空间中的两个页面映射到同一帧,之后,每个进程都可以在自己的虚拟地址空间中写入页面,神奇的是,数据将反映在另一个进程的虚拟地址中。有时需要几个进程相互通信,共享内存机制是最快的方法之一。

  • 保护

计算机病毒通常会更改正在运行的进程的代码,以便它们可以执行自己的代码,通常通过向程序提供一个特定的错误输入序列来实现。如果没有进行适当的检查,那么程序中特定变量的值将被覆盖。一些变量可以更改为指向文本段的指针,并且可以利用此机制更改文本部分段中的指令。可以通过将文本段中的所有页面标记为只读来解决此问题,无法在运行时修改它们的内容。

  • 分段

我们一直假设程序员可以根据自己的意愿自由布局内存映射,例如,程序员可能决定在非常高的地址(例如0xFFFFFFF8)启动堆栈,然而,即使程序的内存占用非常小,该代码也可能无法在使用16位地址的机器上运行。其次,某个系统可能保留了虚拟内存的某些段,并使其无法用于进程。例如,操作系统通常为内核保留较高的1或2 GB。为了解决这些问题,我们需要在虚拟内存之上创建另一个虚拟层。

在分段内存(用于x86系统)中,有用于文本、数据和堆栈段的特定段寄存器。每个虚拟地址指定为特定段寄存器的偏移量。默认情况下,指令使用代码段寄存器,数据使用数据段寄存器。管线的内存访问(MA)阶段将偏移量添加到存储在段寄存器中的值以生成虚拟地址。随后,MMU使用该虚拟地址来生成物理地址。


19.7 多处理器系统

前面已经详细讨论了处理器的设计和实现,以及优化其性能的几种方法,如管线。通过优化处理器和内存系统,可以显著提高程序的性能。问题是,这足够了吗?有没可能做得更好?

简短答案:也许不是。从处理器性能有其局限性开始说起。不可能单独提高处理器的速度,即使是非常复杂的超标量处理器和高度优化的内存系统,通常不可能将IPC增加超过50%。其次,由于功率和温度的考虑,很难将处理器频率提高到3 GHz以上。在过去相当多年中,处理器频率基本保持不变,由此CPU性能的增长也非常缓慢。

下面两图中证明了以上论述。下图显示了英特尔、AMD、Sun、高通和富士通等多家供应商从2001年到2010年发布的处理器的峰值频率。我们观察到,频率或多或少保持不变(大多在1 GHz到2.5 GHz之间),这些趋势表明频率没有逐渐增加。预计在不久的将来,处理器的频率也将限制在3 GHz。

CPU频率。

下图显示了2001年至2010年同一组处理器的Spec Int 2006平均得分。我们观察到,随着时间的推移,CPU性能逐渐饱和,提高性能变得越来越困难。

CPU性能。

尽管单个处理器的性能预计在未来不会显著提高,但计算机架构的未来并不黯淡,因为处理器制造技术正在稳步进步,导致更小更快的晶体管。直到20世纪90年代末,处理器设计者一直在利用晶体管技术的进步,通过实现更多功能来增加处理器的复杂性。然而,由于复杂度和功耗的限制,2005年后,设计师们转而使用更简单的处理器。供应商没有在处理器中实现更多功能,而是决定在单个芯片上安装多个处理器,有助于同时运行多个程序。或者,可以将单个程序拆分为多个部分,并行运行所有部分。

这种使用多个并行运行的计算单元的范例称为多处理(multiprocessing)。多处理是一个相当通用的术语,可以指同一芯片中的多个处理器并行工作,也可以指跨芯片的多个并行处理器。多处理器是一种支持多处理的硬件,当我们在一个芯片中有多个处理器时,每个处理器都被称为一个核心,而这个芯片被称为多核(multicore)处理器。

我们正处于多处理器(multiprocessors)时代,尤其是多核(multicore)系统。每个芯片的核数大约每两年增加两倍,正在编写新的应用程序来利用这些额外的硬件。大多数专家认为计算的未来在于多处理器系统。

在开始设计不同类型的多处理器之前,让我们先来看看多处理器的背景和历史。

19.7.1 多处理器背景

在60年代和70年代,大型计算机主要被银行和金融机构使用。他们拥有越来越多的消费者,因此需要能够每秒执行越来越多事务的计算机。通常,只有一个处理器被证明不足以提供所需的计算吞吐量。因此,早期的计算机设计师决定在一台计算机中安装多个处理器。处理器可以共享计算负载,从而增加整个系统的计算吞吐量。

最早的多处理器之一是Burroughs 5000,它有两个处理器:A和B。A是主处理器,B是辅助处理器。当负载很高时,处理器A给处理器B一些工作要做。当时几乎所有其他主要供应商都有多处理器产品,如IBM 370、PDP 11/74、VAX-11/782和Univac 1108-II,这些计算机支持第二个CPU芯片,已连接到主处理器。在所有这些早期机器中,第二个CPU位于第二个芯片上,该芯片通过导线或电缆与第一个CPU物理连接。它们有两种类型:对称不对称。对称多处理器由多个处理器组成,每个处理器都是相同类型的,并且可以访问操作系统和外围设备提供的服务。非对称多处理器为不同的处理器分配不同的角色,通常有一个独特的处理器来控制操作系统和外围设备,其余的处理器都是从机,它们从主处理器获取工作,并返回结果。

对称多处理器(Symmetric Multiprocessing):此范例将多处理器系统中的所有组成处理器视为相同的,每个处理器都可以平等地访问操作系统和I/O外围设备,也称为SMP系统

非对称多处理器(Asymmetric Multiprocessing):此范例并不将多处理器系统中的所有组成处理器视为相同的,通常有一个主处理器独占控制操作系统和I/O设备,将工作分配给其他处理器。

早期,第二个处理器使用一组电缆连接到主处理器,通常位于主计算机的不同区域。请注意,在那个年代,电脑曾经有一个房间那么大。随着小型化程度的提高,两个处理器逐渐接近。在80年代末和90年代初,公司开始在同一主板上安装多个处理器。主板是一块印刷电路板,包含计算机使用的所有芯片,带有芯片和金属线的大型绿色电路板是主板。到了90年代末,在一块主板上可以有四到八个处理器,它们通过专用高速总线相互连接。

渐渐地,多核处理器的时代开始了,同一芯片中有多个处理器。2001年,IBM率先推出了名为Power 4的双核(2核)多核处理器,2005年,英特尔和AMD也推出了类似产品。截至2022年,有16、32、64甚至更多核心的多核处理器可供选择。

现在更深入地了解一下1960年至2012年间处理器世界发生了什么。在六十年代,一台电脑通常只有一个房间那么大,而今,口袋里装着一台电脑。在60年代早期,手机中的处理器比IBM 360机器快约160万倍,它的功率效率也提高了几个数量级。计算机技术持续发展的主要驱动因素是晶体管的小型化,晶体管在六十年代曾经有几毫米的沟道长度,现在大约有20-30纳米长。1971年,一个典型的芯片曾经有2000-3000个晶体管,如今的一个芯片有数十亿个晶体管。

在过去的四十到五十年中,每个芯片的晶体管数量大约每1-2年翻一番。事实上,英特尔的联合创始人戈登·摩尔(Gordon Moore)在1965年就预测到了这一趋势。摩尔定律预测,芯片上的晶体管数量预计每一到两年就会翻一番。最初,摩尔曾预测每年翻倍的时间,随着时间的推移,这段时间已经变成了大约2年。由于制造技术、新材料和制造技术的稳步发展,这种情况预计会发生。

摩尔定律自20世纪60年代中期提出以来,几乎一直成立。如今,几乎每两年,晶体管的尺寸就会缩小\(\sqrt{2}\)倍,确保了晶体管的面积缩小一倍,从而可以使芯片上的晶体管数量增加一倍。让我们将特征尺寸(feature size)定义为可以在芯片上制造的最小结构的尺寸。下表显示了过去10年英特尔处理器的功能大小,我们观察到特征大小每两年大约减少\(\sqrt{2}\)(1.41)倍,导致晶体管数量加倍。

年份 特征尺寸
2001 130 nm
2003 90 nm
2005 65 nm
2007 45 nm
2009 32 nm
2011 22 nm

请注意,摩尔定律是一个经验定律。然而,由于它在过去四十年中正确预测了趋势,因此在技术文献中被广泛引用。它直接预测了晶体管尺寸的小型化,更小的晶体管更省电、更快。传统上,设计师们利用这些优势来设计具有额外晶体管的更大处理器,他们使用额外的晶体管预算来增加不同单元的复杂性,增加缓存大小,增加问题宽度和功能单元的数量。其次,管线阶段的数量也在稳步增加,直到2002年左右,时钟频率也随之增加。然而,2002年之后,计算机架构的世界发生了根本性的变化。突然间,电力和温度成了主要问题。处理器功耗曲线开始超过100瓦,芯片温度开始超过100摄氏度,这些限制显著地结束了处理器复杂性和时钟频率的扩展。

相反,设计师开始在不改变其基本设计的情况下,为每个芯片封装更多的内核,确保了每个核的晶体管数量保持不变。根据摩尔定律,核的数量每两年翻一番,开启了多核处理器的时代,处理器供应商开始将芯片上的核数量增加一倍。在未来不久,每个芯片的核数预计普遍达到64、128个甚至更多。

除了常规的多核处理器,还有另一个重要的发展。除了每个芯片有4个大内核,还有一些架构在芯片上有64-256个非常小的内核,例如图形处理器。这些处理器也遵循摩尔定律,每2年将其内核翻倍,被越来越多地用于计算机图形学、数值计算和科学计算。也可以拆分处理器的资源,使其支持两个程序计数器,并同时运行两个程序,这些特殊类型的处理器被称为多线程处理器。

本章让读者了解多处理器设计的广泛趋势,首先从软件的角度来看多处理,一旦确定了软件需求,将着手设计支持多处理的硬件,将广泛考虑多核、多线程和矢量处理器。

19.7.2 多处理器系统软件

19.7.2.1 强和松散耦合多处理

松散耦合多处理(Loosely Coupled Multiprocessing)是在多处理器上并行运行多个不相关的程序。

强耦合多处理(Strongly Coupled Multiprocessing)是在多处理器上并行运行一组共享内存空间、数据、代码、文件和网络连接的程序。

本文将主要研究强耦合多处理,并主要关注通过共享大量数据和代码来允许一组程序协同运行的系统。

19.7.2.2 共享内存与消息传递

计算机架构师按照不同的模式为多处理器设计了一套协议。第一个范例被称为共享内存,所有单独的程序都看到内存系统的相同视图,如果程序A将x的值更改为5,则程序B立即看到更改。第二种设置称为消息传递,多个程序通过传递消息相互通信。共享内存范例更适合强耦合多处理器,消息传递范例更适合松散耦合多处理器。请注意,可以在强耦合多处理器上实现消息传递。同样,也可以在松散耦合的多处理器上实现共享内存的抽象,被称为分布式共享内存(distributed shared memory),但通常不是常态。

共享内存

让我们尝试使用多处理器并行添加n个数字,它的代码如下所示,使用OpenMP语言扩展用C++编写了代码。假设所有的数字都已经存储在一个称为numbers.的数组中,数组编号有SIZE个条目,假设可以启动的并行子程序的数量等于N。

/* 变量声明 */
int partialSums[N];
int numbers[SIZE];
int result = 0;

/* 初始化数组 */
(...)
    
/* 并行代码 */
#pragma omp parallel 
{ 
    /* get my processor id */
    int myId = omp_get_thread_num();

    /* add my portion of numbers */
    int startIdx = myId * SIZE/N;
    int endIdx = startIdx + SIZE/N;
    for(int jdx = startIdx; jdx < endIdx; jdx++)
        partialSums[myId] += numbers[jdx];
}

/* 顺序代码 */
for(int idx=0; idx < N; idx++)
    result += partialSums[idx];

除了指令#pragma omp parallel之外,很容易将代码误认为是常规顺序程序,这是在并行程序中添加的唯一额外语义差异,它将此循环的每个迭代作为单独的子程序启动,每个这样的子程序都被称为线程。线程通过修改共享内存空间中内存位置的值与它们通信,每个线程都有自己的一组局部变量,其他线程无法访问这些变量。

迭代次数或启动的并行线程数是预先设置的系统参数,通常等于处理器的数量,上述代码中等于N。因此,并行启动代码的并行部分的N个副本,每个副本在单独的处理器上运行。请注意,程序的每个副本都可以访问在调用并行部分之前声明的所有变量,例如,可以访问partialSumsnumbers数组。每个处理器都调用函数omp_get_thread_num,该函数返回线程的id。每个线程都使用线程id来查找需要添加的数组范围,在数组的相关部分中添加所有条目,并将结果保存在partialSums数组中相应的条目中。一旦所有线程都完成了它们的工作,顺序部分就开始了,这段顺序代码可以在任何处理器上运行,是由操作系统或并行编程框架在运行时动态做出的。为了得到最终结果,必须将顺序部分中的所有部分和相加。

计算的图形表示如下图所示。父线程生成一组子线程,做各自的工作,完成后最终连接,父线程接管并聚合并行结果。此例也是Fork-Join范例的一个具体示例。

并行加法程序的图形表示。

有几个要点需要注意。每个线程都有自己的堆栈,可以使用其堆栈声明其局部变量。一旦完成,堆栈中的所有局部变量都将被销毁。要在父线程和子线程之间传递数据,必须使用两个线程都可以访问的变量,所有线程都需要全局访问这些变量,子线程可以自由地修改这些变量,甚至可以使用它们相互通信。此外,它们还可以自由调用操作系统,并写入外部文件和网络设备。一旦所有线程完成执行,它们就执行一个联接操作,并释放它们的状态,父线程接管并完成聚合结果的角色。join是线程之间同步操作的一个示例,线程之间可以有许多其他类型的同步操作。有一组复杂的结构,线程可以用来协同执行非常复杂的任务,添加一组数字是一个非常简单的例子。多线程程序可以用于执行其他复杂任务,如矩阵代数,甚至可以并行求解微分方程。

消息传统

接下来简单地看看消息传递,只给读者一个消息传递程序的概况,在这种情况下,每个程序都是一个单独的实体,不与其他程序共享代码或数据。它是一个进程,其中进程被定义为程序的运行实例,通常不与任何其他进程共享其地址空间。

现在快速定义消息传递语义,主要使用两个函数:send和receive,如下表所示。send(pid, val)函数用于向id等于pid的进程发送整数(val),receive(pid)用于接收id等于pid的进程发送的整数。如果pid等于ANYSOURCE,那么接收函数可以返回任何进程发送的值。我们的语义基于流行的并行编程框架MPI(消息传递接口),MPI调用有更多的参数,语法相对复杂。

函数 语意
send(pid, val) 将整数val发送给id等于pid的进程。
receive(pid) 1、 从进程pid接收整数。
2、 函数会一直阻塞,直到它得到值。
3、 如果pid等于ANYSOURCE,则接收函数返回任何进程发送的值。

现在考虑以下示例并行添加n个数字的相同示例。假设所有的数字都存储在numbers数组中,并且这个数组可用于所有N个处理器,numbers元素数为SIZE。为了简单起见,假设SIZE可被N整除。

/* start all the parallel processes */
SpawnAllParallelProcesses();

/* For each process execute the following code */
int myId = getMyProcessId();

/* 计算部分和 */
int startIdx = myId * SIZE/N;
int endIdx = startIdx + SIZE/N;
int partialSum = 0;
for(int jdx = startIdx; jdx < endIdx; jdx++)
    partialSum += numbers[jdx];

/* 所有非根节点将其部分和发送到根 */
if(myId != 0) 
{
    send (0, partialSum);
}
else 
{
    /* 处理根节点 */
    int sum = partialSum;
    for (int pid = 1; pid < N; pid++) 
    {
        sum += receive(ANYSOURCE);
    }
    
    /* 关闭所有进程 */
    shutDownAllProcesses();
    
    return sum;
}

19.7.3 多处理器的设计空间

迈克尔·弗林(Michael J.Flynn)在1966年提出了著名的弗林对多处理器的分类,他从观察到不同处理器的集成可能共享代码、数据或两者兼而有之开始。有四种可能的选择:SISD(单指令单数据)、SIMD(单指令多数据)、MISD(多指令单数据)和MIMD(多指令多数据),下面描述这些类型的多处理器:

  • SISD:是一个标准的单处理器,具有单个流水线。SISD处理器可以被看作是一组只有单个处理器的多处理器的特例。

  • SIMD:SIMD处理器可以在一条指令中处理多个数据流,例如SIMD指令可以用一条指令将4组数字相加。现代处理器将SIMD指令纳入其指令集,并具有特殊的SIMD执行单元,例如包含SIMD指令集的SSE集的x86处理器。图形处理器和矢量处理器是高度成功的SIMD处理器的特殊例子。

    多线程SIMD处理器数据路径的简化框图。。

  • MISD:MISD系统在实践中非常罕见,主要用于可靠性要求非常高的系统中。例如,大型商用飞机通常有多个处理器运行同一程序的不同版本,最终结果由表决(voting)决定。例如,一架飞机可能有一个MIPS处理器、一个ARM处理器和一个x86处理器,每个处理器都运行着相同程序的不同版本,如自动驾驶系统,它们有多个指令流,但只有一个数据源。专用投票电路(dedicated voting circuit)计算三个输出的多数投票。例如,由于程序或处理器中的错误,其中一个系统可能错误地决定左转,而其他两个系统都可能做出正确的右转决定,在这种情况下,投票电路将决定右转。由于MISD系统几乎从未在实践中使用过,除了特殊的例子,本文不再讨论它们。

  • MIMD:MIMD系统是目前最流行的多处理器系统,它们有多个指令流和多个数据流,多核处理器和大型服务器都是MIMD系统。多个指令流意味着指令来自多个来源,每个源都有其唯一的位置和相关的程序计数器。MIMD范式的两个重要分支在过去几年中形成。

    第一个是SPMD(单程序多数据),第二个是MPMD(多程序多数据),大多数并行程序以SPMD风格编写。同一程序的多个副本在不同的内核或独立的处理器上运行,然而,每个单独的处理单元都有单独的程序计数器,因此可以感知不同的指令流。有时,SPMD程序的编写方式会根据线程ID执行不同的操作,SPMD的优点是我们不必为不同的处理器编写不同的程序。同一程序的部分可以在所有处理器上运行,尽管它们的行为可能不同。

    一个对比的范例是MPMD,在不同处理器上运行的程序实际上是不同的,它们对于具有异构处理单元的专用处理器更有用。通常只有一个主程序将工作分配给从程序,从属程序完成分配给它们的工作量,然后将结果返回给主程序。这两个程序的工作性质实际上非常不同,通常不可能将它们无缝地组合到一个程序中。

    在MIMD组织中,处理器是通用的,每个处理器都能够处理执行适当数据转换所需的所有指令。MIMD可以通过处理器通信的方式进一步细分(下图)。

    如果处理器共享一个公共存储器,则每个处理器访问存储在共享内存中的程序和数据,处理器通过该内存相互通信,这种系统最常见的形式是对称多处理器(SMP)。在SMP中,多个处理器通过共享总线或其他互连机制共享单个内存或内存池,区别特征在于,对于每个处理器,对任何内存区域的内存访问时间大致相同。前些年的一个发展是非均匀内存访问(NUMA)组织,如下图所述。顾名思义,NUMA处理器对不同内存区域的内存访问时间可能不同。

从上面的描述可以清楚地看出,我们需要关注的系统是SIMD和MIMD。由于MISD系统很少使用,不再讨论,下面首先讨论MIMD多处理,注意只描述MIMD多处理的SPMD变体,因为SPMD是最常见的方法。

19.7.4 MIMD多处理器

现在让我们更深入地研究基于强耦合共享内存的MIMD机器,首先从软件的角度来看它们,从软件的角度制定了这些机器的广泛规格之后,可以继续对硬件的设计进行简要概述。请注意,并行MIMD机器的设计可能需要一整本书来描述。

将共享内存MIMD机器的软件接口称为逻辑角度(logical point of view),并将多处理器的实际物理设计称为物理角度(physical point of view)。当描述逻辑角度时,主要关心的是多处理器相对于软件的行为,硬件对其行为有什么保证,软件可以期待什么,包括正确性、性能,甚至是故障恢复能力。物理角度与多处理器的实际设计有关,包括处理器、存储系统和互连网络的物理设计。请注意,物理角度必须符合逻辑角度。此处采用了与单处理器类似的方法,首先通过查看汇编代码来解释软件视图(架构),然后通过描述流水线处理器(组织)为汇编代码提供了一个实现。

19.7.4.1 逻辑视角

下图显示了共享内存MIMD多处理器的逻辑视图。每个处理器都连接到存储代码和数据的内存系统,其程序计数器指向它正在执行的指令的位置,即在内存的代码段,此段通常是只读的,因此不受我们有多处理器这一事实的影响。

多处理器系统的逻辑视图。

实现共享内存多处理器的主要挑战是正确处理数据访问。上图显示了一种方案,其中每个计算处理器都连接到内存,并将其视为一个黑盒。如果考虑具有不同虚拟地址空间的进程系统,就没有问题。每个处理器都可以处理其数据的私有副本,由于内存占用实际上是不相交的,可以很容易地在这个系统中运行一组并行进程。然而,当研究具有多个线程的共享内存程序,并且存在跨线程的数据共享时,主要的复杂性就出现了。请注意,我们还可以通过将不同的虚拟页面映射到同一物理帧来跨进程共享内存,把这种情况视为并行多线程软件的一种特殊情况。

一组并行线程通常共享其虚拟和物理地址空间,但线程也有私有数据,这些数据保存在它们的堆栈中。有两种方法可以实现不相交的堆栈。第一,所有线程都可以有相同的虚拟地址空间,不同的堆栈指针可以从虚拟地址空间中的不同点开始,需要进一步确保线程堆栈的大小不足以与另一个线程的堆栈重叠。另一种方法是将不同线程的虚拟地址空间的堆栈部分映射到不同的内存帧,每个线程可以在其页面表中为堆栈部分有不同的条目,但对于虚拟地址空间的其余部分(如代码、只读数据、常量和堆变量)有共同的条目。

在任何情况下,并行软件复杂性的主要问题都不是因为代码是只读的,也不是因为线程之间不共享的局部变量,主要问题是由于数据值可能在多个线程之间共享。这就是并行程序的强大之处,也使它们变得非常复杂。在前面展示的并行添加一组数字的示例中,我们可以清楚地看到通过共享内存共享值和计算结果所获得的优势。

然而,跨线程共享值并不是那么简单,是一个相当深刻的话题,本文简要地看一下其中的两个重要主题,即连贯性(coherence)内存一致性(memory consistency)。当在缓存上下文中提到一致性时,一致性也称为缓存一致性。然而,一致性不仅仅限于缓存,它是一个通用术语。

一致性

内存系统中的一致性是指多个线程访问同一位置的方式。当多个线程访问同一内存位置时,许多不同的行为都是可能的,有些行为直觉上是错误的,但也有可能。在研究一致性之前,需要注意,在内存系统中,有许多不同的实体,如缓存、写入缓冲区和不同类型的临时缓冲区。处理器通常将值写入临时缓冲区,然后恢复其操作。内存系统的工作是将数据从这些缓冲区传输到缓存子系统中的某个位置。因此,在内部,给定的内存地址可能在给定的时间点与许多不同的物理位置相关联。其次,将数据从处理器传输到存储器系统中的正确位置(通常是缓存块)的过程不是瞬时的,内存读取或写入请求有时需要超过几十个周期才能到达其位置。如果内存流量很大,这些内存请求消息可能会等待更长时间,消息也可以与之后发送的其他消息重新排序。

让我们假设内存对于所有处理器来说都像一个大的字节数组,尽管在内部,它是一个由不同组件组成的复杂网络,这些组件努力为读/写操作提供简单的逻辑抽象。多处理器内存系统的内部复杂性导致了访问同一组共享变量的程序的几种有趣行为。

让我们考虑一组示例。在每个示例中,所有共享值都被初始化为0,所有局部变量都以t开头,如t1、t2和t3。假设线程1写入跨线程共享的变量x,紧接着,线程2尝试读取其值。

// Thread 1:
x = 1

// Thread 2:
t1 = x

线程2是否保证读取1?或者,它可以得到以前的值0吗?如果线程2在2 ns甚至10 ns后读取x的值,该怎么办?一个线程中的写入传播到其他线程所需的时间是多少?这些问题的答案取决于内存系统的实现。如果内存系统有快速总线和快速缓存,那么写操作可以很快地传播到其他线程。但是,如果总线和缓存很慢,那么其他线程可能需要更多时间才能看到对共享变量的写入。

现在,把这个例子进一步复杂化,假设线程1写入x两次:

// Thread 1:
x = 1
x = 2
    
// Thread 2:
t1 = x
t2 = x

现在让我们看看一系列可能的结果:(t1,t2)=(1,2)、(t1,t2) = (0,1)都是可能的,当t1在线程1启动之前写入,而t2在线程1的rst语句完成之后写入时,这是可能的。同样,可以系统地列举所有可能结果的集合,这些结果是:(0,0)、(0,1)、(0,2)、(1,1)、(1,2)和(2,2)。有趣的问题是,结果(2,1)是否可能?如果对x的第一次写入在内存系统中被延迟,而第二次写入超过了它,这也许是可能的,但问题是我们是否应该允许这种行为。

答案是否定的。如果我们允许这种行为,那么实现多处理器内存系统无疑会变得更简单,但编写和推理并行程序将变得非常困难。因此,大多数多处理器系统都不允许这种行为。

现在稍微正式地看看多个线程访问同一内存位置的问题。我们理想地希望内存系统是连贯的,意味着在处理对同一内存地址的不同访问时,它应该遵守一组规则,以便更容易编写程序。

存储器访问同一内存地址的行为称为一致性(coherence)

通常,一致性有两个公理:

  • 完成(Completion):写入必须最终完成。此公理表示,内存系统中永远不会丢失任何写入,例如不可能将值10写入变量x,而写入请求会被内存系统丢弃,它需要到达x对应的内存位置,然后需要更新其值,稍后可能会被另一个写入请求覆盖。然而,底线是写请求需要在将来的某个时间点更新内存位置。
  • 顺序(Order):对同一内存地址的所有写入都需要被所有线程以相同的顺序看到。此公理表示,所有线程都认为对内存位置的所有写入顺序相同,意味着不可能读取上面案例中的(2,1),因为线程1知道2是在1之后写入到存储位置x的,根据顺序公理,所有其他线程都需要感知到写入x的相同顺序,它们对x的感知不能与线程1的感知不同,因此,它们不能在1之后读取2。一致性的公理具有直观的意义,基本上意味着所有的写入最终都会完成,单处理器系统也是如此。其次,所有处理器都看到单个内存位置的相同视图,如果其值从0变为1变为2,则所有处理器都会看到相同的变化顺序(0-1-2),没有处理器以不同的顺序看到更新。这进一步意味着,无论内存系统如何在内部实现,在外部,每个内存位置都被视为可全局访问的单个位置。

内存一致性

一致性是指对同一内存位置的访问,如何访问不同的存储位置?可用一系列例子来解释。

// Thread 1:
x = 1;
y = 1;
    
// Thread 2:
t1 = y;
t2 = x;

现在从直观的角度来看t1和t2的允许值,总是可以获得(t1,t2)=(0,0),当线程2在线程1之前调度时,可能会发生这种情况。还可能获得(t1,t2)=(1,1),当线程2在线程1完成后调度时,会发生这种情况。同样,可以读取(t1,t2)=(0,1)。下图显示了如何获得所有三种结果。

所有可能结果的示意图。

有趣的问题是(t1,t2)=(1,0)是否被允许?当对x的写入被内存系统以某种方式延迟,而对y的写入很快完成时,就会发生这种情况。在这种情况下,t1将获得y的更新值,t2将获得x的旧值。是否允许这种行为?很明显,如果允许这种行为,就很难对软件和并行算法的正确性进行推理,编程也将变得困难。然而,如果允许这种行为,那么硬件设计就会变得更简单,因为不必为软件提供强有力的保证。

答案显然没有对错之分?完全取决于我们想要如何编程软件,以及硬件设计师想要为软件编写人员构建什么。但是,这个例子仍然有一些非常深刻的东西,(t1,t2)=(1,0)的特例。为了找出原因,再次查看上图,我们已经能够通过在两个线程的指令之间创建交错来推理三个结果。在这些交错中,同一线程中的指令顺序与程序中指定的顺序相同,称为程序顺序(program order)

与每个组成线程的控制流语义一致的指令顺序(可能属于多个线程)称为程序顺序(program order)。线程的控制流语义被定义为一组规则,用于确定在给定指令之后可以执行哪些指令,例如,单周期处理器执行的指令集总是按程序顺序执行。

很明显,我们不能通过按程序顺序交错线程来生成结果(t1,t2)=(1,0)。

如果我们能从可能的输出集合中排除输出(1,0),那就好了,将允许编写并行软件,很容易地预测可能的结果。确定并行程序可能结果集的内存系统模型称为内存模型(memory model)

确定并行程序可能结果集的内存系统模型称为内存模型(memory model)

顺序一致性(Sequential Consistency)

我们可以有不同类型的内存模型,对应于不同类型的处理器,最重要的内存模型之一是顺序一致性(Sequential Consistency,SC)。顺序一致性表示,只允许通过按程序顺序交错线程生成那些结果,意味着上图所示的所有结果都是允许的,因为它们是通过以所有可能的方式交错线程1和线程2生成的,而不会违反它们的程序顺序。然而,结果(t1,t2)=(1,0)是不允许的,因为它违反了程序顺序,在顺序一致的内存模型中是不允许的。请注意,一旦我们按照程序顺序交错多个线程,就等于说我们有一个处理器在一个周期中执行一个线程的指令,可能在下一个周期执行另一个其他线程的指令。因此,处理多个线程的单处理器产生SC执行。事实上,如果我们考虑模型的名称,“sequential”一词来源于这样一个概念,即执行等同于单处理器以某种顺序顺序执行所有线程的指令。

如果一组并行线程的执行结果等同于单个处理器以某种顺序执行来自所有线程的指令的结果,则内存模型是顺序一致的。或者,可以将序列一致性定义为一个内存模型,其一组可能的结果是可以通过按程序顺序交错一组线程来生成的结果。

序列一致性是一个非常重要的概念,在计算机体系结构和分布式系统领域得到了广泛的研究。它通过将并行系统上的执行等同于顺序系统上的运行,将并行系统简化为具有一个处理器的串行系统。需要注意的一点是,SC并不意味着一组并行程序的执行结果始终相同,取决于线程的交错方式以及线程到达的时间,但某些结果是不允许的。

弱一致性(Weak Consistency)

SC的实施是有代价的,使软件变得简单,但使硬件变得非常慢。为了支持SC,通常需要等待读取或写入完成,然后才能将下一次读取或写入发送到内存系统。当任何处理器的所有后续读取都将获得W已写入的值或稍后写入同一位置的值时,写入请求W完成。读取数据后,读取请求完成,而最初写入数据的写入请求完成。

这些要求/限制成为高性能系统的瓶颈,因此计算机架构社区已经转向违反SC的弱内存模型。弱内存模型将允许以下多线程代码段中的结果(t1,t2)=(1,0)。

// Thread 1:
x = 1
y = 1

// Thread 2:
t1 = y
t2 = x

弱一致性(weakly consistent ,WC)内存模型不符合SC,通常允许任意内存排序。

弱内存模型有不同的类型,一个通用的变体是弱一致性(WC)。现在尝试找出为什么WC允许(1,0)结果,假设线程1在核心1上运行,线程2在核心2上运行。此外,假设对应于x的内存位置在核心2附近,对应于y的内存位置位于核心1附近。还假设从核心1附近向核心2发送请求需要数十个周期,并且延迟是可变的。

首先研究核心1的流水线的行为。从核心1流水线的角度来看,一旦将内存写入请求移交给内存系统,则认为内存写入指令已完成,指令进入RW阶段。因此,在这种情况下,处理器将在第-n个周期中将对x的写入移交给内存系统,然后在第(n+1)个周期中将写入传递给y。对y的写入将很快到达y的内存位置,而对x的写入将需要很长时间。

同时,核心2将尝试读取y的值。假设读取请求在写入请求(到y)到达y之后到达y的内存位置,将得到y的新值,该值等于1。随后,核心2将对x发出读操作,对x的读操作可能在对x的写操作到达x之前到达x的内存位置。在这种情况下,它将获取x的旧值,即0。因此,结果(1,0)在弱内存模型中是可能的。

为了避免这种情况,我们可以等待对x的写入完全完成,然后再向y发出写入请求,这样做虽然是正确的,但是一般来说,当我们写入共享内存位置时,其他线程不会在完全相同的时间点读取它们。我们无法在运行时区分这两种情况,因为处理器之间不共享它们的内存访问模式。为了提高性能,将每个内存请求延迟到前一个内存请求完成是不值得的。因此,高性能实现更喜欢允许来自同一线程的内存访问由内存系统重新排序的内存模型。我们将在后续小节中研究避免(1,0)结果的方法。

大多数处理器都假定内存请求在离开管线后的某个时间点瞬间完成,此外,所有线程都假定内存请求在完全相同的时间点瞬间完成。内存请求的这个属性称为原子性(atomicity)。其次,需要注意,内存请求的完成顺序可能与它们的程序顺序不同。当完成顺序与每个线程的程序顺序相同时,内存模型遵循SC,如果完成顺序与程序顺序不同,则内存模型是WC的变体。

当内存请求在发出后的某个时间点被所有线程感知为瞬时执行时,称其为原子的(atomic)观察原子性(observe atomicity)

准确地说,对于每个内存请求,都有三个感兴趣的事件,即开始、结束和完成。让我们考虑一个写请求。当指令将请求发送到MA阶段的L1缓存时,请求开始。当指令移动到RW阶段时,请求完成。在现代处理器中,无法保证在内存请求完成时写入会到达目标内存位置,写入请求到达内存位置且写入对所有处理器可见的时间点称为完成时间。在简单的处理器中,完成请求的时间介于开始时间和结束时间之间。然而,在高性能处理器中,情况并非如此。此概念如下图所示。

读请求怎么样?大多数人会天真地认为读取的完成时间介于开始时间和结束时间之间,因为它需要返回内存位置的值。然而,这并不完全正确,因为读取可能会返回尚未完成的写入的值。在要求写入原子性(写入瞬间完成的错觉)的内存模型中,只有当相应的写入请求完成时,读取才完成。所有假定写原子性的内存一致性模型都是使用内存访问完成顺序的属性来定义的。

在弱内存模型中,不遵循同一线程中独立内存操作之间的顺序。例如,当我们写到x,然后写到y时,线程2发现它们的顺序相反。然而,属于同一线程的从属内存指令的操作顺序始终受到遵循。例如,如果将变量x的值设置为1,然后在同一线程中读取它,我们将得到1或稍后写入x的值,所有其他线程都会感知内存请求的顺序相同。在由同一线程进行的从属内存访问之间,绝不存在任何内存顺序冲突(参见下图)。

多线程程序中内存请求的实际完成时间。

现在说明使用不遵守任何顺序规则的弱内存模型的困难。假设一个顺序一致的系统,让我们编写并行加法程序。请注意,不使用OpenMP,因为OpenMP在幕后做了很多工作,以确保程序在内存模型较弱的机器上正确运行。让我们定义一个并行构造,它并行运行一个代码块,以及一个getThreadId()函数,它返回线程的标识符,线程id的范围是从0到N-1。并行加法函数的代码如下所示。假设在并行部分开始之前,所有数组都被初始化为0,在并行部分中,每个线程将其部分数字相加,并将结果写入数组中相应的条目partialSums。完成后,它将完成数组中的条目设置为1。

/* variable declaration */
int partialSums[N];
int finished[N];
int numbers[SIZE];
int result = 0;
int doneInit = 0;

/* initialise all the elements in partialSums and finished to 0 */
(...)
doneInit = 1;

/* parallel section */
parallel 
{
    /* wait till initialisation */
    while (!doneInit()){};
    
    /* compute the partial sum */
    int myId = getThreadId();
    int startIdx = myId * SIZE/N;
    int endIdx = startIdx + SIZE/N;
    for(int jdx = startIdx; jdx < endIdx; jdx++)
        partialSums[myId] += numbers[jdx];
    
    /* set an entry in the finished array */
    finished[myId] = 1;
}

/* wait till all the threads are done */
do 
{
    flag = 1;
    for (int i=0; i < N; i++)
    {
        if(finished[i] == 0)
        {
            flag = 0;
            break;
        }
    }
} while (flag == 0);

/* compute the final result */
for(int idx=0; idx < N; idx++)
    result += partialSums[idx];

现在阐述需要聚合结果的线程,它需要等待所有线程完成计算部分和的工作,通过等待完成的数组中的所有条目都等于1来实现这一点。一旦确定完成的数组的所有条目均等于1,它就继续将所有部分和相加,以获得最终结果。可以很容易验证,如果假设一个顺序一致的系统,那么这段代码会正确执行。她需要注意的是,只有当读取数组中的所有条目完成为1时,才计算结果。如果计算部分和并写入partialSums数组,则完成数组中的条目等于1。由于我们添加了partialSums数组的元素来计算最终结果,因此可以得出结论,它是正确计算的。

现在考虑一个弱内存模型,在上面的示例中以顺序一致性隐式假设,当最后一个线程读取finished[i]为1时,partialSums[i]包含部分和的值。然而,如果假设弱内存模型,则此假设不成立,因为内存系统可能会将写入重新排序为finished[i]和partialSums[i]。因此,在具有弱内存模型的系统中,写入完成的数组可能发生在写入partialSums数组之前。在这种情况下,finished[i]等于1的事实并不保证partialSums[i]包含更新的值。这种区别正是顺序一致性对程序员非常友好的原因。

在弱内存模型中,同一线程发出的内存访问总是被该线程认为是按程序顺序进行的。但是,其它线程可以不同地感知内存访问的顺序。

回到确保并行加法示例正确运行的问题上。摆脱困境的唯一方法是有一种机制,确保在另一个线程读取完成[i]为1之前完成对partialSums[i]的写入。我们可以使用一种称为栅栏(fence)的通用指令,此指令确保在栅栏开始后的任何读取或写入之前完成栅栏之前发出的所有读取和写入。简单地说,我们可以通过在每条指令后插入栅栏,将弱内存模型转换为顺序一致的模型。然而,这可能会导致大量开销,最好在需要时引入最少数量的栅栏指令。下面通过添加围栏指令,为弱内存模型并行添加一组数字。

/* variable declaration */
int partialSums[N];
int finished[N];
int numbers[SIZE];
int result = 0;

/* initialise all the elements in partialSums and finished to 0 */
(...)
    
/* fence */
/* 确保并行部分可以读取初始化的数组 */
fence();

/* All the data is present in all the arrays at this point */
/* parallel section */
parallel 
{
    /* get the current thread id */
    int myId = getThreadId();
    
    /* compute the partial sum */
    int startIdx = myId * SIZE/N;
    int endIdx = startIdx + SIZE/N;
    for(int jdx = startIdx; jdx < endIdx; jdx++)
        partialSums[myId] += numbers[jdx];
    
    /* fence */
    /* 确保在partialSums[i]之后写入finished[i] */
    fence();
    
    /* set the value of done */
    finished[myId] = 1;
}

/* wait till all the threads are done */
do 
{
    flag = 1;
    for (int i=0; i < N; i++)
    {
        if(finished[i] == 0)
        {
            flag = 0;
            break;
        }
    }
} while (flag == 0) ;

/* sequential section */
for(int idx=0; idx < N; idx++)
    result += partialSums[idx];

上述代码显示了弱内存模型的代码,代码与顺序一致内存模型的代码大致相同,唯一的区别是我们增加了两个额外的栅栏指令。我们假设一个名为fence()的函数在内部调用fence指令,在调用所有并行线程之前,首先调用fence(),确保初始化数据结构的所有写入都已完成。随后开始并行线程,并行线程完成计算和写入部分和的过程,然后再次调用fence操作,以确保在完成[myId]设置为1之前,所有部分和都已计算并写入内存中各自的位置。其次,如果最后一个线程读取finished[i]为1,就可以确定partialSums[i]的值是最新的并且正确的。因此,尽管内存模型较弱,该程序仍能正确执行。

因此,如果程序员意识到弱内存模型并在正确的位置插入栅栏,那么弱内存模型不会影响正确性。尽管如此,程序员有必要理解弱内存模型,否则,会因为程序员没有考虑底层内存模型,导致并行程序中会出现很多细微的错误。弱内存模型目前被大多数处理器使用,因为它们允许我们构建高性能内存系统。相比之下,顺序一致性非常有限,除了MIPS R10000,没有其他主要供应商提供具有顺序一致性的机器,目前所有基于x86和ARM的机器都使用不同版本的弱内存模型

19.7.4.2 物理视角

我们研究了多处理器内存系统逻辑视图的两个重要方面,即连贯性和一致性,需要实现一个兼顾这两个属性的内存系统。本节将研究多处理器内存系统的设计空间,并提供设计备选方案的概述。为多处理器存储器系统设计高速缓存有两种方法:第一种设计称为共享缓存,其中单个缓存在多个处理器之间共享。第二种设计使用一组专用缓存,其中每个处理器或一组处理器通常都有一个专用缓存。所有的私有缓存协作提供共享缓存的错觉,这就是所谓的缓存一致性(cache coherence)。

本节将研究共享缓存的设计和私有缓存的设计,介绍确保内存一致性的问题,最终将得出结论,有效实现给定的一致性模型(如顺序一致性或弱一致性)是困难的,并且是高级计算机体系结构课程中的一个研究主题,本文提出了一个简单的解决方案。

多处理器内存系统:共享和私有缓存

首先考虑一级缓存。可以给每个处理器单独的指令缓存,指令表示只读数据,通常在程序执行期间不会改变。由于共享不是问题,所以每个处理器都可以从其小型专用指令缓存中受益,主要问题是数据缓存。设计数据缓存有两种可能的方法,可以有共享缓存,也可以有私有缓存。共享缓存是所有处理器都可以访问的单个缓存,私有缓存只能由一个处理器或一组处理器访问。可以有共享缓存的层次结构,也可以有私有缓存的层次结构,甚至可以在同一系统中有共享和私有缓存的组合,如下图所示。

具有共享和私有缓存的系统示例。

现在评估一下共享缓存和私有缓存之间的权衡。共享缓存可供所有处理器访问,并且包含缓存内存位置的单个条目,通信协议很简单,就像任何常规缓存访问一样。额外的复杂性主要是因为我们需要正确地调度来自不同处理器的请求。然而,以简单为代价,共享缓存也有其问题,为了服务来自所有处理器的请求,共享缓存需要有大量的读写端口来同时处理请求。不幸的是,缓存的大小大约是端口数的平方。此外,共享缓存需要容纳当前运行的所有线程的工作集,因此,共享缓存往往变得非常大和缓慢。由于物理限制,很难在所有处理器附近放置共享缓存。相比之下,私有缓存通常要小得多,服务请求的核心更少,读/写端口数量更少。因此,它们可以放置在与其关联的处理器附近。因此,私有缓存的速度要快得多,因为它可以放在离处理器更近的地方,而且大小也要小得多。

为了解决共享缓存的问题,设计者经常使用私有缓存,尤其是在内存层次结构的更高层。私有缓存只能由一个处理器或一小组处理器访问,它们体积小,速度快,耗电量小。私有缓存的主要问题是它们需要为程序员提供共享缓存的假象,例如,一个具有两个处理器的系统,以及与每个处理器关联的专用数据缓存。如果一个处理器写入内存地址x,则另一个处理器需要知道该写入。然而,如果它只访问其私有缓存,那么它将永远不会知道写入地址x,意味着写入地址x丢失,因此系统不一致。因此,需要绑定所有处理器的私有缓存,使它们看起来像一个统一的共享缓存,并遵守一致性规则。缓存上下文中的一致性通常称为缓存一致性(cache coherence)。保持缓存一致性是私有缓存的另一个复杂性来源,并限制了其可扩展性。它适用于小型私人缓存,然而,对于更大的私有缓存,维护一致性的开销变得令人望而却步。对于大型低级别缓存,共享缓存更合适。其次,通常会跨多个私有缓存进行一些数据复制,但会浪费空间。

一组私有缓存上下文中的一致性称为缓存一致性(cache coherence)

通过实现缓存一致性协议,可以将一组不相交的私有缓存转换为软件共享缓存。下表概述共享缓存和私有缓存之间的主要权衡。

属性 私有缓存 共享缓存
面积
速度
接近处理器
尺寸扩展性
数据复制
复杂度 高(需缓存一致性)

从表中可以清楚地看出,一级缓存最好是私有的,因为可获得低延迟和高吞吐量。然而,较低级别需要更大的尺寸,并且服务的请求数量要少得多,因此它们应该包括共享缓存。接下来描述一致的私有缓存和大型共享缓存的设计。为了简单起见,只考虑单层私有缓存,而不考虑分层私有缓存,它们会引入额外的复杂性。先讨论共享缓存的设计,因为它们更简单。

19.7.4.3 共享缓存

在共享缓存的最简单实现案例中,可以将其实现为单处理器中的常规缓存,但在实践中它被证明是一种非常糟糕的方法,原因是在单处理器中,只有一个线程访问缓存;然而在多处理器中,多个线程可能会访问缓存,因此我们需要提供更多的带宽。如果所有线程都需要访问相同的数据和标记数组,那么要么请求必须暂停,要么必须增加数组中的端口数,导致面积和功率产生非常负面的后果。最后,根据摩尔定律,缓存大小(尤其是L2和L3)大致加倍,如今片上缓存的大小可达4-16 MB甚至更多。如果对整个缓存使用单个标签数组,那么它将非常大且速度很慢。术语最后一级缓存(last level cache,LLC)定义为在内存层次结构中位置最低的片上缓存(主内存最低),例如,如果多核处理器有一个连接到主内存的片上L3高速缓存,那么LLC就是L3高速缓冲内存。后面会经常使用术语LLC。

要创建一个可以同时支持多个线程的多兆字节LLC,需要将其拆分为多个子缓存。假设有一个4 MB的LLC,在一个典型的设计中,它将被分成8-16个更小的子缓存(subcache),每个子缓存的大小为256-512 KB,这是可接受的大小。每个子缓存本身就是一个缓存,称为缓存库(cache bank)。因此,实际上将一个大型缓存拆分为一组缓存库,缓存库可以是直接映射的,也可以设置为关联的。访问多库缓存有两个步骤:首先计算库地址,然后在库执行常规缓存访问。用一个例子来解释,考虑一个16组、4 MB的缓存,每个库包含256KB的数据,4 MB=\(2^{22}\)字节,可以将位19-22专用于选择存地址。注意,在这种情况下,库选择与关联性无关。选择一个库后,可以在块内的偏移量、集合索引和标签之间分割剩余的28位。

将缓存划分为多个库有两个优点。第一,减少了每个库的争用量。如果我们有4个线程和16个库,那么2个线程访问同一库的概率很低。其次,由于每个库都是一个较小的缓存,因此它更省电、更快。因此,我们实现了支持多线程和设计快速缓存的双重目标。

19.7.4.4 私有缓存

我们的目的是使一组私有缓存的行为就像是一个大型共享缓存,从软件的角度来看,我们不应该知道缓存是私有的还是共享的。系统的概念图如下图所示,它显示了一组处理器及其相关缓存,这组缓存形成一个缓存组,整个缓存组需要显示为一个缓存。

具有许多处理器及其私有缓存的系统。其中左侧是软件视角,而右侧是硬件视角。

这些缓存通过内部网络连接,内部网络可以从简单的共享总线类型拓扑到更复杂的拓扑。假设所有缓存都连接到共享总线,共享总线允许在任何时间点使用单个写入器和多个读取器。如果一个缓存将消息写入总线,那么所有其他缓存都可以读取该消息。拓扑结构如下图所示。请注意,总线在写入消息的任何时间点只提供对一个缓存的独占访问,因此所有缓存都感知到相同的消息顺序。一种与连接在共享总线上的缓存实现缓存一致性的协议称为监听协议(snoopy protocol)

ng)

与共享总线连接的缓存。

现在让我们从一致性的两个公理的角度来考虑史努比协议的操作:写入总是完成(完成公理),并且所有处理器都以相同的顺序看到对同一块的写入(顺序公理)。如果缓存i希望对一个块执行写入操作,那么该写入需要最终对所有其他缓存可见。我们需要这样做来满足完成公理,因为不允许丢失写请求。其次,对同一块的不同写入需要以相同的顺序到达可能包含该块的所有缓存(顺序公理),以确保对于任何给定的块,所有缓存感知到相同的更新顺序。共享总线自动满足顺序公理的要求。

下面给出两个监听协议的设计:写更新(write-update)和写无效(write-invalidate)。

写更新协议

现在让我们设计一个协议,假设一个私有缓存保存一个写请求的副本,并将写请求广播到所有缓存。此策略确保写入永远不会丢失,并且所有缓存都以相同的顺序感知到同一块的写入消息。此策略要求无论何时写入都要广播,是一个很大的额外开销,然而,这一策略依然奏效。

现在将读取纳入协议。对位置x的读取可以首先检查私有缓存,以查看其副本是否已经可用。如果有效副本可用,则可以将该值转发给请求处理器。但是,如果存在缓存未命中,那么它可能与缓存组中的另一个姐妹缓存一起存在,或者可能需要从较低级别获取。首先检查该值是否存在于姐妹缓存中,此处遵循相同的流程,缓存向所有缓存广播读取请求,如果任何一个缓存具有该值,则它会进行回复,并将该值发送到请求缓存。请求缓存插入该值,并将其转发给处理器。但是,如果它没有从任何其他缓存获得任何回复,那么它将启动对较低级别的读取。

该协议称为写更新(write-update)协议。每个缓存块需要保持三种状态:M、S和I。M表示修改后的状态,表示缓存已经修改了块,S(共享)表示缓存未修改块,I(无效)表示块不包含有效数据。

下图显示了每个缓存块的有限状态机(FSM),该FSM由高速缓存控制器执行,状态转换的格式是:事件/动作。如果缓存控制器被发送了一个事件,那么它会采取相应的动作,可能包括状态转换。请注意,在某些情况下,动作字段为空,意味着在这些情况下,不采取任何行动。请注意,缓存块的状态是其在标记数组中的条目的一部分,如果缓存中不存在块,则其状态被假定为无效(I)。值得一提的是,下图显示了处理器生成的事件的转换,它不显示缓存组中其他缓存通过总线发送的事件的操作。

写更新协议中的状态转换图。

所有块最初都处于I状态。如果存在读取未命中,则它将移动到S状态,还需要向缓存组中的所有缓存广播读未命中,并从姊妹缓存或较低级别获取值。请注意,我们首先优先考虑姊妹缓存,因为它可能修改了块而没有将其写回较低级别。类似地,如果在I状态中存在写入未命中,那么需要从另一个姊妹缓存中读取块(如果它可用),并移动到M状态。如果没有其他姐妹缓存具有该块,那么需要从内存层次结构的较低级别读取该块。

如果在S状态下有读取命中,就可以无缝地将数据传递给处理器。但如果要写入S状态的块,就需要将写入广播到所有其他缓存,以便它们获得更新的值。一旦缓存从总线获取了其写入请求的副本,它就可以将值写入块,并将其状态更改为M。要将处于S状态的块逐出,只需要将其从缓存中逐出,此时没有必要写回其值,因为块尚未修改。

现在考虑M状态。如果需要读取M状态的块,那么可以从缓存中读取它,并将值发送给处理器。没有必要发送任何消息,但如果希望写入它,则需要在总线上发送写入请求。一旦缓存看到自己的写入请求到达共享总线,它就可以将其值写入其专用缓存中的内存位置。要回收M状态的块,需要将其写回内存层次结构中的较低级别,因为它已被修改。

每个总线都有一个称为仲裁器(arbiter)的专用结构,它接收来自不同缓存的使用总线的请求,按FIFO顺序将总线分配给缓存。总线仲裁器的示意图如下图所示,是一个非常简单的结构,包含一个在总线上传输的请求队列。每个周期它从队列中提取一个请求,并向相应的缓存授予在总线上传输消息的权限。

总线仲裁器结构。

现在考虑一个姐妹缓存。每当它从总线收到一条未命中消息时,它就会检查缓存以确定是否有该块。如果有缓存命中,它就将块发送到总线上,或直接发送到请求缓存。它如果接收到另一个缓存的写入通知,就会更新其缓存中存在的块的内容。

目录协议

请注意,在监听协议中,我们总是广播写入、读取未命中或写入未命中,实际上只需要向那些包含块副本的缓存发送消息。目录协议(directory protocol)使用称为目录的专用结构来维护此信息,对于每个块地址,目录维护一个共享者列表。共享者是可能包含该块的缓存的id,共享者列表通常是可能包含给定块的缓存的超集。我们可以将共享者列表保持为位向量(每个共享者1位),如果位为1,则缓存包含一个副本,否则不包含。

带有目录的写更新协议修改如下。缓存不是在总线上广播数据,而是将其所有消息发送到目录。对于读或写未命中,目录从姊妹缓存中获取块(如果它有副本),然后它将块转发到请求缓存。类似地,对于写入,目录只将写入消息发送到那些可能有块副本的缓存,当缓存插入或回收块时,需要更新共享者列表。最后,为了保持一致性,目录需要确保所有缓存以相同的顺序获取消息,并且不会丢失任何消息。目录协议最大限度地减少了需要发送的消息的数量,因此更具可扩展性。

监听协议(Snoopy Protocol):在监听协议中,所有缓存都连接到共享总线。缓存将每条消息广播到其他缓存。

目录协议(Directory Protocol):在目录协议中,通过添加一个称为目录的专用结构来减少消息的数量。该目录维护可能包含块副本的缓存列表,只向列表中的缓存发送给定块地址的消息。

为什么需要等待总线的广播来执行写入?

答:让我们假设情况并非如此,处理器1希望将1写入x,处理器2希望将2写入x。然后,它们将首先分别将1和2写入x的副本,然后广播写入,因此两个处理器将以不同的顺序看到对x的写入。这违反了秩序公理。但是,如果它们等待写入请求的副本从总线到达,那么它们将以相同的顺序写入x。总线有效地解决了处理器1和2之间的冲突,并对一个请求进行排序。

写无效协议

我们需要注意的是,为每次写入广播写入请求是不必要的开销,有可能大多数块在一开始就不共享,所以不需要在每次写入时发送额外的消息。让我们尝试通过提出写无效协议来减少写更新协议中的消息数量,此处可以使用监听协议,也可以使用目录协议。下面展示一个监听协议的示例。

为每个块保持三个状态:M、S和I,但改变状态的含义:

  • 无效状态(I)保留相同含义,意味着该条目实际上不存在于缓存中。
  • 共享状态(S)意味着缓存可以读取块,但不能写入块,在共享状态下,可以在不同的缓存中拥有同一块的多个副本。由于共享状态假定块是只读的,因此具有块的多个副本不会影响缓存一致性。
  • M(已修改)状态表示缓存可以写入块。如果一个块处于M状态,那么缓存组中的所有其他缓存都需要使该块处于I状态。不允许任何其他缓存具有S或M状态的块的有效副本,这就是写无效协议不同于写更新协议的地方。它一次只允许一个写入,或者一次允许多个读取,绝不允许读和写同时共存。通过限制在任何时间点对块具有写访问权限的缓存数量,可以减少消息的数量。

内部机制如下。写更新协议不必在读命中时发送任何消息,所以当写命中时发送了额外的消息,我们希望消除之。它需要发送额外的消息,因为多个缓存可以同时读取或写入一个块。写无效协议已经消除了这种行为,如果一个块处于M状态,那么没有其他缓存包含该块的有效副本。

下图显示了由于处理器的动作而导致的状态转换图,状态转换图与写更新协议的状态转换图基本相同。让我们看看差异。第一种是,我们定义了三种类型的消息放在总线上,即写入、写入未命中和读取未命中。当从I状态转换到S状态时,将读取未命中放在总线中。如果姊妹高速缓存没有回复数据,则高速缓存控制器从较低级别读取块。S状态的语义保持不变,要写入S状态的块,我们需要在总线上写入写入消息后转换到M状态。现在,当一个块处于M状态时,可以确信没有其他缓存包含有效副本,可以自由地读写M状态的块,没有必要在总线上发送任何信息。如果处理器决定将M状态的块逐出,则需要将其数据写入较低级别。

由于处理器的动作导致的块的状态转换图。

下图显示了由于总线上接收到的消息而导致的状态转换。在S状态下,如果我们得到一个读未命中,那么这意味着另一个缓存想要对该块进行读访问。包含该块的任何缓存都会将该块的内容发送给它。这个过程可以按如下方式编排。所有具有块副本的缓存都试图访问总线。访问总线的rst缓存将块的副本发送到请求缓存。其余的缓存立即知道块的内容已被传输。他们随后停止了尝试。如果我们在S状态下收到写入或写入未命中消息,那么块将转换到I状态。

现在让我们考虑M状态。如果某个其他缓存发送写入未命中消息,则包含该块的缓存的缓存控制器将向其发送块的内容,并转换为I状态。但是,如果发生读取未命中,则需要执行一系列步骤,假设可以无缝地回收处于S状态的块,因此,有必要在移动到S状态之前将数据写入较低级别。随后,原本具有块的高速缓存也将块的内容发送到请求高速缓存,并将块的状态转换为S状态。

由于总线上的消息导致的块状态转换图。

使用目录的写无效协议

使用目录实现写无效协议相当简单。状态转换图几乎保持不变,没有广播消息,而是将其发送到目录,目录将消息发送给块的共享者。

现在阐述块的生命周期。每当从较低级别引入块时,都会初始化一个目录条目,它只有一个共享者,是从较低级别带来它的缓存。现在,如果块中存在读取未命中,则目录会继续添加共享程序。但如果存在写入未命中,或者处理器决定写入块,则会向目录发送写入或写入未命中消息。该目录清理共享者列表,并只保留一个共享者,即执行写访问的处理器。当一个块被逐出时,它的缓存会通知目录,目录会删除一个共享程序。当共享者集变空时,可以删除目录条目。

可以通过添加一个称为独占(Exclusive,E)状态的附加状态来改进写无效和更新协议,E状态可以是从存储器层次结构的较低级别获取的每个缓存块的初始状态,此状态存储块独占地属于缓存的事实。但是,缓存对其具有只读访问权限,而没有写访问权限。对于E到M的转换,不必在总线上发送写未命中或写消息,因为块只由一个缓存拥有。如果需要,可以无缝地将数据从E状态中逐出。

19.7.4.5 MESI协议

为了在SMP上提供缓存一致性,数据缓存通常支持称为MESI的协议。对于MESI,数据缓存包含每个标记的两个状态位,因此每行可以处于四种状态之一:

  • 已修改(Modified,M):缓存中的行已被修改(与主内存不同),仅在此缓存中可用。
  • 独占(Exclusive,E):缓存中的行与主内存中的行相同,不存在于任何其他缓存中。
  • 共享(Shared,S):缓存中的行与主内存中的行相同,可能存在于另一个缓存中。
  • 无效(Invalid,I):缓存中的行不包含有效数据。

下表总结了四种状态的含义。

M
Modified
E
Exclusive
S
Shared
I
Invalid
此缓存行有效吗?
内存副本是… 过期 有效 有效 -
副本是否存在于其他缓存中? 可能 可能
一个写入到此行… 不进入总线 不进入总线 进入总线且更新缓存 直接进入总线

下图显示了MESI协议的状态图,缓存的每一行都有自己的状态位,因此状态图也有自己的实现。图a显示了由于连接到此缓存的处理器启动的操作而发生的转换,图b显示了由于在公共总线上窥探的事件而发生的转换。处理器启动和总线启动动作的单独状态图有助于阐明MESI协议的逻辑。任何时候,缓存行都处于单一状态,如果下一个事件来自所连接的处理器,则转换由图a指示,如果下一事件来自总线,则转换则由图b指示。

MESI状态转换图。

读未命中(read miss):当本地缓存中发生读未命中时,处理器启动内存读取以读取包含丢失地址的主内存行。处理器在总线上插入一个信号,提醒所有其他处理器/缓存单元窥探事务。有许多可能的结果:

  • 如果另一个缓存具有独占状态的行的干净(自内存读取以来未修改)副本,则它将返回一个信号,指示它共享该行。然后,响应处理器将其副本的状态从独占转换为共享,启动处理器从主内存读取该行,并将其缓存中的行从无效转换为共享。
  • 如果一个或多个缓存具有处于共享状态的行的干净副本,则每个缓存都会发出共享该行的信号。启动处理器读取该行并将其缓存中的行从无效转换为共享。
  • 如果另一个缓存具有该行的修改副本,则该缓存将阻止内存读取,并通过共享总线将该行提供给请求缓存,然后响应缓存将其行从修改更改为共享。发送到请求缓存的行也由内存控制器接收和处理,该控制器将块存储在内存中。
  • 如果没有其他缓存具有该行的副本(清除或修改),则不会返回任何信号。启动处理器读取该行并将其缓存中的行从无效转换为独占。

读命中(read hit):当读命中发生在本地缓存中的当前行上时,处理器只需读取所需的项。没有状态更改:状态保持修改、共享或独占状态。

写未命中(write miss):当本地缓存中发生写未命中时,处理器启动内存读取以读取包含丢失地址的主内存行。为此,处理器在总线上发出一个信号,表示读取意图修改(read-with-intent-to-modify,RWITM)。加载该行后,将立即标记为已修改。对于其他缓存,加载数据行之前有两种可能的情况:

  • 首先,某些其他缓存可能具有该行的已修改副本(状态=修改)。在这种情况下,报警处理器向启动处理器发出信号,表示另一个处理器具有该行的修改副本,发起处理器放弃总线并等待。另一个处理器获得对总线的访问权,将修改后的缓存线写回主存储器,并将缓存行的状态转换为无效(因为启动的处理器将修改此线)。随后,启动处理器将再次向RWITM的总线发出信号,然后从主存储器读取该行,修改缓存中的行,并将该行标记为修改状态。
  • 其次,没有其他缓存具有请求行的修改副本。在这种情况下,不返回任何信号,启动处理器继续读入该行并修改它。同时,如果一个或多个缓存具有处于共享状态的行的干净副本,则每个缓存都会使其行副本无效,如果一个缓存具有排他状态的行副本,则其行副本将无效。

写命中(write hit):当本地缓存中当前行发生写命中时,效果取决于本地缓存中该行的当前状态:

  • 共享:在执行更新之前,处理器必须获得该行的独占所有权,处理器在总线上发出信号,在其缓存中具有行的共享副本的每个处理器将扇区从共享转换为无效,然后发起处理器执行更新并将其行副本从共享转换为修改。
  • 独占:处理器已经对该行具有独占控制权,因此它只需执行更新并将该行的副本从独占转换为已修改。
  • 已修改:处理器已经对该行具有独占控制权,并将该行标记为已修改,因此它只需执行更新。

L1-L2缓存一致性:到目前为止,我们已经根据连接到同一总线或其他SMP互连设施的缓存之间的协作活动描述了缓存一致性协议。通常,这些缓存是L2缓存,每个处理器还具有一个L1缓存,该缓存不直接连接到总线,因此不能参与窥探协议,因此需要某种方案来维护两级缓存和SMP配置中所有缓存的数据完整性。

策略是将MESI协议(或任何缓存一致性协议)扩展到L1缓存,L1高速缓存中的每一行包括指示状态的位,目标如下:对于L2缓存及其对应的L1缓存中存在的任何行,L1行状态应跟踪L2行的状态。一种简单的方法是在L1缓存中采用直写策略;在这种情况下,写入是到L2高速缓存而不是到内存。L1直写策略强制对L2缓存的L1行进行任何修改,从而使其对其他L2缓存可见。使用L1直写策略要求L1内容必须是L2内容的子集。这反过来表明,二级缓存的关联性应等于或大于一级缓存的关联性。L1直写策略用于IBM S/390 SMP。

如果一级缓存具有回写策略,则两个缓存之间的关系更为复杂。有几种维护方法,但超出了本文的范围。

19.7.4.6 内存一致性模型

典型的内存一致性模型指定了同一线程发出的内存操作之间允许的重新排序类型。例如,在顺序一致性中,所有读/写访问都按程序顺序完成,所有其他线程也按程序顺序感知任何线程的内存访问。

让我们构建一个连贯的内存系统,并提供一定的保证。假设所有的写操作都与完成时间相关联,并在完成时立即执行,任何读取操作都不可能在完成之前获取写入的值。写操作完成后,对同一地址的所有读操作要么得到写操作写入的值,要么得到更新的写操作。由于假设一个一致性内存,所以所有处理器都会以相同的顺序看到对同一内存地址的所有写入操作。其次,每次读取操作都会返回最近完成的写入操作写入该地址的值。现在考虑处理器1向地址x发出写入请求,同时处理器2向同一地址x发出读取请求的情况。这种行为没有定义,读取可以获取并发写入操作设置的值,也可以获取先前的值。但是,如果读取操作获得了并发写入操作设置的值,那么任何处理器发出的所有后续读取都需要获得该值或更新的值。一旦完成读取内存位置的值,读取操作就完成了,生成其数据的写入也完成了。

现在,让我们设计一个多处理器,其中每个处理器在完成之前发出的所有内存请求之后发出一个内存请求,意味着在发出内存请求(读/写)之后,处理器会等待它完成,然后再发出下一个内存请求。具有这种特性的处理器的多处理器是顺序一致的。

现在概述一个简短的非正式证明,首先介绍一个称为访问图(access graph)的理论工具。

访问图

下图显示了两个线程的执行及其相关的内存访问序列。对于每个读或写访问,我们在访问图中创建一个圆或节点(见图(c))。在这种情况下,如果一个访问按程序顺序跟随另一个访问,或者如果来自不同线程的两个访问之间存在读写依赖关系,将在两个节点之间添加一个箭头(或边)。例如,如果在线程1中将x设置为5,而线程2中的读取操作读取x的这个值,那么x的读取和写入之间存在相关性,因此在访问图中添加了一个箭头,箭头表示目标请求必须在源请求之后完成。

内存访问的图形表示。

定义节点a和b之间的发生-之前(happens-before)关系,如果访问图中存在从a到b的路径。

发生-之前(happens-before)关系表示访问图中存在从a到b的路径。此关系表示b必须在a完成后完成其执行。

访问图是一种通用工具,用于对并发系统进行推理,由一组节点组成,其中每个节点都是指令的动态实例(通常是内存指令),节点之间有边节点之间有边,来自A-->B的边意味着B需要在A之后完成执行。在上图中,添加了两种边,即程序顺序边(program order edge)和因果关系边(causality edge)。程序顺序边表示同一线程中内存请求的完成顺序,当等待一条指令完成时,在执行下一条指令之前,同一线程的连续指令之间存在边。

因果边位于线程之间的加载和存储指令之间。例如,如果一条给定的指令写入一个值,而另一条指令在另一个线程中读取该值,我们将从存储到加载添加一条边。

为了证明顺序一致性,需要向访问图添加额外的边,见下面阐述。首先假设有一个圣人(一个知道一切的假设实体),由于假设一致性内存,所以对同一内存位置的所有存储都是按顺序排序的。此外,加载和存储到同一内存位置之间存在顺序。例如,如果将x设置为1,然后将x设置成3,然后读取t1=x,然后将x=5,则位置x有一个存储-存储-加载-存储的顺序。圣人知道每个内存位置的加载和存储之间的这种顺序,假设圣人将相应的发生在边之前添加到访问图中,在这种情况下,存储和加载之间的边是因果关系边,存储-存储和加载-存储边是一致性边的示例。

接下来描述如何使用访问图来证明系统的属性。首先,需要基于给定的程序运行,为给定的内存一致性模型M构建程序的访问图,基于内存访问行为添加了一致性和因果关系边。其次,基于一致性模型在同一线程中的指令之间添加程序顺序边。对于SC,在连续指令之间添加边,对于WC,在从属指令之间、常规指令之间和栅栏之间添加边。理解访问图是一个理论工具,但它通常不是一个实用工具,这点非常重要。我们将对访问图的属性进行推理,而不必为给定的程序或系统实际地构建访问图。

如果访问图不包含循环,就可以按顺序排列节点。现在来证明这一事实。在访问图中,如果存在从a到b的路径,那么让a称为b的祖先。可以通过遵循迭代过程来生成顺序,首先找到一个没有祖先的节点,必然有这样的一个节点,因为某些操作必须是第一个完成的(否则会有一个循环)。将其从访问图中删除,然后继续查找另一个没有任何祖先的节点,按照顺序添加每个这样的节点,如上图(d)所示。在每一步中,访问图中的节点数减少1,直到最后只剩下一个节点,它成为顺序中的最后一个节点。现在考虑这样一种情况,即没有在访问图中查找任何没有祖先的节点,只有在访问图中存在循环时才可能,因此正常情况下不可能。

按顺序排列节点相当于证明访问图符合其设计的内存模型。事实上,我们可以按顺序列出节点,而不违反任何happens-before关系,意味着执行等同于单处理器按顺序依次执行每个节点,正是一致性模型的定义。任何一致性模型都由内存指令之间的排序约束以及一致性假设组成,该定义还意味着,单处理器应该可以按顺序执行指令,而不违反任何happens-before关系。

这正是我们通过将访问图转换为等效的节点顺序列表所实现的。现在,程序顺序、因果关系和一致性边缘足以指定一致性模型的事实更加深刻。

因此,如果一个访问图(对于内存模型,M)不包含循环,可以得出一个给定的执行遵循M的结论。如果可以证明一个系统可以生成的所有可能的访问图都是非循环的,就可以得出整个系统遵循M的结果。

顺序一致性的证明

让我们证明,在发出后续内存请求之前等待内存请求完成的简单系统可以生成的所有可能的访问图(假设为SC)都是非循环的。考虑任意一个访问图G,我们必须证明,可以按顺序写入G中的所有内存访问,这样,如果节点b位于节点a之后,那么G中就没有从b到a的路径。换句话说,我们的顺序遵循访问图中所示的访问顺序。

假设访问图有一个循环,它包含一组属于同一线程t1的节点S,其中a是S中程序顺序中最早的节点,b是S中最晚的节点,显然a发生在b之前,因为按照程序顺序执行内存指令,在同一线程中启动下一个请求之前等待请求完成。对于由于因果边而形成的循环,b需要写入另一个内存读取请求(节点)读取的值,c属于另一个线程。或者,在b和属于另一线程的节点c之间可以存在相干边(coherence edge)。现在,为了存在一个循环,c需要发生在a之前。假设c和a之间有一个节点链,节点链中的最后一个节点是d,根据定义,\(d \notin t_{1}\),意味着d写入一个存储位置,节点a从中读取,或者有一条从d到a的相干边。因为有一条路径从节点b到节点a(通过c和d),与节点b相关联的请求必须发生在节点a的请求之前。这是不可能的,因为在节点a请求完成之前,无法执行与节点b关联的内存请求。因此,存在一个矛盾,在访问图中循环是不可能的。因此,执行在SC中。

现在澄清圣人的概念。这里的问题不是生成顺序,而是证明顺序存在,因为正在解决后一个问题,所以总是可以假设一个假设实体向访问图添加了额外的边。产生的顺序次序(sequential order)遵循每个线程的程序顺序、因果关系和基于happens-before关系的连贯性。因此,这是一个有效的顺序。

因此,可以得出结论,在我们的系统中始终可以为线程创建顺序,因此多处理器是在SC中。既然已经证明了我们的系统是顺序一致的,那么让我们描述一种用所做的假设实现多处理器的方法,可以实现如下图所示的系统。

一个简单的顺序一致的系统。

简单顺序一致机器

上图显示了一种设计,它在多处理器的所有处理器上都有一个大的共享L1缓存,每个内存位置只有一个副本,在任何单个时间只能支持一次读或写访问,确保了一致性。其次,当一次写入更改其在一级缓存中的内存位置的值时,写入完成。同样,当读取L1缓存中的内存地址值时,读取完成。我们需要修改前面章节描述的简单有序RISC流水线,以便指令仅在完成读/写访问后才离开内存访问(MA)阶段。如果存在缓存未命中,则指令等待直到块到达一级缓存,并且访问完成。这个简单的系统确保来自同一线程的内存请求按程序顺序完成,因此顺序一致。

请注意,上图描述的系统做出了一些不切实际的假设,因此不实用。如果我们有16个处理器,并且内存指令的频率是1/3,那么每个周期都需要5-6条指令访问一级缓存。因此,一级缓存需要至少6个读/写端口,使得结构太大和太慢。此外,L1缓存需要足够大以容纳所有线程的工作集,进一步使得L1缓存非常大且速度慢。因此,具有这种高速缓存的多处理器系统在实践中将非常缓慢,而现代处理器选择了更高性能的实现,其内存系统更复杂,有很多更小的缓存。这些缓存相互协作,以提供更大缓存的错觉。

很难证明一个复杂系统遵循顺序一致性(SC),设计师选择设计具有弱内存模型的系统。在这种情况下,我们需要证明栅栏指令正确工作,如果考虑复杂设计中可能出现的所有细微角落情况,也是一个相当具有挑战性的问题。

实现弱一致性模型

考虑弱一致系统的访问图,没有边来表示同一线程中节点的程序顺序。相反,对于同一线程中的节点,在常规读/写节点和栅栏操作之间有边缘,需要将因果关系和相干边添加到访问图中,就像对SC的情况所做的那样。

弱一致机器的实现需要确保该访问图没有循环。我们可以证明,下面的实现没有向访问图引入循环,确保在给定线程的程序顺序中的所有先前指令完成后,栅栏指令开始。fence指令是一条伪指令,只需要到达管线的末端,仅用于计时目的。我们在MA阶段暂停栅栏指令,直到前面的所有指令完成,该策略还确保没有后续指令到达MA阶段。一旦所有先前的指令完成,围栏指令进入RW阶段,随后的指令可以向内存发出请求。

总结一下实现内存一致性模型的内容。通过修改处理器的流水线,并确保存储器系统一旦完成对存储器请求的处理就向处理器发送确认,可以实现诸如顺序一致性或弱一致性的内存一致性模型。在高性能实现中,许多细微的角落情况是可能的,确保它们实现给定的一致性模型相当复杂。

关于内存屏障的应用和UE的实现,可参阅1.4.5 内存屏障

19.7.4.7 多线程处理器

现在看看设计多处理器的另一种方法。到目前为止,我们一直坚持需要有物理上分离的管线来创建多处理器,研究了为每个管线分配单独程序计数器的设计。然而,让我们看看在同一管线上运行一组线程的不同方法,这种方法被称为多线程(multithreading)。不在单独的管线上运行单独的线程,而是在同一管道上运行它们。通过讨论称为粗粒度多线程的最简单的多线程变体来说明这个概念。

多线程(multithreading)是一种设计范式,在同一管线上运行多个线程。

多线程处理器(multithreaded processor)是实现多线程的处理器。

粗粒度多线程

假设我们希望在单个管线上运行四个线程。属于同一进程的多个线程有各自的程序计数器、堆栈和寄存器,然而,它们对内存有着共同的视图,所有这四个线程都有各自独立的指令流,因此有必要提供一种错觉,即这四个进程是单独运行的。软件应该忽略线程在多线程处理器上运行的事实,它应该意识到每个线程都有其专用的CPU。除了传统的一致性和一致性保证之外,还需要提供一个额外的保证,即软件应该忽略多线程。

考虑一个简单的方案,如下图所示,线程1执行n个循环,然后切换到线程2并运行n个周期,然后切换至线程3,以此类推。在执行线程4 n个循环后,再次开始执行线程1。要执行线程,需要加载其状态或上下文。程序的上下文包括标志寄存器、程序计数器和一组寄存器,没有必要跟踪主内存,因为不同进程的内存区域不重叠,在多个线程的情况下,明确希望所有线程共享相同的内存空间。

可以采用更简单的方法,而不是显式地加载和卸载线程的上下文,可以在管线中保存线程的上下文,例如,如果望支持粗粒度多线程,就可以有四个独立的标志寄存器、四个程序计数器和四个独立寄存器(每个线程一个),还可以有一个包含当前运行线程的id的专用寄存器。例如,如果正在运行线程2,就使用线程2的上下文,如果在运行线程3,就使用线程3的上下文。以这种方式,多个线程不可能覆盖彼此的状态。

粗粒度多线程的概念图

可以采用更简单的方法,而不是显式地加载和卸载线程的上下文。我们可以在管道中保存线程的上下文。例如,如果我们希望支持粗粒度多线程,那么我们可以有四个独立的标志寄存器、四个程序计数器和四个独立寄存器文件(每个线程一个)。此外,我们可以有一个包含当前运行线程的id的专用寄存器。例如,如果我们正在运行线程2,那么我们使用线程2的上下文,如果我们在运行线程3,我们使用线程3的上下文。以这种方式,多个线程不可能覆盖彼此的状态。

现在看看一些微妙的问题。可能在管线中的同一时间点拥有属于多个线程的指令,当从一个线程切换到下一个线程时,可能会发生这种情况。让我们将线程id字段添加到指令包中,并进一步确保转发和互锁逻辑考虑到线程的id,我们从不跨线程转发值。以这种方式,可以在管线上执行四个独立的线程,而线程之间的切换开销可以忽略不计。我们不需要使用异常处理程序来保存和恢复线程的上下文,也不需要调用操作系统来调度线程的执行。

现在整体来看一下粗粒度多线程。我们快速连续执行n个线程,并按循环顺序执行,此外,有一种在线程之间快速切换的机制,线程不会破坏彼此的状态,但仍然不会同时执行四个线程。那么,这个方案的优点是什么?

考虑一下内存密集型线程的情况,这些线程对内存有很多不规则的访问,它们将经常在二级缓存中发生未命中,其管线需要暂停100-300个周期,直到值从内存中返回。无序管线可以通过执行一些不依赖于内存值的其他指令来隐藏某些延迟,尽管如此,它也将暂停很长一段时间。然而,如果我们可以切换到另一个线程,那么它可能有一些有用的工作要做。如果该线程也来自L2缓存中的未命中,那么我们可以切换到另一个线程并完成其部分工作,这样可以最大化整个系统的吞吐量。可以设想两种可能的方案:可以每n个周期周期性地切换一次,或者在发生二级缓存未命中等事件时切换到另一个线程;其次,如果线程正在等待高延迟事件(如二级缓存丢失),不需要切换到该线程,需要切换到一个具有准备好执行指令池的线程。可以设计大量的启发式算法来优化粗粒度多线程机器的性能。

软件线程和硬件线程的区别:

  • 软件线程是一个子程序,与其他软件线程共享一部分地址空间,这些线程可以相互通信以协作实现共同目标。
  • 硬件线程被定义为在管线上运行的软件线程或单线程程序及其执行状态的实例。

多线程处理器通过跨线程分配资源来支持同一处理器上的多个硬件线程。软件线程可以物理地映射到单独的处理器或硬件线程,与用于执行它的实体无关。需要注意的重要一点是,软件线程是一种编程语言概念,而硬件线程在物理上与管线中的资源相关联。

本文使用“线程”一词来表示软件和硬件线程,需要根据上下文推断正确的用法。

细粒度多线程

细粒度多线程是粗粒度多线程的一种特殊情况,其中切换间隔n非常小,通常为1或2个循环,意味着可以在线程之间快速切换。我们可以利用粒度多线程来执行内存密集型线程,然而,否定多线程对于执行一组线程(例如具有长算术运算,如除法)也很有用。在典型的处理器中,除法运算和其他特殊运算(如三角运算或超越运算)很慢(3-10个周期)。在这段时间内,当原始线程等待操作完成时,可以切换到另一个线程,并在管线阶段中执行它的一些未使用的指令。因此,我们可以利用在线程之间快速切换的能力来减少具有大量数学运算的科学程序中的空闲时间。

因此,我们可以将细粒度多线程视为更灵活的粗粒度多线程形式,在线程之间快速切换,并利用空闲阶段执行有用的工作。请注意,这个概念并不像听起来那么简单。需要在常规有序或无序管线中的所有结构中为多线程提供详细的支持,需要非常仔细地管理每个线程的上下文,并确保不会遗漏指令,也不会引入错误。

线程之间切换的逻辑不是普通的。大多数时候,在线程之间切换的逻辑是基于时间的标准(周期数)和基于事件的标准(高延迟事件,如二级缓存未命中或页面错误)的组合。为了确保多线程处理器在一系列基准测试中表现良好,必须仔细调整启发式。

并发多线程

对于单个执行管线,如果可以通过使用复杂的逻辑在线程之间切换来确保每个阶段都保持忙碌,就可以实现高效率。单个执行管线中的任何阶段每个周期只能处理一条指令。相比之下,多执行流水线每个周期可以处理多个指令,此外,将执行槽的数量设计为等于流水线每个周期可以处理的指令数量。例如,一个3执行处理器,每个周期最多可以获取、解码并最终执行3条指令。

为了在多执行管线中实现多线程,还需要考虑线程中指令之间的依赖性。细粒度和粗粒度方案可能无法很好地执行,因为线程无法为所有执行槽向功能单元执行指令,这种线程可描述成具有低指令级并行性。如果我们使用4个执行流水线,并且由于程序中的依赖性,每个线程的最大IPC为1,那么每个周期中有3个执行槽将保持空闲。因此,4线程系统的总体IPC将为1,多线程的好处将受到限制。

因此,有必要利用额外的执行时段,以便我们能够增加整个系统的IPC。一种简单的方法是为每个线程分配一个执行槽。其次,为了避免结构冲突,可以有四个ALU,并为每个线程分配一个ALU。然而,这是对管线的次优利用,因为线程可能没有执行每个周期的指令。最好有一个更灵活的方案,可以在线程之间动态地划分执行槽,这种方案被称为并发多线程(simultaneous multithreading,SMT)。例如,在给定的周期中,我们可能会从线程2执行2条指令,从线程3和4执行1条指令,这种情况可能在下一个周期中发生逆转。下图说明这个概念,同时还将SMT方法与细粒度和粗粒度多线程进行比较。

多线程处理器中的指令执行。

上图中的列表示多执行机器的执行槽,行表示周期,属于不同线程的指令有不同的颜色。图(a)显示了粗粒度机器中指令的执行情况,其中每个线程执行两个连续的周期。由于没有找到足够数量的可执行指令,所以很多执行槽都是空的,细粒度多线程(图(b))也有同样的问题。然而,在SMT处理器中,通常能够使大多数执行槽保持忙碌,因为总是从准备执行的可用线程集中找到指令。如果一个线程由于某种原因被暂停,其他线程会通过执行更多的指令进行补偿。实际上,所有线程不同时具有低ILP(Instruction Level Parallelism,指令级并行)阶段,因此,SMT方法已被证明是一种非常通用且有效的方法,可以利用多个执行处理器的能力。自从奔腾4(90年代末发布)以来,大多数英特尔处理器都支持不同版本的同时多线程,在英特尔的术语中,SMT被称为超线程,而IBM Power 7处理器有8个内核,每个内核都是4路SMT(每个内核可以运行4个线程)。

请注意,选择要执行的正确指令集的问题对SMT处理器的性能至关重要。其次,n路SMT处理器的内存带宽要求高于等效单处理器,提取逻辑也要复杂得多,因为需要在同一周期内从四个独立的程序计数器中提取。最后,保持连贯性和一致性的问题使情况更加复杂。

执行多个线程的更多方法如下:


它们的说明如下:

  • 超标量(Superscalar):是没有多线程的基本超标量方法,直到前些年,依旧是在处理器内提供并行性的最强大的方法。注意,在某些周期中,并非使用所有可用的执行槽(issue slot),在这些周期中,发出的指令数少于最大指令数,被称为水平损耗(horizontal loss)。在其他指令周期中,不使用执行槽,是无法发出指令的周期,被称为垂直损耗(vertical loss)。
  • 交错多线程超标量(Interleaved multithreading superscalar):在每个周期中,从一个线程发出尽可能多的指令。如前所述,通过这种技术,消除了由于线程切换导致的潜在延迟,但在任何给定周期中发出的指令数量仍然受到任何给定线程中存在的依赖性的限制。
  • 阻塞的多线程超标量(Blocked multithreaded superscalar):同样,在任何周期中,只能发出来自一个线程的指令,并且使用阻塞多线程。
  • 超长指令字(Very long instruction word,VLIW):VLIW架构(如IA-64)将多条指令放在一个字中,通常由编译器构建,它将可以并行执行的操作放在同一个字中。在一个简单的VLIW机器(上图g)中,如果不可能用并行发出的指令完全填充单词,则不使用操作。
  • 交错多线程VLIW(Interleaved multithreading VLIW):提供与超标量架构上交错多线程所提供的效率类似的效率。
  • 阻塞多线程VLIW(Blocked multithreaded VLIW):提供与超标量架构上的阻塞多线程所提供的效率类似的效率。
  • 同时多线程(Simultaneous multithreading):上图j显示了一个能够一次发出8条指令的系统。如果一个线程具有高度的指令级并行性,则它可能在某些周期内能够填满所有的水平槽。在其他周期中,可以发出来自两个或多个线程的指令。如果有足够的线程处于活动状态,则通常可以在每个周期中发出最大数量的指令,从而提供较高的效率。
  • 芯片多处理器(Chip multiprocessor),亦即多核(multicore):上图k显示了一个包含四个核的芯片,每个核都有一个两个问题的超标量处理器。每个内核都分配了一个线程,每个周期最多可以发出两条指令。

19.7.5 SIMD多处理器

本节讨论SIMD多处理器。SIMD处理器通常用于科学应用、高强度游戏和图形,它们没有大量的通用用途。然而,对于一类有限的应用,SIMD处理器往往优于MIMD处理器。

SIMD处理器有着丰富的历史。在过去的好日子里,我们把处理器排列成阵列,数据通常通过处理器的第一行和第一列输入,每个处理器对输入消息进行操作,生成一条输出消息,并将该消息发送给其邻居。这种处理器被称为收缩阵列(systolic array)。收缩阵列用于矩阵乘法和其他线性代数运算。随后的几家供应商,尤其是Cray,在他们的处理器中加入了SIMD指令,以设计更快、更节能的超级计算机。如今,这些早期的努力大多已经隐退,然而,经典SIMD计算机的某些方面,即单个指令对多个数据流进行操作,已经渗透到现代处理器的设计中。

我们将讨论现代处理器设计领域的一个重要发展,即在高性能处理器中加入SIMD功能单元和指令。

19.7.5.1 SIMD:矢量处理器

让我们考虑添加两个n元素数组的问题。在单线程实现中,需要从内存加载操作数,添加操作数,并将结果存储在内存中。因此,为了计算目标数组的每个元素,需要两条加载指令、一条加法指令和一条存储指令。传统处理器试图通过利用可以并行计算(c[i]=a[i]+b[i])和(c[j]=a[j]+b[j])的事实来实现加速,因为这两个运算之间没有任何依赖关系,可以通过并行执行许多这样的操作来增加IPC。

现在让我们考虑超标量处理器。如果它们每个周期可以执行4条指令,那么它们的IPC最多可以是单周期处理器的4倍。在实践中,对于4执行的处理器,我们可以通过这种固有的并行阵列处理操作在单周期处理器上实现的峰值加速大约是3到3.5倍。其次,这种通过宽执行宽度来增加IPC的方法是不可扩展的。在实践中没有8或10个执行处理器,因为流水线的逻辑变得非常复杂,并且面积/功率开销变得令人望而却步。

因此,设计人员决定对对大型数据向量(数组)进行操作的向量操作提供特殊支持,这种处理器被称为矢量处理器,主要思想是一次处理整个数据数组。普通处理器使用常规标量数据类型,如整数和浮点数;而向量处理器使用向量数据类型,本质上是标量数据类型的数组。

向量处理器(vector processor)将原始数据类型(整数或浮点数)的向量视为其基本信息单位。它可以一次加载、存储和执行整个向量的算术运算,这种对数据向量进行操作的指令称为向量指令(vector instruction)

使用多个功能单元来提高单个向量加法C=A+B指令的性能。

包含四通道的矢量单元的结构。

主要使用矢量处理器的最具标志性的产品之一是Cray 1超级计算机,这种超级计算机主要用于主要由线性代数运算组成的科学应用,这样的操作适用于数据和矩阵的向量,因此非常适合在向量处理器上运行。可悲的是,在高强度科学计算领域之外,向量处理器直到90年代末才进入通用市场。

90年代末,个人计算机开始用于研究和运行科学应用。其次,设计师们开始使用普通商品处理器来建造超级计算机,而不是为超级计算机设计定制处理器。从那时起,这一趋势一直持续到图形处理器的发展。1995年至2010年间,大多数超级计算机由数千个商品处理器组成。在常规处理器中使用矢量指令的另一个重要原因是支持高强度游戏,游戏需要大量的图形处理,例如,现代游戏渲染具有多个角色和数千个视觉效果的复杂场景。大多数视觉效果,如照明、阴影、动画、深度和颜色处理,都是对包含点或像素的矩阵进行基本线性代数运算的核心。由于这些因素,常规处理器开始引入有限的矢量支持,特别是,英特尔处理器提供了MMX、SSE 1-4矢量指令集,AMD处理器提供了3DNow!矢量扩展,ARM处理器提供ARM Neon矢量ISA。这些ISA之间有很多共性,因此我们不用关注任何特定的ISA。让我们转而讨论矢量处理器设计和操作背后的广泛原则。

19.7.5.2 软件接口

让我们先考虑一下机器的型号,需要一组向量寄存器,例如,x86 SSE(数据流单指令多数据扩展指令集)指令集定义了16个128位寄存器(XMM0...XMM15),每个这样的寄存器可以包含四个整数或四个浮点值,或者也可以包含八个2字节短整数或十六个1字节字符。在同一行上,每个矢量ISA都需要比普通寄存器宽的附加矢量寄存器。通常,每个寄存器可以包含多个浮点值。此处不妨让我们定义八个128位矢量寄存器:vr0...vr7。

现在,我们需要指令来加载、存储和操作向量寄存器。对于加载向量寄存器,有两个选项,可以从连续内存位置加载值,也可以从非连续内存位置装载值。前一种情况更为特殊,通常适用于基于阵列的应用程序,其中所有阵列元素都存储在连续的内存位置。ISA的大多数矢量扩展都支持加载指令的这种变体,因为它的简单性和规则性。此处不妨将ISA设计这样一个向量加载指令v:ld,考虑下图中所示的语义。此处,v:ld指令将内存位置([r1+12]、[r1/16]、[r2+20]、[r 1+24])的内容读入向量寄存器vr1。在下表中,请注意是向量寄存器。

示例 语法 解释
v.ld vr1, 12[r1] v.ld , vr1 <-- ([r1+12], [r1+16], [r1+20], [r1+ 24])

现在考虑矩阵的情况。假设有一个10000元素矩阵a[100][100],并假设数据是按行主顺序存储的,且要对矩阵的两列进行运算。在这种情况下,我们遇到了一个问题,因为列中的元素没有保存在相邻的位置。因此,依赖于输入操作数保存在连续内存位置的假设的向量加载指令将停止工作,需要有专门的支持来获取列中位置的所有数据,并将它们保存在向量寄存器中。这种操作称为分散-聚集(catter-gather)操作,因为输入操作数基本上分散在主内存中。

我们需要收集,并将它们放在一个叫向量寄存器的地方。让我们考虑向量加载指令的分散-聚集变体,并将其称为v.sg.ld。处理器读取另一个包含元素地址的向量寄存器,而不是假设数组元素的位置(语义见下表)。在这种情况下,专用向量加载单元读取存储在vr2中的内存地址,从内存中提取相应的值,并将它们顺序写入向量寄存器vr1。

示例 语法 解释
v.sg.ld vr1, 12[r1] v.sg.ld , vr1 <-- ([vr2[0]], [vr2[1]], [vr2[2]], [vr2[3]])

一旦在向量寄存器中加载了数据,就可以直接对两个这样的寄存器进行操作。例如,考虑128位矢量寄存器vr1和vr2,那么,汇编语句v.add vr3, vr1, vr2,将存储在输入向量寄存器(vr1和vr2)中的每对对应的4字节浮点数相加,并将结果存储在输出向量寄存器(vr3)中的相关位置。注意,这里使用向量加法指令(v.add)。下图显示了矢量加法指令的示例。

multiprocessor19

矢量ISA为矢量乘法、除法和逻辑运算定义了类似的操作。向量指令不必总是有两个输入操作数,即向量,可以将一个向量与一个标量相乘,也可以有一条只对一个向量操作数进行运算的指令。例如,SSE指令集有专门的指令,用于计算向量寄存器中的一组浮点数的三角函数,如sin和cos。如果一条向量指令可以同时对n个操作数执行操作,就说有n个数据通道,而向量指令同时对所有n个数据路径执行操作。

如果一条向量指令可以同时对n个操作数执行操作,那么就表示有n个数据通道(lane),而向量指令同时对所有n个数据路径执行操作。

最后一步是将向量寄存器存储在内存中,有两种选择:可以存储到相邻的内存位置,也可以保存到非相邻的位置。可以在向量加载指令的两个变体(v.ld和v.sg.ld)的行上设计向量存储指令的两种变体(连续和非连续)。有时需要引入在标量寄存器和矢量寄存器之间传输数据的指令。

19.7.5.3 SSE指令示例

现在考虑一个使用基于x86的SSE指令集的实际示例,不使用实际的汇编指令,改为使用gcc编译器提供的函数,这些函数充当汇编指令的封装器,称为gcc内建函数。

现在让我们解决添加两个浮点数数组的问题,希望对i的所有值计算c[i]=a[i]+b[i]

SSE指令集包含128位寄存器,每个寄存器可用于存储四个32位浮点数。因此,如果有一个N个数字的数组,需要进行N/4次迭代,因为在每个循环中最多可以添加4对数字。在每次迭代中,需要加载向量寄存器,累加它们,并将结果存储在内存中。这种基于向量寄存器大小将向量计算分解为循环迭代序列的过程称为条带开采(strip mining)

用C/C++编写一个函数,将数组a和b中的元素成对相加,并使用x86 ISA的SSE扩展将结果保存到数组C中。假设a和b中的条目数相同,是4的倍数。一种实现的代码如下:

void sseAdd (const float a[], const float b[], float c[], int N)
{
    /* strip mining */
    int numIters = N / 4;

    /* iteration */
    for (int i = 0; i < numIters; i++) 
    {
        /* load the values */
        __m128 val1 = _mm_load_ps(a);
        __m128 val2 = _mm_load_ps(b);

        /* perform the vector addition */
        __m128 res = _mm_add_ps(val1, val2);

        /* store the result */
        _mm_store_ps(c, res);

        /* increment the pointers */
        a += 4 ; b += 4; c+= 4;
    }
}

上述的代码解析:先计算迭代次数,在每次迭代中,考虑一个由4个数组元素组成的块,将一组四个浮点数加载到128位向量变量中,val1.val1由编译器映射到向量寄存器,使用函数_mm_load_ps从内存中加载一组4个连续的浮点值。例如,函数_mm_load_ps(a)将位置a、a+4、a+8和a+12中的四个浮点值加载到向量寄存器中。类似地,加载第二个向量寄存器val2,从存储器地址b开始的四个浮点值。随后执行向量加法,并将结果保存在与变量res关联的128位向量寄存器中。为此,使用内建函数_mm_add_ps,随后将变量res存储在内存位置,即c、c+4、c+8和c+12。

在继续下一次迭代之前,需要更新指针a、b和c。因为每个周期处理4个连续的数组元素,所以用4个(4个数组元素)更新每个指针。

可以很快得出结论,向量指令有助于批量计算,例如批量加载/存储,以及一次性成对添加一组数字。将此函数的性能与四核Intel core i7机器上不使用向量指令的函数版本进行了比较,带有SSE指令的代码对百万元素数组的运行速度快2-3倍。如果有更广泛的SSE寄存器,那么可以获得更多的加速,x86处理器上最新的AVX矢量ISA支持256和512位矢量寄存器。

19.7.5.4 判断指令

到目前为止,我们已经考虑了向量加载、存储和ALU操作。分支呢?通常,分支在向量处理器的上下文中具有不同的含义。例如,一个具有向量寄存器的处理器,其宽度足以容纳32个整数,有一个程序要求仅对18个整数进行成对相加,然后将它们存储在内存中。在这种情况下,无法将整个向量寄存器存储到内存中,因为有覆盖有效数据的风险。

让我们考虑另一个例子。假设想对数组的所有元素应用函数inc10(x),在这种情况下,如果输入操作数x小于10,希望将其加10。在向量处理器上运行的程序中,这种模式非常常见,因此需要向量ISA中的额外支持来支持它们。

function inc10(x):
    if (x < 10)
        x = x + 10;

让我们添加一个规则指令的新变体,并将其称为判断指令(predicated instruction,类似于ARM中的条件指令)。例如,我们可以创建常规加载、存储和ALU指令的判断变体。判断指令在特定条件为真时执行,否则根本不执行,如果条件为false,则判断指令等同于nop。

判断指令(predicated instruction)是正常加载、存储或ALU指令的变体。如果某个条件为真,它将正常执行;如果关联条件为false,那么它将转换为nop。

例如,如果最后一次比较结果相等,则ARM ISA中的addeq指令会像正常的加法指令一样执行。但是,如果不是这样,则add指令根本不执行。

现在让添加对判断的支持。首先创建cmp指令的向量形式,并将其称为v.cmp。它对两个矢量进行成对比较,并将比较结果保存在v.flags寄存器中,该寄存器是标志寄存器的矢量形式。v.flags寄存器的每个组件都包含一个E和GT字段,类似于常规处理器中的标志寄存器。

v.cmp vr1, vr2

上述语句比较vr1和vr2,并将结果保存在v.flags寄存器中。我们可以使用此指令的另一种形式,将向量与标量进行比较。

v.cmp vr1, 10

现在,让我们定义向量加法指令的谓词形式。如果v.flags[i]寄存器满足某些属性,则此指令将两个向量的第i元素相加,并更新目标向量寄存器的第i个元素。否则,它不会更新目标寄存器的第i个元素。假设判断向量add指令的一般形式为:v.p.add,p是判断条件。下表列出了p可以取的不同值。

判断条件 解析
lt <
gt >
le <=
ge >=
eq =
ne !=

现在考虑下面的代码片段:

v.lt.add vr3, vr1, vr2

此处,向量寄存器vr3的值是由vr1和vr2表示的向量之和,预测条件小于(lt),意味着,如果在v.flags寄存器中元素i的E和GT标志都为假,那么只有我们对第i个元素执行加法,并在vr3寄存器中设置其值,vr3寄存器中未被加法指令设置的元素保持其先前的值。因此,实现函数inc10(x)的代码如下,假设vr1包含输入数组的值。

v.cmp    vr1, 10
v.lt.add vr1, vr1, 10

同样,我们可以定义加载/存储指令和其他ALU指令的判断版本。

19.7.6 互连网路

19.7.6.1 互联网络概述

现在考虑互连不同处理和存储元件的问题。通常,多核处理器使用棋盘设计,但此处我们将处理器集划分为块(tile,亦称瓦片),块通常由一组2-4个处理器组成,具有其专用缓存(L1和可能的L2),它还包含共享的最后一级缓存(L2或L3)的一部分。共享的最后一级缓存的一部分是给定分片的一部分,称为分片(slice),分片由2-4个存储库(bank)组成。此外,在现代处理器中,一个块或一组块可能共享一个内存控制器,内存控制器的作用是协调片上高速缓存和主内存之间的数据传输。下图显示了32核多处理器的代表性布局,与缓存组相比,内核的颜色更深。我们使用2个块大小(2个处理器和2个缓存组),并假设共享L2缓存具有32个均匀分布在块上的缓存组。此外,每个块都有一个专用的内存控制器和一个称为路由器(router)的结构。

多核处理器的布局。

紧密耦合多处理器的通用架构图。

集群配置。

路由器是一个专用单元,定义如下。

1、路由器通过片上网络将源自其瓦片中的处理器或缓存的消息发送到其他瓦片。

2、路由器通过片上网络相互连接。

3、消息通过一系列路由器从源路由器传送到(远程瓦片的)目的路由器。途中的每一个路由器都会将消息转发到另一个离目的地更近的路由器。

4、最后,与目的地砖相关联的路由器将消息转发到远程砖中的处理器或高速缓存。

5、相邻路由器通过链路连接,链路是一组用于传输消息的无源铜线。

6、路由器通常有许多传入链路和许多传出链路。一组传入和传出链接将其连接到处理器,并在其瓦片中缓存。每个链接都有一个唯一的标识符。

7、路由器具有相当复杂的结构,通常由3至5级管线组成。大多数设计通常将管线阶段用于缓冲消息、计算传出链路的id、仲裁链路以及通过传出链路发送消息。

8、路由器和链路的布置被称为片上网络或片上网络,缩写为NOC。

9、将连接到NOC的每个路由器称为节点,节点通过发送消息相互通信。

在程序执行过程中,它通过NOC发送数十亿条消息,NOC携带一致性消息、LLC(最后一级缓存)请求/响应消息以及缓存和内存控制器之间的消息。操作系统还使用NOC向内核发送消息以加载和卸载线程,由于信息量大,大部分NOC经常遇到相当大的拥堵。因此,设计尽可能减少拥堵、易于设计和制造并确保信息快速到达目的地的NOC至关重要。让我们定义NOC的两个重要特性:等分带宽(bisection bandwidth)和直径(diameter)。

对称多处理器组织如下:

19.7.6.2 等分带宽和网络直径

让我们考虑一个网络拓扑,其中顶点是节点,顶点之间的边是链接。假设存在链路故障,或者由于拥塞等其他原因,链路不可用,那么应该可以通过备用路径路由消息。例如,考虑一个布置为环的网络,如果一个链路失败,那么总是可以通过环的另一端发送消息。如果以顺时针方式发送消息,可以以逆时针方式发送。然而,如果存在两个链路故障,则网络可能会断开成两个相等的部分。因此,我们希望最大限度地减少将网络完全断开成相当大的部分(可能相等)所需的链路故障数量。将此类故障的数量称为等分带宽(bisection bandwidth),等分带宽是衡量网络可靠性的指标,它精确地定义为需要将网络划分为两个相等部分的最小链路数。

可以对等分带宽进行另一种解释。假设一半网络中的节点正在尝试向另一半网络中节点发送消息,那么可以同时发送的消息数量至少等于等分带宽。因此,等分带宽也是网络带宽的度量。

等分带宽(bisection bandwidth)被定义为将网络分成两个相等部分所需的最小链路故障数。

我们已经讨论了可靠性和带宽,现在转向关注延迟。考虑网络中的节点对,接下来考虑每对节点之间的最短路径,在所有这些最短路径中,考虑具有最大长度的路径,该路径的长度是网络中节点接近度的上限,称为网络直径(Network Diameter)。或者,可以将网络的直径解释为任何一对节点之间最坏情况下延迟的估计。

让我们考虑所有节点对,并计算每对节点之间的最短路径,最长路径的长度称为网络直径(Network Diameter),它是网络最坏情况下延迟的度量。

19.7.6.3 网络拓扑

本节回顾一些最常见的网络拓扑,其中一些拓扑用于多核处理器。然而,大多数复杂的拓扑结构用于使用常规以太网链路连接处理器的松散耦合多处理器。对于每个拓扑,假设它有N个节点,为了计算等分带宽,可以进一步简化N可被2整除的假设。请注意,等分带宽和直径等度量都是近似度量,仅表示广泛的趋势。因此,我们有余地做出简单的假设,从考虑适用于多核的更简单拓扑开始,要以高的等分带宽和低的网络直径为目标。

链和环

下图左显示了一个节点链,它的等分带宽为1,网络直径为N-1,是最糟糕的配置。可以通过考虑一个节点环来改进这两个指标(下图右),此时等分带宽为2,网络直径为N=2。这两种拓扑都相当简单,已被其他拓扑所取代。现在考虑一种称为胖树的拓扑结构,它通常在集群计算机中使用,集群计算机是指由通过局域网连接的多个处理器组成的松散耦合的多处理器。

集群计算机(cluster computer)是指由通过局域网连接的多个处理器组成的松散耦合计算机。

左:链;右:环。

胖树

下图显示了一棵胖树(fat tree),所有节点都在叶子上,树的所有内部节点都是专用于路由消息的路由器,将这些内部节点称为交换机。从节点A到节点b的消息首先传播到最近的节点,该节点是A和b的共同祖先,然后它向下传播到b。请注意,消息的密度在根附近最高,为了避免争用和瓶颈,当向根节点移动时,逐渐增加连接节点及其子节点的链接数。该策略减少了根节点处的消息拥塞。

在示例中,两个子树连接到根节点,每个子树有4个节点,根最多可以从每个子树接收4条消息。其次,它最多需要向每个子树发送4条消息。假设一个双工链接,根需要有4个链接将其连接到其每个子级。同样,下一级节点需要它们与其每个子节点之间的2个链接,每个叶子需要一个链接。因此,当向根部前进时,可以看到这棵树越来越胖,因此它被称为胖树。

网络直径等于\(2log(N)\),等分带宽等于将根节点连接到其每个子节点的最小链路数。假设树的设计是为了确保根上的链接绝对没有争用,那么需要用N=2个链接将根连接到每个子树,这种情况下的等分带宽为N=2。请注意,在大多数实际情况下,不会在根及其子级之间分配N=2个链路,因为子树中所有节点同时发送消息的概率很低,因此在实践中可以减少每个级别的链接数。

网格和圆环

左:网格;右:圆环。

现在看看更适合多核的拓扑,最常见的拓扑之一是网格(mesh),其中所有节点都以类似矩阵的方式连接(上图左)。拐角处的节点有两个邻居,边缘上的节点有三个邻居,其余节点有四个邻居。现在计算网格的直径和等分带宽,最长的路径在两个角节点之间,直径等于(\(2 \sqrt{N}-2\))。要将网络分成两个相等的部分,需要在中间(水平或垂直)分割网格,因为在一行或一列中有\(\sqrt{N}\)个节点,所以等分带宽等于\(\sqrt{N}\)。就这些参数而言,网格优于链和环。

不幸的是,网格拓扑本质上是不对称的,位于网格边缘的节点彼此远离,可以通过每行和每列的末端之间的交叉链接来增加网格,所得结构称为圆环(torus),如上图右所示。现在看看圆环的性质,网络边缘两侧的节点只相隔一跳,最长的路径位于任何角节点和圆环中心的节点之间,直径再次等于(忽略小的相加常数)\(\sqrt{N} / 2+\sqrt{N} / 2=\sqrt{N}\)。回想一下,圆环每边的长度等于\(\sqrt{N}\)

现在,将网络分成两个相等的部分,将其水平拆分。因此,需要捕捉\(\sqrt{N}\)个垂直链接,以及\(\sqrt{N}\)条交叉链接(每列末端之间的链接)。因此,等分带宽等于\(2 \sqrt{N}\)

通过添加\(2\sqrt{N}\)个交叉链接(行为\(\sqrt{N}\),列为\(\sqrt{N}\)),将直径减半,并将圆环的等分带宽加倍。然而,这个方案仍然存在一些问题,后面详细说明。

在确定直径时,我们做了一个隐含的假设,即每个链路的长度几乎相同,或者消息穿过链路所需的时间对于网络中的所有链路几乎相同,由此根据消息经过的链接数来定义直径。这种假设并不十分不切实际,因为与沿途路由器的延迟相比,通常通过链路的传播时间很短。然而,链路的延迟是有限制的,如果链接很长,对直径的定义需要修改,就圆环来说,有这样的情况。交叉链路在物理上比相邻节点之间的常规链路长\(\sqrt{N}\)倍。因此,与网格相比,我实践中没有显著减小直径,因为一行末端的节点仍然相距很远。

幸运的是,可以通过使用一种稍加修改的称为折叠圆环(Folded Torus)的结构来解决这个问题,如下图所示。每一行和每一列的拓扑结构都像一个环,环的一半由原本是网格拓扑的一部分的规则链接组成,另一半由添加的交叉链接组成,这些交叉链接用于将网格转换为圆环,交替地将节点放置在常规链接和交叉链接上。该策略确保折叠环面中相邻节点之间的距离是规则环面中的相邻节点之间距离的两倍,但避免了行或列两端之间的长交叉链接(\(\sqrt{N}\)跳长)。

网络的等分带宽和直径与圆环保持相同。在这种情况下,有几个路径可以作为最长路径,但从拐角到中心的路径不是最长的,最长的路径之一是在两个相对的角落之间。折叠圆环通常是多核处理器中的首选配置,因为它避免了长的交叉链路。

超立方体

现在考虑一个具有\(O(log(N))\)直径的网络,这些网络使用大量链接,因此它们不适用于多核,通常用于大型集群计算机。这个网络被称为超立方体(hypercube)。超立方体实际上是一个网络族,每个网络都有一个阶(order),k阶的超立方体称为\(H_k\)。它有着下图所示的几种拓扑结构。

蝴蝶

最后一个叫做蝴蝶的网络,它也有\(O(log(N))\)的直径,但它适合于多核。下图显示了8个节点的蝶形网络,每个节点由一个圆表示。除了节点之外,还有一组交换机或内部节点(以矩形显示),用于在节点之间路由消息。消息从左侧开始,经过交换机,到达图表的右侧。请注意,图最左侧和最右侧的节点实际上是相同的节点集。没有添加从左到右的交叉链接,以避免使图表复杂化,图中显示了两个节点的集合。

拓扑结构对比

下表用四个参数:内部节点(或交换机)数量、链路数量、直径和二等分带宽来比较拓扑结构。在所有情况下,假设网络有N个节点可以发送和接收消息,N是2的幂。

拓扑 节点数 链接数 直径 等分网络
0 \(N-1\) \(N-1\) 1
0 \(N\) \(N/2\) 2
胖树 \(N-1\) \(2N-2\) \(2 \log(N)\) \(N/2\)
网格 0 \(2N-\sqrt{N}\) \(2\sqrt{N}-2\) \(\sqrt{N}\)
圆环 0 \(2N\) \(\sqrt{N}\) \(2\sqrt{N}\)
折叠圆环 0 \(2N\) \(\sqrt{N}\) \(2\sqrt{N}\)
超立方体 0 \(N\log(N)/2\) \(\log(N)\) \(N/2\)
蝴蝶 \(N\log(N)/2\) \(N + N\log(N)\) \(\log(N)+1\) \(N/2\)

除此之外,还有以下类型的网络拓扑:


19.8 I/O和存储设备

计算机中需要I/O(输入/输出)系统,下图是典型计算机的结构。

典型的计算机系统。

I/O通道的两种架构。

处理器是计算机的核心,它连接到一系列I/O设备,用于处理用户输入和显示结果。这些I/O设备称为外围设备,最常见的用户输入设备是键盘和鼠标,而最常见的显示设备是监视器和打印机。计算机还可以通过一组通用I/O端口与许多其他设备进行通信,如相机、扫描仪、mp3播放器、摄像机、麦克风和扬声器。I/O端口包括:

  • 一组金属引脚,用于帮助处理器与外部设备连接。
  • 一个端口控制器,用于管理与外围设备的连接。计算机还可以通过一种称为网卡的特殊外围设备与外界通信,网卡包含通过有线或无线连接与其他计算机通信的电路。

I/O端口(I/O port)由一组金属引脚组成,用于连接外部设备提供的连接器。每个端口都与协调通信链路上数据交换的端口控制器相关联。

本章特别优先考虑一类特定的设备,即存储设备。硬盘和闪存驱动器等存储设备可以帮助我们永久地存储数据,即使系统断电后亦是如此。本章强调存储设备的原因是因为它们是计算机体系结构的组成部分,跨计算机的外围设备的性质各不相同。但是,从小型手持电话到大型服务器,所有计算机都有某种形式的永久存储,此存储用于在程序运行期间保存文件、系统配置数据和交换空间。因此,架构师特别关注存储系统的设计和优化。

19.8.1 I/O系统概述

现在远离I/O设备的确切细节,在设计计算机系统时,设计者不可能考虑所有可能类型的I/O设备,即使这样做,也有可能在电脑售出后出现一类新的设备,例如苹果iPad等平板电脑在2005年就不存在了。尽管如此,在iPad和旧电脑之间传输数据仍然是可能的,因为大多数设计师都在他们的计算机系统中提供标准接口,例如,典型的台式机或笔记本电脑有一组USB端口,任何符合USB规范的设备都可以连接到USB端口,然后可以与主机通信。类似地,几乎可以将任何监视器或投影仪与任何笔记本电脑相连,因为笔记本电脑有一个通用的DVI端口,可以连接到任何显示器。笔记本电脑公司通过实现DVI端口来遵守其DVI规范,DVI端口可以在处理器和端口之间无缝传输数据。在类似的线路上,监视器公司通过确保其监视器可以无缝显示DVI端口上发送的所有数据来遵守其的DVI规范部分。因此,我们需要确保计算机能够支持与外围设备的一组固定接口,且可以在运行时连接任何外围设备。

注意,仅仅因为可以通过实现端口的规范来连接通用I/O设备,并不意味着I/O设备可以工作。例如,可以始终将打印机连接到USB端口,但是打印机可能无法打印页面,原因是需要软件层面的额外支持来操作打印机。此支持内置于操作系统中的打印机设备驱动程序中,可以有效地将数据从用户程序传输到打印机。

因此需要明确区分软件和硬件的角色。先看软件,大多数操作系统都需要一个非常简单的用户界面来访问I/O设备,例如Linux操作系统有两个系统调用,即读和写,具体如下。

read(int file_descriptor, void *buffer, int num_bytes)
write(int file_descriptor, void *buffer, int num_bytes)

Linux将所有设备视为文件,并为它们分配一个文件描述符,文件描述符是第一个参数,指定了设备的id。第二个参数指向内存中包含数据源或目标的区域,最后一个参数表示需要传输的字节数。从用户的角度来看,这就是所有需要做的事情。这些正是操作系统的设备驱动程序的工作,以及协调其余过程的硬件,此法已被证明是访问I/O设备的一种非常通用的方法。

不幸的是,操作系统需要做更多的工作。对于每个I/O调用,它需要找到适当的设备驱动程序并传递请求,可能有多个进程试图访问同一I/O设备,在这种情况下,需要正确地调度不同的请求。

设备驱动程序的工作是与本地硬件接口并执行所需操作,通常使用汇编指令与硬件设备通信。它首先评估自己的状态,如果是空闲的,就要求外围设备执行所需的操作,启动存储系统和外围设备之间的数据传输过程。

下图概括了上述的讨论。图的上部显示了软件模块(应用程序、操作系统、设备驱动程序),图的下部显示了硬件模块。设备驱动程序使用I/O指令与处理器通信,然后处理器将命令路由到适当的I/O设备。当I/O设备有一些数据要发送给处理器时,它发送一个中断,然后中断服务例程读取数据,并将其传递给应用程序。

I/O系统(硬件和软件)。

实际上,整个I/O过程是一个极其复杂的过程,本章致力于研究和设计设备驱动程序,仅讨论I/O系统的硬件部分,并粗略地了解所需的软件支持。I/O系统的软件和硬件组件之间的重要差异点如下:

  • I/O系统的软件组件由应用程序和操作系统组成。应用程序通常被提供一个非常简单的接口来访问I/O设备,操作系统的作用是整理来自不同应用程序的I/O请求,适当地调度它们,并将它们传递给相应的设备驱动程序。
  • 设备驱动程序通过特殊的组装指令与硬件设备通信。它们协调处理器和I/O设备之间的数据、控制和状态信息的传输。
  • 硬件(处理器和相关电路)的作用只是充当操作系统和连接到专用I/O端口的I/O设备之间的信使。例如,如果我们将数码相机连接到USB端口,则处理器不知道所连接设备的详细信息,它的唯一作用是确保相机的设备驱动程序和连接到相机的USB端口之间的无缝通信。
  • I/O设备可以通过发送中断来启动与处理器的通信,会中断当前正在执行的程序,并调用中断服务例程。中断服务例程将控制传递给相应的设备驱动程序,然后设备驱动程序处理中断,并采取适当的操作。

下面将细讨论I/O系统的硬件组件的架构。

19.8.1.1 I/O系统要求

现在尝试设计I/O系统的架构,下表列出想要支持的所有设备及其带宽要求。需要最大带宽的组件是显示设备(监视器、投影仪、电视),它连接到图形卡,包含处理图像和视频数据的图形处理器。

设备 总线技术 带宽 典型值
显示设备 PCI Express(版本4) 1-10 GB/s
硬盘 ATA/SCSI/SAS 150-600 MB/s
网卡(有线/无线) PCI Express总线 10-100 MB/s
USB设备 USB(通用串口总线) 60-625 MB/s
DVD音频/视频 PCI(个人计算机接口) 1-4 MB/s
扬声器/麦克风 AC'97/Intel High. Def. Audio 100 KB/s至3 MB/s
键盘/鼠标 USB、PCI 非常低 10-100 B/s

请注意,在讨论I/O设备时,经常使用术语卡(card)。卡是一块印刷电路板(PCB),可以连接到计算机的I/O系统以实现特定功能,例如,图形卡可以帮助我们处理图像和视频,声卡可以帮助处理高清晰度音频,网卡可以帮助连接到网络。网卡的图片如下图所示,可以看到印刷电路板上互连的一组芯片,有一组端口用于将外部设备连接到卡。

除了图形卡,另一个需要连接到CPU的高带宽设备是主内存,其带宽大约为10-20 GB/s。因此,我们需要设计一个对主内存和图形卡进行特殊处理的I/O系统。

其余设备的带宽相对较低,硬盘、USB设备和网卡的带宽要求限制在500-600 MB/s,键盘、鼠标、CD-DVD驱动器和音频外围设备的带宽要求极低(小于4 MB/s)。

19.8.1.2 I/O系统设计

结合上表,可以注意到有不同种类的总线技术,如USB、PCI Express和SATA,总线(bus)被定义为I/O系统中两个或两个以上元件之间的链路。我们使用不同类型的总线来连接不同类型的I/O设备,例如,使用USB总线连接USB设备(如笔驱动器和相机),使用SATA或SCSI总线连接到硬盘。需要使用这么多不同类型的总线的原因有:

  • 不同I/O设备的带宽要求非常不同。图形卡需要使用非常高速的总线,而键盘和鼠标只需更简单的总线技术,因为总带宽需求最小。
  • 历史原因。历史上,硬盘供应商一直使用SATA或IDE总线,而图形卡供应商一直使用AGP总线,2010年后,图形卡供应商转而使用PCI Express总线。

由于多种因素的组合,I/O系统设计者需要支持多种总线。

总线(bus)是一组用于并联连接多个设备的导线。设备可以使用总线在彼此之间传输数据和控制信号。

现在深入研究总线的结构。总线不仅仅是两个端点之间的一组铜线,实际上是一个非常复杂的结构,其规格通常长达数百页。我们需要关注它的电气特性、误差控制、发射机(transmitter)和接收机电路、速度、功率和带宽,本章将有充分的机会讨论高速总线。连接到总线的每个节点(源或目的地)都需要总线控制器来发送和接收数据,尽管总线的设计相当复杂,但我们可以将其抽象为一个逻辑链路,可以无缝可靠地将字节从单个源传输到一组目的地。

为了设计计算机的I/O系统,首先需要提供由一组金属引脚或插座组成的外部I/O端口,这些I/O端口可用于连接外部设备,每个端口都有一个与设备接口的专用端口控制器,然后端口控制器需要使用上表列出的总线之一将数据发送到CPU。

这里主要的设计问题是不可能通过I/O总线将CPU连接到每个I/O端口,有几个原因:

1、如果将CPU连接到每个I/O端口,那么CPU需要为每种总线类型配备总线控制器,会增加CPU的复杂性、面积和功耗。

2、CPU的输出引脚的数量是有限的。如果CPU连接到一个I/O设备主机,那么它需要大量额外的引脚来支持所有I/O总线,大多数CPU通常没有足够的引脚来支持此功能。

3、从商业角度来看,将CPU的设计与I/O系统的设计分开是一个好主意,这样就可以在各种各样的计算机中使用CPU。

因此,大多数处理器仅连接到一条总线,或最多连接到2到3条总线。我们需要使用辅助芯片将处理器连接到不同的I/O总线主机,它们需要聚合来自I/O设备的流量,并将CPU生成的数据正确路由到正确的I/O设备,反之亦然。这些额外的芯片包括给定处理器的芯片组,芯片组的芯片在被称为主板的印刷电路板上相互连接。

芯片组(Chipset):是主CPU连接到主内存、I/O设备和执行系统管理功能所需的一组芯片。

主板(Motherboard):芯片组中的所有芯片都在一块称为主板的印刷电路板上相互连接。

大多数处理器的芯片组中通常有两个重要的芯片:北桥(North Bridge)南桥(South Bridge),如下图所示。CPU使用前端总线(FSB)连接到北桥芯片,北桥芯片连接到DRAM内存模块、图形卡和南桥芯片。相比之下,南桥芯片旨在处理速度慢得多的I/O设备,连接到所有USB设备,包括键盘和鼠标、音频设备、网卡和硬盘。

I/O系统架构。

为了完整起见,先阐述计算机系统中其他两种常见类型的总线:

  • 后端总线(back side bus)。它用于将CPU连接到二级缓存。早期的处理器使用芯片外L2缓存,通过后端总线进行交流,如今的L2高速缓存已移动到芯片上,因此后端总线也在芯片上。它通常以核心频率计时,是一种非常快的总线。

  • 背板总线(backplane bus)。它用于大型计算机或存储系统,通常具有多个主板和外围设备,如硬盘。所有这些实体都并联连接到单个背板总线,背板总线本身由多条平行铜线和一组可用于连接设备的连接器组成。

前端总线(front side bus):一种将CPU连接到内存控制器的总线,或者在Intel系统中连接到北桥芯片。

后端总线(back side bus):将CPU连接到二级缓存的总线。

背板总线(backplane bus):连接到多个主板、存储和外围设备的系统范围总线。

北桥和南桥芯片都需要为它们所连接的所有总线配备总线控制器,每个总线控制器协调对其相关总线的访问。成功接收数据包后,它将数据包发送到目的地(朝向CPU或I/O设备)。由于这些芯片互连各种类型的总线,并在目标总线繁忙时临时缓冲数据值,因此它们被称为桥(总线之间的桥)。

内存控制器是北桥芯片的一部分,实现对主存储器的读/写请求。在过去的几年里,处理器供应商已经开始将内存控制器转移到主CPU芯片中,并使其更加复杂,对内存控制器的大多数增强都集中在降低主内存功率、减少刷新周期数和优化性能上。从Intel Sandybridge处理器开始,图形处理器也移动到芯片上。把它们放入CPU芯片的原因是:

  • 有额外的晶体管可用。
  • 芯片内通信比芯片外通信快得多。许多嵌入式处理器还将南桥芯片的大部分、端口控制器以及CPU集成在一个芯片中,有助于减小主板的尺寸,并允许I/O控制器和CPU之间更有效的通信。这种类型的系统被称为SOC(System on Chip,片上系统)

SOC(System on Chip,片上系统)通常将计算系统的所有相关部分封装到单个芯片中,包括主处理器和I/O系统中的大部分芯片。

19.8.1.3 I/O系统中的层

大多数复杂的架构通常被分为多个层(layer),犹如互联网架构,一层基本上独立于另一层。因此,我们可以选择以任何方式实现它,只要它符合标准接口。现代计算机的I/O架构也相当复杂,有必要将其功能划分为不同的层。

我们可以将I/O系统的功能大致分为四个不同的层。请注意,我们将I/O系统的功能划分为多个层,主要是受7层OSI模型(用于划分广域网的功能层)的启发:

  • 物理层:总线的物理层主要定义总线的电气规格。它分为两个子层,即传输子层(transmission sublayer)和同步子层(synchronisation sublayer)。

    传输子层定义了传输比特的规范。例如,一条总线可以为高电平有效(如果电压为高电平,则逻辑1),另一条总线可为低电平有效(电压为零电平,则为逻辑1)。今天的高速总线使用高速差分信号,可以使用两根铜线来传输单个比特,通过监测两条导线之间电压差的符号来推断逻辑0或1(类似于SRAM单元中位线的概念)。现代总线扩展了这一思想,并使用电信号组合对逻辑位进行编码。

    同步子层规定了信号的定时,以及恢复接收器在总线上发送的数据的方法。

  • 数据链路层:数据链路层主要用于处理物理层读取的逻辑位,将比特集分组为帧,执行错误检查,控制对总线的访问,并帮助实现I/O事务。具体而言,它确保在任何时间点只有一个实体可以在总线上传输信号,并且实现了利用公共消息模式的特殊功能。

  • 网络层:该层主要涉及通过芯片组中的各种芯片将一组帧从处理器成功传输到I/O设备,反之亦然。我们唯一地定义了I/O设备的地址,并考虑了在I/O指令中嵌入I/O设备地址的方法。大体上讨论两种方法:基于I/O端口的寻址和内存映射寻址,在后一种情况下,将对I/O设备的访问视为对指定内存位置的常规访问。

  • 协议层:最顶层称为协议层,负责端到端执行I/O请求,包括处理器和I/O设备之间在消息语义方面进行高级通信的方法。例如,I/O设备可以中断处理器,或者处理器可以明确请求每个I/O设备的状态。其次,为了在处理器和设备之间传输数据,可以直接传输数据,也可以将数据传输的责任委托给称为DMA控制器的芯片组中的专用芯片。

下图总结了典型处理器的4层I/O架构。

I/O系统中的4个层。

19.8.2 物理层:传输子层

物理层是I/O系统的最下层,涉及源和接收器之间信号的物理传输。它又可以被分成两个子层,第一个子层是传输子层,它处理从源到目的地的比特传输,该子层涉及链路的电特性(电压、电阻、电容),以及使用电信号表示逻辑位(0或1)的方法。

第二个子层称为同步子层,涉及从物理链路读取整个比特帧,其中帧被定义为一组由特殊标记划分的比特。由于I/O通道受到抖动(不可预测的信号传播时间)的困扰,因此有必要正确地同步数据到达接收器,并正确读取每一帧。

本节将讨论传输子层,下一节讨论同步子层。

请注意,创建多个子层而不是创建多个层的原因是因为子层不需要彼此独立。理论上,可以使用任何物理层和任何其他数据链路层协议,理想情况下,它们应该完全忘记对方。但是,传输子层和同步子层具有很强的联系,因此不可能将它们分离成单独的层。

下图显示了I/O链路的一般视图。源(发射机)向目的地(接收机)发送一系列比特,在传输时,数据始终与源的时钟同步,意味着,如果源以1GHz运行,那么它以1GHZ的速率发送比特。请注意,源的频率不一定等于发送数据的处理器或I/O元件的频率。传输电路通常是一个单独的子模块,它有一个时钟,该时钟是从作为其一部分的模块的时钟中导出的,例如,处理器的传输电路可能以500MHz传输数据,而处理器可能以4GHz运行。在任何情况下,我们假设发射机以其内部时钟速率传输数据,该时钟速率也称为总线频率(bus frequency),该频率通常低于处理器或芯片组中其他芯片的时钟频率。接收器可以以相同的频率运行,也可以使用更快的频率,除非明确说明,否则不会假设源和目的地具有相同的频率。最后要注意,我们将互换使用发送器、源和发送器这三个术语,同样将互换使用“目标”和“接收者”这两个术语。

I/O链路的通用视图。

19.8.2.1 单端信号

现在考虑一种简单的方法,通过从源向目的地发送脉冲序列来发送1和0的序列,这种信令方法称为单端信号(single ended signalling),是最简单的方法。

在特定情况下,可以将高电压脉冲与1相关联,将低电压脉冲与0相关联,这种约定被称为高电平有效(active high)。或者,可以将低压脉冲与逻辑1相关联,将高压脉冲与逻辑0相关联,相反,这种约定被称为低电平有效(active low)。这两种约定如下图所示。

高电平有效和低电平有效的信号发送方法。

可悲的是,这两种方法都极其缓慢和过时。回顾之前章节对SRAM单元的讨论,快速I/O总线需要将逻辑0和1之间的电压差降低到尽可能低的值,因为电压差在对具有内部电容的检测器充电之后被检测到。所需电压越高,电容器充电所需时间越长。如果电压差为1伏,则需要很长时间才能检测到从0到1的转换,将限制总线的速度。然而,如果电压差为30mV,就可以更快地检测到电压的转变,从而可以提高总线的速度。

因此,现代总线技术试图将逻辑0和1之间的电压差降至尽可能低的值。请注意,为了提高总线速度,不能任意减小逻辑0和1之间的电压差。例如,不能让所需的电压差为0.001 mV,因为系统中存在一定量的电噪声,是由几个因素引起的。如果手机在汽车或电脑的扬声器打开时开始响起,那么扬声器中也会有一定的噪音。如果把一部手机放在微波炉旁边,而它正在运行,那么手机的音质就会下降,因为有电磁干扰。同样,处理器中也可能存在电磁干扰,并可能引入电压尖峰。假设这种电压尖峰的最大振幅为20mV,那么0和1之间的电压差需要大于20mV。否则,由于干扰引起的电压尖峰可能会翻转信号的值,从而导致错误。下一节简单地阐述一下片上信令最常见的技术之一,即LVDS。

19.8.2.2 低压差分信号(LVDS)

LVDS使用两根导线传输单个信号。监测这些导线的电压差。从电压差的符号推断出传递的值。

基本LVDS电路如下图所示。有一个3.5 mA的固定电流源。根据输入a的值,电流通过线路1或线路2流向目的地。例如,如果a为1,则电流流经线路1,因为晶体管T1开始导通,而T2截止。在这种情况下,电流到达目的地,通过电阻器Rd,然后通过线路2流回。通常,当没有电流流动时,两条线路的电压保持在1.2V。当电流流过时,存在电压摆动。电压摆动等于3.5mA乘以Rd。Rd通常为100欧姆。因此,总差分电压摆动为350 mV。检测器的作用是检测电压差的符号。如果是肯定的,它可以声明逻辑1。否则,它可以宣布逻辑0。由于摆动电压低(350 mV),LVDS是一种非常快速的物理层协议。

LVDS电路。

19.8.2.3 多位传输

现在考虑按顺序发送多个比特的问题。大多数I/O通道不是一直都很忙,只有在传输数据时才忙,因此它们的占空比(设备运行时间的百分比)往往是高度可变的,而且大多数时间都不是很高。然而,检测器几乎一直处于开启状态,一直在检测总线的电压,可能会影响功耗和正确性。功率是一个问题,因为检测器在每个周期都会检测到逻辑1或0,更高级别的层有必要处理数据。为了避免这种情况,大多数系统通常都有一条额外的线来指示数据位是有效还是无效,这条线路传统上被称为闪控(strobe)。发送器可以通过设置闪控的值来向接收器指示数据的有效期,同样地,有必要同步数据线和闪控。对于高速I/O总线来说,变得越来越困难,因为数据线上的信号和闪控可能会受到不同延迟量的影响。因此,这两条线路可能会不同步,最好定义三种类型的信号:0、1和空闲,其中0和1表示总线上逻辑0和1的传输,而空闲状态是指没有信号被发送的事实。这种信令模式也称为三元信令(ternary signalling),因为使用了三种状态。

我们可以使用LVDS轻松实现三元信令。不妨将LVDS中的导线分别称为A和B,VA是A线的电压,VB是B线的电压。分为以下几种情况:

  • 如果\(\left|V_{A}-V_{B}\right|<\tau\),其中\(\tau\)是检测阈值,就推断线路是空闲的,没有传输任何内容。
  • 如果\(\left|V_{A}-V_{B}\right|>\tau\),表示正在传输逻辑1。
  • 如果\(\left|V_{B}-V_{A}\right|>\tau\),表示正在传输逻辑0。因此,不需要对基本LVDS协议进行任何更改。

后面阐述一组优化用于在物理层中传输多个比特的技术。

19.8.2.4 归零(RZ)协议

此协议会发送一个脉冲(正或负),然后在一个比特周期内暂停一段时间。此处可将比特周期定义为传输比特所需的时间,大多数I/O协议假设比特周期独立于正在传输的比特(0或1)的值,通常,1位周期等于一个I/O时钟周期的长度,I/O时钟是I/O系统元件使用的专用时钟。我们将互换使用术语时钟周期(clock cycle)和位周期(bit period),不强调术语之间的差异。

在RZ协议中,如果希望发送逻辑1,就在链路上发送一个正电压脉冲,持续一个比特周期的一小部分,随后停止发送脉冲,并确保链路上的电压恢复到空闲状态。类似地,当传输逻辑0时,沿着线路发送一个负电压脉冲,持续一个周期的一小部分,随后等待直到线路返回空闲状态。这可通过允许电容器放电,或通过施加反向电压使线路进入空闲状态来实现。在任何情况下,关键点是,当在传输时,传输比特周期的某一部分的实际值,然后允许线路返回到默认状态,不妨假设为空闲状态。返回到空闲状态有助于接收器电路与发送器的时钟同步,从而正确读取数据,这里隐含的假设是,发送方每个周期(发送方周期)发送一个比特。注意,发送器和接收器的时钟周期可能不同。

下图显示了带有三元信令的RZ协议示例。如果要使用二进制信令,就可以有如下的替代方案:

  • 对于逻辑1,我们可以在一个周期内发送一个短脉冲。
  • 对于逻辑0,则不发送任何信号。

主要问题是,通过查看发送逻辑1后的暂停长度,来判断是否正在发送逻辑0,需要在接收器末端设置复杂的电路。

归零(RZ)协议(示例)。

然而,RZ(归零)方法的主要缺点是浪费带宽,需要在传输逻辑0或1之后引入一个短暂的暂停(空闲期)。事实证明,我们可以设计不受此限制的协议。

19.8.2.5 曼彻斯特编码

在讨论曼彻斯特编码之前,让我们区分物理位(physical bit)和逻辑位(logical bit)。到目前为止,我们一直认为它们的意思是一样的,然而从现在起,将不再如此。物理位(如物理1或0)表示链路两端的电压,例如,在有效高电平信令方法中,高电压指示正在传输位1,而低电压(物理位0)指示正在发送0位。然而,现在情况不再如此,因为我们假设逻辑位(逻辑0或1)是物理位值的函数,比如当前和前一个物理位等于10,可以推断逻辑0,同样,可以有不同的规则来推断逻辑1。接收器的工作是将物理信号(或者更确切地说是物理位)转换为逻辑位,并将其传递到I/O系统的更高层。下一层(数据链路层)接受来自物理层的逻辑位,它忽略了信令的性质,以及链路上传输的物理比特的含义。

现在讨论曼彻斯特编码(Manchester Encoding)的机制。这里将逻辑位编码为物理位的转换,下图显示了一个示例。物理位的\(0\rightarrow 1\)转换编码逻辑1,相反,物理位的\(1\rightarrow 0\)转换编码逻辑0。

曼彻斯特代码(示例)。

曼彻斯特码总是有一个转换来编码数据。大多数时候,在一段时间的中间,有一个转换。如果没有转换,可以得出结论,没有信号被传输,链路是空闲的。曼彻斯特编码的一个优点是很容易解码在链路上发送的信息,只需要检测转换的性质,另外,不需要外部闪控信号来同步数据。数据被称为是自计时的(self clocked),意味着可以从数据中提取发送方的时钟,并确保接收方以发送方发送数据的相同速度读取数据。

曼彻斯特编码用于IEEE 802.3通信协议,该协议构成了今天局域网以太网协议的基础。批评者认为,由于每一个逻辑位都与一个转换相关联,我们最终不必要地消耗了大量的能量。每一次转换都需要我们对与链路、驱动器和相关电路相关的一组电容器进行充电/放电。相关的电阻损失作为热量消散,因此让我们尝试减少转换次数。

19.8.2.6 不归零(NRZ)协议

此方法利用了1和0的运行。对于传输逻辑1,将链路的电压设置为高。类似地,对于传输逻辑0,将链路的电压设置为低。现在考虑两个1位的运行。对于第二位,不会在链路中引起任何跃迁,并且将链路的电压保持为高。类似地,如果有n个0。然后,对于最后的(n-1)0,保持链路的低电压,因此没有跃迁。

下图显示了一个示例,我们观察到,当需要传输的逻辑位的值保持不变时,通过完全避免电压转换,已经最小化了转换次数。该协议速度快,因为没有浪费任何时间(例如RZ协议),并且功率效率高,因为消除了相同位运行的转换(与RZ和曼彻斯特码不同)。

不归零协议(示例)。

然而,增加的速度和功率效率是以复杂性为代价的。假设要传输一个100个1的字符串,在这种情况下,只对第一位和最后一位进行转换。由于接收方没有发送方的时钟,因此无法知道比特周期的长度。即使发送器和接收器共享相同的时钟,由于链路中引起的延迟,接收器可能会得出结论,有99或101位的运行,概率为零。因此,必须发送额外的同步信息,以便接收器能够正确读取在链路上发送的所有数据。

不归零(NRZI)反转协议

不归零(NRZI)反转协议是NRZ协议的变体。当希望编码逻辑1时,有一个从0到1或1到0的转换,而对于逻辑0,没有转换。下图显示了一个示例。

19.8.3 物理层:同步子层

传输子层确保脉冲序列从发射机成功地发送到一个接收机或一组接收机。然而还不够,接收机需要在正确的时间读取信号,并且需要假定正确的比特周期。它如果读取信号太早或太晚,就有可能获得错误的信号值。其次,如果它假设了错误的比特周期值,那么NRZ协议可能不起作用,因此,需要保持源和目的地之间的时间概念。目标需要确切地知道何时将值传输到锁存器中。让我们考虑针对单一来源和目的地的解决方案。

总之,同步子层从传输子层接收逻辑比特序列,而没有任何定时保证。它需要计算出比特周期的值,并读入发送方发送的整个数据帧(固定大小的块),然后将其发送到数据链路层。请注意,找出帧边界和在帧中放置比特集的实际工作是由数据链路层完成的。

19.8.3.1 同步总线

首先考虑同步系统的情况,其中发送方和接收方共享相同的时钟,并且将数据从发送方传输到接收方只需一个周期的一小部分,此外,假设发送方一直在发送。让我们把这个系统称为一个简单的同步总线(synchronous bus)。

在这种情况下,发送方和接收方之间的同步任务相当简单。我们知道数据是在时钟的负边缘发送的,在不到一个周期的时间内就到达了接收器,需要避免的最重要的问题是亚稳态。当数据在时钟负边缘附近的一个小时间窗口内发生转变时,触发器进入亚稳态。具体而言,我们希望数据在时钟边缘之前的设置时间间隔内保持稳定,而数据需要在时钟边缘之后的保持时间间隔内稳定。由设置和保持间隔组成的间隔被称为时钟的禁止区域(keep-out region)。

在这种情况下,假设数据在少于\(t_{clk}-t_{setup}\)时间单位的时间内到达接收器,因此不存在亚稳态问题,我们可以将数据读取到接收器的触发器中。由于数字电路通常以较大的块(字节或字)处理数据,在接收器处使用串入——并行地从寄存器出,串行读入n位,并一次性读出n位块。由于发送器和接收器时钟相同,因此没有速率不匹配。接收器的电路如下图所示。

简单同步总线的接收器。

在中时系统中,信号和时钟之间的相位差是一个常数。由于链路中的传播延迟以及发送器和接收器的时钟中可能存在相位差,所以可以在信号中引入相位差。在这种情况下,我们可能会出现亚稳态问题,因为数据可能会到达接收器时钟的关键禁区,因此需要添加一个延迟元件,该延迟元件可以将信号延迟一个固定的时间量,使得在接收器时钟的禁止区域中没有转变。电路的其余部分与用于简单同步总线的电路保持相同。电路设计如下图所示。

中型总线的接收器。

延迟元件可以通过使用延迟锁定环(DLL)来构造,DLL可以有不同的设计,其中一些设计可能相当复杂,一个简单的DLL由一系列反相器组成。注意,我们需要有偶数个反相器,以确保输出等于输入。为了创建一个可调延迟元件,可以在每对反相器之后抽头信号。这些信号在逻辑上等同于输入,但由于反相器的传播延迟而具有渐进相位延迟,然后可以使用多路复用器选择具有特定相位延迟量的信号。

现在考虑一个更现实的情景。在这种情况下,发送方和接收方的时钟可能不完全相同,可能有少量的时钟漂移(drift),可以假设在几十或几百个周期内,它是最小的,然而可以在数百万个周期中有几个周期的漂移。第二,假设发送方不总是传输数据,总线中有空闲时间。这种总线在服务器计算机中可以找到,在服务器计算机上,有多个主板,理论上以相同的频率运行,但不共享一个公共时钟。当考虑数百万周期量级的时间尺度时,处理器之间存在一定的时钟漂移。

现在做一些简单的假设。通常,给定的数据帧包含100或可能1000位。当传输几位(<100)时,不必担心时钟漂移。然而,对于更多的比特(>100),需要周期性地重新同步时钟,以便不会丢失数据。此外,确保接收器时钟的禁止区域中没有跃迁是一个非常重要的问题。

为了解决这个问题,我们使用了一个称为闪控的附加信号,该信号与发送器的时钟同步。在帧传输开始时(或者可能在发送第一个数据比特之前的几个周期)触发闪控脉冲,然后每n个周期周期性地切换闪控脉冲一次。在这种情况下,接收机使用可调谐延迟元件,它根据接收闪控脉冲的时间和时钟转换之间的间隔来调整其延迟。发送闪控脉冲几个周期后,开始传输数据。由于时钟会漂移,需要重新调整或重新调整延迟元件,所以有必要周期性地向接收机发送闪控脉冲。下图显示了数据和闪控的时序图。

准同步总线的时序图。

与中型总线的情况类似,每n个周期,接收机可以使用串行输入——并行输出寄存器并行读出所有n位。接收器的电路如下图所示。我们有一个延迟计算器电路,将闪控脉冲和接收器时钟(rclk)作为输入。基于相位延迟,它调谐延迟元件,使得来自源的数据到达接收机时钟周期的中间。由于以下原因,需要这样做:由于发送方和接收方时钟周期不完全相同,因此可能存在速率不匹配的问题。我们可能在一个接收机时钟周期内得到两个有效数据位,或者根本没有得到位。当一位到达时钟周期的开始或结束时,就会发生这种情况。因此,我们希望确保比特在时钟周期的中间到达,此外,还存在亚稳态避免问题。

准同步总线的接收器。

不幸的是,相位会逐渐改变,比特可能会在时钟周期开始时到达接收器,然后可以在同一周期中接收两个比特。在这种情况下,专用电路需要预测该事件,并预先向发送方发送消息以暂停发送比特。同时,延迟元件应该被重新调谐,以确保比特到达周期的中间。

19.8.3.2 源同步总线

可悲的是,即使是准同步总线也很难制造。在传输信号时,经常会有很大且不可预测的延迟,甚至很难确保紧密的时钟同步。例如,用于在同一主板上的不同处理器之间提供快速I/O路径的AMD超传输协议不采用同步或准同步时钟。其次,该协议假设了高达1个周期的额外抖动(信号传播时间的不可预测性)。

在这种情况下,需要使用更复杂的闪控信号。在源同步总线中,通常将发送器时钟作为选通信号发送,如果在信号传播时间中引入延迟,那么信号和闪控脉冲将受到同等影响。这是一个非常现实的假设,截至2013年,大多数高性能I/O总线都使用源同步总线。源同步总线的电路也不是很复杂,我们使用发送器的时钟(作为闪控信号发送)将数据输入串行输入——并行输出寄存器,它被称为xclk。我们使用接收器的时钟读取数据,如下图所示。通常,每当信号跨越时钟边界时,都需要一个可调谐的延迟元件来将跃迁保持在禁止区域之外。因此有一个延迟计算器电路,它根据作为闪控脉冲接收的发送器时钟(xclk)和接收器时钟(rclk)之间的相位差来计算延迟元件的参数。

源同步总线的接收器。

注意,可以具有多个并行数据链路,从而可以同时发送一组比特,所有数据线可以共享携带同步时钟信号的闪控脉冲。

19.8.3.3 异步总线

现在考虑最通用的总线类,即异步总线。在此,不保证发送方和接收方的时钟同步,也不会将发送器的时钟与信号一起发送。接收器的工作是从信号中提取发送器的时钟,并正确读取数据。让我们看看下图所示的数据读取电路。

异步总线中的接收器电路。

为了便于解释,假设使用NRZ编码位的方法,将设计扩展到其他类型的编码相当容易。由传输子层传递的逻辑比特流被发送到第一D触发器,同时被发送到时钟检测器和恢复电路,这些电路检查I/O信号中的转变,并尝试猜测发送器的时钟。具体而言,时钟恢复电路包含PLL(锁相环),PLL是一种振荡器,它生成时钟信号,并试图调整其相位和频率,使其尽可能接近输入信号中的转变序列。请注意,这是一个相当复杂的操作。

在RZ或曼彻斯特编码的情况下,有周期性跃迁,因此更容易在接收机处同步PLL电路。然而,对于NRZ编码,没有周期性跃迁,因此接收机处的PLL电路可能会失去同步。许多使用NRZ编码的协议(特别是USB协议)在信号中插入周期性转换或伪比特,以使接收器处的PLL重新同步。其次,时钟恢复电路中的PLL还需要处理总线中长时间不活动的问题,在此期间,它可能会失去同步。有先进的方案可以确保从异步信号中正确恢复时钟,本节只粗略地阐述,并假设时钟恢复电路正确地完成其工作。

我们将时钟检测和恢复电路的输出连接到第一个D触发器的时钟输入,因此根据发送者的时钟对数据进行计时。为了避免亚稳态问题,在两个D触发器之间引入了延迟元件,第二个D触发器在接收器的时钟域中。这部分电路与源同步总线的电路相似。

注意,在三元信令的情况下,很容易发现总线何时处于活动状态(当在总线上看到物理0或1时)。然而在二进制信令的情况下,不知道总线何时处于活动状态,因为原则上一直在传输0或1位,因此有必要使用附加闪控信号来指示数据的可用性。现在来看看使用闪控信号来指示总线上数据可用性的协议,闪控信号也可以可选地由三元总线用于指示I/O请求的开始和结束。在任何情况下,使用闪控信号提出的两种方法都是相当基本的,已经被更先进的方法所取代。

假设源希望向目标发送数据。它首先将数据放置在总线上,在一个小的延迟后设置(设置为1)闪控,如下图中的时序图所示。这样做是为了确保在接收器感知到要设置的选通之前,数据在总线上是稳定的。接收器立即开始读取数据值,直到闪控开启,接收器继续读取数据,将其放入寄存器,并将数据块传输到更高层。当源决定停止发送数据时,它重置(设置为0)闪控。注意,这里的时机很重要,通常在停止发送数据之前重置闪控。需要等待完成,因为希望接收器在选通复位后将总线内容视为最后一位。一般而言,希望数据信号在读取后保持其值一段时间(对于亚稳态约束)。

基于闪控的异步通信系统的时序图。

注意,在使用闪控信号的简单异步通信中,源无法知道接收器是否读取了数据。因此,引入了一种握手协议,其中源明确地知道接收器已经读取了其所有数据。相关的时序图如图下图所示。

基于闪控的异步通信系统的时序图。

一开始,发送方将数据放在总线上,然后设置闪控。接收器一观察到要设置的闪控脉冲,就开始从总线上读取数据。读取数据后,它将ack线设置为1。在发射机观察到ack线设置设置为1后,可以确定接收机已读取数据。因此,发射机重置闪控脉冲,并停止发送数据。当接收机观察到闪控脉冲已复位时,它将复位确认线。随后,发射机准备使用相同的步骤序列再次发射。

这一系列步骤确保发射器知道接收器已读取数据的事实。注意,当接收机能够确定它已经读取了发射机希望发送的所有数据时,该图是有意义的,因此设计者大多使用该协议来传输单个比特。在这种情况下,在接收器读取位之后,它可以断言ack线。其次,该方法也与RZ和曼彻斯特编码方法更相关,因为发射机需要在发送新比特之前返回到默认状态。收到确认后,发射机可以开始返回默认状态的过程,如上上图所示。

为了并行传输多个比特,需要为每条数据线设置一个闪控脉冲,然而可以有一条共同的确认线,需要在所有接收器都已读取其位时设置ack信号,并且需要在所有闪控线都已重置时重置ack线。最后,该协议中有四个独立的事件(如图所示)。因此,该协议被称为4阶段握手协议(4-phase handshake protocol)。

如果使用NRZ协议,就不需要返回默认状态,可以在收到确认后立即开始发送下一个比特。然而,在这种情况下,需要稍微改变闪控和确认信号的语义。下图显示了时序图。

具有2相握手的基于闪控的异步通信系统的时序图。

在这种情况下,在将数据放在总线上之后,发射机切换闪控脉冲的值。随后,在读取数据后,接收器切换确认行的值。发射机检测到确认线已切换后,开始发送下一位。短时间后,它切换闪控脉冲的值以指示数据的存在。再次,在读取位之后,接收器切换ack线,因此协议继续。注意,在这种情况下,我们不是设置和重置ack和闪控线,而是切换它们,以减少需要在总线上跟踪的事件数量,然而,需要在发送方和接收方保持一些额外的状态。这是微不足道的开销,因此,4相协议得到了显著简化。NRZ协议更适合这种方法,因为它们具有连续的数据传输,没有任何中间的暂停周期。

简单同步总线:一种简单的同步总线,假设发射机和接收机共享相同的时钟,并且时钟之间没有偏差。

中时总线(Mesochronous Bus):发射器和接收器具有相同的时钟频率,但时钟之间可能存在相位延迟。

准同步总线(Plesiochronous Bus):发射器和接收器的时钟频率之间存在少量不匹配。

源同步总线(Source Synchronous Bus):发射器和接收器的时钟之间没有关系。因此将发射机的时钟与消息一起发送给接收机,以便它可以使用它来对消息中的比特进行采样。

异步总线(Asynchronous Bus):异步总线不假定发射机和接收机的时钟之间有任何关系,通常具有复杂的电路,通过分析消息中的电压转变来恢复发射机的时钟。

19.8.4 数据链路层

数据链路层从物理层获取逻辑位序列。如果串行输入-并行输出寄存器的宽度是n位,那么保证一次获得n位。数据链路层的工作是将数据分成帧,并缓冲帧,以便在其他传出链路上传输。其次,它执行基本的错误检查和纠正。由于电磁干扰,可能会在信号中引起误差,例如,逻辑1可能会翻转为逻辑0,反之亦然。可以在数据链路层中纠正这种单比特错误,如果存在大量错误,并且不可能纠正错误,那么在这个阶段,接收机可以向发射机发送请求重传的消息。错误检查后,如果需要,可以在另一条链路上转发该帧。

可能有多个发送者同时试图访问一条总线。在这种情况下,需要在请求之间进行仲裁,并确保在任何一个时间点只有一个发送方可以发送数据。此过程称为仲裁(arbitration),通常也在数据链路层中执行。最后,仲裁逻辑需要对处理作为事务一部分的请求提供特殊支持,比如到内存单元的总线可能包含作为内存事务一部分的加载请求。作为响应,内存单元发送包含内存位置的内容的响应消息。我们需要在总线控制器级别提供一些额外的支持,以支持这样的消息模式。

概括而言,数据链路层将从物理层接收到的数据分解为帧,执行错误检查,通过允许在单个时间使用单个发射机来管理总线,并优化常见消息模式的通信。

19.8.4.1 分帧和缓存

数据链路层中的处理开始于从物理层读取比特集,可以有一个串行链路,也可以有同时传输比特的多个串行链路。一组多个串行链路称为并行链路。在这两种情况下,我们读入数据,将它们串行保存在——并行输出移位寄存器,并将比特块发送到数据链路层,数据链路层的作用是根据从物理层获得的值创建比特帧。对于从键盘和鼠标传输数据的链路,帧可能是一个字节,对于在处理器和主内存或主内存和图形卡之间传输数据的链路,帧可能高达128个字节。在任何情况下,每个总线控制器的数据链路层都知道帧大小,主要问题是划定帧的边界。帧划分方法有:

  • 通过插入长暂停进行划分。在两个连续帧之间,总线控制器可以插入长暂停。通过检查这些暂停的持续时间,接收机可以推断帧边界。但是,由于I/O通道中的抖动,这些暂停的持续时间可能会发生变化,并且可能会引入新的暂停。此法不是一种非常可靠的方法,而且还浪费宝贵的带宽。
  • 比特计数。可以先验地乘以一帧中的比特数,简单地计算发送的比特数,并在所需的比特数到达接收器后宣布一帧结束。主要问题是,有时由于信号失真,脉冲会被删除,并且很容易失去同步。
  • 位/字节填充。是最灵活的方法,用于大多数商业I/O总线实现。此法使用预先指定的比特序列来指定帧的开始和结束,例如,我以使用模式0xDEADBEF指示帧的开始,使用0x12345678指示帧的结束。帧中任何32位序列在开始和结束时与特殊序列匹配的概率很小,概率等于\(2^{-32}\)\(2:5e-10\)。不幸的是,概率仍然是非零,因此可以采用一个简单的解决方案来解决这个问题。如果序列0xDEADBEF出现在帧的内容中,就再添加32个虚位并重复此模式,比如将位模式0xDEADBEF替换为0xDEADEEFDEADBEF。接收机的链路层可以发现该模式重复偶数次。模式中的一半位是帧的一部分,其余的是伪位,然后接收机可以继续去除伪比特。这种方法是灵活的,它可以对抖动和可靠性问题非常有弹性。这些序列也称为逗点(commas)。

一旦数据链路层创建了一个帧,它就将其发送到错误检查模块,并对其进行缓冲。

19.8.4.2 检错和纠错

由于各种原因,在信号传输中可能引入误差。由于附近运行的其他电子设备,可能会受到外部电磁干扰,比如打开微波炉等电子设备后,可能会注意到手机的音质下降,因为电磁波耦合到I/O通道的铜线并引入电流脉冲。还可能受到附近电线的额外干扰(称为串扰),以及电线传输延迟因温度而发生的变化。累积起来,干扰会引起抖动(在信号的传播时间中引入变化),并引入失真(改变脉冲的形状)。因此,可能错误地将0解释为1,反之亦然。因此,有必要添加冗余信息,以便能够恢复正确的值。

注意,在实践中出错的概率很低,主板上互连的传输率通常不到百万分之一,但也不是一个很小的数字。如果每秒有一百万次I/O操作,通常每秒就会有一次错误,实际上是一个非常高的错误率。因此需要向位添加额外的信息,以便检测错误并从错误中恢复。这种方法被称为前向纠错(forward error correction)。相比之下,在反向纠错(backward error correction)中,我们检测错误,丢弃消息,并请求发送方重新发送。下面讨论流行的错误检测和恢复方案。

单个错误检测

由于单比特错误是相当不可能的,因此在同一帧中出现两个错误的可能性极低。因此,让我们专注于检测单个错误,并假设只有一位由于错误而翻转其状态。

让我们简化问题。假设一帧包含8位,我们希望检测是否存在单位错误。让我们将帧中的比特编号为D1;D2;::;D8。现在让我们添加一个称为奇偶校验位的附加位。奇偶校验位P等于:

\[P=D_{1} \oplus D_{2} \oplus \ldots \oplus D_{8} \]

这里,\(\oplus\)操作是XOR运算符,简而言之,奇偶校验位表示所有数据位(D1 ... D8)的XOR。对于每8位,我们发送一个额外的位,即奇偶校验位(parity bit),因此将8位消息转换为等效的9位消息。在这种情况下,以更高的可靠性为代价,有效地增加了可用带宽12.5%的开销。下图显示了使用8位奇偶校验方案的帧或消息的结构。注意,还可以通过将单独的奇偶校验位与8个数据位的每个序列相关联来支持更大的帧大小。

带有奇偶校验位的8位消息。

当接收器接收到消息时,它通过计算8个数据位的XOR来计算奇偶性。如果该值与奇偶校验位匹配,就可以断定没有错误,但如果消息中的奇偶校验位与计算出的奇偶校验比特的值不匹配,就可以得出结论,存在单个比特错误。错误可能出现在消息中的任何数据位中,甚至可能出现在奇偶校验位中。在这种情况下,无从得知,所能检测到的只是存在一个位错误。现在尝试纠正错误。

单个错误校正

要纠正单个位错误,如果有错误,需要知道已丢弃的位的索引,现在统计一下可能的结果。对于n位块,需要知道有错误的位的索引,此种情况下,可以有n个可能的索引,没有错误,因此对于单个纠错(SEC)电路,总共有n+1个可能结果(n个结果有错误,一个结果无错误)。因此从理论角度来看,需要\([\log(n+1)]\)个额外的比特,比如对于8位帧,需要\([\log(8+1)]=4\)位。让我们设计一个(8,4)代码,每8位数据字有四个附加位。

让我们从扩展奇偶校验方案开始。假设四个附加比特中的每一个都是奇偶校验比特,但它们不是整个数据位集合的奇偶校验函数,相反,每个比特是数据比特子集的奇偶校验。四个奇偶校验位分别命名为P1、P2、P3和P4,此外,排列8个数据位和4个奇偶校验比特,如下图所示。

数据和奇偶校验位的排列。

将奇偶校验位P1、P2、P3和P4分别保持在位置1、2、4和8,将数据位D1...D8分别排列在位置3、5、6、7、9、10、11和12,下一步是为每个奇偶校验位分配一组数据位。用二进制表示每个数据位的位置,在这种情况下,需要4个二进制位,因为需要表示的最大数字是12。现在将第一个奇偶校验位P1与其位置(以二进制表示)的LSB为1的所有数据位相关联,在这种情况下,以1作为LSB的数据位是D1(3)、D2(5)、D4(7)、D5(9)和D7(11)。因此,将奇偶校验位P1计算为:

\[P_{1}=D_{1} \oplus D_{2} \oplus D_{4} \oplus D_{5} \oplus D_{7} \]

类似地,将第二奇偶校验位P2与在其第二位置具有1的所有数据位相关联(假设LSB位于第一位置),对第3和第4奇偶校验位使用类似的定义。

下表显示了数据和奇偶校验位之间的关联。“X”表示给定的奇偶校验位是数据位的函数。基于此表,我们得出以下等式来计算奇偶校验位。

)

数据和奇偶校验位的关系。

消息传输的算法如下。根据下面的等式计算奇偶校验位,然后将奇偶校验位分别插入位置1、2、4和8,并根据上上图通过添加数据位形成消息。一旦接收机的数据链路层收到消息,它首先提取奇偶校验位,并形成由四个奇偶校验位组成的形式为\(P=P_4P_3P_2P_1\)的数字,例如如果P1=0,P2=0,P3=1,P4=1,则P=1100。随后,接收器处的错误检测电路从接收到的数据位中计算出一组新的奇偶校验位(\(P'_1,P'_2,P'_3,P'_4\)),并形成形式为\(P^{\prime}=P_{4}^{\prime} P_{3}^{\prime} P_{2}^{\prime} P_{1}^{\prime}\)的另一个数。理想情况下,P应该等于P0,但如果数据或奇偶校验位中存在错误,则情况不会如此。让我们计算\(P\oplus P'\),这个值也称为伴随式(syndrome)

\[\begin{aligned} P_{1}=& D_{1} \oplus D_{2} \oplus D_{4} \oplus D_{5} \oplus D_{7} \\ P_{2}=& D_{1} \oplus D_{3} \oplus D_{4} \oplus D_{6} \oplus D_{7} \\ & P_{3}=D_{2} \oplus D_{3} \oplus D_{4} \oplus D_{8} \\ & P_{4}=D_{5} \oplus D_{6} \oplus D_{7} \oplus D_{8} \end{aligned} \]

现在尝试将伴随式的值与错误位的位置相关联。首先假设奇偶校验位中存在错误,在这种情况下,下表中的前四个条目显示了消息中错误位的位置和伴随式的值,伴随式的值等于消息中错误位的位置。奇偶校验位分别位于位置1、2、4和8,因此如果任何奇偶校验位有错误,其校正子中的对应位被设置为1,其余位保持为0。因此,校正子匹配错误位的位置。

错误位置与伴随式之间的关系。

现在考虑数据位中的单位错误的情况。再次从上表中可以得出结论,伴随式与数据位的位置相匹配,因为一旦数据位出现错误,所有相关的奇偶校验位都会被翻转。例如,如果D5有错误,则奇偶校验位P1和P4被翻转。回想一下,将P1和P4与D5关联的原因是因为D5是位号9(1001),而9的二进制表示中的两个1分别位于位置1和4。随后,当D5中存在错误时,校正子等于1001,这也是消息中位的索引。同样,每个数据和奇偶校验位都有一个独特的校正子(参见上上表)。

因此可以得出结论,如果存在错误,则伴随式指向错误位(数据或奇偶校验)的索引。如果没有错误,则伴随式等于0。因此有了检测和纠正单个错误的方法。这种用附加奇偶校验位编码消息的方法称为SEC(single error correction,单纠错)码。

单错误校正,双错误检测(SECDED)

现在尝试使用SEC代码来额外检测双重错误(两位错误)。举一个反例,证明基于伴随式的方法是行不通的。假设位D2和D3中存在错误,伴随式将等于0111,但如果D4中存在错误,伴随式也将等于0112。因此,无法知道是否存在单位错误(D4)或双位错误(D2和D3)。

稍微扩充一下算法来检测双重错误。添加一个额外的奇偶校验位P5,它计算SEC代码中使用的所有数据位(D1…D8)和四个奇偶校验位(P1…P4)的奇偶校验,然后将P5添加到消息中。将其保存在信息的第13位,并将其排除在计算伴随式的过程中。新算法如下。首先使用与SEC(单一错误校正)代码相同的过程来计算伴随式。如果伴随式为0,则不会有错误(单或双)。通过查看上上表,可以很容易地验证单个错误的证明。对于双重错误,假设两个奇偶校验位被翻转,在这种情况下,伴随式将有两个1。类似地,如果两个数据位被翻转,则伴随式将至少有一个1位,因为上上表中没有两个数据比特具有相同的列。现在,如果一个数据和一个奇偶校验位被翻转了,那么伴随式也将为非零,因为一个数据比特与多个奇偶校验比特相关联。正确的奇偶校验位将指示存在错误。

因此,如果伴随式是非零的,就可以怀疑有错误;否则假设没有错误。如果有错误,查看消息中的位P5,并在接收器处重新计算。让我们将重新计算的奇偶校验位指定为P'5。现在,如果P5=P'5,那么我们可以得出结论,存在双位错误。在计算最终奇偶校验时,两个单比特错误基本上是相互抵消的。相反,如果P5不等于P'5,则意味着有一个位错误。可以使用此检查来检测两位或一位是否有错误,如果有一个位错误,那么也可以纠正它。然而,对于双位错误,只能检测它,并可能要求源重新传输。该代码通常称为SECDED代码。

汉明码(Hamming Code)

迄今为止描述的所有代码都被称为汉明代码,因为它们隐含地依赖于汉明距离,汉明距离是两个二进制比特序列之间不同的对应比特数。例如,0011和1010之间的汉明距离为2(MSB和LSB不同)。

现在考虑一个4位奇偶校验码。如果消息为0001,则奇偶校验位等于1,且奇偶校验位位于MSB位置的发送消息为10001。不妨将发送消息称为码字(code word)。注意,00001不是一个有效的码字,接收者将依靠这个事实来判断是否存在错误,事实上,在有效码字的汉明距离1内没有其他有效码字。同样,对于SEC代码,码字之间的最小汉明距离为2,对于SECDED代码,最小汉明距离为3。现在考虑一种同样非常流行的不同类型的代码。

汉明纠错码。

汉明SEC-DEC码。

循环冗余校验(CRC)码

CRC(yclic Redundancy Check,循环冗余校验)码主要用于检测错误,即使在大多数情况下它们可以用于纠正单比特错误。为了激励CRC码的使用,让我们看看实际I/O系统中的错误模式。通常在I/O通道中,干扰持续时间比位周期长。例如,如果有一些外部电磁干扰,那么它可能会持续几个周期,并且可能会有几个比特被翻转。这种错误模式称为突发错误(burst error)。例如,32位CRC码可以检测长达32位的突发错误,它通常可以检测大多数2位错误和所有单位错误。

CRC码背后的数学十分复杂,感兴趣的读者可以参考有关编码理论的文本,下面展示一个小示例。

假设我们希望为8位消息计算4位CRC码。让消息等于二进制的101100112,第一步是将消息填充4位,即CRC码的长度,因此新消息等于10110011 0000(增加了一个空格以提高可读性)。CRC码需要另一个5位数字,即生成多项式或除数。原则上,需要将消息表示的数字除以除数表示的数字,剩余部分是CRC码。然而这种划分不同于常规划分,它被称为模2除法。在这种情况下,假设除数是110012。对于n位CRC码,除数的长度是n+1位。

现在阐述算法。首先将除数的MSB与消息的MSB对齐,如果消息的MSB等于1,就计算第一个n+1位和除数的XOR,并用结果替换消息中的相应位。否则,如果MSB为0,则不执行任何操作。在下一步中,将除数向右移动一步,将消息中与除数的MSB对齐的位视为消息的MSB,然后重复相同的过程。继续这一系列步骤,直到除数的LSB与消息的LSB对齐。最后,最小有效n(4位)包含CRC码。对于发送消息,在消息中附加CRC码。接收机重新计算CRC码,并将其与消息附加的码匹配。

具体示例,显示计算4位CRC码的步骤,其中消息等于10110011,除数等于11001。算法过程示意图如下:

此图忽略了消息相关部分的MSB为0的步骤,因为在这些情况下,无需执行任何操作。

Reed-Solomon

汉明码在人们可以合理预期错误是罕见事件的情况下工作得很好,固定磁盘驱动器的错误率约为1亿分之一,3位汉明码将很容易纠正这种错误,但汉明码在多个相邻比特可能被损坏(即突发错误)的情况下是无用的。由于它们暴露于处理不当和环境压力,在磁带和光盘等可移动介质上,突发错误很常见。

如果期望错误发生在块中,就应该使用基于块级(block level)操作的纠错码,而不是比特级(bit level)操作的汉明码。Reed-Solomon(所罗门,RS)码可以被认为是一种CRC,它在整个字符上运行,而不仅仅是几个比特。RS码和CRC一样,都是系统化的:奇偶校验字节被附加到一个信息字节块上。使用以下参数定义\(RS(n, k)\)码:

  • \(s\) = 字符(或“符号”)中的位数。
  • \(k\) = 构成数据块的s位字符数。
  • \(n\) = 码字中的位数。

\(RS(n, k)\)可以校正k个信息字节中的\(\cfrac{n-k}{2}\)个错误。因此,流行的\(RS(255, 223)\)码使用223个8位信息字节和32个伴随式字节来形成255字节的码字,它将纠正信息块中多达16个错误字节。RS码的生成多项式由一个定义在抽象数学结构(称为Galois域)上的多项式给出,RS生成多项式为:

\[g(x)=\left(x-a^{i}\right)\left(x-a^{i+1}\right) \ldots\left(x-a^{i+2 t}\right) \]

其中\(t=n− k\)\(x\)是整个字节(或符号),并且\(g(x)\)在字段\(GF(2^S)\)上操作。注意,这个多项式在Galois域上展开,与普通代数中使用的整数域有很大不同。使用以下等式计算\(n\)字节\(RS\)码字:

\[c(x)=g(x) \times i(x) \]

其中\(i(x)\)是信息块。尽管RS纠错算法背后有令人望而生畏的代数,但它很适合在计算机硬件中实现,在大型计算机的高性能磁盘驱动器以及用于音乐和数据存储的光盘中实现。

19.8.4.3 仲裁

现在阐述总线仲裁(arbitration)的问题,“仲裁”一词的字面意思是“解决争端”。考虑一种多点总线,那里可能有多个发射机。如果多个发射机有兴趣通过总线发送值,需要确保在任何时间点只有一个发射机可以在总线上发送值。因此,需要一个仲裁策略来选择可以通过总线发送数据的设备。如果有点对点总线,其中有一个发送器和一个接收器,那么不需要仲裁。如果有不同类型的消息等待传输,就需要根据一些最优性标准来调度链路上的消息传输。

设想了一种称为仲裁器(arbiter)的专用结构,它执行总线仲裁的任务。所有设备都连接到总线和仲裁器,它们通过向仲裁器发送消息来表示它们愿意传输数据。仲裁器选择其中一个设备,有两种拓扑用于将设备连接到仲裁器。可以使用星形(star like)拓扑,也可以使用菊花链拓扑(daisy chain)。接下来的小节中讨论这两种方案。

星形拓扑

在这个集中式协议中,有一个称为仲裁器的中央实体,它是一个专用电路,接受来自所有希望在总线上传输的设备的总线请求。它强制执行优先级和公平性政策,并授予单个设备在总线上发送数据的权利。具体来说,在请求完成后,仲裁器查看所有当前请求,然后为选择发送数据的设备断言总线授权信号。所选设备随后成为总线主控器并获得总线的独占控制,然后它可以适当地配置总线,并传输数据。系统概述如下图所示。

集中的基于仲裁者的架构。

我们可以采用两种方法来确定当前请求何时完成。第一种方法是,连接到总线的每个设备在给定的周期数n内进行传输,在这种情况下,在经过n个周期后,仲裁器可以自动假设总线是空闲的,并且可以调度另一个请求。然而,情况可能并不总是这样,可能有不同的传输速度和不同的消息大小,在这种情况下,每个发送设备都有责任让仲裁器知道这已经完成。我们设想了一个额外的信号总线释放,每个设备都有一条到仲裁器的专用线路,用于发送总线释放信号。一旦完成了传输过程,它就断言这条线(将其设置为1)。随后,仲裁器将总线分配给另一个设备。它通常遵循标准策略,如循环或FIFO。

基于菊花链的仲裁

如果有多个设备连接到一条总线,仲裁器需要知道所有设备及其相对优先级。此外,当增加连接到总线的设备数量时,仲裁器开始出现高争用,并且变得缓慢。因此,希望有一个方案,可以容易地执行优先级,保证一定程度的公平性,并且不会在增加连接设备的数量时导致总线分配决策的缓慢。菊花链总线是考虑到所有这些要求而提出的。

下图显示了基于菊花链的总线的拓扑结构。该拓扑结构类似于线性链,一端有仲裁器。除最后一个设备外,每个设备都有两个连接。协议开始如下。一个设备从断言其总线请求线开始,所有设备的总线请求线以有线或方式连接,到仲裁器的请求线本质上计算所有总线请求线的逻辑或。随后,如果仲裁器具有令牌,则仲裁器将令牌传递给与其连接的设备,否则需要等待仲裁器获得释放信号。一旦设备获得令牌,它就成为总线主机,如果需要,它可以在总线上传输数据。发送消息后,每个设备将令牌传递给链上的下一个设备,该设备也遵循相同的协议。如果需要,它会传输数据,否则只传递令牌。最后,令牌到达链的末端。链上的最后一个设备断言总线释放信号,并销毁令牌,释放信号是所有总线释放信号的逻辑或。一旦仲裁器观察到要断言的释放信号,它就会创建一个令牌。在看到请求行设置为1后,它会将此令牌重新插入菊花链。

菊花链架构。

这个方案有几个微妙的优点。首先,有一个隐含的优先权概念,连接到仲裁器的设备具有最高优先级。渐渐地,当离开仲裁器时,优先级会降低。其次,该协议具有一定程度的公平性,因为在高优先级设备放弃令牌之后,它无法再次取回令牌,直到所有低优先级设备都获得令牌,所以设备不可能单独等待。其次,很容易将设备插入和移除到总线,我们从不维护设备的任何单独状态,所有到仲裁器的通信都是聚合的,我们只计算总线请求和总线释放线的OR函数。设备必须保持的唯一状态是关于其在菊花链中的相对位置以及其近邻的地址的信息。

我们也可以有完全避免中央仲裁器的纯分布式方案。在这种方案中,所有节点都独立地做出决策,但这种方案很少使用。

19.8.4.4 面向事务的总线

到目前为止,我们只关注单向通信,在任何一个时间点,只有一个节点可以向其他节点进行传输。现在考虑更现实的总线,实际上大多数高性能I/O总线都不是多点总线。多点总线可能允许多个发射机,尽管不是在同一时间点,现代I/O总线是点对点总线,通常有两个端点。其次,I/O总线通常由两条物理总线组成,因此可以进行双向通信。例如,如果有一条连接节点A和B的I/O总线,就可以同时向彼此发送消息。

一些早期的系统有一条总线,将处理器直接连接到内存。在这种情况下,处理器被指定为主处理器,因为它只能启动总线消息的传输。内存被称为从属内存,它只能响应请求。如今,主和从的概念已经淡化,但并发双向通信的概念仍然很普遍。双向总线被称为双工总线(duplex bus)全双工总线(full duplex bus)。相比之下,可以使用半双工总线(half duplex bus),它只允许一方在任何时间点进行传输。

下图中的内存控制器芯片和DRAM模块之间的双工通信的典型场景,显示了内存读取操作的消息顺序和时序。实际上有两条总线。第一总线将存储器控制器连接到DRAM模块,它由地址线(承载存储器地址的线)和承载专用控制信号的线组成,控制信号指示操作的定时以及需要在DRAM阵列上执行的操作的性质。第二总线将DRAM模块连接到内存控制器,包括数据线(承载从DRAM读取的数据的线)和定时线(传送定时信息的线)。

DRAM读取时序。

协议如下。内存控制器通过断言RAS(行地址选通)信号开始,RAS信号激活设置字线值的解码器。同时,内存控制器将行的地址放置在地址线上,它估计了DRAM模块读取行地址所需的时间(\(t_{row}\))。在\(t_{row}\)时间单位后,它断言CAS信号(列地址选通),并将列的地址放在总线上的DRAM阵列中,它还使能向DRAM模块指示它需要执行读访问的读信号。随后,DRAM模块读取存储器位置的内容并将其传输到其输出缓冲器。然后它断言就绪信号,并将数据放在总线上。但此时内存控制器不是空闲的,它开始在总线上放置下一个请求的行地址。注意,DRAM访问的时序非常复杂,连续消息的处理通常是重叠的,例如当第n个请求正在传输其数据时,我们可以继续解码第(n+1)个请求的行地址,可减少DRAM延迟,但为了支持这一功能,需要双工总线和复杂的消息序列。

让注意上图所示的基本DRAM访问协议的一个显著特征,请求和响应彼此之间的耦合非常强,源(存储器控制器)知道目的地(DRAM模块)的复杂性,并且源和目的地发送的消息的性质和定时之间存在强烈的相互关系。其次,在请求期间,内存控制器和DRAM模块之间的I/O链路被锁定,我们无法为原始请求和响应之间的任何干预请求提供服务。这种消息序列被称为总线事务(bus transaction)

面向事务的总线有利弊。首先是复杂性,它们对接收机的定时做了很多假设,因此消息传输协议对于每种类型的接收机都非常特殊,对可移植性不利,插入具有不同消息语义的设备变得非常困难。此外,总线可能会被锁定很长一段时间,并有空闲时间,会浪费带宽。然而,在一些场景中,例如我们所展示的示例中,面向事务的总线表现非常好,并且优于其他类型的总线。

19.8.4.5 拆分事务总线

现在看一下试图纠正面向事务总线缺点的拆分事务总线,我们不假设不同节点之间的消息序列是严格的,比如对于DRAM和内存控制器示例,将消息传输分成两个较小的事务。首先,内存控制器向DRAM发送内存请求,DRAM模块缓冲消息,并继续进行内存访问。随后,它向内存控制器发送一个单独的消息,其中包含来自内存的数据,两个消息序列之间的间隔可以任意大。这种总线被称为拆分事务总线(split transaction bus),它将一个较大的事务拆分为更小、更短的单个消息序列。

这里的优点是简单性和可移植性,所有的传输基本上都是单向的。我们发送一条消息,然后不通过锁定总线来等待它的回复,发送者继续处理其他消息。每当接收器准备好响应时,它都会发送一条单独的消息。除了简单,这种方法还允许我们将各种接收器连接到总线,只需要定义一个简单的消息语义,任何符合该语义的接收器电路都可以连接到总线。我们不能使用此总线执行复杂的操作,例如重叠多个请求和响应,以及细粒度的定时控制。对于此类需求,可以始终使用支持事务的总线。

19.8.5 网络层

前面小节研究了如何设计全双工总线,具体来说,研究了信令、信号编码、定时、分帧、错误检查和事务相关问题。现在可以假设I/O总线在端点之间正确地传递消息,并确保及时和正确的传递。现在看看整个芯片组,它本质上是一个大型I/O总线网络。

本节解决的问题与I/O寻址有关。例如,如果处理器希望向USB端口发送消息,则需要有一种唯一寻址USB端口的方法。随后,芯片组需要确保将消息正确路由到适当的I/O设备。类似地,如果键盘等设备需要将按键的ASCII码发送给处理器,则需要有一种寻址处理器的方法。本节将查看芯片组中的路由消息。

19.8.5.1 I/O端口寻址

硬件I/O端口称为外部连接设备的连接端点。现在考虑一个软件端口,将其定义为一个抽象实体,它对软件来说是一个寄存器或一组寄存器,例如USB端口物理上包含一组金属管脚及运行USB协议的端口控制器。然而USB端口的软件版本,是一组可寻址的寄存器,如果希望写入USB设备,就将写入由USB端口暴露给软件的一组寄存器,USB端口控制器通过将处理器发送的数据物理写入连接的I/O设备来实现软件抽象。同样,为了读取I/O设备通过USB端口发送的值,处理器发出读取相应的端口控制器将I/O设备的输出转发给处理器。

下图说明这个概念。图中有一个物理硬件端口,它有一组金属引脚,以及实现物理和数据链路层的相关电路。端口控制器通过处理器发送的完整请求实现网络层,它还公开了一组8到32位寄存器,这些寄存器可以是只读、只读或读写的,例如,显示器等显示设备的端口包含只读寄存器,因为无法从中获取任何输入。类似地,鼠标的端口控制器包含只读寄存器,而扫描仪的端口控制器则包含读写寄存器,因为通常向扫描仪发送配置数据和命令,并从扫描仪读取文档的图像。

I/O端口的软件接口。

例如,Intel处理器需要64K(216)个8位I/O端口,可以将4个连续端口融合为32位端口,这些端口相当于汇编代码可访问的寄存器。其次,诸如以太网端口或USB端口的给定物理端口可以具有分配给它们的多个这样的软件端口,例如,如果希望一次性将大量数据写入以太网,就可能会使用数百个端口。Intel处理器中的每个端口都使用从0到0xFFFF不等的16位数字进行寻址,类似地,其他架构定义了一组I/O端口,这些端口充当实际硬件端口的软件接口。

让我们将术语I/O地址空间定义为操作系统和用户程序可访问的所有I/O端口地址的集合。I/O地址空间中的每个位置对应于一个I/O端口,该端口是物理I/O端口控制器的软件接口。

大多数指令集架构有两条指令:输入和输出。指令的语义如下。

指令 语义
in r1, <I/O port> I/O端口的内容传输到r1寄存器。
out r1, <I/O port> r1寄存器的内容传输到I/O端口。

in指令将数据从I/O端口传输到寄存器。相反,out指令将数据从寄存器传输到I/O端口,是一种用于编程I/O设备的通用通用机制。例如,如果想要打印一页,就可以将整个页面的内容传输到打印机的I/O端口,最后将打印命令写入接受打印机命令的I/O端口,随后打印机可以开始打印。

现在让我们执行输入和输出指令。第一个任务是确保消息到达适当的端口控制器,第二个任务是在输出指令的情况下将响应路由回处理器。

让我们再次看看之前章节涉及的主板架构。CPU通过前端总线连接到北桥芯片,DRAM内存模块和图形卡也连接到北桥芯片,北桥芯片连接到处理速度较慢的设备的南桥芯片,南桥芯片连接到USB端口、PCI Express总线(及其连接的所有设备)、硬盘、鼠标、键盘、扬声器和网卡。这些设备中的每一个都有一组相关的I/O端口和I/O端口号。

通常,主板设计者有分配I/O端口的方案。让我们尝试构建一个这样的方案,假设有64K个8位I/O端口,就像Intel处理器一样,I/O端口的地址范围从0到0xFFFF。首先,将I/O端口分配给连接到北桥芯片的高带宽设备,给他们0到0x00FF范围内的端口地址,为连接到南桥芯片的设备划分其余地址,假设硬盘的端口范围为0x0100到0x0800,让USB端口的范围为0x0801到0x0FFF,为网卡分配以下范围:0x1000到0x4000,将剩余的几个端口分配给其他设备,并为以后可能要连接的任何新设备保留一部分空白。

现在,当处理器发出I/O指令(输入或输出)时,处理器识别出这是一条I/O指令,通过FSB(前端总线)向北桥芯片发送I/O端口地址和指令类型,北桥芯片为每种I/O端口类型及其位置维护一个范围表。一旦它看到来自处理器的消息,它就会访问这个表并找出目标的相对位置。如果目的地是直接连接到它的设备,则北桥芯片将消息转发到目的地。否则,它将请求转发到南桥芯片,南桥芯片维护I/O端口范围和设备位置的类似表。在该表中执行查找后,它将接收到的消息转发到适当的设备。这些表称为I/O路由表(I/O routing table),I/O路由表在概念上类似于大型网络和互联网使用的网络路由表。

对于反向路径,通常将响应发送到处理器。我们为处理器分配了一个唯一的标识符,消息由北桥和南桥芯片适当地路由。有时需要将消息路由到内存模块,使用类似的寻址方案。

该方案本质上将物理I/O端口集映射到I/O地址空间中的位置,专用I/O指令使用端口地址与它们通信。这种访问和寻址I/O设备的方法通常称为I/O映射I/O( I/O mapped I/O)

19.8.5.2 内存映射寻址

现在再次查看输入和输出I/O指令。执行程序需要了解I/O端口的命名方案,不同的芯片组和主板可能使用不同的I/O端口地址,例如,一块主板可能会为USB端口分配I/O端口地址范围0xFF80到0xFFC0,另一块主板则可能会分配范围0xFEA0到0xFFB0。因此,在第一块主板上运行的程序可能无法在第二块主板上工作。

为了解决这个问题,需要在I/O端口和软件之间添加一个附加层,提出一种类似于虚拟内存的解决方案。事实上,虚拟化是解决计算机架构中各种问题的标准技术,后续继续设计用户程序和I/O地址空间之间的虚拟层。

假设在操作系统中有一个专用的设备驱动程序,该驱动程序专用于芯片组和主板。它需要了解I/O端口的语义,以及它们到实际设备的映射。考虑一个希望访问USB端口的程序(用户程序或操作系统),一开始,它不知道USB端口的I/O端口地址,因此它需要首先请求操作系统中的相关模块将其虚拟地址空间中的存储器区域映射到I/O地址空间的相关部分。例如,如果USB设备的I/O端口在0xF000到0xFFFF之间,那么I/O地址空间中的这个4 KB区域可以映射到程序虚拟地址空间中一个页面。需要在TLB和页表条目中添加一个特殊的位,以指示该页实际上映射到I/O端口。其次,需要存储I/O端口地址,而不是存储物理帧的地址。主板驱动程序的角色是操作系统的一部分,用于创建此映射。在操作系统将I/O地址空间映射到进程的虚拟地址空间之后,进程可以继续进行I/O访问。注意,在创建映射之前,需要确保程序具有足够的权限来访问I/O设备。

创建映射后,程序可以自由访问I/O端口。它使用常规加载和存储指令来写入虚拟地址空间中的位置,而不是使用I/O指令(如in和out)。在这样的指令到达流水线的内存访问(MA)阶段之后,有效地址被发送到TLB以进行转换。如果有TLB命中,那么管线也会意识到虚拟地址映射到I/O地址空间而不是物理地址空间的事实。其次,TLB还将虚拟地址转换为I/O端口地址,注意,在这个阶段不需要使用TLB,可以使用另一个专用模块来转换地址。在任何情况下,处理器在MA阶段接收等效的I/O端口地址。随后,它创建一个I/O请求并将该请求分派到I/O端口。

内存映射I/O是一种通过将I/O地址空间中的每个地址分配给进程虚拟地址空间中唯一的地址来寻址和访问I/O设备的方案。对于访问I/O端口,该过程使用常规加载和存储指令。

这种方案称为内存映射I/O,其主要优点是它使用常规加载和存储指令来访问I/O设备,而不是专用的I/O指令。其次,程序员不需要知道I/O地址空间中I/O端口的实际地址。由于操作系统和内存系统中的专用模块在I/O地址空间和进程的虚拟地址空间之间建立了映射,因此程序可以完全忽略寻址I/O端口的语义。

19.8.6 协议层

现在讨论I/O系统中的最后一层。前三层确保消息从I/O系统中的一个设备正确传递到另一个设备,现在看看完整I/O请求的级别,例如打印整个页面、扫描整个文档或从硬盘读取一大块数据。以打印文档为例。

假设打印机连接到USB端口。打印机设备驱动程序首先指示处理器将文档的内容发送到与USB端口关联的缓冲区,假设每个这样的缓冲区都分配了唯一的端口地址,并且整个文档都在缓冲区集中,此外假设设备驱动程序知道缓冲区是空的。要发送文档内容,设备驱动程序可以使用一系列输出指令,也可以使用内存映射的I/O。传输文档内容后,最后一步是将PRINT命令写入预先指定的I/O端口。USB控制器管理与其相关的所有I/O端口,并确保发送到这些端口的消息发送到连接的打印机,打印机在从USB控制器接收到PRINT命令后开始打印作业。

假设用户单击另一个文档的打印按钮。在将新文档发送到打印机之前,驱动程序需要确保打印机已完成前一文档的打印。此处的假设是,有一个简单的打印机,一次只能处理一个文档。因此,应该有一种方法让驱动程序知道打印机是否空闲。

在研究打印机与其驱动程序通信的不同机制之前,考虑一个类比场景,在这个场景中,Sofia正在等待一封信送达。如果这封信是通过Sofia的一个朋友寄来的,那么Sofia可以继续给她的朋友打电话,询问她何时会回来,一旦她回来,Sofia就可以去她家收信了。或者,发件人可以通过快递服务发送信件,在这种情况下,Sofia只需要等待快递员来送信。前者接收消息的机制称为轮询(polling),后者称为中断(interrupt)。后续小节会详细说明。

19.8.6.1 轮询

假设打印机中有一个名为状态寄存器的专用寄存器,用于维护打印机的状态。每当打印机的状态发生变化时,它都会更新状态寄存器的值。假设状态寄存器可以包含两个值,即0(空闲)和1(忙),当打印机打印文档时,状态寄存器的值为1(忙碌),当打印机完成文档打印时,它将状态寄存器的值设置为0(空闲)。

现在假设打印机驱动程序希望读取打印机的状态寄存器的值。它向打印机发送一条消息,要求它获取状态寄存器的数值。发送消息的第一步是向USB端口控制器的相关I/O端口发送字节序列,端口控制器依次将字节发送到打印机。如果它使用拆分事务总线,那么它将等待响应到达。同时,打印机解释消息,并将状态寄存器的值作为响应发送,USB端口控制器通过I/O系统将其转发给处理器。

如果打印机空闲,则设备驱动程序可以继续打印下一个文档,否则,它需要等待打印机完成。它可以继续向打印机请求状态,直到打印机空闲。这种反复查询设备状态直到其状态具有特定值的方法称为轮询(polling)

轮询(polling)是一种等待I/O设备达到给定状态的方法,是通过在循环中重复查询设备的状态来实现的。

下面展示一段在假设系统中实现轮询的汇编代码,假设获取打印机状态的消息是0xDEADBEEF,需要首先将消息发送到I/O端口0xFF00,然后从I/O端口0xFF04读取响应。

/* 加载DEADBEEF到r0 */
movh r0, 0xDEAD
addu r0, r0, 0xBEEF

/* 轮询的循环 */
.loop:
    out r0, 0xFF00
    in r1, 0xFF04
    cmp r1, 1
    beq .loop /* 保持循环,直到status = 1 */

19.8.6.2 中断

基于轮询的方法有几个缺点。它使处理器保持忙碌,浪费电力,并增加I/O流量,可以改用中断。想法是向打印机发送消息,通知处理器何时空闲。打印机空闲后,或者如果打印机已经空闲,打印机会向处理器发送中断。I/O系统通常将中断视为常规消息,然后它将中断传递给处理器或专用中断控制器,这些实体意识到中断来自I/O系统。随后,处理器停止执行当前程序,并跳转到中断处理程序。

请注意,每个中断都需要标识自己或生成它的设备,主板上的每个设备通常都有一个唯一的代码,此代码是中断的一部分。在某些情况下,当我们将设备连接到通用端口(如USB端口)时,中断代码包含两部分,其中一部分是主板上连接到外部设备的端口的地址,另一部分是由主板上的I/O端口分配给设备的id。这种包含唯一代码的中断称为矢量中断(vectored interrupt)

在某些系统(如x86机器)中,中断处理的第一阶段由可编程中断控制器(PIC)完成,这些中断控制器在x86处理器中称为APIC(高级可编程中断控制器),其作用是缓冲中断消息,并根据一组规则将它们发送给处理器。

现在看看PIC遵循的一套规则。大多数处理器在计算的某些关键阶段禁用中断处理,例如,当中断处理程序保存原始程序的状态时,我们不能允许处理器中断。成功保存状态后,中断处理程序可能会重新启用中断。在某些系统中,每当中断处理程序运行时,中断都会被完全禁用。一个密切相关的概念是中断屏蔽,它选择性地启用一些中断,并禁用一些其他中断,例如,我们可以在处理中断处理程序期间允许温度控制器的高优先级中断,并选择暂时忽略硬盘的低优先级中断。PIC通常有一个向量,每个中断类型有一个条目。它被称为中断掩码向量(interrupt mask vector)。对于中断,如果中断掩码向量中的对应位为1,则中断被启用,否则被禁用。

最后,如果在同一时间窗口内有多个中断到达,PIC需要尊重中断的优先级,比如来自具有实时约束的设备(如连接的高速通信设备)的中断具有较高的优先级,而键盘和鼠标中断具有较低的优先级。PIC使用考虑到其优先级和到达时间的启发式方法对中断进行排序,并按照该顺序将其呈现给处理器。随后,处理器根据前述章节说明的方法处理中断。

矢量中断(Vectored Interrupt):包含生成中断的设备id或连接到外部设备的I/O端口地址的中断。

可编程中断控制器(Programmable Interrupt Controller,PIC):用来缓冲、替换和管理发送给处理器的中断。

中断屏蔽(Interrupt Masking):用户或操作系统可以选择在程序的某些关键阶段(如运行设备驱动程序和中断处理程序时)选择性地禁用一组中断。这种机制被称为中断屏蔽。PIC中的中断掩码向量通常是位向量(每种中断类型一位),如果位设置为1,则中断被启用,否则被禁用,中断将被忽略,或在PIC中缓冲并稍后处理。

19.8.6.3 DMA

对于访问I/O设备,可以同时使用轮询和中断。在任何情况下,对于每个I/O指令,通常一次传输4个字节,意味着,如果需要将4KB块传输到I/O设备,就需要发出1024条输出指令。类似地,如果希望读入4KB的数据,就需要发出1024个指令。每个I/O指令通常需要十多个周期,因为它在经过几个级别的间接寻址后到达一个I/O端口。其次,I/O总线的频率通常是处理器频率的三分之一到四分之一。因此,大数据块的I/O是一个相当缓慢的过程,会使处理器长时间处于忙碌状态。目标是尽可能缩短设备驱动程序和中断处理程序等敏感代码。

因此,尝试设计一种可以加载处理器部分工作的解决方案。考虑一个类比场景,假设一位教授教授的班级有100多名学生,考试后需要给100多个剧本打分,这种情况将使她忙碌至少一周,而给剧本打分的过程是一个非常累和耗时的过程。因此,她可以将考试脚本评分的工作交给助教,确保教授有空闲时间,可以专注于解决最先进的研究问题。我们可以从这个例子中得到线索,并为处理器设计一个类似的方案。

设想一个称为DMA(直接内存访问)引擎的专用单元,它可以代表处理器做一些工作。具体而言,如果处理器希望将内存中的大量数据传输到I/O设备,反之亦然,那么DMA引擎可以代替发出大量I/O指令来承担责任。使用DMA引擎的过程如下:

  • 设备驱动程序确定有必要在存储器和I/O设备之间传输大量数据。
  • 它将内存区域的细节(字节范围)和I/O设备的细节(I/O端口地址)发送到DMA引擎,进一步规定了数据传输是从内存到I/O还是反向。
  • 设备驱动程序挂起自己,处理器可以自由运行其他程序。同时,DMA引擎或DMA控制器开始在主存储器和I/O设备之间传输数据的过程。根据传输的方向,它会读取数据,暂时保存数据,然后将其发送到目的地。
  • 一旦传输结束,它将向处理器发送一个中断,指示传输结束。
  • I/O设备的设备驱动程序准备好恢复操作并完成所有剩余步骤。

现代处理器通常使用基于DMA的方法在主内存、硬盘或网卡之间传输大量数据。数据传输是在后台完成的,处理器基本上不会注意这个过程。其次,大多数操作系统都有库来编程DMA引擎以执行数据传输。

需要在DMA引擎的上下文中讨论两个微妙的点:

  • 第一个是DMA控制器需要偶尔成为总线主控器。在大多数设计中,DMA引擎通常是北桥芯片的一部分,在需要时成为总线到内存的总线主控器,以及到南桥芯片的总线。它可以一次性传输所有数据,此法称为突发(burst)模式;或者,它等待总线中的空闲周期,并使用这些周期来调度自己的传输,此法称为周期窃取(cycle stealing)模式。
  • 第二个微妙的问题是,如果不小心,可能会出现正确性问题。例如,可能在缓存中有一个给定的位置,同时DMA引擎正在向主内存中的位置写入数据。在这种情况下,缓存中的值将变得过时,而遗憾的是,处理器将无法知道这一事实。因此,确保DMA控制器访问的位置不在缓存中是很重要的。通常是通过一个称为DMA监听电路(snoop circuit)的专用逻辑块实现的,如果DMA引擎写入了缓存中的位置,则该逻辑块会动态逐出缓存中存在的位置。

DMA机制可以以多种方式配置,下图显示了一些可能性。在第一个示例中,所有模块共享相同的系统总线,DMA模块充当代理处理器,使用编程的I/O通过DMA模块在存储器和I/O模块之间交换数据,这种配置虽然可能很便宜,但显然效率很低。与处理器控制的编程I/O一样,每个字的传输消耗两个总线周期。

19.8.7 I/O协议

本节将描述几种最先进的I/O协议的操作,简要概述之。为了进行详细的研究,或者在有疑问的地方,可以查看网上发布的正式规范。正式规范通常由支持I/O协议的公司联盟发布,本节提供的大多数材料都来自于此。

19.8.7.1 PCI Express

大多数主板需要可用于将专用声卡、网卡和图形卡等设备连接到北桥或南桥芯片的本地总线。为了响应这一要求,1993年,一个公司联盟创建了PCI(外围组件互连)总线规范。

1996年,Intel创建了用于连接图形卡的AGP(加速图形端口)总线。在90年代末,许多新的总线类型被提出用于将各种硬件设备连接到北桥和南桥芯片。设计者很快意识到,拥有许多不同的总线协议会阻碍标准化工作,并迫使设备供应商支持多种总线协议。因此,一个公司联盟开始了标准化工作,并于2004年创建了PCI Express总线标准。该技术取代了大多数早期技术,迄今为止,它是主板上最流行的总线。

PCI express总线的基本思想是它是一种高速点对点串行(单位)互连。点对点互连只有两个端点,为了将多个设备连接到南桥芯片,创建了PCI express设备树。树的内部节点是PCI express交换机,可以多路复用来自多个设备的流量。其次,与旧协议相比,每个PCI Express总线在单个位线上串行发送位。通常高速总线避免使用多条铜线并行传输多个比特,因为不同的链路经历不同程度的抖动和信号失真。要保持不同导线中的所有信号彼此同步变得非常困难,因此,现代总线大多是串行的。

单个PCI Express总线实际上由许多单独的串行总线(称为通道)组成,每个通道都有其单独的物理层,PCI Express数据包在通道上分条(striped)。条带化意味着将一个数据块(数据包)划分为更小的数据块,并将它们分布在各个通道上,例如,在具有8个通道和8位数据包的总线中,可以在单独的通道上发送数据包的每一位。注意,在不同的通道上并行发送多个比特与具有多条线路发送数据的并行总线不同,因为并行总线对所有铜线都有一个物理层电路,而在这种情况下,每条通道都有其单独的同步和定时。数据链路层通过聚合从不同通道收集的每个数据包的子部分来完成成帧(framing)工作。

条带化(striping)过程是指将一个数据块划分为更小的数据块,并将它们分布在一组实体上。

通道由两条基于LVDS的导线组成,用于全双工信令。一根导线用于从第一个端点向第二个端点发送消息,第二根导线用于反向发送信号。一组通道被分组在一起以形成一个I/O链路,该链路被假定为传输完整的数据包(或帧)。然后,物理层将数据包传输到数据链路层,数据链路层执行纠错、流控制和实现事务。PCI Express协议是一种分层协议,其中每一层的功能大致类似于我们定义的I/O层。它没有将事务视为数据链路层的一部分,而是有一个单独的事务层。但是,除非另有说明,否则我们将使用本章中定义的术语来解释所有I/O协议。

PCI Express协议的规格汇总如下表所示,有1-32个通道,每条通道都是一条异步总线,它使用一种称为8bit/10bit编码的复杂数据编码。8bit/10bit编码在概念上可以被认为是NRZ协议的扩展,它将8个逻辑位的序列映射到10个物理位的序列,确保连续不超过五个1或0,可以有效地恢复时钟。回想一下,接收器通过分析数据中的转换来恢复发送器的时钟。其次,编码确保我们在传输信号中具有几乎相同数量的物理1和0。在数据链路层中,PCI Express协议实现了具有1-128字节帧和基于32位CRC的纠错的分割事务总线。

PCI Express总线通常用于连接通用I/O设备。有时有些插槽未使用,这样用户以后就可以为其特定应用程序连接卡,例如,如果用户对使用专用医疗设备感兴趣,那么她可以连接一个I/O卡,该卡可以从外部与医疗设备连接,也可以从内部连接到PCI Express总线。这种免费的PCI Express插槽称为扩展插槽(expansion slot)

与QPI类似,PCIe是点对点架构,每个PCIe端口由多个双向通道组成(注意,QPI的通道仅指单向传输)。通过一对电线上的差分信号,在通道的每个方向上进行传输,PCI端口可以提供1、4、6、16或32个通道。

与QPI一样,PCIe使用多通道分发技术。下图显示了由四个通道组成的PCIe端口的示例。使用简单的循环方案将数据一次分配到四个通道1个字节,在每个物理通道上,每次缓冲和处理16字节(128位)的数据。128位的每个块被编码成用于传输的唯一130位码字,被称为128b/130b编码。因此,单个通道的有效数据速率降低了128/130倍。

PCIe多层配线。

下图说明了加扰(scrambling)和编码的使用。要传输的数据被送入扰频器,然后将加扰的输出馈送到128b/130b编码器,该编码器缓冲128位,然后将128位块映射到130位块。然后,该块通过并行到串行转换器,并使用差分信令一次传输一位。

19.8.7.2 SATA

现在看一下总线,它主要是为连接硬盘和光盘等存储设备而开发的。从80年代中期开始,设计师和存储供应商开始设计这种总线。随着时间的推移,开发了几种这样的总线,例如IDE(集成驱动电子)和PATA(并行高级技术附件)总线。这些总线主要是并行总线,其组成通信链路受到不同程度的抖动和失真。因此,这些技术被称为SATA(串行ATA)的串行标准所取代,是一种像PCI Express一样的点对点链路。

用于访问存储设备的SATA协议现在在绝大多数笔记本电脑和台式机处理器中使用,它已成为事实上的标准。SATA协议有三层:物理层、数据链路层和传输层。我们将SATA协议的传输层映射到协议层,每个SATA链路包含一对使用LVDS信令的单位链路。与PCI Express不同,SATA协议中的端点不可能同时读取和写入数据。在任何时间点只能执行其中一个操作,因此,它是一条半双工总线,使用8b/10b编码,并且是异步总线。数据链路层完成分帧工作。现在讨论网络层。由于SATA是一种点对点协议,因此可以以树结构连接一组SATA设备。树的每个内部节点都被称为乘数,它将请求从父级路由到其子级,或从其子级路由到父级。最后,协议层作用于帧并确保它们以正确的顺序传输,并实现SATA命令。具体来说,它实现DMA请求,访问存储设备,缓冲数据,并按预定顺序将其发送给处理器。

下表显示了SATA协议的规格。需要注意,SATA协议具有非常丰富的协议层,为基于存储的设备提供了多种命令,比如有专用命令来执行DMA访问、执行直接硬盘访问、编码和加密数据以及控制存储设备的内部。SATA总线是分离事务总线,数据链路层区分命令及其响应。协议层实现所有命令的语义。

19.8.7.3 SCSI和SAS

SCSI概述

现在讨论另一种适用于外围设备的I/O协议,即SCSI(发音为“scuzzy”)协议。SCSI最初是PCI的竞争对手,但随着时间的推移,它转变为连接存储设备的协议。

最初的SCSI总线是多点并行总线,可以有8到16个连接。SCSI协议区分主机和外围设备,例如南桥芯片是主机,而CD驱动器的控制器是外围设备,任何一对节点(主机或外围设备)都可以相互通信。与当今的高速总线相比,最初的SCSI总线是同步的,运行频率相对较低。SCSI至今仍然存在,最先进的SCSI总线使用80-160 MHz时钟并行传输16位,因此它们的理论最大带宽为320-640MB/s。请注意,串行总线可以达到1GHz,更通用,并且可以支持更大的带宽。

考虑到多点并行总线存在问题,设计人员开始将SCSI协议重新定位为点对点串行总线。回想一下,PCI Express和SATA总线也是出于同样的原因创建的。因此,设计者提出了一系列扩展了原始SCSI协议的总线,但本质上是点对点串行总线。两种这样重要的技术是SAS(串行连接SCSI)和FC(双通道)总线。FC总线主要用于超级计算机等非常高端的系统,SAS总线更常用于企业和科学应用。

因此,让我们主要关注SAS协议,因为它是当今使用的SCSI协议的最流行变体。SAS是一种串行点对点技术,也与以前版本的基于SATA的设备兼容,其规格与SATA规格非常接近。

SAS概述

SAS被设计为与SATA向后兼容,因此这两种协议在物理层和数据链路层上没有很大不同,但仍然存在一些差异。最大的区别是SAS允许全双工传输,而SATA仅允许半双工传输。其次,SAS通常可以支持更大的机架尺寸,并且与SATA相比,SAS支持端点之间更大的电缆长度(SAS为8米,SATA为1米)。

网络层与SATA不同。SAS没有使用乘法器(用于SATA),而是使用一种更复杂的结构,称为扩展器,用于连接多个SAS目标。传统上,SAS总线的总线主节点称为启动器,而另一个节点称为目标节点。有两种扩展器:边缘扩展器和扇出扩展器,边缘扩展器最多可用于连接255个SAS设备,扇出扩展器最多可连接255个边缘扩展器。我们可以使用根节点和一组扩展器在基于树的拓扑中添加大量设备,启动时为每个设备分配一个唯一的SCSI id,设备可以进一步细分为几个逻辑分区,例如,写入者目前正在处理一个被划分为两个逻辑分区的存储系统,每个分区都有一个逻辑单元号(LUN)。路由算法如下:如果存在直接连接,启动器会直接向设备发送命令,或者向扩展器发送命令。扩展器有一个详细的路由表,根据其SCSI id维护设备的位置,它查找此路由表并将数据包转发到设备或边缘扩展器。此边缘扩展器具有另一个路由表,用于将命令转发到适当的SCSI设备,然后SCSI设备将该命令转发到相应的LUN。对于向另一个SCSI设备或处理器发送消息,请求遵循反向路径。

最后,协议层对于SAS总线非常灵活。它支持三种协议,可以使用SATA命令、SCSI命令或SMP(SAS管理协议)命令。SMP命令是用于配置和维护SAS设备网络的专用命令。SCSI命令集非常广泛,旨在控制一系列设备(主要是存储设备),请注意,在向设备发送SCSI命令之前,设备必须与SCSI协议层兼容。如果设备不理解某个命令,那么可能会发生灾难性的事情,例如如果想读取CD,但CD驱动程序不理解该命令,那么它可能会弹出CD。更糟糕的是,它可能永远不会弹出CD,因为它不理解弹出命令。同样的论点也适用于SATA的情况。如果希望使用SATA命令,需要SATA兼容设备,如SATA兼容硬盘驱动器和SATA兼容光盘驱动器。由于协议层的灵活性,SAS总线在设计上与SATA设备和SAS/SSCSI设备兼容。对于协议层,SAS启动器向SAS/SSCSI设备发送SCSI命令,向SATA设备发送SATA命令。

近线SAS(NL-SAS)驱动器本质上是SATA驱动器,但具有将SCSI命令转换为SATA命令的SCSI接口。因此,NL-SAS驱动器可以在SAS总线上无缝使用。由于SCSI命令集更具表现力和效率,NL-SAS驱动器的速度比纯SATA驱动器快10-20%。

现在用4句话来简单描述SCSI命令集。启动器首先向目标发送命令,每个命令都有一个1字节的标头,并且具有可变长度的有效负载。然后,目标发送带有命令执行状态的回复,SCSI规范为设备控制和数据传输提供了至少60种不同的命令。

19.8.7.4 USB

概述

USB协议主要用于将外部设备连接到笔记本电脑或台式电脑,如键盘、鼠标、扬声器、网络摄像头和打印机。在90年代中期,供应商意识到存在多种I/O总线协议和连接器,主板设计者和设备驱动程序编写者很难支持大量设备。因此需要标准化,一个公司联盟(DEC、IBM、Intel、Nortel、NEC和Microsoft)构想了USB协议(通用串行总线)。

USB协议的主要目的是为各种设备设计一个标准接口。设计者一开始将设备分为三种类型,即低速(键盘、鼠标)、全速(高速音频)和高速(扫描仪和摄像机)。截至2012年,已经提出了三个版本的USB协议,即版本1.0、2.0和3.0。基本USB协议大致相同,协议向后兼容,意味着具有USB 3.0端口的现代计算机支持USB 1.0设备。与为特定硬件集设计的SAS或SATA协议不同,USB协议的设计非常通用,因此可以对目标设备的行为进行大量假设。因此,设计者需要为操作系统提供广泛的支持,以发现设备的类型、需求,并对其进行适当配置。其次,许多USB设备没有电源,例如键盘和鼠标。有必要在USB电缆中包括用于运行连接的设备的电源线。USB协议的设计者牢记了所有这些要求。

从一开始,设计者就希望USB成为一种快速协议,能够在未来支持高速设备,如高清视频。因此,他们决定使用点对点串行总线(类似于PCI Express、SATA和SAS)。每台笔记本电脑、台式机和中型服务器的前面板或后面板上都有一系列USB端口,每个USB端口都被视为可以与一组USB设备连接的主机。由于使用串行链接,可以创建一个类似于PCI Express和SAS设备树的USB设备树。大多数时候,只将一个设备连接到USB端口。但不是唯一的配置,也可以连接一个USB集线器,它就像树的内部节点。USB集线器原则上类似于SATA乘法器和SAS扩展器。

USB集线器大部分时间是被动设备,通常有四个端口连接到下游的其他设备和集线器,集线器最常见的配置包括一个上游端口(连接到父节点)和四个下游端口。我可以用这种方式创建一个USB集线器树,并将多个设备连接到主板上的单个USB主机。USB协议支持每个主机127个设备,最多可以串行连接5个集线器。集线器可以由主机供电,也可以自供电,如果集线器是自供电的,它可以连接更多设备,因为USB协议对其可以传输到任何单个设备的电流量有限制,目前,它被限制为500 mA,并且功率以100 mA的块分配。因此,由主机供电的集线器最多可以有4个端口,因为它可以给每个设备100 mA,并保持100 mA。有时,集线器需要成为活动设备,每当USB设备与集线器断开连接时,集线器就会检测到此事件,并向处理器发送消息。

USB协议的层

  • 物理层

现在更详细地讨论协议,并从物理层开始。标准USB连接器有4个引脚,第一个引脚是提供固定5V DC电压的电源线,通常被称为Vbus的Vcc。差分信号有两个引脚,即D+和D-,其默认电压设置为3.3V。第四个引脚是接地引脚(GND),迷你和微型USB连接器有一个称为ID的附加引脚,有助于区分与主机和设备的连接。

USB协议使用差分信令,它使用NRZI协议的变体。对于编码逻辑位,它假设逻辑0由物理位中的转换表示,而逻辑1由无转换表示(与传统NRZI协议相反)。USB总线是一种恢复时钟的异步总线,为了帮助时钟恢复,如果数据中没有转换,则同步子层引入虚拟转换。例如,如果我们有1的连续运行,那么传输的信号中就不会有跃迁。在这种情况下,USB协议在每次运行6个1后引入0位。该策略确保了信号中有一些保证的过渡,并且接收机可以恢复发射机的时钟而不失同步。USB连接器只有一对用于差分信号的导线,因此全双工信令是不可能的,相反,USB链路使用半双工信令。

  • 数据链接层

对于数据链路层,USB协议使用基于CRC的错误检查和可变帧长度,它使用位填充(专用帧开始和结束符号)来划分帧边界。仲裁在USB集线器中是一个相当复杂的问题,因为有很多种流量和很多种设备。USB协议定义了四种流量:

  • 控制:用于配置设备的控制消息。
  • 中断:需要紧急发送到设备的少量数据。
  • 大量(Bulk):大量数据,没有任何延迟和带宽保证。如扫描仪中的图像数据。
  • 同步(Isochronous):具有延迟和带宽保证的固定速率数据传输。 如网络摄像机中的音频/视频。

随着流量的不同,我们有不同种类的USB设备,即低速设备(192 KB/s)、全速设备(1.5 MB/s)和高速设备(60 MB/s),USB 3.0协议引入了需要384 MB/s的高速设备。

现在,有可能将高速和低速设备连接到同一个集线器。假设高速设备正在进行批量传输,而低速设备正在发送中断。在这种情况下,需要优先考虑枢纽上游链路的接入。仲裁很难,因为需要符合每类流量和每类设备的规范,在执行批量传输和发送中断之间陷入了两难境地。理想情况下,希望通过使用不同的流量优先级启发式方法,在冲突的需求之间取得平衡。有关仲裁机制的详细说明,可参阅USB规范。

现在考虑事务问题。假设高速集线器连接到主机,高速集线器(hub)还连接到下游的全速和低速设备,在这种情况下,如果主机通过高速集线器启动到低速设备的事务,那么它必须等待从设备获得回复,因为高速集线器和设备之间的链接很慢。在这种情况下,没有理由锁定主机和集线器之间的总线。可以改为实现拆分事务,拆分事务的第一部分将命令发送到低速设备,拆分事务的第二部分包括从低速设备到主机的消息。在拆分事务之间的间隔内,主机可以与其他设备通信。USB总线为许多其他类型的场景实现了类似的拆分事务(请参阅USB规范)。

  • 网络层

现在考虑网络层。包括集线器的每个USB设备都由主机分配一个唯一的ID,由于每个主机最多可以支持127个设备,因此需要一个7位设备id。其次,每个设备都有多个I/O端口,每个这样的I/O端口都称为端点。我们可以有数据端点(中断、批量或同步),也可以有控制端点。此外,可以将端点分类为IN或OUT,IN端点表示只能向处理器发送数据的I/O端口,OUT端点接受来自处理器的数据。每个USB设备最多可以有16个IN端点和16个OUT端点,任何USB请求都明确规定了它需要访问的端点类型(IN或OUT)。考虑到端点的类型由请求固定,只需要4位就可以指定端点的地址。

所有USB设备都有一组默认的IN和OUT端点,其id等于0,这些端点用于激活设备并与其建立通信,随后每个设备定义其自定义端点集。简单的设备,如鼠标或键盘,通常只需一个IN端点即可将数据发送到处理器。然而更复杂的设备(如网络摄像头)需要多个端点。一个端点用于视频馈送,一个端点是音频馈送,并且可以有多个端点用于交换控制和状态数据。

集线器负责将消息路由到正确的USB设备,集线器维护将USB设备与本地端口ID关联的路由表。一旦消息到达设备,它就会将其路由到正确的端点。

  • 协议层

USB协议层相当复杂。首先,在端点之间定义两种连接,称为管道,它将流管道定义为没有任何特定消息结构的数据流。相比之下,消息管道更加结构化,并且定义了发送方和接收方都必须遵循的消息序列,消息管道中的典型消息由三种数据包组成。通信以令牌包开始,令牌包包含设备id、端点id、通信性质和有关连接的附加信息。路径上的集线器将令牌分组路由到目的地,从而建立连接。然后,根据传输的方向(主机到设备或设备到主机),主机或设备发送一系列数据包。最后,在数据分组序列的末尾,分组的接收器发送握手分组以指示I/O请求的成功完成。

总结

下表总结了USB的讨论,可以参考USB协议的规范以获取更多信息。

19.8.8 存储

在所有通常连接到处理器的外围设备中,存储设备有一个特殊的位置,主要是因为它们是计算机系统功能的组成部分。

存储设备保持持久状态。持久状态指的是计算机系统中存储的所有数据,即使它通电时也是如此。值得注意的是,存储系统存储操作系统、所有程序及其相关数据,包括所有文档、歌曲、图像和视频。从计算机架构师的角度来看,存储系统在引导过程中扮演着积极的角色,保存文件和数据以及虚拟内存。让我们逐一讨论这些角色。

当处理器启动时(该过程称为引导),它需要加载操作系统的代码。通常操作系统的代码在主硬盘的地址空间的开头可用,然后处理器将操作系统的代码加载到主存储器中,并开始执行它。在引导过程之后,用户可以使用操作系统来运行程序和访问数据。程序在存储系统中保存为常规文件,数据也保存在文件中。文件本质上是硬盘或类似存储设备中的数据块,这些数据块需要读入主内存,以便处理器可以访问。

最后,存储设备在实现虚拟内存方面发挥着非常重要的作用,它们存储交换空间,交换空间包含主内存中无法包含的所有帧,有效地帮助扩展物理地址空间以匹配虚拟地址空间的大小。一部分帧存储在主内存中,其余帧存储在交换空间中。当出现页面错误时,它们被引入(交换)。

几乎所有类型的计算机都连接了存储设备,但也有一些例外,某些机器,特别是在实验室环境中,可能通过网络访问硬盘。它们通常使用网络启动协议从远程硬盘启动,并通过网络访问包括交换空间在内的所有文件。从概念上讲,它们仍然有一个连接的存储设备。它只是没有物理连接到主板,尽管如此,仍然可以通过网络访问。

现在看看主要的存储技术。传统上,磁存储一直是主导技术,这种存储技术记录了大型铁磁磁盘微小区域中的位值。根据磁化状态,可以推断逻辑0或1。可以使用光盘技术,如CD/DVD/蓝光驱动器,而不是磁盘技术。CD/DVD/蓝光光盘包含一系列凹坑(表面像差),这些凹坑编码一系列二进制值,光盘驱动器使用激光读取存储在磁盘上的值。计算机的大多数操作通常访问硬盘,而光盘主要用于存档视频和音乐,但从光盘驱动器启动并不罕见。

固态驱动器是磁盘和光盘的快速替代品。与具有移动部件的磁性和光学驱动器不同,固态驱动器由半导体制成。固态驱动器中最常用的技术是闪存,闪存设备使用存储在半导体中的电荷来表示逻辑0或1,它们比传统硬盘驱动器快得多,但可以存储的数据要少得多,截至2012年,其成本要高出5-6倍。因此,高端服务器选择混合解决方案,有一个快速的SSD驱动器,可以作为更大硬盘的缓存。

19.8.8.1 硬盘

从笔记本电脑到服务器,硬盘是大多数计算机系统的组成部分。它是一种由铁磁材料和机械部件制成的存储设备,可以以低成本提供大量存储容量。因此,在过去三十年中,硬盘一直被专门用于保存个人计算机、服务器和企业级系统中的持久状态。

令人惊讶的是,数据存储的基本物理原理非常简单,在一系列磁铁中保存0和1,现在快速回顾一下硬盘中数据存储的基本物理。

硬盘数据存储物理学

考虑一个典型的磁体,它有北极和南极,同极相互排斥,相反极相互吸引。除了机械性能外,磁体还具有电学性能,例如,当通过电线线圈的磁场由于磁体和线圈之间的相对运动而改变时,根据法拉第定律,电线两端会感应出EMF(电压)。硬盘使用法拉第定律作为其操作的基础。

硬盘的基本元素是一个小磁铁。硬盘中使用的磁体通常由氧化铁制成,具有永久磁性,意味着它们的磁性一直保持不变。它们被称为永磁体或铁磁体(因为氧化铁)。相比之下,可以拥有由缠绕在铁棒上的载流电线线圈组成的电磁铁,电流切断后,电磁铁失去磁性。

现在考虑一组串联的磁体,如下图所示。它们的相对方向有两种选择,即N-S(北-南)或S-N(南-北)。现在在磁铁的排列上移动一小圈电线,每当它穿过两个方向相反的磁体的边界时,磁场就会发生变化。因此,作为法拉第定律的直接结果,线圈两端感应出EMF。然而,当磁场方向没有变化时,线圈两端感应的EMF可以忽略不计。微小磁体方向的转变对应于逻辑1位,而没有转变表示逻辑0位。因此,图中的磁体表示位模式0101,类似于I/O通道的NRZI编码。

硬盘表面的一系列微小磁铁。

由于在转换中编码数据,因此需要保存数据块,硬盘在扇区中保存一块数据。硬盘扇区的大小在512字节之间,它被视为一个原子块,通常一次读取或写入整个扇区,包含小线圈并穿过磁铁的结构称为读取头。

现在看看将数据写入硬盘。在这种情况下,任务是设置磁铁的方向,还有一种叫做写头的结构,它包含一个微型电磁铁。如果电磁铁经过永久磁铁,它会引起永久磁铁的磁化。其次,磁化方向取决于电流的方向,如果改变电流的方向,磁化的方向就会改变。

为了简洁起见,将读磁头和写磁头的组合组件称为磁头。

盘片结构

硬盘通常由一组盘片组成。盘片是一个中间有孔的圆形圆盘,一个主轴通过中间的圆孔连接到盘片上。盘片被分成一组称为轨道(track,亦称磁道)的同心环,轨道被进一步划分为固定长度的扇区(sector),如下图所示。


硬盘由多个盘片组成。盘片是一个固定在主轴上的圆盘,盘片还包括一组称为磁道的同心环,每个磁道由一组扇区组成。扇区通常包含固定数量的字节,而与磁道无关。

现在概述一下硬盘的基本操作。盘片连接到主轴上。在硬盘操作过程中,主轴及其连接的盘片不断旋转。为了简单起见,假设一个单盘磁盘。第一步是将磁头定位在包含所需数据的磁道上。接下来,磁头需要在此位置等待,直到所需扇区到达磁头下方。由于盘片以恒定的速度旋转,可以根据头部的当前位置计算需要等待的时间。一旦所需扇区到达头部下方,就可以继续读取或写入数据。

这里需要考虑一个重要问题。每个轨道的扇区数是相同的还是不同的?请注意,每个磁道可以保存的位数存在技术限制。因此,如果每个磁道的扇区数相同,那么实际上是在向外围浪费磁道中的存储容量,因为受限于最接近中心的磁道中可以存储的位数。因此,现代硬盘避免了这种方法。

尝试为每个轨道存储可变数量的扇区。朝向中心的轨道包含更少的扇区,而朝向外围的轨道包含更多的扇区。这一计划也有其自身的问题。比较最内侧和最外侧轨道,并假设最内侧轨道包含N个扇区,最外侧轨道包含2N个扇区。如果假设每分钟的旋转数是恒定的,那么需要在最外层轨道上读取数据的速度是最内层轨道的两倍。事实上,每一条轨道的数据检索率都是不同的,使得磁盘中的电子电路复杂化。可以探索另一种选择,即以不同的速度为每个磁道旋转磁盘,以使数据传输速率保持恒定。在这种情况下,电子电路更简单,但以各种不同速度运行主轴电机所需的复杂程度令人望而却步。因此,这两种解决方案都是不切实际的。

怎么样,把两个不切实际的解决方案结合起来,让它变得实用!将这组轨迹划分为一组区域,每个区域由一组连续的m条轨道组成。如果盘片中有n个轨道,那么有n=m个区域。在每个区域中,每个磁道的扇区数是相同的,盘片以恒定的角速度旋转一个区域中的所有轨道。在一个区域中,与朝向盘片外围的磁道相比,更靠近中心的磁道的数据更密集。换言之,扇区在一个区域中的磁道具有物理上不同的大小。这不是问题,因为磁盘驱动器假设在一个区域中通过每个扇区所需的时间相同,并且以恒定的角速度旋转可以确保这一点。

下图显示了将盘片划分为区域的概念图,请注意,每个磁道的扇区数因区域而异。这种方法被称为分区位记录(Zoned-Bit Recording,ZBR)。我们没有考虑的两个不切实际的设计是ZBR的特例。第一种设计假设我们有一个区域,第二种设计假设每个轨道属于不同的区域。

分区位记录。

磁盘布局方法的比较:(a) 恒定角速度,(b)多区记录。

现在看看这个方案为什么有效。由于有多个分区,因此浪费的存储空间不如仅使用单个分区的设计高。其次,由于区域的数量通常不是很大,因此主轴的电机不需要频繁地重新调整其速度。事实上,由于空间位置的原因,留在同一区域的可能性相当高。

硬盘结构

现在将所有部件放在一起,看看下面两图的硬盘结构。有一组连接到单个旋转主轴(spindle)的盘片(platter),以及一组盘片臂(disk arm,盘片的每一侧一个),其末端包含一个磁头(head)。通常,所有臂一起移动,所有头部在同一圆柱体上垂直对齐。在这里,圆柱体(cylinder)被定义为来自多个盘片的一组轨道,这些盘片具有相同的半径。在大多数硬盘中,一个时间点只有一个磁头被激活,它对给定扇区执行读或写访问。在读取访问的情况下,数据被传输回驱动电子设备进行后处理(成帧、纠错),然后通过总线接口在总线上发送到处理器。

硬盘结构。

现在考虑一下硬盘设计中的一些细微之处(参见下图)。它显示了连接到主轴的两个盘片,每个盘片都有两个记录表面。主轴连接到电机(motor,称为主轴电机),该电机根据我们希望访问的区域调整其速度。所有臂组一起移动,并使用心轴连接到致动器(actuator)。致动器是一个小型电机,用于顺时针或逆时针移动臂。致动器的作用是通过顺时针或逆时针旋转给定的角度,将臂的头部定位在指定的轨道上。

硬盘内部结构。

桌面处理器中的典型磁盘驱动器的磁道密度约为每英寸10000个磁道,意味着轨道之间的距离为2.5微米,因此致动器必须非常精确。通常,扇区上有一些标记,指示轨道的编号,致动器通常需要进行轻微调整以达到准确的点。这种控制机制被称为伺服控制(servo control)。致动器和主轴电机均由硬盘机箱内的电子电路控制,一旦致动器将磁头放置在正确的轨道上,它需要等待所需的扇区到达磁头下方,轨道上有标记以指示扇区的编号。磁头放置在轨道上后,会继续读取标记。根据这些标记,它可以准确预测所需扇区何时位于头部下方。

除了机械部件外,硬盘还具有包括小型处理器在内的电子部件。它们在总线上接收和传输数据,在硬盘上调度请求,并执行纠错。硬盘是人类工程学的一项令人难以置信的成就。硬盘可以在大多数时间无缝地容忍错误,动态地使坏扇区(有故障的扇区)无效,并将数据重新映射到好扇区。

下图说明了与任何SSD系统相关的通用体系结构系统组件的一般视图。在主机系统上,操作系统调用文件系统软件来访问磁盘上的数据,文件系统反过来调用I/O驱动程序软件,I/O驱动程序软件提供对特定SSD产品的主机访问。图中的接口组件是指主机处理器和SSD外围设备之间的物理和电气接口,如果设备是内部硬盘驱动器,则通用接口为PCIe。对于外部设备,一个通用接口是USB。

固态驱动器架构。

硬盘存取的数学模型

现在,让我们为请求完成对硬盘的访问所需的时间构建一个快速的数学模型。可以把花费的时间分成三部分:

  • 第一个是寻道时间,定义为磁头到达正确轨道所需的时间。
  • 头部需要等待所需扇区到达其下方,该时间间隔称为旋转延迟。
  • 磁头需要读取数据,处理数据以消除错误和冗余信息,然后在总线上传输数据,被称为传输时间。

因此,有一个简单的方程式描述之:

\[T_{\text {disk\_access }}=T_{\text {seek }}+T_{\text {rot\_latency }}+T_{\text {transfer }} \]

除此之外,还有RAID阵列、光盘、闪存盘等介质,更多可参阅:18.11 文件和I/O


19.9 GPU

19.9.1 概述

高强度图形是当代计算机系统的标志。今天的计算机,从智能手机到高端台式机,都使用各种复杂的视觉效果来增强用户体验。此外,用户还可以使用计算机玩图形密集型游戏、观看高清视频,以及进行计算机辅助工程设计,所有这些应用程序都需要大量的图形处理。

在早期,计算机中的图形支持非常初级,程序员需要指定屏幕上绘制的每个形状的坐标,例如要绘制一条线,程序员需要明确提供该线的坐标,并指定其颜色。颜色的范围非常有限,而且几乎没有用于卸载图形密集型任务的硬件。由于在屏幕上绘制的每一条线或圆都需要几个汇编语句,因此创建和使用计算机图形的过程非常缓慢。渐渐地,需要在硬件中对图形进行一些支持。

由于GPU和CPU是为两种截然不同的应用程序而设计和优化的,因此它们的体系结构存在显著差异,可以通过比较两种处理器技术专用于高速缓存、控制逻辑和处理逻辑的管芯面积(晶体管计数)的相对数量来看出(下图)。

CPU和GPU在缓存、ALU、控制器等硬件单元的对比图。

19.9.1.1 图形应用

我们可以将现代图形应用程序分为两种类型。第一类是自动图像合成。例如考虑游戏中的一个复杂场景,其中一个角色在月明的夜晚拿着机枪奔跑。在这种情况下,程序员不是手动将每个像素的值设置为给定的颜色,此过程太慢且耗时。如果使用这种方法,互动游戏都不会起作用。相反,程序员在高级对象级别编写程序,例如他可以用道路、植物和障碍物等一组对象来定义场景,可以塑造一个角色,以及随身携带的诸如机关枪、小刀和斗篷等工艺品,程序员根据这些对象编写程序。此外,他还可指定了一组规则来定义这些对象的交互,例如,如果角色与墙发生碰撞,则该角色会转身并朝另一个方向运行。除了定义对象和对象的语义外,还必须定义场景中的光源。在这种情况下,程序员需要指定月光下夜晚的光线强度。然后,通过专用图形软件和硬件自动计算角色和背景的照度。

遗憾的是,图形硬件不理解复杂对象和字符的语言。因此,大多数图形工具包都有图形库来将复杂结构分解为一组基本形状,计算机图形应用程序中的大多数形状都被分解为一组三角形,所有操作(如对象碰撞、移动、照明、阴影和照明)都转换为三角形上的基本操作。然而,图形库不使用常规处理器来处理这些三角形,并最终创建要在计算机屏幕上显示的像素阵列。一旦程序员的意图转化为对基本形状的操作,图形库就会将代码发送到专用图形处理器,该处理器完成其余的处理。图形处理器根据用户提供的数据和规则生成复杂场景,对由边和顶点指定的形状进行操作。大多数时候,这些形状是二维空间中的三角形,或者三维空间中的四面体。图形处理器还在生成最终图像时计算照明、对象位置、深度和透视的效果,一旦图形处理器生成了最终图像,它就会将其发送到显示设备。如果我们在玩电脑游戏,那么这个过程需要每秒至少进行50-100次。

总之,由于生成复杂的图形场景既困难又缓慢,因此程序员对对象进行高级描述。随后,图形库将程序员的指令转换为对基本形状的操作,并将一组形状和对其进行操作的规则发送给图形处理器。图形处理器通过对基本形状进行操作,然后将其转换为像素阵列来生成最终场景。

图形处理器的第二个重要应用是显示视频等动画内容,高清晰度视频每个场景有数百万像素。为了减少存储需求,大多数高清晰度视频都经过了严格压缩(编码)。因此,计算机需要解码或解压缩视频,每秒计算50-100次像素阵列,并在屏幕上显示它们。这是一个非常计算密集的过程,可能占用CPU的资源。因此,视频解码通常也被加载到图形处理器,该处理器包含处理视频的专用单元。

几乎所有现代计算机系统都包含图形处理器,它被称为GPU(Graphics Processing Unit,图形处理单元)。现代GPU包含超过64-128个内核,因此设计用于广泛的并行处理。

19.9.1.2 图形管线

现在让我们看看下图中的典型图形处理器的流水线。

图形管线。

第一阶段称为顶点处理。在此阶段,将处理一组顶点、形状和三角形。GPU执行复杂的操作,例如对象旋转和平移。程序员可能会指定给定的对象以一定的速度朝向另一个对象移动,因此有必要以给定的速率平移形状的位置,这种操作也在这个阶段进行。此阶段的输出是2D平面中的一组简单三角形。

第二阶段称为光栅化。光栅化过程将每个三角形转换为一组像素,称为片元(或片段)。此外,它将片元中的每个像素与一组参数相关联,这些参数稍后用于插值颜色的值。

第三阶段是片元处理。该阶段使用前一阶段计算的中间结果根据一组固定规则对片元的像素进行着色,或者将给定纹理映射到片元。例如,如果一块片元代表一张木制桌子的表面,那么这个阶段将木材的纹理映射到像素的颜色。此阶段还用于合并阴影和照明等效果。

注意,到目前为止,我们已经计算了场景中所有对象的片元颜色。然而,一个对象可能位于另一个对象的前面,因此第二个对象的一部分可能被隐藏。

第四阶段聚合来自第三阶段的所有片元,并执行称为帧缓冲处理的操作。帧缓冲区是一个大数组,包含每个像素的颜色值,图形卡每秒向显示设备传送50-100次帧缓冲器。在此阶段执行的操作之一称为深度缓冲,它通过隐藏部分对象,以一定角度计算3D空间的2D视图。创建最终场景后,图形管线将图像传输到帧缓冲区。

以上就是图形处理器渲染复杂游戏,甚至是最小化或最大化窗口等标准操作的方式。渲染被定义为通过根据对象、规则和视觉效果处理场景的高级描述,以像素为单位生成场景的过程。渲染过程本质上涉及很多线性代数运算,包含对象旋转或平移都等矩阵运算。这些操作处理大量浮点值,并且本质上是并行的。

19.9.1.3 高性能计算与图形计算的融合

到了90年代末,计算机图形学领域迅速发展。计算机游戏、桌面视觉效果和先进的工程软件激增,需要复杂的计算机图形硬件加速器。因此,设计师越来越需要创造更生动的场景和更逼真的物体。我们可以比较80年代后期制作的动画电影和今天的好莱坞电影,今天的动画电影有非常逼真的人物,面部表情非常细致。多亏了图形硬件,所有这些都成为可能。为了创造这种身临其境的体验,有必要在图形处理器中增加很大程度的灵活性,以结合不同类型的视觉效果。因此,图形处理器设计者将处理器的许多内部部件暴露给低级软件,并允许程序员更灵活地使用处理器。一组名为着色器的程序诞生于2000年初,它们允许程序员创建灵活的片段和像素处理例程。

到2006年,主要GPU供应商已经认识到图形管道也可以用于通用计算,例如大量数值化的科学代码在概念上类似于片元或像素处理操作。我们如果允许常规用户程序访问图形处理器以执行其任务,就可以在图形处理器上运行大量科学程序。为了响应这一要求,NVIDIA发布了CUDA API,允许C程序员用C语言编写代码,并在图形处理器上运行,GPGPU(通用GPU)一词就此诞生了。

GPGPU代表通用图形处理单元,本质上是一个图形处理器,允许普通用户在其上编写和运行代码。用户通常使用专用语言或标准语言的扩展来生成与GPGPU兼容的代码。

后面将讨论NVIDIA Tesla GPU架构的设计,具体来说,将讨论GeForce 8800 GPU的设计。GPU最快的部分(核心)通常工作在1.5GHz或更高,其他部件的工作频率为600 MHz、750 MHz或以上。

19.9.2 GPU系统架构

当今常用的GPU系统架构有几种,下面将阐述它们的系统配置、GPU功能和服务、标准编程接口以及基本的GPU内部架构。

19.9.2.1 异构CPU–GPU系统架构

使用GPU和CPU的异构计算机系统架构可以通过两个主要特征在高层次上描述:第一,使用了多少功能子系统和/或芯片,以及它们的互连技术和拓扑结构;第二,哪些内存子系统可用于这些功能子系统。

下图显示了大约1990年遗留PC的高级结构图。北桥包含连接CPU、内存和PCI总线的高带宽接口,南桥包含传统的接口和设备:ISA总线(音频、LAN)、中断控制器;DMA控制器;时间/计数器。在该系统中,显示器由一个简单的帧缓冲子系统驱动,该子系统被称为VGA(视频图形阵列),它连接到PCI总线。具有内置处理元件(GPU)的图形子系统在1990年的PC环境中并不存在。

下图说明了目前常用的两种配置。它们的特点是具有各自存储器子系统的独立GPU(离散GPU)和CPU。在图a中,对于Intel CPU,GPU通过16通道PCI Express 2.0链路连接,以提供峰值16 GB/s传输速率(每个方向的峰值为8 GB/s)。类似地,在图b中,对于AMD CPU,GPU也通过具有相同可用带宽的PCI Express连接到芯片组。在这两种情况下,GPU和CPU可以访问彼此的内存,尽管可用带宽比它们访问更直接连接的内存的带宽要少。在AMD系统的情况下,北桥或存储器控制器与CPU集成在同一芯片中。

PCI Express(PCIe):使用点对点链路的标准系统I/O互连,链路具有可配置的通道数和带宽。

统一内存架构(unified memory architecture,UMA):CPU和GPU共享公共系统内存的系统架构。

这些系统上的一种低成本变体,即统一内存架构系统,仅使用CPU系统内存,而省略了系统中的GPU内存。这些系统具有相对较低的性能GPU,因为它们实现的性能受到可用系统内存带宽和增加的内存访问延迟的限制,而专用GPU内存提供高带宽和低延迟。

高性能系统变体使用多个连接的GPU,通常两到四个并行工作,其显示器呈菊花链,如NVIDIA SLI(可扩展链接互连)多GPU系统,专为高性能游戏和工作站而设计。

下一个系统类别将GPU与北桥(Intel)或芯片组(AMD)集成在一起,无论有无专用图形内存。

前述章节解释了缓存如何在共享地址空间中保持一致性。对于CPU和GPU,有多个地址空间,GPU可以使用由GPU上的MMU转换的虚拟地址访问自己的物理本地内存和CPU系统的物理内存。操作系统内核管理GPU的页表,可以使用一致或非一致的PCI Express事务访问系统物理页面,取决于GPU页面表中的属性。CPU可以通过PCI Express地址空间中的地址范围(也称为开口,aperture)访问GPU的本地内存。

诸如Sony PlayStation 3和Microsoft Xbox 360的控制台系统类似于前面描述的PC系统架构,控制台系统设计为在使用寿命长达五年或更长的时间内提供相同的性能和功能。在此期间,可以多次重新实现系统以开发更先进的硅制造工艺,从而以更低的成本提供恒定的能力。控制台系统不需要像PC系统那样扩展和升级其子系统,因此主要的内部系统总线倾向于定制而非标准化。

在如今的PC中,GPU通过PCI Express连接到CPU,前几代使用AGP。图形应用程序调用OpenGL或Direct3DAPI函数,将GPU用作协处理器,API通过为特定GPU优化的图形设备驱动程序向GPU发送命令、程序和数据。

AGP:原始PCI I/O总线的扩展版本,为单个卡插槽提供了高达原始PCI总线八倍的带宽。其主要目的是将图形子系统连接到PC系统中。

19.9.2.2 基础统一GPU架构

统一GPU架构基于许多可编程处理器的并行阵列。它们将顶点、几何体和像素着色器处理和并行计算统一在同一处理器上,与早期GPU不同,早期GPU具有专用于每种处理类型的单独处理器。可编程处理器阵列与固定功能处理器紧密集成,用于纹理过滤、光栅化、光栅操作、抗锯齿、压缩、解压缩、显示、视频解码和高清视频处理。尽管固定功能处理器在受面积、成本或功率预算限制的绝对性能方面明显优于更一般的可编程处理器,本小节重点介绍可编程处理器。

与多核CPU相比,多核GPU具有不同的架构设计点,其重点是在多个处理器核上高效地执行多个并行线程。通过使用许多更简单的内核并优化线程组之间的数据并行行为,每个芯片的晶体管预算更多地用于计算,而更少地用于片上缓存和开销。

统一的GPU处理器阵列包含许多处理器核心,通常组织为多线程多处理器。下图显示了具有112个流处理器(SP)核心阵列的GPU,这些核心被组织为14个多线程流多处理器(SM)。每个SP核心都是高度多线程的,在硬件中管理96个并发线程及其状态。处理器通过互连网络与四个64位宽的DRAM分区连接,每个SM有八个SP核、两个特殊功能单元(SFU)、指令和常量缓存、一个多线程指令单元和一个共享内存。这是NVIDIA GeForce 8800实现的基本Tesla架构,具有统一的架构,其中用于顶点、几何和像素着色的传统图形程序在统一的SM及其SP内核上运行,计算程序在相同的处理器上运行。

通过缩放多处理器的数量和内存分区的数量,处理器阵列架构可扩展到更小和更大的GPU配置。上图显示了共享纹理单元和纹理L1缓存的两个SM的七个集群,纹理单元将过滤后的结果传递给SM,并将一组坐标转换为纹理图。由于连续纹理请求的支持过滤器区域经常重叠,因此小型流式L1纹理缓存可有效减少对内存系统的请求数量。处理器阵列通过GPU范围的互连网络与光栅操作处理器(ROP)、二级纹理缓存、外部DRAM存储器和系统存储器连接。处理器的数量和内存的数量可以进行扩展,以针对不同的性能和市场细分设计平衡的GPU系统。

下图显示了NVIDIA Fermi架构GPU的总体布局。如图所示,L2缓存位于16个SM(上下8个SM)的中心,每个SM由2个相邻列和16行矩形(GPU处理器核心)以及一列16个加载/存储单元和一列4个特殊功能单元(SFU)表示。SM模块的更详细图示如下下图所示。下图中SM头部和底部的矩形是寄存器和L1/共享内存所在的位置,6个DRAM I/O接口中的每一个都具有64位存储器接口(DRAM接口电路在最外侧的左侧和右侧以深蓝色矩形显示)。因此,总体而言,GPU的GDDR5(图形双倍数据速率,专为图形处理而设计的DDR存储器)DRAM具有384位接口,允许支持总计6 GB的SM片外存储器(即全局、固定、纹理和局部)。此外,下图所示为主机接口,可在GPU布局图的左侧找到,主机接口允许GPU和CPU之间的PCIe连接。最后,GigaThread全局调度器(位于主机接口旁边)负责将线程块分配给所有SM的warp调度器。


19.9.3 多线程多处理器架构

为了解决不同的市场细分,GPU实现了可扩展的多处理器数量,实际上GPU是由多处理器组成的多处理器,此外,每个多处理器都是高度多线程的,可以高效地执行许多细粒度顶点和像素着色器线程。一个高质量的基本GPU有两到四个多处理器,而游戏爱好者的GPU或计算平台有几十个。本节将介绍一个这样的多线程多处理器的架构,是前面描述的NVIDIA Tesla流式多处理器(SM)的简化版本。

为什么要使用多处理器,而不是几个独立的处理器?每个多处理器内的并行性提供了本地化的高性能,并支持细粒度并行编程模型的广泛多线程,线程块的各个线程在多处理器内一起执行以共享数据。这里描述的多线程多处理器设计在紧密耦合的架构中有八个标量处理器内核,最多执行512个线程。为了提高面积和功率效率,多处理器在八个处理器内核中共享大型复杂单元,包括指令缓存、多线程指令单元和共享内存RAM。

GPU处理器高度多线程,可实现以下几个目标:

  • 覆盖DRAM内存加载和纹理提取的延迟
  • 支持细粒度并行图形着色器编程模型
  • 支持细粒度并行计算编程模型
  • 将物理处理器虚拟化为线程和线程块,以提供透明的可扩展性
  • 将并行编程模型简化为为一个线程编写串行程序

内存和纹理提取延迟可能需要数百个处理器时钟,因为GPU通常具有小型流缓存,而不像CPU这样的大型工作集缓存,提取请求通常需要完整的DRAM访问延迟加上互连和缓冲延迟。当一个线程等待加载或纹理获取完成时,多线程有助于利用有用的计算来覆盖延迟,处理器可以执行另一个线程。细粒度并行编程模型提供了数千个独立的线程,尽管单个线程的内存延迟很长,但这些线程仍能让许多处理器保持忙碌。

图形顶点或像素着色器程序是用于处理顶点或像素的单个线程的程序,类似地,CUDA程序是用于计算结果的单个线程的C程序。图形和计算程序实例化许多并行线程,以渲染复杂图像并计算大型结果数组。为了动态平衡移动顶点和像素着色器线程工作负载,每个多处理器同时执行多个不同的线程程序和不同类型的着色器程序。

为了支持图形着色语言的独立顶点、图元和像素编程模型以及CUDA C/C++的单线程编程模型,每个GPU线程都有自己的专用寄存器、专用每线程内存、程序计数器和线程执行状态,并且可以执行独立的代码路径。为了有效地执行数百个并发轻量级线程,GPU多处理器是硬件多线程的,在硬件中管理和执行数百个并行线程,而无需调度开销。线程块中的并发线程可以在一个屏障处与单个指令同步,轻量级线程创建、零开销线程调度和快速屏障同步有效地支持非常细粒度的并行性。

19.9.3.1 海量线程

GPU处理器高度多线程化,可实现以下几个目标:

  • 覆盖DRAM内存加载和纹理提取的延迟。
  • 支持细粒度并行图形着色器编程模型。
  • 支持细粒度并行计算编程模型。
  • 将物理处理器虚拟化为线程和线程块,以提供透明的可扩展性。
  • 将并行编程模型简化为为一个线程编写串行程序。

内存和纹理提取延迟可能需要数百个处理器时钟,因为GPU通常具有小型流缓存,而不像CPU的大型工作集缓存。提取请求通常需要完整的DRAM访问延迟加上互连和缓冲延迟,当一个线程等待加载或纹理获取完成时,多线程有助于利用有用的计算来覆盖延迟,处理器可以执行另一个线程(下图)。细粒度并行编程模型提供了数千个独立的线程,尽管单个线程的内存延迟很长,但这些线程仍能让许多处理器保持忙碌。

GPU利用多个Context切换来覆盖内存访问延迟。

图形顶点或像素着色器程序是用于处理顶点或像素的单个线程的程序,类似地,CUDA程序是用于计算结果的单个线程的C程序,图形和计算程序实例化许多并行线程以渲染复杂的图像并计算大型结果数组。为了动态平衡移动顶点和像素着色器线程工作负载,每个多处理器同时执行多个不同的线程程序和不同类型的着色器程序。

为了支持图形着色语言的独立顶点、图元和像素编程模型以及CUDA C/C++的单线程编程模型,每个GPU线程都有自己的专用寄存器、专用逐线程内存、程序计数器和线程执行状态,并且可以执行独立的代码路径。为了有效地执行数百个并发轻量级线程,GPU多处理器是硬件多线程的,它在硬件中管理和执行数百个并行线程,而无需调度开销。线程块中的并发线程可以在一个屏障处与单个指令同步,轻量级线程创建、零开销线程调度和快速屏障同步有效地支持非常细粒度的并行性。

19.9.3.2 多处理器架构

统一的图形和计算多处理器执行顶点、几何体和像素片段着色器程序以及并行计算程序。如下图所示,示例多处理器由八个标量处理器(SP)内核组成,每个内核具有一个大型多线程寄存器文件(RF)、两个特殊功能单元(SFU)、一个多线程指令单元、一个指令缓存、一个只读常量缓存和一个共享内存。

具有八个标量处理器(SP)核的多线程多处理器。八个SP核每个都有一个大型多线程寄存器文件(RF),并共享一个指令缓存、多线程指令发布单元、常量缓存、两个特殊功能单元(SFU)、互连网络和一个多组共享内存。

16KB的共享内存保存图形数据缓冲区和共享计算数据,声明为__shared__的CUDA变量驻留在共享内存中。为了通过多处理器多次映射逻辑图形管道工作负载,顶点、几何体和像素线程具有独立的输入和输出缓冲区,工作负载的到达和离开与线程执行无关。

每个SP核心包含执行大多数指令的标量整数和浮点算术单元。SP是硬件多线程的,最多支持64个线程。每个流水线SP内核每时钟每个线程执行一个标量指令,在不同的GPU产品中,其范围从1.2 GHz到1.6 GHz。每个SP核心都有一个1024个通用32位寄存器的大RF,在其分配的线程之间进行分区。程序声明其寄存器需求,通常每个线程16到64个标量32位寄存器。SP可以同时运行使用少量寄存器的多个线程或使用更多寄存器的更少线程,编译器优化寄存器分配,以平衡溢出寄存器的成本与更少线程的成本。像素着色器程序通常使用16个或更少的寄存器,使每个SP能够运行多达64个像素着色器线程,以覆盖长延迟纹理提取。编译的CUDA程序通常每个线程需要32个寄存器,将每个SP限制为32个线程,限制了该示例多处理器上的内核程序每个线程块只能有256个线程,而不是最多512个线程。

流水线SFU执行线程指令,这些指令计算特殊函数,并从原始顶点属性插值像素属性,可以与SP上的指令同时执行。

多处理器通过纹理接口在纹理单元上执行纹理提取指令,并使用内存接口执行外部内存加载、存储和原子访问指令,这些指令可以与SP上的指令同时执行。共享内存访问使用SP处理器和共享内存组之间的低延迟互连网络。

19.9.3.3 单指令多线程(SIMT)

为了高效地管理和执行运行多个不同程序的数百个线程,多处理器采用了单指令多线程(SIMT)架构,它在称为warp的并行线程组中创建、管理、调度和执行并发线程。“warp”一词起源于第一种平行线技术——编织,下图中的照片显示了织机上出现的平行线的warp,此示例多处理器使用32个线程的SIMT warp大小,在四个时钟上在八个SP核中的每一个中执行四个线程。Tesla SM多处理器还使用32个并行线程的warp大小,每个SP内核执行四个线程,以提高大量像素线程和计算线程的效率。线程块由一个或多个warp组成。

SIMT多线程warp调度。调度器选择一个准备好的warp,并向组成warp的并行线程同步发出指令。因为warp是独立的,所以调度器每次都可以选择不同的warp。

单指令多线程(single-instruction multiple-thread,SIMT):一种并行地将一条指令应用于多个独立线程的处理器架构。

经线(warp):在SIMT体系结构中一起执行同一指令的一组并行线程。

此示例SIMT多处理器管理一个包含16个warp的池,总共512个线程。组成warp的单个并行线程是相同的类型,并在相同的程序地址一起开始,但在其他情况下可以自由分支并独立执行。在每次指令发出时,SIMT多线程指令单元选择一个准备好执行其下一条指令的warp,然后将该指令发出给该warp的活动线程。SIMT指令被同步广播到warp的活动并行线程,由于独立的分支或预测,各个线程可能处于非活动状态。在该多处理器中,每个SP标量处理器内核使用四个时钟为一个warp的四个单独线程执行一条指令,反映了warp线程与内核的4:1比率。

SIMT处理器架构类似于单指令多数据(SIMD)设计,它将一条指令应用于多个数据通道,但不同之处在于,SIMT将一条命令并行应用于多条独立线程,而不仅仅是多条数据通道。用于SIMD处理器的指令一起控制多个数据通道的向量,而用于SIMT处理器的指令控制单个线程,并且SIMT指令单元向独立并行线程的warp发出指令以提高效率。SIMT处理器在运行时发现线程之间的数据级并行性,类似于超标量处理器在运行时间发现指令之间的指令级并行性。

当warp的所有线程采用相同的执行路径时,SIMT处理器实现了充分的效率和性能。如果warp的线程通过依赖于数据的条件分支分叉,则执行会对所采用的每个分支路径进行串行化,并且当所有路径完成时,线程会汇聚到同一执行路径。对于等长路径,发散的if-else代码块的效率为50%,多处理器使用分支同步堆栈来管理发散和聚合的独立线程。不同的warp以全速独立执行,而不管它们是执行公共的还是不相交的代码路径。因此,与早期GPU相比,SIMT GPU在分支代码上的效率和灵活性显著提高,因为它们的warp比现有GPU的SIMD宽度窄得多。

四元素预测向量核上的分支和非分支执行。每个元素执行在判断p上分支的十个操作着色器A。在情况B中,所有四个元素都采用无分支,没有发散,只需要六个执行步骤。在情况C中,元素1采用no分支,但其他三个元素采用yes分支。判断通过分别执行no和yes操作来处理这种差异,因此需要所有十个执行步骤。

与SIMD向量架构相比,SIMT使程序员能够为单个独立线程编写线程级并行代码,以及为许多协调线程编写数据并行代码。对于程序的正确性,程序员基本上可以忽略warp的SIMT执行属性,但通过注意代码很少需要warp中的线程来发散,可以实现显著的性能改进。实际上,这与传统代码中缓存线的作用类似:在设计正确性时可以安全地忽略缓存行大小,但在设计峰值性能时必须在代码结构中考虑缓存行大小。

19.9.3.4 SIMT Warp执行和发散

调度独立warp的SIMT方法比先前GPU架构的调度更灵活。warp包含相同类型的并行线程:顶点、几何体、像素或计算。像素片段着色器处理的基本单元是实现为四个像素着色器线程的2*2像素四边形,多处理器控制器将像素四边形打包为warp,它类似地将顶点和图元分组为warp,并将计算线程打包为warp,线程块包括一个或多个warp。SIMT设计在一个warp的并行线程之间有效地共享指令获取和发出单元,但需要一个完整的活动线程warp来获得充分的性能效率。

这种统一的多处理器同时调度和执行多个warp类型,允许它同时执行顶点和像素warp。它的warp调度器以低于处理器时钟速率的速度运行,因为每个处理器内核有四个线程通道。在每个调度周期中,它选择一个warp来执行SIMT warp指令,如上图所示。发出的warp指令在四个处理器吞吐量周期内作为四组八个线程执行,处理器流水线使用几个延迟时钟来完成每个指令。如果活动warp次数乘以每个warp的时钟数超过了管线延迟,程序员可以忽略管线延迟。对于该多处理器,八个warp的循环调度在同一个warp的连续指令之间有32个周期。如果程序可以保持每个多处理器256个线程处于活动状态,那么单个连续线程可以隐藏多达32个周期的指令延迟。然而,由于很少有活动warp,处理器管线深度变得可见,可能会导致处理器停滞。

一个具有挑战性的设计问题是为不同warp程序和程序类型的动态混合实现零开销warp调度。指令调度程序必须每四个时钟选择一个warp,以便每个线程每个时钟发出一条指令,相当于每个处理器内核1.0的IPC。因为warp是独立的,所以唯一的依赖关系是来自同一warp的顺序指令。调度器使用寄存器相关性记分板来限定活动线程准备好执行指令的warp,它会优先考虑所有这些准备好的warp,并为问题选择最高优先级的warp。优先级必须考虑warp类型、指令类型以及对所有活动warp公平的愿望。

19.9.3.5 管理线程和线程块

多处理器控制器和指令单元管理线程和线程块。控制器接受工作请求和输入数据,并仲裁对共享资源的访问,包括纹理单元、内存访问路径和I/O路径。对于图形工作负载,它同时创建和管理三种类型的图形线程:顶点、几何体和像素。每种图形工作类型都有独立的输入和输出路径。它将这些输入工作类型中的每一种累积并打包为执行同一线程程序的并行线程的SIMT warp,它分配一个自由的warp,为warp线程分配寄存器,并在多处理器中开始warp执行。每个程序都声明其每线程寄存器需求,只有当控制器可以为warp分配请求的寄存器计数时,控制器才启动warp。当warp的所有线程退出时,控制器将解开打包结果并释放warp寄存器和资源。

控制器创建协作线程阵列(cooperative thread array,CTA),将CUDA线程块实现为一个或多个并行线程warp,当它可以创建所有CTA warp并分配所有CTA资源时,它会创建CTA。除了线程和寄存器,CTA还需要分配共享内存和障碍。程序声明所需的容量,控制器等待,直到可以分配这些容量,然后启动CTA。随后,它以warp调度速率创建CTA warp,从而使CTA程序立即以完全的多处理器性能开始执行。控制器监控CTA的所有线程何时退出,并释放CTA共享资源及其warp资源。

协同线程阵列(cooperative thread array,CTA):一组并发线程,它们执行相同的线程程序,并可以协作计算结果。GPU CTA实现CUDA线程块。

19.9.3.6 线程指令

SP线程处理器为单个线程执行标量指令,与早期的GPU矢量指令架构不同,后者为每个顶点或像素着色器程序执行四个分量矢量指令。顶点程序通常计算(x,y,z,w)位置向量,而像素着色器程序计算(红、绿、蓝、Alpha)颜色向量。然而,着色器程序变得越来越长,越来越标量化,甚至很难完全占据传统GPU四分量矢量架构的两个组件。实际上,SIMT架构跨32个独立的像素线程进行并行化,而不是并行化一个像素内的四个矢量组件。CUDA C/C++程序主要具有每个线程的标量代码,以前的GPU使用向量打包(例如,组合工作的子向量以获得效率),但会使得调度硬件和编译器复杂化。标量指令更简单且编译器友好,纹理指令仍然基于向量,获取源坐标向量并返回过滤后的颜色向量。

为了支持具有不同二进制微指令格式的多个GPU,高级图形和计算语言编译器生成中间汇编程序级指令(例如Direct3D矢量指令或PTX标量指令),然后将其优化并转换为二进制GPU微指令。NVIDIA PTX(并行线程执行)指令集定义为编译器提供了稳定的目标ISA,并提供了几代GPU与不断发展的二进制微指令集架构的兼容性,优化器很容易将Direct3D矢量指令扩展为多个标量二进制微指令。尽管一些PTX指令扩展为多个二进制微指令,并且多个PTX指令可以折叠成一个二进制微命令,但PTX标量指令几乎可以用标量二进制微指令进行一对一转换。由于中间汇编程序级指令使用虚拟寄存器,优化器分析数据相关性并分配实际寄存器。优化器消除了死代码,在可行时将指令折叠在一起,并优化了SIMT分支的分叉点和聚合点。

指令集体系结构(ISA)

这里描述的线程ISA是Tesla架构PTX ISA的简化版本,是一个基于寄存器的标量指令集,包括浮点、整数、逻辑、转换、特殊函数、流控制、内存访问和纹理操作。下图列出了基本的PTX GPU线程指令,有关详细信息,请参阅NVIDIA PTX规范。

其指令格式为:

opcode.type d, a, b, c;

其中d是目标操作数,a、b、c是源操作数,.type是以下之一:

类型 .type特定值
无类型的位8、16、32和64位 .b8、.b16、.b32、.b64
无符号整数8、16、32和64位 .u8、.u16、.u22、.u64
有符号整数8、16、32和64位 .s8、.s16、.s32、.s64
浮点16、32和64位 .16、.f32、.f64

源操作数是寄存器中的标量32位或64位值、立即数或常量,判断操作数是1位布尔值。目的地是寄存器,存储到内存除外。指令是通过在它们前面加上@p或@!p、 其中p是判断寄存器。内存和纹理指令传输两到四个分量的标量或向量,总计最多128位。PTX指令指定一个线程的行为。

PTX算术指令对32位和64位浮点、有符号整数和无符号整数类型进行操作。当前GPU支持64位双精度浮点,PTX 64位整数和逻辑指令被转换为两个或多个执行32位操作的二进制微指令,GPU特殊功能指令仅限于32位浮点。线程控制流指令包括条件分支、函数调用和返回、线程退出和bar.sync(屏障同步)。条件分支指令@p bra target使用判断寄存器p(或!p)来确定线程是否执行分支,该判断寄存器p先前由比较和设置判断setp指令设置,其他指令也可以基于判断寄存器为真或假。

19.9.3.7 内存访问指令

tex指令通过纹理子系统从内存中的1D、2D和3D纹理阵列中提取并过滤纹理样本。纹理提取通常使用插值浮点坐标来处理纹理。一旦图形像素着色器线程计算其像素片段颜色,光栅操作处理器将其与指定(x,y)像素位置的像素颜色混合,并将最终颜色写入内存。

为了支持计算和C/C++语言需求,Tesla PTX ISA实现了内存加载/存储指令。它使用整数字节寻址和寄存器加偏移地址算法,以促进常规编译器代码优化。内存加载/存储指令在处理器中很常见,但在Tesla架构GPU中是一项重要的新功能,因为以前的GPU只提供图形API所需的纹理和像素访问。

对于计算,加载/存储指令访问实现第B.3节中相应CUDA存储空间的三个读/写存储空间:

  • 逐线程专用可寻址临时数据的局部内存(在外部DRAM中实现)。
  • 共享内存,用于低延迟访问同一个CTA/线程块中协作线程共享的数据(在片上SRAM中实现)。
  • 由计算应用程序的所有线程共享的大型数据集的全局存储器(在外部DRAM中实现)。

内存加载/存储指令ld.global、st.global、ld.shared、st.shared、ld.local和st.local分别访问全局、共享和局部内存空间。计算程序使用快速屏障同步指令bar.sync以同步CTA/线程块内通过共享和全局内存彼此通信的线程。

为了提高内存带宽并减少开销,当地址落在同一块中并满足对齐标准时,局部和全局加载/存储指令将来自同一SIMT warp的单个并行线程请求合并为单个内存块请求。与来自单个线程的单独请求相比,合并内存请求可显著提高性能。多处理器的大量线程数,加上对许多未完成的负载请求的支持,有助于覆盖负载,从而使用外部DRAM中实现的局部和全局内存的延迟。

Tesla架构GPU还通过atom.op.u32指令在内存上提供高效的原子内存操作,包括整数操作add、min、max、and、or、xor、exchange和cas(比较和交换)操作,有助于并行缩减和并行数据结构管理。

19.9.3.8 线程通信的屏障同步

快速屏障同步允许CUDA程序通过简单调用__syncthreads(),通过共享内存和全局内存频繁通信,作为每个线程间通信步骤的一部分。同步内建函数生成单个bar.sync指令,但在每个CUDA线程块最多512个线程之间实现快速屏障同步是一个挑战。

将线程分组为32个线程的SIMT warp将同步难度降低了32倍。线程在SIMT线程调度程序中的一个屏障处等待,因此它们在等待时不会消耗任何处理器周期。当线程执行一条bar.sync指令,它递增屏障的线程到达计数器,调度器将线程标记为在屏障处等待。一旦所有CTA线程到达,屏障计数器与预期的终端计数相匹配,调度器释放在屏障处等待的所有线程并恢复执行线程。

19.9.3.9 流式多处理器(SM)

纹理/处理器集群。

上图显示了具有两个SM的TPC的结构。几何控制器在单个核上协调顶点和形状处理,它从内存层次结构中引入顶点数据,指导内核处理它们,然后协调将输出存储到内存层次结构的过程。此外,它还有助于将输出转发到下一个处理阶段。SMC(SM控制器)调度对外部资源的请求,例如,SM中的多个内核可能希望写入DRAM内存或访问纹理单元。在这种情况下,SMC对请求进行仲裁。

现在看看SM的结构。每个SM都有一个I缓存(指令缓存)、一个C缓存(常量缓存)和一个用于多线程工作负载的内置线程调度器(MT Issue Unit)。8个SP核可以访问嵌入在SM中的共享存储器单元,以便在它们之间进行通信。SP核心具有符合IEEE 754的浮点ALU,可以执行常规浮点运算,如加法、减法和乘法。它还支持称为乘法加法的特殊指令,这在图形计算中是非常常见的,此指令计算表达式的值:a*b+c。与FP ALU一起,每个SP都有一个整数ALU,可以执行常规整数指令和逻辑指令,此外,SP核心可以执行内存指令和分支指令。与向量处理器类似,SP核心实现预测指令,意味着他们将执行槽专用于错误路径中的指令,尽管它们被nop指令取代。SP针对速度进行了优化,是整个GPU中速度最快的单元,因为它们实现了一个非常简单的类似RISC的指令集,它主要由基本指令组成。

为了计算更复杂的数学函数,例如超越函数或三角函数,每个SM中有两个特殊的函数单元(SFU)。SFU还具有专门的单元,用于插值片元内的颜色值,GPU使用此功能为每个三角形片段的内部着色。除了专用单元外,SFU还具有用于运行通用代码的常规整数/浮点ALU。
TPC中的两个SM共享一个纹理单元,纹理单元可以同时处理四个线程,并将光栅化后生成的所有三角形与与三角形关联的曲面纹理进行处理。纹理信息存储在纹理单元内的小缓存中,在缓存未命中时,纹理单元可以从相关的二级缓存或从主DRAM存储器获取数据。

现在讨论如何在GPU上执行计算。SM中的每个线程(映射到SP)可以访问逐线程局部内存(保存在外部DRAM上)、共享内存(在SM中的所有线程之间共享,并保存在芯片上)或全局DRAM内存。程序员可以明确指示GPU使用某种内存。

更详细的单个SM结构如下图所示。

单个SM架构。

上图右侧将NVIDIA费米体系结构分解为单个SM的基本组件,这些组件是:

  • GPU处理器内核(共32个CUDA内核)。
  • Warp调度程序和调度端口。
  • 16个加载/存储单元。
  • 四个SFU。
  • 32k*32位寄存器。
  • 共享内存和一级缓存(共64 kB)。

下面详细阐述SM内的各个部件。先阐述双warp调度器(dual warp scheduler)。

如前所述,GPU芯片上的GigaThread全局调度器单元将线程块分配给SM,然后双warp调度器将其处理的每个线程块分解为warp,其中warp是由32个线程组成的束,这些线程从相同的起始地址开始,其线程ID是连续的。一旦发出warp,每个线程都会有自己的指令地址计数器和寄存器集,以允许SM中每个线程的独立分支和执行。

GPU在处理尽可能多的warp以最大限度地利用CUDA内核时效率最高。如下图所示,当双warp调度器和指令调度单元能够每两个时钟周期发出两次warp(Fermi架构)时,SM硬件利用率将达到最大值。如下文所述,结构冲突是SM无法达到最大处理速率的主要原因,而片外内存访问延迟则更容易隐藏。

如果组件列不存在结构冲突,则每个划分的列由16个CUDA核心(*2)、16个加载/存储单元和4个SFU(上图)组成,每个时钟周期可以从两个warp调度器/调度单元中的每一个分配半个warp(16个线程)进行处理。结构冲突由有限的SFU、双精度乘法和分支引起,但是,warp调度程序有一个内置的记分板(scoreboard)来跟踪可用于执行的warp以及结构冲突,使得SM既能避免结构冲突,又能尽可能地隐藏芯片外内存访问延迟。

双warp调度器和指令调度单元运行示例。

因此,程序员必须将线程块大小设置为大于SM中CUDA内核的总数,但小于每个块允许的最大线程数,并确保线程块大小(在x和/或y维度)为32的倍数(warp大小),以实现SM的接近最佳利用率。

阐述完双warp调度器,再阐述CUDA核心。

NVIDIA GPU处理器内核也称为CUDA内核,在Fermi架构中,共有32个CUDA核专用于每个SM。每个CUDA核心都有两个独立的管线或数据路径:一个整数(INT)单元管线和一个浮点(FP)单元管线(见上上图),在一个时钟周期内只能使用这些数据路径中的一个。INT单元能够进行32位、64位和扩展精度的整数和逻辑/位运算,FP单元可以执行单精度FP运算,而双精度FP运算需要两个CUDA核。因此,与单精度FP线程相比,仅执行双精度FP操作的线程运行所需的时间是其两倍。通过在每个SM中包含专用的双精度单元以及大多数单精度单元,Kepler架构解决了双精度FP算法的性能影响。幸运的是,CUDA程序员隐藏了线程级FP单精度和双精度操作的管理,但程序员应该意识到使用基于所用GPU的两种精度类型之间可能产生的潜在性能影响。

Fermi架构为CUDA核心的FP单元增加了一项改进,从IEEE 754-1985浮点算术标准升级为IEEE 754-2008标准,是通过使用融合乘法加法(FMA)指令提高乘法加法指令(MAD)的精度来实现的。FMA指令对单精度和双精度算术都有效,Fermi架构仅在FMA指令末尾执行一次舍入,此举不仅提高了结果的准确性,而且执行FMA指令也被压缩到单处理器时钟周期中。因此,每个SM在一个处理器时钟周期内可以进行32次单精度或16次双精度FMA操作。

其它部件说明如下:

  • 特殊函数单元(special function unit):每个SM有四个SFU。SFU在一个时钟周期内执行超越运算,如余弦、正弦、倒数和平方根。由于一个SM中只有4个SFU,而一个warp中只有一条指令的32个并行线程,因此完成一个需要SFU的warp需要8个时钟周期,但CUDA处理器以及加载和存储单元仍然可以同时使用。

  • 加载和存储单位:SM的16个加载和存储单元中的每一个计算每个时钟周期单个线程的源地址和目标地址,这些地址用于线程希望写入数据或从中读取数据的缓存或DRAM。

  • 寄存器、共享内存和L1缓存:每个SM都有自己的(片上)专用寄存器集和共享内存/l1缓存块。关于低延迟片上内存的详细信息和优点如下表。

    内存类型 相对访问时间 访问类型 范围 数据生存期
    寄存器 最快,芯片内 R/W 单线程 线程
    共享 快,芯片内 R/W 块上的所有线程
    局部 比共享和寄存器慢100到150倍,芯片外 R/W 单线程 线程
    全局 比共享和寄存器慢100到150倍,芯片外 R/W 所有线程和主机 应用程序
    固定 比共享和寄存器慢100到150倍,芯片外 R 所有线程和主机 应用程序
    纹理 比共享和寄存器慢100到150倍,芯片外 R 所有线程和主机 应用程序

尽管Fermi架构每个SM有一个令人印象深刻的32k x 32位寄存器,但每个线程最多分配64x32位的寄存器,如CUDA计算能力2.x版所定义的,这是每个SM允许的最大活动warp数以及每个SM的寄存器数的函数。如上表所示,寄存器和共享内存的最快访问时间只有几纳秒(ns)。如果有任何临时寄存器溢出,数据将首先移动到L1缓存,然后再发送到L2缓存,然后是长访问延迟本地内存(见下图a)。使用一级缓存有助于防止发生数据读/写冲突,因此分配给线程的寄存器中的数据的寿命仅与线程的寿命相同。

Fermi内存架构。

与当代多核微处理器(如CPU)相比,专用于SM的GPU处理器核心的可寻址片上共享内存是一种独特的配置,这些当代架构具有专用的片上L1缓存和每个内核一组寄存器,但它们通常没有片上可寻址内存。相反,专用内存管理硬件在没有程序员控制的情况下调节高速缓存和主内存之间的数据移动,与GPU架构有很大不同。

共享内存被添加到GPU架构中,专门用于辅助GPGPU应用程序。优化共享内存的使用可以通过消除对片外内存的不必要的长延迟访问,显著提高GPGPU应用程序的速度和性能。尽管每个SM的共享内存大小很小(最大配置为48 kB),但它的访问延迟非常低,比全局内存少100到150倍(见上表)。因此,共享内存可以通过三种主要方式加速并行处理任务:

  • 块的所有线程多次重复使用共享内存数据(如用于矩阵-矩阵乘法的数据块)。
  • 使用块的选择线程(基于特定ID)将数据从全局内存传输到共享内存,从而消除了对相同内存位置的冗余读取和写入。
  • 如果可能,用户可以通过确保访问被合并来优化对全局内存的数据访问。

所有这些点也有助于减少片外内存带宽限制问题。SM共享内存中数据的生命周期与在其上处理的线程块的生命周期一样长。因此,一旦块的所有线程完成,SM共享内存中的数据就不再有效。

尽管共享内存的使用将提供最佳运行时间,但在某些应用程序中,在编程阶段内存访问是未知的,拥有更多可用的L1缓存(最大设置为48 kB)将获得最佳结果。此外,L1缓存有助于防止寄存器溢出,而不是直接进入本地(片外)DRAM内存。两级缓存层次结构每个SM一个L1缓存,以及跨芯片、SM共享的L2缓存提供了与传统多核微处理器相同的好处。

我们需要认识到,在GPU编程中,理解内存类型具有举足轻重的作用。

程序员必须了解各种GPU内存的细微差别,特别是每种内存类型的可用大小、相对访问时间和可访问性限制,以使用CUDA进行正确高效的代码开发。GPGPU编程所需的方法与针对CPU的程序开发方法大不相同,其中所使用的特定数据存储硬件(文件I/O除外)对程序员来说是隐藏的。

例如,在GPU架构中,分配给CUDA内核的每个线程都有自己的寄存器集,因此一个线程无法访问另一个线程的寄存器,无论是否在同一个SM中。特定SM中的线程可以相互协作(通过数据共享)的唯一方式是通过共享内存(下图),通常通过程序员仅分配SM的某些线程来写入其共享内存的特定位置来实现,从而防止写入冲突或浪费周期(例如许多线程从全局内存读取相同的数据并将其写入相同的共享内存地址)。在特定SM的所有线程被允许从刚刚写入的共享内存中读取之前,需要对该SM的所有的线程进行同步,以防止写入后读取(RAW)数据冲突。

GPU基本架构的CUDA表示。

19.9.3.10 流处理器(SP)

多线程流处理器(SP)核心是多处理器中的主要线程指令处理器,其寄存器文件(RF)为多达64个线程提供1024个标量32位寄存器。它执行所有基本的浮点运算,包括add.f32、mul.f32、mad.f32(浮动乘加)、min.f32, max.f32和setp.f32(浮动比较和设置判断)。浮点加法和乘法运算与IEEE 754标准兼容,适用于单精度FP数,包括非整数(NaN)和无穷大值。SP核心还实现了所有32位和64位整数运算、比较、转换和逻辑PTX指令。

浮点加法和乘法运算采用IEEE舍入调,甚至作为默认舍入模式。mad.f32浮点乘法加法运算执行带截断的乘法,然后执行带舍入到最接近偶数的加法。SP将输入非正规操作数刷新为符号保留零,舍入后,将目标输出指数范围下溢的结果刷新为符号保留零。

19.9.3.11 特殊功能单元(SFU)

某些线程指令可以与SP上执行的其他线程指令同时在SFU上执行。SFU实现了特殊函数指令,该指令计算32位浮点逼近的倒数、倒数平方根和关键超越函数,它还为像素着色器实现32位浮点平面属性插值,提供颜色、深度和纹理坐标等属性的精确插值。

每个流水线SFU每个周期生成一个32位浮点特殊函数结果,每个多处理器的两个SFU以八个SP的简单指令速率的四分之一执行特殊功能指令。SFU还与八个SP同时执行mul.f32乘法指令,将具有适当指令混合的线程的峰值计算率提高到50%。

对于功能评估,Tesla架构SFU采用基于增强的最小极大近似的二次插值来逼近倒数、倒数平方、\(\log_2x\)、2x和sin/cos函数,函数计算的精度范围从22到24个尾数位。

19.9.3.12 与其他多处理器相比

与x86 SSE等SIMD矢量体系结构相比,SIMT多处理器可以独立执行单个线程,而不是总是在同步组中一起执行它们。SIMT硬件在独立线程之间找到数据并行性,而SIMD硬件要求软件在每个向量指令中明确表示数据并行性。当线程采用相同的执行路径时,SIMT机器同步执行32个线程的warp,但当它们分开时,可以独立执行每个线程。这一优势非常明显,因为SIMT程序和指令只描述单个独立线程的行为,而不是四个或更多数据通道的SIMD数据向量。然而,SIMT多处理器具有类似于SIMD的效率,将一个指令单元的面积和成本扩展到32个warp线程和8个流处理器核心。SIMT提供了SIMD的性能和多线程的生产力,避免了为边缘条件和部分发散显式编码SIMD向量的需要。

SIMT多处理器的开销很小,因为它是带有硬件屏障同步的硬件多线程,允许图形着色器和CUDA线程表达非常细粒度的并行性。图形和CUDA程序使用线程来表示每线程程序中的细粒度数据并行性,而不是强迫程序员将其表示为SIMD向量指令。与矢量代码相比,开发标量单线程代码更简单、更高效,SIMT多处理器以类似SIMD的效率执行代码。

将八个流处理器核心紧密耦合到一个多处理器中,然后实现可扩展数量的多处理器,从而形成由多处理器组成的两级多处理器。CUDA编程模型通过为细粒度并行计算提供单个线程,并为粗粒度并行操作提供线程块网格,从而利用了两级层次结构,同一线程程序可以提供细粒度和粗粒度操作。相反,具有SIMD向量指令的CPU必须使用两种不同的编程模型来提供细粒度和粗粒度操作:不同内核上的粗粒度并行线程,以及用于细粒度数据并行的SIMD向量。

19.9.3.13 多线程多处理器结论

基于Tesla架构的示例GPU多处理器是高度多线程的,同时执行多达512个轻量级线程,以支持细粒度像素着色器和CUDA线程。它使用了SIMD架构和多线程的一种变体,称为SIMT(单指令多线程),以有效地将一条指令广播到32个并行线程的warp中,同时允许每个线程独立地分支和执行。每个线程在八个流处理器(SP)内核之一上执行其指令流,这些内核最多有64个线程。

PTX ISA是一种基于寄存器的加载/存储标量ISA,用于描述单个线程的执行。由于PTX指令被优化并转换为特定GPU的二进制微指令,因此硬件指令可以快速发展,而不会中断生成PTX指令的编译器和软件工具。

19.9.3.14 分块渲染(Binned Rendering)

我们通常将光栅化定义为将屏幕坐标几何图元直接转换为像素片段的过程,但是,也可以将光栅化到更大的屏幕区域,例如n×n像素块。GeForce 9800 GTX光栅化器就是一个例子,它输出2×2个四边形片段以简化纹理重映射计算。分块渲染(Binned Rendering,亦称装箱渲染)将光栅化分为两个阶段:第一阶段输出中等大小的分块片段,每个片段对应于屏幕坐标中的8×8、16×16或32×32像素网格,随后是第二阶段,该第二阶段将每个分块片段减少为像素片段。当然,平铺片段包括从屏幕坐标图元导出的信息,以便第二阶段光栅化可以产生正确的像素片段。

分块渲染实际上将整个渲染过程分为两个阶段,对应于光栅化的两个阶段。在第一阶段,通过分块光栅化处理场景,并将生成的分块片段分类到各个分格中,每个分格对应于每个屏幕分块。只有在第一阶段完成之后(即在生成了整个场景的分块片段并将其分类到箱子中之后),第二阶段才开始。在第二阶段,每个bin都被单独处理,直到完成,产生一个n×n的像素块,并将其存储在帧缓冲区中。

分块渲染有几个吸引人的特性:

  • 局部内存:帧缓冲区数据一致性的绝对保证,仅访问分块中的像素,允许在局部内存中处理像素,而不是从主内存缓存。功耗和主内存周期都可节省,使得分块渲染成为移动设备的一个有吸引力的解决方案。
  • 全场景抗锯齿:回想一下,多采样抗锯齿需要在每个像素存储多个颜色和深度采样。由于通过增加采样数提高了质量,因此当渲染到整个帧缓冲区时,存储和带宽都变得非常昂贵,但当渲染仅限于一小块像素时,它们仍然很经济。甚至更高级的渲染算法,如透明表面的顺序无关渲染,都可以通过巧妙地使用局部内存来支持。
  • 延迟着色:将渲染限制在一小块像素上解决了延迟着色的关键限制:需要过多的内存存储和带宽,以及与多样本抗锯齿不兼容。

分块渲染的优点是引人注目的,但目前还没有PC级GPU实现它。最根本的原因是,分块渲染与管线Direct3D和OpenGL架构的差异太大,我们说抽象距离太大。通常,过度的抽象距离会导致产品具有混杂的性能特征(预期快的操作是慢的,预期慢的操作是快的)或与指定操作的细微偏差。遇到的实际问题包括:

  • 过度延迟:以前的分块渲染系统,如北卡罗来纳大学教堂山分校开发的PixelPlanes 5系统,增加了全帧延迟时间。
  • 较差的多pass操作:Direct3D和OpenGL鼓励先进的多pass渲染技术,在分块实现中,每个最终帧需要多次两遍操作。例如,通过1)渲染在反射中可见的场景,2)将该图像加载为纹理,3)使用适当扭曲的纹理图像渲染表面来渲染来自表面的反射。一些分块渲染系统无法支持此类操作,其他的虽支持但表现不佳。
  • 无边界内存需求:虽然分块渲染将像素存储限制为单个块所需的存储,但分块本身所需的内存会随着场景复杂性而增加。OpenGL和Direct3D都没有场景复杂度限制,因此完全确认的实现需要无限的内存(显然不可能),或者必须引入复杂度来处理有限块存储不足的情况。

这些复杂性已经足以将binned渲染排除在主流PC GPU之外。但是最近的实现趋势,特别是使用时间共享的单个计算引擎来实现所有管线着色阶段,可能会克服一些困难。

19.9.4 并行内存系统

在GPU本身之外,内存子系统是图形系统性能的最重要决定因素,图形工作负载需要非常高的内存传输速率。像素写入和混合(读取-修改-写入)操作、深度缓冲区读取和写入、纹理贴图读取,以及命令和对象顶点和属性数据读取,构成了大部分内存流量。

现代GPU是高度并行的,例如GeForce 8800可以在600 MHz下处理每个时钟32个像素,每个像素通常需要4字节像素的颜色读写和深度读写。通常读取平均两个或三个四字节的纹素,以生成像素的颜色,对于典型情况,每个时钟需要28字节乘以32像素=896字节,显然对内存系统的带宽需求是巨大的。

为了满足这些要求,GPU内存系统具有以下特点:

  • 它们很宽,意味着GPU和它的内存设备之间有大量的引脚来传输数据,而内存阵列本身包括许多DRAM芯片来提供全部的数据总线宽度。
  • 它们速度很快,意味着使用积极的信令技术来最大化每引脚的数据速率(比特/秒)。
  • GPU寻求使用每个可用周期来向或从内存阵列传输数据。为了实现这一点,GPU特别不以最小化内存系统的延迟为目标。高吞吐量(利用效率)和短延迟从根本上来说是相冲突的。
  • 使用的压缩技术既有程序员必须意识到的有损压缩技术,也有应用程序不可见的无损压缩技术。
  • 缓存和工作合并结构用于减少所需的片外流量,并确保尽可能充分地使用移动数据所花费的周期。

19.9.4.1 显存结构

虽然DRAM通常被视为一个扁平的字节数组,但其内部结构要复杂得多。对于像GPU这样的高性能应用程序,非常有必要深入地理解它。从下往上大致看,VRAM由以下部分组成:

  • R行乘以C列的内存平面(memory plane),每个单元为一位。

    img

  • 由32、64或128个并行使用的内存平面组成的内存体(memory bank)——这些平面通常分布在多个芯片上,其中一个芯片包含16或32个内存平面。bank中的所有页面都连接到行寻址系统(列也是如此),并且这些页面由命令信号和每行/列的地址控制。bank中的行和列越多,地址中需要使用的位就越多。

    img

  • 由若干个[2、4或8]个memory bank连接在一起并由地址位选择的内存排(memory rank)——给定内存平面的所有memory bank位于同一芯片中。

  • 由一个或两个连接在一起并由芯片选择线选择的memory rank组成的内存子分区(memory subpartition)——rank的行为类似于bank,但不必具有统一的几何结构,而是在单独的芯片中。

  • 由一个或两个稍微独立的memory subpartition组成了内存分区(memory partition)

  • 整个VRAM由几个[1-8]个memory partition组成。

以上数量会因不同的GPU架构和家族而不同。

简化GDDR3存储器电路的结构图。为了提高清晰度,实际存储容量(十亿位)减少到256位,实现为16个16位块(也称为行)的阵列。到达块左边缘的红色箭头表示控制路径,而到达块顶部和底部的蓝色箭头表示数据路径。

DRAM最基本的单元是内存平面,它是按所谓的列和行组织的二维位数组:

     column
row  0  1  2  3  4  5  6  7
0    X  X  X  X  X  X  X  X
1    X  X  X  X  X  X  X  X
2    X  X  X  X  X  X  X  X
3    X  X  X  X  X  X  X  X
4    X  X  X  X  X  X  X  X
5    X  X  X  X  X  X  X  X
6    X  X  X  X  X  X  X  X
7    X  X  X  X  X  X  X  X

buf  X  X  X  X  X  X  X  X

内存平面包含一个缓冲区,该缓冲区可容纳整个行。在内部,DRAM通过缓冲区以行为单位进行读/写。因此有几个后果:

  • 在对某个位进行操作之前,必须将其行加载到缓冲区中,会很慢。
  • 处理完一行后,需要将其写回内存数组,也很慢。
  • 因此,访问新行的速度很慢,如果已经有一个活动行,访问速度甚至更慢。
  • 在一段不活动时间后,抢先关闭一行通常很有用——这种操作称为precharging(预充电?)一个bank。
  • 但是,可以快速访问同一行中的不同列。

由于加载列地址本身比实际访问活动缓冲区中的位花费更多的时间,所以DRAM是以突发方式访问的,即对活动行中1-8个相邻位的一系列访问,通常突发中的所有位都必须位于单个对齐的8位组中。内存平面中的行和列的数量始终是2的幂,并通过行选择和列选择位的计数来衡量[即行/列计数的log2],通常有8-10列位和10-14行位。内存平面被组织在bank中,bank由两个内存平面的幂组成。内存平面是并行连接的,共享地址和控制线,只有数据/数据启用线是分开的。这有效地使内存bank类似于由32位/64位/128位内存单元组成的内存平面,而不是单个位——适用于平面的所有规则仍然适用于bank,但操作的单元比位大。单个存储芯片通常包含16或32个存储平面,用于单个bank,因此多个芯片通常连接在一起以形成更宽的bank。

一个内存芯片包含多个[2、4或8]个bank,使用相同的数据线,并通过bank选择线进行多路复用。虽然在bank之间切换比在一行中的列之间切换要慢一些,但要比在同一bank中的行之间切换快得多。因此,一个内存bank由(MEMORY_CELL_SIZE / MEMORY_CELL_SIZE_PER_CHIP)内存芯片组成。一个或两个通过公共线(包括数据)连接的内存列,芯片选择线除外,构成内存子分区。在rank之间切换与在bank中的列组之间切换具有基本相同的性能后果,唯一的区别是物理实现和为每个rank使用不同数量行选择位的可能性(尽管列计数和列计数必须匹配)。存在多个bank/rank的后果:

  • 确保一起访问的数据要么属于同一行,要么属于不同的bank,这一点很重要(以避免行切换)。
  • 分块内存布局的设计使分块大致对应于一行,相邻的分块从不共享一个bank。

内存子分区在GPU上有自己的DRAM控制器。1或2个子分区构成一个内存分区,它是一个相当独立的实体,具有自己的内存访问队列、自己的ZROP和CROP单元,以及更高版本卡上的二级缓存。所有内存分区与crossbar逻辑一起构成了GPU的整个VRAM逻辑,分区中的所有子分区必须进行相同的配置,GPU中的分区通常配置相同,但在较新的卡上则不是必需的。子分区/分区存在的后果:

  • 与bank一样,可以使用不同的分区来避免相关数据的行冲突。
  • 与bank不同,如果(子)分区没有得到同等利用,带宽就会受到影响。因此,负载平衡非常重要。

虽然内存寻址高度依赖于GPU系列,但这里概述了基本方法。内存地址的位按顺序分配给:

  • 识别内存单元中的字节,因为无论如何都必须访问整个单元。
  • 多个列选择位,以允许突发(burst)。
  • 分区/子分区选择-以低位进行,以确保良好的负载平衡,但不能太低,以便在单个分区中保留相对较大的tile,以利于ROP。
  • 剩余列选择位。
  • 所有/大部分bank选择位,有时是排名选择位,以便相邻地址不会导致行冲突。
  • 行位(row bit)。
  • 剩余的bank位或rank位,有效地允许将VRAM拆分为两个区域,在其中一个区域放置颜色缓冲区,在另一个区域放置zeta缓冲区,这样它们之间就不会有行冲突。

GPU必须考虑DRAM的独特特性。DRAM芯片在内部被布置为多个(通常为四到八个)存储体(bank),其中每个bank包括2次幂数的行(通常为16384),并且每行包含2次幂位数的位(通常为8192)。DRAM对其控制处理器施加了各种时序要求,例如激活一行需要几十个周期,但一旦激活,该行内的位可以每四个时钟随机访问一个新的列地址。双倍数据速率(DDR)同步DRAM在接口时钟的上升沿(rising edge)和下降沿(falling edge)传输数据(下面两图),因此1GHz时钟DDR DRAM以每数据引脚每秒2千兆比特的速度传输数据。图形DDR DRAM通常有32个双向数据引脚,因此每个时钟可以从DRAM读取或写入8个字节。

img

单ank和双rank对比。

img

单速率、双速率、四速率对比图。

GPU内部有大量的内存流量生成器。逻辑图形管线的不同阶段都有自己的请求流:命令和顶点属性提取、着色器纹理提取和加载/存储,以及像素深度和颜色读写。在每个逻辑阶段,通常有多个独立的单元来提供并行吞吐量,都是独立的内存请求者。当在内存系统中查看时,有大量不相关的请求正在运行,是与DRAM优选的参考模式(pattern)的自然不匹配。一种解决方案是GPU的内存控制器为不同的DRAM组保持单独的流量堆,并等待特定DRAM行有足够的流量等待,然后激活该行并同时传输所有流量。请注意,累积未决请求虽然有利于DRAM行位置,从而有效地使用数据总线,但会导致较长的平均等待时间,正如请求者等待其他请求所看到的那样。设计必须注意,任何特定的请求都不会等待太长时间,否则一些处理单元可能会等待数据,最终导致相邻处理器闲置。

GPU内存子系统被布置为多个存储器分区,每个内存分区包括完全独立的内存控制器和一个或两个DRAM设备,这些DRAM设备由该分区完全和独占拥有。为了实现最佳的负载平衡,并因此接近n个分区的理论性能,地址在所有内存分区之间均匀地精细交错,分区交错步长通常是几百字节的块,内存分区的数量旨在平衡处理器和其他内存请求者的数量。

19.9.4.2 缓存

GPU工作负载通常具有数百兆字节量级的非常大的工作集,以生成单个图形帧。与CPU不同,在足够大的芯片上构建缓存以容纳接近图形应用程序全部工作集的内容是不现实的。尽管CPU可以假设非常高的缓存命中率(99.9%或更高),但GPU的命中率接近90%,因此必须应对运行中的许多未命中。虽然CPU可以合理地设计为在等待罕见的缓存未命中时停滞,但GPU需要处理混合的未命中和命中。我们称之为流缓存架构(streaming cache architecture)

GPU缓存必须为其客户端提供非常高的带宽。考虑纹理缓存的情况,典型的纹理单元可以为每个时钟周期四个像素中的每一个执行两个双线性插值,并且GPU可以具有许多这样的纹理单元,所有这些纹理单元都独立地操作。每个双线性插值需要四个单独的纹素,每个纹素可能是64位值,四个16位组件是典型的,因此总带宽为2×4×4×64=2048位/时钟。每个单独的64位纹素都是独立寻址的,因此缓存需要每个时钟处理32个唯一的地址。这自然有利于SRAM阵列的多组和/或多端口布置。

19.9.4.3 MMU

现代GPU能够将虚拟地址转换为物理地址。在GeForce 8800上,所有处理单元都在40位虚拟地址空间中生成内存地址。对于计算,加载和存储线程指令使用32位字节地址,通过添加40位偏移量将其扩展为40位虚拟地址。内存管理单元执行虚拟到物理地址转换;硬件从本地内存中读取页表,以代表分布在处理器和渲染引擎之间的翻译后备缓冲区的层次结构来响应未命中。除了物理页面位之外,GPU页面表条目还指定了每个页面的压缩算法,页面大小从4到128 KB不等。

CUDA公开了不同的内存空间,以允许程序员以最佳性能的方式存储数据值。下图是CPU和GPU内存请求路线:

img

GTT/GART作为CPU-GPU共享缓冲区用于通信:

后面小节的讨论以NVIDIA Tesla架构GPU为基准。

19.9.4.4 全局内存

全局内存存储在外部DRAM中,不是任何一个物理流多处理器(SM)的局部,因为它用于不同网格中不同CTA(线程块)之间的通信。事实上,引用全局内存中某个位置的许多CTA可能不会同时在GPU中执行,通过设计,在CUDA中,程序员不知道CTA执行的相对顺序。由于地址空间均匀分布在所有内存分区之间,因此必须有从任何流式多处理器到任何DRAM分区的读/写路径。

不同线程(和不同处理器)对全局内存的访问不能保证具有顺序一致性。线程程序看到一个宽松的(relaxed)内存排序模型,在线程中,内存对同一地址的读写顺序被保留,但对不同地址的访问顺序可能不会被保留。不同线程请求的内存读取和写入是无序的,在CTA中,屏障同步指令bar.sync可用于在CTA的线程之间获得严格的内存排序。membar线程指令提供了一个内存屏障/栅栏操作,该操作提交先前的内存访问,并在继续之前使其他线程可见。线程还可以使用原子内存操作来协调它们共享的内存上的工作。

19.9.4.5 共享内存

逐CTA共享内存仅对属于该CTA的线程可见,并且共享内存仅从创建CTA到终止CTA期间占用存储空间,因此共享内存可以驻留在芯片上。这种方法有以下好处:

  • 共享内存流量不需要与全局内存引用所需的有限片外带宽竞争。
  • 在芯片上构建非常高带宽的内存结构以支持每个流式多处理器的读/写需求是可行的。事实上,共享内存与流式多处理器紧密耦合。

每个流式多处理器包含八个物理线程处理器。在一个共享内存时钟周期内,每个线程处理器可以处理两个线程的指令,因此每个时钟必须处理16个线程的共享内存请求。因为每个线程都可以生成自己的地址,并且地址通常是唯一的,所以共享内存是使用16个可独立寻址的SRAM bank构建的。对于常见的访问模式,16个bank足以保持吞吐量,但也可能存在极端情况,例如所有16个线程可能恰好访问一个SRAM组上的不同地址。必须能够将请求从任何线程通道路由到任何SRAM组,因此需要16*16的互连网络。

19.9.4.6 局部内存

逐线程局部内存是仅对单个线程可见的专用内存。局部内存在架构上大于线程的寄存器文件,程序可以将地址计算到局部内存中。为了支持局部内存的大量分配(回想一下,总分配是每线程分配乘以活动线程数),局部内存分配在外部DRAM中。虽然全局和逐线程局部内存驻留在芯片外,但它们非常适合缓存在芯片上。

19.9.4.7 常量内存

常量内存对SM上运行的程序是只读的(可以通过命令写入GPU),存储在外部DRAM中,并缓存在SM中。因为通常SIMT warp中的大多数或所有线程都是从常量内存中的同一地址读取的,所以每个时钟的单个地址查找就足够了。常量缓存被设计为向每个warp中的线程广播标量值。

19.9.4.8 纹理内存

纹理内存保存大型只读数据数组,用于计算的纹理与用于3D图形的纹理具有相同的属性和功能。虽然纹理通常是二维图像(像素值的2D阵列),但也可以使用1D(线性)和3D(体积)纹理。

计算程序使用tex指令引用纹理,操作数包括用于命名纹理的标识符,以及基于纹理维度的一个、两个或三个坐标。浮点坐标包括指定样本位置的分数部分,通常位于纹素位置之间。在将结果返回到程序之前,非整数坐标调用四个最接近值(对于2D纹理)的双线性加权插值。

纹理提取缓存在流缓存层次结构中,该层次结构旨在优化数千个并发线程的纹理提取吞吐量。一些程序使用纹理提取作为缓存全局内存的方法。

19.9.4.9 表面(Surface)

表面是一维、二维或三维像素值阵列及其相关格式的通用术语,定义了多种格式,例如4个8位RGBA整数分量或4个16位浮点分量。程序内核不需要知道表面类型,tex指令根据表面格式将其结果值重新转换为浮点。

19.9.4.10 加载/存储访问

带有整数字节寻址的加载/存储指令允许用C和C++等传统语言编写和编译程序,CUDA程序使用加载/存储指令来访问内存。

为了提高内存带宽并减少开销,当地址位于同一块中并满足对齐标准时,局部和全局加载/存储指令将来自同一warp的单个并行线程请求合并为单个内存块请求。将单个小内存请求合并为大数据块请求可以显著提高单独请求的性能,大的线程数,加上支持许多未完成的负载请求,有助于覆盖外部DRAM中实现的局部和全局内存的负载使用延迟。

19.9.4.11 ROP

NVIDIA Tesla架构GPU包括可扩展流处理器阵列(SPA)和可扩展内存系统,可扩展流处理阵列执行GPU的所有可编程计算,可扩展内存系统包括外部DRAM控制和固定功能光栅操作处理器(Raster Operation Processor,ROP),可直接在内存上执行颜色和深度帧缓冲操作。每个ROP单元与特定的内存分区配对,ROP分区通过互连网络被SM填充数据。每个ROP负责深度和模板测试和更新,以及颜色混合。ROP和内存控制器协作实现无损颜色和深度压缩(高达8:1),以减少外部带宽需求,ROP单元还对内存执行原子操作。

19.9.5 浮点运算

如今的GPU使用IEEE 754兼容的单精度32位浮点运算在可编程处理器内核中执行大多数算术运算,早期GPU的定点算法是由16位、24位和32位浮点,然后是IEEE 754兼容的32位浮点继承的。GPU中的一些固定功能逻辑,如纹理过滤硬件,继续使用专有的数字格式,部分GPU还提供IEEE 754兼容的双精度64位浮点指令。

19.9.5.1 支持的格式

IEEE 754浮点算术标准规定了基本格式和存储格式。GPU使用两种基本的计算格式,32位和64位二进制浮点,通常称为单精度和双精度,该标准还指定了16位二进制存储浮点格式,半精度。GPU和Cg着色语言采用窄16位半数据格式,以实现高效的数据存储和移动,同时保持高动态范围,GPU在纹理过滤单元和光栅操作单元内以半精度执行许多纹理过滤和像素混合计算。Industrial Light and Magic[2003]开发的OpenEXR高动态范围图像文件格式在计算机成像和运动图像应用中使用相同的半格式颜色分量值。

半精度(half precision):一种16位二进制浮点格式,具有1个符号位、5位指数、10位小数和一个隐含整数位。

19.9.5.2 基本算术

GPU可编程内核中常见的单精度浮点运算包括加法、乘法、乘法、最小值、最大值、比较、设置判断以及整数和浮点数之间的转换,浮点指令通常为求反和绝对值提供源操作数修饰符。

乘加(multiply-add,MAD):一种执行复合运算的单浮点指令——乘法后相加。

今天大多数GPU的浮点加法和乘法运算都与IEEE 754标准兼容,适用于单精度FP数,包括非数字(NaN)和无穷大值。FP加法和乘法运算使用IEEE舍入到最接近,甚至作为默认舍入模式。为了提高浮点指令吞吐量,GPU通常使用复合乘加指令(mad),mad运算执行带截断的FP乘法,然后执行带舍入到最接近偶数的FP加法。它在一个发出周期内提供两个浮点运算,而不需要指令调度器调度两个单独的指令,但计算没有融合,并在加法之前截断乘积,使得它不同于后面讨论的融合乘加(fused multiply-add)指令。GPU通常会将非规范化的源操作数刷新为符号保留零,并在舍入后将目标输出指数范围下溢的结果刷新为符号保持零。

19.9.5.3 特殊算术

GPU提供硬件来加速特殊函数计算、属性插值和纹理过滤,特殊函数指令包括余弦、正弦、二元指数、二元对数、倒数和平方根倒数。属性插值指令提供了从平面方程求值导出的像素属性的有效生成,前面介绍的特殊函数单元(SFU)计算特殊函数并插值平面属性。

特殊函数单元(special function unit,SFU):计算特殊函数和插值平面属性的硬件单元。

有几种方法可用于执行硬件中的特殊功能。已经表明,基于增强的Minimax逼近的二次插值是一种非常有效的硬件函数逼近方法,包括倒数、倒数平方根、\(log_2x\)\(2^x\)、sin和cos。

我们可以总结SFU二次插值的方法。对于具有n位有效位的二进制输入操作数X,有效位分为两部分:\(X_u\)是包含m位的上部,\(X_l\)是包含n-m位的下部。较高的m位\(X_u\)用于查询一组三个查找表,以返回三个有限域系数C0、C1和C2。要近似的每个函数都需要一组唯一的表,这些系数用于近似\(X_u ≤ X < X_u+2^{−m}\)范围内的给定函数f(X),通过计算表达式:

\[f(X)=C_{0}+C_{1} X_{1}+C_{2} X_{1}^{2} \]

每个函数计算的精度范围为22到24个有效位,示例功能统计如下图所示。

IEEE 754标准规定了除法和平方根的精确舍入要求,但对于许多GPU应用程序,不需要严格遵守,相反,更高的计算吞吐量比最后一位精度更重要。对于SFU特殊函数,CUDA数学库提供了全精度函数和具有SFU指令精度的快速函数。

GPU中的另一种特殊算术运算是属性插值,通常为构成要渲染的场景的图元的顶点指定关键点属性,例如颜色、深度和纹理坐标。必须根据需要在(x,y)屏幕空间内插入这些属性,以确定每个像素位置的属性值,(x,y)平面中给定属性U的值可以使用以下形式的平面方程表示:

\[U(x, y)=A_{u} x+B_{u} Y+C_{u} \]

其中A、B和C是与每个属性U关联的插值参数,插值参数A、B、C都表示为单精度浮点数。

考虑到像素着色器处理器中同时需要函数求值器和属性插值器,可以设计一个执行这两个函数以提高效率的SFU。两个函数都使用乘积和运算来插值结果,两个函数中要求和的项数非常相似。

纹理映射和过滤是GPU中另一组关键的专用浮点算术运算。用于纹理映射的操作包括:

1.接收当前屏幕像素(x,y)的纹理地址(s,t),其中s和t是单精度浮点数。

2.计算细节级别以识别正确的纹理MIPmap级别。

3.计算三线性插值分数。

4.缩放所选MIP映射级别的纹理地址(s,t)。

5.访问存储器并检索期望的纹素(纹理元素)。

6.对纹素执行过滤操作。

MIP-map:包含不同分辨率的预计算图像,用于提高渲染速度和减少伪影。

纹理映射对于全速操作需要大量的浮点计算,其中大部分是以16位半精度完成的,例如除了传统的IEEE单精度浮点指令外,GeForce 8800 Ultra还为纹理映射指令提供了约500GFLOPS的专有格式浮点计算。

浮点加法和乘法运算硬件是完全管线化的,延迟被优化以平衡延迟和面积。虽然采用管线,但特殊函数的吞吐量小于浮点加法和乘法运算,特殊函数的四分之一速度吞吐量是现代GPU的典型性能,一个SFU由四个SP核共享。相比之下,CPU对于类似的功能(如除法和平方根)通常具有明显更低的吞吐量,尽管结果更准确。属性插值硬件通常完全管线化,以启用全速像素着色器。

19.9.5.4 双精度

Tesla T10P等GPU也支持硬件中的IEEE 754 64位双精度操作。双精度标准浮点算术运算包括加法、乘法以及不同浮点和整数格式之间的转换。2008年IEEE 754浮点标准包括融合乘加(fused-multiply-add,FMA)操作的规范,FMA操作执行浮点乘法,然后执行加法,并进行一次舍入,融合的乘法和加法运算在中间计算中保持了完全的精度。这种行为可以实现更精确的浮点计算,包括积的累加,包括点积、矩阵乘法和多项式求值。FMA指令还实现了精确舍入除法和平方根的高效软件实现,无需硬件除法或平方根单元。

双精度硬件FMA单元实现64位加法、乘法、转换和FMA运算本身,双精度FMA单元的体系结构可在输入和输出上实现全速非标准化数支持。下图显示了FMA单元的结构。

双精度融合乘加(FMA)单元,硬件实现双精度浮点A×B+C。

如上图所示,A和B的有效位相乘形成106位乘积,结果保留进位形式,并行地,53位加数C有条件地反转并与106位乘积对齐,106位乘积的和和进位结果通过161位宽进位保存加法器(CSA)与对齐的加数相加。然后,进位保存输出在进位传播加法器中相加,以产生一个非冗余二进制补码形式的非舍入结果。结果被有条件地重新计算,以便以符号大小形式返回结果,补码结果被归一化,然后被舍入以符合目标格式。

19.9.6 可编程GPU

编程多处理器GPU与编程其他多处理器(如多核CPU)有本质上的不同。GPU比CPU提供了两到三个数量级的线程和数据并行性,可扩展到数百个处理器内核和数万个并发线程。GPU继续提高其并行性,大约每12到18个月将其翻倍,这是摩尔定律提高集成电路密度和提高架构效率的结果。为了跨越不同细分市场的广泛价格和性能范围,不同的GPU产品实现了不同数量的处理器和线程。然而,用户希望游戏、图形、图像和计算应用程序能够在任何GPU上运行,无论它执行多少并行线程或拥有多少并行处理器内核,而且他们希望更昂贵的GPU(具有更多线程和内核)能够更快地运行应用程序。因此,GPU编程模型和应用程序被设计为透明地扩展到广泛的并行度。

GPU中大量并行线程和内核背后的驱动力是实时图形性能——需要以每秒至少60帧的交互式帧速率以高分辨率渲染复杂的3D场景。相应地,图形着色语言(如Cg、HLSL、GLSL)的可扩展编程模型被设计为通过许多独立的并行线程利用大程度的并行性,并可扩展到任意数量的处理器核。CUDA可扩展并行编程模型类似地使通用并行计算应用程序能够利用大量并行线程,并可扩展到任意数量的并行处理器内核,对应用程序透明。

在这些可扩展编程模型中,程序员为单个线程编写代码,GPU并行运行无数线程实例,所以程序可以在广泛的硬件并行性上透明地扩展。这种简单的范例源自图形API和描述如何对一个顶点或一个像素进行着色的着色语言,自20世纪90年代末以来,随着GPU快速提高其并行性和性能,一直是一个有效的范例。

本节简要介绍使用图形API和编程语言为实时图形应用程序编程GPU,然后介绍使用C语言和CUDA编程模型为可视化计算和通用并行计算应用程序编程GPU。

API在GPU和处理器的快速、成功开发中发挥了重要作用。有两个主要的标准图形API:OpenGL和Direct3D。OpenGL是一种开放标准,最初由Silicon Graphics Incorporated提出并定义,OpenGL标准的持续开发和扩展由行业协会Khronos管理。Direct3D是一种事实上的标准,由微软和合作伙伴定义并向前发展。OpenGL和Direct3D的结构相似,并随着GPU硬件的进步不断快速发展,它们定义了映射到GPU硬件和处理器上的逻辑图形处理管线,以及可编程管道阶段的编程模型和语言。

下图说明了Direct3D 10逻辑图形管线,OpenGL具有类似的图形管线结构。API和逻辑管线为可编程着色器阶段提供了流数据流基础设施和管道,如蓝色所示。3D应用程序向GPU发送分组为几何图元点、线、三角形和多边形的顶点序列,输入装配程序收集顶点和基元。顶点着色器程序执行逐顶点处理,包括将顶点3D位置转换为屏幕位置并照亮顶点以确定其颜色,几何着色器程序执行逐图元处理,并可以添加或删除图元,设置和光栅化单元生成由几何图元覆盖的像素片段(片段是对像素的潜在贡献)。

像素着色器程序执行每片段处理,包括插值每片段参数、纹理和着色。像素着色器使用插值浮点坐标,广泛使用采样和过滤查找到大型1D、2D或3D阵列(称为纹理)中。着色器使用贴图、函数、贴花、图像和数据的纹理访问。光栅操作处理(或输出合并)阶段执行Z缓冲深度测试和模板测试,这可以丢弃隐藏的像素片段或用片段的深度替换像素的深度,并执行颜色混合操作,该操作将片段颜色与像素颜色相结合,并用混合的颜色写入像素。

图形API和图形管道为处理每个顶点、图元和像素片段的着色器程序提供输入、输出、内存对象和基础结构。

19.9.6.1 编程并行计算应用程序

图形处理模型实际上是多线程、多编程和SIMD执行的组合,NVIDIA称其型号为SIMT(单指令、多线程)。让我们看看NVIDIA的SIMT执行模型。

程序员首先用CUDA编程语言编写代码。CUDA代表计算统一设备架构,是C/C++的自定义扩展,由NVIDIA的nvcc编译器编译,以在CPU的ISA(用于CPU)和PTX指令集(用于GPU)中生成代码。CUDA程序包含一组在GPU上运行的内核和一组在主机CPU上运行的函数。主机CPU上的功能将数据传输到GPU和从GPU传输数据,初始化变量,并协调GPU上内核的执行,内核被定义为在GPU上并行执行的函数。图形硬件为每个CUDA内核创建多个副本,每个副本在单独的线程上执行。

GPU将每个这样的线程映射到SP核心。可以为单个CUDA内核无缝创建和执行数百个线程。有些人可能会认为,如果多个副本的代码相同,那么运行多个副本有什么意义。答案是代码并不完全相同,代码隐式地将线程的id作为输入,例如,如果我们为每个CUDA内核生成100个线程,那么每个线程在集合[0...99]中都有一个唯一的id,CUDA内核中的代码根据线程的id执行适当的处理。许多单独应用程序的线程可能同时运行,每个SM的MT发布逻辑调度线程并协调其执行。这种架构中的SM可以处理多达768个线程。

如果我们并行运行多个应用程序,那么GPU作为一个整体将需要调度数千个线程,调度开销过高。因此,为了简化调度任务,GeForce 8800 GPU将一组32个线程组合成一个warp。每个SM可以管理24个warp,warp是线程的原子单位,warp中的所有线程都被调度,或者warp中没有线程被调度。此外,warp中的所有线程都属于同一内核,并且从完全相同的地址开始。然而,在它们启动之后,可以有不同的程序计数器。

每个SM将warp的线程映射到SP核心,它按指令执行warp指令,类似于经典的SIMD执行,我们在多个数据流上执行一条指令,然后转到下一条指令。SM为warp中的每个线程执行一条指令,在所有线程完成该指令后,它执行下一条指令。如果内核有一个依赖于数据或线程的分支,那么SM只为那些在正确的分支路径中有指令的线程执行指令。GeForce GPU使用预测指令,对于错误路径上的指令,判断条件为false,因此这些指令被nop指令动态替换。一旦分支路径(已执行和未执行)重新合并,warp中的所有线程将再次激活。与SIMD模型的主要区别在于,在SIMD处理器中,同一线程处理同一指令中的多个数据流。然而,在这种情况下,同一条指令在多个线程中执行,每条指令对不同的数据流进行操作。在warp中执行指令后,MT执行单元可能会调度相同的warp、来自相同应用程序的另一个warp或来自另一个应用程序的warp。GPU本质上实现了warp级别的细粒度多线程,下图显示了一个示例。

Warp的调度。

对于32线程的执行,SM通常使用4个周期。在第一个周期中,它向8个SP核心中的每一个发出8个线程。在第二个周期中,它向SFU再发出8个线程。由于两个SFU各有4个功能单元,因此它们可以并行处理8个指令,而不会产生任何结构冲突。在第三个周期中,又向SP核心发送了8个线程,最后在第四个周期中向两个SFU核心发送8个线程。这种在使用SFU和SP核心之间切换的策略确保了两个单元都保持忙碌。由于warp是一个原子单元,它不能在SM之间拆分,并且warp的每条指令必须在所有活动线程上执行完毕,然后才能执行warp中的下一条指令。我们可以在概念上将warp的概念等同于32通道宽的SIMD机器,同一应用程序中的多个warp可以独立执行。为了在warp之间进行同步,我们需要使用全局内存,或者现代GPU中可用的复杂同步原语。

CUDA、Brook和CAL是GPU的编程接口,专注于数据并行计算而不是图形。CAL(计算抽象层)是AMD GPU的低级汇编语言接口,Brook是Buck等人的一种适用于GPU的流式语言,由NVIDIA开发的CUDA是C和C++语言的扩展,用于多核GPU和多核CPU的可扩展并行编程。

凭借新模型,GPU在数据并行和吞吐量计算方面表现出色,可执行高性能计算应用程序和图形应用程序。

为了有效地将大型计算问题映射到高度并行的处理架构,程序员或编译器将问题分解为许多可以并行解决的小问题。例如,程序员将一个大的结果数据数组划分为块,并将每个块进一步划分为元素,从而可以并行地独立计算结果块,并且并行地计算每个块内的元素。下图显示了将结果数据数组分解为3×2块网格,其中每个块进一步分解为5×3元素数组。两级并行分解自然映射到GPU架构:并行多处理器计算结果块,并行线程计算结果元素。

将结果数据分解为要并行计算的元素块网格。

程序员编写一个程序来计算一系列结果数据网格,将每个结果网格划分为粗粒度的结果块,这些块可以独立并行计算。程序使用细粒度并行线程数组计算每个结果块,在线程之间划分工作,以便每个线程计算一个或多个结果元素。

19.9.6.2 CUDA编程

CUDA可扩展并行编程模型扩展了C和C++语言,以在高度并行的多处理器(特别是GPU)上为通用应用程序开发大量并行性,早期经验表明,许多复杂的程序可以用一些容易理解的抽象来表达。自2007年NVIDIA发布CUDA以来,开发人员迅速开发了可扩展的并行程序,用于广泛的应用,包括地震数据处理、计算化学、线性代数、稀疏矩阵求解器、排序、搜索、物理模型和可视化计算,这些应用程序可以透明地扩展到数百个处理器内核和数千个并发线程。具有Tesla统一图形和计算架构的NVIDIA GPU运行CUDA C程序,并广泛用于笔记本电脑、PC、工作站和服务器。CUDA模型也适用于其他共享内存并行处理架构,包括多核CPU。

CUDA提供了三个关键抽象——线程组的层次结构、共享内存和屏障同步,为层次结构中的一个线程提供了与传统C代码的清晰并行结构。多级线程、内存和同步提供细粒度数据并行和线程并行,嵌套在粗粒度数据并行和任务并行中,抽象指导程序员将问题划分为可以独立并行解决的粗略子问题,然后划分为可以并行解决的更精细的部分。编程模型可以透明地扩展到大量处理器内核:编译后的CUDA程序可以在任意数量的处理器上执行,只有运行时系统才需要知道物理处理器的数量。

CUDA是C和C++编程语言的最小扩展,程序员编写一个调用并行内核的串行程序,可以是简单的函数,也可以是完整的程序。内核跨一组并行线程并行执行,程序员将这些线程组织成线程块的层次结构和线程块的网格。线程块是一组并发线程,它们可以通过屏障同步和共享访问块专用的内存空间来相互协作。网格是一组线程块,每个线程块可以独立执行,因此可以并行执行。

内核(kernel):一个线程的程序或函数,设计为可由多个线程执行。

线程块(thread block):一组并发线程,它们执行相同的线程程序,并可以协作计算结果。

网格(grid):执行同一内核程序的一组线程块。

线程、块和网格之间的关系。

CUDA术语与GPU硬件组件等效映射如下表:

CUDA术语 定义 等效的GPU硬件组件
内核(Kernel) 在GPU上运行的函数形式的并行代码 不适用
线程(Thread) GPU上内核的实例 GPU/CUDA处理器核心
块(Block) 分配给特定SM的一组线程 CUDA多处理器(SM)
网格(Grid) GPU GPU

CUDA程序自然映射到GPU的结构。我们首先在CUDA中编写一个内核,该内核根据运行时分配给它的线程id执行一组操作,内核的动态实例是线程(类似于CPU上下文中的线程)。我们将一组线程分组为一个块(block)或CTA(协作线程数组),块或CTA对应于warp,一个块中可以有1-512个线程,每个SM在任何时间点最多可以缓冲8个块的状态。块中的每个线程都有一个唯一的线程id,类似地,块被分组在一个网格中,网格包含应用程序的所有线程,不同的块(或warp)可以彼此独立地执行,除非我们明确实施某种形式的同步。在我们的简单示例中,将块视为线程的线性数组,将网格视为块的线性数组。此外,可以将块定义为线程的2D或3D数组,或者将网格定义为块的2D或三维数组。

现在来看一个小型CUDA程序,它添加了两个n元素数组,让我们部分考虑CUDA调度。在下面的代码片段中,初始化了三个数组a、b和c,希望添加a和b元素,并将结果保存在c中。

#define N 1024

void main() 
{
    // 声明数组
    int a[N], b[N], c[N];

    // 在GPU中声明相应的数组
    int size = N * sizeof(int);
    int *gpu_a, *gpu_b, *gpu_c;

    // 为GPU中的数组分配空间
    cudaMalloc((void**) &gpu_a, size);
    cudaMalloc((void**) &gpu_b, size);
    cudaMalloc((void**) &gpu_c, size);

    // 初始化数组
    (...)

    // 拷贝数组到GPU
    cudaMemcpy (gpu_a, a, size, cudaMemcpyHostToDevice);
    cudaMemcpy (gpu_b, b, size, cudaMemcpyHostToDevice);
}

在这个代码片段中,声明了三个数组(a、b和c),其中包含N个元素,随后定义了它们在gpu中的相应存储位置。然后,使用cudaMalloc调用在GPU中为它们分配空间。接下来,用值初始化数组a和b(代码未显示),然后使用CUDA函数cudaMemcpy将这些数组复制到gpu中的相应位置(gpu_a和gpu_b),它使用名为cudaMemcpyHostToDevice的标志,其中主机是CPU,设备是GPU。

下一个操作是在gpu中添加向量gpu_a和gpu_b。为此,我们需要编写一个vectorAdd函数来添加向量。此函数应包含三个参数,由两个输入向量和一个输出向量组成。下面展示调用此函数的代码。

vectorAdd <<< N/32, 32 >>> (gpu_a, gpu_b, gpu_c);

我们使用三个参数调用vectorAdd函数:gpu_a、gpu_b和gpu_c。表达式<<< N/32, 32 >>>向GPU表明,有N=32个块,每个块包含32个线程。假设GPU神奇地添加了两个数组,并将结果保存在其物理内存空间中的数组gpu_c中。主功能的最后一步是从GPU获取结果,并释放GPU中的空间,其代码如下。

/* Copy from the GPU to the CPU */
cudaMemcpy(c, gpu_c, size, cudaMemcpyDeviceToHost);

/* free space in the GPU */
cudaFree(gpu_a);
cudaFree(gpu_b);
cudaFree(gpu_c);

/* end of the main function */

现在,让我们定义需要在GPU上执行的vectorAdd函数。

/* The GPU kernel */
__global__ void vectorAdd( int *gpu a, int *gpu b, int *gpu c)
{
    /* compute the index */
    int idx = threadIdx.x + blockIdx.x * blockDim.x;

    /* perform the addition */
    gpu_c[idx] = gpu_a[idx] + gpu_b[idx];
}

上述代码中,访问CUDA运行时填充的一些内置变量,通常情况网格和块有三个轴(x, y, z)。因为我们在这个例子中假设块和网格中只有一个轴,所以我们只使用x轴。变量blockDim.x等于块中的线程数。如果我们考虑二维网格,那么块的尺寸将是blockDim.x*blockDim.y,blockIdx.x是块的索引,threadIdx.x是块中线程的索引,因此表达式threadIdx.x+blockIdx.x * blockDim.x表示线程的索引。注意此示例中,数组的每个元素与一个线程相关联。由于创建、初始化和切换线程的开销很小,因此我们可以在GPU的情况下采用这种方法,如果CPU在创建和管理线程时开销很大,那么这种方法是不可行的。一旦计算了线程的索引,就执行加法运算。

GPU创建此内核的N个副本,并将其分发给N个线程。每个内核计算不同的索引,然后执行加法运算。然而,使用CUDA扩展到C/C++,可以编写极其复杂的程序,其中包含同步语句和条件分支语句。

下面再举个并行编程的一个简单的例子,假设我们得到了n个浮点数的两个向量x和y,并且希望计算某个标量值a的y=ax+y的结果,正是BLAS线性代数库定义的所谓SAXPY内核。下面显示了使用CUDA在串行处理器和并行处理器上执行此计算的C代码。

// 用串行循环计算y=ax+y
void saxpy_serial( int n, float alpha, float * x, float ) 
{
    for( int i=0; i<n; ++i) 
        y[i] = alpha * x[i] + y[i];
}
// 调用串行SAXPY内核  
saxpy_serial(n, 2.0, x, y); 

// 用CUDA并行计算y=ax+y
__global__
void saxpy_parallel( int n, float alpha, float *x, float *y) 
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if(i < n) 
        y[i] = alpha * x[i] + y[i];
}

// Invoke parallel SAXPY kernel (256 threads per block) 
int nblocks = (n + 255) / 256;
saxpy_parallel<<< nblocks, 256>>> ( n, 2.0, x, y);

__global__声明说明符表示过程是内核入口点,CUDA程序使用扩展函数调用语法启动并行内核:

kernel<<<dimGrid, dimBlock>>>(… parameter list …);

其中,dimGrid和dimBlock是dim3类型的三个元素向量,分别指定网格在块中的尺寸和线程中的块的尺寸。未指定的尺寸默认为1。

上述代码启动了一个由n个线程组成的网格,为向量的每个元素分配一个线程,并在每个块中放置256个线程。每个单独的线程根据其线程和块ID计算元素索引,然后对相应的向量元素执行所需的计算。比较这段代码的串行和并行版本,会发现它们非常相似,是一种相当常见的模式。串行代码由一个循环组成,其中每个迭代都独立于所有其他迭代。这样的循环可以机械地转换为并行内核:每个循环迭代都成为一个独立的线程。通过为每个输出元素分配一个线程,避免了在将结果写入内存时线程之间的任何同步。

CUDA内核的文本只是一个顺序线程的C函数,因此通常很容易编写,并且比为向量运算编写并行代码更简单。通过在启动内核时指定网格及其线程块的维度,可以明确地确定并行性。

并行执行和线程管理是自动的,所有线程的创建、调度和终止都由底层系统为程序员处理。事实上,Tesla架构GPU直接在硬件中执行所有线程管理。块的线程同时执行,并且可以通过调用__syncthreads()内在函数在同步屏障处同步,以此保证在块中的所有线程都到达屏障之前,块中的任何线程都不能继续。在通过屏障之后,这些线程还可以确保在屏障之前看到块中的线程对内存执行的所有写入。因此,块中的线程可以通过在同步屏障处写入和读取每个块共享内存来彼此通信。

同步屏障(synchronization barrier):线程在同步屏障处等待,直到线程块中的所有线程到达该屏障。

由于块中的线程可以共享内存并通过屏障进行同步,因此它们将一起驻留在同一物理处理器或多处理器上,但线程块的数量可能大大超过处理器的数量。CUDA线程编程模型将处理器虚拟化,并使程序员能够灵活地以最方便的粒度进行并行化。虚拟化为线程和线程块允许直观的问题分解,因为块的数量可以由正在处理的数据的大小决定,而不是由系统中的处理器数量决定,它还允许相同的CUDA程序扩展到不同数量的处理器内核。

为了管理这种处理元素虚拟化并提供可扩展性,CUDA要求线程块能够独立执行,必须能够以任何顺序并行或串行执行块。不同的块没有直接通信的方式,尽管它们可以通过例如原子递增队列指针,使用对所有线程可见的全局内存上的原子内存操作来协调它们的活动。这种独立性要求允许跨任意数量的内核以任意顺序调度线程块,从而使CUDA模型可跨任意数量内核以及多种并行架构进行扩展,也有助于避免死锁的可能性。应用程序可以独立或独立地执行多个网格,给定足够的硬件资源,独立网格可以同时执行。从属网格按顺序执行,其间有一个隐式内核间屏障,从而保证第一个网格的所有块在第二个从属网格的任何块开始之前完成。

原子内存操作(atomic memory operation):一种内存读取、修改、写入操作序列,在没有任何干预访问的情况下完成。

线程在执行过程中可以从多个内存空间访问数据,每个线程都有一个专用局部内存,CUDA对不适合线程寄存器的线程专用变量以及堆栈帧和寄存器溢出使用本地内存。每个线程块都有一个共享内存,该内存对该块的所有线程都可见,并且与该块具有相同的生存期。最后,所有线程都可以访问相同的全局内存,程序使用__shared____device__类型限定符在共享和全局内存中声明变量。在Tesla架构的GPU上,这些内存空间对应于物理上独立的内存:每个块共享内存是一个低延迟的片上RAM,而全局内存驻留在图形板上的快速DRAM中。

局部内存(local memory):线程专用的逐线程局部内存。

共享内存(shared memory):块的所有线程共享的逐块内存。

全局内存(global memory):所有线程共享的逐应用程序内存。

共享内存应该是每个处理器附近的低延迟内存,很像L1缓存,因此它可以在线程块的线程之间提供高性能通信和数据共享。由于它的生存期与其对应的线程块相同,内核代码通常会初始化共享变量中的数据,使用共享变量进行计算,并将共享内存结果复制到全局内存。顺序相关网格的线程块通过全局内存进行通信,使用它来读取输入和写入结果。

下图显示了线程、线程块和线程块网格的嵌套级别图,还显示了相应的内存共享级别:逐线程、逐线程块和逐应用程序数据共享的局部、共享和全局内存。

嵌套粒度级别线程、线程块和网格具有相应的局部、共享和全局内存共享级别。逐线程局部内存是线程专用的,逐块共享内存由块的所有线程共享,逐应用程序的全局内存由所有线程共享。

程序通过调用CUDA运行时(如cudaMalloc()和cudaFree())来管理内核可见的全局内存空间。内核可以在物理上独立的设备上执行,就像在GPU上运行内核一样,所以应用程序必须使用cudaMemcpy()在分配的空间和主机系统内存之间复制数据。

CUDA编程模型在风格上类似于熟悉的单程序多数据(SPMD)模型,它显式地表示并行性,每个内核在固定数量的线程上执行。然而,CUDA比SPMD的大多数实现更灵活,因为每个内核调用都会动态地创建一个新的网格,其中包含正确数量的线程块和应用程序步骤的线程。程序员可以为每个内核使用方便的并行度,而不必设计计算的所有阶段来使用相同数量的线程。下图显示了类似SPMD的CUDA代码序列的示例。它首先在3×2块的2D网格上实例化内核F,其中每个2D线程块由5×3个线程组成。然后,它在四个一维线程块的一维网格上实例化内核G,每个一维线程块有六个线程。因为kernelG依赖于kernelF的结果,所以它们被内核间同步屏障分隔开。

单程序多数据(single-program multiple data,SPMD):一种并行编程模型,其中所有线程执行同一程序。SPMD线程通常与屏障同步协调。

在2D线程块的2D网格上实例化的内核F序列,是一个内核间同步屏障,之后是1D线程块的1D网格上的内核G。

线程块的并发线程表示细粒度数据并行和线程并行,网格的独立线程块表示粗粒度数据并行性,独立网格表示粗粒度任务并行性。内核只是层次结构中一个线程的C代码。

请注意,我们将GPU内核与CPU执行的代码合并为一个程序,NVIDIA的编译器将单个文件拆分为两个二进制文件,一个二进制在CPU上运行并使用CPU的指令集,另一个二进制运行在GPU上并使用PTX指令集。这是一个典型的MPMD执行方式的例子,在不同的指令集和多个数据流中有不同的程序。因此,可以将GPU的并行编程模型视为SIMD、MPMD和warp级别的细粒度多线程的组合(下图)。

为了提高效率并简化其实现,CUDA编程模型有一些限制。线程和线程块只能通过调用并行内核而不是从并行内核中创建,再加上线程块所需的独立性,使得使用简单的调度器执行CUDA程序成为可能,该调度器引入了最小的运行时开销。事实上,Tesla GPU架构实现了线程和线程块的硬件管理和调度。

任务并行性可以在线程块级别表达,但很难在线程块中表达,因为线程同步障碍在块的所有线程上运行。为了使CUDA程序能够在任意数量的处理器上运行,同一内核网格内的线程块之间的依赖关系是不允许的。由于CUDA要求线程块是独立的并且允许以任何顺序执行块,组合由多个块生成的结果通常必须通过在线程块的新网格上启动第二个内核来完成(尽管线程块可以通过例如原子递增队列指针来使用对所有线程可见的全局内存上的原子内存操作来协调其活动)。

CUDA内核中当前不允许递归函数调用,递归在大规模并行内核中不具备吸引力,因为为数以万计的活动线程提供堆栈空间需要大量内存。通常使用递归(如快速排序)表示的串行算法通常最好使用嵌套数据并行而不是显式递归来实现。

为了支持将CPU和GPU结合在一起的异构系统架构,CUDA程序必须在主机内存和设备内存之间复制数据和结果。通过使用DMA块传输引擎和快速互连,CPU与GPU交互和数据传输的开销最小化,大到需要GPU性能提升的计算密集型问题比小问题更好地分摊开销。

图形和计算的并行编程模型使得GPU架构不同于CPU架构,驱动GPU处理器架构的GPU程序的关键方面是:

  • 广泛使用细粒度数据并行性:着色器程序描述如何处理单个像素或顶点,CUDA程序描述如何计算单个结果。
  • 高线程编程模型:着色器线程程序处理单个像素或顶点,CUDA线程程序可以生成单个结果。GPU必须以每秒60帧的速度每帧创建和执行数百万个这样的线程程序。
  • 可扩展性:当提供额外的处理器时,程序必须自动提高性能,而无需重新编译。
  • 密集型浮点(或整数)计算
  • 支持高吞吐量计算

19.9.6.3 NVIDIA GPU内存结构

下图显示了NVIDIA GPU的内存结构,每个多线程SIMD处理器本地的片上内存称为局部内存。它由多线程SIMD处理器内的SIMD通道共享,但此内存不在多线程SIMC处理器之间共享,整个GPU和所有线程块共享的片外DRAM称为GPU内存。

GPU内存结构。GPU内存由矢量化循环共享,线程块中SIMD指令的所有线程共享局部内存。

GPU传统上使用较小的流式缓存,并依赖SIMD指令线程的广泛多线程处理来隐藏DRAM的长延迟,而不是依赖于大型缓存来包含应用程序的整个工作集,因为它们的工作集可能是数百M字节。因此,它们不适合多核微处理器的最后一级缓存。考虑到使用硬件多线程来隐藏DRAM延迟,系统处理器中用于缓存的芯片区域被用于计算资源和大量寄存器,以保存SIMD指令的许多线程的状态。

虽然隐藏内存延迟是基本原理,但请注意,最新的GPU和矢量处理器增加了缓存,例如最近的Fermi架构增加了缓存,但它们被认为是减少GPU内存需求的带宽过滤器,或者是多线程无法隐藏延迟的少数变量的加速器。用于堆栈帧、函数调用和寄存器溢出的本地内存与缓存非常匹配,因为调用函数时延迟很重要。缓存也可以节省能量,因为片上缓存访问比访问多个外部DRAM芯片消耗的能量少得多。

在高层次上,具有SIMD指令扩展的多核计算机确实与GPU有相似之处,下图总结了相似性和差异。两者都是MIMD,其处理器使用多个SIMD通道,尽管GPU有更多的处理器和更多的通道。两者都使用硬件多线程来提高处理器利用率,尽管GPU对更多线程具有硬件支持。两者都使用缓存,尽管GPU使用较小的流缓存,而多核计算机使用大型多级缓存,试图完全包含整个工作集。两者都使用64位地址空间,尽管GPU中的物理主内存要小得多。虽然GPU在页面级别支持内存保护,但它们还不支持按需分页。

特性 带SIMD的多核(CPU) GPU
SIMD处理器 4到8 8到16
每个处理器的SIMD通道数 2到4 8到16
SIMD线程的多线程硬件支持 2到4 16到32
最大的缓存尺寸 8M 0.75M
内存地址尺寸 64-bit 64-bit
主内存尺寸 8G到256G 4G到16G
页面级别的内存保护
按需分页
缓存一致性

SIMD处理器也类似于矢量处理器。GPU中的多个SIMD处理器充当独立的MIMD核心,就像许多矢量计算机具有多个矢量处理器一样。这种观点认为Fermi GTX 580是一个16核机器,具有多线程硬件支持,每个核有16个通道。最大的区别是多线程,这是GPU的基础,也是大多数向量处理器所缺少的。

GPU和CPU在计算机体系结构谱系中不会追溯到共享祖先,没有缺失链接可以解释这两者。由于这种不同寻常的传统,GPU没有使用计算机架构社区中常见的术语,导致了对GPU是什么以及它们如何工作的困惑。为了帮助解决混淆,下图列出了本文部分使用的更具描述性的术语,与主流计算最接近的术语。

尽管GPU正朝着主流计算方向发展,但他们不能放弃继续在图形方面取得优异成绩的责任。因此,当架构师问,考虑到为做好图形而投入的硬件,我们如何补充它以提高更广泛应用程序的性能时,GPU的设计可能更有意义?

关于GPU的更多技术细节可参阅:深入GPU硬件架构及运行机制

19.9.7 i7 960和Tesla GPU性能

Intel研究人员在2010年发表了一篇论文,将四核Intel core i7 960与上一代GPU NVIDIA Tesla GTX 280的多媒体SIMD扩展进行了比较。下表列出了这两种系统的特点。酷睿i7采用英特尔的45纳米半导体技术,而GPU采用台积电的65纳米技术。尽管由中立方或两个相关方进行比较可能更公平,但本节的目的不是确定一种产品比另一种产品快多少,而是试图了解这两种截然不同的架构风格的特征的相对价值。

特性 Core i7-960 GTX 280 GTX 480 280/i7的比率 480/i7的比率
处理元素(核或SM)的数量 4 30 15 7.5 3.8
时钟频率(GHz) 3.2 1.3 1.4 0.41 0.44
模具(Die)尺寸 263 576 520 2.2 2.0
技术 Intel 45 nm TSMC 65 nm TSMC 40 nm 1.6 1.0
功率(芯片,非模块) 130 130 167 1.0 1.3
晶体管 700 M 1400 M 3030 M 2.0 4.4
内存带宽(G/sec) 32 141 177 4.4 5.5
单精度SIMD宽 4 8 32 2.0 8.0
双精度SIMD宽 2 1 16 0.5 8.0
峰值单精度标量FLOPS(GFLOP/sec) 26 117 63 4.6 2.5
峰值单精度SIMD FLOPS(GFLOP/sec) 102 311-933 515-1344 3.0-9.1 6.6-13.1
SP 1相加或相乘 N/A 311 515 3.0 6.6
SP 1指令融合乘法-加法 N/A 622 1344 6.1 13.1
特殊的SP双问题融合乘加乘 N/A 933 N/A 9.1 -
峰值双精度SIMD FLOPS(GFLOP/sec) 51 78 515 1.5 10.1

下图中的Core i7 960和GTX 280的曲线说明了计算机的差异。GTX280不仅具有更高的内存带宽和双精度浮点性能,而且它的双精度脊点也位于左侧。GTX 280的双精度脊点为0.6,而Core i7为3.1。如上所述,曲线的脊点越靠近左侧,就越容易达到峰值计算性能。对于单精度性能,两台计算机的脊点都会向右移动,因此很难达到单精度性能的顶点。请注意,内核的算术强度基于进入主内存的字节,而不是进入缓存的字节。因此,如上所述,如果大多数引用真的到了缓存,缓存可以改变特定计算机上内核的算术强度。还请注意,这两种架构中的单位步长访问都使用此带宽,GTX 280和Core i7上的真实聚集分散地址可能会更慢。

这些曲线在顶行显示双精度浮点性能,在底行显示单精度性能。(DP FP性能上限也在最下面一行,以提供透视图。)左侧的Core i7 960的DP FP性能峰值为51.2 GFLOP/sec,SP FP峰值为102.4 GFLOP/sec,峰值内存带宽为16.4 GBytes/sec。NVIDIA GTX 280的DP FP峰值为78 GFLOP/秒,SP FP峰值为624 GFLOP//秒,内存带宽为127 GB/秒。左侧的垂直虚线表示0.5 FLOP/字节的算术强度,在Core i7上,内存带宽限制为不超过8 DP GFLOP/sec或8 SP GFLOP/sec。右侧的垂直虚线的算术强度为4 FLOP/字节。在Core i7上,它的计算速度仅限于51.2 DP GFLOP/sec和102.4 SP GFLOP/sec,在GTX 280上,它仅限于78 DP GFLOp/sec和624 SP GFLOp/sec。要在Core i8上达到最高的计算速度,需要使用所有四个核心和SSE指令,并使用相同数量的乘法和加法。对于GTX 280,需要在所有多线程SIMD处理器上使用融合乘-加指令。

研究人员通过分析最近提出的四个基准套件的计算和内存特性来选择基准程序,然后“制定了一组捕获这些特性的吞吐量计算内核”。下图显示了性能结果,数字越大意味着速度越快,曲线有助于解释本案例研究中的相对性能。

鉴于GTX 280的原始性能规格从2.5倍慢(时钟速率)到7.5倍快(每个芯片的内核数)不等,而性能从2.0倍慢(Solv)到15.2倍快(GJK)不等,Intel研究人员决定找出差异的原因:

  • 内存带宽。GPU具有4.4倍的内存带宽,有助于解释为什么LBM和SAXPY的运行速度分别为5.0和5.3倍,它们的工作集有数百兆字节,因此不适合Core i7缓存,为了集中访问内存,它们故意不使用缓存阻塞(cache blocking),曲线的坡度解释了它们的性能。SpMV也有一个大的工作集,但它的运行速度仅为1.9倍,因为GTX 280的双精度浮点运算速度仅为Core i7的1.5倍。
  • 计算带宽。剩下的五个内核是计算密集的:SGEMM、Conv、FFT、MC和Bilat,GTX的速度分别为3.9、2.8、3.0、1.8和5.7倍。前三个使用单精度浮点运算,GTX 280单精度运算速度快3到6倍,MC使用双倍精度,这解释了为什么DP性能只快1.5倍,所以它只快1.8倍。Bilat使用GTX 280直接支持的超越函数,Core i7将三分之二的时间用于计算Bilat的超越函数,因此GTX 280的速度要快5.7倍。这一观察有助于指出硬件支持工作负载中发生的操作的价值:双精度浮点运算,甚至可能是超验运算。
  • 缓存优势。光线投射(RC)在GTX上的速度仅为1.6倍,因为Core i7缓存的缓存阻塞阻止了它成为内存带宽限制,就像在GPU上一样,缓存阻塞也可以帮助搜索。如果索引树很小,可以放入缓存,那么Core i7的速度是它的两倍,较大的索引树使其内存带宽受限,总体而言,GTX 280的搜索速度快1.8倍,缓存阻塞也有助于排序。虽然大多数程序员不会在SIMD处理器上运行排序,但它可以用一个称为拆分的1位排序原语来编写,但分割算法执行的指令比标量排序多得多,所以Core i7的运行速度是GTX 280的1.25倍。请注意,缓存也有助于Core i7上的其他内核,因为缓存阻塞允许SGEMM、FFT和SpMV成为计算绑定。这一观察再次强调了缓存阻塞优化的重要性。
  • 分散-聚集。如果数据分散在主存储器中,多媒体SIMD扩展几乎没有帮助,只有当对数据的访问在16字节边界上对齐时,才能获得最佳性能。因此,GJK从Core i7上的SIMD中获得的好处很少。如上所述,GPU提供了向量架构中的聚集-分散寻址,但大多数SIMD扩展中都忽略了这种寻址,存储器控制器甚至一起批量访问同一DRAM页。这种组合表明GTX 280以惊人的15.2倍于Core i7的速度运行GJK,比上上图中的任何单个物理参数都要大。这一观察结果强化了SIMD扩展中缺少的矢量和GPU架构的聚集散射的重要性。
  • 同步。同步性能受到原子更新的限制,尽管Core i7具有硬件获取和增量指令,但原子更新占Core i7总运行时间的28%。因此,Hist在GTX 280上的速度仅为1.7倍,Solv在少量计算中解决了一批独立约束,然后进行了屏障同步。Core i7得益于原子指令和内存一致性模型,即使不是以前对内存层次结构的所有访问都已完成,也能确保正确的结果。在没有内存一致性模型的情况下,GTX 280版本从系统处理器启动了一些批处理,导致GTX 280的运行速度是Core i7的0.5倍。这一观察结果指出了同步性能对于某些数据并行问题的重要性。

令人惊讶的是,Intel研究人员选择的内核发现的Tesla GTX 280中的弱点,已经在Tesla的后续架构中得到了解决:Fermi具有更快的双精度浮点性能、更快的原子运算和缓存。同样有趣的是,比SIMD指令早了几十年的向量架构的聚集-分散支持对于这些SIMD扩展的有效有用性非常重要,有些人在比较之前就已经预测到了这一点。Intel的研究人员指出,14个内核中的6个内核可以更好地利用SIMD,在Core i7上提供更高效的聚集-分散支持。这项研究也肯定了缓存阻塞的重要性。

19.9.8 NVidia Tesla架构

下图显示了Tesla架构,让我们从图的顶部开始解释。主机CPU通过专用总线向图形处理器发送命令和数据序列,然后,专用总线将一组命令和数据传输到GPU上的缓冲区,随后GPU的单元处理信息。在下图中,工作从上到下流动。GPU本质上是一组非常简单的有序内核,此外,它还有大量额外的硬件来协调复杂任务的执行,并将工作分配给一组核心。GPU还支持多级内存层次结构,并具有专门执行少数图形特定操作的专用单元。

NVIDIA Tesla架构。

19.9.8.1 工作分配

GPU可以分配三种工作:顶点处理、像素处理和常规计算工作。GPU定义自己的汇编代码,使用PTX和SASS指令集,这些指令集中的每个指令都在GPU上执行基本操作,它使用寄存器操作数或内存操作数。与CPU不同,GPU中寄存器文件的结构通常不暴露于软件,程序员需要使用无限数量的虚拟寄存器,GPU或设备驱动程序将它们映射到实际寄存器。

现在,对于处理顶点,低级图形软件向GPU发送一系列装配指令。GPU有一个硬件汇编程序,它生成二进制代码,并将其发送到一个专用的顶点处理单元,该单元协调和分配GPU内核之间的工作。或者,CPU可以向GPU发送像素处理操作,GPU执行光栅化、片段处理和深度缓冲的过程。GPU中的一个专用单元为这些操作生成代码片段,并将其发送到像素处理单元,该像素处理单元将工作项分配给GPU核心集。第三个单元是计算工作分配器,它接受CPU的常规计算任务,例如添加两个矩阵或计算两个向量的点积。程序员指定一组子任务,计算工作分配引擎的作用是将这些子任务集发送到GPU中的核心。

在这个阶段之后,GPU或多或少地忽略了指令的来源,注意,这部分工程是GPU成功背后的关键贡献。设计师已经成功地将GPU的功能分为两层,第一层特定于操作类型(图形或通用)。在此阶段,每个流水线的作用是将特定的操作序列转换为一组通用的操作,这样无论高级操作的性质如何,都可以使用相同的硬件单元。现在来看看包含计算引擎的GPGPU的后半部分。

19.9.8.2 GPU计算引擎

GeForce 8800 GPU有128个内核。核心小组分为8组,每个组称为TPC(纹理/处理器集群),每个TPC包含两个SM(流式多处理器)。此外,每个SM包含8个称为流处理器(SP)的核心,每个SP都是一个简单的有序内核,具有符合IEEE 754的浮点ALU、分支和内存访问单元。除了一组简单的内核外,每个SM都包含一些专用的内存结构。这些内存结构包含常量、纹理数据和GPU指令。所有SP都可以并行执行一组指令,并且彼此紧密同步。

19.9.8.3 互连网络、DRAM模块、二级缓存和ROP

8个TPC通过互连网络连接到一组缓存、DRAM模块和ROP(光栅操作处理器)。SM包含一级缓存,在缓存未命中时,SP核心通过NOC访问相关的二级缓存库。在GPU的情况下,二级缓存是在存储体(bank)级别上分割的共享缓存,在二级缓存之下,GPU有一个大的外部DRAM内存。GeForce 8800有384个引脚可连接到外部DRAM模块,该组引脚分为6组,每组包含64个引脚。物理内存空间也被分成6个部分,跨越6个组。光栅化操作通常需要一些专门的处理例程,不幸的是,这些例程在TPC上运行效率低下,因此GeForce 8800芯片具有6个ROP,每个ROP处理器每个周期最多可以处理4个像素,它主要对像素的颜色进行插值,并执行颜色混合操作。

19.9.9 NVidia RTX 4090架构和特性

前不久,NVIDIA宣布推出Ada Lovelace GeForce一代时,曾有过一些大胆的声明,光线跟踪性能的有效翻倍,在测试了一系列流行的渲染引擎之后,确实如此。

RTX 4090 GPU芯片结构。

Ada Lovelace一代带来了第四代Tensor磁芯和改进的光流。在创建过程中,这些功能加速了降噪等功能,而在游戏应用中,它们通过DLSS 3.0进行了升级。在光线跟踪核心方面,Ada Lovelace推出了第三代实现,并在很大程度上提供比Ampere一代提高2倍的性能。

其他值得注意的功能是Shader Execution Reordering,它进一步提高了光线跟踪性能,包括在游戏中,其中一个例子显示《赛博朋克2077》中有44%的提升。此外,Intel率先推出AV1加速GPU编码器,NVIDIA也紧随其后,Ada Lovelace也推出了一款。有趣的是,NVIDIA提供了板载双编码器,它声称这将使编码时间减半。我们将通过即将推出的完整创作者性能外观来探索这一点。

在开始了解NVIDIA最新旗舰的渲染性能之前,先看看NVIDIA官方正版的硬件参数:

GPU型号 核心数 最大频率 峰值FP32 内存 带宽 总功率
RTX 4090 16,384 2,520 82.6 TFLOPS 24GB 1008 GB/s 450W
RTX 4080 16GB 9,728 2,510 48.8 TFLOPS 16GB 717 GB/s 320W
RTX 3090 Ti 10,752 1,860 40 TFLOPS 24GB 1008 GB/s 450W
RTX 3080 Ti 10,240 1,670 34.1 TFLOPS 12GB 912 GB/s 350W
RTX 3070 Ti 6,144 1,770 21.7 TFLOPS 8GB 608 GB/s 290W
RTX 3060 Ti 4,864 1,670 16.2 TFLOPS 8GB 448 GB/s 200W

RTX 4090配备了这一代的第一款Ada Lovelace GPU:AD102。但值得注意的是,这款旗舰卡中使用的芯片并不是全核,尽管其规格表已经非常庞大。其核心是16384个CUDA内核,分布在128个流式多处理器(SM)上,意味着比RTX 3090 Ti的GA102 GPU(其本身就是完整的Ampere核心)增加了52%。


上:RTX 4090内的AD102结构;下:完整的AD102 GPU结构。

GA102和AD102架构对比图。

完整的AD102芯片包括18432个CUDA核心和144个SM,也意味着将看到144个第三代RT核心和576个第四代Tensor核心。如果英伟达愿意,RTX 4090 Ti甚至Titan都有足够的空间。

Ada Lovelace和Ampere架构的SM对比图。

内存变化不大,同样是24GB的GDDR6X以21Gbps的速度运行,可提供1008GB/秒的内存带宽。下表是GeForce RTX 4090和GeForce RTX 3090 Ti的部分参数对比图:

GeForce RTX 4090 GeForce RTX 3090 Ti
架构 Ada Lovelace Ampere
CUDA核心 16,432 10,752
SM 128 84
RT核心 128 84
Tensor核心 512 4代 336 3代
ROP 176 112
最大频率 2,520MHz 1,860MHz
内存 24GB GDDR6X 24GB GDDR6X
内存速度 21Gbps 21Gbps
内存带宽 1,008GB/s 1,008GB/s
总线宽 384 384
L1 | L2缓存 16,384KB | 73,728KB 10,752KB | 6,144KB
制作工艺 5nm TSMC 8nm Samsung
晶体管 763亿 283亿
芯片面积 608.5mm² 628.5mm²
总功率 450W 450W

在方程式的原始着色器方面,事情也没有从Ampere架构中真正发展到那么远。每个SM仍然使用相同的64个专用FP32单元,但具有64个单元的辅助流,可以根据需要在浮点和整数计算之间进行拆分,与Ampere引入的相同。

当查看RTX 3090和RTX 4090之间的相对性能差异时,可以从光栅化的角度看到这两种架构有多相似。

如果忽略光线追踪和放大,则相应的性能提升仅略高于AD102 GPU中额外的CUDA内核数量。尽管业绩增长“略高于”相应水平,但确实表明在这一水平上存在一些差异。

部分原因在于英伟达用于Ada Lovelace GPU的新4纳米生产工艺。与Ampere的8纳米三星工艺相比,据说台积电制造的4N工艺在相同功率下提供了两倍的性能,或者在相同性能下提供了一半的性能。

这意味着英伟达可以在时钟速度方面具有超强的攻击性,RTX 4090的提升时钟为2520MHz。实际上,我们在测试中看到了Founders Edition卡的平均频率为2716MHz,比上一代的RTX 3090快了整整1GHz。

而且,由于工艺的缩减,英伟达与台积电合作的工程师已经在AD102核心中塞进了惊人的763亿个晶体管。考虑到608.5mm²的Ada GPU包含的晶体管比GA102硅的283亿晶体管还要多,它可能比628.4mm²的Ampere芯片小得多。

事实上,英伟达能够继续将数量不断增加的晶体管塞进单片芯片中,并仍然不断缩小其实际管芯尺寸,这证明了该领域先进工艺节点的威力。作为参考,RTX 2080 Ti的TU102芯片面积为754mm²,仅容纳186亿个12nm晶体管。但并不意味着单片GPU可以永远继续,不受限制。GPU的竞争对手AMD承诺将于11月推出新的RDNA 3芯片,转而使用图形计算芯片。考虑到AD102 GPU的复杂性仅次于先进的814mm²Nvidia Hopper硅的800亿晶体管,它肯定是一种昂贵的芯片。然而,较小的计算芯片应该会降低成本,提高产量。

更多参数规格和特性如下所示:

但至少到目前,暴力的整体方法仍在为英伟达带来回报。

当想要更高的速度,并且已经尽可能多地安装了先进的晶体管时,还能做什么?答案是可以在包中添加更多的缓存,是AMD在其Infinity Cache中取得的巨大效果,尽管英伟达不一定会采用一些花哨的新品牌方法,但它在Ada核心中增加了大量L2缓存。

上一代GA102包含6144KB的共享二级缓存,位于其SM的中间,Ada将其增加16倍,以创建98304KB的二级缓存池,供AD102 SM使用。对于RTX 4090版本的芯片,其容量降至73728KB,但仍有大量缓存。每个SM的L1数量没有变化,但因为现在芯片内总共有更多的SM,这也意味着与Ampere相比,L1缓存的数量也更大。

但如今,光栅化并不是GPU的全部。当图灵首次在游戏中引入实时光线追踪时,可能会有这样的感觉,现在它几乎已经成为PC游戏的标准组成。升级也是如此,因此架构如何接近PC游戏的这两大支柱,对于整体理解设计至关重要。

如今的所有三家显卡制造商都专注于光线追踪性能以及升级技术的复杂性,俨然成为他们之间的一场全新战争。

RTX 4090的规格为450W,是一款耗电的GPU,因此PSU越大越好。NVIDIA规定的最低功率为850W,在进行密集的3DMark测试时,已经达到了650W的峰值。RTX 4090需要3个8针电源连接器,或者一个带有新的PCIe 5支持PSU的电源连接器。由于测试平台的PSU刚好符合最低要求,将在未来转向更大的PSU。

关于RTX 4090的冷却器,其设计与上一代RTX 3090相似,但发动机罩下的改进有利于温度。最新型号的风扇更大,同时减少了叶片数量。经过对RTX 4090进行了足够的测试,在3DMark Fire Strike Ultra测试期间,它比3090(总功率650W)多了100W。

阐述完它的硬件规格,下面聊聊其渲染特性。

Ada流式多处理器中发生了真正的变化。光栅化组件可能非常相似,但第三代RT Core已经发生了巨大变化。前两代RT Core包含一对专用单元,即长方体相交引擎和三角体相交引擎,在计算光线跟踪核心的边界体积层次(BVH)算法时,这两个单元从SM的其余部分中提取了大量RT工作量。

Ada引入了另外两个独立的单元来卸载SM的更多工作: Opacity Micromap Engine(OME)和Displaced Micro-Mesh Engine。第一种方法在处理场景中的透明度时大大加快了计算速度,第二种方法旨在分解几何上复杂的对象,以减少完成整个BVH计算所需的时间。

左:Ampere三角形相交示意图,其中射线可能会多次击中浅黄色的三角形,每次击中都会触发一次anyhit着色器。右:Ada的OPACITY MICRO MAPS Shading(OMMS)的纹理渲染技术,配合OME可以显著减少Alpha遍历后的计算。透过 OMMS 技术,射线遇到上图中浅蓝色部分的时候直接忽略掉 anyhit 计算,从而显著提升这类物件的计算量。

Displaced Micro-Mesh Engine工作机制示意图。

除此之外,Nvidia还称之为“GPU的一项重大创新,就像20世纪90年代CPU的无序执行一样”。创建了着色器执行重新排序(SER)来切换着色工作负载,从而允许Ada芯片通过实时重新调度任务来大大提高图形管线的效率。

Intel一直在为其炼金术师GPU(在新选项卡中打开)开发类似的功能,线程排序单元,以帮助光线跟踪场景中的发散光线。据报道,它的设置不需要开发人员的输入。目前,Nvidia需要一个特定的API来将SER集成到开发者的游戏代码中,正在与微软和其他公司合作,将该功能引入DirectX 12和Vulkan等标准图形API中。

最后来看看DLSS 3.0,它的王牌:帧生成,DLSS 3现在不仅会升级,还会自己创建整个游戏帧。不一定是从头开始,而是通过使用AI和深度学习的力量,对下一帧的外观进行最佳猜测,如果真的要渲染它,然后它在下一个真正渲染的帧之前注入AI生成的帧。

这是巫毒,是黑魔法,是黑暗的艺术,而且相当壮观。它使用第四代张量核内的增强型硬件单元(称为光流单元)进行所有这些飞行计算,然后利用神经网络将先前帧中的所有数据、场景中的运动矢量和光流单元拉到一起,以帮助创建一个全新的帧,该帧还能够包括光线追踪和后期处理效果。

英伟达与DLSS升级(现在称为DLSS超级分辨率)一起工作时表示,在某些情况下,AI将通过升级生成初始帧的四分之三,然后使用帧生成生成整个第二帧。总的来说,它估计AI正在创建所有显示像素的八分之七。它在3DMark Time Spy Extreme的得分是大安培核心的两倍,在光线追踪或DLSS加入之前,原始硅提供的4K帧速率也是《赛博朋克2077》的两倍。

赛博朋克2077的对比数据如下:

在能效方面,4090的平均功率高于3090约18%,每瓦特的性能是3090的1.75倍(1080P),平均温度比3090低约4.5%。

19.9.10 谬论与陷阱

GPU的发展和变化如此之快,以至于出现了许多谬误和陷阱,此节介绍其中一部分。

19.9.10.1 谬论:GPU只是SIMD向量多处理器

很容易得出这样的错误结论:GPU只是简单的SIMD向量多处理器。GPU有一个SPMD风格的编程模型,程序员可以编写一个在多个线程实例中使用多个数据执行的程序,但这些线程的执行不是单纯的SIMD或向量,实际上它是单指令多线程(SIMT)。每个GPU线程都有自己的标量寄存器、线程专用内存、线程执行状态、线程ID、独立执行和分支路径以及有效的程序计数器,并且可以独立地寻址内存。尽管当用于线程的PC相同时,一组线程(如32个线程的warp)执行效率更高,但不是必需的,所以多处理器并非纯粹的SIMD。线程执行模型是MIMD,具有屏障同步和SIMT优化。如果单个线程加载/存储内存访问也可以合并为块访问,则执行效率更高,但不是绝对必要。在纯SIMD向量架构中,不同线程的内存/寄存器访问必须以规则向量模式对齐,GPU对寄存器或存储器访问没有这种限制,然而,如果线程的warp访问局部数据块,则执行效率更高。

与纯SIMD模型相比,SIMT GPU可以同时执行多个线程warp。在图形应用中,可能有多组顶点程序、像素程序和几何程序同时在多处理器阵列中运行,计算程序也可以在不同的warp中同时执行不同的程序。

19.9.10.2 谬论:GPU性能的增长速度不能超过摩尔定律

摩尔定律只是一个速率,不是任何其他速率的“光速”限制。摩尔定律描述了一种预期,即随着时间的推移,随着半导体技术的进步和晶体管的变小,每个晶体管的制造成本将呈指数下降。换言之,在制造成本不变的情况下,晶体管的数量将成倍增加。戈登·摩尔(Gordon Moore)预测,在相同的制造成本下,每年将提供大约两倍的晶体管数量,后来将其修改为每2年增加一倍。尽管摩尔在1965年做出了最初的预测,当时每个集成电路只有50个组件,但事实证明这一预测非常一致。晶体管尺寸的减小在历史上也有其他好处,例如每个晶体管的功率更低,恒定功率下的时钟速度更快。

越来越多的晶体管被芯片设计师用来制造处理器、内存和其他组件。一段时间以来,CPU设计者使用额外的晶体管以类似摩尔定律的速度提高处理器性能,以至于许多人认为每18-24个月处理器性能增长两倍是摩尔定律。事实上,事实并非如此。

微处理器设计人员将一些新晶体管用于处理器核心,改进了架构和设计,并通过流水线实现了更高的时钟速度。其余的新晶体管用于提供更多的缓存,以加快内存访问速度。相比之下,GPU设计者几乎不使用任何新晶体管来提供更多缓存,大多数晶体管用于改进处理器内核和添加更多处理器内核。GPU通过四种机制加快速度:

  • GPU设计人员通过应用成倍增加的晶体管来构建更并行、从而更快的处理器,直接获得摩尔定律的好处。
  • GPU设计者可以随着时间的推移改进架构,提高处理效率。
  • 摩尔定律假设成本不变,因此,如果花费更多的钱购买更大的芯片和更多的晶体管,显然可以超过摩尔定律的比率。
  • GPU内存系统通过使用更快的内存、更宽的内存、数据压缩和更好的缓存,以几乎与处理速度相当的速度增加了有效带宽。

这四种方法的结合在历史上允许GPU性能定期翻倍,大约每12到18个月一次,超过了摩尔定律的速度,已经在图形应用程序上演示了大约10年,并且没有明显放缓的迹象。最具挑战性的限速器似乎是内存系统,但竞争性创新也在迅速推进。

19.9.10.3 谬论:GPU仅渲染3D图形不能做通用计算

GPU用于渲染3D图形以及2D图形和视频。为了满足图形软件开发人员在图形API的接口和性能/功能要求中所表达的需求,GPU已经成为大规模并行可编程浮点处理器。在图形领域,这些处理器通过图形API和晦涩难懂的图形编程语言(OpenGL和Direct3D中的GLSL、Cg和HLSL)进行编程。然而,没有什么可以阻止GPU架构师将并行处理器内核暴露给没有图形API或神秘图形语言的程序员。

事实上,Tesla架构的GPU系列通过一个名为CUDA的软件环境来暴露处理器,该软件环境允许程序员使用C语言和C++开发通用应用程序。GPU是图灵完备的处理器,因此它们可以运行CPU可以运行的任何程序,尽管可能不太好,也许更快。

19.9.10.4 谬论:GPU无法快速运行双精度浮点程序

在过去,GPU根本无法运行双精度浮点程序,除非通过软件仿真,但软件仿真一点都不快。GPU已经从索引算术表示(颜色查找表)到每个颜色分量8位整数,再到定点算术,再到单精度浮点,后又增加了双精度。现代GPU几乎所有计算都采用单精度IEEE浮点运算,并且开始使用双精度运算。

GPU可以支持双精度浮点和单精度浮点,只需少量额外成本。如今,双精度运行速度比单精度运行速度慢,大约慢5到10倍。对于增加的额外成本,随着更多应用的需要,双精度性能可以在阶段中相对于单精度提高。

19.9.10.5 谬论:GPU不能正确执行浮点运算

至少在Tesla体系结构系列处理器中,GPU执行IEEE 754浮点标准规定的单精度浮点处理。因此,就精度而言,GPU与任何其他符合IEEE 754的处理器一样。

如今的GPU没有实现标准中描述的某些特定功能,例如处理非规范化的数字和提供精确的浮点异常,但Tesla T10P GPU提供了完整的IEEE舍入、融合乘加和双精度非规范化数字支持。

19.9.10.6 谬论:O(n)算法很难加速

无论GPU处理数据的速度有多快,向设备传输数据和从设备传输数据的步骤可能会限制具有O(n)复杂性的算法的性能(每个数据的工作量很小)。当使用DMA传输时,PCIe总线上的最高传输速率约为48 GB/秒,而对于非DMA传输则稍低,相比之下,CPU对系统内存的访问速度通常为8–12 GB/秒。例如矢量加法,将受到输入到GPU的传输和计算返回输出的限制,有三种方法可以克服传输数据的成本:

  • 尽量将数据留在GPU上,而不是在复杂算法的不同步骤中来回移动数据。CUDA故意在两次启动之间将数据单独留在GPU中以支持这一点。
  • GPU支持复制入、复制出和计算的并发操作,因此数据可以在设备进行计算时流入和流出设备。该模型对于任何可以在到达时处理的数据流都很有用。例如视频处理、网络路由、数据压缩/解压缩,甚至更简单的计算,如大向量数学。
  • 将CPU和GPU一起使用,通过将工作的子集分配给每一个来提高性能,将系统视为异构计算平台。CUDA编程模型支持将工作分配给一个或多个GPU,以及在不使用线程的情况下继续使用CPU(通过异步GPU功能),因此保持所有GPU和CPU同时工作以更快地解决问题相对简单。

19.9.10.7 陷阱:只需使用更多的线程来覆盖更长的内存延迟

CPU内核通常被设计为以全速运行单个线程。要全速运行,每个指令及其数据都需要在该指令运行时可用。如果下一条指令未就绪或该指令所需的数据不可用,则该指令无法运行,处理器将停滞。外部内存与处理器相距较远,因此从内存中获取数据需要许多周期的浪费执行。

因此,CPU需要大型局部缓存来保持运行而不停滞,内存延迟很长,因此可以通过努力在缓存中运行来避免。在某些情况下,程序工作集的需求可能比任何缓存都大,一些CPU使用多线程来容忍延迟,但每个内核的线程数通常被限制在一个小数目。

GPU策略不同。GPU内核设计为同时运行多个线程,但一次只能从任何线程执行一条指令。另一种说法是GPU缓慢地运行每个线程,但总体上高效地运行线程。每个线程都可以容忍一定的内存延迟,因为有其他线程可以运行。

这样做的缺点是需要多个多线程来覆盖内存延迟。此外,如果内存访问在线程之间分散(scattered)或不是相关的(correlated),那么内存系统在响应每个单独的请求时会逐渐变慢,最终即使是多个线程也无法覆盖延迟。因此,陷阱在于,对于“只使用更多线程”策略来覆盖延迟,必须有足够的线程,并且线程必须在内存访问的位置方面表现良好。


19.10 电源

本节着重阐述移动设备的电源技术。

智能手机已成为我们日常生活中不可替代的商品。无论是职业还是个人生活,每项任务都以某种方式或其他方式与这些设备相关。为了满足我们日益增长的依赖,这些智能手机每天都在变得更加强大。强大的处理器、更多的存储空间和改进的摄像头是每个买家都想要的功能。

除了操作系统,消费者还使用各种应用程序,这些应用程序使用我们设备的不同传感器和处理能力。所有这些过程都需要一个电源来运行,移动设备中则是一个电池。这些电池必须不时充电,以保持流程正常运行。更长的电池寿命是选择智能手机的另一个重要标准,与电池寿命优化相关的技术发展速度与智能手机行业的其他垂直行业不同。

通过硬件和软件技术可以提高智能手机的电池寿命。改变硬件可能意味着安装更大的电池,但也意味着增加智能手机的尺寸。设计高效的电源管理单元和高效的集成电路(IC)是一种可行的解决方案。此外,操作系统中管理电池密集型应用程序和明智使用可用电池的软件改进也被视为该问题的另一个潜在解决方案。

下图是一款便携式产品的电源管理:

智能手机的耗电组件多种多样,常见的如下图所示:

19.10.1 电源硬件

不同的嵌入式系统、芯片、处理器和传感器集成在一起,同步工作,使这些移动设备变得智能。它的每个硬件设备在运行时都会消耗电力。在所有这些电子模块中,收发器模块消耗最大的功率,因为它在很长时间内保持活动状态以接收传入的分组。已经讨论了各种软件技术来优化这些分组的数据传输。数字信号处理器(DSP)是这些收发器模块的关键部件,它处理大量数据以供多媒体使用,降低DSP的电源电压是降低功耗的直接方法。

为了延长电池寿命,智能手机的DSP在通话期间需要低功耗和高吞吐量乘法累加(MAC),在等待期间需要低功率间歇操作。1V多阈值CMOS电路通过简单的并行架构和使用嵌入式处理器的电源管理技术满足这些要求,嵌入式处理器与适用于电源控制的改进DFF一起使用。

除了处理器和收发器,屏幕是电池电量的另一个主要消耗源。需要背光的LED屏幕更耗电,因此可以被更省电的显示器(如OLED)取代,后者耗电更少。与LED和LCD显示器不同,OLED不需要背光,OLED中的每个像素都有自己独立的颜色和光源。因此,OLED上的黑色图像将是完全黑色的,但LED和LCD的情况并非如此。

研究表明,随着时间的推移,电池寿命的下降也可能是由于聚偏氟乙烯(PVDF)。PVDF是一种用于防止电池中石墨阳极剥落的粘合剂,不导电,并且由于粘附率差而溶解在电解质中。还存在一种新的n型共轭共聚物——双亚氨基并萘醌对亚苯基(BP)粘合剂,其性能优于传统的PVDF基粘合剂,延长了电池寿命,并防止了电池老化时的退化。

MC13892的电源结构图。包含电池、接口控制等元件。

MC13892电源管理和用户接口结构图。

研究人员还提出了一种动态电源管理单元(Power Management Unit,PMU),它在智能手机上运行不同应用程序时,收集处理器和输入/输出设备的不同参数的信息,然后PMU将基于收集的信息提出预测功率感知管理方案。

一款名为nRF5340中的电源和时钟管理系统针对超低功耗应用进行了优化,以确保最大功率效率。电源和时钟管理系统的核心是电源管理单元(PMU),如下图所示。

PMU在任何给定时间自动跟踪系统中不同组件所需的电源和时钟资源。为了实现可能的最低功耗,PMU通过评估电源和时钟请求、自动启动和停止时钟源以及选择调节器操作模式来优化系统。

PMU一般有系统开启模式(System ON)、系统关闭(System OFF)、强制关闭(Force OFF)3个模式,具体详情如下所述。

系统开启(System ON)模式是通电复位后的默认操作模式。在System ON(系统开启)中,所有功能块(如CPU和外围设备)都可以处于IDLE(空闲)或RUN(运行)状态,取决于软件设置的配置和正在执行的应用程序的状态。网络核心的CPU和外围设备可以处于空闲状态、运行状态或强制关闭模式。

PMU可以根据电源要求打开和关闭适当的内部电源。外围设备的电源需求与其活动级别直接相关,当触发特定任务或生成事件时,活动级别会增加或减少。

  • 电压和频率缩放。nRF5340自动调整内部电压以优化功率效率。一些配置选项要求更高的内部电压,被视为功耗的增加。这些配置如下:

    • 将应用程序核心时钟的频率设置为128 MHz。当CPU休眠时,例如在执行WFI(等待中断)或WFE(等待事件)指令后,也会观察到此模式下的功耗增加。通过在进入CPU休眠之前将应用程序核心的时钟配置为64 MHz,可以降低CPU和外围设备处于空闲状态休眠时系统开启期间的功耗。
    • 使用96 MHz时钟频率的QSPI。
    • 使用USB外围设备。
    • 调试时。
    • 使用VREQCTRL请求VREGRADIO电源上的额外电压-电压请求控制。
  • 电源子模式(Power submode)。在系统开启模式下,当CPU和所有外围设备处于空闲状态时,系统可以处于两种电源子模式之一。
    电源子模式包括:

    • 恒定延迟。在恒定延迟模式下,CPU唤醒延迟和PPI任务响应将保持恒定并保持在最小值,是由一组始终启用的资源保护的。与低功耗模式相比,具有恒定和可预测的延迟的优势是以增加功耗为代价的,通过触发CONSTRAT任务选择恒定延迟模式。
    • 低功耗。在低功率模式下,自动电源管理系统选择最省电的电源选项,实现最低功率是以CPU唤醒延迟和PPI任务响应的变化为代价的。通过触发LOWPWR任务选择低功率模式。

    当系统进入system ON(系统开启)时,默认为Low power(低功率)子模式。

系统关闭(System OFF)是系统可以进入的最深省电模式。在此模式下,系统的核心功能关闭,所有正在进行的任务都将终止。使用寄存器SYSTEMOFF将设备置于System OFF(系统关闭)模式。以下操作将从System OFF(关闭)启动唤醒:

  • GPIO外围设备生成的DETECT信号。
  • LPCOMP外围设备生成的ANADETECT信号。
  • 由NFCT外围设备产生的SENSE信号在现场唤醒。
  • 检测到VBUS引脚上的有效USB电压。
  • 调试会话已启动。
  • A引脚复位。

当设备从系统关闭状态唤醒时,将执行系统重置。根据外围VMC-易失性存储器控制器中的RAM保留设置,一个或多个RAM部分可以保留在系统关闭状态。在进入系统关闭之前,当进入系统关闭时,启用EasyDMA的外围设备不得处于活动状态。还建议网络核心处于空闲状态,意味着外围设备已停止,CPU处于空闲状态。

强制关闭(Force-OFF)模式仅适用于网络核心。

应用程序核心使用寄存器接口RESET-RESET控件强制网络核心进入强制关闭模式。在此模式下,网络核心被停止,以实现可能的最低功耗。当网络核心处于强制关闭模式时,只有应用程序核心可以释放该模式,导致网络核心唤醒并再次启动CPU。

在应用程序核心将网络核心设置为强制关闭模式之前,建议网络核心处于IDLE状态,如下所示:

  • 所有外围设备均已停止。
  • 使用VREQCTRL-电压请求控制取消VREGRADIO电源上的附加电压。
  • CPU处于IDLE状态,这意味着它正在运行WFI或WFE指令。

当网络核心从强制关闭模式唤醒时,它将被重置。根据外围VMC-易失性存储器控制器中的RAM保留设置,可以在强制关闭模式下保留几个RAM部分。

19.10.2 电源软件

具有更高处理能力和更快互联网连接的现代智能手机的巨大普及也增加了Android和iOS中数据和硬件密集型应用程序的数量,WhatsApp、Instagram、Skype等应用程序不仅需要CPU资源,还需要全天候的互联网连接。研究表明,在空闲状态下,互联网使用约占耗电量的62%。此外,与Wi-Fi相比,当频繁交换小尺寸数据包时,3G/4G消耗更多的电池。

数据压缩、数据包聚合和批量调度等多种软件技术可用于优化电池寿命。智能手机上不同应用程序的随机数据传输会消耗更多的电池,因此,可以使用批处理调度机制通过应用程序重复传输数据来最大化睡眠时间并最小化唤醒频率。

从3G/4G到Wi-Fi的数据卸载是提高电池寿命的另一种有效方式,因为Wi-Fi在数据传输方面比3G/4G更高效。另一种软件技术是将更高的计算任务(如CPU密集型软件)卸载到云上进行计算。该策略可用于在移动设备上运行Office 365和MATLAB等软件,但这会增加云与设备之间的通信成本。应用状态代理(ASP)是另一种技术,其中不仅使用CPU资源而且还使用Internet数据的后台应用程序被抑制并传输到另一设备上,并且仅在请求时才被带到设备上。

智能手机行业在处理能力和其他功能方面的进步速度远快于电池,研究人员现在正专注于通过软件和硬件手段来有效管理可用电池能量的电源管理技术,上述不同技术正被用于将智能手机的电池寿命提高多倍。

将能耗分配给并发运行的应用程序具有挑战性,因为功率状态传输有时是不同应用程序动作的累积结果。

例如,假设当每秒发送N个分组时,Wi-Fi接口从低功率状态传输到高功率状态。现在假设两个应用程序以每秒N/2个数据包的速度传输,导致Wi-Fi接口进入高功率状态。类似地,设想一个应用程序以每秒N个数据包的速度传输,另一个应用以每秒9N个数据。在这两种情况下,Wi-Fi接口都处于高功率状态,但不清楚如何为每个应用程序分配功率使用。在第一种情况下,两个应用程序都不会单独触发高功率状态,因此为它们充电有意义吗?在第二种情况下,两个应用程序都会触发高功率状态,因此应该大致相同的充电量,但应该是多少?

一种可能的解决方案是根据应用程序的工作负载在每个应用程序之间分配组件功率,意味着在第一种情况下,每个应用程序将被分配高功率状态功率的一半,而在第二种情况下一个应用程序将分配1/10的功率,另一个将分配9/10的功率。该解决方案具有有利的性质,即应用程序功耗的总和等于全局功耗。然而,这种解决方案是幼稚的,因为电力使用不是传输速率的线性函数,所以用这种方式来分解它没有什么意义。当我们考虑到Wi-Fi接口的功耗不是一个一维函数时,这个解决方案似乎更加可疑。

相反,我们需要一个独立于每个组件的强大功能工作的解决方案,并且对应用程序开发人员(PowerTutor的主要目标用户)来说是直观的。对于每个组件,我们计算功耗,就像每个特定应用程序单独运行一样,意味着在情况1中,每个应用程序将为低功率Wi-Fi状态充电,而在情况2中,每个应用将为高功率状态充电。这就失去了应用程序功耗之和等于全局功耗的良好特性(正如这两个案例所说明的那样,它既不是低估值,也不是高估值)。然而,通过这个定义,我们可以独立于其他正在运行的应用程序来理解应用程序的功耗,使得PowerTutor的用户可以观察到类似的应用程序级功率特性,而不考虑资源共享:对于专注于优化特定应用程序的工程师来说是一个有用的特性。请注意,PowerTutor还报告了准确的系统级功耗。

PowerTutor界面。(a) 应用程序视图。(b) 图表视图。(c) 饼状视图。图中无意但不适当地使用智能手机硬件组件。

此解决方案存在局限性。第一,如果应用程序正在争夺资源,那么很难预测它们单独执行时的行为。第二,在某些情况下,我们看到一个应用程序调用另一个来执行某项任务。在这种情况下,如何分配功耗尚不清楚。在真实的Android系统中,媒体服务器进程经常发生这种行为。第三,使用巧妙技术的应用程序,如与其他应用程序同时进行传输,不会在该方案中获得收益。然而,对于第一个案例,我们无能为力。解决其他两个问题需要对所涉及的应用程序的语义有一个高层次的理解,这目前超出了我们工具的范围。

下表显示了ADP1和ADP2手机的内部和内部电源型号变化。类型内变化是由同一类型手机样本的平均值归一化的标准差,类型间差异是两种类型手机的样本均值之间的差异。注意,表中的功率模型参数也可以被视为特定工作负载的功率测量,即功率模型参数的变化与预测误差线性相关。例如,对于使用音频设备的应用程序,我们预计使用为ADP1导出的功率模型预测另一个ADP1的音频设备功耗时,预测误差小于4%。这些数据为以下结论提供了一些支持。

Android系统中的图形架构如下:

下图显示了DRS(Dynamic Resolution Scaling,动态分辨率缩放)系统的架构。为了实现分辨率缩放,在现有Android系统中添加了两个新层:

  • 第一层是DRS上层,位于应用层和OpenGL ES/EGL层之间,拦截必要的OpenGLES函数调用和EGL函数调用,以确保以适当的显示分辨率完成图形渲染。它将缩放因子应用于必要的OpenGL ES/EGL函数调用的参数,以将默认显示分辨率转换为目标分辨率。
  • 第二层是DRS下层,位于SurfaceFlinger层和Hardware Composer之间。该层拦截传递给硬件合成器的函数调用,以确保以正确的显示分辨率完成合成。第二层的作用是在DRS上层降低分辨率后提高分辨率,以便渲染的内容能够以本地显示分辨率正确显示在屏幕上。

这两个DRS层彼此同步,以确保它们对BufferQueue中的相同图形缓冲区使用相同的目标显示分辨率,此举很有必要,因为如果用户将目标显示分辨率更改为新值,DRS上层将开始使用新的缩放因子将图形缓冲区生成到BufferQueue中。DRS下层需要确保旧的缩放因子用于先前生成的图形缓冲区,并且新的缩放因子在合成期间仅应用于新生成的图形缓冲器。

不同缩放因子下每帧游戏和基准的标准化能量如下表:

从覆盖率测试中的15个应用程序,包括14个游戏和一个基准(上表中列出的名称)来评估在不同的显示分辨率下可以节省多少电量。在S5手机上运行测试用例,并使用季风功率监视器测量系统功率,将手机切换到飞行模式,禁用不必要的硬件组件,如GPS和摄像头,并将背光亮度设置为50%。将GPU频率锁定为500MHz,以避免GPU的DVFS推断。在每次测试前都会对手机进行冷却,以确保GPU能够在500MHz下工作至少60秒。将每个测试重复三次,并报告平均结果。

采用每帧总系统能量(EPF)作为衡量标准来评估原型系统的节能。为了方便地比较不同的结果,将结果标准化为原生显示分辨率的情况。上表显示了不同比例因子的归一化EPF。缩放因子被归一化为原生显示分辨率,即对于全分辨率,缩放因子为1.0。当显示分辨率降低一半(即缩放因子为0.5,将显示分辨率从2560x1440像素降低到1280x720像素)时,对于16个测试用例,平均而言,可以将EPF降低30.1%,范围从15.7%到60.5%。对于这14款游戏,无论缩放因子是什么值,它们总是以固定的帧速率运行。因此,在实践中可以实现相同的功耗节省量(如果仅计算这14款比赛,则为24.9%)。对于两种GFXBench情况,由于基准测试总是试图用尽所有GPU处理能力,因此其功耗在所有缩放因子中几乎保持不变。然而,分辨率会极大地影响帧速率。对于较小的缩放因子,它们可以以较高的帧速率运行,从而提供更好的用户体验。

19.10.3 电源优化

在开始应用程序开发之前,分析并定义应用程序的需求、范围和功能,以确保高效的功能和流畅的用户体验。为单一目的设计应用程序,并分析它如何最好地为用户服务。

以下指南帮助您设计和开发适用于具有不同特性(如屏幕大小和输入法支持)的移动设备的应用程序:

  • 了解目标用户。了解谁将使用该应用程序,他们将使用它做什么,以及拥有哪些移动设备,然后设计应用程序以适应特定的使用环境。
  • 小屏幕设计。移动设备的屏幕尺寸明显小于桌面设备的屏幕大小。仔细考量在应用程序UI上显示的最相关的内容是什么,因为在桌面应用程序中尝试将尽可能多的内容放入屏幕可能是不合理的。
  • 多种屏幕尺寸的设计。将每个控件的位置和大小与显示器的尺寸相关联,使得同一组信息能够以所有分辨率显示在屏幕上,更高分辨率的设备只显示更精细的图形。
  • 更改屏幕方向的设计。某些设备支持屏幕旋转,在这些设备上,应用程序可以纵向或横向显示,考虑方向并在屏幕旋转时动态调整显示。
  • 设计在应用程序中移动的直观方式。移动设备缺少鼠标和全尺寸键盘,因此用户必须使用触摸屏或五向导航板在应用程序中移动,此外,许多用户用一只手控制设备。要创建优化的用户体验,允许用户一键访问信息,不要让它们滚动和键入。
  • 有限输入法设计。应用程序从用户那里收集有关手头任务的信息。除了触摸屏输入,一些设备还包含物理键,如五向导航板、键盘和键盘。用户通过使用屏幕控件(如列表、复选框、单选按钮和文本字段)输入信息。
  • 缩短响应时间。延迟可能会导致用户交互延迟。如果用户认为某个应用程序速度慢,他们很可能会感到沮丧并停止使用它。
  • 节省电池时间。移动设备不总是连接到电源,而是依靠电池供电。优化功耗以将总功耗保持在可接受的水平,并防止用户耗尽电池时间。
  • 考虑网络问题。如果用户没有固定费率的数据计划或WLAN支持,移动网络连接会让他们花钱。此外,当用户带着设备四处移动时,可用于连接的网络会不断变化。
  • 记住设备的处理限制。设备上可用的内存有限,应谨慎使用。尽管所有移动设备都具有通用功能,但就可用资源和额外功能而言,每个设备都是独立的,因此必须考虑所有目标设备的约束。
  • 最大限度地提高应用程序的效率和电池寿命
    • 优化切换器效率(针对大部分时间使用处理器的地方)。
    • 使用PFM、PWM-PS提高低功率条件下的效率。
  • 最小化物料清单(BOM)成本和面积
  • 电池技术(1、2或3个锂离子电池)
  • 保持功耗在应用范围内
  • 软件驱动程序支持
  • 灵活的加电顺序/默认电压,支持多处理器和外围设备
  • PMIC内部或外部音频。内部优势:降低成本,节省电路板空间,外部优势:噪音更小、更灵活。
  • 使用动态分辨率。详见上节。

高通采用了整体系统方法,通过定制关键技术块和整个片上系统(SoC)来实现节能。

该系统方法涉及四个关键级别的功率和热量优化:

  • 专用处理引擎。定制设计专用处理引擎和其他关键组件,如电源管理集成电路(PMIC)、射频(RF)芯片等。

    • 微架构。

      aSMP和其它典型的SMP实现。

    • 电路设计。

    • 晶体管级别设计。

    骁龙SoC内的处理引擎。

  • 智能集成。巧妙地集成了技术块并设计了系统架构。

    • 系统结构/互连。
    • 缓存和内存设计。
    • 软件与硬件加速。
  • 优化系统软件。将软件与硬件紧密结合。

    • 软件工具和API。
    • 软件、OS和编译器优化。
    • 电源和热量算法。
  • 设备级优化。仔细考量移动设备上的所有其他组件,并优化了整个解决方案的操作。

    • 电源和热量模型。
    • 设备组件优化。
    • OEM最佳实践。

为了解决在具有功率和热量限制的设备中提供更高性能的日益增加的挑战,以移动为中心的设计方法至关重要。高通采用整体系统方法进行电源和热管理,其移动SoC通过设计专门的处理引擎,巧妙地集成它们,并优化系统软件和整个设备,实现了功率和热效率的最佳平衡,使移动设备能够提供最佳的用户体验。

关于电源技术的更多详情可参阅:


19.11 UE硬件

本章节将基于UE 5.1的源码解析涉及的硬件接口和逻辑。

19.11.1 CPU

下面的接口可以计算CPU的性能等级等参数:

// GenericPlatformSurvey.h

struct FSynthBenchmarkResults 
{
    FSynthBenchmarkStat CPUStats[2];

    // 计算CPU性能等级,100表明是平局等级的CPU, 小于100更慢, 大于100更快。
    float ComputeCPUPerfIndex(TArray<float>* OutIndividualResults = nullptr) const;
};

下面的接口可以追踪CPU的性能,包含追踪数据、利用率、分析器等:

// CpuProfilerTrace.h

struct FCpuProfilerTrace
{
    static uint32 OutputEventType(const ANSICHAR* Name, const ANSICHAR* File = nullptr, uint32 Line = 0);
    static uint32 OutputEventType(const TCHAR* Name, const ANSICHAR* File = nullptr, uint32 Line = 0);
    static void OutputBeginEvent(uint32 SpecId);
    static void OutputBeginDynamicEvent(const ANSICHAR* Name, const ANSICHAR* File = nullptr, uint32 Line = 0);
    static void OutputBeginDynamicEvent(const TCHAR* Name, const ANSICHAR* File = nullptr, uint32 Line = 0);
    static void OutputBeginDynamicEvent(const FName& Name, const ANSICHAR* File = nullptr, uint32 Line = 0);
    static void OutputEndEvent();
    static void OutputResumeEvent(uint64 SpecId, uint32& TimerScopeDepth);
    static void OutputSuspendEvent();

    class FEventScope
    {
        (...)
    };

    struct FDynamicEventScope
    {
        (...)
    };

    (...)
};


// CpuProfilerTraceAnalysis.h

class FCpuProfilerAnalyzer : public UE::Trace::IAnalyzer
{
public:
    virtual void OnAnalysisBegin(const FOnAnalysisContext& Context) override;
    virtual void OnAnalysisEnd(/*const FOnAnalysisEndContext& Context*/) override;
    virtual bool OnEvent(uint16 RouteId, EStyle Style, const FOnEventContext& Context) override;

private:
    IAnalysisSession& Session;
    IEditableTimingProfilerProvider& EditableTimingProfilerProvider;
    IEditableThreadProvider& EditableThreadProvider;
    TMap<uint32, FThreadState*> ThreadStatesMap;
    TMap<uint32, uint32> SpecIdToTimerIdMap;
    TMap<const TCHAR*, uint32> ScopeNameToTimerIdMap;
    uint32 CoroutineTimerId = ~0;
    uint32 CoroutineUnknownTimerId = ~0;
    uint64 TotalEventSize = 0;
    uint64 TotalScopeCount = 0;
    double BytesPerScope = 0.0;

    (...)
};

以下接口包含CPU的时钟、频率、亲缘性等信息和接口:

// GenericPlatformTime.h

// 包含CPU利用率数据
struct FCPUTime
{
    float CPUTimePct;         // 上一个间隔的CPU利用率百分比。
    float CPUTimePctRelative; // 上一个间隔相对于一个核心的CPU利用率百分比,因此如果CPUTimePct为8.0%,而设备有6个核心,则该值将为48.0%。
};

// 时间
struct FGenericPlatformTime
{
    // 时间、时钟、频率等接口
    static TCHAR* StrDate( TCHAR* Dest, SIZE_T DestSize );
    static TCHAR* StrTime( TCHAR* Dest, SIZE_T DestSize );
    static const TCHAR* StrTimestamp();
    static FString PrettyTime( double Seconds );
    static bool UpdateCPUTime( float DeltaTime );
    static bool UpdateThreadCPUTime(float = 0.0);
    static void AutoUpdateGameThreadCPUTime(double UpdateInterval);
    static FCPUTime GetCPUTime();
    static FCPUTime GetThreadCPUTime();
    static double GetLastIntervalCPUTimeInSeconds();
    static double GetLastIntervalThreadCPUTimeInSeconds();
    static double GetSecondsPerCycle();
    static float ToMilliseconds( const uint32 Cycles );
    static float ToSeconds( const uint32 Cycles );
    static double GetSecondsPerCycle64();
    static double ToMilliseconds64(const uint64 Cycles);
    static double ToSeconds64(const uint64 Cycles);

    (...)

protected:

    static double SecondsPerCycle;
    static double SecondsPerCycle64;
    static double LastIntervalCPUTimeInSeconds;
};

// PlatformAffinity.h

struct FThreadAffinity 
{ 
    uint64 ThreadAffinityMask = FPlatformAffinity::GetNoAffinityMask(); 
    uint16 ProcessorGroup = 0; 
};

19.11.2 内存

以下代码包含内存的硬件信息、分配、缓存、池化等接口:

// GenericPlatformMemory.h

struct FGenericPlatformMemory
{
    static bool bIsOOM; // 是否内存不足
    static uint64 OOMAllocationSize; // 设置为触发内存不足的分配大小,否则为零.
    static uint32 OOMAllocationAlignment; // 设置为触发内存不足的分配对齐,否则为零。
    static void* BackupOOMMemoryPool; // 内存不足时要删除的预分配缓冲区。用于OOM处理和崩溃报告。
    static uint32 BackupOOMMemoryPoolSize; // BackupOOMMemoryPool的大小(字节)。

    // 可用于内存统计的各种内存区域。枚举的确切含义相对依赖于平台,尽管一般的(物理、GPU)很简单。一个平台可以添加更多的内存,并且不会影响其他平台,除了StatManager跟踪每个区域的最大可用内存(使用数组FPlatformMemory::MCR_max big)所需的少量内存之外.
    enum EMemoryCounterRegion
    {
        MCR_Invalid, // not memory
        MCR_Physical, // main system memory
        MCR_GPU, // memory directly a GPU (graphics card, etc)
        MCR_GPUSystem, // system memory directly accessible by a GPU
        MCR_TexturePool, // presized texture pools
        MCR_StreamingPool, // amount of texture pool available for streaming.
        MCR_UsedStreamingPool, // amount of texture pool used for streaming.
        MCR_GPUDefragPool, // presized pool of memory that can be defragmented.
        MCR_PhysicalLLM, // total physical memory including CPU and GPU
        MCR_MAX
    };

    // 使用的分配器.
    enum EMemoryAllocatorToUse
    {
        Ansi, // Default C allocator
        Stomp, // Allocator to check for memory stomping
        TBB, // Thread Building Blocks malloc
        Jemalloc, // Linux/FreeBSD malloc
        Binned, // Older binned malloc
        Binned2, // Newer binned malloc
        Binned3, // Newer VM-based binned malloc, 64 bit only
        Platform, // Custom platform specific allocator
        Mimalloc, // mimalloc
    };
    static EMemoryAllocatorToUse AllocatorToUse;

    enum ESharedMemoryAccess
    {
        Read    =        (1 << 1),
        Write    =        (1 << 2)
    };

    // 共享内存区域的通用表示
    struct FSharedMemoryRegion
    {
        TCHAR    Name[MaxSharedMemoryName];
        uint32   AccessMode;
        void *   Address;
        SIZE_T   Size;
    };

    // 内存操作.
    static void Init();
    static void OnOutOfMemory(uint64 Size, uint32 Alignment);
    static void SetupMemoryPools();
    static uint32 GetBackMemoryPoolSize()
    static FMalloc* BaseAllocator();
    static FPlatformMemoryStats GetStats();
    static uint64 GetMemoryUsedFast();
    static void GetStatsForMallocProfiler( FGenericMemoryStats& out_Stats );
    static const FPlatformMemoryConstants& GetConstants();
    static uint32 GetPhysicalGBRam();

    static bool PageProtect(void* const Ptr, const SIZE_T Size, const bool bCanRead, const bool bCanWrite);
    
    // 分配.
    static void* BinnedAllocFromOS( SIZE_T Size );
    static void BinnedFreeToOS( void* Ptr, SIZE_T Size );
    static void NanoMallocInit();
    static bool PtrIsOSMalloc( void* Ptr);
    static bool IsNanoMallocAvailable();
    static bool PtrIsFromNanoMalloc( void* Ptr);

    // 虚拟内存块及操作.
    class FBasicVirtualMemoryBlock
    {
    protected:
        void *Ptr;
        uint32 VMSizeDivVirtualSizeAlignment;

    public:
        FBasicVirtualMemoryBlock(const FBasicVirtualMemoryBlock& Other) = default;
        FBasicVirtualMemoryBlock& operator=(const FBasicVirtualMemoryBlock& Other) = default;
        FORCEINLINE uint32 GetActualSizeInPages() const;
        FORCEINLINE void* GetVirtualPointer() const;

        void Commit(size_t InOffset, size_t InSize);
        void Decommit(size_t InOffset, size_t InSize);
        void FreeVirtual();

        void CommitByPtr(void *InPtr, size_t InSize);
        void DecommitByPtr(void *InPtr, size_t InSize);
        void Commit();
        void Decommit();
        size_t GetActualSize() const;

        static FPlatformVirtualMemoryBlock AllocateVirtual(size_t Size, ...);
        
        static size_t GetCommitAlignment();
        static size_t GetVirtualSizeAlignment();

    };

    // 数据和调试
    static bool BinnedPlatformHasMemoryPoolForThisSize(SIZE_T Size);
    static void DumpStats( FOutputDevice& Ar );
    static void DumpPlatformAndAllocatorStats( FOutputDevice& Ar );

    static EPlatformMemorySizeBucket GetMemorySizeBucket();

    // 内存数据操作.
    static void* Memmove( void* Dest, const void* Src, SIZE_T Count );
    static int32 Memcmp( const void* Buf1, const void* Buf2, SIZE_T Count );
    static void* Memset(void* Dest, uint8 Char, SIZE_T Count);
    static void* Memzero(void* Dest, SIZE_T Count);
    static void* Memcpy(void* Dest, const void* Src, SIZE_T Count);
    static void* BigBlockMemcpy(void* Dest, const void* Src, SIZE_T Count);
    static void* StreamingMemcpy(void* Dest, const void* Src, SIZE_T Count);
    static void* ParallelMemcpy(void* Dest, const void* Src, SIZE_T Count, EMemcpyCachePolicy Policy = EMemcpyCachePolicy::StoreCached);

    (...)
};

// 结构用于保存所有平台的通用内存常数。这些值不会在可执行文件的整个生命周期内发生变化。
struct FGenericPlatformMemoryConstants
{
    // 实际物理内存量,以字节为单位(对于运行32位代码的64位设备,需要处理>4GB)。
    uint64 TotalPhysical;
    // 虚拟内存量,以字节为单位
    uint64 TotalVirtual;
    // 物理页面的大小,以字节为单位,也是物理RAM的PageProtection()、提交和属性(例如访问能力)的粒度。
    SIZE_T PageSize;

    // 如果内存以大于PageSize的块分配,则某些平台具有优势(例如VirtualAlloc()目前的粒度似乎为64KB),该值是系统将在后台使用的最小分配大小。
    SIZE_T OsAllocationGranularity;
    // Binned2 malloc术语中“页面”的大小,以字节为单位,至少为64KB。BinnedMloc希望从BinnedAllocFromOS()返回的内存与BinnedPageSize边界对齐。
    SIZE_T BinnedPageSize;
    // BinnedMalloc术语中的“分配粒度”,即BinnedMlloc将以该值的增量分配内存。如果为0,Binned将对此值使用BinnedPageSize.
    SIZE_T BinnedAllocationGranularity;

    // AddressLimit-第二个参数是BinnedAllocFromOS()预期返回的地址范围的估计值。Binned Malloc将调整其内部结构,以查找此范围的内存分配O(1)。超出这个范围是可以的,查找会稍微慢一点
    uint64 AddressLimit;
    // 近似物理RAM(GB),除PC外的所有设备上都有1。用于“course tuning”,如FPlatformMisc::NumberOfCores()。
    uint32 TotalPhysicalGB;
};

// 用于保存所有平台的通用内存统计信息,可能会在可执行文件的整个生命周期内发生变化。
struct FGenericPlatformMemoryStats : public FPlatformMemoryConstants
{
    // 当前可用的物理内存量,以字节为单位。
    uint64 AvailablePhysical;
    // 当前可用的虚拟内存量(字节)。
    uint64 AvailableVirtual;
    // 进程使用的物理内存量,以字节为单位。
    uint64 UsedPhysical;
    // 进程使用的物理内存的峰值量,以字节为单位
    uint64 PeakUsedPhysical;
    // 进程使用的虚拟内存总量。
    uint64 UsedVirtual;
    // 进程使用的虚拟内存的峰值量。
    uint64 PeakUsedVirtual;
    
    // 内存压力状态,适用于可用内存估计可能不考虑关闭非活动进程或诉诸交换可回收内存的平台。
    enum class EMemoryPressureStatus : uint8 
    { 
        Unknown,
        Nominal, 
        Critical, // OOM(Out Of Memory)条件的高风险
    };
    EMemoryPressureStatus GetMemoryPressureStatus();

    struct FPlatformSpecificStat
    {
        const TCHAR* Name;
        uint64 Value;
    };

    TArray<FPlatformSpecificStat> GetPlatformSpecificStats() const;
    uint64 GetAvailablePhysical(bool bExcludeExtraDevMemory) const;
    // 由FCsvProfiler::EndFrame调用以设置特定于平台的CSV统计信息。
    void SetEndFrameCsvStats() const {}
};

以下接口指示了D3D12的某些资源是CPU或GPU的可读可写性:

// D3D12Util.h

inline bool IsCPUWritable(D3D12_HEAP_TYPE HeapType, const D3D12_HEAP_PROPERTIES *pCustomHeapProperties = nullptr);
inline bool IsGPUOnly(D3D12_HEAP_TYPE HeapType, const D3D12_HEAP_PROPERTIES *pCustomHeapProperties = nullptr);
inline bool IsCPUAccessible(D3D12_HEAP_TYPE HeapType, const D3D12_HEAP_PROPERTIES* pCustomHeapProperties = nullptr);

以下代码是内存追踪相关的类型和接口:

// MemoryTrace.h

enum EMemoryTraceRootHeap : uint8
{
    SystemMemory, // RAM
    VideoMemory, // VRAM
    EndHardcoded = VideoMemory,
    EndReserved = 15
};

// 追踪堆标记。
enum class EMemoryTraceHeapFlags : uint16
{
    None = 0,
    Root = 1 << 0,
    NeverFrees = 1 << 1, // The heap doesn't free (e.g. linear allocator)
};
ENUM_CLASS_FLAGS(EMemoryTraceHeapFlags);

enum class EMemoryTraceHeapAllocationFlags : uint8
{
    None = 0,
    Heap = 1 << 0, // Is a heap, can be used to unmark alloc as heap.
};
ENUM_CLASS_FLAGS(EMemoryTraceHeapAllocationFlags);

class FMalloc* MemoryTrace_Create(class FMalloc* InMalloc);
void MemoryTrace_Initialize();
HeapId MemoryTrace_HeapSpec(HeapId ParentId, const TCHAR* Name, EMemoryTraceHeapFlags Flags = EMemoryTraceHeapFlags::None);
HeapId MemoryTrace_RootHeapSpec(const TCHAR* Name, EMemoryTraceHeapFlags Flags = EMemoryTraceHeapFlags::None);
void MemoryTrace_MarkAllocAsHeap(uint64 Address, HeapId Heap, ...);
void MemoryTrace_UnmarkAllocAsHeap(uint64 Address, HeapId Heap);
void MemoryTrace_Alloc(uint64 Address, uint64 Size, uint32 Alignment, HeapId RootHeap = EMemoryTraceRootHeap::SystemMemory);
void MemoryTrace_Free(uint64 Address, HeapId RootHeap = EMemoryTraceRootHeap::SystemMemory);
void MemoryTrace_ReallocFree(uint64 Address, HeapId RootHeap = EMemoryTraceRootHeap::SystemMemory);
void MemoryTrace_ReallocAlloc(uint64 Address, uint64 NewSize, uint32 Alignment, HeapId RootHeap = EMemoryTraceRootHeap::SystemMemory);

(...)

以下代码涉及了GPU资源数组的操作:

// ResourceArray.h

// 资源数组的独立于元素类型的接口。
class FResourceArrayInterface
{

public:
    virtual const void* GetResourceData() const = 0;
    virtual uint32 GetResourceDataSize() const = 0;
    virtual void Discard() = 0;
    virtual bool IsStatic() const = 0;
    virtual bool GetAllowCPUAccess() const = 0;
    virtual void SetAllowCPUAccess( bool bInNeedsCPUAccess ) = 0;
};

// 允许直接为批量资源类型分配GPU内存。
class FResourceBulkDataInterface
{
public:
    virtual const void* GetResourceBulkData() const = 0;
    virtual uint32 GetResourceBulkDataSize() const = 0;
    virtual void Discard() = 0;
    
    enum class EBulkDataType
    {
        Default,
        MediaTexture,
        VREyeBuffer,
    };
    virtual EBulkDataType GetResourceType() const;
};

// 允许直接为纹理资源分配GPU内存。
class FTexture2DResourceMem : public FResourceBulkDataInterface
{
public:
    virtual void* GetMipData(int32 MipIdx) = 0;
    virtual int32 GetNumMips() = 0;
    virtual int32 GetSizeX() = 0;
    virtual int32 GetSizeY() = 0;

    virtual bool IsValid() = 0;
    virtual bool HasAsyncAllocationCompleted() const = 0;
    virtual void FinishAsyncAllocation() = 0;
    virtual void CancelAsyncAllocation() = 0;
};

以下是操作系统页缓存分配器:

// CachedOSPageAllocator.h

struct FCachedOSPageAllocator
{
protected:
    struct FFreePageBlock
    {
        void*  Ptr;
        SIZE_T ByteSize;
    };

    void* AllocateImpl(SIZE_T Size, uint32 CachedByteLimit, FFreePageBlock* First, FFreePageBlock* Last, ...);
    void FreeImpl(void* Ptr, SIZE_T Size, uint32 NumCacheBlocks, uint32 CachedByteLimit, FFreePageBlock* First, ...);
    void FreeAllImpl(FFreePageBlock* First, uint32& FreedPageBlocksNum, SIZE_T& CachedTotal, FCriticalSection* Mutex);
};

template <uint32 NumCacheBlocks, uint32 CachedByteLimit>
struct TCachedOSPageAllocator : private FCachedOSPageAllocator
{
    void* Allocate(SIZE_T Size, uint32 AllocationHint = 0, FCriticalSection* Mutex = nullptr);
    void Free(void* Ptr, SIZE_T Size, FCriticalSection* Mutex = nullptr, bool ThreadIsTimeCritical = false);
    void FreeAll(FCriticalSection* Mutex = nullptr);
    void UpdateStats();
    uint64 GetCachedFreeTotal();

private:
    FFreePageBlock FreedPageBlocks[NumCacheBlocks*2];
    SIZE_T         CachedTotal;
    uint32         FreedPageBlocksNum;
};

// CachedOSVeryLargePageAllocator.h

// 超大页面的缓存分配器。
class FCachedOSVeryLargePageAllocator
{
    // 将地址空间设置为所需的两倍,并将第一个用于小池分配,第二个用于仍为==SizeOfSubPage的其他分配
#if UE_VERYLARGEPAGEALLOCATOR_TAKEONALL64KBALLOCATIONS
    static constexpr uint64 AddressSpaceToReserve = ((1024LL * 1024LL * 1024LL) * UE_VERYLARGEPAGEALLOCATOR_RESERVED_SIZE_IN_GB * 2LL);
    static constexpr uint64 AddressSpaceToReserveForSmallPool = AddressSpaceToReserve/2;
#else
    static constexpr uint64 AddressSpaceToReserve = ((1024 * 1024 * 1024LL) * UE_VERYLARGEPAGEALLOCATOR_RESERVED_SIZE_IN_GB);
    static constexpr uint64 AddressSpaceToReserveForSmallPool = AddressSpaceToReserve;
#endif
    static constexpr uint64 SizeOfLargePage = (UE_VERYLARGEPAGEALLOCATOR_PAGESIZE_KB * 1024);
    static constexpr uint64 SizeOfSubPage = (1024 * 64);
    static constexpr uint64 NumberOfLargePages = (AddressSpaceToReserve / SizeOfLargePage);
    static constexpr uint64 NumberOfSubPagesPerLargePage = (SizeOfLargePage / SizeOfSubPage);

public:
    void* Allocate(SIZE_T Size, uint32 AllocationHint = 0, FCriticalSection* Mutex = nullptr);
    void Free(void* Ptr, SIZE_T Size, FCriticalSection* Mutex = nullptr, bool ThreadIsTimeCritical = false);
    void FreeAll(FCriticalSection* Mutex = nullptr);
    void UpdateStats();

    uint64 GetCachedFreeTotal();
    bool IsPartOf(const void* Ptr);

private:
    (...)

    FLargePage*    FreeLargePagesHead[FMemory::AllocationHints::Max];            // no backing store
    FLargePage*    UsedLargePagesHead[FMemory::AllocationHints::Max];            // has backing store and is full
    FLargePage*    UsedLargePagesWithSpaceHead[FMemory::AllocationHints::Max];    // has backing store and still has room
    FLargePage*    EmptyButAvailableLargePagesHead[FMemory::AllocationHints::Max];    // has backing store and is empty
    FLargePage    LargePagesArray[NumberOfLargePages];
    TCachedOSPageAllocator<CACHEDOSVERYLARGEPAGEALLOCATOR_MAX_CACHED_OS_FREES, CACHEDOSVERYLARGEPAGEALLOCATOR_BYTE_LIMIT> CachedOSPageAllocator;
};
CORE_API extern bool GEnableVeryLargePageAllocator;

// PooledVirtualMemoryAllocator.h

// 此Class将从FMallocBinned2进行的OS分配汇集在一起。
struct FPooledVirtualMemoryAllocator
{
    void* Allocate(SIZE_T Size, uint32 AllocationHint = 0, FCriticalSection* Mutex = nullptr);
    void Free(void* Ptr, SIZE_T Size, FCriticalSection* Mutex = nullptr, bool ThreadIsTimeCritical = false);
    void FreeAll(FCriticalSection* Mutex = nullptr);

    // 描述特定大小池的结构
    struct FPoolDescriptorBase
    {
        FPoolDescriptorBase* Next;
        SIZE_T VMSizeDivVirtualSizeAlignment;
    };
    uint64 GetCachedFreeTotal();
    void UpdateStats();

private:
    enum Limits
    {
        NumAllocationSizeClasses    = 64,
        MaxAllocationSizeToPool        = NumAllocationSizeClasses * 65536,

        MaxOSAllocCacheSize            = 64 * 1024 * 1024,
        MaxOSAllocsCached            = 64
    };

    int32 GetAllocationSizeClass(SIZE_T Size);
    SIZE_T CalculateAllocationSizeFromClass(int32 Class);
    int32 NextPoolSize[Limits::NumAllocationSizeClasses];
    FPoolDescriptorBase* ClassesListHeads[Limits::NumAllocationSizeClasses];
    FCriticalSection     ClassesLocks[Limits::NumAllocationSizeClasses];
    void DecideOnTheNextPoolSize(int32 SizeClass, bool bGrowing);
    FPoolDescriptorBase* CreatePool(SIZE_T AllocationSize, int32 NumPooledAllocations);
    void DestroyPool(FPoolDescriptorBase* Pool);
    FCriticalSection OsAllocatorCacheLock;
    TCachedOSPageAllocator<MaxOSAllocsCached, MaxOSAllocCacheSize> OsAllocatorCache;
};

以下是虚拟内存分配器:

// VirtualAllocator.h

class FVirtualAllocator
{
    struct FFreeLink
    {
        void *Ptr = nullptr;
        FFreeLink* Next = nullptr;
    };
    struct FPerBlockSize
    {
        int64 AllocBlocksSize = 0;
        int64 FreeBlocksSize = 0;
        FFreeLink* FirstFree = nullptr;
    };

    FCriticalSection CriticalSection;

    uint8* LowAddress;
    uint8* HighAddress;
    size_t TotalSize;
    size_t PageSize;
    size_t MaximumAlignment;
    uint8* NextAlloc;
    FFreeLink* RecycledLinks;
    int64 LinkSize;
    bool bBacksMalloc;
    FPerBlockSize Blocks[64]; 
    
    void FreeVirtualByBlock(void* Ptr, FPerBlockSize& Block, size_t AlignedSize);

protected:
    size_t SpaceConsumed;
    virtual uint8* AllocNewVM(size_t AlignedSize)
    {
        uint8* Result = NextAlloc;
        check(IsAligned(Result, MaximumAlignment) && IsAligned(AlignedSize, MaximumAlignment));
        NextAlloc = Result + AlignedSize;
        SpaceConsumed = NextAlloc - LowAddress;
        return Result;
    }

public:
    uint32 GetPagesForSizeAndAlignment(size_t Size, size_t Alignment = 1) const;
    void* AllocateVirtualPages(uint32 NumPages, size_t AlignmentForCheck = 1);
    void FreeVirtual(void* Ptr, uint32 NumPages);

    struct FVirtualAllocatorStatsPerBlockSize
    {
        size_t AllocBlocksSize;
        size_t FreeBlocksSize;
    };

    struct FVirtualAllocatorStats
    {
        size_t PageSize;
        size_t MaximumAlignment;
        size_t VMSpaceTotal;
        size_t VMSpaceConsumed;
        size_t VMSpaceConsumedPeak;

        size_t FreeListLinks;

        FVirtualAllocatorStatsPerBlockSize BlockStats[64];
    };
    void GetStats(FVirtualAllocatorStats& OutStats);
};

19.11.3 GPU

下面的接口可以计算GPU的性能等级等参数:

// GenericPlatformSurvey.h

struct FSynthBenchmarkResults 
{
    FSynthBenchmarkStat GPUStats[7];
    // 计算GPU性能等级,100表明是平局等级的CPU, 小于100更慢, 大于100更快。
    float ComputeGPUPerfIndex(TArray<float>* OutIndividualResults = nullptr) const;
    // 以秒为单位返回,用于检查基准测试是否耗时过长(硬件速度非常慢,不要使用大型WorkScale进行测试).
    float ComputeTotalGPUTime() const;
};

// GPU适配器
struct FGPUAdpater    
{
    static const uint32 MaxStringLength = 260;
    // 名称
    TCHAR AdapterName[MaxStringLength];
    // 内部驱动版本
    TCHAR AdapterInternalDriverVersion[MaxStringLength];
    // 用户驱动版本
    TCHAR AdapterUserDriverVersion[MaxStringLength];
    // 额外的数据
    TCHAR AdapterDriverDate[MaxStringLength];
    // 适配器专用的内存
    TCHAR AdapterDedicatedMemoryMB[MaxStringLength];
};

下面代码涉及了GPU驱动相关的信息和操作:

// GenericPlatformDriver.h

// GPU驱动信息。
struct FGPUDriverInfo
{
    // DirectX VendorId,0(如果未设置),请使用以下函数设置/获取
    uint32 VendorId;
    // e.g. "NVIDIA GeForce GTX 680" or "AMD Radeon R9 200 / HD 7900 Series"
    FString DeviceDescription;
    // e.g. "NVIDIA" or "Advanced Micro Devices, Inc."
    FString ProviderName;
    // e.g. "15.200.1062.1004"(AMD)
    // e.g. "9.18.13.4788"(NVIDIA) 
    // 第一个数字是Windows版本(例如7:Vista、6:XP、4:Me、9:Win8(1)、10:Win7),最后5个数字编码了UserDriver版本,也称为技术版本号(https://wiki.mozilla.org/Blocklisting/Blocked_Graphics_Drivers)如果驱动程序检测失败,则TEXT("Unknown") 
    FString InternalDriverVersion;    
    // e.g. "Catalyst 15.7.1"(AMD) or "Crimson 15.7.1"(AMD) or "347.88"(NVIDIA)
    // 也称为商业版本号
    FString UserDriverVersion;
    // e.g. 3-13-2015
    FString DriverDate;
    // e.g. D3D11, D3D12
    FString RHIName;

    bool IsValid() const;
    // get VendorId
    bool IsAMD() const { return VendorId == 0x1002; }
    // get VendorId
    bool IsIntel() const { return VendorId == 0x8086; }
    // get VendorId
    bool IsNVIDIA() const { return VendorId == 0x10DE; }
    bool IsSameDriverVersionGeneration(const TCHAR* InOpWithMultiInt) const;
    static FString TrimNVIDIAInternalVersion(const FString& InternalVersion);
    FString GetUnifiedDriverVersion() const;
};

// Hardware.ini文件中的一个条目
struct FDriverDenyListEntry
{
    // optional, e.g. "<=223.112.21.1", might includes comparison operators, later even things multiple ">12.22 <=12.44"
    FString DriverVersionString;
    // optional, e.g. "<=MM-DD-YYYY"
    FString DriverDateString;
    // optional, e.g. "D3D11", "D3D12"
    FString RHIName;
    // required
    FString Reason;

    void LoadFromINIString(const TCHAR* In);
    bool IsValid() const;
    bool IsLatestDenied() const;
};

// GPU硬件信息
struct FGPUHardware
{
    const FGPUDriverInfo DriverInfo;
    FString GetSuggestedDriverVersion(const FString& InRHIName) const;
    FDriverDenyListEntry FindDriverDenyListEntry() const;
    bool IsLatestDenied() const;
    FString GetVendorSectionName() const;
};

下面代码是GPU的装箱分配器:

// MallocBinnedGPU.h

class FMallocBinnedGPU final : public FMalloc
{
    struct FGPUMemoryBlockProxy
    {
        uint8 MemoryModifiedByCPU[32 - sizeof(void*)]; // might be modified for free list links, etc
        void *GPUMemory;  // pointer to the actual GPU memory, which we cannot modify with the CPU
    };
    struct FFreeBlock
    {
        uint16 BlockSizeShifted;        // Size of the blocks that this list points to >> ArenaParams.MinimumAlignmentShift
        uint8 PoolIndex;                // Index of this pool
        uint8 Canary;                    // Constant value of 0xe3
        uint32 NumFreeBlocks;          // Number of consecutive free blocks here, at least 1.
        FFreeBlock* NextFreeBlock;     // Next free block or nullptr
    };
    struct FPoolTable
    {
        uint32 BlockSize;
        uint16 BlocksPerBlockOfBlocks;
        uint8 PagesPlatformForBlockOfBlocks;
        FBitTree BlockOfBlockAllocationBits; // one bits in here mean the virtual memory is committed
        FBitTree BlockOfBlockIsExhausted;    // one bit in here means the pool is completely full
        uint32 NumEverUsedBlockOfBlocks;
        FPoolInfoSmall** PoolInfos;
        uint64 UnusedAreaOffsetLow;
    };

    struct FPtrToPoolMapping
    {
    private:
        /** Shift to apply to a pointer to get the reference from the indirect tables */
        uint64 PtrToPoolPageBitShift;
        /** Shift required to get required hash table key. */
        uint64 HashKeyShift;
        /** Used to mask off the bits that have been used to lookup the indirect table */
        uint64 PoolMask;
        // PageSize dependent constants
        uint64 MaxHashBuckets;
    };

    struct FBundleNode
    {
        FBundleNode* NextNodeInCurrentBundle;
        union
        {
            FBundleNode* NextBundle;
            int32 Count;
        };
    };

    struct FBundle
    {
        FBundleNode* Head;
        uint32       Count;
    };

    // 空闲的块列表
    struct FFreeBlockList
    {
        bool PushToFront(FMallocBinnedGPU& Allocator, void* InPtr, uint32 InPoolIndex, uint32 InBlockSize, const FArenaParams& LocalArenaParams);
        bool CanPushToFront(uint32 InPoolIndex, uint32 InBlockSize, const FArenaParams& LocalArenaParams);
        void* PopFromFront(FMallocBinnedGPU& Allocator, uint32 InPoolIndex);

        FBundleNode* RecyleFull(FArenaParams& LocalArenaParams, FGlobalRecycler& GGlobalRecycler, uint32 InPoolIndex);
        bool ObtainPartial(FArenaParams& LocalArenaParams, FGlobalRecycler& GGlobalRecycler, uint32 InPoolIndex);
        FBundleNode* PopBundles(uint32 InPoolIndex);
    private:
        FBundle PartialBundle;
        FBundle FullBundle;
    };

    // 逐线程的空闲块列表
    struct FPerThreadFreeBlockLists
    {
        static FPerThreadFreeBlockLists* Get(uint32 BinnedGPUTlsSlot);
        static void SetTLS(FMallocBinnedGPU& Allocator);
        static int64 ClearTLS(FMallocBinnedGPU& Allocator);

        void* Malloc(FMallocBinnedGPU& Allocator, uint32 InPoolIndex);
        bool Free(FMallocBinnedGPU& Allocator, void* InPtr, uint32 InPoolIndex, uint32 InBlockSize, const FArenaParams& LocalArenaParams);
        bool CanFree(uint32 InPoolIndex, uint32 InBlockSize, const FArenaParams& LocalArenaParams);
        FBundleNode* RecycleFullBundle(FArenaParams& LocalArenaParams, FGlobalRecycler& GlobalRecycler, uint32 InPoolIndex);
        bool ObtainRecycledPartial(FArenaParams& LocalArenaParams, FGlobalRecycler& GlobalRecycler, uint32 InPoolIndex);
        FBundleNode* PopBundles(uint32 InPoolIndex);

        int64 AllocatedMemory;
        TArray<FFreeBlockList> FreeLists;
    };

    // 全局回收器
    struct FGlobalRecycler
    {
        void Init(uint32 PoolCount);
        bool PushBundle(uint32 NumCachedBundles, uint32 InPoolIndex, FBundleNode* InBundle);
        FBundleNode* PopBundle(uint32 NumCachedBundles, uint32 InPoolIndex);

    private:
        struct FPaddedBundlePointer
        {
            FBundleNode* FreeBundles[BINNEDGPU_MAX_GMallocBinnedGPUMaxBundlesBeforeRecycle];
        };
        TArray<FPaddedBundlePointer> Bundles;
    };

    uint64 PoolIndexFromPtr(const void* Ptr);
    uint8* PoolBasePtr(uint32 InPoolIndex);
    uint64 PoolIndexFromPtrChecked(const void* Ptr);
    bool IsOSAllocation(const void* Ptr);

    void* BlockOfBlocksPointerFromContainedPtr(const void* Ptr, uint8 PagesPlatformForBlockOfBlocks, uint32& OutBlockOfBlocksIndex);
    uint8* BlockPointerFromIndecies(uint32 InPoolIndex, uint32 BlockOfBlocksIndex, uint32 BlockOfBlocksSize);
    FPoolInfoSmall* PushNewPoolToFront(FMallocBinnedGPU& Allocator, uint32 InBlockSize, uint32 InPoolIndex, uint32& OutBlockOfBlocksIndex);
    FPoolInfoSmall* GetFrontPool(FPoolTable& Table, uint32 InPoolIndex, uint32& OutBlockOfBlocksIndex);
    bool AdjustSmallBlockSizeForAlignment(SIZE_T& InOutSize, uint32 Alignment);

public:
    FArenaParams& GetParams();
    void InitMallocBinned();

    virtual bool IsInternallyThreadSafe() const override;
    virtual void* Malloc(SIZE_T Size, uint32 Alignment) override;
    virtual void* Realloc(void* Ptr, SIZE_T NewSize, uint32 Alignment) override;
    virtual void Free(void* Ptr) override;
    virtual bool GetAllocationSize(void *Ptr, SIZE_T &SizeOut) override;
    virtual SIZE_T QuantizeSize(SIZE_T Count, uint32 Alignment) override;

    virtual bool ValidateHeap() override;
    virtual void Trim(bool bTrimThreadCaches) override;
    virtual void SetupTLSCachesOnCurrentThread() override;
    virtual void ClearAndDisableTLSCachesOnCurrentThread() override;
    virtual const TCHAR* GetDescriptiveName() override;

    void FlushCurrentThreadCache();
    void* MallocExternal(SIZE_T Size, uint32 Alignment);
    void FreeExternal(void *Ptr);
    bool GetAllocationSizeExternal(void* Ptr, SIZE_T& SizeOut);

    MBG_STAT(int64 GetTotalAllocatedSmallPoolMemory();)
    virtual void GetAllocatorStats(FGenericMemoryStats& out_Stats) override;
    virtual void DumpAllocatorStats(class FOutputDevice& Ar) override;

    uint32 BoundSizeToPoolIndex(SIZE_T Size);
    uint32 PoolIndexToBlockSize(uint32 PoolIndex);

    void Commit(uint32 InPoolIndex, void *Ptr, SIZE_T Size);
    void Decommit(uint32 InPoolIndex, void *Ptr, SIZE_T Size);

    (...)

    // Pool tables for different pool sizes
    TArray<FPoolTable> SmallPoolTables;
    uint32 SmallPoolInfosPerPlatformPage;
    PoolHashBucket* HashBuckets;
    PoolHashBucket* HashBucketFreeList;
    uint64 NumLargePoolsPerPage;

    FCriticalSection Mutex;
    FGlobalRecycler GGlobalRecycler;
    FPtrToPoolMapping PtrToPoolMapping;

    FArenaParams ArenaParams;

    TArray<uint16> SmallBlockSizesReversedShifted; // this is reversed to get the smallest elements on our main cache line
    uint32 BinnedGPUTlsSlot;
    uint64 PoolSearchDiv; // if this is zero, the VM turned out to be contiguous anyway so we use a simple subtract and shift
    uint8* HighestPoolBaseVMPtr; // this is a duplicate of PoolBaseVMPtr[ArenaParams.PoolCount - 1]
    FPlatformMemory::FPlatformVirtualMemoryBlock PoolBaseVMBlock;
    TArray<uint8*> PoolBaseVMPtr;
    TArray<FPlatformMemory::FPlatformVirtualMemoryBlock> PoolBaseVMBlocks;
    // Mapping of sizes to small table indices
    TArray<uint8> MemSizeToIndex;

    FCriticalSection FreeBlockListsRegistrationMutex;
    TArray<FPerThreadFreeBlockLists*> RegisteredFreeBlockLists;
    TArray<void*> MallocedPointers;
};

// GPUDefragAllocator.h

// 简单的最适合分配器,无论何时何地都可以拆分和合并。不是线程安全的。使用TMap查找给定指针的内存块(可能与malloc/free主线程冲突)使用单独的链接列表进行自由分配,假设由于合并导致相对较少的自由块.
class FGPUDefragAllocator
{
public:
    typedef TDoubleLinkedList<FAsyncReallocationRequest*> FRequestList;
    typedef TDoubleLinkedList<FAsyncReallocationRequest*>::TDoubleLinkedListNode FRequestNode;

    // 分配器设置的容器
    struct FSettings
    {
        int32        MaxDefragRelocations;
        int32        MaxDefragDownShift;
        int32        OverlappedBandwidthScale;
    };

    enum EMemoryElementType
    {
        MET_Allocated,
        MET_Free,
        MET_Locked,
        MET_Relocating,
        MET_Resizing,
        MET_Resized,
        MET_Max
    };

    struct FMemoryLayoutElement
    {
        int32                Size;
        EMemoryElementType    Type;
    };

    // 分配器重新分配统计信息的容器。
    struct FRelocationStats
    {
        int64 NumBytesRelocated;
        int64 NumBytesDownShifted;
        int64 LargestHoleSize;
        int32 NumRelocations;        
        int32 NumHoles;
        int32 NumLockedChunks;
    };

    // 包含单个分配或空闲块的信息。
    class FMemoryChunk
    {
    public:
        uint8*                    Base;
        int64                    Size;
        int64                    OrigSize;
        bool                    bIsAvailable;    
        int32                    LockCount;
        uint16                    DefragCounter;

        // 允许访问FBestFitAllocator成员,如FirstChunk、FirstFreeChunk和LastChunk。
        FGPUDefragAllocator&    BestFitAllocator;
        FMemoryChunk*            PreviousChunk;
        FMemoryChunk*            NextChunk;
        FMemoryChunk*            PreviousFreeChunk;
        FMemoryChunk*            NextFreeChunk;
        uint32                    SyncIndex;
        int64                    SyncSize;
        void*                    UserPayload;        
        TStatId Stat;
        bool bTail;
    };

    virtual void*   Allocate(int64 AllocationSize, int32 Alignment, TStatId InStat, bool bAllowFailure);    
    virtual void    Free(void* Pointer);
    virtual void    Lock(const void* Pointer);
    virtual void    Unlock(const void* Pointer);
    void*           Reallocate(void* OldBaseAddress, int64 NewSize);
    void            DefragmentMemory(FRelocationStats& Stats);

    void    SetUserPayload(const void* Pointer, void* UserPayload);
    void*    GetUserPayload(const void* Pointer);
    int64    GetAllocatedSize(void* Pointer);
    bool    IsValidPoolMemory(const void* Pointer) const;
    void    DumpAllocs(FOutputDevice& Ar = *GLog);
    int64   GetTotalSize() const;
    int32   GetLargestAvailableAllocation(int32* OutNumFreeChunks = nullptr);
    uint32    GetBlockedCycles() const
    bool    InBenchmarkMode() const
    bool    GetTextureMemoryVisualizeData(FColor* TextureData, int32 SizeX, int32 SizeY, int32 Pitch, const int32 PixelSize);
    void    GetMemoryLayout(TArray<FMemoryLayoutElement>& MemoryLayout);
    virtual int32 Tick(FRelocationStats& Stats, bool bPanicDefrag);
    bool    FinishAllRelocations();
    void    BlockOnAsyncReallocation(FAsyncReallocationRequest* Request);
    void    CancelAsyncReallocation(FAsyncReallocationRequest* Request, const void* CurrentBaseAddress);
    static bool IsAligned(const volatile void* Ptr, const uint32 Alignment);
    int32 GetAllocationAlignment() const;

    (...)
};

下面涉及了动态分辨率:

// DynamicResolutionProxy.h

// 渲染线程代理是动态解析的启发式方法
class FDynamicResolutionHeuristicProxy
{
public:
    static constexpr uint64 kInvalidEntryId = ~uint64(0);

    void Reset_RenderThread();
    uint64 CreateNewPreviousFrameTimings_RenderThread(float GameThreadTimeMs, float RenderThreadTimeMs);
    void CommitPreviousFrameGPUTimings_RenderThread(...);
    void RefreshCurentFrameResolutionFraction_RenderThread();
    float GetResolutionFractionUpperBound() const;

    float QueryCurentFrameResolutionFraction_RenderThread() const;
    float GetResolutionFractionApproximation_GameThread() const;
    static TSharedPtr< class IDynamicResolutionState > CreateDefaultState();

private:
    struct FrameHistoryEntry
    {
        float ResolutionFraction;
        float GameThreadTimeMs;
        float RenderThreadTimeMs;
        float TotalFrameGPUBusyTimeMs;
        float GlobalDynamicResolutionTimeMs;
        bool bGPUTimingsHaveCPUBubbles;
    };

    TArray<FrameHistoryEntry> History;
    int32 PreviousFrameIndex;
    int32 HistorySize;
    int32 NumberOfFramesSinceScreenPercentageChange;
    int32 IgnoreFrameRemainingCount;

    float CurrentFrameResolutionFraction;
    uint64 FrameCounter;
};

下面代码吗涉及了多GPU、GPU掩码等逻辑:

// MultiGPU.h

/** A mask where each bit is a GPU index. Can not be empty so that non SLI platforms can optimize it to be always 1.  */
struct FRHIGPUMask
{
private:
    uint32 GPUMask;

    uint32 ToIndex() const;
    bool HasSingleIndex() const;
    uint32 GetLastIndex() const;
    uint32 GetFirstIndex() const;
    bool Contains(uint32 GPUIndex) const;
    bool ContainsAll(const FRHIGPUMask& Rhs) const;
    bool Intersects(const FRHIGPUMask& Rhs) const;
    bool operator ==(const FRHIGPUMask& Rhs) const;
    bool operator !=(const FRHIGPUMask& Rhs) const;
     uint32 GetNative() const;
    static const FRHIGPUMask GPU0() { return FRHIGPUMask(1); }
    static const FRHIGPUMask All() { return FRHIGPUMask((1 << GNumExplicitGPUsForRendering) - 1); }
    static const FRHIGPUMask FilterGPUsBefore(uint32 GPUIndex) { return FRHIGPUMask(~((1u << GPUIndex) - 1)) & All(); }

    struct FIterator
    {
        explicit FIterator(const uint32 InGPUMask) : GPUMask(InGPUMask), FirstGPUIndexInMask(0);
        explicit FIterator(const FRHIGPUMask& InGPUMask) : FIterator(InGPUMask.GPUMask);
        FIterator& operator++();
        FIterator operator++(int);
    private:
        uint32 GPUMask;
        unsigned long FirstGPUIndexInMask;
    };

    friend FRHIGPUMask::FIterator begin(const FRHIGPUMask& NodeMask);
    friend FRHIGPUMask::FIterator end(const FRHIGPUMask& NodeMask);
};

// GPU掩码实用程序,用于获取有关AFR组和兄弟姐妹的信息。AFR组是一起在同一帧上工作的一组GPU。AFR兄弟是其他组中的GPU,在后续帧上执行相同的工作。例如,在不同帧上渲染相同视图的两个GPU是AFR同级。对于具有2个AFR组的4 GPU设置:每个AFR组有2个GPU。0b1010和0b0101是两个组, 每个GPU有一个同级GPU。0b1100和0b0011是兄弟姐妹。
struct AFRUtils
{
    static inline uint32 GetNumGPUsPerGroup();
    static inline uint32 GetGroupIndex(uint32 GPUIndex);
    static inline uint32 GetIndexWithinGroup(uint32 GPUIndex);
    static inline uint32 GetNextSiblingGPUIndex(uint32 GPUIndex);
    static inline FRHIGPUMask GetNextSiblingGPUMask(FRHIGPUMask InGPUMask);
    static inline uint32 GetPrevSiblingGPUIndex(uint32 GPUIndex);
    static inline FRHIGPUMask GetPrevSiblingGPUMask(FRHIGPUMask InGPUMask);
    static inline FRHIGPUMask GetGPUMaskForGroup(uint32 GPUIndex);
    static inline FRHIGPUMask GetGPUMaskForGroup(FRHIGPUMask InGPUMask);
    static inline FRHIGPUMask GetGPUMaskWithSiblings(uint32 GPUIndex);
    static inline FRHIGPUMask GetGPUMaskWithSiblings(FRHIGPUMask InGPUMask);
#if WITH_MGPU
    static TArray<FRHIGPUMask, TFixedAllocator<MAX_NUM_GPUS>> GroupMasks;
    static TArray<FRHIGPUMask, TFixedAllocator<MAX_NUM_GPUS>> SiblingMasks;
#endif
};

以下类型或接口涉及了GPU厂商、驱动和特性:

// RHIDefinitions.h

enum class EGpuVendorId
{
    Unknown        = -1,
    NotQueried    = 0,

    Amd            = 0x1002,
    ImgTec        = 0x1010,
    Nvidia        = 0x10DE, 
    Arm            = 0x13B5, 
    Broadcom    = 0x14E4,
    Qualcomm    = 0x5143,
    Intel        = 0x8086,
    Apple        = 0x106B,
    Vivante        = 0x7a05,
    VeriSilicon    = 0x1EB1,

    Kazan        = 0x10003,    // VkVendorId
    Codeplay    = 0x10004,    // VkVendorId
    Mesa        = 0x10005,    // VkVendorId
};

inline bool RHIHasTiledGPU(const FStaticShaderPlatform Platform);
inline EGpuVendorId RHIConvertToGpuVendorId(uint32 VendorId);

class FGenericDataDrivenShaderPlatformInfo
{
    FName Language;
    ERHIFeatureLevel::Type MaxFeatureLevel;
    uint32 bIsMobile: 1;
    uint32 bIsMetalMRT: 1;
    uint32 bIsPC: 1;
    uint32 bIsConsole: 1;
    uint32 bIsAndroidOpenGLES: 1;
    uint32 bSupportsDebugViewShaders : 1;
    uint32 bSupportsMobileMultiView: 1;
    uint32 bSupportsArrayTextureCompression : 1;
    uint32 bSupportsDistanceFields: 1; // used for DFShadows and DFAO - since they had the same checks
    uint32 bSupportsDiaphragmDOF: 1;
    uint32 bSupportsRGBColorBuffer: 1;
    uint32 bSupportsCapsuleShadows: 1;
    uint32 bSupportsPercentageCloserShadows : 1;
    uint32 bSupportsVolumetricFog: 1; // also used for FVVoxelization
    uint32 bSupportsIndexBufferUAVs: 1;
    uint32 bSupportsInstancedStereo: 1;
    uint32 bSupportsMultiView: 1;
    uint32 bSupportsMSAA: 1;
    uint32 bSupports4ComponentUAVReadWrite: 1;
    uint32 bSupportsRenderTargetWriteMask: 1;
    uint32 bSupportsRayTracing: 1;
    uint32 bSupportsRayTracingProceduralPrimitive : 1;
    uint32 bSupportsRayTracingIndirectInstanceData : 1; // Whether instance transforms can be copied from the GPU to the TLAS instances buffer
    uint32 bSupportsHighEndRayTracingReflections : 1; // Whether fully-featured RT reflections can be used on the platform (with multi-bounce, translucency, etc.)
    uint32 bSupportsPathTracing : 1; // Whether real-time path tracer is supported on this platform (avoids compiling unnecessary shaders)
    uint32 bSupportsGPUSkinCache: 1;
    uint32 bSupportsGPUScene : 1;
    uint32 bSupportsByteBufferComputeShaders : 1;
    uint32 bSupportsPrimitiveShaders : 1;
    uint32 bSupportsUInt64ImageAtomics : 1;
    uint32 bRequiresVendorExtensionsForAtomics : 1;
    uint32 bSupportsNanite : 1;
    uint32 bSupportsLumenGI : 1;
    uint32 bSupportsSSDIndirect : 1;
    uint32 bSupportsTemporalHistoryUpscale : 1;
    uint32 bSupportsRTIndexFromVS : 1;
    uint32 bSupportsWaveOperations : 1; // Whether HLSL SM6 shader wave intrinsics are supported
    uint32 bSupportsIntrinsicWaveOnce : 1;
    uint32 bSupportsConservativeRasterization : 1;
    uint32 bRequiresExplicit128bitRT : 1;
    uint32 bSupportsGen5TemporalAA : 1;
    uint32 bTargetsTiledGPU: 1;
    uint32 bNeedsOfflineCompiler: 1;
    uint32 bSupportsComputeFramework : 1;
    uint32 bSupportsAnisotropicMaterials : 1;
    uint32 bSupportsDualSourceBlending : 1;
    uint32 bRequiresGeneratePrevTransformBuffer : 1;
    uint32 bRequiresRenderTargetDuringRaster : 1;
    uint32 bRequiresDisableForwardLocalLights : 1;
    uint32 bCompileSignalProcessingPipeline : 1;
    uint32 bSupportsMeshShadersTier0 : 1;
    uint32 bSupportsMeshShadersTier1 : 1;
    uint32 MaxMeshShaderThreadGroupSize : 10;
    uint32 bSupportsPerPixelDBufferMask : 1;
    uint32 bIsHlslcc : 1;
    uint32 bSupportsDxc : 1; // Whether DirectXShaderCompiler (DXC) is supported
    uint32 bSupportsVariableRateShading : 1;
    uint32 NumberOfComputeThreads : 10;
    uint32 bWaterUsesSimpleForwardShading : 1;
    uint32 bNeedsToSwitchVerticalAxisOnMobileOpenGL : 1;
    uint32 bSupportsHairStrandGeometry : 1;
    uint32 bSupportsDOFHybridScattering : 1;
    uint32 bNeedsExtraMobileFrames : 1;
    uint32 bSupportsHZBOcclusion : 1;
    uint32 bSupportsWaterIndirectDraw : 1;
    uint32 bSupportsAsyncPipelineCompilation : 1;
    uint32 bSupportsManualVertexFetch : 1;
    uint32 bRequiresReverseCullingOnMobile : 1;
    uint32 bOverrideFMaterial_NeedsGBufferEnabled : 1;
    uint32 bSupportsMobileDistanceField : 1;
    uint32 bSupportsFFTBloom : 1;
    uint32 bSupportsInlineRayTracing : 1;
    uint32 bSupportsRayTracingShaders : 1;
    uint32 bSupportsVertexShaderLayer : 1;
    uint32 bSupportsVolumeTextureAtomics : 1;
    
private:
    static FGenericDataDrivenShaderPlatformInfo Infos[SP_NumPlatforms];
    
    (...)
}

19.11.4 其它

以下代码提供了部分硬件的信息和操作:

// GenericPlatformMisc.h

struct FGenericPlatformMisc
{
    // 设备/硬件
    static FString GetDeviceId();
    static FString GetUniqueAdvertisingId();
    static void SubmitErrorReport( const TCHAR* InErrorHist, EErrorReportMode::Type InMode );
    static bool IsRemoteSession();
    static bool IsDebuggerPresent();
    static EProcessDiagnosticFlags GetProcessDiagnostics();
    static FString GetCPUVendor();
    static uint32 GetCPUInfo();
    static bool HasNonoptionalCPUFeatures();
    static bool NeedsNonoptionalCPUFeaturesCheck();
    static FString GetCPUBrand();
    static FString GetCPUChipset();
    static FString GetPrimaryGPUBrand();
    static FString GetDeviceMakeAndModel();
    static struct FGPUDriverInfo GetGPUDriverInfo(const FString& DeviceDescription);
    
    static void PrefetchBlock(const void* InPtr, int32 NumBytes = 1);
    static void Prefetch(void const* x, int32 offset = 0);

    static const TCHAR* GetDefaultDeviceProfileName();
    static int GetBatteryLevel();
    static void SetBrightness(float bBright);
    static float GetBrightness();
    static bool SupportsBrightness();
    static bool IsInLowPowerMode();
    static float GetDeviceTemperatureLevel();
    static inline int32 GetMaxRefreshRate();
    static inline int32 GetMaxSyncInterval();
    static bool IsPGOEnabled();
    
    static TArray<uint8> GetSystemFontBytes();
    static bool HasActiveWiFiConnection();
    static ENetworkConnectionType GetNetworkConnectionType();

    static bool HasVariableHardware();
    static bool HasPlatformFeature(const TCHAR* FeatureName);
    static bool IsRunningOnBattery();
    static EDeviceScreenOrientation GetDeviceOrientation();
    static void SetDeviceOrientation(EDeviceScreenOrientation NewDeviceOrientation);
    static int32 GetDeviceVolume();

    // 内存
    static void MemoryBarrier();
    static void SetMemoryWarningHandler(void (* Handler)(const FGenericMemoryWarningContext& Context));
    static bool HasMemoryWarningHandler();

    // I/O
    static void InitTaggedStorage(uint32 NumTags);
    static void ShutdownTaggedStorage();
    static void TagBuffer(const char* Label, uint32 Category, const void* Buffer, size_t BufferSize);
    static bool SetStoredValues(const FString& InStoreId, const FString& InSectionName, const TMap<FString, FString>& InKeyValues);
    static bool SetStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName, const FString& InValue);
    static bool GetStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName, FString& OutValue);
    static bool DeleteStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName);
    static bool DeleteStoredSection(const FString& InStoreId, const FString& InSectionName);
    
    static TArray<FCustomChunk> GetOnDemandChunksForPakchunkIndices(const TArray<int32>& PakchunkIndices);
    static TArray<FCustomChunk> GetAllOnDemandChunks();
    static TArray<FCustomChunk> GetAllLanguageChunks();
    static TArray<FCustomChunk> GetCustomChunksByType(ECustomChunkType DesiredChunkType);
    static void ParseChunkIdPakchunkIndexMapping(TArray<FString> ChunkIndexRedirects, TMap<int32, int32>& OutMapping);
    static int32 GetChunkIDFromPakchunkIndex(int32 PakchunkIndex);
    static int32 GetPakchunkIndexFromPakFile(const FString& InFilename);
    
    static FText GetFileManagerName();
    static bool IsPackagedForDistribution();
    static FString LoadTextFileFromPlatformPackage(const FString& RelativePath);
    static bool FileExistsInPlatformPackage(const FString& RelativePath);
    
    static bool Expand16BitIndicesTo32BitOnLoad();
    static void GetNetworkFileCustomData(TMap<FString,FString>& OutCustomPlatformData);
    static bool SupportsBackbufferSampling();

    (...)
};


// GenericPlatformApplicationMisc.h

struct FGenericPlatformApplicationMisc
{
    // 模块/上下文/设备
    static void LoadPreInitModules();
    static void LoadStartupModules();
    static FOutputDeviceConsole* CreateConsoleOutputDevice();
    static FOutputDeviceError* GetErrorOutputDevice();
    static FFeedbackContext* GetFeedbackContext();
    static bool IsThisApplicationForeground();    
    static void RequestMinimize();
    static bool RequiresVirtualKeyboard();
    static void PumpMessages(bool bFromMainLoop);

    // 屏幕/窗口
    static void PreventScreenSaver();
    static bool IsScreensaverEnabled();
    static bool ControlScreensaver(EScreenSaverAction Action);
    static struct FLinearColor GetScreenPixelColor(const FVector2D& InScreenPos, float InGamma);
    static bool GetWindowTitleMatchingText(const TCHAR* TitleStartsWith, FString& OutTitle);
    static void SetHighDPIMode();
    static float GetDPIScaleFactorAtPoint(float X, float Y);
    static bool IsHighDPIAwarenessEnabled();
    static bool AnchorWindowWindowPositionTopLeft();
    static EScreenPhysicalAccuracy GetPhysicalScreenDensity(int32& OutScreenDensity);
    static EScreenPhysicalAccuracy ComputePhysicalScreenDensity(int32& OutScreenDensity);
    static EScreenPhysicalAccuracy ConvertInchesToPixels(T Inches, T2& OutPixels);
    static EScreenPhysicalAccuracy ConvertPixelsToInches(T Pixels, T2& OutInches);

    // 控制器
    static void SetGamepadsAllowed(bool bAllowed);
    static void SetGamepadsBlockDeviceFeedback(bool bAllowed);
    static void ResetGamepadAssignments();
    static void ResetGamepadAssignmentToController(int32 ControllerId);
    static bool IsControllerAssignedToGamepad(int32 ControllerId);
    static FString GetGamepadControllerName(int32 ControllerId);
    static class UTexture2D* GetGamepadButtonGlyph(...);
    static void EnableMotionData(bool bEnable);
    static bool IsMotionDataEnabled();
    
    (...)
};

// 硬件查询结果
struct FHardwareSurveyResults
{
    static const int32 MaxDisplayCount = 8; 
    static const int32 MaxStringLength = 260;

    TCHAR Platform[MaxStringLength];

    TCHAR OSVersion[MaxStringLength];
    TCHAR OSSubVersion[MaxStringLength];
    uint32 OSBits;
    TCHAR OSLanguage[MaxStringLength];

    TCHAR RenderingAPI[MaxStringLength];
    TCHAR MultimediaAPI_DEPRECATED[MaxStringLength];

    uint32 HardDriveGB;
    uint32 HardDriveFreeMB;
    uint32 MemoryMB;

    float CPUPerformanceIndex;
    float GPUPerformanceIndex;
    float RAMPerformanceIndex;

    uint32 bIsLaptopComputer:1;
    uint32 bIsRemoteSession:1;

    uint32 CPUCount;
    float CPUClockGHz;
    TCHAR CPUBrand[MaxStringLength];
    TCHAR CPUNameString[MaxStringLength];
    uint32 CPUInfo;

    uint32 DisplayCount;
    FHardwareDisplay Displays[MaxDisplayCount];
    FGPUAdpater RHIAdapter;

    uint32 ErrorCount;
    TCHAR LastSurveyError[MaxStringLength];
    TCHAR LastSurveyErrorDetail[MaxStringLength];
    TCHAR LastPerformanceIndexError[MaxStringLength];
    TCHAR LastPerformanceIndexErrorDetail[MaxStringLength];

    FSynthBenchmarkResults SynthBenchmark;
};

// 不同的平台实现获取FHardwareSurveyResults。
struct APPLICATIONCORE_API FGenericPlatformSurvey
{
    static bool GetSurveyResults(FHardwareSurveyResults& OutResults, bool bWait);
};

// HardwareInfo.h

// 硬件信息
struct ENGINE_API FHardwareInfo
{
    static void RegisterHardwareInfo( const FName SpecIdentifier, const FString& HardwareInfo );
    static FString GetHardwareInfo(const FName SpecIdentifier);
    static const FString GetHardwareDetailsString();
};

下面代码提供了性能检测功能:

// GenericPlatformSurvey.h

struct FSynthBenchmarkStat
{
    // 计算线性性能指数(>0),在硬件良好的情况下约为100,但数字可能更高.
    float ComputePerfIndex() const;
    
    void SetMeasuredTime(const FTimeSample& TimeSample, float InConfidence = 90);
    float GetNormalizedTime() const;
    float GetMeasuredTotalTime() const;
    float GetConfidence() const;
    float GetWeight() const;

private:

    // -1(如果未定义),以秒为单位,有助于查看测试是否运行时间过长(某些较慢的GPU可能超时).
    float MeasuredTotalTime;
    // -1(如果未定义),则取决于测试(例如s/g像素),WorkScale被划分.
    float MeasuredNormalizedTime;
    // -1(如果未定义),则为标准GPU上预期的定时值(索引值100,此处为NVidia 670).
    float IndexNormalizedTime;

    // 0..100,100:完全自信
    float Confidence;
    // 1为正常权重,0为无权重,>1为无边界附加权重.
    float Weight;
};

下面的代码提供了磁盘的利用率追踪:

// DiskUtilizationTracker.h

struct FDiskUtilizationTracker
{
    struct UtilizationStats
    {
        double GetOverallThroughputBS() const;
        double GetOverallThroughputMBS() const;
        double GetReadThrougputBS() const;
        double GetReadThrougputMBS() const;
        double GetTotalIdleTimeInSeconds() const;
        double GetTotalIOTimeInSeconds() const;
        double GetPercentTimeIdle() const;

        uint64 TotalReads;
        uint64 TotalSeeks;

        uint64 TotalBytesRead;
        uint64 TotalSeekDistance;

        double TotalIOTime;
        double TotalIdleTime;
    };

    UtilizationStats LongTermStats;
    UtilizationStats ShortTermStats;

    FCriticalSection CriticalSection;

    uint64 IdleStartCycle;
    uint64 ReadStartCycle;
    uint64 InFlightBytes;
    int32  InFlightReads;

    FThreadSafeBool bResetShortTermStats;

    void StartRead(uint64 InReadBytes, uint64 InSeekDistance = 0);
    void FinishRead();

    uint32 GetOutstandingRequests() const;
    const struct UtilizationStats& GetLongTermStats() const;
    const struct UtilizationStats& GetShortTermStats() const;
    void ResetShortTermStats();

private:
    static float GetThrottleRateMBS();
    static constexpr float PrintFrequencySeconds = 0.5f;
};

下面提供了存储IO相关的信息和接口:

// IoStore.h

// I/O存储TOC标头。
struct FIoStoreTocHeader
{
    static constexpr char TocMagicImg[] = "-==--==--==--==-";

    uint8    TocMagic[16];
    uint8    Version;
    uint8    Reserved0 = 0;
    uint16    Reserved1 = 0;
    uint32    TocHeaderSize;
    uint32    TocEntryCount;
    uint32    TocCompressedBlockEntryCount;
    uint32    TocCompressedBlockEntrySize;    // For sanity checking
    uint32    CompressionMethodNameCount;
    uint32    CompressionMethodNameLength;
    uint32    CompressionBlockSize;
    uint32    DirectoryIndexSize;
    uint32    PartitionCount = 0;
    FIoContainerId ContainerId;
    FGuid    EncryptionKeyGuid;
    EIoContainerFlags ContainerFlags;
    uint8    Reserved3 = 0;
    uint16    Reserved4 = 0;
    uint32    TocChunkPerfectHashSeedsCount = 0;
    uint64    PartitionSize = 0;
    uint32    TocChunksWithoutPerfectHashCount = 0;
    uint32    Reserved7 = 0;
    uint64    Reserved8[5] = { 0 };
};

// 组合偏移量和长度。
struct FIoOffsetAndLength
{
public:
    inline uint64 GetOffset() const;
    inline uint64 GetLength() const;
    inline void SetOffset(uint64 Offset);
    inline void SetLength(uint64 Length);

private:
    uint8 OffsetAndLength[5 + 5];
};

// TOC条目元数据
struct FIoStoreTocEntryMeta
{
    FIoChunkHash ChunkHash;
    FIoStoreTocEntryMetaFlags Flags;
};

// 压缩块条目
struct FIoStoreTocCompressedBlockEntry
{
    static constexpr uint32 OffsetBits = 40;
    static constexpr uint64 OffsetMask = (1ull << OffsetBits) - 1ull;
    static constexpr uint32 SizeBits = 24;
    static constexpr uint32 SizeMask = (1 << SizeBits) - 1;
    static constexpr uint32 SizeShift = 8;

    inline uint64 GetOffset() const;
    inline void SetOffset(uint64 InOffset);
    inline uint32 GetCompressedSize() const;
    inline void SetCompressedSize(uint32 InSize);
    inline uint32 GetUncompressedSize() const;
    inline void SetUncompressedSize(uint32 InSize);
    inline uint8 GetCompressionMethodIndex() const;
    inline void SetCompressionMethodIndex(uint8 InIndex);

private:
    uint8 Data[5 + 3 + 3 + 1];
};

// TOC资源读取操作
enum class EIoStoreTocReadOptions
{
    Default,
    ReadDirectoryIndex    = (1 << 0),
    ReadTocMeta            = (1 << 1),
    ReadAll                = ReadDirectoryIndex | ReadTocMeta
};
ENUM_CLASS_FLAGS(EIoStoreTocReadOptions);

// TOC数据容器
struct FIoStoreTocResource
{
    enum { CompressionMethodNameLen = 32 };

    FIoStoreTocHeader Header;
    TArray<FIoChunkId> ChunkIds;
    TArray<FIoOffsetAndLength> ChunkOffsetLengths;
    TArray<int32> ChunkPerfectHashSeeds;
    TArray<int32> ChunkIndicesWithoutPerfectHash;
    TArray<FIoStoreTocCompressedBlockEntry> CompressionBlocks;
    TArray<FName> CompressionMethods;
    FSHAHash SignatureHash;
    TArray<FSHAHash> ChunkBlockSignatures;
    TArray<FIoStoreTocEntryMeta> ChunkMetas;

    TArray<uint8> DirectoryIndexBuffer;

    static FIoStatus Read(const TCHAR* TocFilePath, EIoStoreTocReadOptions ReadOptions, FIoStoreTocResource& OutTocResource);
    static TIoStatusOr<uint64> Write(const TCHAR* TocFilePath, FIoStoreTocResource& TocResource, ...);
    static uint64 HashChunkIdWithSeed(int32 Seed, const FIoChunkId& ChunkId);
};

// 以下是IO的目录、文件、索引相关

// IoDirectoryIndex.h

struct FIoDirectoryIndexEntry
{
    uint32 Name                = ~uint32(0);
    uint32 FirstChildEntry    = ~uint32(0);
    uint32 NextSiblingEntry    = ~uint32(0);
    uint32 FirstFileEntry    = ~uint32(0);
};

struct FIoFileIndexEntry
{
    uint32 Name                = ~uint32(0);
    uint32 NextFileEntry    = ~uint32(0);
    uint32 UserData            = 0;
};

struct FIoDirectoryIndexResource
{
    FString MountPoint;
    TArray<FIoDirectoryIndexEntry> DirectoryEntries;
    TArray<FIoFileIndexEntry> FileEntries;
    TArray<FString> StringTable;
};

class FIoDirectoryIndexWriter
{
public:
    void SetMountPoint(FString InMountPoint);
    uint32 AddFile(const FString& InFileName);
    void SetFileUserData(uint32 InFileEntryIndex, uint32 InUserData);
    void Flush(TArray<uint8>& OutBuffer, FAES::FAESKey InEncryptionKey);

private:
    uint32 GetDirectory(uint32 DirectoryName, uint32 Parent);
    uint32 CreateDirectory(const FStringView& DirectoryName, uint32 Parent);
    uint32 GetNameIndex(const FStringView& String);
    uint32 AddFile(const FStringView& FileName, uint32 Directory);
    static bool IsValid(uint32 Index);

    FString MountPoint;
    TArray<FIoDirectoryIndexEntry> DirectoryEntries;
    TArray<FIoFileIndexEntry> FileEntries;
    TMap<FString, uint32> StringToIndex;
    TArray<FString> Strings;
};

// IoDispatcherPrivate.h

class FIoBatchImpl
{
public:
    TFunction<void()> Callback;
    FEvent* Event = nullptr;
    FGraphEventRef GraphEvent;
    TAtomic<uint32> UnfinishedRequestsCount;
};

下面代码提供了平台无关的亲缘性操作:

// GenericPlatformAffinity.h

class FGenericPlatformAffinity
{
public:
    static const uint64 GetMainGameMask();
    static const uint64 GetRenderingThreadMask();
    static const uint64 GetRHIThreadMask();
    static const uint64 GetRHIFrameOffsetThreadMask();
    static const uint64 GetRTHeartBeatMask();
    static const uint64 GetPoolThreadMask();
    static const uint64 GetTaskGraphThreadMask();
    static const uint64 GetAudioThreadMask();
    static const uint64 GetNoAffinityMask();
    static const uint64 GetTaskGraphBackgroundTaskMask();
    static const uint64 GetTaskGraphHighPriorityTaskMask();
    static const uint64 GetAsyncLoadingThreadMask();
    static const uint64 GetIoDispatcherThreadMask();
    static const uint64 GetTraceThreadMask();

    static EThreadPriority GetRenderingThreadPriority();
    static EThreadCreateFlags GetRenderingThreadFlags();
    static EThreadPriority GetRHIThreadPriority();
    static EThreadPriority GetGameThreadPriority();
    static EThreadCreateFlags GetRHIThreadFlags();
    static EThreadPriority GetTaskThreadPriority();
    static EThreadPriority GetTaskBPThreadPriority();
};

下面定义了许多硬件、ISA、操作系统、编译器、图形API及它们的特性相关的宏:

// Platform.h

PLATFORM_WINDOWS
PLATFORM_XBOXONE
PLATFORM_MAC
PLATFORM_MAC_X86
PLATFORM_MAC_ARM64
PLATFORM_PS4
PLATFORM_IOS
PLATFORM_TVOS
PLATFORM_ANDROID
PLATFORM_ANDROID_ARM
PLATFORM_ANDROID_ARM64
PLATFORM_ANDROID_X86
PLATFORM_ANDROID_X64
PLATFORM_APPLE
PLATFORM_LINUX
PLATFORM_LINUXARM64
PLATFORM_SWITCH
PLATFORM_FREEBSD
PLATFORM_UNIX
PLATFORM_MICROSOFT
PLATFORM_HOLOLENS
    
PLATFORM_CPU_X86_FAMILY
PLATFORM_CPU_ARM_FAMILY
    
PLATFORM_COMPILER_CLANG
PLATFORM_DESKTOP
PLATFORM_64BITS
PLATFORM_LITTLE_ENDIAN

PLATFORM_SUPPORTS_UNALIGNED_LOADS
PLATFORM_EXCEPTIONS_DISABLED
PLATFORM_SUPPORTS_PRAGMA_PACK
PLATFORM_ENABLE_VECTORINTRINSICS
    
PLATFORM_MAYBE_HAS_SSE4_1
PLATFORM_MAYBE_HAS_AVX
PLATFORM_ALWAYS_HAS_AVX_2
PLATFORM_ALWAYS_HAS_FMA3
    
PLATFORM_HAS_CPUID
PLATFORM_ENABLE_POPCNT_INTRINSIC
PLATFORM_ENABLE_VECTORINTRINSICS_NEON
PLATFORM_USE_LS_SPEC_FOR_WIDECHAR
PLATFORM_USE_SYSTEM_VSWPRINTF
    
PLATFORM_COMPILER_DISTINGUISHES_INT_AND_LONG
PLATFORM_COMPILER_HAS_GENERIC_KEYWORD
PLATFORM_COMPILER_HAS_DEFAULTED_FUNCTIONS
PLATFORM_COMPILER_COMMON_LANGUAGE_RUNTIME_COMPILATION
PLATFORM_COMPILER_HAS_TCHAR_WMAIN
PLATFORM_COMPILER_HAS_DECLTYPE_AUTO
PLATFORM_COMPILER_HAS_IF_CONSTEXPR
PLATFORM_COMPILER_HAS_FOLD_EXPRESSIONS
    
PLATFORM_TCHAR_IS_4_BYTES
PLATFORM_WCHAR_IS_4_BYTES
PLATFORM_TCHAR_IS_CHAR16
PLATFORM_UCS2CHAR_IS_UTF16CHAR
    
PLATFORM_HAS_BSD_TIME
PLATFORM_HAS_BSD_THREAD_CPUTIME
PLATFORM_HAS_BSD_SOCKETS
PLATFORM_HAS_BSD_IPV6_SOCKETS
PLATFORM_HAS_BSD_SOCKET_FEATURE_IOCTL
PLATFORM_HAS_BSD_SOCKET_FEATURE_SELECT
PLATFORM_HAS_BSD_SOCKET_FEATURE_GETHOSTNAME
    
PLATFORM_SUPPORTS_UDP_MULTICAST_GROUP
PLATFORM_USE_PTHREADS
PLATFORM_MAX_FILEPATH_LENGTH_DEPRECATED
PLATFORM_SUPPORTS_TEXTURE_STREAMING
    
PLATFORM_SUPPORTS_VIRTUAL_TEXTURES
PLATFORM_SUPPORTS_VARIABLE_RATE_SHADING
PLATFORM_REQUIRES_FILESERVER
    
PLATFORM_SUPPORTS_MULTITHREADED_GC
PLATFORM_SUPPORTS_TBB
PLATFORM_USES_FIXED_RHI_CLASS
PLATFORM_HAS_TOUCH_MAIN_SCREEN
PLATFORM_SUPPORTS_STACK_SYMBOLS
PLATFORM_HAS_128BIT_ATOMICS
PLATFORM_USE_FULL_TASK_GRAPH
    
PLATFORM_HAS_FPlatformVirtualMemoryBlock
PLATFORM_USE_FULL_TASK_GRAPH
PLATFORM_IS_ANSI_MALLOC_THREADSAFE
    
PLATFORM_SUPPORTS_GPU_FRAMETIME_WITHOUT_MGPU
    
(...)

以下提供了平台属性、输出设备、堆栈遍历等相关的操作:

// 输出设备在大多数平台的通用实现
struct FGenericPlatformOutputDevices
{
    static void                            SetupOutputDevices();
    static FString                        GetAbsoluteLogFilename();
    static FOutputDevice*                GetLog();
    static void                            GetPerChannelFileOverrides(TArray<FOutputDevice*>& OutputDevices);
    static FOutputDevice*                GetEventLog();
    static FOutputDeviceError*            GetError();
    static FFeedbackContext*            GetFeedbackContext();

protected:
    static void ResetCachedAbsoluteFilename();

private:
    static constexpr SIZE_T AbsoluteFileNameMaxLength = 1024;
    static TCHAR CachedAbsoluteFilename[AbsoluteFileNameMaxLength];

    static void OnLogFileOpened(const TCHAR* Pathname);
    static FCriticalSection LogFilenameLock;
};

// 平台属性
struct FGenericPlatformProperties
{
    static const char* GetPhysicsFormat();
    static bool HasEditorOnlyData();
    static const char* IniPlatformName();
    static bool IsGameOnly();
    static bool IsServerOnly();
    static bool IsClientOnly();
    static bool IsMonolithicBuild();
    static bool IsProgram();
    static bool IsLittleEndian();
    static const char* PlatformName();
    static bool RequiresCookedData();
    static bool HasSecurePackageFormat();
    static bool RequiresUserCredentials();
    static bool SupportsBuildTarget( EBuildTargetType TargetType );
    static bool SupportsAutoSDK();
    static bool SupportsGrayscaleSRGB();
    static bool SupportsMultipleGameInstances();
    static bool SupportsWindowedMode();
    static bool AllowsFramerateSmoothing();
    static bool SupportsAudioStreaming();
    static bool SupportsHighQualityLightmaps();
    static bool SupportsLowQualityLightmaps();
    static bool SupportsDistanceFieldShadows();
    static bool SupportsDistanceFieldAO();
    static bool SupportsTextureStreaming();
    static bool SupportsMeshLODStreaming();
    static bool SupportsMemoryMappedFiles();
    static bool SupportsMemoryMappedAudio();
    static bool SupportsMemoryMappedAnimation();
    static int64 GetMemoryMappingAlignment();
    static bool SupportsVirtualTextureStreaming();
    static bool SupportsLumenGI();
    static bool SupportsHardwareLZDecompression();
    static bool HasFixedResolution();
    static bool SupportsMinimize();
    static bool SupportsQuit();
    static bool AllowsCallStackDumpDuringAssert();
    static const char* GetZlibReplacementFormat();
};

// 用于捕获加载pdb所需的所有模块信息。
struct FStackWalkModuleInfo
{
    uint64 BaseOfImage;
    uint32 ImageSize;
    uint32 TimeDateStamp;
    TCHAR ModuleName[32];
    TCHAR ImageName[256];
    TCHAR LoadedImageName[256];
    uint32 PdbSig;
    uint32 PdbAge;
    struct
    {
        unsigned long  Data1;
        unsigned short Data2;
        unsigned short Data3;
        unsigned char  Data4[8];
    } PdbSig70;
};

// 与程序计数器相关的符号信息。ANSI版本。
struct FProgramCounterSymbolInfo final
{
    enum
    {
        /** Length of the string used to store the symbol's names, including the trailing character. */
        MAX_NAME_LENGTH = 1024,
    };

    ANSICHAR    ModuleName[MAX_NAME_LENGTH];
    ANSICHAR    FunctionName[MAX_NAME_LENGTH];
    ANSICHAR    Filename[MAX_NAME_LENGTH];
    int32        LineNumber;
    int32        SymbolDisplacement;
    uint64        OffsetInModule;
    uint64        ProgramCounter;
};

// 程序计数器符号信息
struct FProgramCounterSymbolInfoEx
{
    FString    ModuleName;
    FString    FunctionName;
    FString    Filename;
    uint32    LineNumber;
    uint64    SymbolDisplacement;
    uint64    OffsetInModule;
    uint64    ProgramCounter;
};

// 堆栈遍历
struct FGenericPlatformStackWalk
{
    typedef FGenericPlatformStackWalk Base;

    struct EStackWalkFlags
    {
        enum
        {
            AccurateStackWalk                =    0,
            FastStackWalk                    =    (1 << 0),
            FlagsUsedWhenHandlingEnsure        =    (FastStackWalk)
        };
    };

    static void Init();
    static bool InitStackWalking()
    static bool InitStackWalkingForProcess(const FProcHandle& Process);
    static bool ProgramCounterToHumanReadableString( int32 CurrentCallDepth, uint64 ProgramCounter, ...);
    static bool SymbolInfoToHumanReadableString( const FProgramCounterSymbolInfo& SymbolInfo, ... );
    static bool SymbolInfoToHumanReadableStringEx( const FProgramCounterSymbolInfoEx& SymbolInfo, FString& out_HumanReadableString );
    static void ProgramCounterToSymbolInfo( uint64 ProgramCounter, FProgramCounterSymbolInfo& out_SymbolInfo);
    static void ProgramCounterToSymbolInfoEx( uint64 ProgramCounter, FProgramCounterSymbolInfoEx& out_SymbolInfo);
    static uint32 CaptureStackBackTrace( uint64* BackTrace, uint32 MaxDepth, void* Context = nullptr );
    static uint32 CaptureThreadStackBackTrace(uint64 ThreadId, uint64* BackTrace, uint32 MaxDepth, void* Context = nullptr);

    static void StackWalkAndDump(ANSICHAR* HumanReadableString, SIZE_T HumanReadableStringSize, ... );
    static void StackWalkAndDump(ANSICHAR* HumanReadableString, SIZE_T HumanReadableStringSize, ...);
    static TArray<FProgramCounterSymbolInfo> GetStack(int32 IgnoreCount, int32 MaxDepth = 100, ...);
    static void ThreadStackWalkAndDump(ANSICHAR* HumanReadableString, SIZE_T HumanReadableStringSize, ...);
    static void StackWalkAndDumpEx(ANSICHAR* HumanReadableString, SIZE_T HumanReadableStringSize, ...);
    static void StackWalkAndDumpEx(ANSICHAR* HumanReadableString, SIZE_T HumanReadableStringSize, ...);

    static int32 GetProcessModuleCount();
    static int32 GetProcessModuleSignatures(FStackWalkModuleInfo *ModuleSignatures, const int32 ModuleSignaturesSize);
    static TMap<FName, FString> GetSymbolMetaData();

protected:
    static bool WantsDetailedCallstacksInNonMonolithicBuilds();
};

19.12 本篇总结

本篇主要阐述了计算机硬件体系的由底向上的只是,以及UE对硬件层的抽象和封装,使得读者对此模块有着大致的理解,至于更多技术细节和原理,需要读者自己去研读UE源码发掘。



特别说明


参考文献

posted @ 2022-12-11 02:27  0向往0  阅读(2595)  评论(3编辑  收藏  举报