当前位置: 首页 > news >正文

1 Studying《Computer Architecture A Quantitative Approach》1-4

目录

Preface

1 Fundamentals of Quantitative Design and Analysis

1.1 Introduction

1.2 Classes of Computers

1.3 Defining Computer Architecture

1.4 Trends in Technology

1.5 Trends in Power and Energy in Integrated Circuits

1.6 Trends in Cost

1.7 Dependability

1.8 Measuring, Reporting, and Summarizing Performance

1.9 Quantitative Principles of Computer Design

1.10 Putting It All Together: Performance, Price, and Power

1.11 Fallacies and Pitfalls

1.12 Concluding Remarks

1.13 Historical Perspectives and References

2 Memory Hierarchy Design

2.1 Introduction

2.2 Memory Technology and Optimizations

2.3 Ten Advanced Optimizations of Cache Performance

2.4 Virtual Memory and Virtual Machines

2.5 跨越性问题:内存层次结构的设计  

2.6 整合内容:ARM Cortex-A53 和 Intel Core i7 6700 的内存层次结构

2.7 Fallacies and Pitfalls

2.8 Concluding Remarks: Looking Ahead

3 Instruction-Level Parallelism and Its Exploitation

3.1 Instruction-Level Parallelism: Concepts and Challenges

3.2 Basic Compiler Techniques for Exposing ILP

3.3 Reducing Branch Costs With Advanced Branch Prediction

3.4 Overcoming Data Hazards With Dynamic Scheduling

3.5 Dynamic Scheduling: Examples and the Algorithm

3.6 Hardware-Based Speculation

3.7 Exploiting ILP Using Multiple Issue and Static Scheduling

3.8 Exploiting ILP Using Dynamic Scheduling, Multiple Issue, and Speculation

3.9 Advanced Techniques for Instruction Delivery and Speculation

3.10 Cross-Cutting Issues

3.11 Multithreading: Exploiting Thread-Level Parallelism to Improve Uniprocessor Throughput

3.12 Putting It All Together: The Intel Core i7 6700 and ARM Cortex-A53

3.13 Fallacies and Pitfalls

3.14 Concluding Remarks: What’s Ahead?

4 Data-Level Parallelism in Vector, SIMD, and GPU Architectures

4.1 Introduction

4.2 Vector Architecture

4.3 SIMD Instruction Set Extensions for Multimedia

4.4 Graphics Processing Units 

4.5 Detecting and Enhancing Loop-Level Parallelism

4.6 Cross-Cutting Issues

4.7 Putting It All Together: Embedded Versus Server GPUs and Tesla Versus Core i7

4.8 Fallacies and Pitfalls

4.9 Concluding Remarks


Preface

Why We Wrote This Book
通过本书的六个版本,我们的目标一直是描述未来技术发展的基本原理。我们对计算机架构的机会仍然充满激情,并重申我们在第一版中对这一领域的看法:“这不是一种永远无法实现的纸上谈兵的枯燥科学。不!这是一个充满智慧兴趣的学科,需要在市场力量、成本、性能和功耗之间找到平衡,从而带来辉煌的失败和一些显著的成功。”
我们编写第一本书的主要目标是改变人们学习和思考计算机架构的方式。我们认为这一目标仍然有效且重要。这个领域每天都在变化,必须通过真实的例子和实际计算机上的测量来学习,而不仅仅是作为一系列定义和设计的集合,这些定义和设计永远不会被实现。我们热烈欢迎过去和现在与我们同行的人。不论如何,我们都承诺以相同的定量方法分析实际系统。
与早期版本一样,我们努力制作一个对专业工程师和架构师以及从事高级计算机架构和设计课程的人员都同样重要的新版本。与第一版一样,本版专注于新平台——个人移动设备和大规模仓库计算机——以及新架构——特别是领域特定架构。与其前身一样,本版旨在通过强调成本、性能和能源权衡及良好的工程设计,揭示计算机架构的奥秘。我们相信,该领域已经继续成熟,并朝着长期建立的科学和工程学科的严格定量基础迈进。
This Edition
摩尔定律和丹纳德缩放的终结对计算机架构产生的影响与向多核处理器的转变一样深远。我们继续关注计算规模的极端情况,将个人移动设备(如手机和平板电脑)作为客户端,将大规模仓库计算机提供云计算服务作为服务器。同时,我们保持对各种形式的并行性的关注:第1章和第4章中的数据级并行性(DLP),第3章中的指令级并行性(ILP),第5章中的线程级并行性,以及第6章中的请求级并行性(RLP)。
本版中最显著的变化是从MIPS指令集转向RISC-V指令集。我们怀疑这个现代、模块化、开放的指令集可能在信息技术行业中成为一个重要力量,甚至可能在计算机架构领域中与Linux在操作系统中的重要性相媲美。
本版中新加入的是第7章,介绍了领域特定架构,并提供了若干来自工业界的具体例子。
如前所述,本书的前三个附录介绍了RISC-V指令集、内存层次结构和流水线基础,适合没有读过《计算机组织与设计》的读者。为了控制成本但仍提供对部分读者有兴趣的补充材料,网上可获得九个附录,链接为https://www.elsevier.com/books-and-journals/book-companion/9780128119051。这些附录的页数超过了本书本身!
本版继续采用现实世界的例子来演示这些概念,且“综合应用”部分是全新的。本版的“综合应用”部分包括ARM Cortex A8处理器、Intel Core i7处理器、NVIDIA GTX-280和GTX-480 GPU以及谷歌大规模仓库计算机的流水线组织和内存层次结构。
Topic Selection and Organization
如前所述,我们在选择主题时采取了保守的态度,因为在这一领域中存在许多更有趣的想法,而这些想法在基本原理的处理范围内难以合理覆盖。我们避免了对读者可能遇到的每种架构进行全面调查。相反,我们的展示重点放在任何新机器中可能会出现的核心概念上。关键标准仍然是选择那些已经经过充分研究并成功应用到实际中的想法,以便可以用量化的方式进行讨论。
我们的意图始终是专注于那些从其他来源无法获得的材料,因此我们继续在可能的情况下强调先进内容。实际上,这里有几个系统的描述在文献中找不到。(对于仅对计算机架构的基本介绍感兴趣的读者,建议阅读《计算机组织与设计:硬件/软件接口》。)
An Overview of the Content
第1章包括能量、静态功率、动态功率、集成电路成本、可靠性和可用性的公式。(这些公式也可以在前封面内找到。)我们希望这些主题可以在本书的其余部分中使用。除了经典的计算机设计和性能测量的定量原则外,它还展示了通用微处理器性能提升的放缓,这也为特定领域架构提供了灵感。我们认为,指令集架构在今天的作用已不如1990年那么重要,因此我们将这部分内容移到了附录A,现在使用的是RISC-V架构。(快速回顾,RISC-V ISA的摘要可以在背面封底找到。)对于指令集架构爱好者,本版附录K进行了修订,涵盖了8种RISC架构(5种用于桌面和服务器,3种用于嵌入式),以及80!86、DEC VAX和IBM 360/370。
接着,我们在第2章讨论内存层次结构,因为将成本-性能-能量原则应用于这些内容很容易,而且内存是后续章节的关键资源。与以前的版本一样,附录B包含了缓存原理的入门回顾,以备需要时使用。第2章讨论了10种缓存的高级优化。章节中包括虚拟机,这些虚拟机在保护、软件管理和硬件管理方面具有优势,并在云计算中扮演着重要角色。除了涵盖SRAM和DRAM技术外,本章还包括有关Flash内存和堆叠芯片封装用于扩展内存层次的新材料。PIAT示例包括用于PMD的ARM Cortex A8和用于服务器的Intel Core i7。
第3章探讨了高性能处理器中指令级并行性的利用,包括超标量执行、分支预测(包括新的标记混合预测器)、猜测、动态调度和同时多线程。如前所述,附录C是一个关于流水线的回顾,以备需要。第3章还概述了ILP的限制。与第2章一样,PIAT示例再次是ARM Cortex A8和Intel Core i7。尽管第三版包含了大量关于Itanium和VLIW的内容,这些材料现在被移至附录H,表明我们认为这些架构没有实现早期的承诺。
随着游戏和视频处理等多媒体应用的重要性增加,能够利用数据级并行性的架构也变得愈加重要。特别是对使用图形处理单元(GPU)进行计算的兴趣日益增长,但很少有架构师真正理解GPU的工作原理。我们决定编写一个新章节,主要是为了揭示这种新型计算机架构。第4章从向量架构的介绍开始,这为解释多媒体SIMD指令集扩展和GPU奠定了基础。(附录G对向量架构进行了更深入的探讨。)本章介绍了Roofline性能模型,并用它来比较Intel Core i7和NVIDIA GTX 280及GTX 480 GPU。本章还描述了用于PMD的Tegra 2 GPU。
第5章描述了多核处理器。它探讨了对称和分布式内存架构,考察了组织原理和性能。本章的主要新增内容包括对多核组织的更多比较,涵盖了多核多级缓存的组织、多核一致性方案和片上多核互连。接下来是同步和内存一致性模型的话题。示例是Intel Core i7。对互连网络感兴趣的读者应阅读附录F,对大规模多处理器系统和科学应用感兴趣的读者应阅读附录I。
第6章描述了仓库级计算机(WSC)。基于来自Google和Amazon Web Services工程师的帮助,本章进行了广泛修订。该章整合了有关WSC设计、成本和性能的细节,这些细节很少被架构师知晓。它以流行的MapReduce编程模型作为起点,然后描述WSC的架构和物理实现,包括成本。成本信息使我们能够解释云计算的兴起,即使用云中的WSC进行计算可能比在本地数据中心更便宜。PIAT示例描述了一个Google WSC,包括首次在本书中发布的信息。
新第7章阐述了领域特定架构(DSAs)的必要性。它基于四个DSA示例,提出了DSA的指导原则。每个DSA对应于已在商业环境中部署的芯片。我们还解释了为何我们期望通过DSA实现计算机架构的复兴,因为通用微处理器的单线程性能已停滞不前。
这将我们引导到附录A至M。附录A涵盖了ISA的基本原则,包括RISC-V,附录K描述了RISC-V、ARM、MIPS、Power和SPARC的64位版本及其多媒体扩展。此外,还包括一些经典架构(80x86、VAX和IBM 360/370)以及流行的嵌入式指令集(Thumb-2、microMIPS和RISC-V C)。附录H与此相关,它涵盖了VLIW ISA的架构和编译器。
如前所述,附录B和附录C是有关基本缓存和流水线概念的教程。相对较新接触缓存的读者应在阅读第2章之前阅读附录B,而新接触流水线的读者应在阅读第3章之前阅读附录C。
附录D《存储系统》扩展了对可靠性和可用性的讨论,包括对RAID的教程,描述了RAID 6方案,并提供了实际系统的故障统计数据。它继续介绍排队理论和I/O性能基准。我们评估了一个真实集群的成本、性能和可靠性:互联网档案馆。“综合应用”示例是NetApp FAS6000存储器。
附录E由Thomas M. Conte编写,将嵌入式材料集中在一个地方。
附录F由Timothy M. Pinkston和José Duato修订,讨论了互连网络。附录G由Krste Asanović原始编写,包含了向量处理器的描述。我们认为这两个附录是我们所知的在每个主题上的最佳材料之一。
附录H描述了VLIW和EPIC架构,即Itanium的架构。附录I讨论了大规模共享内存并行处理应用和一致性协议。David Goldberg编写的附录J介绍了计算机算术。Abhishek Bhattacharjee编写的附录L探讨了内存管理的高级技术,重点是虚拟机支持和大地址空间的地址转换设计。随着云处理器的发展,这些架构改进变得更加重要。附录M将每章的“历史视角和参考文献”汇集到一个附录中,旨在给予每章思想的适当认可,并提供围绕发明的历史背景。我们认为这呈现了计算机设计的人类戏剧。它还提供了建筑学生可能感兴趣的参考资料。如果有时间,我们建议阅读这些部分提到的一些经典论文,直接听取创作者的想法既愉快又具有教育意义。“历史视角”是以前版本中最受欢迎的部分之一。
Navigating the Text
没有单一的最佳顺序来阅读这些章节和附录,但所有读者应从第1章开始。如果你不想阅读所有内容,可以参考以下建议的顺序:
- 内存层次结构:附录B,第2章,以及附录D和M。
- 指令级并行:附录C,第3章,以及附录H。
- 数据级并行:第4、6和7章,附录G。
- 线程级并行:第5章,附录F和I。
- 请求级并行:第6章。
- ISA:附录A和K。
附录E可以随时阅读,但在阅读ISA和缓存相关章节后可能效果最佳。附录J可以在涉及算术时阅读。完成每章后,建议阅读附录M的相关部分。
Chapter Structure
我们选择的材料被纳入了一个一致的框架,每章都遵循这一框架。我们首先解释章节中的基本概念。接着是“交叉问题”部分,这一部分展示了本章讨论的概念如何与其他章节的内容互动。之后是“整合总结”部分,这一部分通过展示这些概念在实际机器中的应用来将它们结合在一起。
接下来的部分是“谬误与陷阱”,它让读者从他人的错误中学习。我们展示了常见的误解和即使知道它们存在也难以避免的架构陷阱。“谬误与陷阱”部分是本书最受欢迎的部分之一。每章最后都有一个“总结”部分。
Case Studies With Exercises
每章末尾都有案例研究和配套练习。这些案例研究由业界和学术界的专家撰写,探讨了章节中的关键概念,并通过逐渐增加难度的练习来验证理解。教师应该发现这些案例研究足够详细且具有深度,可以帮助他们创建自己的额外练习。
每个练习旁边的括号(<chapter.section>)指示了完成该练习所需的主要相关文本部分。我们希望这能帮助读者避免那些尚未阅读相应章节的练习,同时提供复习的来源。练习根据所需时间进行了评级,以便读者了解完成练习所需的时间:
[10] 少于 5 分钟(阅读和理解)
[15] 5–15 分钟完成完整答案
[20] 15–20 分钟完成完整答案
[25] 1 小时完成完整书面答案
[30] 短期编程项目:少于 1 个完整工作日的编程
[40] 大型编程项目:2 周的时间
[讨论] 供与他人讨论的主题
注册于 textbooks.elsevier.com 的教师可以获取案例研究和练习的解决方案。
Supplemental Materials
各种资源可在线访问,网址为 https://www.elsevier.com/books/computer-architecture/hennessy/978-0-12-811905-1,包括以下内容:
- 参考附录,部分由主题专家撰写,涵盖多种高级主题
- 探讨每章中关键思想发展的历史视角材料
- 教师用的 PowerPoint 幻灯片
- 书中的图形,以 PDF、EPS 和 PPT 格式提供
- 相关网页材料的链接
- 勘误表
新的材料和其他资源的链接将定期更新。

1 Fundamentals of Quantitative Design and Analysis

 iPod、电话、互联网移动通讯器……这不是三个独立的设备!我们称之为 iPhone!今天,苹果将重新定义电话。它来了。  
—— 史蒂夫·乔布斯,2007年1月9日
新的信息和通讯技术,尤其是高速互联网,正在改变公司经营方式,改造公共服务交付,并使创新民主化。高速互联网连接增加10%,经济增长将增加1.3%。  
—— 世界银行,2009年7月28日


1.1 Introduction

计算机技术在自从第一个通用电子计算机问世的约70年间取得了令人难以置信的进步。如今,花费不到500美元就能购买一部性能相当于1993年花费5000万美元购买的世界最快计算机的手机。这种快速改进既得益于计算机建设技术的进步,也得益于计算机设计的创新。
尽管技术改进历史上一直较为稳定,但由于更好的计算机架构带来的进步则不那么一致。在电子计算机的头25年里,这两种力量都作出了重要贡献,提供了每年约25%的性能提升。1970年代末,微处理器的出现改变了这一局面。微处理器能够借助集成电路技术的进步,导致了更高的性能提升率——每年约35%的增长。
这一增长率,加上大规模生产的微处理器带来的成本优势,导致计算机行业中越来越多的部分基于微处理器。此外,计算机市场的两个重大变化使得新的架构比以往更容易取得商业成功。首先,汇编语言编程的虚拟消除减少了对目标代码兼容性的需求。其次,标准化的、供应商独立的操作系统的创建,如UNIX及其克隆Linux,降低了推出新架构的成本和风险。
这些变化使得在1980年代初期成功开发出一系列新的架构成为可能,这些架构的指令集较为简化,被称为RISC(精简指令集计算机)架构。基于RISC的计算机将设计师的关注集中在两种关键的性能技术上:利用指令级并行性(最初通过流水线技术,后来通过多指令发射)和使用缓存(最初采用简单形式,后来采用更复杂的组织和优化)。
基于RISC的计算机提高了性能标准,迫使先前的架构要么跟上要么被淘汰。Digital Equipment 的Vax架构无法跟上,因此被RISC架构取代。英特尔迎接了这一挑战,主要通过将80x86指令内部转换为类似RISC的指令,从而能够采纳许多最初由RISC设计开创的创新。随着1990年代末晶体管数量的激增,转换复杂x86架构的硬件开销变得微不足道。在低端应用中,如手机,x86转换开销在功耗和硅面积上的成本促使RISC架构ARM成为主流。
图1.1显示,架构和组织上的增强使得性能在17年内以每年超过50%的速度持续增长——这一增长率在计算机行业中前所未有。
这种在20世纪的剧烈增长率产生了四重影响。首先,它显著增强了计算机用户的能力。在许多应用中,最高性能的微处理器超越了不到20年前的超级计算机。

图1.1 处理器性能在40年间的增长。这张图展示了与VAX 11/780相比的程序性能,性能通过SPEC整数基准测试来衡量(见第1.8节)。在1980年代中期之前,处理器性能的增长主要受技术驱动,年均增长约22%,即每3.5年性能翻倍。从1986年开始,增长率提高到约52%,即每2年翻倍,这归因于以RISC架构为代表的更先进的架构和组织理念。到2003年,这种增长使得性能与22%增长率下的性能相比,差异大约达到25倍。由于Dennard缩放的终结和可用的指令级并行性的限制,2003年之后单处理器性能增长减缓至每年23%,即每3.5年翻倍。(自2007年以来最快的SPECintbase性能已开启了自动并行化,因此单处理器速度较难衡量。这些结果限于通常每芯片四核的单芯片系统。)从2011年到2015年,年增长率低于12%,即每8年翻倍,部分原因是Amdahl定律的并行性限制。自2015年以来,随着摩尔定律的终结,年增长率仅为3.5%,即每20年翻倍!浮点计算的性能遵循相同的趋势,但在每个阴影区域通常有1%到2%的年增长差异。第27页的图1.11展示了这些时期的时钟频率改进。由于SPEC测试随着时间的推移有所变化,新机器的性能通过一个缩放因子来估算,该因子与不同版本的SPEC(SPEC89、SPEC92、SPEC95、SPEC2000和SPEC2006)性能相关。目前SPEC2017的结果还不够多,无法绘制。
其次,成本性能的显著提升催生了新类型的计算机。个人计算机和工作站在1980年代随着微处理器的问世而出现。过去十年间,智能手机和平板电脑的兴起,使许多人将其作为主要的计算平台,取代了PC。这些移动客户端设备越来越多地使用互联网访问包含10万台服务器的数据中心,这些数据中心被设计成像单一庞大计算机一样运行。
第三,摩尔定律预测的半导体制造进步导致了微处理器计算机在整个计算机设计领域的主导地位。传统上由现成逻辑或门阵列构建的迷你计算机被使用微处理器构建的服务器所取代。即使是主机计算机和高性能超级计算机,也都是微处理器的集合。
这些硬件创新带来了计算机设计的复兴,强调了架构创新和技术进步的有效利用。这种增长率的加成效应使得到2003年,高性能微处理器的速度比仅依赖于技术(包括改进的电路设计)所能获得的速度快7.5倍,即52%每年相比于35%每年。
这种硬件复兴影响了软件开发。从1978年至今性能提升了50,000倍,使现代程序员能够用生产力换取性能。如今,很多编程工作使用像Java和Scala这样的托管编程语言,取代了以性能为导向的语言如C和C++。此外,更具生产力的脚本语言如JavaScript和Python,以及编程框架如AngularJS和Django,也越来越受欢迎。为了保持生产力并尽量弥补性能差距,采用即时编译器和基于跟踪的编译的解释器正在取代传统的编译器和链接器。软件部署也在改变,互联网服务的软件(SaaS)取代了需要安装和在本地计算机上运行的传统包装软件。
应用程序的性质也在变化。语音、声音、图像和视频变得越来越重要,以及对用户体验至关重要的可预测响应时间。一个激动人心的例子是Google翻译。该应用程序允许你将手机对准一个物体,图像通过无线网络传输到数据中心,数据中心识别照片中的文字并将其翻译成你的母语。你也可以对着它说话,它会将你说的话翻译成另一种语言的音频输出。它可以翻译90种语言的文本和15种语言的语音。
然而,图1.1也显示了这一17年的硬件复兴已经结束。根本原因是曾经存在几十年的半导体工艺的两个特性不再适用。
1974年,罗伯特·丹纳德观察到,即使在硅片的面积相同的情况下,随着晶体管数量的增加,功率密度保持不变,这是因为每个晶体管的尺寸变小。令人惊讶的是,晶体管可以更快但使用更少的功率。由于电流和电压无法继续下降而仍维持集成电路的可靠性,丹纳德缩放在2004年左右结束。这一变化迫使微处理器行业转向使用多个高效的处理器或核心,而不是单个低效的处理器。确实,2004年英特尔取消了其高性能单处理器项目,并与其他公司一起宣布,提升性能的道路将是通过每片芯片上的多个处理器,而不是通过更快的单处理器。这一里程碑标志着一个历史性的转变,从完全依赖指令级并行(ILP)——该书前三版的主要关注点——转向数据级并行(DLP)和线程级并行(TLP),这些内容在第四版中介绍,并在第五版中扩展。第五版还增加了WSC和请求级并行(RLP),在本版中进行了扩展。虽然编译器和硬件在不需要程序员注意的情况下隐式地利用ILP,但DLP、TLP和RLP是显式并行的,需要重构应用程序以利用显式并行。在某些情况下,这很容易;但在许多情况下,这是对程序员的重大新负担。阿姆达尔定律(第1.9节)规定了每片芯片上有用核心数量的实际限制。如果任务的10%是串行的,那么并行所能带来的最大性能收益是10,无论你在芯片上放多少个核心。最近结束的第二个观察是摩尔定律。1965年,戈登·摩尔著名地预测,每片芯片上的晶体管数量每年翻一番,1975年修正为每两年翻一番。这个预测持续了大约50年,但现在不再成立。例如,在2010年版中,最新的英特尔微处理器有1,170,000,000个晶体管。如果摩尔定律继续下去,我们可以预期2016年的微处理器将有18,720,000,000个晶体管。实际上,相应的英特尔微处理器只有1,750,000,000个晶体管,偏差了摩尔定律预测值的10倍。
由于以下因素:
- 晶体管性能的提升因摩尔定律放缓和丹纳德缩放的结束而变得不再显著,
- 微处理器的功耗预算不变,
- 单一功耗高的处理器被多个节能处理器取代,以及
- 实现阿姆达尔定律的多处理器限制,
导致处理器性能的提升速度放缓,即性能的翻倍周期从1986年至2003年间的每1.5年变为每20年(见图1.1)。
改善能效-性能-成本的唯一途径是专业化。未来的微处理器将包括多个领域特定的核心,这些核心在某一类计算上表现卓越,但远胜于通用核心。本版新增的第七章介绍了领域特定架构。
本书讨论了使过去一个世纪内惊人增长率成为可能的架构理念及相关编译器改进、剧烈变化的原因,以及面向21世纪的架构理念、编译器和解释器的挑战和初步有希望的方法。核心是量化的计算机设计和分析方法,使用程序的实证观察、实验和模拟作为工具。本书体现了这种设计风格和方法。本章的目的是为接下来的章节和附录奠定量化基础。
本书不仅旨在解释这种设计风格,还希望激发您为这一进步做出贡献。我们相信,这种方法将为未来的计算机提供服务,就像它曾经服务于隐式并行计算机一样。


1.2 Classes of Computers

这些变化为我们在新世纪如何看待计算、计算应用和计算机市场奠定了基础。自个人计算机问世以来,我们未曾见过如此显著的计算机外观和使用方式的变化。这些计算机使用方式的变化导致了五个不同的计算市场,每个市场都有其独特的应用、需求和计算技术。图1.2总结了这些主流计算环境类别及其重要特征。

图1.2 总结了五种主流计算类别及其系统特征。2015年的销售情况包括约16亿台个人移动设备(其中90%为手机)、2.75亿台台式电脑和1500万台服务器。销售的嵌入式处理器总数接近190亿。在2015年,共出货了148亿台基于ARM技术的芯片。请注意,服务器和嵌入式系统的系统价格范围广泛,从USB密钥到网络路由器不等。服务器的价格范围来自于对高端事务处理所需的大规模多处理器系统的需求。
物联网/嵌入式计算机
嵌入式计算机存在于日常机器中:微波炉、洗衣机、大多数打印机、网络交换机以及所有汽车。物联网(IoT)指的是那些连接到互联网的嵌入式计算机,通常是无线连接的。当嵌入传感器和执行器时,IoT设备能够收集有用的数据并与物理世界互动,导致各种“智能”应用的出现,如智能手表、智能温控器、智能扬声器、智能汽车、智能家居、智能电网和智能城市。
嵌入式计算机的处理能力和成本范围最广泛。它们包括从成本仅为一分钱的8位到32位处理器,到用于汽车和网络交换机的高端64位处理器,其成本可达100美元。尽管嵌入式计算市场中的计算能力范围非常大,但价格是设计此类计算机时的关键因素。当然,性能要求确实存在,但主要目标通常是以最低价格满足性能需求,而不是以更高的价格获取更多性能。预计到2020年,IoT设备的数量将从200亿到500亿不等。
本书的大部分内容适用于嵌入式处理器的设计、使用和性能,无论是现成的微处理器还是将与其他专用硬件组装的微处理器核心。不幸的是,驱动其他计算机类别的定量设计和评估的数据尚未成功扩展到嵌入式计算领域(例如,见第1.8节中的EEMBC挑战)。因此,目前我们只能依赖定性描述,这些描述与本书其余部分不太契合。因此,嵌入式部分集中在附录E中。我们认为,将嵌入式内容放在单独的附录中可以改善文本的思路流,同时让读者了解不同需求如何影响嵌入式计算。
个人移动设备(PMD)
个人移动设备(PMD)是我们用来指代一系列具有多媒体用户界面的无线设备的术语,例如手机、平板电脑等。考虑到整个产品的消费者价格为几百美元,成本是一个主要关注点。虽然能源效率的重视通常由电池使用驱动,但对使用更便宜的包装(塑料而非陶瓷)和缺乏风扇冷却的要求也限制了总功耗。我们在第1.5节中更详细地讨论了能源和功率的问题。PMD上的应用程序通常是基于网页的和媒体导向的,比如之前提到的Google翻译示例。能源和尺寸要求导致使用闪存(第2章)进行存储,而不是磁盘。
PMD中的处理器通常被视为嵌入式计算机,但我们将它们保留为一个独立类别,因为PMD是可以运行外部开发软件的平台,并且它们具有许多桌面计算机的特征。其他嵌入式设备在硬件和软件的复杂性上更为有限。我们将运行第三方软件的能力作为非嵌入式和嵌入式计算机之间的分界线。
响应性和可预测性是媒体应用的关键特性。实时性能要求意味着应用程序的某个部分有一个绝对的最大执行时间。例如,在PMD上播放视频时,处理每个视频帧的时间是有限的,因为处理器必须很快接受和处理下一个帧。在某些应用中,存在更为细致的要求:不仅要约束特定任务的平均时间,还要约束超过最大时间的实例数量。这种方法——有时称为软实时——当偶尔错过时间限制但不会错过太多时是可以接受的。实时性能往往高度依赖于应用程序。
许多PMD应用中的其他关键特性是需要最小化内存和有效利用能源。能源效率受电池电力和热量散发的驱动。内存可能占据系统成本的相当大一部分,因此在这种情况下优化内存大小非常重要。内存大小的重要性转化为对代码大小的重视,因为数据大小由应用程序决定。
桌面计算
第一个市场可能也是目前在金额上仍然最大的是桌面计算。桌面计算涵盖了从售价不到300美元的低端上网本到售价2500美元的高端重配置工作站。自2008年以来,每年生产的桌面计算机中,超过一半是电池驱动的笔记本电脑。桌面计算的销售正在下降。
在价格和能力的广泛范围内,桌面市场往往被驱动以优化性价比。系统的性能(主要以计算性能和图形性能来衡量)与价格的结合是这个市场客户最关心的,因此也是计算机设计师关注的重点。因此,最新的高性能微处理器和降低成本的微处理器通常会首先出现在桌面系统中(详见第1.6节关于计算机成本问题的讨论)。
桌面计算在应用程序和基准测试方面也相对有较好的特征,但随着基于网页的互动应用程序的增加,性能评估面临新的挑战。
服务器
随着1980年代桌面计算的兴起,服务器的角色也逐渐扩大,提供更大规模和更可靠的文件和计算服务。这些服务器已经成为大规模企业计算的支柱,取代了传统的大型计算机。
对于服务器来说,不同的特性是重要的。首先,可靠性至关重要(我们在第1.7节中讨论了可靠性)。考虑一下运行银行自动取款机或航空公司订票系统的服务器。这些服务器的故障比单台桌面计算机的故障要严重得多,因为它们必须全天候、每周七天不间断地运行。图1.3估算了服务器应用程序停机的收入损失成本。

图1.3 显示了系统不可用时成本的估算(四舍五入至最近的10万美元),通过分析停机的成本(即立即损失的收入),假设三种不同的可用性水平,并且停机时间分布均匀。这些数据来自Landstrom(2014),由应急计划研究公司收集和分析。
服务器系统的第二个关键特性是可扩展性。服务器系统通常会根据服务需求的增加或功能要求的扩展而增长。因此,扩展计算能力、内存、存储和I/O带宽的能力至关重要。最后,服务器设计的目标是高效的吞吐量。也就是说,服务器的整体性能——如每分钟交易数或每秒服务的网页数——是关键。对单个请求的响应仍然重要,但整体效率和成本效益(即单位时间内处理的请求数量)是大多数服务器的关键指标。我们将在第1.8节中回到不同计算环境下性能评估的问题。
集群/仓库规模计算机
软件即服务(SaaS)在搜索、社交网络、视频观看和共享、多玩家游戏、在线购物等应用领域的增长,导致了一类计算机的出现,即集群。集群是由通过局域网连接的台式计算机或服务器集合组成的,作为一个更大的计算机系统进行操作。每个节点运行自己的操作系统,节点之间通过网络协议进行通信。仓库规模计算机(WSC)是集群中最大的,它们设计成可以让成千上万台服务器作为一个整体来工作。第6章描述了这一类极其大型的计算机。
由于WSC的规模庞大,价格性能和功耗是关键因素。正如第6章所解释的,仓库的大部分成本与仓库内部计算机的电力和冷却相关。WSC的年折旧计算机和网络设备的成本为4000万美元,因为它们通常每隔几年就会更换一次。当你购买如此大量的计算时,你需要明智地购买,因为价格性能提升10%意味着每个WSC每年节省400万美元(4000万美元的10%);像亚马逊这样的公司可能有100个WSC!
WSC与服务器相关,因为可用性至关重要。例如,亚马逊在2016年的销售额为1360亿美元。由于一年大约有8800小时,平均每小时收入约为1500万美元。在圣诞购物的高峰时段,潜在损失将高出很多倍。正如第6章所解释的,WSC与服务器的区别在于WSC使用冗余、便宜的组件作为构建块,依赖软件层来捕捉和隔离在这种规模计算中会发生的许多故障,以提供所需的可用性。而WSC的可扩展性由连接计算机的局域网处理,而不是像服务器那样的集成计算机硬件。
超级计算机与WSC相关,因为它们都同样昂贵,价格在数亿美元,但超级计算机通过强调浮点性能和运行大型、通信密集型的批处理程序(这些程序可以运行数周)来与WSC区分开来。相比之下,WSC强调交互式应用、大规模存储、可靠性和高互联网带宽。
**平行性类别和并行体系结构**
在所有四类计算机中,多层次的平行性现已成为计算机设计的主要驱动力,能源和成本是主要的制约因素。应用程序中基本上有两种平行性:
1. **数据级平行性(DLP)**:这是因为有许多数据项可以同时进行操作。
2. **任务级平行性(TLP)**:这是因为创建了可以独立并且大部分时间并行操作的任务。
计算机硬件可以以四种主要方式利用这两种应用程序平行性:
1. **指令级平行性** 利用编译器的帮助在适度的水平上利用数据级平行性,使用诸如流水线技术的思想;在中等水平上使用诸如猜测执行的思想。
2. **向量体系结构、图形处理单元(GPU)和多媒体指令集** 通过将单个指令应用于数据集合来利用数据级平行性。
3. **线程级平行性** 在紧密耦合的硬件模型中利用数据级平行性或任务级平行性,这种模型允许并行线程之间的交互。
4. **请求级平行性** 利用程序员或操作系统指定的大部分解耦任务之间的平行性。
当Flynn(1966年)研究1960年代的并行计算工作时,他发现了一种简单的分类方法,我们今天仍在使用它的缩写。这些分类针对数据级平行性和任务级平行性。他考察了指令和数据流中的平行性,并将所有计算机划分为以下四类:
1. **单指令流,单数据流(SISD)**——这一类别是单处理器。程序员将其视为标准的顺序计算机,但它可以利用指令级平行性。第3章涵盖了使用ILP技术(如超标量和猜测执行)的SISD体系结构。
2. **单指令流,多数据流(SIMD)**——多个处理器使用不同的数据流执行相同的指令。SIMD计算机通过将相同的操作应用于多个数据项来利用数据级平行性。每个处理器有自己的数据内存(因此,SIMD中的“MD”),但有一个单独的指令内存和控制处理器,负责提取和分派指令。第4章涵盖了数据级平行性及三种利用它的体系结构:向量体系结构、标准指令集的多媒体扩展和GPU。
3. **多指令流,单数据流(MISD)**——到目前为止,没有建造出这种类型的商业多处理器,但它完整了这种简单分类。
4. **多指令流,多数据流(MIMD)**——每个处理器提取自己的指令并操作自己的数据,主要针对任务级平行性。一般来说,MIMD比SIMD更灵活,因此更具通用性,但其成本也相对较高。例如,MIMD计算机也可以利用数据级平行性,尽管开销可能比SIMD计算机更高。这种开销意味着粒度大小必须足够大,以有效利用平行性。第5章涵盖了紧密耦合的MIMD体系结构,这些体系结构利用线程级平行性,因为多个协作线程并行操作。第6章涵盖了松散耦合的MIMD体系结构——特别是集群和仓库规模计算机——这些结构利用请求级平行性,其中许多独立任务可以自然地并行进行,几乎不需要通信或同步。
这一分类法是一个粗略模型,因为许多并行处理器是SISD、SIMD和MIMD类别的混合体。尽管如此,它在我们将要讨论的计算机设计空间上提供了一个有用的框架。


1.3 Defining Computer Architecture

计算机设计师面临的任务十分复杂:确定新计算机的重要属性,然后在成本、电力和可用性限制内,设计出最大化性能和能效的计算机。这个任务涉及多个方面,包括指令集设计、功能组织、逻辑设计和实现。实现可能包括集成电路设计、封装、电源和散热。优化设计需要对编译器、操作系统、逻辑设计和封装等广泛技术有深入了解。
几十年前,“计算机架构”这个术语通常仅指指令集设计。计算机设计的其他方面被称为实现,常常暗示实现不够有趣或挑战性较小。我们认为这种看法是不正确的。架构师或设计师的工作远不止于指令集设计,其他方面的技术难题可能比指令集设计中的更具挑战性。我们将在快速回顾指令集架构之后,描述计算机架构师面临的更大挑战。
指令集架构:计算机架构的狭隘视角
在本书中,我们使用“指令集架构”(ISA)来指代实际的程序员可见指令集。ISA 作为软件和硬件之间的界限。这一指令集架构的简要回顾将使用 80x86、ARMv8 和 RISC-V 的例子来说明 ISA 的七个维度。最受欢迎的 RISC 处理器来自 ARM(高级 RISC 机器),在 2015 年出货了 148 亿片,相当于 80x86 处理器出货量的 50 倍。附录 A 和 K 提供了更多关于这三种 ISA 的细节。
RISC-V(“RISC 五”)是加州大学伯克利分校开发的现代 RISC 指令集,响应了业界的需求,免费开放供使用。除了完整的软件栈(编译器、操作系统和模拟器)外,还有多个 RISC-V 实现可供定制芯片或现场可编程门阵列使用。RISC-V 在第一个 RISC 指令集问世 30 年后开发,继承了其前身的优良理念——大量寄存器、易于流水线的指令和精简的操作集,同时避免了它们的遗漏或错误。它是一个免费的、开放的、优雅的 RISC 架构示例,因此包括 AMD、Google、HP 企业、IBM、微软、英伟达、高通、三星和西部数据在内的 60 多家公司加入了 RISC-V 基金会。我们在本书中使用 RISC-V 的整数核心 ISA 作为示例 ISA。
1. ISA 类别——今天几乎所有的 ISA 都被归类为通用寄存器架构,其中操作数可以是寄存器或内存位置。80x86 具有 16 个通用寄存器和 16 个可以存储浮点数据的寄存器,而 RISC-V 拥有 32 个通用寄存器和 32 个浮点寄存器(见图 1.4)。这一类别中的两个流行版本是寄存器-内存 ISA,例如 80x86,它可以在许多指令中访问内存,以及加载-存储 ISA,例如 ARMv8 和 RISC-V,它只能通过加载或存储指令访问内存。自 1985 年以来公布的所有 ISA 都是加载-存储类型。

图 1.4 RISC-V 寄存器、名称、用途及调用约定。除了 32 个通用寄存器(x0–x31),RISC-V 还有 32 个浮点寄存器(f0–f31),它们可以存储 32 位单精度数或 64 位双精度数。跨过程调用保持不变的寄存器标记为“被调用者”保存。
2. 内存寻址——几乎所有的桌面和服务器计算机,包括 80x86、ARMv8 和 RISC-V,都使用字节寻址来访问内存操作数。一些架构(如 ARMv8)要求对象必须对齐。如果在字节地址 A 处访问一个大小为 s 字节的对象,当且仅当 A mod s = 0 时,该访问才是对齐的。(参见第 A-8 页的图 A.5。)80x86 和 RISC-V 不要求对齐,但如果操作数对齐,访问速度通常会更快。
3. 寻址模式——除了指定寄存器和常数操作数外,寻址模式还指定内存对象的地址。RISC-V 的寻址模式包括寄存器模式、立即数模式(用于常数)和位移模式,其中常数偏移量被加到寄存器上以形成内存地址。80x86 支持这三种模式,加上三种位移变体:无寄存器(绝对寻址)、两个寄存器(基于索引加位移),以及两个寄存器中一个寄存器乘以操作数的字节大小(基于缩放索引和位移)。它还有类似于最后三种模式的更多模式,但不包括位移字段,并且支持寄存器间接寻址、索引寻址和基于缩放索引的寻址。ARMv8 除了 RISC-V 的三种寻址模式外,还支持 PC 相对寻址、两个寄存器之和,以及两个寄存器之和,其中一个寄存器乘以操作数的字节大小。它还支持自增和自减寻址,其中计算出的地址替代形成地址时所用的寄存器之一的内容。
4. 操作数的类型和大小——像大多数指令集架构一样,80x86、ARMv8 和 RISC-V 支持 8 位(ASCII 字符)、16 位(Unicode 字符或半字)、32 位(整数或字)、64 位(双字或长整数)以及 IEEE 754 浮点数(32 位单精度和 64 位双精度)。80x86 还支持 80 位浮点数(扩展双精度)。
5. 操作——操作的一般类别包括数据传输、算术逻辑、控制(下文讨论)和浮点运算。RISC-V 是一种简单且易于流水线化的指令集架构,代表了 2017 年使用的 RISC 架构。图 1.5 总结了整数 RISC-V ISA,图 1.6 列出了浮点 ISA。80x86 具有更丰富、更大规模的操作集(见附录 K)。

图 1.5 RISC-V 指令集的一个子集。RISC-V 有一个基础指令集(R64I),并提供可选扩展:乘除运算(RVM)、单精度浮点(RVF)、双精度浮点(RVD)。此图包含了 RVM,而下一个图展示了 RVF 和 RVD。附录 A 提供了有关 RISC-V 的更多详细信息。

图 1.6 RISC-V 的浮点指令。RISC-V 有一个基础指令集(R64I),并提供可选的单精度浮点(RVF)和双精度浮点(RVD)扩展。SP = 单精度;DP = 双精度。
6. 控制流指令——几乎所有的指令集架构,包括这三种,都支持条件分支、无条件跳转、过程调用和返回。三者都使用 PC 相对寻址,其中分支地址由一个加到 PC 的地址字段指定。存在一些小的差异。RISC-V 的条件分支(BE、BNE 等)测试寄存器的内容,而 80x86 和 ARMv8 的分支测试算术/逻辑操作的条件码位。ARMv8 和 RISC-V 的过程调用将返回地址放在寄存器中,而 80x86 的调用(CALLF)将返回地址放在内存中的栈上。
7. ISA 编码——编码有两种基本选择:固定长度和可变长度。所有 ARMv8 和 RISC-V 指令都是 32 位长,这简化了指令解码。图 1.7 显示了 RISC-V 的指令格式。80x86 的编码是可变长度的,范围从 1 到 18 字节。可变长度指令可能占用的空间比固定长度指令少,因此为 80x86 编译的程序通常比为 RISC-V 编译的同一程序要小。注意,之前提到的选择将影响指令如何编码成二进制表示。例如,寄存器数量和寻址模式数量对指令大小有显著影响,因为寄存器字段和寻址模式字段在单条指令中可以出现多次。(注意,ARMv8 和 RISC-V 后来提供了扩展,称为 Thumb-2 和 RV64IC,分别提供 16 位和 32 位长度的指令,以减少程序大小。这些紧凑版本的 RISC 架构的代码大小小于 80x86。见附录 K。)

图 1.7 基础 RISC-V 指令集架构格式。所有指令都是 32 位长。R 格式用于整数寄存器到寄存器的操作,如 ADD、SUB 等。I 格式用于加载和立即数操作,如 LD 和 ADDI。B 格式用于分支,J 格式用于跳转和链接。S 格式用于存储。为存储操作设置单独的格式使得三个寄存器说明符(rd、rs1、rs2)在所有格式中始终保持相同的位置。U 格式用于宽立即数指令(LUI、AUIPC)。
计算机架构师在指令集架构(ISA)设计之外面临的其他挑战在当前尤为突出,因为指令集之间的差异很小,而应用领域却各不相同。因此,从本书第四版开始,除了这一简要回顾之外,大部分指令集的材料都放在附录中(请参见附录 A 和 K)。
真正的计算机架构:设计组织和硬件以满足目标和功能需求
计算机的实现包含两个组成部分:组织和硬件。组织指计算机设计的高层次方面,如内存系统、内存互连以及内部处理器或中央处理单元(CPU)的设计(在这里实现算术、逻辑、分支和数据传输)。微架构这个术语也用来代替组织。例如,两个具有相同指令集架构但不同组织的处理器是 AMD Opteron 和 Intel Core i7。这两个处理器都实现了 x86 指令集,但它们的流水线和缓存组织非常不同。
由于每个微处理器都有多个处理器,"核心"这个术语也被用来指代处理器。因此,"多核"这一术语逐渐流行开来。鉴于几乎所有芯片都有多个处理器,中央处理单元(CPU)这个术语的使用逐渐减少。
硬件指计算机的具体细节,包括详细的逻辑设计和计算机的封装技术。通常,一系列计算机中包含具有相同指令集架构和非常相似组织的计算机,但它们在硬件实现的细节上有所不同。例如,Intel Core i7 和 Intel Xeon E7 几乎相同,但提供不同的时钟频率和内存系统,使得 Xeon E7 更适合服务器计算机。
在本书中,"架构"一词涵盖计算机设计的三个方面——指令集架构、组织或微架构以及硬件。
计算机架构师必须设计计算机以满足功能需求,同时达到价格、功耗、性能和可用性目标。图 1.8 总结了在设计新计算机时需要考虑的要求。架构师通常还必须确定功能需求,这可能是一项重要任务。这些需求可能是市场驱动的特定功能。应用软件通常决定计算机的使用方式,从而驱动某些功能需求的选择。如果针对特定指令集架构存在大量软件,架构师可能决定新计算机应实现现有的指令集。对于某一应用类别的大型市场可能会促使设计师在计算机中加入使其在该市场上具有竞争力的需求。后续章节将深入探讨这些要求和特性。

图 1.8 总结了架构师面临的一些最重要的功能需求。左侧列描述了需求的类别,而右侧列给出了具体的例子。右侧列还包含了涉及这些具体问题的章节和附录的参考。
架构师还必须关注技术和计算机使用中的重要趋势,因为这些趋势不仅影响未来的成本,还影响架构的持久性。


1.4 Trends in Technology

如果一个指令集架构要取得成功,它必须设计得能够经受计算机技术的快速变化。毕竟,一个成功的新指令集架构可能会持续几十年,例如,IBM 大型机的核心已经使用了超过 50 年。架构师必须为技术变化做好规划,以延长成功计算机的使用寿命。为了规划计算机的演变,设计师必须了解实现技术的快速变化。五种实现技术的变化速度非常快,对于现代实现至关重要:
■ 集成电路逻辑技术——历史上,晶体管密度每年大约增长 35%,四年内增加四倍。芯片面积的增长则较难预测且较慢,年增长范围为 10% 至 20%。两者的综合效应使得芯片上晶体管数量的传统增长率约为每年 40%–55%,即每 18–24 个月翻倍。这个趋势被称为摩尔定律。设备速度的提升则较慢,如下文所述。令人震惊的是,摩尔定律已不再成立。每片芯片上的设备数量仍在增加,但增长速度正在减缓。与摩尔定律时代不同,我们预计每一代新技术的翻倍时间将会被拉长。
■ 半导体 DRAM(动态随机存取内存)——这一技术是主内存的基础,我们将在第 2 章讨论它。DRAM 的增长已经显著放缓,从过去每三年翻四倍的速度减缓。2014 年时,8-gigabit DRAM 已开始出货,但 16-gigabit DRAM 要到 2019 年才能达到这一水平,并且似乎不会出现 32-gigabit DRAM(Kim, 2005)。第 2 章提到了一些可能在 DRAM 达到容量极限时取代它的技术。
■ 半导体 Flash(电可擦除可编程只读内存)——这种非易失性半导体存储器是 PMD 的标准存储设备,其快速增长的容量推动了其迅猛的发展速度。近年来,Flash 芯片的容量每年增加约 50%–60%,大约每 2 年翻倍。目前,Flash 存储器每比特的价格比 DRAM 便宜 8–10 倍。第 2 章描述了 Flash 存储器。
■ 磁盘技术——在 1990 年之前,磁盘的密度每年增加约 30%,每三年翻一倍。此后密度增幅上升至每年 60%,并在 1996 年增加到每年 100%。2004 年至 2011 年间,密度增幅回落至每年约 40%,即每两年翻一倍。最近,磁盘的改进速度减缓至每年不到 5%。增加磁盘容量的一种方法是保持相同的面积密度,增加更多的盘片,但在 3.5 英寸的硬盘中,已经有七个盘片叠在一英寸的深度内。最多还能再加一到两个盘片。提升真实密度的最后希望是使用一个小激光器在每个磁盘读写头上,将 30 纳米的点加热到 400°C,以便在其冷却之前进行磁性写入。目前尚不清楚热辅助磁记录(HAMR)是否能够经济且可靠地制造,尽管 Seagate 宣布计划在 2018 年开始有限生产 HAMR。HAMR 是硬盘驱动器在面积密度方面持续改进的最后机会,硬盘驱动器每比特的成本比 Flash 便宜 8–10 倍,比 DRAM 便宜 200–300 倍。此技术对服务器和仓储规模的存储至关重要,我们在附录 D 中详细讨论了这一趋势。
■ 网络技术——网络性能既依赖于交换机的性能,也依赖于传输系统的性能。我们在附录 F 中讨论了网络技术的趋势。
这些迅速变化的技术塑造了计算机的设计,考虑到速度和技术的提升,这种计算机的使用寿命可能为 3 到 5 年。关键技术如闪存的变化足够大,以至于设计师必须为这些变化进行规划。实际上,设计师们常常会设计出符合下一个技术发展的产品,因为当产品开始大规模生产时,下一代技术可能会更具成本效益或具有性能优势。传统上,成本的下降速度大约与密度的增加速度相同。
尽管技术不断改进,这些提高的影响可能会以离散的跃进形式出现,通常是因为达到了允许新功能的临界点。例如,当 MOS 技术在 1980 年代初期达到每片芯片上能够容纳 25,000 到 50,000 个晶体管的水平时,就可以制造出单芯片的 32 位微处理器。到 1980 年代末,第一层缓存也可以放在芯片上。通过消除处理器内部和处理器与缓存之间的芯片交叉,显著提高了成本效益和能效。这种设计在技术达到一定水平之前是完全不可行的。随着多核微处理器和每代核心数量的增加,即使是服务器计算机也越来越趋向于将所有处理器集成在单个芯片上。这种技术的临界点并不罕见,对各种设计决策有着重大影响。
**性能趋势:带宽与延迟**
正如我们将在第1.8节中看到的,带宽或吞吐量是指在给定时间内完成的总工作量,例如磁盘传输的每秒兆字节数。相对而言,延迟或响应时间是指事件开始和完成之间的时间,例如磁盘访问的毫秒数。图1.9 绘制了微处理器、内存、网络和磁盘技术里程碑的带宽和延迟的相对改进。图1.10 对这些例子和里程碑进行了更详细的描述。
性能是微处理器和网络的主要区别因素,因此它们在带宽和延迟方面取得了最大的提升:带宽提高了32,000–40,000倍,而延迟降低了50–90倍。对于内存和磁盘来说,容量通常比性能更为重要,因此容量的提升更多,但带宽的进步仍然远远大于延迟的改进,带宽的提升达到400–2,400倍,而延迟的改进只有8–9倍。
显然,带宽在这些技术领域中超越了延迟,并且可能会继续保持这种趋势。一个简单的经验法则是,带宽的增长至少是延迟改进的平方倍。计算机设计师应据此进行规划。

**图1.9** 图示了图1.10中带宽和延迟里程碑的对数-对数图,相对于第一个里程碑。注意,延迟改善了8–91倍,而带宽提高了约400–32,000倍。除了网络技术外,我们注意到,在过去六年里,其他三种技术的延迟和带宽改进都较为温和:延迟改进了0%–23%,带宽改进了23%–70%。更新自Patterson, D., 2004年,延迟滞后于带宽。《计算机通讯协会通讯》47 (10), 71–75。

图1.10展示了在25至40年期间,微处理器、内存、网络和磁盘的性能里程碑。微处理器的里程碑包括多个IA-32处理器的代际进展,从16位总线的微编码80286到64位总线、多核、乱序执行的超级流水线Core i7。内存模块的里程碑从16位宽的普通DRAM发展到64位宽的第三代双倍数据率同步DRAM。以太网的速度从10 Mbit/s提升到400 Gbit/s。磁盘的里程碑基于旋转速度,从3600 RPM提升到15,000 RPM。每种情况展示的是最佳带宽,而延迟是指在没有争用的情况下完成一个简单操作的时间。更新自Patterson, D., 2004. 延迟滞后于带宽。Commun. ACM 47 (10), 71–75。
**晶体管性能与电线缩放**
集成电路工艺的特征由特征尺寸决定,即晶体管或电线在x或y维度上的最小尺寸。特征尺寸从1971年的10μm减少到2017年的0.016μm;实际上,我们已经更换了单位,因此2017年的生产被称为“16 nm”,7 nm芯片也在研发中。由于每平方毫米硅片上的晶体管数量由晶体管的表面积决定,因此晶体管的密度随着特征尺寸的线性减少而二次增加。
然而,晶体管性能的提升则更为复杂。随着特征尺寸的缩小,器件在水平维度上以平方的速度缩小,同时在垂直维度上也会缩小。垂直维度的缩小需要降低工作电压,以维持晶体管的正常操作和可靠性。这种缩放因素的组合导致了晶体管性能与工艺特征尺寸之间复杂的相互关系。粗略地说,在过去,晶体管性能随着特征尺寸的减小线性提高。晶体管数量随着线性提高的晶体管性能而二次增长,这既是挑战也是计算机架构师创造的机会!在微处理器的早期,密度的提高被用来快速从4位、8位、16位、32位到64位微处理器的发展。最近,密度的提高支持了每芯片多个处理器的引入、更宽的SIMD单元以及第2到第5章中讨论的许多预测执行和缓存的创新。
尽管晶体管一般会随着特征尺寸的减少而性能提升,但集成电路中的电线则不然。特别是,电线的信号延迟与其电阻和电容的乘积成正比。尽管特征尺寸缩小时,电线变得更短,但每单位长度的电阻和电容却会变差。这种关系复杂,因为电阻和电容依赖于工艺的详细方面、电线的几何形状、电线上的负载,甚至是与其他结构的邻接。偶尔有一些工艺改进,如引入铜,可以在一次性提高电线延迟方面提供帮助。
然而,总体而言,与晶体管性能相比,电线延迟的缩放效果较差,为设计师带来了额外的挑战。除了功耗限制外,电线延迟已经成为大型集成电路的主要设计障碍,通常比晶体管开关延迟更为关键。越来越多的时钟周期被信号在电线上的传播延迟消耗,但现在功耗在设计中比电线延迟扮演了更重要的角色。


1.5 Trends in Power and Energy in Integrated Circuits

今天,能源是几乎所有计算机类别面临的最大挑战。首先,必须将电力引入并在芯片上分配,现代微处理器使用数百个引脚和多个互连层来处理电源和接地。其次,电力作为热量散发出去,必须被移除。
**电力和能源:系统视角**
系统架构师或用户应如何考虑性能、电力和能源?从系统设计者的角度看,有三个主要关注点。首先,处理器的最大功耗是多少?满足这一需求对于确保正常操作至关重要。例如,如果处理器试图抽取超过电源系统可提供的电力(通过抽取超过系统能供给的电流),通常会导致电压下降,从而可能导致设备故障。现代处理器在功耗上差异很大,具有高峰电流,因此它们提供了电压调整方法,使处理器能够在更宽的范围内降低速度并调节电压。这显然会降低性能。
其次,什么是持续功耗?这个指标通常被称为热设计功耗(TDP),因为它决定了冷却需求。TDP既不是峰值功耗(通常高出1.5倍),也不是在特定计算期间实际消耗的平均功耗(可能更低)。系统的典型电源通常会超出TDP,而冷却系统通常设计为匹配或超出TDP。如果冷却不足,处理器的结温可能超过其最大值,从而导致设备故障甚至永久损坏。现代处理器提供了两种帮助管理热量的功能,因为最高功耗(因此热量和温度升高)可能超过TDP规定的长期平均值。首先,当热温度接近结温极限时,电路会降低时钟频率,从而减少功耗。如果这种方法不成功,第二种热过载保护机制会激活,关闭芯片。
设计师和用户需要考虑的第三个因素是能效。请记住,功率只是单位时间的能量:1瓦特=1焦耳每秒。比较处理器时,选择能量还是功率作为指标更合适?通常,能量是更好的指标,因为它与特定任务和完成该任务所需的时间相关。特别地,完成工作负载所需的能量等于平均功率乘以工作负载的执行时间。因此,如果我们想知道哪种处理器对特定任务更高效,我们需要比较执行任务的能量消耗(而不是功率)。例如,处理器A的平均功耗可能比处理器B高20%,但如果A在B所需时间的70%内完成任务,其能量消耗将是1.2×0.7=0.84,这显然更好。
有人可能会认为,在大型服务器或云计算中,考虑平均功率就足够了,因为工作负载通常被假定为无限的,但这具有误导性。如果我们的云计算中使用的是处理器B而不是A,那么在消耗相同能量的情况下,云计算完成的工作将会更少。使用能量来比较不同选择可以避免这个陷阱。无论是对于大型云计算还是智能手机,比较能量总是正确的,因为云计算的电费和智能手机的电池寿命都由能量消耗决定。
什么时候功耗是一个有用的度量?主要的合法用途是作为限制:例如,一个空气冷却的芯片可能被限制在100瓦特。如果工作负载固定,可以将其用作指标,但这只是每任务能量的真实指标的一个变体。
微处理器中的能量和功率
在微处理器中,CMOS芯片的传统主要能量消耗来源于切换晶体管,也称为动态能量。每个晶体管所需的能量与晶体管驱动的电容负载和电压的平方的乘积成正比。

这个公式表示逻辑转换脉冲的能量,例如从0到1再到0,或从1到0再到1。单次转换(0到1或1到0)的能量则为:

每个晶体管所需的功率仅仅是单次转换的能量乘以转换频率的乘积。

对于一个固定的任务,降低时钟频率可以减少功率,但不会减少能量。显然,通过降低电压可以大幅减少动态功率和能量,因此在过去的20年里,电压从5伏降到了不到1伏。电容负载则是由连接到输出端的晶体管数量和技术决定的,技术决定了导线和晶体管的电容值。
**示例**
一些现代微处理器设计为具有可调电压,因此电压降低15%可能会导致频率降低15%。这对动态能量和动态功率的影响是什么?
**答案**
由于电容未改变,能量的变化比例是电压的比例。

这将能量减少到原始能量的大约72%。对于功率,我们需要计算频率的比例。

将功率减少到原始功率的大约61%。
随着我们从一个处理过程移动到下一个,晶体管切换数量和切换频率的增加主导了负载电容和电压的减少,导致功耗和能量的总体增长。早期的微处理器消耗不到1瓦,而第一代32位微处理器(如Intel 80386)约消耗2瓦,而4.0 GHz的Intel Core i7-6700K则消耗95瓦。鉴于这些热量必须从约1.5厘米见方的芯片中散发出来,我们已接近空气冷却的极限,这也是我们近十年来一直面临的困境。根据前述公式,如果我们不能降低电压或增加每芯片的功率,则时钟频率的增长预计将会放缓。图1.11显示,自2003年以来,即使是每年表现最优的微处理器,时钟频率的增长确实变得缓慢。值得注意的是,这段时钟频率平稳的时期对应于图1.1中性能改善缓慢的阶段。分配功率、去除热量和防止热点变得越来越困难。现在,能源是使用晶体管的主要限制因素,以前则是原材料硅的面积。因此,现代微处理器提供了许多技术来提高能效,尽管时钟频率保持平稳和电压恒定:

图1.11 显示了微处理器时钟频率的增长情况。从1978年到1986年,时钟频率每年提高不到15%,而性能每年提高22%。在1986年到2003年期间的“文艺复兴时期”,性能每年提高了52%,而时钟频率几乎每年增加了40%。从那时起,时钟频率几乎保持平稳,每年增长不到2%,而单核处理器性能最近仅提高了3.5%每年。
1. **做好无事的处理**。如今,大多数微处理器会关闭闲置模块的时钟以节省能量和动态功率。例如,如果没有浮点指令执行,则浮点单元的时钟会被禁用。如果某些核心闲置,则它们的时钟也会停止。
2. **动态电压-频率调整(DVFS)**。第二种技术直接来源于前述公式。PMD、笔记本电脑甚至服务器在低活动期间不需要以最高时钟频率和电压运行。现代微处理器通常提供几个时钟频率和电压选项,以使用较低的功率和能量。图1.12展示了在工作负载缩小时,服务器通过DVFS的潜在功率节省情况,分别为3种不同的时钟频率:2.4、1.8和1 GHz。总体服务器功率节省约为每两步10%–15%。

图1.12 显示了使用AMD Opteron微处理器、8 GB DRAM和一个ATA磁盘的服务器的能耗节省情况。在1.8 GHz时,服务器最多可以处理三分之二的工作负载而不会造成服务级别违规,而在1 GHz时,服务器只能安全地处理三分之一的工作负载(参见Barroso和Hölzle, 2009年,第5.11图)。
3. **针对典型情况设计**。由于PMD和笔记本电脑常常处于闲置状态,内存和存储提供低功耗模式以节省能量。例如,DRAM具有一系列逐渐降低功耗的模式,以延长PMD和笔记本电脑的电池寿命,还有提案提出当磁盘未使用时以更慢的速度旋转以节省功率。然而,在这些模式下无法访问DRAM或磁盘,因此必须返回完全活动模式以进行读写,无论访问率多低。如前所述,PC的微处理器已被设计为在高操作温度下重负荷使用,依赖于芯片上的温度传感器检测何时应自动减少活动以避免过热。这种“紧急减速”允许制造商设计更典型的情况,然后依靠这一安全机制,以防有人运行的程序消耗的功率远超出正常范围。
4. **超频**。英特尔在2008年开始提供Turbo模式,在这种模式下,芯片会判断在短时间内以更高的时钟频率运行是安全的,可能只在几个核心上运行,直到温度开始上升。例如,3.3 GHz的Core i7在短时间内可以以3.6 GHz运行。实际上,自2008年以来,图1.1中显示的每年最高性能的微处理器都提供了约10%的临时超频。对于单线程代码,这些微处理器可以关闭所有核心,只保留一个核心并让其以更高的速度运行。需要注意的是,尽管操作系统可以关闭Turbo模式,但一旦启用后没有通知,因此程序员可能会因为环境温度变化而发现他们的程序性能有所波动!
虽然动态功率通常被认为是CMOS中功耗的主要来源,但静态功率也成为了一个重要问题,因为即使晶体管处于关闭状态,泄漏电流仍会流动。

也就是说,静态功率与设备数量成正比。因此,即使晶体管处于空闲状态,增加晶体管数量也会增加功耗,而且晶体管尺寸较小的处理器中电流泄漏增加。因此,为了控制泄漏导致的损耗,低功耗系统甚至关闭非活动模块的电源(电源门控)。2011年的目标是使泄漏占总功耗的25%,但在高性能设计中,泄漏有时远远超过这一目标。这些芯片的泄漏可能高达50%,部分原因是需要电力来维持存储值的大容量SRAM缓存。(SRAM中的S代表静态。)停止泄漏的唯一希望是关闭芯片子集的电源。
最后,由于处理器只是整个系统能源成本的一部分,使用一个更快但能效较低的处理器来让其余系统进入睡眠模式是有意义的。这种策略被称为“竞速停止”。能源的重要性提高了对创新效率的审查,因此主要评估标准现在是每焦耳的任务数或每瓦特的性能,而不是过去的每平方毫米硅的性能。这一新指标影响了并行性的方法,我们将在第4章和第5章中看到。
计算机架构的变革源于能源限制
随着晶体管改进的减缓,计算机架构师必须寻找其他途径以提高能效。如今,在给定的能量预算下,设计一个晶体管数量过多以至于无法同时开启的微处理器已变得简单。这种现象被称为“黑硅”,因为芯片的大部分由于热限制而无法在任何时刻都保持启用。这一观察促使架构师重新审视处理器设计的基础,以寻求更高的能源成本性能。
图1.13列出了现代计算机构建块的能源成本和面积成本,揭示了意外的比例差异。例如,32位浮点加法所用的能量是8位整数加法的30倍。面积差异更大,达到60倍。然而,最大的差距在于内存;32位DRAM访问所需的能量是8位加法的20,000倍。小型SRAM的能效是DRAM的125倍,这突显了缓存和内存缓冲区的精确使用的重要性。

图1.13 比较了算术运算的能量和芯片面积,以及对SRAM和DRAM访问的能量成本。[Azizi][Dally]。面积数据基于TSMC 45纳米技术节点。
新的设计原则是最小化每任务的能量,再结合图1.13中的相对能量和面积成本,启发了计算机架构的新方向,这将在第7章中描述。特定领域处理器通过减少广泛的浮点运算并部署专用内存以减少对DRAM的访问来节省能量。它们利用这些节省来提供比传统处理器多10到100倍的(更窄的)整数运算单元。尽管这些处理器仅执行有限的任务,但它们比通用处理器在速度和能效上表现得更为出色。就像医院有全科医生和医疗专家一样,在这个注重能效的世界里,计算机可能会是能够执行任何任务的通用核心与在某些方面表现极其出色且更具成本效益的专用核心的组合。


1.6 Trends in Cost

尽管在某些计算机设计中——特别是超级计算机——成本往往不那么重要,但对成本敏感的设计变得越来越重要。事实上,在过去的35年里,利用技术进步降低成本并提高性能一直是计算机行业的一个主要主题。教科书通常忽略成本与性能的关系,因为成本会发生变化,从而使书籍过时,并且这些问题复杂且在行业领域之间有所不同。然而,对于计算机架构师来说,了解成本及其因素对于做出明智的决策是至关重要的,特别是在成本是一个问题的情况下是否应该在设计中加入新功能(可以想象,如果架构师在设计摩天大楼时没有关于钢梁和混凝土成本的信息,那将是怎样的情景!)。本节将讨论影响计算机成本的主要因素以及这些因素随时间变化的情况。
**时间、产量与商品化的影响**
即使没有显著的基础实施技术改进,制造计算机组件的成本随着时间的推移也会下降。推动成本下降的基本原则是学习曲线——制造成本随着时间的推移而降低。学习曲线的最佳测量方式是通过良率的变化来衡量——即通过测试程序的制造设备的百分比。无论是芯片、板卡还是系统,良率提高一倍的设计将使成本降低一半。
理解学习曲线如何提高良率对于预测产品生命周期内的成本至关重要。例如,DRAM每兆字节的价格长期以来已下降。由于DRAM的价格通常与成本密切相关——除非出现短缺或过剩——价格和成本紧密跟踪。
微处理器的价格也会随时间下降,但由于它们不像DRAM那样标准化,价格与成本之间的关系更为复杂。在激烈竞争的时期,价格通常与成本紧密跟踪,尽管微处理器供应商可能很少以亏损的价格出售。
产量是决定成本的第二个关键因素。增加产量会以多种方式影响成本。首先,它减少了通过学习曲线所需的时间,这部分与制造的系统(或芯片)数量成正比。其次,产量降低了成本,因为它提高了采购和制造效率。作为经验法则,一些设计师估计,每增加一倍产量,成本大约下降10%。此外,产量减少了每台计算机必须摊销的开发成本,从而使成本和售价更加接近,同时仍能获利。
商品化是指由多个供应商以大批量销售的产品,且这些产品基本上是相同的。超市货架上几乎所有的产品都是商品化产品,如标准DRAM、闪存、显示器和键盘。在过去30年里,个人计算机行业的大部分已成为一个商品化业务,专注于生产运行微软Windows的台式机和笔记本电脑。
由于许多供应商提供几乎相同的产品,市场竞争非常激烈。当然,这种竞争缩小了成本和售价之间的差距,但也降低了成本。成本降低的原因在于商品化市场具有体量和明确的产品定义,这使得多个供应商能够竞争构建商品化产品的组件。因此,由于组件供应商之间的竞争和供应商能够实现的体量效率,总体产品成本更低。这种竞争使得计算机业务的低端能够实现比其他领域更好的性价比,并在低端取得了更大的增长,尽管利润非常有限(这是任何商品化业务的典型情况)。
**集成电路的成本**
为什么计算机架构书籍中会有关于集成电路成本的部分?在竞争日益激烈的计算机市场中,标准部件——例如硬盘、闪存、DRAM等——已经占据了系统成本的很大一部分,因此集成电路的成本也成为了不同计算机成本中越来越重要的组成部分,尤其是在高需求和对成本敏感的市场中。事实上,随着PMD(便携式多媒体设备)越来越依赖于系统芯片(SOC),集成电路的成本占据了PMD成本的很大一部分。因此,计算机设计师必须了解芯片的成本,以便更好地理解当前计算机的成本。

图1.16:这个200毫米直径的硅片上装有RISC-V芯片,由SiFive设计。它包含两种类型的RISC-V芯片,使用了较旧、更大的加工工艺线。FE310芯片的尺寸为2.65毫米 × 2.72毫米,而SiFive测试芯片的尺寸为2.89毫米 × 2.72毫米。该硅片包含1846个FE310芯片和1866个测试芯片,共计3712个芯片。
尽管集成电路的成本已呈指数下降,但硅片制造的基本过程并没有改变:硅片仍然被测试并切割成芯片,然后进行封装(参见图1.14-1.16)。因此,封装集成电路的成本是

在本节中,我们将重点讨论芯片的成本,并在最后总结测试和封装中的关键问题。要学会预测每片硅片上的良品数量,首先需要了解每片硅片上能放置多少个芯片,然后预测这些芯片中有多少会正常工作。接下来,预测成本就变得简单了。

这个芯片成本方程的初始项最有趣的特点是其对芯片尺寸的敏感性,如下所示。
每片硅片上的芯片数量大致是硅片面积除以芯片面积。它可以通过以下公式更准确地估算:

第一个项是硅片面积(πr²)与芯片面积的比率。第二项补偿了“方块在圆孔中”的问题——圆形硅片边缘附近的矩形芯片。用圆的周长(πd)除以方形芯片的对角线大致可以得到边缘上芯片的数量。
例子:找出对于侧边长为1.5厘米和1.0厘米的芯片,300毫米(30厘米)硅片上每种芯片的数量。
答案:当芯片面积为2.25平方厘米时,

由于较大芯片的面积是较小芯片面积的2.25倍,因此每片硅片上大约有2.25倍数量的较小芯片。

然而,这个公式只给出了每片硅片上芯片的最大数量。关键问题是:硅片上良品芯片的比例是多少,或芯片的良率是多少?一种简单的集成电路良率模型假设缺陷在硅片上随机分布,且良率与制造工艺的复杂性成反比,得出以下结果:

这个 Bose-Einstein 公式是通过观察许多制造线的良率而发展出的经验模型(Sydow,2006),至今仍然适用。硅片良率考虑了完全坏的硅片,因此不需要测试。为了简化,我们假设硅片良率为100%。每单位面积的缺陷数是随机制造缺陷的度量。2017年,对于28纳米工艺节点,缺陷数通常为每平方英寸0.08–0.10;对于更新的16纳米节点为0.10–0.30,因为这取决于工艺的成熟度(回忆一下之前提到的学习曲线)。相应的度量是28纳米工艺的每平方厘米0.012–0.016缺陷,16纳米工艺为0.016–0.047。最后,N 是一个称为工艺复杂性因子的参数,用来衡量制造难度。对于2017年的28纳米工艺,N 为7.5–9.5;对于16纳米工艺,N 在10到14之间。
示例:假设缺陷密度为每平方厘米0.047,N为12,计算边长分别为1.5厘米和1.0厘米的芯片的良品率。
答案:总芯片面积分别为2.25平方厘米和1.00平方厘米。对于较大的芯片,良品率为

结论是每片晶圆上的良品芯片数量。大尺寸芯片中不到一半是良品,而小尺寸芯片中接近70%是良品。
尽管许多微处理器的面积在1.00到2.25平方厘米之间,低端嵌入式32位处理器的面积有时仅为0.05平方厘米,用于嵌入式控制(如廉价的物联网设备)的处理器通常小于0.01平方厘米,而高端服务器和GPU芯片的面积可以达到8平方厘米。
考虑到对商品产品(如DRAM和SRAM)的巨大价格压力,设计师们已经加入了冗余以提高良品率。多年来,DRAM中通常会包括一些冗余存储单元,以容纳一定数量的缺陷。设计师们在标准SRAM和用于微处理器缓存的大型SRAM阵列中也使用了类似的技术。GPU为了同样的原因在84个处理器中包含了4个冗余处理器。显然,冗余条目的存在可以显著提高良品率。
在2017年,使用28纳米技术处理直径300毫米(12英寸)的晶圆的成本在4000到5000美元之间,而16纳米晶圆的成本约为7000美元。假设处理后的晶圆成本为7000美元,那么1.00平方厘米的芯片的成本大约为16美元,而2.25平方厘米芯片的成本约为58美元,几乎是稍大两倍的芯片成本的四倍。
计算机设计师应记住芯片成本的哪些因素?制造工艺决定了晶圆成本、晶圆良品率以及单位面积的缺陷数量,因此设计师唯一可以控制的是芯片面积。在实际操作中,由于单位面积的缺陷数量较少,良品芯片的数量以及每个芯片的成本大致随着芯片面积的平方增长。计算机设计师通过在芯片上包含或排除哪些功能以及I/O引脚的数量来影响芯片大小,从而影响成本。
在计算机中使用之前,芯片必须经过测试(以区分良品和不良品)、封装,并在封装后再次测试。这些步骤都增加了显著的成本,使总成本增加了一半。
上述分析集中在生产功能芯片的可变成本上,这对于大批量集成电路来说是合适的。然而,对于低批量(少于100万件)的集成电路,固定成本中的一个非常重要的部分是掩模组的成本。集成电路过程中的每一步都需要一个单独的掩模。因此,对于现代高密度制造工艺(最多有10层金属层),16纳米的掩模成本约为400万美元,28纳米的掩模成本约为150万美元。
好消息是,半导体公司提供了“穿梭测试”服务,以大幅降低小型测试芯片的成本。它们通过将许多小设计放置在一个芯片上来摊销掩模成本,然后再将这些芯片拆分成更小的部分用于每个项目。因此,TSMC在2017年提供了80到100个未测试的28纳米工艺芯片,每个芯片的尺寸为1.57×1.57毫米,价格为30,000美元。尽管这些芯片很小,但它们为架构师提供了数百万个晶体管。例如,几个RISC-V处理器可以适配在这样的芯片上。
尽管穿梭测试有助于原型设计和调试,但它们并不能解决生产数万到十几万件的小批量生产问题。由于掩模成本可能继续上升,一些设计师正在将可重构逻辑纳入设计中,以增强零件的灵活性,从而减少掩模的成本影响。
### 成本与价格
随着计算机的商品化,制造一个产品的成本与产品售价之间的差距正在缩小。这些差距用于支付公司的研究与开发(R&D)、市场营销、销售、制造设备维护、建筑租金、融资成本、税前利润以及税费。许多工程师会惊讶地发现,大多数公司在研发上的支出仅占其收入的4%(在普通PC业务中)到12%(在高端服务器业务中),这些支出包括所有的工程费用。
### 制造成本与运营成本
在本书的前四版中,“成本”指的是建造计算机的成本,而“价格”指的是购买计算机的价格。随着Web规模计算(WSC)的出现,这些系统包含数以万计的服务器,运营计算机的成本在购买成本之外也变得相当重要。经济学家将这两种成本称为资本支出(CAPEX)和运营支出(OPEX)。
如第六章所示,服务器和网络的摊销购买价格大约是运营一个WSC的月度成本的一半,假设IT设备的短期寿命为3-4年。尽管这些基础设施的摊销期为10-15年,但每月运营成本中约40%用于电力消耗以及分配电力和冷却IT设备的基础设施。因此,为了降低WSC的运营成本,计算机架构师需要高效地使用能源。


1.7 Dependability

历史上,集成电路是计算机中最可靠的组件之一。尽管其引脚可能会受到威胁,通信通道可能会出现故障,但芯片内部的故障率非常低。随着特征尺寸逐渐缩小到16纳米及以下,这种传统观念正在发生变化,因为瞬态故障和永久性故障变得更加普遍,因此架构师必须设计系统以应对这些挑战。本节简要概述了可靠性问题,将术语和方法的正式定义留给附录D的第D.3节。
计算机在不同的抽象层次上设计和构建。我们可以递归地深入计算机,看到组件逐渐扩展为完整的子系统,直到我们遇到单个晶体管。尽管一些故障是广泛存在的,如电源丧失,但许多故障可以限制在模块中的单一组件。因此,一个层次的模块的完全失败在更高层次的模块中可能仅被视为组件错误。这种区分有助于找到构建可靠计算机的方法。
一个困难的问题是决定系统是否正常运行。这一理论问题在互联网服务普及后变得具体化。基础设施提供商开始提供服务水平协议(SLA)或服务水平目标(SLO),以保证其网络或电力服务的可靠性。例如,如果他们未能满足每月一定小时的协议,他们将向客户支付罚金。因此,SLA可以用于决定系统是运行还是停机。
系统在服务水平协议(SLA)方面在两种状态之间交替:
1. 服务完成,指按照规定交付服务。
2. 服务中断,指交付的服务与SLA不符。
这些状态之间的转换由故障(从状态1到状态2)或恢复(从状态2到状态1)引起。量化这些转换会产生两个主要的可靠性度量:
- 模块可靠性是指从参考初始时刻起,服务完成的持续时间(或等效地,故障时间)。因此,平均故障时间(MTTF)是一种可靠性度量。MTTF的倒数是故障率,通常以每十亿小时操作的故障数表示,或称为FIT(故障数)。例如,MTTF为1,000,000小时等于10^9 / 10^6或1000 FIT。服务中断的测量为平均修复时间(MTTR)。平均故障间隔时间(MTBF)是MTTF和MTTR之和。虽然MTBF广泛使用,但MTTF通常是更合适的术语。如果一组模块的寿命呈指数分布——即模块的年龄对故障概率无关——则该集合的总体故障率是模块故障率的总和。
- 模块可用性是指相对于完成和中断这两种状态之间交替的服务完成情况。对于具有修复功能的非冗余系统,模块可用性是

请注意,可靠性和可用性现在是可量化的度量标准,而不再是可靠性的同义词。根据这些定义,如果我们对组件的可靠性做出一些假设,并且假设故障是独立的,那么我们可以定量估计系统的可靠性。
例子:假设一个磁盘子系统包含以下组件及其MTTF:
- 10个磁盘,每个磁盘的MTTF为1,000,000小时
- 1个ATA控制器,MTTF为500,000小时
- 1个电源,MTTF为200,000小时
- 1个风扇,MTTF为200,000小时
- 1根ATA电缆,MTTF为1,000,000小时
使用简化假设:寿命呈指数分布,故障是独立的,计算整个系统的MTTF。
答案:故障率的总和是


应对故障的主要方法是冗余,既可以是时间上的冗余(重复操作以查看是否仍然出错),也可以是资源上的冗余(让其他组件接管故障组件的工作)。一旦组件被替换且系统完全修复,系统的可靠性被认为和新系统一样好。让我们通过一个例子来量化冗余的好处。
例子:磁盘子系统通常配备冗余电源以提高可靠性。使用上述组件和MTTF,计算冗余电源的可靠性。假设一个电源足以运行磁盘子系统,我们正在添加一个冗余电源。
答案:我们需要一个公式来显示当可以容忍一次故障且仍能提供服务时的预期情况。为了简化计算,我们假设组件的寿命呈指数分布且故障之间没有依赖性。冗余电源的MTTF是一个电源故障的平均时间除以另一个电源在第一个电源更换之前故障的概率。因此,如果第二次故障发生的可能性很小,那么这对电源的MTTF就会很大。
由于我们有两个电源且故障是独立的,一个电源故障的平均时间是MTTF电源/2。第二次故障的概率的一个很好的近似值是MTTR除以另一个电源故障的平均时间。因此,冗余电源对的合理近似值是

使用上述MTTF数字,如果我们假设人类操作员平均需要24小时才能发现电源故障并进行更换,那么故障容错电源对的可靠性是

1.8 Measuring, Reporting, and Summarizing Performance

当我们说一台计算机比另一台计算机更快时,我们指的是什么?手机用户可能会说一台计算机更快是因为一个程序运行的时间更短,而亚马逊网站管理员可能会说一台计算机更快是因为它每小时完成的事务更多。手机用户希望减少响应时间——即事件开始和完成之间的时间,也称为执行时间。WSC(大规模数据中心)操作员希望提高吞吐量——即在给定时间内完成的总工作量。
在比较设计方案时,我们通常希望比较两台不同计算机的性能,比如X和Y。在这里,“X比Y更快”这个短语指的是在给定任务上X的响应时间或执行时间低于Y。特别地,“X比Y快n倍”将意味着

短语“X的吞吐量是Y的1.3倍”在这里表示计算机X在单位时间内完成的任务数量是计算机Y的1.3倍。
不幸的是,在比较计算机性能时,时间并不总是被引用的指标。我们的观点是,唯一一致且可靠的性能度量是实际程序的执行时间,而所有提出的替代时间作为指标或实际程序作为测量对象的方法,最终都导致了误导性的声明或计算机设计中的错误。
即使是执行时间也可以根据我们统计的内容定义不同。最直接的时间定义称为挂钟时间、响应时间或经过时间,即完成任务的延迟,包括存储访问、内存访问、输入/输出活动、操作系统开销——所有内容。由于多程序设计,处理器在等待I/O时可能会处理其他程序,这可能不会最小化一个程序的经过时间。因此,我们需要一个术语来考虑这种活动。CPU时间承认了这种区别,指的是处理器计算的时间,不包括等待I/O或运行其他程序的时间。(显然,用户看到的响应时间是程序的经过时间,而不是CPU时间。)
那些经常运行相同程序的计算机用户将是评估新计算机的理想候选人。为了评估新系统,这些用户只需比较他们工作负载的执行时间——即他们在计算机上运行的程序和操作系统命令的混合体。然而,只有少数人处于这种理想状况。大多数人必须依赖其他方法来评估计算机,通常依靠其他评估者,希望这些方法能预测新计算机的性能。一种方法是基准程序,这是许多公司用来确定计算机相对性能的程序。
基准测试
测量性能的最佳基准选择是实际应用程序,例如第1.1节中提到的Google Translate。尝试运行比实际应用程序简单得多的程序通常会导致性能误区。以下是一些例子:
■ 核心程序,它们是实际应用程序中的小型关键部分。
■ 玩具程序,例如Quicksort,是由100行代码组成的初学者编程作业。
■ 合成基准,它们是为了模拟真实应用程序的特征和行为而发明的假程序,例如Dhrystone。
这三种基准方法如今都不再被信任,通常因为编译器作者和架构师可以密谋使计算机在这些替代程序上的表现看起来比在实际应用程序上更快。令人遗憾的是——尽管我们在本书第四版中抛弃了使用合成基准来表征性能的错误观念,因为我们认为所有计算机架构师都同意这已不再可信——合成程序Dhrystone在2017年仍然是嵌入式处理器最广泛引用的基准测试!
另一个问题是基准测试运行的条件。提高基准测试性能的一种方法是使用基准测试特定的编译器标志;这些标志通常会引起对许多程序来说非法的转换或会降低其他程序的性能。为了限制这种情况并提高结果的显著性,基准测试开发人员通常要求供应商为同一语言中的所有程序(如C++或C)使用一个编译器和一组标志。除了编译器标志的问题,另一个问题是是否允许源代码修改。对此问题有三种不同的解决方法:
1. 不允许修改源代码。
2. 允许修改源代码,但实际上几乎不可能。例如,数据库基准依赖于标准的数据库程序,这些程序有数千万行代码。数据库公司极不可能为某台特定计算机进行性能提升的修改。
3. 允许源代码修改,只要修改后的版本产生相同的输出。
基准设计者面临的关键问题是,允许修改源代码是否会反映实际应用,并为用户提供有用的见解,或者这些修改是否仅仅降低了基准作为真实性能预测工具的准确性。正如第七章所述,领域特定的架构师在为明确定义的任务创建处理器时,通常会选择第三种方案。
为了避免将所有资源集中于一个方案,基准应用程序的集合,称为基准套件,是衡量处理器在各种应用程序中性能的一个受欢迎的方式。当然,这些集合的质量取决于其组成的单个基准程序。然而,基准套件的一个关键优势是,它们能够通过其他基准程序来减少任何一个基准程序的弱点。基准套件的目标是准确地描述两台计算机的实际相对性能,特别是针对客户可能运行的、不在套件中的程序。
一个警示性的例子是电子设计新闻嵌入式微处理器基准联盟(EEMBC)的基准测试。这是一组41个核心程序,用于预测不同嵌入式应用的性能:汽车/工业、消费、网络、办公自动化和电信。EEMBC报告了未经修改的性能和“完全疯狂”性能,其中几乎允许任何操作。由于这些基准测试使用小核心程序,并且由于报告选项,EEMBC并没有成为一个良好的嵌入式计算机相对性能的预测工具。这种不成功是为什么Dhrystone,EEMBC试图替代的基准,遗憾地仍然在使用的原因。
最成功的标准化基准应用程序套件之一是SPEC(标准性能评估公司),其起源于20世纪80年代末期的努力,旨在提供更好的工作站基准。正如计算机行业随时间演变一样,对不同基准套件的需求也随之发展,目前有SPEC基准覆盖了许多应用程序类别。所有SPEC基准套件及其报告结果都可以在http://www.spec.org找到。
尽管我们在接下来的许多部分中将重点讨论SPEC基准,但也有许多基准被开发用于运行Windows操作系统的PC。
桌面基准
桌面基准测试分为两大类:处理器密集型基准测试和图形密集型基准测试,尽管许多图形基准测试也包括密集的处理器活动。SPEC最初创建了一个专注于处理器性能的基准集(最初称为SPEC89),它已经发展到第六代:SPEC CPU2017,继SPEC2006、SPEC2000、SPEC95、SPEC92和SPEC89之后。SPEC CPU2017包括10个整数基准测试(CINT2017)和17个浮点基准测试(CFP2017)。
Figure 1.17 describes the current SPEC CPU benchmarks and their ancestry

图1.17展示了SPEC2017程序及SPEC基准测试随时间的演变,其中整数程序在上方,浮点程序在下方。在10个SPEC2017整数程序中,有5个用C编写,4个用C++编写,1个用Fortran编写。对于浮点程序,分布为3个用Fortran,2个用C++,2个用C,6个用混合C、C++和Fortran编写。图中显示了1989年、1992年、1995年、2000年、2006年和2017年版本中的82个程序。Gcc是该组的“资深者”。只有3个整数程序和3个浮点程序存活了三代或更多代。虽然有些程序从一代延续到另一代,但程序的版本会变化,基准的输入或大小通常会扩大,以增加运行时间并避免测量中的扰动或被除CPU时间以外的其他因素主导执行时间。左侧的基准描述仅适用于SPEC2017,不适用于早期版本。来自不同SPEC代的同一行程序通常无关,例如,fpppp不像bwaves那样是CFD代码。
SPEC基准测试是经过修改的实际程序,旨在提高可移植性并最小化I/O对性能的影响。整数基准测试包括C编译器的一部分、go程序和视频压缩。浮点基准测试包括分子动力学、光线追踪和天气预测。SPEC CPU套件对桌面系统和单处理器服务器的处理器基准测试都很有用。我们将在本书中看到这些程序的数据。然而,这些程序与现代编程语言和环境、以及第1.1节中描述的Google翻译应用程序相去甚远。它们中近一半至少部分是用Fortran编写的!它们甚至是静态链接的,而不是像大多数实际程序那样动态链接的。虽然SPEC2017应用程序本身可能真实,但并不令人振奋。SPECINT2017和SPECFP2017是否捕捉到21世纪计算的激动人心之处尚不明确。
在第1.11节中,我们将描述在开发SPEC CPU基准套件过程中出现的陷阱,以及维护有用且具有预测性的基准套件面临的挑战。
SPEC CPU2017专注于处理器性能,但SPEC提供了许多其他基准测试。图1.18列出了2017年活跃的17个SPEC基准测试。

服务器基准测试
正如服务器有多种功能一样,也有多种类型的基准测试。最简单的基准测试可能是面向处理器吞吐量的测试。SPEC CPU2017 使用 SPEC CPU 基准来构建一个简单的吞吐量基准,通过运行每个 SPEC CPU 基准的多个副本(通常与处理器数量相同),并将 CPU 时间转换为速率,从而测量多处理器的处理速率。这得出了一个叫做 SPECrate 的测量值,它衡量了请求级并行性。为了测量线程级并行性,SPEC 提供了所谓的高性能计算基准,涉及 OpenMP 和 MPI 以及 GPU 等加速器(见图 1.18)。
除了 SPECrate,大多数服务器应用程序和基准测试都涉及显著的 I/O 活动,包括存储或网络流量的基准测试,如文件服务器系统、网页服务器以及数据库和事务处理系统的基准测试。SPEC 提供了文件服务器基准(SPECSFS)和 Java 服务器基准(详见附录 D)。SPECvirt_Sc2013 评估虚拟化数据中心服务器的端到端性能。另一个 SPEC 基准测量功耗,详见第 1.10 节。
事务处理(TP)基准测试测量系统处理数据库访问和更新的事务能力。航空公司预订系统和银行 ATM 系统是 TP 的典型简单示例;更复杂的 TP 系统涉及复杂的数据库和决策制定。20 世纪 80 年代中期,一群关注的工程师成立了供应商独立的事务处理委员会(TPC),旨在创建现实且公平的 TP 基准。TPC 基准测试描述详见 http://www.tpc.org。
第一个 TPC 基准测试,TPC-A,于 1985 年发布,此后被多个不同的基准测试取代和增强。TPC-C 于 1992 年首次创建,模拟复杂的查询环境。TPC-H 模拟即席决策支持——查询相互独立,无法利用过去的查询知识来优化未来的查询。TPC-DI 基准测试是一个新的数据集成(DI)任务,也称为 ETL,是数据仓库的重要部分。TPC-E 是一个在线事务处理(OLTP)工作负载,模拟经纪公司客户账户。
考虑到传统关系数据库和 “No SQL” 存储解决方案之间的争议,TPCx-HS 测量使用 Hadoop 文件系统运行 MapReduce 程序的系统,TPC-DS 测量使用关系数据库或基于 Hadoop 的系统的决策支持系统。TPC-VMS 和 TPCx-V 测量虚拟化系统的数据库性能,而 TPC-Energy 为所有现有 TPC 基准增加了能源指标。
所有 TPC 基准都以每秒事务数来衡量性能。此外,它们包括响应时间要求,以确保吞吐量性能仅在响应时间限制内进行测量。为了模拟现实世界系统,更高的事务处理速率也与更大的系统相关,包括用户和应用事务的数据库。最后,基准系统的成本也必须包括在内,以便准确比较成本性能。TPC 修改了定价政策,以便为所有 TPC 基准提供单一规范,并允许验证 TPC 发布的价格。
**报告性能结果**
报告性能测量结果的指导原则应是可重复性——列出另一个实验者需要的所有信息,以便能够重复实验结果。SPEC 基准测试报告要求对计算机和编译器标志进行详尽描述,并且必须公布基准结果和优化结果。除了硬件、软件和基准调优参数的描述外,SPEC 报告还包含实际的性能时间,这些数据以表格形式和图形形式展示。TPC 基准测试报告则更为详尽,因为它必须包括基准审计结果和成本信息。这些报告是寻找计算系统真实成本的绝佳来源,因为制造商在高性能和成本效益上竞争。
**总结性能结果**
在实际计算机设计中,必须评估大量设计选择在一系列被认为相关的基准测试中的相对定量效益。同样,消费者在选择计算机时会依赖于基准测试的性能测量,这些基准测试理想情况下应与用户的应用程序类似。在这两种情况下,拥有一系列基准测试的测量结果是有用的,这样可以确保重要应用程序的性能与一或多个基准测试的结果相似,并且可以理解性能的变异性。在最佳情况下,该系列基准测试类似于应用空间的统计有效样本,但这样的样本需要比大多数系列中典型的更多的基准测试,并且需要随机抽样,这几乎没有基准测试系列使用。
一旦选择用基准测试系列来测量性能,我们希望能够用一个唯一的数字来总结该系列的性能结果。一种简单的方法是比较系列中程序执行时间的算术平均值。另一种方法是为每个基准添加一个权重因子,使用加权算术平均值作为总结性能的单一数字。一种方法是使用使所有程序在某个参考计算机上执行相等时间的权重,但这会使结果偏向于参考计算机的性能特征。
而不是选择权重,我们可以通过将参考计算机上的时间除以被评估计算机上的时间来规范化执行时间,从而得到与性能成比例的比率。SPEC 使用这种方法,称这种比率为 SPECRatio。它具有特别有用的属性,符合我们在本书中基准测试计算机性能的方式——即比较性能比率。例如,假设计算机 A 在某基准测试中的 SPECRatio 是计算机 B 的 1.25 倍;那么我们知道

注意,当比较作为比率进行时,参考计算机上的执行时间会被消除,选择参考计算机也变得无关紧要,这正是我们始终使用的方法。图 1.19 给出了一个例子。由于 SPECRatio 是比率而非绝对执行时间,均值必须使用几何均值来计算。(因为 SPECRatios 没有单位,所以算术上比较 SPECRatios 是没有意义的。)公式是

在 SPEC 的情况下,samplei 是程序 i 的 SPECRatio。使用几何均值确保了两个重要属性:
1. 比率的几何均值等于几何均值的比率。
2. 几何均值的比率等于性能比率的几何均值,这意味着选择参考计算机是不相关的。
因此,使用几何均值的理由是充分的,特别是当我们使用性能比率进行比较时。
例子:证明几何均值的比率等于性能比率的几何均值,并且 SPECRatio 的参考计算机不重要。
解答:假设有两台计算机 A 和 B,以及每台计算机的一组 SPECRatios。

也就是说,计算机 A 和 B 的 SPECRatios 的几何均值比率等于 A 和 B 在所有基准测试中的性能比率的几何均值。图 1.19 使用 SPEC 的例子展示了这一有效性。

图 1.19 显示了 Sun Ultra 5(SPEC2006 的参考计算机)的 SPEC2006Cint 执行时间(以秒为单位),以及 AMD A10 和 Intel Xeon E5-2690 的执行时间和 SPECRatios。最后两列展示了执行时间比率和 SPEC 比率的比率。该图示例明了参考计算机在相对性能中的不相关性。执行时间的比率与 SPECRatios 的比率完全一致,几何均值的比率(63.72/31.91 ÷ 20.86 ≈ 2.00)与比率的几何均值(2.00)完全相同。第 1.11 节讨论了 libquantum,它的性能比其他 SPEC 基准测试高几个数量级。


1.9 Quantitative Principles of Computer Design

现在我们已经了解了如何定义、测量和总结性能、成本、可靠性、能耗和功耗,我们可以探索在计算机设计和分析中有用的指导方针和原则。本节介绍了设计中的重要观察点,以及用于评估替代方案的两个方程。
利用并行性
利用并行性是提高性能的重要方法之一。本书的每一章都有如何通过利用并行性来增强性能的示例。这里我们给出三个简短的例子,后续章节将对此进行详细阐述。
第一个例子是系统级别的并行性使用。为了提高典型服务器基准测试(如 SPECSFS 或 TPC-C)的吞吐量性能,可以使用多个处理器和多个存储设备。处理请求的工作负载可以在处理器和存储设备之间分配,从而提高吞吐量。能够扩展内存以及处理器和存储设备的数量被称为可扩展性,这对于服务器来说是一项宝贵的资产。将数据分布在多个存储设备上进行并行读写实现了数据级并行性。SPECSFS 还依赖于请求级并行性来使用多个处理器,而 TPC-C 则利用线程级并行性来加快数据库查询的处理速度。
在单个处理器级别,利用指令间的并行性对于实现高性能至关重要。实现这一点的最简单方法之一是通过流水线技术。(流水线技术在附录 C 中有更详细的解释,并且是第 3 章的主要内容。)流水线技术的基本思想是重叠指令执行,以减少完成指令序列的总时间。对流水线技术的一个关键见解是,并非每条指令都依赖于其直接前驱,因此可能可以完全或部分地并行执行这些指令。流水线技术是 ILP(指令级并行性)的最著名示例。
并行性还可以在详细的数字设计级别上得到利用。例如,集合关联缓存使用多个内存银行,这些内存银行通常会并行搜索以找到所需的项目。算术逻辑单元使用进位前瞻技术,这种技术利用并行性将计算和数值加法的过程从线性时间缩短到对数时间。这些都是数据级并行性的更多示例。
**局部性原理**
从程序的属性中得到了一些重要的基本观察。我们经常利用的最重要的程序属性是局部性原理:程序倾向于重用最近使用过的数据和指令。一个广泛接受的经验法则是,程序的90%执行时间花费在仅10%的代码上。局部性的一个含义是,我们可以基于程序最近的访问情况,合理地预测程序在不久的将来会使用哪些指令和数据。局部性原理也适用于数据访问,尽管它对数据访问的影响没有对代码访问那么强烈。
已经观察到两种不同类型的局部性。时间局部性表明,最近访问过的项很可能会很快再次被访问。空间局部性则表明,地址相近的项往往会在时间上紧密地被引用。我们将在第二章中看到这些原理的应用。
**关注常见情况**
计算机设计中也许最重要和最普遍的原则是关注常见情况:在进行设计权衡时,优先考虑频繁发生的情况而非不常见的情况。这个原则适用于确定如何分配资源,因为如果某种情况很常见,则改善的效果会更显著。关注常见情况不仅适用于能源管理,还适用于资源分配和性能优化。处理器的指令获取和解码单元可能比乘法器使用得更频繁,因此应优先优化它。这个原则也适用于可靠性方面。如果一个数据库服务器有50个存储设备与每个处理器配对,那么存储的可靠性将主导系统的整体可靠性。
此外,常见情况往往比不常见情况更简单,可以更快地处理。例如,在处理器中添加两个数字时,我们可以预期溢出是一个罕见的情况,因此可以通过优化更常见的没有溢出的情况来提高性能。这种强调可能会使溢出发生时的处理变慢,但如果这种情况很少见,那么通过优化正常情况来提升整体性能。
在本文中,我们将看到许多应用这一原则的实例。在应用这一简单原则时,我们必须决定什么是频繁发生的情况,并评估通过使这些情况更快处理可以提高多少性能。一条称为阿姆达尔定律的基本法则可以用来量化这一原则。
**阿姆达尔定律**
通过改进计算机的某些部分所能获得的性能提升可以使用阿姆达尔定律来计算。阿姆达尔定律指出,通过使用某种更快的执行模式所获得的性能提升受到该更快模式可以使用时间比例的限制。
阿姆达尔定律定义了通过使用特定特性所能获得的加速比。那么,加速比是什么呢?假设我们可以对计算机进行某种增强,从而在使用时提高性能。加速比是

加速比告诉我们,在使用增强功能的计算机上,任务运行速度相比于原始计算机提高了多少。阿姆达尔定律为我们提供了一种快速找到某项增强的加速比的方法,这依赖于两个因素:
1. 在原始计算机上可以利用增强功能的计算时间比例——例如,如果一个总执行时间为100秒的程序中,有40秒的时间可以使用增强功能,那么这个比例就是40/100。这个值称为Fractionenhanced,始终小于或等于1。
2. 增强执行模式带来的改进,即如果整个程序都使用增强模式,任务会运行得多快——这个值是原始模式的时间与增强模式的时间之比。如果增强模式对于程序的一部分需要4秒,而原始模式下是40秒,那么改进比为40/4,即10。我们称这个值为Speedupenhanced,始终大于1。
使用原始计算机加上增强模式的执行时间将是使用未增强部分的时间加上使用增强部分的时间。

例子
假设我们想要提升用于网页服务的处理器。新处理器在网页服务应用中的计算速度比旧处理器快10倍。假设原始处理器在40%的时间里忙于计算,而在60%的时间里等待I/O,那么通过引入增强功能所获得的总体加速比是多少?
答案

阿姆达尔定律表达了收益递减法则:通过对部分计算的改进所获得的加速增益会随着改进的增加而递减。阿姆达尔定律的一个重要推论是,如果增强功能仅能用于任务的一部分,那么我们不能使任务的加速比超过1减去那个比例的倒数。
在应用阿姆达尔定律时,一个常见的错误是混淆“转换为使用增强功能的时间比例”和“增强功能使用后的时间比例”。如果我们不是测量可以使用增强功能的计算时间,而是测量增强功能使用后的时间,那么结果将是不正确的!
阿姆达尔定律可以作为指导,帮助我们了解增强功能将如何改善性能,以及如何分配资源以提高性价比。显然,目标是将资源投入与时间支出的比例相匹配。阿姆达尔定律特别适用于比较两个方案的整体系统性能,但它也可以用于比较两种处理器设计方案,如以下示例所示。
示例
在图形处理器中,一个常见的变换是平方根。浮点数(FP)平方根的实现性能差异很大,特别是在为图形设计的处理器中。假设浮点平方根(FSQRT)在一个关键的图形基准测试中占用了20%的执行时间。一个提议是提升FSQRT硬件,将这一操作的速度提高10倍。另一种方案是将所有浮点指令在图形处理器中的执行速度提高1.6倍;浮点指令负责应用程序执行时间的一半。设计团队认为,他们可以用与快速平方根相同的努力使所有浮点指令速度提高1.6倍。比较这两种设计方案。
答案
我们可以通过比较这两种方案的加速比来进行比较:

提升浮点操作的整体性能稍微更好,因为频率更高。
阿姆达尔定律不仅适用于性能。让我们重新审视第39页中的可靠性示例,假设通过冗余将电源的可靠性从200,000小时提高到830,000,000小时的平均无故障时间(MTTF),即提高了4150倍。
示例
磁盘子系统故障率的计算是

因此,可以改进的故障率比例是每百万小时中5次故障,相对于整个系统的23次故障,即0.22。
答案
可靠性改进将是

尽管一个模块的可靠性提高了令人印象深刻的4150倍,但从系统的角度来看,这一变化带来的好处是可测量的但相对较小。
在前面的示例中,我们需要新版本和改进版本所消耗的比例;通常很难直接测量这些时间。在下一部分中,我们将看到另一种进行此类比较的方法,这种方法基于一个将CPU执行时间分解为三个独立组件的方程式。如果我们知道某个替代方案如何影响这三个组件,就可以确定它的整体性能。此外,通常可以在硬件实际设计之前构建模拟器来测量这些组件。
处理器性能方程
几乎所有计算机都是使用以恒定频率运行的时钟构建的。这些离散的时间事件称为时钟周期、时钟周期或时钟周期。计算机设计师通过时钟周期的持续时间(例如,1纳秒)或频率(例如,1 GHz)来表示时钟周期的时间。程序的CPU时间可以用两种方式来表示:

除了执行程序所需的时钟周期数外,我们还可以计算执行的指令数,即指令路径长度或指令计数(IC)。如果我们知道时钟周期数和指令计数,就可以计算每条指令的平均时钟周期数(CPI)。由于CPI更易于操作,并且我们在本章中将处理简单的处理器,因此使用CPI。设计师有时也使用每时钟周期指令数(IPC),它是CPI的倒数。CPI的计算公式为:

这个处理器性能指标提供了对不同指令集和实现风格的洞察,我们将在接下来的四章中广泛使用它。
通过转置前述公式中的指令计数,可以将时钟周期定义为 IC × CPI。这使我们可以在执行时间公式中使用CPI:

将第一个公式展开成测量单位的形式,可以展示各部分是如何组合在一起的:

正如这个公式所示,处理器性能依赖于三个特性:时钟周期(或频率)、每条指令的时钟周期数和指令计数。此外,CPU时间同样依赖于这三个特性;例如,任何一个特性的10%改善会导致CPU时间的10%改善。
不幸的是,由于改变每个特性的基本技术是相互依赖的,因此很难在完全隔离的情况下改变一个参数:
- 时钟周期时间——硬件技术和组织
- CPI——组织和指令集架构
- 指令计数——指令集架构和编译器技术
幸运的是,许多潜在的性能改进技术主要增强处理器性能的一个组件,同时对其他两个组件的影响较小或可预测。
在设计处理器时,有时计算总处理器时钟周期数是很有用的:

其中 ICi 代表指令 i 在程序中执行的次数,而 CPIi 代表指令 i 的平均每条指令时钟周期数。这个形式可以用来表示 CPU 时间为:

CPI 计算的后一种形式使用每个 CPIi 以及该指令在程序中出现的比例(即 ICi)。由于它必须包括流水线效应、缓存缺失以及其他内存系统的低效,CPIi 应该通过测量获得,而不仅仅是从参考手册的表格中计算得出。
考虑我们在第 52 页的性能示例,这里修改为使用指令频率和指令 CPI 值的测量值,这些测量值实际上是通过模拟或硬件仪器获得的。
示例
假设我们进行了以下测量:
- 浮点运算的频率 = 25%
- 浮点运算的平均 CPI = 4.0
- 其他指令的平均 CPI = 1.33
- FSQRT 的频率 = 2%
- FSQRT 的 CPI = 20
答案
假设两种设计方案是将 FSQRT 的 CPI 降低到 2,或者将所有浮点运算的平均 CPI 降低到 2.5。使用处理器性能方程比较这两种设计方案。
首先,注意到只有 CPI 改变,时钟频率和指令计数保持不变。我们开始通过计算没有任何增强的原始 CPI。

通常可以测量处理器性能方程的组成部分。这些独立的测量是使用处理器性能方程相较于前述 Amdahl 定律的关键优势。特别是,测量一组指令负责的执行时间比例可能很困难。在实践中,这通常通过将每个指令的指令计数和 CPI 的乘积求和来计算。由于起点通常是单独的指令计数和 CPI 测量,因此处理器性能方程非常有用。
要将处理器性能方程作为设计工具使用,我们需要能够测量各种因素。对于现有处理器,通过测量获取执行时间很简单,而时钟速度是已知的。挑战在于发现指令计数或 CPI。大多数处理器包括执行的指令和时钟周期的计数器。通过定期监控这些计数器,还可以将执行时间和指令计数附加到代码的片段上,这对试图理解和优化应用程序性能的程序员非常有帮助。设计师或程序员通常会希望在比硬件计数器提供的更精细的级别上理解性能。例如,他们可能想知道 CPI 的具体原因。在这种情况下,使用的模拟技术类似于设计中的处理器。
有助于能源效率的技术,如动态电压频率调整和超频(参见第 1.5 节),使得使用这个方程变得更难,因为在测量程序时时钟速度可能会变化。一个简单的方法是关闭这些功能以使结果可重复。幸运的是,由于性能和能源效率通常高度相关——运行程序所需的时间更少通常会节省能源——因此考虑性能时通常可以不必担心 DVFS 或超频对结果的影响。


1.10 Putting It All Together: Performance, Price, and Power

在每章末尾出现的“综合应用”部分,我们提供了实际示例,这些示例使用了本章中的原则。在本节中,我们将使用SPECpower基准测试来观察小型服务器的性能和功耗性能指标。
图1.20展示了我们正在评估的三款多处理器服务器及其价格。为了保持价格比较的公平性,所有服务器都是戴尔PowerEdge系列。第一款是PowerEdge R710,它基于Intel Xeon #85670微处理器,时钟频率为2.93 GHz。与第2–5章中的Intel Core i7-6700(具有20个核心和40 MB L3缓存)不同,这款Intel芯片具有22个核心和55 MB L3缓存,尽管核心本身是相同的。我们选择了一个双插槽系统(总共44个核心),配备128 GB的ECC保护2400 MHz DDR4 DRAM。下一款服务器是PowerEdge C630,拥有相同的处理器、插槽数量和DRAM。主要的区别在于它的机架安装包更小:730的高度为“2U”(3.5英寸),而630为“1U”(1.75英寸)。

图1.20 显示了三台戴尔PowerEdge服务器的测量情况及其2016年7月的价格。我们通过减去第二个处理器的成本来计算处理器的费用。类似地,我们通过查看额外内存的成本来计算整体内存的费用。因此,服务器的基本成本通过去除默认处理器和内存的估算成本进行调整。第5章描述了这些多插槽系统是如何连接在一起的,第6章则描述了集群是如何连接在一起的。
第三款服务器是由16台PowerEdge 630组成的集群,通过1 Gbit/s以太网交换机连接。所有服务器都运行Oracle Java HotSpot版本1.7的Java虚拟机(JVM)和Microsoft Windows Server 2012 R2 Datacenter版本6.3操作系统。
注意,由于基准测试的影响(见第1.11节),这些服务器配置异常。图1.20中的系统相对于计算量拥有较少的内存,并且仅有一个120 GB的固态硬盘。如果不需要相应增加内存和存储,增加核心的成本较低!
SPECpower使用了基于SPECjbb的现代Java软件栈,测量的性能指标是每秒事务数,称为ssj_ops。它不仅测试服务器的处理器,还测试缓存、内存系统以及多处理器互连系统。此外,它还测试JVM,包括JIT运行时编译器和垃圾回收器,以及底层操作系统的部分功能。
如图1.20的最后两行所示,性能赢家是16台R630服务器的集群,这并不令人意外,因为它是最昂贵的。性价比最高的是PowerEdge R630,但它在213和211 ssj-ops/$之间略微领先于集群。令人惊讶的是,尽管16节点集群比单节点大16倍,但其性价比与单节点差距不到1%。
虽然大多数基准测试(和计算机架构师)只关注系统在峰值负载下的性能,但计算机很少在峰值负载下运行。事实上,第6章的图6.2显示了在Google对数万台服务器进行的6个月利用率测量结果,其中不到1%的服务器的平均利用率达到100%。大多数服务器的平均利用率在10%到50%之间。因此,SPECpower基准测试通过在10%到0%的负载范围内变化,捕捉到目标工作负载的功耗情况,其中0%称为活动空闲。
图1.21绘制了每瓦特ssj_ops(SSJ操作/秒)和目标负载从100%到0%变化时的平均功耗。Intel R730始终具有最低功耗,而单节点R630在每个目标负载水平下的ssj_ops每瓦特最佳。由于瓦特等于焦耳/秒,这个指标与每焦耳SSJ操作成正比。


图1.21显示了图1.20中三台服务器的功耗性能。ssj_ops/watt值在左侧轴上,与三个柱状图相关;瓦特值在右侧轴上,与三条折线相关。横轴显示目标工作负载,从100%到活动空闲变化。每个负载水平下,单节点R630的ssj_ops/watt表现最佳,但R730在每个负载水平下的功耗最低。
为了计算一个单一的数值用于比较系统的功耗效率,SPECpower使用了

三台服务器的整体ssj_ops/watt分别为:R730为10,802,R630为11,157,16台R630集群为10,062。因此,单节点R630具有最佳的功耗性能。将这些数据除以服务器价格,ssj_ops/watt/$1,000分别为:R730为879,R630为899,16节点R630集群(每节点)为789。因此,考虑到功耗后,单节点R630在性能/价格比方面仍然排名第一,但单节点R730的效率显著高于16节点集群。


1.11 Fallacies and Pitfalls

本节的目的是解释一些常见的误解或错误观念,你应该避免这些误解。我们称这些误解为谬论。在讨论一个谬论时,我们尝试提供一个反例。我们还讨论了一些陷阱——这些是容易犯的错误。陷阱通常是原则的普遍化,这些原则在有限的背景下是正确的。这些部分的目的是帮助你在设计计算机时避免这些错误。
**陷阱:所有的指数规律最终都将结束。**
第一个结束的是Dennard缩放。Dennard在1974年观察到,随着晶体管尺寸的减小,功率密度保持不变。如果晶体管的线性区域缩小了一倍,则电流和电压也减少了一倍,因此功耗下降了四倍。因此,芯片可以设计得更快,同时消耗更少的电力。Dennard缩放在观察到后的30年结束了,这并不是因为晶体管没有继续变小,而是因为集成电路的可靠性限制了电流和电压的进一步下降。阈值电压被压得非常低,以至于静态功耗成为整体功耗的重要部分。
接下来的是硬盘驱动器的减速。虽然没有硬盘的规律,但在过去30年里,硬盘的最大面积密度——决定了硬盘容量——每年提高了30%–100%。近年来,这一增长率已降至不到5%每年。增加每个硬盘的密度主要是通过增加更多的盘片来实现的。
接下来的是久负盛名的摩尔定律。已经有一段时间,芯片上的晶体管数量不再每一到两年翻一番。例如,2014年推出的DRAM芯片包含80亿个晶体管,而我们在2019年之前不会有16亿晶体管的DRAM芯片投入大规模生产,但摩尔定律预测64亿晶体管的DRAM芯片。
此外,平面逻辑晶体管缩放的实际终点甚至被预测在2021年到来。图1.22展示了来自国际半导体技术路线图(ITRS)两版报告的逻辑晶体管物理栅极长度预测。与2013年报告预测的2028年栅极长度达到5纳米不同,2015年报告预测栅极长度在2021年停留在10纳米。之后的密度提升将不得不通过其他方式实现,而不是通过缩小晶体管的尺寸。虽然ITRS的预测有些悲观,但像英特尔和台积电这样的公司计划将栅极长度缩小到3纳米,但变化的速度在减缓。

图1.22 展示了来自ITRS报告两个版本的逻辑晶体管尺寸预测。这些报告始于2001年,但2015年将是最后一个版本,因为该小组因兴趣减退而解散。目前只有GlobalFoundries、Intel、Samsung和TSMC能够生产最先进的逻辑芯片,而在首份ITRS报告发布时,有19家公司参与。只剩下四家公司,计划的共享变得难以维持。摘自IEEE Spectrum,2016年7月,Rachel Courtland撰文《晶体管将于2021年停止缩小,摩尔定律路线图预测》。
图1.23显示了微处理器和DRAM在带宽增加上的变化,这些变化受到Dennard缩放和摩尔定律终结的影响,以及磁盘的变化。技术改进的减缓在逐渐下降的曲线中显而易见。持续的网络改进归功于光纤技术的进步和计划中的脉冲振幅调制(PAM-4)变化,该技术允许双位编码,从而实现以400 Gbit/s的速度传输信息。

谬误:多处理器是一种灵丹妙药。
在2005年左右转向每芯片多个处理器并不是因为出现了某种突破,能够显著简化并行编程或使多核计算机更易于构建。这一变化发生是因为由于ILP壁垒和功耗壁垒,没有其他选择。每芯片多个处理器并不保证降低功耗;确实有可能设计出功耗更高的多核芯片。其潜力在于可以通过用几个低时钟频率、高效率的核心替换一个高时钟频率、低效率的核心,从而继续提高性能。随着缩小晶体管技术的进步,可以略微缩小电容和供电电压,从而在每一代中获得适度的核心数增加。例如,近年来,Intel在其高端芯片中每代增加两个核心。
正如我们在第4章和第5章中将看到的,性能现在成为了程序员的负担。程序员们依赖硬件设计师使他们的程序在不费吹灰之力的情况下更快的时代正式结束了。如果程序员希望他们的程序在每一代中变得更快,他们必须使程序更具并行性。
摩尔定律的流行版本——每一代技术的性能提升——现在取决于程序员。
陷阱:陷入阿姆达尔定律的悲剧性误区。
几乎每位计算机架构师都知道阿姆达尔定律。尽管如此,我们几乎总是偶尔在测量某个特性使用情况之前,就投入大量精力进行优化。只有当整体加速效果令人失望时,我们才会意识到我们应该先进行测量,再投入这么多精力来提升它!
陷阱:单点故障。
使用阿姆达尔定律计算的可靠性提升(见第53页)表明,可靠性并不比链条中最弱的环节更强。无论我们如何提高电源的可靠性,就像我们在示例中所做的那样,单个风扇仍然会限制磁盘子系统的可靠性。这一阿姆达尔定律的观察促成了容错系统的一个经验法则:确保每个组件都是冗余的,以防单个组件的故障导致整个系统崩溃。第6章展示了如何通过软件层在WSCs(大规模数据中心)内部避免单点故障。
谬误:提高性能的硬件增强也会改善能源效率,或者在最坏的情况下,能源效率保持中性。
Esmaeilzadeh等人(2011年)测量了在2.67 GHz的Intel Core i7上,仅使用一个核心的SPEC2006(见第1.5节)。当时钟频率增加到2.94 GHz(即提高了1.10倍)时,性能提高了1.07倍,但i7所需的能量却增加了1.37倍的焦耳和1.47倍的瓦时!
谬误:基准测试的有效性是无限的。
影响基准测试作为实际性能预测工具的因素有很多,其中一些会随时间变化。一个重要因素是基准测试抵抗“基准测试工程”或“基准测试营销”的能力。一旦基准测试成为标准化和流行的工具,就会有巨大的压力通过有针对性的优化或对基准测试规则的激进解释来提升性能。短小的内核或程序,特别是那些在少量代码中运行的程序,尤其容易受到影响。
例如,尽管出于良好意图,最初的SPEC89基准套件包括一个小内核,叫做matrix300,它包含了八个不同的300x300矩阵乘法。在这个内核中,99%的执行时间集中在一行代码上(见SPEC,1989)。当IBM编译器优化了这个内循环(使用了一种叫做分块的好方法,在第2章和第4章讨论),性能提高了9倍!这个基准测试测试了编译器的调优,当然并不是整体性能的良好指示,也不是这种特定优化的典型价值。
图1.19显示,如果忽视历史,我们可能会被迫重复历史。SPEC Cint2006已经十年未更新,给编译器开发者大量时间来优化他们的优化器。注意,除了libquantum之外,所有基准测试在AMD计算机上的SPEC比率都在16-52范围内,在Intel上从22到78。Libquantum在AMD上运行快约250倍,在Intel上快7300倍!这种“奇迹”是Intel编译器的优化结果,该编译器自动将代码并行化到22个核心,并通过使用位打包来优化内存,这种方法将多个狭窄范围的整数打包在一起,从而节省内存空间,减少内存带宽。如果我们去掉这个基准测试并重新计算几何平均值,AMD的SPEC Cint2006从31.9降到26.5,Intel从63.7降到41.4。Intel计算机现在大约是AMD计算机的1.5倍,而不是包含libquantum时的2.0倍,这更接近它们的真实相对性能。SPECCPU2017去掉了libquantum。
为了说明基准测试的短暂寿命,图1.17列出了所有82个SPEC版本的基准测试状态;Gcc是SPEC89中的唯一幸存者。令人惊讶的是,大约70%的SPEC2000或更早版本的所有程序在下一个版本中被淘汰。
谬误:磁盘的额定平均故障时间(MTTF)为1,200,000小时,或将近140年,因此磁盘几乎不会失败。
磁盘制造商的当前营销实践可能会误导用户。MTTF是如何计算的?在早期,制造商会将数千个磁盘放在一个房间里,运行几个月,然后统计失败的数量。他们将MTTF计算为磁盘总共工作小时数除以失败的数量。
一个问题是,这个数字远远超过磁盘的实际寿命,通常假设磁盘的使用寿命为五年或43,800小时。为了让这个大的MTTF有意义,磁盘制造商辩称,这个模型对应于用户购买磁盘后每五年更换一次磁盘——即磁盘的计划使用寿命。声称如果很多客户(及其曾孙)在下一个世纪这样做,平均每个磁盘在故障前会被更换27次,或大约140年。
一个更有用的度量是失败的磁盘百分比,称为年失败率。假设有1000个磁盘,MTTF为1,000,000小时,并且这些磁盘每天24小时使用。如果你用具有相同可靠性特征的新磁盘替换失败的磁盘,那么在一年(8,760小时)内会失败的磁盘数量是

此外,这些高数字是在假设温度和振动范围有限的情况下引用的;如果超出这些范围,那么结果就不再可靠。对实际环境中磁盘驱动器的调查(Gray和van Ingen,2005年)发现,每年有3%到7%的驱动器发生故障,对应的MTTF约为125,000到300,000小时。另一项更大规模的研究发现,年磁盘故障率为2%到10%(Pinheiro等,2007年)。因此,实际环境中的MTTF大约比制造商提供的MTTF差2到10倍。
谬误:峰值性能跟踪观察到的性能。
峰值性能唯一普遍正确的定义是“计算机保证不会超过的性能水平。” 图1.24显示了四个程序在四个多处理器上的峰值性能百分比,范围从5%到58%。由于差距如此之
大,并且可能因基准测试而显著变化,峰值性能通常无法有效预测观察到的性能。

图1.24 显示了四个程序在四个多处理器系统上,相对于64个处理器的峰值性能百分比。地球模拟器和X1是向量处理器(参见第4章和附录G)。它们不仅提供了更高的峰值性能百分比,还具有最高的峰值性能和最低的时钟频率。除了Paratec程序外,Power 4和Itanium 2系统的性能介于峰值的5%到10%之间。数据来源:Oliker, L., Canning, A., Carter, J., Shalf, J., Ethier, S., 2004. 现代并行向量系统上的科学计算. 见:ACM/IEEE超级计算会议论文集,2004年11月6-12日,匹兹堡,宾夕法尼亚州,第10页。
陷阱
故障检测可能会降低系统的可用性。这种看似讽刺的陷阱是因为计算机硬件有相当一部分状态可能并非始终对正确操作至关重要。例如,分支预测器发生错误并不是致命的,因为只会影响性能。
在那些积极利用指令级并行(ILP)的处理器中,并非所有操作都是程序正确执行所必需的。Mukherjee 等(2003年)发现,SPEC2000基准测试中,少于30%的操作可能在关键路径上。
程序也是如此。如果程序中的一个寄存器是“死”的——即程序在再次读取之前会写入该寄存器——那么错误并不重要。如果在检测到死寄存器中的瞬态故障时让程序崩溃,这会不必要地降低可用性。
Oracle的Sun Microsystems部门在2000年经历了这一陷阱,他们在Sun E3000到Sun E10000系统的L2缓存中包括了奇偶校验,但没有错误纠正。用于构建缓存的SRAM存在间歇性故障,这些故障被奇偶校验检测出来。如果缓存中的数据没有被修改,处理器会简单地从缓存中重新读取数据。由于设计师没有使用ECC(错误更正码)保护缓存,操作系统只能报告数据损坏错误并崩溃程序。现场工程师在超过90%的情况下检查没有发现问题。
为了减少这种错误的频率,Sun修改了Solaris操作系统,通过一个进程主动将脏数据写入内存来“清理”缓存。由于处理器芯片没有足够的引脚添加ECC,唯一的硬件选项是复制外部缓存,使用没有奇偶错误的副本来纠正错误。
这个陷阱在于检测故障而没有提供纠正机制。这些工程师不太可能设计另一个没有ECC的外部缓存计算机。


1.12 Concluding Remarks

本章介绍了一些概念,并提供了一个定量框架,我们将在整本书中扩展这个框架。从上一版开始,能源效率成为了性能的永恒伴侣。
在第2章中,我们从内存系统设计这一至关重要的领域开始。我们将研究一系列技术,这些技术共同作用,使内存看起来几乎无限大,同时尽可能快。(附录B为那些对缓存不太了解的读者提供了基础材料。)与后续章节一样,我们将看到硬件和软件的合作已成为高性能内存系统的关键,就像它对高性能流水线系统的重要性一样。本章还涵盖了虚拟机,这是一种越来越重要的保护技术。
在第3章中,我们探讨了指令级并行(ILP),其中流水线是最简单和最常见的形式。利用ILP是构建高速单处理器的最重要技术之一。第3章开始于对基本概念的广泛讨论,这将为你准备后续章节中讨论的各种思想。第3章的例子涵盖了大约40年,涉及从最早的超级计算机(IBM 360/91)到2017年市场上最快的处理器。它强调了动态或运行时的ILP利用方法。它还讨论了ILP思想的限制,并介绍了多线程,这在第4章和第5章中有进一步的发展。附录C为那些对流水线不太了解的读者提供了基础材料。(我们期望它对许多读者,包括我们介绍性文本《计算机组织与设计:硬件/软件接口》的读者来说是一个复习。)
第4章解释了利用数据级并行的三种方法。最经典且最古老的方法是向量架构,我们从这里开始,阐明SIMD设计的原则。(附录G对向量架构进行了更深入的探讨。)接下来,我们解释了今天大多数桌面微处理器中发现的SIMD指令集扩展。第三部分是对现代图形处理单元(GPU)工作原理的深入解释。大多数GPU描述都是从程序员的角度编写的,这通常掩盖了计算机的真实工作方式。本节从内部人员的角度解释了GPU,包括GPU术语与传统架构术语之间的映射。
第5章重点讨论了通过使用多个处理器或多处理器系统来实现更高性能的问题。与通过并行性重叠单独指令不同,多处理器利用并行性允许多个指令流在不同处理器上同时执行。我们主要关注共享内存多处理器这一主要形式,同时也介绍了其他类型,并讨论了任何多处理器系统中出现的广泛问题。我们将探讨各种技术,重点是1980年代和1990年代首次提出的重要思想。
第6章介绍了集群,并深入探讨了计算机架构师参与设计的WSC(Web服务器集群)。WSC的设计者是超级计算机先驱(如Seymour Cray)的专业后代,他们设计的是极端计算机。WSC包含数万台服务器,其设备和建筑的总成本接近2亿美元。前几章中价格性能和能源效率的关注点也适用于WSC,并且使用量化方法做决策。
第7章是本版新增的内容。它介绍了领域特定架构作为在摩尔定律和Dennard缩放结束后的性能和能源效率提升的唯一途径。它提供了如何构建有效的领域特定架构的指导,介绍了深度神经网络这一令人兴奋的领域,描述了四个采用不同方法加速神经网络的最新例子,并比较了它们的成本效益。
本书附带了大量在线资料(详见前言),以减少成本并向读者介绍各种先进主题。图1.25展示了所有这些内容。书中的附录A–C将为许多读者提供复习。

在附录D中,我们将重点从处理器中心的视角转向存储系统,并讨论存储系统中的问题。我们采用类似的定量方法,但基于系统行为的观察,使用端到端的性能分析方法。该附录主要解决如何使用低成本的磁性存储技术高效地存储和检索数据的问题。我们专注于检查磁盘存储系统在典型的I/O密集型工作负载(如本章提到的OLTP基准测试)中的性能。我们详细探讨了基于RAID的高级主题,这些系统使用冗余磁盘来实现高性能和高可用性。最后,附录D介绍了排队理论,为权衡利用率和延迟提供了基础。
附录E从嵌入式计算的角度应用了各章和早期附录中的理念。
附录F广泛探讨了系统互连的话题,包括允许计算机通信的广域网和系统区域网。
附录H回顾了VLIW(非常长指令字)硬件和软件,这些在EPIC(扩展指令集计算)出现时的流行程度较低。
附录I描述了用于高性能计算的大规模多处理器系统。
附录J是从第一版中保留下来的唯一附录,涵盖了计算机算术。
附录K提供了指令架构的概述,包括80x86、IBM 360、VAX及许多RISC架构,如ARM、MIPS、Power、RISC-V和SPARC。
附录L是新增的,讨论了内存管理的高级技术,重点支持虚拟机和设计用于非常大地址空间的地址转换。随着云处理器的发展,这些架构增强变得越来越重要。
接下来,我们将描述附录M。


1.13 Historical Perspectives and References

附录M(在线提供)包括了对本书各章中关键思想的历史视角。这些历史视角部分允许我们追踪一个思想通过一系列机器的发展,或描述重要的项目。如果你对某个思想或处理器的初期发展感兴趣,或者想要进一步阅读,参考文献在每段历史末尾提供。有关数字计算机和性能测量方法初期发展的讨论,请参阅本章的M.2节“计算机的早期发展”。
在阅读这些历史材料时,你会很快意识到,与许多其他工程领域相比,计算机领域的一个重要好处是一些先驱者仍然在世——我们可以通过直接询问他们来学习历史!
案例研究和练习 由 Diana Franklin 编写
### 案例研究 1: 芯片制造成本
#### 本案例研究所展示的概念
- 制造成本
- 制造良率
- 通过冗余的缺陷容忍
计算机芯片的价格涉及许多因素。英特尔正在花费70亿美元来完成其 Fab 42 制造设施,以实现 7 纳米技术。在本案例研究中,我们探讨一个假设的公司面临的类似情况,以及涉及制造技术、面积和冗余的不同设计决策如何影响芯片的成本。
1.1 [10/10] <1.6> 图 1.26 给出了影响多个当前芯片成本的假设相关芯片统计数据。在接下来的几个练习中,你将探讨不同设计决策对英特尔芯片的影响。

a. [10] <1.6> Phoenix 芯片的良率是多少?
b. [10] <1.6> 为什么 Phoenix 的缺陷率比 BlueDragon 高?
#### 1.2 [20/20/20/20] <1.6>
他们将从那个工厂销售一系列芯片,需要决定将多少产能分配给每种芯片。假设他们将销售两种芯片。Phoenix 是一种完全新的架构,设计时考虑了 7 纳米技术,而 RedDragon 则与他们的 10 纳米 BlueDragon 具有相同的架构。假设 RedDragon 每个无缺陷芯片的利润为 15 美元,而 Phoenix 每个无缺陷芯片的利润为 30 美元。每片晶圆的直径为 450 毫米。
a. [20] <1.6> 每片 Phoenix 芯片的利润是多少?
b. [20] <1.6> 每片 RedDragon 芯片的利润是多少?
c. [20] <1.6> 如果你的需求是每月 50,000 个 RedDragon 芯片和每月 25,000 个 Phoenix 芯片,而你的工厂每月可以制造 70 片晶圆,你应该生产多少片每种芯片的晶圆?
#### 1.3 [20/20] <1.6>
你在 AMD 的同事建议,由于良率很低,你可能通过推出多个版本的相同芯片(只是核心数量不同)来降低芯片生产成本。例如,你可以销售 Phoenix8、Phoenix4、Phoenix2 和 Phoenix1,它们分别包含 8、4、2 和 1 个核心。如果所有八个核心都是无缺陷的,那么它将作为 Phoenix8 销售。具有四到七个无缺陷核心的芯片将作为 Phoenix4 销售,具有两个或三个无缺陷核心的芯片将作为 Phoenix2 销售。为了简化计算,将单个核心的良率视为与原始 Phoenix 芯片面积为 1/8 的芯片的良率相同。然后将该良率视为单个核心无缺陷的独立概率。计算每种配置的良率,即对应数量核心无缺陷的概率。
a. [20] <1.6> 单个核心无缺陷的良率是多少?Phoenix4、Phoenix2 和 Phoenix1 的良率是多少?
b. [5] <1.6> 根据第 a 部分的结果,确定哪些芯片值得打包和销售,以及原因。
c. [10] <1.6> 如果生产 Phoenix8 芯片的成本之前是 20 美元,那么新 Phoenix 芯片的成本是多少,假设没有与从垃圾中救回芯片相关的额外成本?
d. [20] <1.6> 你目前每个无缺陷的 Phoenix8 芯片的利润为 30 美元,每个 Phoenix4 芯片的售价为 25 美元。如果考虑到 (i) Phoenix4 芯片的购买价格完全作为利润,以及 (ii) 将 Phoenix4 芯片的利润按生产比例分配到每个 Phoenix8 芯片中,你每个 Phoenix8 芯片的利润是多少?请使用第 1.3a 部分计算的良率,而不是第 1.1a 部分的结果。
### 案例研究 2: 计算机系统中的功耗
#### 相关概念
- 阿姆达尔定律(Amdahl’s Law)
- 冗余(Redundancy)
- 平均故障前时间(MTTF)
- 功耗(Power Consumption)
现代系统的功耗受多种因素影响,包括芯片时钟频率、效率和电压。以下练习探讨了不同设计决策和使用场景对功耗和能量的影响。
#### 1.4 [10/10/10/10] <1.5>
手机执行非常不同的任务,包括音乐流媒体、视频流媒体和阅读电子邮件。这些任务涉及非常不同的计算操作。电池寿命和过热是手机常见的问题,因此减少功耗和能量消耗至关重要。在这个问题中,我们考虑当用户没有充分使用手机计算能力时应该怎么办。我们将评估一个不切实际的场景,其中手机没有专用处理单元。相反,它有一个四核的通用处理单元。每个核心在满负荷时使用0.5瓦特。在处理电子邮件任务时,这个四核的速度是必要速度的1/8。
a. [10] <1.5> 与满功率运行相比,需要多少动态能量和功率?首先,假设四核在1/8的时间内运行,其他时间处于空闲状态。也就是说,时钟在7/8的时间内被禁用,在这段时间内没有漏电。比较总动态能量和动态功率。
b. [10] <1.5> 使用频率和电压缩放需要多少动态能量和功率?假设频率和电压都降低到原来的1/8。
c. [10] <1.6, 1.9> 现在假设电压不能低于原始电压的50%。这个电压被称为电压下限,低于这个电压将丢失状态。因此,虽然频率可以继续降低,但电压不能。此情况下的动态能量和功率节省是多少?
d. [10] <1.5> 使用“暗硅”(dark silicon)方法的能量消耗是多少?这涉及为每个主要任务创建专用的ASIC硬件,并在不使用时对这些元素进行功率门控。只提供一个通用核心,芯片的其余部分将填充专用单元。对于电子邮件,这一个核心将运行25%的时间,并在其他75%的时间内完全关闭。其余75%的时间,将运行一个需要核心能量20%的专用ASIC单元。
1.5 [10/10/10] <1.5> 如练习1.4中所述,手机运行各种不同的应用程序。在本练习中,我们将做出与上一个练习相同的假设,即每个核心功耗为0.5瓦特,并且四核在处理电子邮件时的速度是单核的3倍。
a. [10] <1.5> 假设80%的代码是可并行化的。为了使单核在与四核并行代码相同的速度下运行,单核的频率和电压需要增加多少?
b. [10] <1.5> 使用频率和电压缩放在部分a中带来了多少动态能量的减少?
c. [10] <1.5> 使用“暗硅”(dark silicon)方法时消耗了多少能量?在这种方法中,所有硬件单元都被断电,以使它们完全关闭(不会产生泄漏)。提供的专用ASIC的功耗是通用处理器的20%。假设每个核心都被断电。视频游戏需要两个ASIC和两个核心。与在四核上并行处理的基线相比,这种方法需要多少动态能量?
1.6 [10/10/10/10/10/20] <1.5,1.9> 通用处理器经过优化以适应广泛的计算需求,即它们针对大量应用程序中普遍存在的行为进行了优化。然而,一旦将领域有所限制,目标应用程序中大多数行为可能与通用应用程序不同。深度学习或神经网络就是一个例子。尽管深度学习可以应用于许多不同的应用程序,但推断的基本构建块——使用学习到的信息做决策——在所有应用中都是相同的。推断操作大多是并行的,因此它们目前在图形处理单元(GPU)上执行,GPU更倾向于这种类型的计算,但并不是专门针对推断。为了提高每瓦特的性能,谷歌创造了一种使用张量处理单元(TPU)的定制芯片,以加速深度学习中的推断操作。这个方法可以用于语音识别和图像识别等应用。这个问题探讨了这种处理方式与通用处理器(Haswell E5-2699 v3)和GPU(NVIDIA K80)在性能和冷却方面的权衡。如果计算机中的热量没有有效去除,风扇将把热空气吹回计算机,而不是冷空气。注意:差异不仅在于处理器,芯片内存和DRAM也会影响结果。因此统计数据是系统级别的,而不是芯片级别的。
a. [10] <1.9> 如果谷歌的数据中心在运行GPU时,70%的时间用于负载A,30%的时间用于负载B,那么TPU系统相对于GPU系统的加速比是多少?
b. [10] <1.9> 如果谷歌的数据中心在运行GPU时,70%的时间用于负载A,30%的时间用于负载B,那么每个系统的最大IPS百分比是多少?
c. [15] <1.5, 1.9> 基于(b),假设功率随着IPS从0%增长到100%而线性缩放,那么TPU系统相对于GPU系统的每瓦特性能是多少?
d. [10] <1.9> 如果另一个数据中心将40%的时间用于负载A,10%的时间用于负载B,50%的时间用于负载C,那么GPU和TPU系统相对于通用处理系统的加速比是多少?
e. [10] <1.5> 一个机架冷却门的成本是4000美元,能散发14 kW的热量(进入房间;还需要额外的费用将其排出房间)。根据图1.27和1.28中的TDP,一个冷却门可以冷却多少台Haswell、NVIDIA或Tensor基服务器?
f. [20] <1.5> 典型的服务器机房每平方英尺的最大散热量为200 W。假设一个服务器机架需要11平方英尺(包括前后清理空间),那么一个机架上可以放置多少台第(e)部分中的服务器,并且需要多少个冷却门?

2 Memory Hierarchy Design

 理想情况下,我们希望拥有一个无限大的内存容量,使得任何特定的词汇都能立即获得。然而,我们被迫认识到,必须构建一个内存层次结构,每个层次的内存容量都比前一个层次大,但访问速度较慢。
—— A. W. Burks、H. H. Goldstine 和 J. von Neumann,《电子计算仪逻辑设计的初步讨论》(1946年)。


2.1 Introduction

计算机先驱们正确预测到程序员将会希望拥有无限量的快速内存。对此需求的经济解决方案是内存层次结构,它利用了局部性原则以及内存技术在成本和性能上的权衡。局部性原则(在第一章中介绍)指出,大多数程序不会均匀地访问所有的代码或数据。局部性分为时间局部性(temporal locality)和空间局部性(spatial locality)。这个原则加上这样一个指导方针:对于给定的实现技术和功率预算,较小的硬件可以变得更快,这导致了基于不同速度和大小内存的层次结构。图2.1展示了几种不同的多级内存层次结构,包括典型的访问速度和大小。随着Flash和下一代内存技术在每比特成本上逐渐缩小与磁盘的差距,这些技术很可能会越来越多地取代磁性磁盘作为二级存储。如图2.1所示,这些技术已经在许多个人计算机中使用,并且在服务器中使用越来越多,因为它们在性能、功率和密度方面的优势是显著的。

图2.1 显示了在个人移动设备(PMD)如手机或平板电脑(A)、笔记本电脑或台式计算机(B)、以及服务器(C)中的典型内存层次结构。随着离处理器的距离增加,下一层级的内存变得更慢且更大。需要注意的是,在磁性磁盘的情况下,时间单位从皮秒变化到毫秒,变化因子为10^9,而大小单位从千字节变化到十几TB,变化因子为10^10。如果我们将数据中心级别的计算机(而不仅仅是服务器)加入考虑,容量规模将增加三到六个数量级。闪存组成的固态硬盘(SSD)在个人移动设备中被专门使用,并且在笔记本电脑和台式机中也被广泛使用。在许多台式机中,主要的存储系统是SSD,扩展磁盘主要是硬盘驱动器(HDD)。类似地,许多服务器混合使用SSD和HDD。
由于快速内存更昂贵,因此内存层次结构被组织成几个层级——每一层级比下一层级更小、更快且每字节成本更高,而下一层级则离处理器更远。目标是提供一个内存系统,其每字节成本几乎与最便宜的内存层级一样低,同时其速度也几乎与最快的内存层级一样快。在大多数情况下(但并非所有情况),较低层级中的数据是下一个较高层级的超集。这种特性称为包含性(inclusion property),对于层次结构中的最低层级总是要求具备这种特性,最低层级在缓存的情况下是主内存,在虚拟内存的情况下是二级存储(磁盘或Flash)。
随着处理器性能的提升,内存层次结构的重要性也在增加。图2.2绘制了单处理器性能预测与访问主内存时间的历史性能改进之间的关系。处理器线条显示了每秒内存请求的增加(即内存引用之间延迟的倒数),而内存线条显示了每秒DRAM访问的增加(即DRAM访问延迟的倒数),假设只有一个DRAM和一个内存银行。实际情况更为复杂,因为处理器请求速率并不均匀,内存系统通常有多个DRAM银行和通道。尽管访问时间的差距多年来显著增加,但单处理器性能的改进有限,导致处理器与DRAM之间的差距增长减缓。

图2.2 从1980年的性能基线开始,绘制了处理器内存请求(针对单个处理器或核心)和DRAM访问延迟之间的时间差距的性能差距随时间的变化。到2017年中期,AMD、Intel和Nvidia都宣布使用版本的HBM技术的芯片组。注意,垂直轴必须使用对数刻度来记录处理器-DRAM性能差距的大小。内存基线为1980年的64 KiB DRAM,延迟性能每年提高1.07倍(见第88页的图2.4)。处理器线条假设1986年之前每年提高1.25倍,2000年之前提高1.52倍,2000到2005年之间提高1.20倍,而2005到2015年之间处理器性能(每核心基础上)的提高很小。如图所示,直到2010年,DRAM中的内存访问时间改善缓慢但稳定;自2010年以来,相比早期时期,访问时间的改善有所减少,尽管带宽仍持续提升。有关更多信息,请参见第1章的图1.1。
由于高端处理器具有多个核心,其带宽需求高于单核处理器。尽管单核带宽近年来增长缓慢,但随着核心数量的增加,CPU内存需求与DRAM带宽之间的差距仍在扩大。现代高端桌面处理器如Intel Core i7 6700每个核心每个时钟周期可以生成两个数据内存引用。以4个核心和4.2 GHz的时钟频率为例,i7可以产生高达32.8亿个64位数据内存引用每秒,以及约12.8亿个128位指令引用的峰值指令需求;总峰值需求带宽为409.6 GiB/s!这一惊人的带宽通过缓存的多端口和流水线技术实现;通过使用三个级别的缓存,每个核心有两个私有级别和一个共享的L3;以及在第一级使用独立的指令和数据缓存来实现。相比之下,使用两个内存通道的DRAM主内存的峰值带宽仅为需求带宽的8%(34.1 GiB/s)。预计即将推出的版本将有一个使用嵌入式或堆叠DRAM的L4 DRAM缓存(见第2.2和2.3节)。
传统上,内存层次结构的设计师专注于优化平均内存访问时间,这一时间由缓存访问时间、缺失率和缺失惩罚决定。然而,近年来,功耗成为了一个主要考虑因素。在高端微处理器中,可能会有60 MiB或更多的片上缓存,大型的二级或三级缓存会消耗大量电力,包括在不运行时的漏电流(称为静态功耗)和在执行读写操作时的动态功耗(称为动态功耗),如第2.3节所述。在便携式设备(PMDs)的处理器中,问题更加严重,因为这些处理器的CPU往往不那么积极,且功耗预算可能小20到50倍。在这种情况下,缓存可能占总功耗的25%到50%。因此,更多的设计必须同时考虑性能和功耗的权衡,本章将对这两方面进行讨论。
内存层次结构基础:快速回顾
这一差距的不断扩大及其重要性促使内存层次结构的基础知识进入计算机体系结构的本科课程,甚至扩展到操作系统和编译器课程。因此,我们将从缓存及其操作的快速回顾开始。然而,本章的大部分内容将描述针对处理器—内存性能差距的更先进的创新。
当一个字在缓存中未被找到时,该字必须从层次结构的较低层级(可能是另一个缓存或主内存)中提取,并放置到缓存中,然后才能继续操作。为了效率原因,通常会移动多个字,这些字被称为块(或行),因为它们由于空间局部性很可能很快被需要。每个缓存块包括一个标签,用于指示其对应的内存地址。
一个关键的设计决策是块(或行)可以放置在缓存中的位置。最常见的方案是集合关联,其中一个集合是一组缓存块。一个块首先被映射到一个集合中,然后可以放置在该集合中的任何位置。找到一个块的过程包括首先将块地址映射到集合中,然后在集合中—通常是并行—搜索该块。集合是通过数据的地址选择的。

如果一个集合中有 \( n \) 个块,那么缓存的放置方式称为 \( n \)-路集合关联(\( n \)-way set associative)。集合关联的两个极端有各自的名称。直接映射缓存(direct-mapped cache)每个集合只有一个块(因此,一个块总是放置在相同的位置),而全关联缓存(fully associative cache)只有一个集合(因此,一个块可以放置在任何位置)。
缓存只读数据比较简单,因为缓存中的副本和内存中的副本将是相同的。缓存写操作则更复杂;例如,如何保持缓存和内存中的副本一致?主要有两种策略。
1. **写透缓存(write-through cache)**:更新缓存中的项并同时更新主内存。
2. **写回缓存(write-back cache)**:仅更新缓存中的副本。当块即将被替换时,将其复制回内存。两种写策略都可以使用写缓冲区(write buffer),允许缓存只要数据被放置在缓冲区中就继续操作,而不必等待完全延迟写入内存。
不同缓存组织的效益可以通过未命中率(miss rate)来衡量。未命中率是缓存访问中导致未命中的比例,即未命中的访问次数除以总访问次数。
为了深入了解高未命中率的原因,这有助于激发更好的缓存设计,三类模型(three Cs model)将所有未命中分为三类:
- **强制性未命中(Compulsory)**:对块的第一次访问不可能在缓存中,因此该块必须被带入缓存。强制性未命中是指即使缓存无限大也会发生的未命中。
- **容量未命中(Capacity)**:如果缓存无法容纳执行程序所需的所有块,将发生容量未命中(除了强制性未命中),因为块被丢弃后又被重新取回。
- **冲突未命中(Conflict)**:如果块放置策略不是全关联的,将发生冲突未命中(除了强制性和容量未命中),因为多个块可能映射到同一个集合,并且对不同块的访问是交织在一起的。
图B.8(第24页)展示了缓存未命中的相对频率,按照三种未命中类型(三C)进行分类。正如附录B中提到的,三C模型是概念性的,尽管其见解通常适用,但它并不是解释单个引用缓存行为的决定性模型。
正如我们在第3章和第5章中将看到的,多线程和多核增加了缓存的复杂性,不仅增加了容量未命中的潜力,还增加了第四种未命中类型,即由于缓存刷新保持多处理器中多个缓存一致性而产生的一致性未命中;我们将在第5章中讨论这些问题。
然而,未命中率可能因多种原因而具有误导性。因此,一些设计师更倾向于测量每条指令的未命中次数,而不是每个内存引用的未命中率。这两者是相关的:

(这个公式通常以整数形式表示,而不是分数形式,例如每1000条指令的未命中次数。)
这两种度量方法的问题在于它们没有考虑未命中的成本。一个更好的度量是平均内存访问时间。

其中,命中时间是指在缓存中命中的时间,而未命中惩罚是指从内存中替换块的时间(即未命中的成本)。平均内存访问时间仍然是间接的性能度量;虽然它比未命中率更好,但不能替代执行时间。在第3章中,我们将看到投机处理器可能在未命中期间执行其他指令,从而减少有效未命中惩罚。多线程(在第3章介绍)也允许处理器在未命中时继续工作,而不必闲置。正如我们将很快讨论的,为了利用这种延迟容忍技术,我们需要能够在处理未命中请求时继续服务的缓存。
如果这些内容对你来说较新,或者这个快速回顾过于简略,请参阅附录B。附录B对这些基础内容进行了更深入的讲解,并包括了真实计算机的缓存示例和其有效性的定量评估。
附录B的第B.3节介绍了六种基本缓存优化,我们在这里快速回顾了这些内容。附录还提供了这些优化的定量效果示例,并简要评论了这些权衡的功耗影响。
1. **增大块大小以减少未命中率**—最简单的方法是利用空间局部性,增加块大小。较大的块可以减少强制未命中,但也会增加未命中惩罚。由于较大的块降低了标签的数量,它们可以略微减少静态功耗。较大的块还可能增加容量或冲突未命中,特别是在较小的缓存中。选择合适的块大小是一个复杂的权衡,取决于缓存的大小和未命中惩罚。
2. **增加缓存大小以减少未命中率**—减少容量未命中的显而易见的方法是增加缓存容量。缺点包括可能更长的缓存命中时间以及更高的成本和功耗。较大的缓存会增加静态和动态功耗。
3. **提高关联度以减少未命中率**—显然,提高关联度可以减少冲突未命中。但更高的关联度可能会增加命中时间。正如我们将看到的,关联度也会增加功耗。
4. **多级缓存以减少未命中惩罚**—一个困难的决策是选择使缓存命中时间快,以跟上处理器的高时钟频率,还是使缓存更大,以缩小处理器访问和主存访问之间的差距。在原始缓存和内存之间添加另一层缓存可以简化决策。一级缓存可以足够小以匹配快速的时钟周期时间,而二级(或三级)缓存可以足够大以捕捉许多会去主存的访问。二级缓存中的未命中关注导致更大的块、更大的容量和更高的关联度。多级缓存比单一的聚合缓存更节能。如果L1和L2分别指的是一级和二级缓存,我们可以重新定义平均内存访问时间:

5. **优先处理读取未命中而非写入未命中以减少未命中惩罚**—写入缓冲区是实现这一优化的好地方。写入缓冲区可能会造成危险,因为它们保存了读取未命中时需要的内存位置的更新值,即写后读(read-after-write)危险。一种解决方案是,在读取未命中时检查写入缓冲区的内容。如果没有冲突,并且内存系统可用,则在写操作之前发送读取操作可以减少未命中惩罚。大多数处理器优先考虑读取而非写入。这个选择对功耗的影响很小。
6. **避免在缓存索引时进行地址转换以减少命中时间**—缓存必须处理从处理器到物理地址的虚拟地址转换以访问内存。(虚拟内存在第2.4节和附录B.4中讨论。)一种常见的优化是使用页面偏移量——即在虚拟地址和物理地址中相同的部分——来索引缓存,如附录B第B.38页所述。这种虚拟索引/物理标签方法引入了一些系统复杂性和/或对L1缓存的大小和结构的限制,但去除翻译后备缓冲区(TLB)访问的优势大于其劣势。
需要注意的是,上述六种优化中的每一种都有可能的缺点,可能导致平均内存访问时间的增加,而非减少。本章其余部分假定读者熟悉前述材料和附录B中的细节。在“综合分析”部分,我们将考察为高端桌面计算机或较小服务器设计的微处理器内存层次结构,例如Intel Core i7 6700,以及为便携设备(PMD)设计的处理器,例如Arm Cortex-A53,这是几款平板电脑和智能手机中使用的处理器的基础。在这些类别中,由于计算机的预期用途,方法存在显著的多样性。
尽管i7 6700相比于为移动用途设计的Intel处理器具有更多的核心和更大的缓存,但这些处理器具有相似的架构。为小型服务器(如i7 6700)或大型服务器(如Intel Xeon处理器)设计的处理器,通常运行大量的并发进程,通常是不同用户的进程。因此,内存带宽变得更加重要,这些处理器提供更大的缓存和更积极的内存系统以提升带宽。
相比之下,便携设备不仅服务于单一用户,而且通常操作系统较小,通常多任务处理较少(同时运行多个应用程序),且应用程序更简单。便携设备必须考虑性能和能耗,这决定了电池寿命。在深入探讨更高级的缓存组织和优化之前,需要了解各种内存技术及其发展趋势。


2.2 Memory Technology and Optimizations

“使计算机站稳脚跟的唯一一项发展就是发明了一种可靠的存储形式,即磁心存储器。它的成本合理,可靠,而且由于它的可靠性,最终可以制造得很大。”(第209页)
——莫里斯·威尔克斯,《计算机先驱回忆录》(1985年)
这一节描述了内存层次结构中使用的技术,特别是在构建缓存和主内存时使用的技术。这些技术包括 SRAM(静态随机访问内存)、DRAM(动态随机访问内存)和 Flash。最后一种技术作为硬盘的替代方案,但由于其特性基于半导体技术,因此适合在这一节中介绍。
使用 SRAM 可以满足最小化缓存访问时间的需求。然而,当发生缓存未命中时,我们需要尽可能快速地从主内存中移动数据,这就需要高带宽的内存。这种高带宽内存可以通过将构成主内存的多个 DRAM 芯片组织成多个内存银行,并将内存总线加宽,或者同时进行这两种方式来实现。
为了使内存系统能够跟上现代处理器的带宽需求,内存创新开始在 DRAM 芯片内部发生。这一节将描述内存芯片内部的技术及其创新的内部组织。在描述技术和选项之前,我们需要介绍一些术语。
随着突发传输内存的引入,现在广泛用于 Flash 和 DRAM,内存延迟使用两个指标来表示——访问时间和周期时间。访问时间是从发起读取请求到期望的数据到达之间的时间,而周期时间是无关请求之间的最小时间。
几乎所有计算机从1975年起都使用 DRAM 作为主内存,SRAM 作为缓存,且通常将一至三级集成在处理器芯片上。便携设备必须在功耗和性能之间取得平衡,由于其存储需求较小,便携设备使用 Flash 而非硬盘驱动器,这一决策也越来越被桌面计算机所采纳。
**SRAM 技术**
SRAM 的第一个字母代表“静态”。DRAM 中电路的动态特性要求在读取数据后必须重新写入,因此访问时间和周期时间之间存在差异,并且需要刷新。而 SRAM 不需要刷新,因此其访问时间非常接近周期时间。SRAM 通常使用六个晶体管来存储每一位数据,以防在读取时信息被干扰。SRAM 仅需极少的电力即可在待机模式下保持电荷。
在早期,大多数桌面计算机和服务器系统使用 SRAM 芯片作为其主缓存、二级缓存或三级缓存。今天,这三级缓存通常集成在处理器芯片上。在高端服务器芯片中,可能有多达 24 个核心和多达 60 MiB 的缓存;这样的系统通常配置有每个处理器芯片 128–256 GiB 的 DRAM。大型三级缓存的访问时间通常是二级缓存的两到八倍。即便如此,L3 缓存的访问时间通常至少比 DRAM 访问时间快五倍。
芯片上的缓存 SRAM 通常以与缓存块大小匹配的宽度组织,标签并行存储于每个块。这允许在一个周期内读取或写入整个块。这种能力在写入因未命中而获取的数据到缓存中或写回必须从缓存中驱逐的块时特别有用。缓存的访问时间(忽略集合关联缓存中的命中检测和选择)与缓存中的块数量成正比,而能耗则取决于缓存中的位数(静态功耗)和块的数量(动态功耗)。集合关联缓存减少了对内存的初始访问时间,因为内存的大小较小,但增加了命中检测和块选择的时间,这一话题我们将在第 2.3 节中讨论。
**DRAM 技术**
随着早期 DRAM 的容量增加,包含所有必要地址线的封装成本成为问题。解决方案是对地址线进行复用,从而将地址引脚的数量减半。图 2.3 显示了基本的 DRAM 组织结构。在行访问时钟(RAS)期间,首先发送地址的一半。另一半地址在列访问时钟(CAS)期间发送。这些名称源于内部芯片的组织,因为内存按行和列组织成一个矩形矩阵。

**图 2.3 DRAM 的内部组织结构**
现代 DRAM 组织为多个银行,DDR4 支持最多 16 个银行。每个银行由一系列行组成。发送 ACT(激活)命令会打开一个银行和一行,并将该行加载到行缓冲区。当行在缓冲区中时,可以通过连续的列地址进行传输,具体传输宽度取决于 DRAM 的宽度(在 DDR4 中通常为 4、8 或 16 位),或者通过指定块传输及起始地址进行传输。预充电命令(PRE)关闭银行和行,并为新的访问做好准备。每个命令以及块传输都与时钟同步。请参阅下一节讨论 SDRAM。行和列信号有时被称为 RAS 和 CAS,基于这些信号的原始名称。
DRAM 的一个额外要求来源于其首字母 D 代表的动态特性。为了在每个芯片上存储更多位,DRAM 只使用一个晶体管,实际上充当电容器来存储一个比特。这有两个影响:首先,检测电荷的感应线必须预充电,使它们处于逻辑 0 和 1 之间的“中间”状态,允许存储在单元中的微小电荷被感应放大器检测为 0 或 1。在读取时,一行数据被放入行缓冲区,CAS 信号可以选择该行的一部分从 DRAM 中读取出来。由于读取一行会销毁信息,因此在该行不再需要时必须将其写回。这个写回是重叠进行的,但在早期 DRAM 中,这意味着在可以读取新行之前的周期时间大于读取一行并访问该行一部分的时间。
此外,为了防止信息丢失,因为单元中的电荷会泄漏(假设没有被读取或写入),每个位必须定期“刷新”。幸运的是,只需读取该行并将其写回,即可同时刷新行中的所有位。因此,内存系统中的每个 DRAM 必须在一定时间窗口内访问每一行,比如 64 毫秒。DRAM 控制器包括硬件来定期刷新 DRAM。
这一要求意味着内存系统偶尔会不可用,因为它需要向每个芯片发送信号进行刷新。刷新所需的时间包括行激活和预充电,并将行数据写回(这大约需要总时间的 2/3,因为不需要列选择),每行 DRAM 都需要进行这样的操作。由于 DRAM 的内存矩阵在概念上是正方形的,刷新所需的步骤数通常是 DRAM 容量的平方根。DRAM 设计者试图将刷新时间保持在总时间的 5% 以下。到目前为止,我们介绍的主要内存仿佛像瑞士列车一样,始终按照计划精确交付。实际上,对于 SDRAM,DRAM 控制器(通常在处理器芯片上)尝试通过避免打开新行和在可能的情况下使用块传输来优化访问。刷新增加了另一个不可预测的因素。
Amdahl 建议作为经验法则,内存容量应与处理器速度线性增长,以保持系统平衡。因此,一个 1000 MIPS 的处理器应该有 1000 MiB 的内存。处理器设计师依赖 DRAM 来满足这种需求。过去,他们期望每三年容量提高四倍,或者每年提高 55%。不幸的是,DRAM 的性能增长速度远远较慢。这种性能增长缓慢主要由于行访问时间的减少较小,而行访问时间受限于功率限制和单个内存单元的电荷容量(以及大小)。在我们更详细地讨论这些性能趋势之前,需要描述从 1990 年代中期开始 DRAM 发生的重大变化。
提升 DRAM 芯片内部内存性能:SDRAMs
虽然早期的 DRAM 包括一个缓冲区,允许对单行进行多个列访问而不需要新的行访问,但它们使用了异步接口,这意味着每个列访问和传输都涉及到与控制器同步的开销。在 1990 年代中期,设计师向 DRAM 接口添加了时钟信号,从而消除了重复传输的开销,创建了同步 DRAM(SDRAM)。除了减少开销,SDRAM 还允许添加突发传输模式,在这种模式下,多个传输可以在不指定新列地址的情况下进行。通常,通过将 DRAM 设置为突发模式,可以进行八次或更多次 16 位传输而无需发送任何新地址。突发模式传输的引入意味着在随机访问流和数据块访问之间存在显著的带宽差距。
为了在 DRAM 密度增加时获得更多带宽,DRAM 被设计得更宽。最初,它们提供了四位传输模式;到 2017 年,DDR2、DDR3 和 DDR DRAM 的总线宽度达到了 4、8 或 16 位。
在 2000 年代初,进一步的创新被引入:双倍数据速率(DDR),它允许 DRAM 在内存时钟的上升沿和下降沿进行数据传输,从而使峰值数据速率翻倍。
最后,SDRAM 引入了多个存储银行,以帮助管理功耗、提高访问时间,并允许对不同银行进行交错和重叠的访问。对不同银行的访问可以相互重叠,每个银行都有自己的行缓冲区。在 DRAM 内部创建多个银行实际上是在地址中添加了另一个分段,现在的地址包括银行号、行地址和列地址。当发送一个指定新银行的地址时,该银行必须被打开,产生额外的延迟。现代内存控制接口完全处理银行和行缓冲区的管理,因此当后续访问指定了已打开银行的相同行时,可以快速访问,只需发送列地址即可。
要启动新的访问,DRAM 控制器发送一个银行和行号(在 SDRAM 中称为 Activate,之前称为 RAS——行选择)。该命令打开行并将整行读入缓冲区。然后可以发送列地址,SDRAM 可以传输一个或多个数据项,具体取决于是单项请求还是突发请求。在访问新行之前,银行必须预充电。如果行在同一银行中,则会经历预充电延迟;然而,如果行在其他银行中,则关闭行和预充电可以与访问新行重叠。在同步 DRAM 中,每个命令周期需要一个完整的时钟周期。
从 1980 年到 1995 年,DRAM 的容量随着摩尔定律的推进,每 18 个月翻倍(或每 3 年翻 4 倍)。从 1990 年代中期到 2010 年,容量增加的速度较慢,大约每 26 个月翻倍。从 2010 年到 2016 年,容量仅翻倍!图 2.4 显示了不同代 DDR SDRAM 的容量和访问时间。从 DDR1 到 DDR3,访问时间提高了大约 3 倍,或每年约 7%。DDR4 相比 DDR3 提升了功耗和带宽,但访问延迟相似。
如图 2.4 所示,DDR 是一系列标准的延续。DDR2 通过将电压从 2.5 V 降低到 1.8 V 来减少功耗,并提供更高的时钟频率:266、333 和 400 MHz。DDR3 将电压降低到 1.5 V,最大时钟速度为 800 MHz。(正如我们在下一节中讨论的,GDDR5 是一种图形内存,基于 DDR3 DRAM。)DDR4 于 2016 年初大规模上市,但原定于 2014 年发布,将电压降低到 1–1.2 V,最大预期时钟频率为 1600 MHz。DDR5 可能要到 2020 年或更晚才能达到生产数量。

图 2.4 显示了按生产年份划分的 DDR SDRAM 的容量和访问时间。访问时间指的是对一个随机内存字的访问,并假设需要打开一个新行。如果该行位于不同的银行,我们假设该银行已预充电;如果行尚未打开,则需要进行预充电,此时访问时间会更长。随着银行数量的增加,隐藏预充电时间的能力也有所提高。DDR4 SDRAM 最初预计在 2014 年推出,但直到 2016 年初才开始生产。
随着 DDR 的引入,内存设计师越来越关注带宽,因为提升访问时间变得困难。更宽的 DRAM、突发传输和双倍数据速率都促进了内存带宽的快速增长。DRAM 通常以称为双列直插内存模块(DIMM)的较小板卡出售,这些板卡包含 4 到 16 个 DRAM 芯片,通常组织为 8 字节宽(+ ECC)用于桌面和服务器系统。当 DDR SDRAM 被封装成 DIMM 时,它们通常根据峰值 DIMM 带宽进行标记,因此 DIMM 名称 PC3200 来自于 200 MHz × 2 × 8 字节,即 3200 MiB/s;它装配有 DDR SDRAM 芯片。为了增加混淆,芯片本身标记的是每秒位数而不是时钟频率,因此一个 200 MHz 的 DDR 芯片被称为 DDR400。图 2.5 显示了 I/O 时钟频率、每芯片每秒传输次数、芯片带宽、芯片名称、DIMM 带宽和 DIMM 名称之间的关系。

图 2.5 显示了 2016 年 DDR DRAM 和 DIMM 的时钟频率、带宽和名称。请注意各列之间的数字关系。第三列的数值是第二列的两倍,第四列的名称中使用了第三列的数字。第五列的数值是第三列的八倍,并且这个数字的四舍五入值被用于 DIMM 的名称。DDR4 在 2016 年首次得到了显著应用。
降低 SDRAM 功耗
动态内存芯片的功耗包括读写过程中使用的动态功耗和静态或待机功耗;这两者都依赖于操作电压。在最先进的 DDR4 SDRAM 中,操作电压已降低至 1.2 V,相比于 DDR2 和 DDR3 SDRAM,功耗显著减少。由于只有单个银行中的行被读取,增加银行数量也减少了功耗。
除了这些变化,所有近期的 SDRAM 都支持一个省电模式,该模式通过让 DRAM 忽略时钟来进入。省电模式会禁用 SDRAM,除了内部的自动刷新(如果没有刷新,长时间进入省电模式会导致内存内容丢失)。图 2.6 显示了 2 GB DDR3 SDRAM 在三种情况下的功耗。返回低功耗模式所需的具体延迟取决于 SDRAM,但典型的延迟是 200 个 SDRAM 时钟周期。

图 2.6 显示了 DDR3 SDRAM 在三种条件下的功耗:低功耗(关闭)模式、典型系统模式(DRAM 在读操作中活跃 30% 的时间,在写操作中活跃 15% 的时间),以及完全活跃模式,其中 DRAM 持续进行读取或写入操作。读取和写入操作假定为八次传输的突发。这些数据基于 Micron 1.5V 2GB DDR3-1066,类似的节能效果也适用于 DDR4 SDRAM。
图形数据 RAM(GDRAMs 或 GSDRAMs,图形或图形同步 DRAMs)是一类特殊的 DRAM,基于 SDRAM 设计,但经过调整以处理图形处理单元对带宽的更高需求。GDDR5 基于 DDR3,而早期的 GDDR 则基于 DDR2。由于图形处理单元(GPU;见第 4 章)每个 DRAM 芯片需要比 CPU 更高的带宽,GDDR 有几个重要的不同点:
1. GDDR 的接口更宽:32 位,而当前设计中的 DRAM 通常为 4、8 或 16 位。
2. GDDR 在数据引脚上的最高时钟频率更高。为了允许更高的传输速率而不产生信号问题,GDDR 通常直接连接到 GPU,并通过焊接方式附着在板上,这与通常以可扩展的 DIMM 阵列形式排列的 DRAM 不同。
总的来说,这些特性使得 GDDR 的带宽是 DDR3 DRAM 的两到五倍。
**封装创新:堆叠或嵌入式 DRAM**
2017 年 DRAM 的最新创新是封装创新,而非电路创新。这种创新将多个 DRAM 以堆叠或相邻的方式嵌入到与处理器相同的封装中。(嵌入式 DRAM 也用于指将 DRAM 放置在处理器芯片上的设计。)将 DRAM 和处理器放置在同一封装中可以降低访问延迟(通过缩短 DRAM 和处理器之间的延迟)并潜在地增加带宽,因为这允许处理器和 DRAM 之间有更多更快的连接;因此,许多生产商称之为高带宽内存(HBM)。
这种技术的一个版本是将 DRAM 芯片直接放置在 CPU 芯片上,并使用焊球技术连接它们。假设有足够的散热管理,多个 DRAM 芯片可以以这种方式堆叠。另一种方法则是仅堆叠 DRAM,并将其与 CPU 使用一个包含连接的基板(中介层)放在一个封装中。图 2.7 展示了这两种不同的互连方案。已经展示了允许堆叠多达八个芯片的 HBM 原型。使用特殊版本的 SDRAM,这种封装可以容纳 8 GiB 的内存,并具有 1 TB/s 的数据传输速率。2.5D 技术目前已可用。由于芯片必须专门制造以适应堆叠,因此最早的应用可能主要集中在高端服务器芯片组中。

在某些应用中,可能可以内部封装足够的 DRAM 以满足应用需求。例如,正在开发一种用于特殊目的集群设计的 Nvidia GPU 版本,该版本使用 HBM,并且 HBM 可能会成为高端应用的 GDDR5 的继任者。在某些情况下,可能可以将 HBM 用作主内存,尽管成本限制和散热问题目前使这种技术不适用于某些嵌入式应用。在下一部分中,我们将考虑将 HBM 用作额外缓存层的可能性。
**闪存**
闪存是一种 EEPROM(电子可擦写可编程只读存储器),通常是只读的,但可以被擦除。闪存的另一个关键特性是它能够在没有电力的情况下保持其内容。我们重点关注 NAND 闪存,因为它的密度比 NOR 闪存更高,更适合大规模非易失性存储器;缺点是访问是顺序的,写入速度较慢,具体如下。
闪存作为 PMD(便携式移动设备)中的二级存储器,其功能类似于笔记本电脑或服务器中的磁盘。此外,由于大多数 PMD 的 DRAM 数量有限,闪存还可能作为内存层级的一部分,比桌面或服务器中 10-100 倍大的主内存要大得多。
闪存使用与标准 DRAM 完全不同的架构,具有不同的特性。主要区别包括:
1. 读取闪存是顺序的,并且读取整个页面,这可以是 512 字节、2 KiB 或 4 KiB。因此,NAND 闪存从随机地址访问第一个字节的延迟较长(大约 25 μs),但可以以大约 40 MiB/s 的速度提供页面块的其余部分。相比之下,DDR4 SDRAM 获取第一个字节需要大约 40 ns,且可以以 4.8 GiB/s 的速度传输剩余的行。比较传输 2 KiB 的时间,NAND 闪存大约需要 75 μs,而 DDR SDRAM 少于 500 ns,使得闪存速度约为 150 倍慢。然而,与磁盘相比,从闪存读取 2 KiB 的速度快 300 到 500 倍。由此可见,闪存不适合替代 DRAM 作为主内存,但有潜力替代磁盘。
2. 闪存必须在被重写之前擦除(因此有“闪光”擦除过程的名称),并且是以块的形式擦除,而不是单独的字节或字。这一要求意味着在向闪存写入数据时,必须将整个块组装起来,或者作为新数据,或者通过合并待写数据和块的其余内容来完成。对于写入操作,闪存速度大约是 SDRAM 的 1500 倍慢,而比磁盘快 8-15 倍。
3. 闪存是非易失性的(即在没有电力时也能保持内容),并且在不读取或写入时消耗的电力显著减少(在待机模式下为原来的一半以下,在完全不活动时为零)。
4. 闪存限制了任何给定块的写入次数,通常至少为 100,000 次。通过确保写入块在内存中的均匀分布,系统可以最大化闪存系统的寿命。这种技术称为写入均衡,由闪存控制器处理。
5. 高密度 NAND 闪存比 SDRAM 便宜,但比磁盘更贵:闪存约为 $2/GiB,SDRAM 约为 $20 至 $40/GiB,磁盘约为 $0.09/GiB。在过去五年中,闪存的成本下降速度几乎是磁盘的两倍。
像 DRAM 一样,闪存芯片包含冗余块,以允许存在少量缺陷的芯片得以使用;块的重新映射在闪存芯片中处理。闪存控制器负责页面传输、页面缓存以及写入均衡。
高密度闪存的快速进步对低功耗便携设备和笔记本电脑的发展至关重要,但也显著改变了桌面计算机(它们越来越多地使用固态硬盘)以及大型服务器(通常结合了磁盘和闪存存储)。
**相变存储器技术**
相变存储器(PCM)已成为一个活跃的研究领域几十年。该技术通常使用一个小的加热元件来改变基质的状态,使其在晶态和非晶态之间转换,这两种状态具有不同的电阻特性。每个位对应于覆盖基质的二维网络中的一个交点。读取操作是通过感测 x 点和 y 点之间的电阻来完成的(因此有了“忆阻器”这一替代名称),而写入操作则是通过施加电流来改变材料的相态。由于没有主动设备(如晶体管),因此其成本应低于 NAND 闪存,密度也更高。
2017 年,Micron 和 Intel 开始交付被认为基于 PCM 的 Xpoint 存储芯片。预计该技术的写入耐久性比 NAND 闪存要好得多,并且通过消除在写入前擦除页面的需求,写入性能有望比 NAND 提高多达十倍。读取延迟也可能比闪存好,提升因素大约为 2-3 倍。最初,价格预计会稍高于闪存,但在写入性能和写入耐久性方面的优势可能使其在 SSD 中具有吸引力。如果这种技术能够良好地扩展并实现进一步的成本降低,它可能成为取代磁盘的固态技术,而磁盘作为主要的非易失性大容量存储器已经统治了超过 50 年。
**提升内存系统的可靠性**
大型缓存和主存显著增加了在制造过程中以及操作过程中发生错误的可能性。由电路变化引起的可重复错误称为硬错误或永久性故障。硬错误可以在制造过程中发生,也可以在操作过程中由于电路变化而发生(例如,Flash 存储单元在多次写入后出现故障)。所有 DRAM、Flash 存储和大多数 SRAM 都配备了备用行,以便通过编程将有缺陷的行替换为备用行,从而容纳少量制造缺陷。动态错误,即对单元内容的变化而非电路变化,称为软错误或瞬态故障。
动态错误可以通过奇偶校验位进行检测,通过使用纠错码(ECC)进行检测和修复。由于指令缓存是只读的,奇偶校验就足够了。在较大的数据缓存和主存中,ECC 被用来检测和修复错误。奇偶校验只需要一个附加位来检测一个位序列中的单个错误。由于多位错误将无法被奇偶校验检测到,因此奇偶校验位保护的位数必须有限。每 8 位数据使用一个奇偶校验位是典型的比例。ECC 能够检测两个错误并修复一个错误,其开销为每 64 位数据 8 位的附加位。
在非常大的系统中,多个错误以及单个内存芯片的完全失败的可能性变得显著。IBM 引入了 Chipkill 来解决这个问题,许多大型系统,如 IBM 和 SUN 服务器以及 Google 集群,都使用了这一技术。(英特尔称其版本为 SDDC。)Chipkill 的性质类似于用于磁盘的 RAID 方法,它分配数据和 ECC 信息,使得单个内存芯片的完全失败可以通过支持从剩余内存芯片中重建丢失的数据来处理。根据 IBM 的分析,假设一个拥有每个处理器 4 GiB 的 10,000 处理器服务器,在三年的操作中,以下是无法恢复错误的发生率:
- 仅奇偶校验:约 90,000 次,即每 17 分钟发生一次无法恢复(或未检测到)的故障。
- 仅 ECC:约 3,500 次,即每 7.5 小时发生一次未检测到或无法恢复的故障。
- Chipkill:约每 2 个月发生一次未检测到或无法恢复的故障。
另一种考虑方式是找到可以保护的最大服务器数量(每台服务器 4 GiB),同时实现与 Chipkill 演示的相同错误率。对于奇偶校验,即使是只有一个处理器的服务器,其无法恢复的错误率也高于 10,000 台服务器 Chipkill 保护系统。对于 ECC,17 台服务器系统的故障率与 10,000 台服务器 Chipkill 系统大致相同。因此,Chipkill 是仓库规模计算机(参见第 6 章第 6.8 节)中 50,000 至 100,000 台服务器的必备条件。


2.3 Ten Advanced Optimizations of Cache Performance

之前的平均内存访问时间公式为缓存优化提供了三个度量指标:命中时间、未命中率和未命中惩罚。鉴于近期趋势,我们将缓存带宽和功耗也加入了这份清单。我们可以将我们检查的10种高级缓存优化按这些指标分为五类:
1. **减少命中时间**——小型简单的一级缓存和路径预测。这些技术通常也会减少功耗。
2. **增加缓存带宽**——流水线缓存、多银行缓存和非阻塞缓存。这些技术对功耗的影响各异。
3. **减少未命中惩罚**——关键字优先和合并写缓冲区。这些优化对功耗的影响较小。
4. **减少未命中率**——编译器优化。显然,编译时间的任何改进都会改善功耗。
5. **通过并行性减少未命中惩罚或未命中率**——硬件预取和编译器预取。这些优化通常会增加功耗,主要是因为预取的数据未被使用。
总体而言,随着优化的进行,硬件复杂性会增加。此外,一些优化需要复杂的编译器技术,而最后一种依赖于HBM。我们将总结这10种技术的实施复杂性和性能收益,详见第113页的图2.18。由于其中一些技术比较简单,我们会简要介绍;其他的则需要更多描述。
**首次优化:减少命中时间和功耗的小型简单一级缓存**
在高速时钟周期和功耗限制的压力下,一级缓存的大小被限制在一个相对较小的范围内。同样,较低的关联度水平也可以减少命中时间和功耗,尽管这种权衡比涉及缓存大小的权衡要复杂得多。
在缓存命中的关键时间路径是一个三步过程:使用地址的索引部分访问标记内存,将读取的标记值与地址进行比较,以及在缓存为集合关联时设置多路复用器以选择正确的数据项。直接映射缓存可以将标记检查与数据传输重叠,从而有效减少命中时间。此外,较低的关联度水平通常会减少功耗,因为需要访问的缓存行更少。
尽管新一代微处理器的片上缓存总量大幅增加,但由于较大L1缓存对时钟频率的影响,L1缓存的大小最近有所增加,但增加幅度较小或几乎没有。在许多近期的处理器中,设计师更倾向于增加关联度,而不是增大缓存大小。选择关联度的另一个考虑因素是消除地址别名的可能性;我们将在稍后讨论这一主题。
确定对命中时间和功耗的影响的一个方法是使用CAD工具。CACTI是一个用于估算CMOS微处理器上各种缓存结构的访问时间和能耗的程序,其估算结果在10%以内,接近于更详细的CAD工具。对于给定的最小特征尺寸,CACTI根据缓存大小、关联度、读/写端口数量以及其他更复杂的参数估算缓存的命中时间。
图2.8显示了缓存大小和关联度变化对命中时间的估算影响。根据这些参数的缓存大小模型,直接映射缓存的命中时间略快于双向集合关联缓存,双向集合关联缓存的命中时间是四向缓存的1.2倍,四向缓存的命中时间是八向缓存的1.4倍。当然,这些估算结果取决于技术和缓存大小,CACTI必须与技术保持仔细对齐;图2.8显示了某一技术的相对权衡。

**图2.8**:随着缓存大小和关联度的增加,相对访问时间通常会增加。这些数据来自Tarjan等人(2005年)的CACTI模型6.5。数据假设了典型的嵌入式SRAM技术、单一银行和64字节块。关于缓存布局和复杂的权衡(这些权衡涉及到互连延迟,这取决于被访问的缓存块的大小,以及标记检查和多路复用的成本)导致了有时令人惊讶的结果,例如64 KiB的二路集合关联缓存的访问时间低于直接映射缓存。同样,八路集合关联缓存的结果在缓存大小增加时也表现出异常行为。由于这些观察结果高度依赖于技术和详细的设计假设,因此像CACTI这样的工具有助于减少搜索空间。这些结果是相对的;然而,随着我们进入更新和更密集的半导体技术,这些结果可能会发生变化。
**例题**:使用附录B中图B.8和图2.8的数据,确定32 KiB四路集合关联L1缓存是否比32 KiB两路集合关联L1缓存具有更快的内存访问时间。假设L2的缺失惩罚是较快L1缓存访问时间的15倍。忽略L2之后的缺失。哪一个具有更快的平均内存访问时间?
**答案**:设两路集合关联缓存的访问时间为1。那么,对于两路缓存,

对于四路集合关联缓存,访问时间是1.4倍长。缺失惩罚的经过时间为15/1.4≈10.1。为了简化,假设为10:

显然,更高的关联度看起来是一个不好的权衡;然而,由于现代处理器中的缓存访问通常是流水线化的,因此对时钟周期时间的确切影响很难评估。
能源消耗在选择缓存大小和关联度时也是一个重要的考虑因素,如图2.9所示。在128 KiB或256 KiB的缓存中,从直接映射到两路集合关联的情况下,更高关联度的能源成本范围从超过2倍到微不足道。

图2.9显示了每次读取的能量消耗随着缓存大小和关联度的增加而增加。与之前的图一样,使用CACTI进行建模,采用相同的技术参数。八路集合关联缓存的较大惩罚是由于并行读取八个标签和相应数据的成本。
由于能源消耗变得至关重要,设计师们专注于减少缓存访问所需的能量。除了关联度,决定缓存访问中使用的能量的另一个关键因素是缓存中的块数量,因为它决定了被访问的“行”的数量。设计师可以通过增加块大小(保持总缓存大小不变)来减少行数,但这可能会增加缺失率,尤其是在较小的L1缓存中。
一种替代方案是将缓存组织为多个银行,使得一次访问只激活缓存的一部分,即存放所需块的银行。多银行缓存的主要用途是增加缓存带宽,这是我们稍后会讨论的优化。多银行设计还可以减少能量消耗,因为访问的缓存部分更少。许多多核处理器中的L3缓存在逻辑上是统一的,但在物理上是分布式的,实际上充当了一个多银行缓存。根据请求的地址,实际上只访问一个物理L3缓存(一个银行)。我们将在第5章进一步讨论这种组织方式。
在最近的设计中,有三个其他因素导致尽管有能源和访问时间成本,但仍使用更高的关联度在一级缓存中。首先,许多处理器访问缓存至少需要2个时钟周期,因此较长的命中时间可能不会是关键问题。其次,为了将TLB从关键路径中排除(这种延迟会大于与增加关联度相关的延迟),几乎所有L1缓存都应进行虚拟索引。这将缓存的大小限制为页面大小乘以关联度,因为此时仅使用页面内的位来进行索引。尽管在地址转换完成之前还有其他解决缓存索引问题的方法,但增加关联度,且具有其他好处,是最具吸引力的。第三,随着多线程的引入(见第3章),冲突失效可能会增加,使得更高的关联度变得更具吸引力。
**第二种优化:通过方式预测来减少命中时间**
另一种方法是在减少冲突失效的同时保持直接映射缓存的命中速度。这种方法称为方式预测,它在缓存中保留额外的位来预测下一个缓存访问的方式(或集合内的块)。这种预测使得多路复用器可以提前设置,以选择所需的块,并在该时钟周期内,只有一个标签比较与读取缓存数据同时进行。未命中将导致在下一个时钟周期检查其他块以寻找匹配。
每个缓存块都增加了块预测位。这些位选择下一个缓存访问尝试的块。如果预测正确,缓存访问延迟为快速命中时间。如果预测错误,它会尝试其他块,改变方式预测器,增加一个额外的时钟周期延迟。模拟结果表明,对于二路集合关联缓存,方式预测的准确性超过90%,对于四路集合关联缓存为80%,并且I缓存的准确性优于D缓存。如果预测的速度至少比缓存访问快10%,则二路集合关联缓存的平均内存访问时间会更低,这种情况很可能发生。方式预测最早在1990年代中期的MIPS R10000中使用。在使用二路集合关联的处理器中很受欢迎,并且在多个ARM处理器中使用,这些处理器有四路集合关联缓存。对于非常快速的处理器,实现关键的一周期停顿以保持方式预测惩罚较小可能是具有挑战性的。
方式预测的扩展形式还可以通过使用方式预测位来减少功耗,以决定实际访问哪个缓存块(方式预测位实际上是额外的地址位);这种方法,可能称为方式选择,当方式预测正确时能节省功耗,但在方式错误预测时增加显著时间,因为不仅需要重复访问,还需要重新进行标签匹配和选择。这样的优化可能只有在低功耗处理器中才有意义。Inoue等(1999)估计,使用四路集合关联缓存的方式选择方法在SPEC95基准测试中,I缓存的平均访问时间增加了1.04,D缓存增加了1.13,但相对于正常的四路集合关联缓存,I缓存的平均功耗减少了0.28,D缓存减少了0.35。方式选择的一个显著缺点是它使得缓存访问的流水线化变得困难;然而,随着能源问题的加剧,不需要为整个缓存供电的方案变得越来越有意义。
**示例** 假设D-cache的访问次数是I-cache的一半,并且在正常的四路集合关联实现中,I-cache和D-cache分别占处理器功耗的25%和15%。根据前述研究的估算,确定方式选择是否能提高每瓦特的性能。
**回答** 对于I-cache,功耗节省为总功耗的25% × 0.28 = 0.07,而对于D-cache则为15% × 0.35 = 0.05,总共节省0.12。方式预测版本需要标准四路缓存功耗的0.88。缓存访问时间的增加是I-cache平均访问时间增加加上D-cache访问时间增加的一半,即1.04 + 0.5 × 0.13 = 1.11倍。这表明方式选择的性能为标准四路缓存的0.90。因此,方式选择每焦耳的性能稍微提高了,比例为0.90/0.88 = 1.02。该优化在功耗而非性能为主要目标的情况下效果最佳。
**第三种优化:流水线访问和多银行缓存以增加带宽**
这些优化通过流水线缓存访问或通过增加多个银行来拓宽缓存,以允许每个时钟周期进行多次访问,从而提高缓存带宽。这些优化是增加指令吞吐量的超流水线和超标量方法的对偶。这些优化主要针对L1缓存,因为在这里访问带宽限制了指令吞吐量。虽然L2和L3缓存也使用多个银行,但主要是作为节能管理技术。
对L1进行流水线化可以允许更高的时钟周期,但代价是增加了延迟。例如,在1990年代中期,Intel Pentium处理器的指令缓存访问流水线需要1个时钟周期;对于1990年代中期至2000年的Pentium Pro到Pentium III,流水线需要2个时钟周期;而对于2000年发布的Pentium 4和当前的Intel Core i7,则需要4个时钟周期。对指令缓存进行流水线化有效地增加了流水线阶段的数量,这会导致对错误预测分支的惩罚加大。相应地,对数据缓存进行流水线化会导致从发出加载指令到使用数据之间的时钟周期增加(见第3章)。今天,所有处理器都使用某种形式的L1流水线,即使只是为了简单地分开访问和命中检测,许多高速处理器则具有三层或更多层级的缓存流水线。
流水线处理指令缓存比数据缓存更容易,因为处理器可以依赖高性能的分支预测来限制延迟影响。许多超标量处理器可以在每个时钟周期发出并执行多个内存引用(常见的是加载或存储,有些处理器允许多个加载)。为了处理每个时钟周期的多个数据缓存访问,我们可以将缓存分成独立的银行,每个银行支持独立的访问。银行最初用于提高主内存的性能,现在也用于现代DRAM芯片和缓存中。Intel Core i7的L1缓存有四个银行(支持每个时钟周期最多2次内存访问)。
显然,banks的效果最佳时,当访问自然分布到各个银行时,因此地址到银行的映射会影响内存系统的行为。一个简单而有效的映射方法是将块的地址顺序地分布到各个银行,这被称为顺序交错。例如,如果有四个banks,银行0包含地址模4为0的所有块,银行1包含地址模4为1的所有块,图2.10显示了其相关性,以此类推。多个banks也可以减少缓存和DRAM的功耗。

图2.10 使用块地址的四路交错缓存banks。假设每个块64字节,每个地址都需要乘以64来得到字节地址。
多个banks在L2或L3缓存中也很有用,但原因不同。L2缓存中的多个banks可以处理多个未解决的L1缓存未命中,只要banks之间没有冲突。这是支持非阻塞缓存的关键能力。Intel Core i7的L2缓存有八个banks,而Arm Cortex处理器的L2缓存使用了1到4个banks。如前所述,多banks也可以减少能耗。
第四种优化:非阻塞缓存以增加缓存带宽
对于允许乱序执行的流水线计算机(在第3章中讨论),处理器在数据缓存未命中时不需要停顿。例如,处理器可以在等待数据缓存返回缺失数据的同时继续从指令缓存中提取指令。非阻塞缓存或无锁缓存通过允许数据缓存在未命中期间继续提供缓存命中,从而提升这种方案的潜在好处。这种“未命中的命中”优化通过在未命中期间继续提供帮助来减少有效未命中惩罚,而不是忽视处理器的请求。一个微妙而复杂的选项是缓存如果能够重叠多个未命中,可能会进一步降低有效未命中惩罚:一种“多重未命中的命中”或“未命中的未命中”优化。第二种选项只有在内存系统可以同时处理多个未命中时才有益;大多数高性能处理器(如Intel Core处理器)通常支持这两种选项,而许多低端处理器在L2中仅提供有限的非阻塞支持。
为了研究非阻塞缓存在减少缓存未命中惩罚方面的有效性,Farkas和Jouppi(1994)进行了研究,假设缓存为8 KiB,未命中惩罚为14个周期(适用于1990年代初期)。他们观察到,当允许一个未命中期间的命时时,SPECINT92基准测试的有效未命中惩罚减少了20%,SPECFP92基准测试减少了30%。
Li等(2011)更新了这项研究,使用了多级缓存、对未命中惩罚的更现代假设以及更大且要求更高的SPECCPU2006基准测试。该研究假设基于单个Intel i7核心(见第2.6节)运行SPECCPU2006基准测试。图2.11展示了在允许1、2和64个未命中期间的命中时数据缓存访问延迟的减少;标题中描述了内存系统的更多细节。与早期研究相比,由于缓存变得更大以及L3缓存的添加,SPECINT2006基准测试显示出平均约9%的缓存延迟减少,而SPECFP2006基准测试则约减少了12.5%。
例子:对于浮点程序,二路组相联和在一次未命中下的命中哪个更重要?对于整数程序呢?
假设32 KiB数据缓存的平均未命中率如下:浮点程序使用直接映射缓存的未命中率为5.2%,使用二路组相联缓存的未命中率为4.9%;整数程序使用直接映射缓存的未命中率为3.5%,使用二路组相联缓存的未命中率为3.2%。假设L2缓存的未命中惩罚为10个周期,L2缓存的未命中次数和惩罚相同。
回答:
对于浮点程序,平均内存停顿时间的计算如下:

二路组相联缓存的访问延迟(包括停顿时间)为 0.49/0.52,即直接映射缓存的94%。图2.11的标题指出,在一次未命中下的命中将浮点程序的平均数据缓存访问延迟降低到阻塞缓存的87.5%。因此,对于浮点程序来说,直接映射数据缓存支持一次未命中下的命中,相比于在未命中时阻塞的二路组相联缓存,性能更佳。

图2.11 通过允许在缓存未命中情况下进行1次、2次或64次命中来评估非阻塞缓存的有效性,左侧展示了9个SPECINT基准测试,右侧展示了9个SPECFP基准测试。该数据内存系统模拟了Intel i7处理器,包括一个32 KiB的L1缓存,访问延迟为四个周期。L2缓存(与指令共享)为256 KiB,访问延迟为10个时钟周期。L3缓存为2 MiB,访问延迟为36个周期。所有缓存均为八路组相联,块大小为64字节。在未命中情况下允许一次命中可以将整数基准测试的未命中惩罚降低9%,将浮点基准测试的未命中惩罚降低12.5%。允许第二次命中将这些结果改善到10%和16%,而允许64次命中则几乎没有额外的改进。
对于整数程序,计算方法如下:

二路组相联缓存的数据缓存访问延迟为 0.32/0.35,即直接映射缓存的91%,而允许一次未命中下的命中将访问延迟减少了9%,使得这两种选择的性能相当。
非阻塞缓存性能评估的真正难点在于,缓存未命中并不一定会使处理器停顿。在这种情况下,很难判断单次未命中的影响,从而计算平均内存访问时间。有效的未命中惩罚不是未命中的总和,而是处理器被阻塞的非重叠时间。非阻塞缓存的好处很复杂,因为它取决于多次未命中的惩罚、内存引用模式以及处理器在未命中时能够执行多少指令。
一般而言,乱序处理器能够隐藏大部分L1数据缓存未命中时的惩罚,如果未命中数据在L2缓存中,但对较低级缓存的未命中则难以隐藏较大部分的惩罚。决定支持多少个未命中需要考虑多种因素:
- 未命中流中的时间和空间局部性,这决定了未命中是否可以发起对低级缓存或内存的新访问。
- 响应内存或缓存的带宽。
- 在缓存的最低级别(未命中时间最长)允许更多的未命中需要在更高级别支持至少相同数量的未命中,因为未命中必须在最高级别缓存发起。
- 内存系统的延迟。
下面的简化示例说明了关键思想。
例子:假设主存访问时间为36纳秒,内存系统能够维持16 GiB/s的持续传输率。如果块大小为64字节,那么在假设我们可以保持峰值带宽给定请求流,并且访问之间没有冲突的情况下,我们需要支持的最大未命中数是多少?如果一个引用与之前四个引用中的任何一个发生冲突的概率是50%,并且我们假设访问必须等待直到先前的访问完成,估计最大未命中数。为了简化问题,忽略未命中之间的时间。
答案:在第一个情况下,假设我们能够维持峰值带宽,内存系统可以支持 (16 × 10^9) / 64 = 2.5 亿个引用每秒。由于每个引用需要36纳秒,我们可以支持 250 × 10^6 × 36 × 10^-9 = 9 个引用。
如果冲突的概率大于0,那么我们需要更多的未命中引用,因为我们无法开始处理那些发生冲突的引用;内存系统需要更多独立的引用,而不是更少!为了近似估计,我们可以简单地假设一半的内存引用不需要发给内存。这意味着我们必须支持两倍的未命中引用,即18个。
在Li、Chen、Brockman和Jouppi的研究中,他们发现整数程序的CPI(每条指令周期数)在一次未命中的情况下减少约7%,而在块大小为64字节时减少约12.7%。对于浮点程序,减少分别为一次未命中情况下的12.7%和块大小为64字节时的17.8%。这些减少值与图2.11中显示的数据缓存访问延迟减少情况相当接近。
实现非阻塞缓存
虽然非阻塞缓存有潜力提高性能,但实现起来并非简单。主要面临两个初步挑战:处理命中与未命中之间的争用,以及追踪未处理的未命中,以便了解何时可以继续进行加载或存储。首先考虑第一个问题。在阻塞缓存中,未命中会导致处理器停滞,直到未命中处理完成之前,不会有进一步的缓存访问。然而,在非阻塞缓存中,命中可能与从内存层次结构下一级返回的未命中发生冲突。如果允许多个未处理的未命中(几乎所有现代处理器都允许),未命中之间发生冲突是可能的。这些冲突必须解决,通常通过首先优先处理命中,其次按顺序处理冲突的未命中(如果可能的话)。
第二个问题是因为我们需要追踪多个未处理的未命中。在阻塞缓存中,我们始终知道哪个未命中正在返回,因为只有一个未命中可以处于处理状态。而在非阻塞缓存中,这种情况很少发生。乍看之下,你可能认为未命中总是按顺序返回,因此可以通过简单的队列来匹配返回的未命中与最长未处理请求。然而,考虑到在L1中发生的未命中,它可能在L2中生成命中或未命中;如果L2也是非阻塞的,那么未命中返回到L1的顺序可能与它们最初发生的顺序不同。多核和其他多处理器系统中不均匀的缓存访问时间也引入了这种复杂性。
当未命中返回时,处理器必须知道哪个加载或存储操作引起了未命中,以便该指令可以继续执行;同时,它必须知道数据应放置在缓存中的位置(以及该块的标签设置)。在现代处理器中,这些信息保存在一组寄存器中,通常称为未命中状态处理寄存器(MSHRs)。如果允许n个未处理的未命中,就会有n个MSHR,每个MSHR保存有关未命中在缓存中位置的信息、任何标签位的值以及引起未命中的加载或存储信息(在下一章中,你将看到如何追踪这些)。因此,当未命中发生时,我们为处理该未命中分配一个MSHR,输入有关未命中的信息,并用MSHR的索引标记内存请求。内存系统在返回数据时使用该标签,允许缓存系统将数据和标签信息转移到适当的缓存块,并“通知”生成未命中的加载或存储操作数据现在可用,可以继续操作。非阻塞缓存显然需要额外的逻辑,因此有一定的能量成本。然而,由于它们可能减少停滞时间,从而缩短执行时间并降低能量消耗,因此很难准确评估其能量成本。
除了上述问题外,多处理器内存系统(无论是在单个芯片内还是多个芯片上)还必须处理与内存一致性和一致性相关的复杂实现问题。此外,由于缓存未命中不再是原子的(因为请求和响应被拆分并可能在多个请求之间交错),可能会出现死锁。有关这些问题的详细信息,请参见在线附录I中的第I.7节。
第五种优化:优先访问关键字和早期重启以减少未命中惩罚
这种技术基于这样一个观察:处理器通常一次只需要块中的一个字。这个策略的核心在于“急躁”:不要等到整个块完全加载后再发送请求的字和重启处理器。以下是两种具体策略:
■ 关键字优先—首先从内存中请求未命中的字,并在其到达后立即将其发送给处理器;在填充块中的其他字时,让处理器继续执行。
■ 早期重启—按正常顺序获取字,但一旦请求的块中的字到达,将其发送给处理器,并让处理器继续执行。
通常,这些技术只有在块较大时才有利,因为如果块较小,效果不明显。请注意,缓存通常在填充剩余块时继续满足对其他块的访问。然而,由于空间局部性,下一次访问很可能是对块中剩余部分的访问。就像非阻塞缓存一样,未命中惩罚的计算并不简单。当关键字优先策略有第二个请求时,有效的未命中惩罚是从参考点到第二块到达的非重叠时间。关键字优先和早期重启的效果取决于块的大小以及对尚未提取的块部分的再次访问的可能性。例如,对于使用早期重启和关键字优先的i7 6700处理器运行的SPECint2006测试,平均每个块有超过一个引用(平均1.23次,范围从0.5到3.0)。我们将在第2.6节中更详细地探讨i7内存层次结构的性能。
第六种优化:合并写缓冲区以减少未命中惩罚
写直通缓存依赖于写缓冲区,因为所有写操作必须发送到层次结构的下一级。即使是写回缓存,在替换块时也会使用简单的缓冲区。如果写缓冲区为空,数据和完整地址将写入缓冲区,从处理器的角度来看,写操作完成;处理器继续工作,同时写缓冲区准备将字写入内存。如果缓冲区包含其他已修改的块,可以检查地址以查看新数据的地址是否与有效写缓冲区条目的地址匹配。如果匹配,新数据将与该条目合并。写合并就是这种优化的名称。英特尔Core i7等许多处理器使用写合并。
如果缓冲区已满且没有地址匹配,缓存(和处理器)必须等待直到缓冲区有空闲条目。这种优化更有效地使用内存,因为多字写入通常比逐字写入要快。Skadron和Clark(1997)发现,即使是合并的四条目写缓冲区也会产生导致5%-10%性能损失的停顿。
这种优化还减少了由于写缓冲区已满而导致的停顿。图2.12显示了有和没有写合并的写缓冲区。假设写缓冲区中有四个条目,每个条目可以容纳四个64位字。没有这种优化时,四个对顺序地址的写入将以每个条目一个字的方式填满缓冲区,即使这四个字合并后正好可以放入写缓冲区的一个条目中。

图2.12 在这个写合并的示意图中,顶部的写缓冲区不使用写合并,而底部的写缓冲区使用了写合并。四个写入操作在写合并的情况下合并为一个缓冲区条目;如果没有写合并,缓冲区已满,尽管每个条目的四分之三空间被浪费。缓冲区有四个条目,每个条目可容纳四个64位字。每个条目的地址在左侧,带有有效位(V),指示该条目中下一个顺序8字节是否被占用。(如果没有写合并,图上部右侧的字仅用于同时写入多个字的指令。)
需要注意的是,输入/输出设备寄存器通常映射到物理地址空间。这些I/O地址无法允许写合并,因为单独的I/O寄存器可能无法像内存中的字数组那样工作。例如,它们可能需要每个I/O寄存器一个地址和数据字,而不是使用单一地址进行多字写入。这些副作用通常通过标记页面为需要非合并直通写入的缓存来实现。
第七种优化:编译器优化以降低未命中率
到目前为止,我们的技术都需要更改硬件。下一种技术则在不改变任何硬件的情况下降低未命中率。
这种神奇的减少源于优化软件——硬件设计师最喜欢的解决方案!处理器与主存之间日益扩大的性能差距促使编译器开发者仔细研究内存层次结构,以查看编译时优化是否能提高性能。再次,研究分为对指令未命中的改进和对数据未命中的改进。接下来介绍的优化在许多现代编译器中都可以找到。
循环互换
某些程序具有嵌套循环,这些循环以非顺序的方式访问内存中的数据。简单地交换循环的嵌套顺序可以使代码按数据存储的顺序访问数据。假设数组无法完全放入缓存,这种技术通过改善空间局部性来减少未命中率;重新排序最大限度地利用缓存块中的数据,直到它们被丢弃。例如,如果 x 是一个大小为 [5000,100] 的二维数组,分配方式使得 x[i,j] 和 x[i,j+1] 是相邻的(这种排列方式称为行主序,因为数组是按行布局的),那么以下两段代码展示了如何优化访问:

原始代码会以每次跨越100个字的方式跳过内存,而修订后的版本在进入下一个缓存块之前会访问一个缓存块中的所有字。这种优化在不影响执行指令数量的情况下提高了缓存性能。
阻塞优化
这种优化提高了时间局部性以减少未命中率。我们再次处理多个数组,其中一些数组按行访问,另一些按列访问。按行(行主序)或按列(列主序)存储数组并不能解决问题,因为在每次循环迭代中都会使用行和列。这种正交访问意味着像循环互换这样的变换仍然有很大的改进空间。
阻塞算法不是在整个数组的行或列上操作,而是在子矩阵或块上操作。其目标是在数据被替换之前,最大限度地访问已加载到缓存中的数据。下面的代码示例展示了执行矩阵乘法的过程,有助于激励这种优化:

两个内层循环读取z的所有N-by-N元素,重复读取y中同一行的N个元素,并写入x的一行N个元素。图2.13展示了对这三个数组的访问快照。深色表示最近的访问,浅色表示较旧的访问,白色表示尚未访问。容量未命中次数显然取决于N和缓存大小。如果缓存能够容纳所有三个N-by-N矩阵,那么一切正常,前提是没有缓存冲突。如果缓存可以容纳一个N-by-N矩阵和一行N元素,那么至少y的第i行和数组z可能会保留在缓存中。少于此,x和z都可能发生未命中。在最坏情况下,进行N³次操作时将访问2N³ + N²个内存字。
为了确保被访问的元素能够适应缓存,原始代码被修改为在B by B的子矩阵上计算。两个内层循环现在以B为步长计算,而不是x和z的完整长度。B被称为阻塞因子。(假设x初始化为零。)

图2.14展示了使用阻塞技术对三个数组的访问。仅考虑容量未命中,总共访问的内存字数为2N³/B + N²。这个总数大约提高了B倍。因此,阻塞利用了空间局部性和时间局部性的结合,因为y受益于空间局部性,而z受益于时间局部性。虽然我们的示例使用了正方形块(B x B),但如果矩阵不是正方形,我们也可以使用矩形块。

虽然我们旨在减少缓存未命中,但阻塞还可以用于帮助寄存器分配。通过选择一个小的阻塞大小,以便块可以保留在寄存器中,我们可以最小化程序中的加载和存储次数。正如我们将在第4章第4.8节中看到的,缓存阻塞对于从基于缓存的处理器中获得良好性能是绝对必要的,尤其是在使用矩阵作为主要数据结构的应用程序中。
### 第八个优化:硬件预取指令和数据以减少未命中惩罚或未命中率
非阻塞缓存通过重叠执行和内存访问,有效减少了未命中惩罚。另一种方法是在处理器请求之前预取数据和指令。这些预取可以直接放入缓存,或者放入一个比主内存更快访问的外部缓冲区。
指令预取通常是在缓存外的硬件中完成。通常情况下,处理器在未命中时会获取两个块:请求的块和下一个连续的块。请求的块在返回时被放入指令缓存,而预取的块则放入指令流缓冲区。如果请求的块已经在指令流缓冲区中,则原始缓存请求会被取消,从流缓冲区读取该块,并发出下一个预取请求。
类似的方法也可以应用于数据访问(Jouppi, 1990)。Palacharla和Kessler(1994)研究了一组科学程序,考虑了能够处理指令或数据的多个流缓冲区。他们发现,八个流缓冲区可以捕获来自具有两个64 KiB四路组相联缓存的处理器(一个用于指令,另一个用于数据)50%-70%的所有未命中。
Intel Core i7支持对L1和L2的硬件预取,最常见的预取情况是访问下一行。一些早期的Intel处理器使用了更激进的硬件预取,但这导致某些应用程序性能下降,促使一些高级用户关闭该功能。
图2.15显示了当启用硬件预取时,SPEC2000程序子集的整体性能提升。请注意,该图仅包括12个整数程序中的2个,而涵盖了大多数SPECCPU浮点程序。我们将在第2.6节中回到对i7上预取的评估。

图2.15 显示了在启用硬件预取的情况下,Intel Pentium 4 对12个SPECint2000基准测试中2个程序和14个SPECfp2000基准测试中9个程序的加速效果。仅显示了最能从预取中受益的程序;对其余15个SPECCPU基准测试的预取加速效果低于15%(Boggs等,2004)。
预取依赖于利用本来未使用的内存带宽,但如果干扰了需求未命中,实际上可能降低性能。编译器的帮助可以减少无效预取。当预取效果良好时,对功耗的影响可以忽略不计。然而,当预取的数据未被使用,或有用数据被替换时,预取将对功耗产生非常负面的影响。
### 第九个优化:编译器控制的预取以减少未命中惩罚或未命中率
硬件预取的替代方案是让编译器插入预取指令,以在处理器需要数据之前请求数据。预取有两种形式:
- **寄存器预取**将值加载到寄存器中。
- **缓存预取**仅将数据加载到缓存中,而不进入寄存器。
这两者可以是故障型或非故障型,即地址可能会或不会引发虚拟地址错误和保护违规。用这种术语来说,普通加载指令可以视为“故障寄存器预取指令”。非故障预取在正常情况下会导致异常时,会变为无操作指令,这正是我们希望的。
最有效的预取是“语义上不可见”的:它不改变寄存器和内存的内容,也不会导致虚拟内存故障。目前大多数处理器提供非故障缓存预取。本节假设使用非故障缓存预取,也称为非绑定预取。
预取只有在处理器能够在预取数据的同时继续执行时才有意义;即,缓存不会停滞,而是继续提供指令和数据,同时等待预取数据返回。正如预期的那样,这类计算机的数据缓存通常是非阻塞的。
与硬件控制的预取类似,目标是使执行与数据预取重叠。循环是重要的目标,因为它们适合进行预取优化。如果未命中惩罚较小,编译器只需展开循环一次或两次,并将预取与执行调度。如果未命中惩罚较大,则使用软件流水线技术(参见附录H)或多次展开,以为未来的迭代预取数据。
然而,发出预取指令会产生指令开销,因此编译器必须小心确保这些开销不会超过收益。通过集中关注可能发生缓存未命中的引用,程序可以避免不必要的预取,同时显著改善平均内存访问时间。
示例:对于以下代码,确定哪些访问可能导致数据缓存未命中。接下来,插入预取指令以减少未命中。最后,计算执行的预取指令数量以及通过预取避免的未命中次数。假设我们有一个8 KiB的直接映射数据缓存,块大小为16字节,并且它是一个写回缓存,采用写分配策略。数组a和b的元素为8字节长,因为它们是双精度浮点数组。数组a有3行100列,数组b有101行3列。假设它们在程序开始时不在缓存中。

回答:编译器首先会确定哪些访问可能导致缓存未命中;否则,我们将浪费时间为会命中的数据发出预取指令。数组a的元素按照存储顺序写入,因此会受益于空间局部性:偶数j会未命中,奇数j会命中。由于a有3行100列,其访问将导致150次未命中。
数组b不受益于空间局部性,因为访问顺序不一致。但数组b在时间局部性上受益两次:每次i的迭代访问相同元素,而每次j的迭代使用与上次相同的b值。忽略潜在的冲突未命中,b的未命中将是i=0时b[j + 1][0]的访问,以及j=0时b[j][0]的首次访问。因为当i=0时,j从0到99,访问b将导致101次未命中。
因此,该循环将导致a约150次未命中,加上b的101次,或总计251次未命中。为了简化优化,我们不考虑循环的首次访问是否需要预取。这些可能已经在缓存中,或者我们将面临a或b的前几个元素的未命中惩罚。我们也不担心循环结束时试图超出a(a[i][100] … a[i][106])和b(b[101][0] … b[107][0])的预取。如果这些是错误的预取,我们就不能这么奢侈。假设未命中惩罚非常大,我们需要至少提前七次迭代开始预取。(换句话说,我们假设在第八次迭代之前,预取没有任何好处。)我们将下划线标出添加预取所需的代码更改。


这段修订后的代码预取了a[i][7]到a[i][99]和b[7][0]到b[100][0],将未预取的未命中次数减少到:
- 第一个循环中b[0][0]、b[1][0]、…、b[6][0]的7次未命中
- 第一个循环中a[0][0]、a[0][1]、…、a[0][6]的4次未命中(空间局部性将未命中减少到每个16字节缓存块1次)
- 第二个循环中a[1][0]、a[1][1]、…、a[1][6]的4次未命中
- 第二个循环中a[2][0]、a[2][1]、…、a[2][6]的4次未命中
总共为19次未预取的未命中。避免232次缓存未命中的代价是执行400条预取指令,这可能是一个不错的权衡。
例子:计算前面例子的节省时间。忽略指令缓存未命中,假设数据缓存中没有冲突或容量未命中。假设预取可以与彼此和缓存未命中重叠,从而以最大内存带宽传输。以下是忽略缓存未命中的关键循环时间:原始循环每次迭代需要7个时钟周期,第一个预取循环每次迭代需要9个时钟周期,第二个预取循环每次迭代需要8个时钟周期(包括外部for循环的开销)。未命中需要100个时钟周期。
答案:原始的双重嵌套循环执行乘法3 × 100或300次。由于循环每次迭代需要7个时钟周期,总共为300 × 7或2100个时钟周期,加上缓存未命中。缓存未命中增加了251 × 100或25,100个时钟周期,总计27,200个时钟周期。第一个预取循环迭代100次;每次迭代9个时钟周期,总共为900个时钟周期,加上缓存未命中的1100个时钟周期,得到总计2000个时钟周期。第二个循环执行2 × 100或200次,每次迭代8个时钟周期,总共需要1600个时钟周期,加上800个时钟周期的缓存未命中,总计2400个时钟周期。从之前的例子中我们知道,这段代码在执行这两个循环的4400个时钟周期中执行了400条预取指令。如果我们假设预取与其余执行完全重叠,则预取代码的速度是27,200/4400,约为6.2倍。
虽然数组优化易于理解,但现代程序更可能使用指针。Luk 和 Mowry(1999)证明,基于编译器的预取有时也可以扩展到指针。在10个具有递归数据结构的程序中,当访问一个节点时预取所有指针,使得一半的程序性能提高了4%到31%。另一方面,其余程序的性能仍在原始性能的2%之内。关键问题在于预取是否针对已经在缓存中的数据,以及预取是否发生得足够早,以便数据能在需要时到达。
许多处理器支持缓存预取指令,高端处理器(如Intel Core i7)通常还会在硬件中进行某种类型的自动预取。
第十个优化:使用高带宽内存(HBM)扩展内存层次结构
由于大多数服务器中的通用处理器可能需要比HBM封装所能提供的更多内存,因此建议使用封装内的DRAM构建大规模L4缓存,未来技术的容量范围从128 MiB到1 GiB及以上,远超过当前的片上L3缓存。使用如此大的基于DRAM的缓存会引发一个问题:标签存储在哪里?这取决于标签的数量。假设使用64B块大小,那么1 GiB的L4缓存需要96 MiB的标签,这远超过CPU缓存中的静态存储。将块大小增大到4 KiB,会显著减少标签存储至256K条目,总存储量不足1 MiB,这在下一代多核处理器中可能是可以接受的,因为L3缓存通常为4–16 MiB或更多。然而,这样的大块大小存在两个主要问题。
首先,当许多块的内容不被需要时,缓存可能会被低效使用,这被称为碎片化问题,这在虚拟内存系统中也会发生。此外,传输如此大的块如果其中许多数据未被使用也是低效的。第二,由于块大小较大,DRAM缓存中持有的不同块数量显著减少,这可能导致更多的未命中,特别是冲突未命中和一致性未命中。
解决第一个问题的一个部分方案是添加子锁定(subblocking)。子锁定允许块的部分内容无效,要求在未命中时进行获取。然而,子锁定对第二个问题没有任何解决作用。
标签存储是使用较小块大小的主要缺点。一个可能的解决方案是将L4的标签存放在HBM中。乍一看,这似乎不可行,因为每次L4访问需要对DRAM进行两次访问:一次用于标签,一次用于数据。由于随机DRAM访问的长延迟,通常需要100个或更多的处理器时钟周期,因此这种方法曾被放弃。Loh和Hill(2011)提出了一种巧妙的解决方案:将标签和数据存放在HBM SDRAM的同一行中。尽管打开(并最终关闭)行需要较长时间,但访问行中不同部分的CAS延迟约为新行访问时间的三分之一。因此,我们可以首先访问块的标签部分,如果命中,则通过列访问选择正确的数据字。Loh和Hill(L-H)建议将L4 HBM缓存组织为每个SDRAM行包含一组标签(位于块头部)和29个数据段,形成29路集合关联缓存。当访问L4时,适当的行被打开并读取标签;命中时只需再进行一次列访问即可获取匹配的数据。
Qureshi和Loh(2012)提出了一种名为合金缓存的改进,减少命中时间。合金缓存将标签和数据结合在一起,使用直接映射的缓存结构。这使得L4的访问时间减少到一个HBM周期,通过直接索引HBM缓存并进行标签和数据的突发传输。图2.16显示了合金缓存、L-H方案和基于SRAM的标签的命中延迟。合金缓存的命中延迟比L-H方案减少了超过2倍,但未命中率增加了1.1到1.2倍。基准测试的选择在图注中有所说明。

图2.16显示了L-H方案、当前不切实际的使用SRAM作为标签的方案以及合金缓存组织的平均命中时间延迟(以时钟周期为单位)。在SRAM情况下,我们假设SRAM的访问时间与L3相同,并且在访问L4之前会进行检查。平均命中延迟为43(合金缓存)、67(SRAM标签)和107(L-H)。这里使用的10个SPECCPU2006基准测试是内存密集型的;如果L3完美,每个基准的运行速度将提高一倍。
不幸的是,在这两种方案中,未命中都需要两次完整的DRAM访问:一次用于获取初始标签,另一次用于访问主存(速度更慢)。如果我们能加快未命中检测的速度,就能减少未命中时间。为了解决这个问题,提出了两种不同的解决方案:一种使用映射表来跟踪缓存中的块(仅记录块是否存在,而非其位置);另一种使用内存访问预测器,通过历史预测技术预测可能的未命中,类似于全局分支预测中使用的方法(见下一章)。研究表明,小型预测器可以高精度地预测可能的未命中,从而降低整体未命中惩罚。
图2.17显示了在图2.16中使用的内存密集基准测试中,SPECrate获得的加速效果。合金缓存方法优于LH方案,甚至优于不切实际的SRAM标签,因为未命中预测器的快速访问时间与良好的预测结果结合,导致预测未命中的时间更短,从而降低未命中惩罚。合金缓存的表现接近理想情况,即具有完美未命中预测和最小命中时间的L4。

图2.17显示了在LH方案、SRAM标签方案和理想L4(Ideal)下运行SPECrate基准测试的性能加速情况;加速比为1表示L4缓存没有改进,加速比为2则意味着如果L4完美且没有访问时间,将能够实现。使用了10个内存密集型基准测试,每个基准测试运行八次。所采用的未命中预测方案也一同使用。理想情况下假设仅需访问和传输L4中请求的64字节块,并且L4的预测准确性是完美的(即,所有未命中在零成本下已知)。
HBM可能会在多种不同配置中得到广泛应用,从作为一些高性能专用系统的整个内存系统,到作为较大服务器配置的L4缓存。
缓存优化总结  
提高命中时间、带宽、未命中惩罚和未命中率的技术通常会影响平均内存访问方程的其他组件以及内存层次的复杂性。图2.18总结了这些技术并估计了对复杂性的影响,其中“+”表示该技术改善了某个因素,“%”表示该因素受到影响,空白表示没有影响。通常,没有任何技术在多个类别上都能提供帮助。

图2.18 显示了10种高级缓存优化的总结,体现了对缓存性能、功耗和复杂性的影响。尽管通常一个技术只对一个因素有帮助,但如果预取足够提前进行,可以减少未命中;如果没有做到这一点,则可以降低未命中惩罚。"+"表示该技术改善了该因素,"%"表示对该因素造成负面影响,空白表示没有影响。复杂性度量是主观的,0表示最简单,3表示具有挑战性。


2.4 Virtual Memory and Virtual Machines

虚拟机被视为真实机器的高效、隔离的副本。我们通过虚拟机监视器(VMM)的概念来解释这些观点……VMM有三个基本特征。首先,VMM为程序提供的环境与原始机器基本相同;其次,在该环境中运行的程序在速度上最多仅有轻微下降;最后,VMM对系统资源具有完全控制权。
——杰拉尔德·波佩克和罗伯特·戈德堡,《可虚拟化的第三代架构的正式要求》,《计算机协会通讯》(1974年7月)。
附录B的B.4节描述了虚拟内存的关键概念。虚拟内存允许将物理内存视为二级存储(可能是磁盘或固态硬盘)的缓存。虚拟内存在内存层次结构的两个级别之间移动页面,就像缓存在不同层次之间移动块一样。TLB作为页表的缓存,消除了每次地址转换时进行内存访问的需要。虚拟内存还提供了共享同一物理内存但拥有独立虚拟地址空间的进程之间的隔离。读者应确保理解虚拟内存的这两项功能后再继续。
本节重点讨论共享同一处理器的进程之间的保护和隐私问题。安全性和隐私是2017年信息技术面临的最棘手挑战之一。电子盗窃事件频繁发生,通常涉及信用卡号码的列表,且据信还有更多未报告的案件。当然,这些问题源于编程错误,允许网络攻击访问不该访问的数据。编程错误是常态,随着现代复杂软件系统的发展,这种情况时有发生。因此,研究人员和从业者都在寻找改进计算系统安全性的途径。虽然保护信息不仅限于硬件,但在我们看来,真正的安全性和隐私可能涉及计算机架构和系统软件的创新。
本节首先回顾通过虚拟内存保护进程之间相互隔离的架构支持,接着描述虚拟机提供的额外保护、虚拟机的架构要求以及虚拟机的性能。如第六章所示,虚拟机是云计算的基础技术。
### 通过虚拟内存实现保护
基于页面的虚拟内存,包括缓存页面表条目的TLB,是保护进程相互隔离的主要机制。附录B的B.4和B.5节回顾了虚拟内存,详细描述了在80x86架构下通过分段和分页实现的保护。本节提供了快速回顾;如果过于简略,请参考附录B中标明的部分。
多道程序设计允许多个程序同时运行在同一计算机上,这导致了对程序之间保护和共享的需求,以及“进程”这一概念的提出。从比喻上讲,进程是一个程序的呼吸空气和生活空间,即正在运行的程序及其继续运行所需的所有状态。在任何时刻,都必须能够从一个进程切换到另一个进程。这种交换称为进程切换或上下文切换。
操作系统和体系结构共同努力,使得进程能够共享硬件而不相互干扰。为此,体系结构必须限制用户进程运行时可以访问的内容,同时允许操作系统进程访问更多资源。至少,体系结构必须做到以下几点:
### 1. 提供至少两种模式,以指示正在运行的进程是用户进程还是操作系统进程。后者有时称为内核进程或监控进程。
### 2. 提供一部分处理器状态,供用户进程使用但不可写。这部分状态包括用户/监控模式位、异常使能/禁用位和内存保护信息。用户被禁止写入此状态,因为如果用户能够赋予自己监控权限、禁用异常或更改内存保护,操作系统将无法控制用户进程。
### 3. 提供机制,使处理器能够从用户模式切换到监控模式,反之亦然。通常,向监控模式的切换通过系统调用实现,该调用作为一种特殊指令,将控制权转移到监控代码空间中的特定位置。程序计数器(PC)会保存系统调用时的点,处理器进入监控模式。返回用户模式的过程类似于子程序返回,恢复之前的用户/监控模式。
### 4. 提供机制限制内存访问,以保护进程的内存状态,而无需在上下文切换时将进程交换到磁盘上。
附录 A 描述了几种内存保护方案,但迄今为止最流行的方案是为每个虚拟内存页面添加保护限制。固定大小的页面,通常为 4 KiB、16 KiB 或更大,通过页表从虚拟地址空间映射到物理地址空间。保护限制包含在每个页表项中。这些保护限制可能决定用户进程是否可以读取该页面、是否可以写入该页面,以及是否可以从该页面执行代码。此外,如果某个页面不在页表中,进程将无法读取或写入该页面。由于只有操作系统可以更新页表,因此分页机制提供了完全的访问保护。
分页虚拟内存意味着每次内存访问逻辑上至少需要两次时间,一次访问用于获取物理地址,第二次访问用于获取数据。这种成本将是过于昂贵的。解决方案是依赖局部性原则;如果访问具有局部性,那么访问的地址转换也必须具有局部性。通过将这些地址转换保存在特殊的缓存中,内存访问很少需要第二次访问来转换地址。这个特殊的地址转换缓存被称为 TLB。
TLB 条目类似于缓存条目,其中标签保存虚拟地址的部分,而数据部分保存物理页面地址、保护字段、有效位、以及通常的使用位和脏位。操作系统通过更改页表中的值并使相应的 TLB 条目无效来更改这些位。当从页表重新加载条目时,TLB 会获得这些位的准确副本。
假设计算机忠实地遵守页面限制并将虚拟地址映射到物理地址,似乎我们已经完成了。然而,新闻头条表明情况并非如此。
我们尚未完成的原因在于我们依赖于操作系统和硬件的准确性。如今的操作系统由数千万行代码组成。由于错误通常按每千行代码的数量来衡量,因此在生产操作系统中存在成千上万的漏洞。操作系统中的缺陷导致了常常被利用的安全漏洞。
这一问题以及未能执行保护可能带来的成本远高于过去的可能性,促使一些人寻求比完整操作系统更小代码库的保护模型,例如虚拟机。
### 通过虚拟机实现保护
与虚拟内存相关的一个几乎同样古老的概念是虚拟机(VMs)。它们在20世纪60年代末首次被开发,并在多年来一直是大型计算机的重要组成部分。尽管在1980年代和1990年代的单用户计算机领域几乎被忽视,但由于以下原因,它们最近重新获得了关注:
- 现代系统中隔离和安全性的重要性日益增加;
- 标准操作系统在安全性和可靠性方面的失败;
- 在数据中心或云计算中,多个无关用户共享单台计算机;
- 处理器原始速度的显著提升,使得虚拟机的开销更加可接受。
虚拟机(VMs)的最广泛定义基本上包括所有提供标准软件接口的仿真方法,例如Java虚拟机。我们关注的是在二进制指令集架构(ISA)级别提供完整系统级环境的虚拟机。通常,虚拟机支持与底层硬件相同的ISA,但也可以支持不同的ISA,这种方法常用于在ISA迁移期间,使来自旧ISA的软件能够在新ISA移植之前继续使用。我们这里关注的是虚拟机和底层硬件的ISA相匹配的情况。这种虚拟机称为(操作)系统虚拟机,如IBM VM/370、VMware ESX Server和Xen等。它们给用户呈现出拥有整台计算机的幻觉,包括操作系统的副本。单台计算机可以运行多个虚拟机,并支持多种不同的操作系统。在传统平台上,单个操作系统“拥有”所有硬件资源,但在虚拟机的情况下,多个操作系统共享硬件资源。
支持虚拟机的软件称为虚拟机监控器(VMM)或管理程序;VMM是虚拟机技术的核心。底层硬件平台称为主机,其资源在客虚拟机之间共享。VMM决定如何将虚拟资源映射到物理资源:物理资源可以是时间共享、分区,甚至在软件中仿真。VMM的体积比传统操作系统小得多;VMM的隔离部分可能只有大约10,000行代码。
一般而言,处理器虚拟化的成本取决于工作负载。用户级的处理器绑定程序(如SPECCPU2006)几乎没有虚拟化开销,因为操作系统很少被调用,因此一切以本地速度运行。相反,I/O密集型工作负载通常也是操作系统密集型的,并执行许多系统调用(I/O操作所需)和特权指令,这可能导致高虚拟化开销。开销由VMM必须仿真的指令数量和仿真的速度决定。因此,当客虚拟机与主机运行相同的ISA时,架构和VMM的目标是几乎所有指令都直接在本地硬件上运行。另一方面,如果I/O密集型工作负载也是I/O绑定,处理器虚拟化的成本可能会因处理器利用率低而被完全掩盖,因为它常常在等待I/O。
尽管我们这里的兴趣在于改善保护的虚拟机,但虚拟机还提供两个其他具有商业意义的好处:
1. 管理软件——虚拟机提供了一种抽象,可以运行完整的软件栈,甚至包括旧操作系统如DOS。典型的部署可能包括一些运行遗留操作系统的虚拟机,许多运行当前稳定版本的操作系统,以及一些测试下一个操作系统版本的虚拟机。
2. 管理硬件——多个服务器的一个原因是每个应用程序在不同计算机上运行其兼容版本的操作系统,这种分离可以提高可靠性。虚拟机允许这些独立的软件栈共享硬件,从而减少服务器数量。另一个例子是,大多数较新的虚拟机监控器支持将正在运行的虚拟机迁移到不同计算机,以平衡负载或从故障硬件中转移。云计算的兴起使得将整个虚拟机切换到另一个物理处理器的能力越来越有用。
这两个原因是云服务器(如亚马逊的)依赖虚拟机的原因。
### 虚拟机监控器的要求
虚拟机监控器必须做什么?它为客户软件提供一个软件接口,必须将客户的状态相互隔离,并保护自己免受客户软件(包括客户操作系统)的影响。定性要求如下:
- 客户软件在虚拟机上应表现得与在本地硬件上运行时完全相同,除了与性能相关的行为或多个虚拟机共享的固定资源的限制。
- 客户软件不得直接更改真实系统资源的分配。
为了“虚拟化”处理器,虚拟机监控器必须控制几乎所有内容——对特权状态、地址转换、输入/输出、异常和中断的访问,即使当前运行的是客户虚拟机和操作系统。例如,在定时器中断的情况下,虚拟机监控器会暂停当前运行的客户虚拟机,保存其状态,处理中断,确定下一个要运行的客户虚拟机,然后加载其状态。依赖定时器中断的客户虚拟机由虚拟机监控器提供虚拟定时器和模拟定时器中断。
为了掌控一切,虚拟机监控器的特权级别必须高于客户虚拟机,而客户虚拟机通常在用户模式下运行;这还确保了任何特权指令的执行将由虚拟机监控器处理。系统虚拟机的基本要求与前面提到的分页虚拟内存几乎相同:
- 至少有两种处理器模式:系统模式和用户模式。
- 仅在系统模式下可用的特权指令子集,如果在用户模式下执行,则会导致陷阱。所有系统资源必须仅通过这些指令进行控制。
### 指令集架构对虚拟机的支持
如果在指令集架构(ISA)的设计中考虑了虚拟机(VM),那么减少虚拟机监控器(VMM)必须执行的指令数量以及仿真这些指令所需的时间就相对容易。能够让虚拟机直接在硬件上执行的架构被称为可虚拟化的,IBM 370架构自豪地拥有这一标签。
然而,由于虚拟机最近才被考虑用于桌面和基于PC的服务器应用,许多指令集是在没有考虑虚拟化的情况下创建的。这些问题的根源包括80x86和大多数早期的RISC架构,尽管后者相比80x86架构问题较少。近期对x86架构的扩展试图弥补早期的不足,而RISC-V则明确包括了对虚拟化的支持。
由于VMM必须确保客户系统仅与虚拟资源交互,因此常规的客户操作系统作为用户模式程序在VMM之上运行。如果客户操作系统试图通过特权指令访问或修改与硬件资源相关的信息,例如读取或写入页表指针,它将会陷阱到VMM。VMM随后可以对相应的真实资源进行适当的更改。
因此,如果任何试图读取或写入此类敏感信息的指令在用户模式下执行时陷阱,VMM可以拦截它,并以客户操作系统所期望的方式支持该敏感信息的虚拟版本。
在缺乏此类支持的情况下,必须采取其他措施。VMM必须特别注意定位所有问题指令,并确保它们在客户操作系统执行时表现正确,从而增加了VMM的复杂性并降低了运行虚拟机的性能。第2.5节和第2.7节提供了80x86架构中问题指令的具体示例。
一个有吸引力的扩展允许虚拟机和操作系统在不同的特权级别上运行,每个级别都与用户级别不同。通过引入一个额外的特权级别,一些操作系统操作——例如,超出用户程序所授予权限但不需要VMM干预的操作(因为它们不会影响其他虚拟机)——可以直接执行,而无需陷阱和调用VMM的开销。我们将在后面讨论的Xen设计利用了三个特权级别。
虚拟机对虚拟内存和I/O的影响
另一个挑战是虚拟内存的虚拟化,因为每个虚拟机中的来宾操作系统管理自己的一组页表。为了解决这个问题,虚拟机监控器(VMM)将真实内存与物理内存区分开,将真实内存作为虚拟内存和物理内存之间的独立中间层。来宾操作系统通过其页表将虚拟内存映射到真实内存,而VMM的页表则将来宾的真实内存映射到物理内存。虚拟内存架构通常通过页表或TLB结构来指定。
为了避免每次内存访问都带来额外的间接层,VMM维护一个影子页表,直接从来宾虚拟地址空间映射到物理地址空间。通过检测对来宾页表的所有修改,VMM确保硬件用于转换的影子页表条目与来宾操作系统环境的条目相对应。因此,VMM必须拦截来宾操作系统对其页表的任何修改尝试。这通常通过写保护来宾页表来实现,任何对页表指针的访问都会被捕获。
IBM 370架构在1970年代通过VMM管理的额外间接层解决了页表问题。来宾操作系统仍然保持其页表,因此影子页是多余的。AMD也为其80x86实现了类似的方案。
在许多RISC计算机中,VMM管理真实的TLB,并保留每个来宾虚拟机的TLB内容副本。访问TLB的任何指令必须被捕获。带有进程ID标签的TLB可以支持来自不同虚拟机和VMM的条目的混合,从而避免在虚拟机切换时刷新TLB。同时,VMM在后台支持虚拟进程ID与真实进程ID之间的映射。
架构中最后要虚拟化的部分是I/O。这是系统虚拟化中最困难的部分,因为连接到计算机的I/O设备数量和种类日益增加。另一个难点是多个虚拟机之间共享真实设备,并且支持各种设备驱动程序也是一大挑战,尤其是在同一虚拟机系统上支持不同的来宾操作系统时。通过给每个虚拟机提供每种I/O设备驱动程序的通用版本,可以维持虚拟机的幻象,而让VMM处理真实的I/O。
虚拟到物理I/O设备的映射方法取决于设备类型。例如,物理磁盘通常由VMM进行分区,以创建来宾虚拟机的虚拟磁盘,VMM维护虚拟轨道和扇区与物理轨道和扇区之间的映射。网络接口通常在虚拟机之间以非常短的时间片共享,VMM的工作是跟踪虚拟网络地址的消息,以确保来宾虚拟机仅接收其专属的消息。
扩展指令集以实现高效虚拟化和更好的安全性
在过去的5到10年里,处理器设计师,包括AMD和Intel(以及在较小程度上ARM),引入了指令集扩展,以更有效地支持虚拟化。主要的性能提升体现在两个方面:处理页表和TLB(虚拟内存的基石)以及I/O,特别是在处理中断和DMA方面。通过避免不必要的TLB刷新,并使用嵌套页表机制(IBM几十年前采用的技术),而不是完全的影子页表集合,增强了虚拟内存的性能(参见附录L中的L.7节)。为了提高I/O性能,添加了架构扩展,允许设备直接使用DMA进行数据移动(消除了VMM可能导致的复制),并允许设备中断和命令由客户操作系统直接处理。这些扩展在内存管理或I/O密集型应用程序中显示出显著的性能提升。
随着公共云系统在关键应用中的广泛采用,关于这些应用中数据安全的担忧日益增加。任何能够访问比必须保密数据更高特权级别的恶意代码都会危及系统。例如,如果你正在运行一个信用卡处理应用,你必须绝对确保恶意用户无法访问信用卡号码,即使他们使用的是相同的硬件并故意攻击操作系统或虚拟机监视器(VMM)。通过使用虚拟化,我们可以防止外部用户访问不同虚拟机中的数据,这相比于多程序环境提供了显著的保护。然而,如果攻击者攻陷了VMM,或者通过观察另一VMM获取信息,这种保护可能仍然不足。例如,假设攻击者侵入了VMM;攻击者可以重新映射内存,从而访问任何数据部分。
另外,攻击可能依赖于引入到代码中的木马(参见附录B),该木马可以访问信用卡。由于木马与信用卡处理应用运行在同一虚拟机中,它只需要利用操作系统的漏洞就能访问关键数据。大多数网络攻击都使用了某种形式的木马,通常是利用操作系统漏洞,使得攻击者能够在保持CPU仍处于特权模式的同时恢复访问,或者允许攻击者上传并执行代码,仿佛它是操作系统的一部分。在这两种情况下,攻击者都获得了CPU的控制权,并利用更高的特权模式访问虚拟机内的任何内容。需要注意的是,仅靠加密并不能阻止这种攻击。如果内存中的数据是未加密的(这很常见),那么攻击者就能访问所有这些数据。此外,如果攻击者知道加密密钥存储的位置,他们可以自由访问密钥,从而访问任何加密数据。
最近,英特尔推出了一组指令集扩展,称为软件保护扩展(SGX),允许用户程序创建安全区,这些代码和数据块始终保持加密状态,只有在使用时才会解密,并且只有使用用户代码提供的密钥才能解密。由于安全区始终保持加密,标准的操作系统操作对于虚拟内存或I/O可以访问安全区(例如,移动一个页面),但无法提取任何信息。为了使安全区正常工作,所有必需的代码和数据必须是安全区的一部分。尽管更细粒度保护的话题已讨论了几十年,但由于其高开销以及其他更高效、干扰性更小的解决方案的可接受性,之前并未获得太多关注。网络攻击的增加和在线机密信息的数量促使人们重新审视提高这种细粒度安全性的技术。像英特尔的SGX一样,IBM和AMD近期的处理器也支持内存的动态加密。
一个示例虚拟机监视器:Xen虚拟机
在虚拟机(VM)早期开发过程中,许多低效问题逐渐显现。例如,来宾操作系统(OS)管理其虚拟到实际页面的映射,但这一映射被虚拟机监视器(VMM)忽略,后者执行实际的物理页面映射。换句话说,为了让来宾操作系统满意,耗费了大量不必要的精力。为了减少这种低效,VMM开发者决定,允许来宾操作系统意识到它正在运行于虚拟机上可能是值得的。例如,来宾操作系统可以假设物理内存与其虚拟内存一样大,从而无需进行内存管理。
允许对来宾操作系统进行小修改以简化虚拟化的做法被称为准虚拟化(paravirtualization),开源的Xen VMM就是一个很好的例子。Xen VMM被用于亚马逊的网络服务数据中心,为来宾操作系统提供了类似于物理硬件的虚拟机抽象,但去掉了许多麻烦的部分。例如,为了避免刷新翻译后备缓冲区(TLB),Xen将自己映射到每个虚拟机地址空间的上64 MiB中。Xen允许来宾操作系统分配页面,只需检查确保来宾操作系统不违反保护限制即可。为了保护来宾操作系统免受虚拟机中用户程序的影响,Xen利用了80x86架构中可用的四个保护级别。Xen VMM在最高特权级别(0)运行,来宾操作系统在下一个级别(1)运行,而应用程序在最低特权级别(3)运行。大多数针对80x86的操作系统将所有内容保持在特权级别0或3。
为了使子集功能正常工作,Xen修改了来宾操作系统,使其不使用架构中有问题的部分。例如,将Linux移植到Xen大约改动了3000行代码,约占80x86特定代码的1%。然而,这些更改并不影响来宾操作系统的应用程序二进制接口。
为了简化虚拟机的I/O挑战,Xen为每个硬件I/O设备分配了特权虚拟机。这些特殊的虚拟机称为驱动域(driver domains)。驱动域运行物理设备驱动程序,尽管中断仍由VMM处理,然后再发送到相应的驱动域。常规虚拟机称为来宾域(guest domains),运行简单的虚拟设备驱动程序,这些驱动程序必须通过通道与驱动域中的物理设备驱动程序进行通信,以访问物理I/O硬件。数据通过页面重映射在来宾域和驱动域之间传送。


2.5 跨越性问题:内存层次结构的设计  

本节描述了在其他章节中讨论的四个与内存层次结构密切相关的主题。
保护、虚拟化与指令集架构  
保护是架构与操作系统的共同努力,但随着虚拟内存的普及,架构师不得不修改一些现有指令集架构中的不便细节。例如,为了支持IBM 370中的虚拟内存,架构师必须对仅在6年前发布的成功的IBM 360指令集架构进行更改。今天,为了适应虚拟机,也正在进行类似的调整。
例如,80x86指令POPF从内存堆栈顶部加载标志寄存器。其中一个标志是中断使能(IE)标志。在最近为支持虚拟化而进行的更改之前,在用户模式下运行POPF指令,而不是将其捕获,简单地会改变所有标志,除了IE。在系统模式下,它确实会改变IE标志。由于来宾操作系统在虚拟机内的用户模式下运行,这成了一个问题,因为操作系统会期望看到IE的变化。对80x86架构的扩展以支持虚拟化解决了这个问题。
历史上,IBM大型机硬件和虚拟机监视器(VMM)采取了三个步骤来提高虚拟机的性能:
1. 降低处理器虚拟化的成本。
2. 减少由于虚拟化带来的中断开销。
3. 通过将中断导向适当的虚拟机而不调用VMM来降低中断成本。
IBM仍然是虚拟机技术的黄金标准。例如,2000年,IBM大型机同时运行了数千个Linux虚拟机,而Xen在2004年只能运行25个虚拟机(Clark等,2004)。最近版本的英特尔和AMD芯片组增加了特定指令,以支持在虚拟机中掩盖来自每个虚拟机的较低级别的中断,并将中断导向适当的虚拟机。
### 自主指令获取单元
许多具有乱序执行的处理器,甚至一些仅有深流水线的处理器,都将指令获取(有时还包括初始解码)解耦,使用单独的指令获取单元(见第3章)。通常,指令获取单元会访问指令缓存,以获取整个块,然后将其解码为单个指令;这种技术在指令长度变化时尤为有用。由于指令缓存是按块访问的,因此将未命中率与逐条访问指令缓存的处理器进行比较就变得没有意义。此外,指令获取单元还可以预取块到L1缓存;这些预取可能会生成额外的未命中,但实际上可能会减少总的未命中惩罚。许多处理器还包括数据预取,这可能会增加数据缓存的未命中率,同时降低总的数据缓存未命中惩罚。
### 推测与内存访问
高级流水线中使用的主要技术之一是推测,即在处理器确定指令是否真正需要之前,暂时执行该指令。这种技术依赖于分支预测,如果预测错误,则需要将推测的指令从流水线中清除。在支持推测的内存系统中,有两个独立的问题:保护和性能。
通过推测,处理器可能会生成内存引用,这些引用将永远不会被使用,因为这些指令是由于错误推测而产生的。如果执行这些引用,可能会产生保护异常。显然,这种错误只应在指令实际上被执行时发生。在下一章中,我们将看到如何解决这种“推测异常”。由于推测处理器可能会对指令和数据缓存进行访问,并随后不使用这些访问的结果,因此推测可能会增加缓存未命中率。然而,与预取一样,这种推测实际上可能会降低总的缓存未命中惩罚。推测的使用,如同预取的使用,使得将未命中率与没有推测的处理器进行比较变得具有误导性,即便它们的指令集架构(ISA)和缓存结构在其他方面是相同的。
### 特殊指令缓存
超标量处理器面临的最大挑战之一是提供足够的指令带宽。对于将指令转换为微操作的设计,例如最近的Arm和i7处理器,可以通过保持一个小型的最近翻译指令缓存来减少指令带宽需求和分支误预测惩罚。我们将在下一章中更深入地探讨这一技术。
### 缓存数据的一致性
数据可以存储在内存和缓存中。只要处理器是唯一更改或读取数据的组件,并且缓存位于处理器与内存之间,处理器看到旧的或过期的副本的风险就很小。然而,正如我们将看到的,多个处理器和I/O设备的存在增加了副本不一致的可能性,并可能导致读取错误的副本。
在多处理器系统中,缓存一致性问题的频率与I/O不同。对于I/O来说,多个数据副本是一个罕见事件——应尽量避免——但在多个处理器上运行的程序通常希望在多个缓存中保留相同数据的副本。多处理器程序的性能依赖于系统共享数据时的表现。
I/O缓存一致性的问题在于:I/O发生在计算机的哪个位置——在I/O设备与缓存之间,还是在I/O设备与主内存之间?如果输入将数据放入缓存,而输出从缓存读取数据,则I/O和处理器都看到相同的数据。这种方法的困难在于,它会干扰处理器,并可能导致处理器因I/O而停滞。输入也可能通过用不太可能很快被访问的新数据替换一些信息而干扰缓存。
在具有缓存的计算机中,I/O系统的目标是防止过期数据问题,同时尽量减少干扰。因此,许多系统倾向于直接对主内存进行I/O,使主内存充当I/O缓冲区。如果使用写透缓存,那么内存将拥有最新的信息副本,从而避免输出时出现过期数据的问题。(这一好处也是处理器倾向使用写透的原因。)然而,如今写透通常只出现在由使用写回的L2缓存支持的一级数据缓存中。
输入需要额外的工作。软件解决方案是确保输入缓冲区的任何块不在缓存中。包含缓冲区的页面可以标记为不可缓存,操作系统可以始终向该页面输入数据。或者,操作系统可以在输入发生之前从缓存中刷新缓冲区地址。硬件解决方案是在输入时检查I/O地址是否在缓存中。如果在缓存中发现匹配的I/O地址,则使缓存条目失效以避免过期数据。所有这些方法也可以用于使用写回缓存的输出。
在多核处理器时代,处理器缓存一致性是一个关键主题,我们将在第五章中详细探讨。


2.6 整合内容:ARM Cortex-A53 和 Intel Core i7 6700 的内存层次结构

本节揭示了 ARM Cortex-A53(以下简称 A53)和 Intel Core i7 6700(以下简称 i7)的内存层次结构,并展示了它们各自组件在一组单线程基准测试中的性能。我们首先研究 Cortex-A53,因为它具有更简单的内存系统;然后更详细地探讨 i7,详细追踪一次内存引用。本节假设读者熟悉使用虚拟索引缓存的两级缓存层次结构的组织。关于这种内存系统的基本知识将在附录 B 中详细解释,建议对该系统组织不确定的读者仔细阅读附录 B 中的 Opteron 示例。一旦他们理解了 Opteron 的组织,A53 系统的简要说明(与其相似)将容易理解。
### ARM Cortex-A53
Cortex-A53 是一个可配置的核心,支持 ARMv8A 指令集架构,包括 32 位和 64 位模式。Cortex-A53 作为 IP(知识产权)核心提供。IP 核心是嵌入式、PMD 和相关市场中技术交付的主流形式;数十亿个 ARM 和 MIPS 处理器都是由这些 IP 核心构建而成。需要注意的是,IP 核心与 Intel i7 或 AMD Athlon 多核处理器中的核心不同。IP 核心(它本身也可以是多核)设计为与其他逻辑结合使用(因此它是芯片的核心),包括应用特定处理器(如视频编码器或解码器)、I/O 接口和内存接口,然后制造出针对特定应用优化的处理器。例如,Cortex-A53 IP 核心被广泛应用于各种平板电脑和智能手机;它被设计为高能效,这是电池驱动 PMD 的关键标准。A53 核心能够根据需求配置多个核心以用于高端 PMD;我们在这里的讨论集中在单个核心上。
一般来说,IP 核心有两种类型。硬核(Hard cores)针对特定半导体厂商进行了优化,通常是黑箱,具有外部(但仍在芯片内)接口。硬核通常只允许对核心外部的逻辑进行参数化,比如 L2 缓存大小,而 IP 核心本身不能被修改。软核(Soft cores)通常以使用标准逻辑元件库的形式提供。软核可以为不同的半导体厂商编译,并且也可以进行修改,尽管由于现代 IP 核心的复杂性,进行大规模修改非常困难。一般而言,硬核提供更高的性能和更小的芯片面积,而软核则允许重新定位到其他厂商并且更容易修改。
Cortex-A53 可以在高达 1.3 GHz 的时钟频率下每个时钟周期发出两条指令。它支持两级 TLB 和两级缓存;图 2.19 总结了内存层次结构的组织。关键术语首先被返回,处理器可以在缺失完成时继续执行;支持最多四个银行的内存系统。对于 32 KiB 的 D-cache 和 4 KiB 的页面大小,每个物理页面可以映射到两个不同的缓存地址;通过硬件检测在缺失情况下避免这种别名,如附录 B 的 B.3 节所述。图 2.20 显示了如何使用 32 位虚拟地址来索引 TLB 和缓存,假设有 32 KiB 的主缓存和 1 MiB 的二级缓存,页面大小为 16 KiB。

图 2.19 Cortex A53 的内存层次结构包括多级 TLB 和缓存。页面映射缓存跟踪一组虚拟页面的物理页面位置;它减少了 L2 TLB 缺失惩罚。L1 缓存是虚拟索引和物理标记的;L1 数据缓存和 L2 都采用写回策略,默认在写入时进行分配。所有缓存中的替换策略都是 LRU 近似。如果同时发生 MicroTLB 和 L1 缺失,L2 的缺失惩罚会更高。L2 到主内存的总线宽度为 64–128 位,窄总线的缺失惩罚更大。

图 2.20 假设使用 32 位地址,展示了 ARM Cortex-A53 的虚拟地址、物理地址和数据块。上半部分 (A) 显示指令访问;下半部分 (B) 显示数据访问,包括 L2。TLB(指令或数据)是完全关联的,每个都有 10 个条目,在这个例子中使用 64 KiB 的页面。L1 指令缓存是二路组相联,具有 64 字节块和 32 KiB 的容量;L1 数据缓存为 32 KiB,四路组相联,块大小为 64 字节。L2 TLB 有 512 个条目,并且是四路组相联。L2 缓存是十六路组相联,块大小为 64 字节,容量从 128 KiB 到 2 MiB;这里展示的是 1 MiB 的 L2。这张图没有显示缓存和 TLB 的有效位和保护位。
#### Cortex-A53 内存层次的性能
Cortex-A8 的内存层次在运行 SPECInt2006 基准测试时,使用了 32 KiB 的主缓存和 1 MiB 的 L2 缓存。对于这些 SPECInt2006 测试,指令缓存的缺失率非常小,即使仅考虑 L1:大多数情况下接近零,所有情况下均低于 1%。这一低缺失率可能是由于 SPECCPU 程序的计算密集型特性以及二路组相联缓存有效消除了大部分冲突缺失。
图 2.21 显示了数据缓存的结果,其 L1 和 L2 的缺失率显著。L1 的缺失率变化范围大约为 75 倍,从 0.5% 到 37.3%,中位缺失率为 2.4%。全局 L2 的缺失率变化范围为 180 倍,从 0.05% 到 9.0%,中位数为 0.3%。MCF 被称为缓存破坏者,设置了上限并显著影响了平均值。需要注意的是,L2 全局缺失率明显低于 L2 局部缺失率;例如,中位 L2 单独缺失率为 15.1%,而全局缺失率为 0.3%。

图 2.21 显示了使用 SPECInt2006 基准测试时,ARM 的 32 KiB L1 缓存的数据缺失率和 1 MiB L2 缓存的全局数据缺失率受应用程序的显著影响。具有较大内存占用的应用程序在 L1 和 L2 中往往会有更高的缺失率。请注意,L2 缺失率是全局缺失率,计算了所有引用,包括那些在 L1 中命中的引用。MCF 被称为缓存破坏者。
使用图 2.19 中的缺失惩罚,图 2.22 显示了每次数据访问的平均惩罚。尽管 L1 的缺失率约为 L2 的七倍,但 L2 的惩罚却高出 9.5 倍,这导致 L2 的缺失在压力测试内存系统的基准测试中略占优势。在下一章中,我们将探讨缓存缺失对整体 CPI 的影响。

图 2.22 显示了 A53 处理器在运行 SPECInt2006 时,每次数据存储引用的平均内存访问惩罚,包括来自 L1 和 L2 的惩罚。尽管 L1 的缺失率明显更高,但 L2 的缺失惩罚却高出五倍以上,这意味着 L2 的缺失会显著影响性能。
**Intel Core i7 6700**
i7 支持 x86-64 指令集架构,这是 80x86 架构的 64 位扩展。i7 是一款乱序执行处理器,包含四个核心。本章重点从单核的角度讨论内存系统设计和性能。多处理器设计的系统性能,包括 i7 多核处理器,会在第五章中详细探讨。
i7 的每个核心每个时钟周期可以执行最多四条 80x86 指令,使用一种多发射、动态调度的 16 阶段流水线,我们将在第三章中详细描述。i7 还支持每个处理器最多两个同时线程,采用称为同时多线程技术,详见第四章。2017 年,最快的 i7 的时钟频率为 4.0 GHz(在 Turbo Boost 模式下),这使得其峰值指令执行率达到每秒 160 亿条指令,四核设计则为每秒 640 亿条指令。当然,峰值性能和持续性能之间存在较大差距,接下来的几章将对此进行分析。
i7 可以支持最多三个内存通道,每个通道由一组独立的 DIMM 组成,并且可以并行传输。使用 DDR3-1066(DIMM PC8500),i7 的峰值内存带宽超过 25 GB/s。i7 使用 48 位虚拟地址和 36 位物理地址,最大物理内存为 36 GiB。内存管理采用两级 TLB(见附录 B,第 B.4 节),如图 2.23 所示。

**图 2.23** i7 的 TLB 结构特征,其中包含独立的一级指令和数据 TLB,二者均由联合的二级 TLB 支持。一级 TLB 支持标准的 4 KiB 页大小,同时也有限地支持 2–4 MiB 大页;在二级 TLB 中仅支持 4 KiB 页。i7 能够并行处理两个 L2 TLB 未命中。有关多级 TLB 和对多种页大小支持的更多讨论,请参见在线附录 L 的第 L.3 节。
图 2.24 总结了 i7 的三级缓存层次结构。一级缓存是虚拟索引和物理标记的(见附录 B,第 B.3 节),而 L2 和 L3 缓存是物理索引的。某些版本的 i7 6700 将支持使用 HBM 封装的四级缓存。

**图 2.24** i7 中三级缓存层次结构的特征。所有三个缓存均使用写回策略和 64 字节的块大小。L1 和 L2 缓存对于每个核心是独立的,而 L3 缓存则在芯片上的各个核心之间共享,且每个核心的总容量为 2 MiB。所有三个缓存都是非阻塞的,允许多个未完成的写操作。L1 缓存使用合并写缓冲区,以便在写入时如果该行不在 L1 中,则保存数据。(也就是说,L1 写未命中不会导致该行被分配。)L3 是 L1 和 L2 的包含;我们在解释多处理器缓存时将进一步探讨这一特性。替换策略采用一种伪 LRU 的变体;在 L3 的情况下,所替换的块始终是访问位为关闭状态的最低编号方式。这并不是完全随机,但计算起来相对简单。
图 2.25 标明了访问内存层次结构的步骤。首先,程序计数器(PC)被发送到指令缓存。指令缓存的索引是


或者 6 位。指令地址的页面框架(36¼48% 12 位)被发送到指令 TLB(步骤 1)。同时,来自虚拟地址的 12 位页面偏移被发送到指令缓存(步骤 2)。注意,对于八路组相联的指令缓存,需要 12 位作为缓存地址:6 位用于索引缓存,加上 6 位用于 64 字节块的块偏移,因此不会出现别名。之前版本的 i7 使用四路组相联的 I-cache,这意味着与虚拟地址对应的块实际上可以在缓存中的两个不同位置,因为相应的物理地址在该位置可以是 0 或 1。对于指令而言,这并不构成问题,因为即使一条指令在缓存中出现在两个不同的位置,这两个版本也必须是相同的。如果允许数据的这种重复或别名,页面映射更改时必须检查缓存,而这种情况并不频繁。请注意,非常简单的页面着色方法(见附录 B,第 B.3 节)可以消除这些别名的可能性。如果偶数地址的虚拟页面映射到偶数地址的物理页面(奇数页面也是如此),那么这些别名就不会发生,因为虚拟和物理页面号中的低位将是相同的。
然后访问指令 TLB,以找到地址与有效页面表项(PTE)之间的匹配(步骤 3 和 4)。除了转换地址,TLB 还会检查 PTE 是否要求此访问因访问违规而导致异常。
指令 TLB 缺失首先会转到 L2 TLB,它包含 1536 个 4 KiB 页面大小的页面表项(PTE),并且是 12 路组相联的。从 L2 TLB 加载数据到 L1 TLB 需要 8 个时钟周期,这导致包括访问 L1 TLB 的初始时钟周期在内的 9 个周期缺失惩罚。如果 L2 TLB 也缺失,则使用硬件算法遍历页面表并更新 TLB 条目。在线附录 L 的 L.5 和 L.6 节描述了页面表遍历器和页面结构缓存。在最坏的情况下,页面不在内存中,操作系统将从二级存储中获取该页面。由于在页面错误期间可能会执行数百万条指令,如果有其他进程等待运行,操作系统将会交换进程。否则,如果没有 TLB 异常,指令缓存访问将继续进行。
地址的索引字段被发送到指令缓存的所有八个银行(步骤 5)。指令缓存标签为 36 位 = 6 位(索引)+ 6 位(块偏移),即 24 位。四个标签和有效位与来自指令 TLB 的物理页框进行比较(步骤 6)。由于 i7 每次取指期望 16 字节,因此从 6 位块偏移中额外使用 2 位来选择适当的 16 字节。因此,6 + 2 或 8 位用于将 16 字节的指令发送给处理器。L1 缓存是管道化的,命中延迟为 4 个时钟周期(步骤 7)。如果未命中,则转向二级缓存。
如前所述,指令缓存是虚拟寻址的,而物理标记的。由于二级缓存是物理寻址的,因此来自 TLB 的物理页面地址与页面偏移组合以生成访问 L2 缓存的地址。L2 索引是

因此,30 位块地址(36 位物理地址 % 6 位块偏移)被分为 20 位标签和 10 位索引(步骤 8)。再次,索引和标签被发送到统一的 L2 缓存的四个银行(步骤 9),并行比较。如果其中一个匹配且有效(步骤 10),它将在初始的 12 个周期延迟后以每个时钟周期 8 字节的速率按顺序返回块。
如果 L2 缓存未命中,则访问 L3 缓存。对于具有 8 MiB L3 的四核 i7,索引大小是

将13位索引(步骤11)发送到所有16个L3缓存银行(步骤12)。L3标签(36% (13 + 6) ≈ 17位)与来自TLB的物理地址进行比较(步骤13)。如果命中,数据块将在初始延迟42个时钟周期后返回,以每个时钟16字节的速率放入L1和L3。如果L3未命中,则会发起内存访问。
如果在L3缓存中未找到指令,片上内存控制器必须从主内存中获取数据块。i7具有三个64位内存通道,可以作为一个192位通道使用,因为只有一个内存控制器,并且相同的地址会通过两个通道发送(步骤14)。当两个通道具有相同的DIMM时,会发生宽幅传输。每个通道最多支持四个DDR DIMM(步骤15)。当数据返回时,它们会被放入L3和L1中(步骤16),因为L3是包含的。
由主内存服务的指令未命中的总延迟大约为42个处理器周期,以确定发生了L3未命中,加上关键指令的DRAM延迟。对于单银行的DDR4-2400 SDRAM和4.0 GHz的CPU,DRAM延迟约为40纳秒或160个时钟周期到前16字节,总未命中惩罚约为200个时钟周期。内存控制器以每个I/O总线时钟周期16字节的速率填充剩余的64字节缓存块,这需要额外5纳秒或20个时钟周期。
由于二级缓存是写回缓存,任何未命中都可能导致旧数据块被写回内存。i7具有10条项的合并写缓冲区,当下一级缓存未被读取时写回脏缓存行。在未命中时会检查写缓冲区以查看缓存行是否存在于缓冲区中;如果存在,未命中将从缓冲区填充。L1和L2缓存之间也使用类似的缓冲区。如果这个初始指令是加载,数据地址会被发送到数据缓存和数据TLB,就像访问指令缓存一样。
假设指令是存储而不是加载。当发出存储时,它会像加载一样进行数据缓存查找。未命中时,数据块会被放入写缓冲区,因为L1缓存在写未命中时不会分配该块。在命中时,存储不会立即更新L1(或L2)缓存,直到知道其不是推测性的。在此期间,存储保持在负载-存储队列中,这是处理器乱序控制机制的一部分。
i7还支持从层次结构的下一级对L1和L2进行预取。在大多数情况下,预取的行只是缓存中的下一个块。通过仅对L1和L2进行预取,可以避免高成本的不必要的内存抓取。
### i7内存系统性能
我们使用SPECint2006基准测试评估i7缓存结构的性能。本节的数据由路易斯安那州立大学的彭卢教授和博士生刘群收集。他们的分析基于之前的工作(见Prakash和Peng,2008年)。
i7管道的复杂性,包括自主指令抓取单元、推测执行,以及指令和数据预取,使得与更简单的处理器比较缓存性能变得困难。如第110页所提到的,使用预取的处理器可以生成与程序执行的内存访问无关的缓存访问。由于实际的指令访问或数据访问而生成的缓存访问有时被称为需求访问,以区别于预取访问。需求访问可以来自推测性的指令抓取和推测性的数据访问,其中一些随后会被取消(有关推测和指令毕业的详细描述,请参见第3章)。推测处理器生成的未命中数量至少与顺序非推测处理器相同,通常还会更多。除了需求未命中外,还有指令和数据的预取未命中。
i7的指令抓取单元试图每个周期获取16字节,这使得比较指令缓存未命中率变得复杂,因为每个周期都会抓取多个指令(平均约4.5条)。实际上,整个64字节的缓存行会被读取,而后续的16字节抓取不需要额外的访问。因此,仅根据64字节块跟踪未命中情况。32 KiB的八路组相联指令缓存使得SPECint2006程序的指令未命中率非常低。如果为了简化,我们将SPECint2006的未命中率定义为64字节块的未命中次数除以完成的指令数,则所有基准的未命中率都低于1%,只有一个基准(XALANCBMK)具有2.9%的未命中率。由于一个64字节块通常包含16到20条指令,因此每条指令的有效未命中率要低得多,这取决于指令流中的空间局部性程度。
指令获取单元因等待I-cache未命中而停滞的频率同样很小(占总周期的百分比),对于两个基准测试增加到2%,而XALANCBMK的I-cache未命中率最高,达到了12%。在下一章中,我们将看到IFU中的停滞如何影响i7的整体管道吞吐量。
L1数据缓存则更有趣,也更难以评估,因为除了预取和推测的影响外,L1数据缓存并不是写分配的,对不存在的缓存块的写入不会视为未命中。因此,我们只关注内存读取。i7的性能监控测量将预取访问与需求访问区分开,但仅保留那些成功完成的指令的需求访问。未成功完成的推测指令的影响不可忽视,尽管管道效应可能主导由推测引起的二级缓存效应;我们将在下一章回到这个问题。

图2.26展示了SPECint2006基准测试中L1数据缓存的未命中率,有两种表示方式:一种是包括需求读取和预取访问,另一种仅包含需求访问。i7将不在缓存中的块的L1未命中与已经在处理并从L2进行预取的块的L1未命中分开;我们将后者视为命中,因为它们在阻塞缓存中会命中。这些数据和本节其余部分的数据均由路易斯安那州立大学的彭卢教授和博士生刘群收集,基于对Intel Core Duo及其他处理器的早期研究(见Peng et al., 2008)。
为了解决这些问题,同时保持数据量的合理性,图2.26以两种方式展示了L1数据缓存的未命中情况:
1. 包括预取和推测加载的L1未命中率相对于需求引用,计算公式为:L1未命中率(包括预取和推测加载)/ L1需求读取引用,针对那些成功完成的指令。
2. 仅考虑需求未命中的未命中率,计算公式为:L1需求未命中/L1需求读取引用,同样只针对成功完成的指令。
平均而言,包括预取的未命中率是仅考虑需求的未命中率的2.8倍。将这些数据与早期的i7 920进行比较,后者具有相同大小的L1缓存,我们发现新款i7的包括预取的未命中率更高,但导致停滞的需求未命中数通常较少。
为了理解i7中激进的预取机制的有效性,让我们看看一些关于预取的测量数据。图2.27展示了L2请求中预取与需求请求的比例以及预取未命中率。乍一看,这些数据可能令人震惊:预取请求大约是L2需求请求的1.5倍,而这些需求请求直接来自L1未命中。此外,预取未命中率非常高,平均未命中率为58%。尽管预取比例差异很大,但预取未命中率始终显著。乍一看,你可能会得出设计者犯了错误的结论:他们预取太多,未命中率过高。然而,注意到高预取比例的基准测试(如ASTAR、BZIP2、HMMER、LIBQUANTUM和OMNETPP)也显示出预取未命中率和需求未命中率之间的差距最大,在每种情况下都超过2倍。激进的预取机制是在用提前发生的预取未命中换取稍后发生的需求未命中;因此,由于预取,管道停顿的可能性降低。

图2.27显示了L2请求中预取请求的比例,柱状图和左侧轴表示这一比例。右侧轴和折线则展示了预取命中率。这些数据与本节其余部分的数据一样,是由路易斯安那州立大学的卢鹏教授和博士生刘群基于对英特尔Core Duo及其他处理器的早期研究收集的(参见Peng et al., 2008)。
同样,考虑到高预取未命中率。假设大多数预取实际上是有用的(这很难测量,因为需要跟踪单个缓存块),那么预取未命中意味着未来可能会出现L2缓存未命中。通过预取提前发现并处理未命中,很可能会减少停顿周期。对像i7这样的推测超标量处理器进行的性能分析表明,缓存未命中往往是管道停顿的主要原因,因为保持处理器运行尤其困难,特别是在L2和L3未命中持续较长时间时。英特尔设计师无法轻易增加缓存的大小而不影响能耗和周期时间;因此,使用激进的预取来尝试降低有效缓存未命中的惩罚,是一种有趣的替代方法。
结合L1需求未命中和预取请求,大约17%的加载操作生成L2请求。分析L2性能需要考虑写入的影响(因为L2是写分配的),以及预取命中率和需求命中率。图2.28展示了L2缓存对于需求和预取访问的未命中率,分别与L1引用次数(读取和写入)进行比较。与L1一样,预取是一个重要的贡献者,产生了75%的L2未命中。将L2需求未命中率与早期的i7实现(同样大小的L2)进行比较显示,i7 6700的L2需求未命中率大约低了2倍,这很可能证明了更高的预取未命中率是合理的。

图2.28显示了L2需求未命中率和预取未命中率,这两者都是相对于所有对L1的访问而言,其中也包括预取、未完成的推测加载以及程序生成的加载和存储(需求引用)。这些数据与本节其余部分的数据一样,是由路易斯安那州立大学的卢鹏教授和博士生刘群收集的。
由于内存未命中的代价超过100个周期,而L2的平均数据未命中率(结合预取和需求未命中)超过7%,因此L3显得尤为关键。如果没有L3,并假设大约三分之一的指令是加载或存储,那么L2缓存未命中可能会使每条指令的CPI增加超过两个周期!显然,如果没有L3,L2的预取就毫无意义。
相比之下,平均L3数据未命中率为0.5%,虽然仍然显著,但不到L2需求未命中率的三分之一,并且比L1需求未命中率低10倍。只有在两个基准测试(OMNETPP和MCF)中,L3未命中率超过0.5%;在这两种情况下,约2.3%的未命中率可能主导了所有其他性能损失。在下一章中,我们将研究i7的CPI与缓存未命中之间的关系,以及其他管道效应。


2.7 Fallacies and Pitfalls

作为计算机体系结构学科中最具数量化特征的领域,内存层次结构似乎不太容易受到谬论和陷阱的影响。然而,我们在这里受限的并不是缺乏警告,而是缺乏空间!
**谬论:从一个程序预测另一个程序的缓存性能。** 图2.29展示了来自SPEC2000基准套件的三个程序在缓存大小变化时的指令未命中率和数据未命中率。根据程序的不同,4096 KiB缓存下每千条指令的数据未命中率分别为9、2或90,而4 KiB缓存下每千条指令的指令未命中率则为55、19或0.0004。商业程序如数据库即使在大型二级缓存中也会有显著的未命中率,这通常不是SPECCPU程序的情况。显然,从一个程序推广缓存性能到另一个程序是不可取的。正如图2.24所提醒我们的那样,存在很大的变化,甚至关于整数和浮点密集型程序相对未命中率的预测也可能是错误的,正如mcf和sphinx3所提醒我们的那样!

图2.29 显示了缓存大小从4 KiB变化到4096 KiB时,每千条指令的指令未命中和数据未命中率。gcc的指令未命中率是lucas的30,000到40,000倍,而反过来,lucas的数据未命中率是gcc的2到60倍。程序gap、gcc和lucas均来自SPEC2000基准套件。
**陷阱:模拟足够的指令以获取内存层次结构的准确性能测量。**
这里实际上有三个陷阱。第一个是试图使用小的跟踪数据来预测大型缓存的性能。第二个是程序的局部性行为在整个运行过程中并不是恒定的。第三个是程序的局部性行为可能会根据输入的不同而有所变化。
图2.30显示了对单个SPEC2000程序五个输入的每千条指令的累积平均指令未命中率。对于这些输入,前19亿条指令的平均内存未命中率与其余执行过程中的平均未命中率非常不同。

图2.30 显示了对SPEC2000中perl基准的五个输入,每千次引用的指令未命中率。在前19亿条指令中,未命中率变化很小,五个输入之间几乎没有差别。完整运行显示了未命中率在程序生命周期中的变化以及它们如何依赖于输入。顶部图表显示了前19亿条指令的运行平均未命中率,起始约为2.5,结束时所有五个输入的未命中率约为4.7每千次引用。底部图表显示了完整运行的运行平均未命中率,这个过程根据输入需要16到41亿条指令。在前19亿条指令之后,每千次引用的未命中率根据输入变化在2.4到7.9之间。模拟是针对Alpha处理器进行的,使用了分别用于指令和数据的独立L1缓存,每个缓存为双路64 KiB,采用LRU替换策略,并配备统一的1 MiB直接映射L2缓存。
**陷阱**:在基于缓存的系统中未能提供高内存带宽。  
缓存有助于减少平均缓存内存延迟,但可能无法为必须访问主内存的应用程序提供高内存带宽。架构师必须设计一个高带宽内存,以便在缓存后面支持此类应用程序。我们将在第4章和第5章重温这个陷阱。
**陷阱**:在未设计为虚拟化的指令集架构上实现虚拟机监控器。  
许多架构师在1970年代和1980年代并没有仔细确保所有读取或写入与硬件资源信息相关的信息的指令都是特权指令。这种放任自流的态度给所有这些架构的虚拟机监控器(VMM)带来了问题,包括我们在这里作为示例使用的80x86架构。图2.31描述了导致半虚拟化问题的18条指令(Robin和Irvine,2000)。这两大类指令是:
- 在用户模式下读取控制寄存器,显示客操作系统正在虚拟机中运行(例如,前面提到的POPF)。
- 根据分段架构的要求检查保护,但假设操作系统以最高特权级别运行。
虚拟内存也是一个挑战。由于80x86 TLB不支持进程ID标签,而大多数RISC架构支持,因此VMM和客操作系统共享TLB的成本更高;每次地址空间更改通常需要刷新TLB。

**图2.31**:导致虚拟化问题的18条80x86指令总结(Robin和Irvine,2000)。  
顶部组的前五条指令允许用户模式下的程序读取控制寄存器,例如描述符表寄存器,而不会引发异常。POP FLAGS指令修改了包含敏感信息的控制寄存器,但在用户模式下默默失败。80x86分段架构的保护检查是底部组的致命缺陷,因为这些指令在读取控制寄存器时会隐式检查特权级别。该检查假设操作系统必须处于最高特权级别,但这对于客户虚拟机并不成立。只有MOVE到段寄存器尝试修改控制状态,但保护检查也阻止了这一操作。
虚拟化I/O对于80x86架构也是一个挑战,部分原因在于它支持内存映射I/O并且有独立的I/O指令,但更重要的是,PC设备和设备驱动程序的种类和数量非常庞大,导致虚拟机监控器(VMM)需要处理这些复杂性。第三方供应商提供自己的驱动程序,而这些驱动程序可能未能正确虚拟化。传统的虚拟机实现的一种解决方案是将真实设备驱动程序直接加载到VMM中。
为了简化在80x86上的虚拟机监控器实现,AMD和Intel都提出了对架构的扩展。Intel的VT-x提供了一种新的执行模式来运行虚拟机,定义了虚拟机状态的架构化定义,提供了快速切换虚拟机的指令,以及一套参数来选择何时必须调用VMM。总的来说,VT-x为80x86添加了11条新指令。AMD的安全虚拟机(SVM)提供了类似的功能。
在启用VT-x支持的模式后(通过新的VMXON指令),VT-x为客户操作系统提供了四个优先级低于原始四个的特权级别(并修复了之前提到的POPF指令问题)。VT-x捕获虚拟机的所有状态到虚拟机控制状态(VMCS)中,并提供原子指令来保存和恢复VMCS。除了关键状态外,VMCS还包含配置信息,用以确定何时调用VMM,以及具体是什么原因导致VMM被调用。为了减少VMM调用的次数,这种模式增加了某些敏感寄存器的影像版本,并添加了掩码,以检查在触发之前敏感寄存器的关键位是否会被改变。为了降低虚拟化虚拟内存的成本,AMD的SVM增加了一个额外的间接级别,称为嵌套页表,这使得影像页表变得不必要(请参见附录L的第L.7节)。


2.8 Concluding Remarks: Looking Ahead

在过去三十年里,有许多关于计算机性能提升即将停滞的预测。每一个这样的预测都是错误的。这些错误的原因在于,它们依赖于一些未明确说明的假设,而这些假设在随后的事件中被推翻。例如,未能预见从离散组件到集成电路的转变,导致了一个预测,即光速将限制计算机速度,使其慢几个数量级于现在的水平。我们对内存墙的预测可能也是错误的,但它表明我们需要开始“跳出框架”思考。
——Wm. A. Wulf 和 Sally A. McKee,《击中内存墙:显而易见的影响》,维吉尼亚大学计算机科学系(1994年12月)。本文首次引入了“内存墙”这一术语。
使用内存层次结构的可能性可以追溯到20世纪40年代末和50年代初通用数字计算机的早期阶段。虚拟内存在60年代初期被引入研究计算机中,并在70年代进入IBM大型机。缓存大约在同一时期出现。这些基本概念随着时间的推移得到了扩展和增强,以帮助缩小主内存与处理器之间的访问时间差距,但基本概念依然保持不变。
一个导致内存层次结构设计发生重大变化的趋势是DRAM的密度和访问时间持续放缓。在过去的15年中,这两种趋势都得到了观察,并在过去5年中变得更加明显。尽管DRAM带宽有所增加,但访问时间的减少则进展缓慢,几乎在DDR4和DDR3之间消失。Dennard缩放的结束以及摩尔定律的放缓都促成了这种情况。DRAM中使用的埋入电容设计也限制了其扩展能力。可能的情况是,诸如堆叠内存等封装技术将成为改善DRAM访问带宽和延迟的主要来源。
独立于DRAM的改进,Flash内存发挥了更大的作用。在PMD(便携式媒体设备)中,Flash已经主导了15年,并且在近10年前成为笔记本电脑的标准配置。在过去几年中,许多台式机也以Flash作为主要的二级存储。Flash相较于DRAM的潜在优势在于没有每比特控制写入的晶体管,但这也是其致命弱点。Flash必须使用批量擦除-重写周期,这样的速度明显较慢。因此,尽管Flash已成为增长最快的二级存储形式,SDRAM仍然在主存储中占据主导地位。
虽然作为内存基础的相变材料存在已久,但它们从未成为磁盘或Flash的真正竞争对手。英特尔和美光最近宣布的交叉点技术可能会改变这一局面。这项技术似乎在多个方面优于Flash,包括消除了缓慢的擦除-写入周期以及更长的使用寿命。这项技术有可能最终取代主导大容量存储超过50年的电机械磁盘!
多年来,人们对即将到来的内存墙做出过各种预测(参见之前引用的论文),这将严重限制处理器性能。幸运的是,多级缓存的扩展(从2级到4级)、更复杂的填充和预取方案、编译器和程序员对局部性重要性的更高意识,以及DRAM带宽的巨大改善(自1990年代中期以来提高了超过150倍)帮助我们抵御了内存墙的威胁。近年来,L1缓存的访问时间限制(受时钟周期限制)和L2、L3缓存的能量相关限制带来了新的挑战。i7处理器系列在6到7年间的发展便体现了这一点:i7 6700中的缓存大小与第一代i7处理器相同!更积极地使用预取是一种试图克服无法增加L2和L3缓存大小的解决方案。由于离芯片L4缓存的能量限制较小,它们可能变得愈发重要。
除了依赖多级缓存的方案,采用具有多个未完成缺失的乱序管线的引入,使得可用的指令级并行性能够隐藏基于缓存系统中剩余的内存延迟。引入多线程和更多线程级并行性则进一步提升了这一点,提供了更多的并行性,从而带来更多隐藏延迟的机会。在现代多级缓存系统中,使用指令级和线程级并行性可能将成为隐藏遇到的各种内存延迟的重要工具。
一个周期性出现的想法是使用程序员控制的临时存储器或其他高速度可见内存,这在 GPU 中有所应用。然而,由于几个原因,这种想法从未在通用处理器中成为主流:首先,它们通过引入具有不同行为的地址空间来打破内存模型。其次,与基于编译器或程序员的缓存优化(例如预取)不同,使用临时存储器的内存转换必须完全处理从主内存地址空间到临时存储器地址空间的重新映射。这使得这种转换更加困难,适用性也受到限制。在 GPU 中(见第4章),局部临时存储器被广泛使用,管理这些存储器的负担目前落在了程序员身上。对于能够使用这些存储器的特定领域软件系统,性能提升非常显著。因此,HBM 技术很可能会被用于大型通用计算机中的缓存,甚至很可能作为图形和类似系统中的主要工作内存。随着特定领域架构在克服 Dennard 定律终结和摩尔定律减缓所带来的限制中变得愈加重要,临时存储器和类向量寄存器集的使用可能会增加。
Dennard 定律的终结对 DRAM 和处理器技术都有影响。因此,我们可能不会看到处理器和主内存之间的鸿沟扩大,而是两种技术的放缓,将导致整体性能增长率的减慢。计算机架构及相关软件的新创新,将共同提高性能和效率,是继续实现过去50年所见的性能提升的关键。

3 Instruction-Level Parallelism and Its Exploitation

“谁是第一?”
“美国。”
“谁是第二?”
“先生,没有第二。”
这是1851年两位观察者在航海比赛中的对话,该比赛后来被称为“美洲杯”,这也启发了约翰·科克(John Cocke)将IBM研究处理器命名为“America”,这是第一个超标量处理器,也是PowerPC的前身。
因此,IA-64寄希望于未来,功耗不会是关键限制,大规模资源不会影响时钟速度、路径长度或CPI因素。我的看法显然持怀疑态度……
马丁·霍普金斯(Marty Hopkins,2000年),IBM院士及早期RISC先驱,评论新推出的英特尔Itanium处理器,这是英特尔与惠普的联合开发。Itanium采用静态ILP方法(见附录H),并且是英特尔的一项巨额投资,但其微处理器销售从未超过英特尔总销量的0.5%。


3.1 Instruction-Level Parallelism: Concepts and Challenges

自1985年以来,所有处理器都采用了流水线技术,以重叠指令执行并提高性能。这种指令之间的潜在重叠称为指令级并行性(ILP),因为这些指令可以并行评估。在本章和附录H中,我们将探讨一系列扩展基本流水线概念的技术,以增加指令之间利用的并行性。
本章的内容比附录C中关于基本流水线的材料要高级得多。如果您对附录C中的概念不够熟悉,建议在阅读本章之前复习该附录。
我们首先讨论数据和控制冒险带来的限制,然后转向提高编译器和处理器利用并行性能力的话题。这些部分引入了大量概念,我们将在本章及下一章中继续构建。虽然本章中一些较基础的内容可以在没有前两节所有概念的情况下理解,但这些基础材料对后续部分仍然很重要。
利用ILP主要有两种可分离的方法:(1)依赖硬件动态发现和利用并行性的方式;(2)依赖软件技术在编译时静态寻找并行性的方式。采用动态硬件基础方法的处理器,包括所有最近的英特尔和许多ARM处理器,在桌面和服务器市场占据主导地位。在个人移动设备市场,这些方法同样应用于平板电脑和高端手机中的处理器。在物联网领域,由于功耗和成本约束主导性能目标,设计师利用较低水平的指令级并行性。从1980年代开始,积极的编译器基础方法已经尝试过多次,最近的例子是1999年推出的英特尔Itanium系列。尽管付出了巨大的努力,但这种方法仅在特定领域或具有显著数据级并行性的良好结构化科学应用中取得了成功。
近年来,许多为一种方法开发的技术被应用于主要依赖另一种方法的设计中。本章介绍了基本概念和这两种方法。我们讨论了对指令级并行性(ILP)方法的限制,这些限制直接导致了向多核处理器的转变。理解这些限制在平衡ILP和线程级并行性使用时仍然至关重要。
在本节中,我们讨论了限制指令间可利用并行性特征的程序和处理器的特性,以及程序结构与硬件结构之间关键的映射关系,这对理解程序属性是否会限制性能以及在什么情况下限制性能至关重要。
流水线处理器的每指令周期数(CPI)的值是基本CPI及所有停顿贡献之和:
Pipeline CPI = Ideal pipeline CPI + Structural stalls + Data hazard stalls + Control stalls
理想流水线CPI是实现可达到的最大性能的度量。通过减少右侧各项的数值,我们可以降低整体流水线CPI,或者说提高每时钟周期指令数(IPC)。
上述方程允许我们通过某一技术减少的整体CPI组件来表征各种技术。图3.1展示了本章和附录H中我们将探讨的技术,以及附录C中介绍材料所涵盖的主题。在本章中,我们将看到,为降低理想流水线CPI而引入的技术可能会增加处理冒险的复杂性。

### 什么是指令级并行性(ILP)?
本章中的所有技术都利用了指令之间的并行性。在一个基本块内可用的并行性量相对较小——基本块是一个直线代码序列,除了入口处没有分支,出口处也没有分支。对于典型的RISC程序,平均动态分支频率通常在15%到25%之间,这意味着在一对分支之间通常有三到六条指令执行。由于这些指令可能相互依赖,我们在基本块内可以利用的重叠量往往少于平均基本块大小。为了获得显著的性能提升,我们必须在多个基本块之间利用ILP。
增加ILP的最简单和最常见的方法是利用循环迭代之间的并行性。这种类型的并行性通常称为循环级并行性。以下是一个简单的例子,展示了一个完全可以并行的循环,该循环将两个1000元素的数组相加:

每次循环迭代可以与其他任何迭代重叠,尽管在每次循环迭代内部,重叠的机会很少或没有。我们将研究多种技术,以将这种循环级并行性转化为指令级并行性。基本上,这些技术通过编译器静态展开循环(如下一节所示)或通过硬件动态展开循环(如3.5节和3.6节所示)来实现。
利用循环级并行性的一个重要替代方法是使用SIMD,在矢量处理器和图形处理单元(GPU)中均有应用,这将在第4章中详细介绍。SIMD指令通过并行操作少量到中等数量的数据项(通常为两个到八个)来利用数据级并行性。矢量指令则通过使用并行执行单元和深流水线,操作许多数据项来实现数据级并行性。例如,前面的代码序列在简单形式下,每次迭代需要七条指令(两条加载、一条加法、一条存储、两条地址更新和一条分支),总共需要7000条指令,而在某些SIMD架构中,如果每条指令处理四个数据项,则可能只需执行四分之一的指令。在某些矢量处理器上,这个序列可能只需四条指令:两条指令从内存中加载向量x和y,一条指令加这两个向量,以及一条指令将结果向量存回。当然,这些指令将被流水线化,并具有相对较长的延迟,但这些延迟可能会重叠。
### 数据依赖性和危险
确定一个指令如何依赖于另一个指令,对于判断程序中存在多少并行性以及如何利用这种并行性至关重要。特别是,为了利用指令级并行性,我们必须确定哪些指令可以并行执行。如果两个指令是并行的,它们可以在一个任意深度的流水线中同时执行,而不会导致任何停顿,前提是流水线有足够的资源(因此不存在结构性危险)。如果两个指令是相互依赖的,则它们不是并行的,必须按顺序执行,尽管它们往往可以部分重叠。在这两种情况下,关键在于确定一个指令是否依赖于另一个指令。
### 数据依赖性
依赖性有三种不同类型:数据依赖性(也称为真实数据依赖性)、名称依赖性和控制依赖性。如果指令 j 对指令 i 数据依赖,则满足以下任一条件:
- 指令 i 产生的结果可能被指令 j 使用。
- 指令 j 对指令 k 数据依赖,且指令 k 对指令 i 数据依赖。
第二个条件简单地说明,如果在两个指令之间存在第一类依赖链,则一个指令依赖于另一个指令。这条依赖链可以长达整个程序。注意,在单个指令内部的依赖(例如 `add x1, x1, x1`)不被视为依赖。
例如,考虑以下 RISC-V 代码序列,它通过寄存器 f2 中的标量值递增内存中的一个值向量(从地址 0(x1) 开始,最后一个元素在 0(x2) 处结束)。

在前面的依赖序列中,如箭头所示,每条指令都依赖于前一条指令。这里的箭头以及后续示例中展示了为正确执行而必须保持的顺序。箭头指向的指令必须在箭头所指向的指令之前执行。如果两条指令之间存在数据依赖关系,它们必须按顺序执行,不能同时执行或完全重叠。这个依赖关系意味着这两条指令之间会有一个或多个数据危害(请参见附录C以了解数据危害的简要描述,我们将在几页后进行准确定义)。同时执行这些指令将导致具有流水线互锁的处理器(且流水线深度超过指令之间的周期距离)检测到危害并暂停,从而减少或消除重叠。在没有互锁、依赖于编译器调度的处理器中,编译器无法以使依赖指令完全重叠的方式进行调度,因为程序将无法正确执行。在指令序列中存在的数据依赖反映了生成该指令序列的源代码中的数据依赖。必须保留原始数据依赖的效果。
依赖是程序的一个属性。给定的依赖是否导致实际的危害被检测到,以及该危害是否真正导致暂停,是流水线组织的属性。这种差异对于理解如何利用指令级并行性至关重要。
数据依赖传达了三件事:(1)潜在的危害,(2)结果必须计算的顺序,以及(3)可能利用的并行性的上限。这些限制在第262页的一个陷阱和附录H中进行了更详细的探讨。
由于数据依赖可能限制我们可以利用的指令级并行性,本章的主要焦点是克服这些限制。依赖关系可以通过两种不同方式克服:(1)保持依赖但避免危害,以及(2)通过变换代码来消除依赖。调度代码是避免危害而不改变依赖的主要方法,这种调度既可以由编译器完成,也可以由硬件完成。
数据值可以通过寄存器或内存位置在指令之间流动。当数据流通过寄存器时,检测依赖是直接的,因为寄存器名称在指令中是固定的,但当分支介入时,正确性问题会迫使编译器或硬件采取保守策略,情况就会变得复杂。
通过内存位置流动的依赖更难以检测,因为两个地址可能指向相同的位置但看起来不同:例如,100(x4)和20(x6)可能是相同的内存地址。此外,加载或存储的有效地址可能在指令的不同执行之间发生变化(因此20(x4)和20(x4)可能不同),进一步复杂化了依赖检测。
在本章中,我们将研究用于检测涉及内存位置的数据依赖的硬件,但我们将看到这些技术也有其局限性。检测此类依赖的编译器技术对于发现循环级并行性至关重要。
### 名称依赖
第二种类型的依赖是名称依赖。当两个指令使用相同的寄存器或内存位置(称为名称)时,就会发生名称依赖,但与该名称相关的指令之间没有数据流动。对于在程序顺序中指令 i 之前的指令 j,存在两种类型的名称依赖:
1. **反依赖**:当指令 j 写入一个寄存器或内存位置,而指令 i 读取该寄存器或内存位置时,指令 i 和指令 j 之间发生反依赖。必须保留原始顺序,以确保 i 读取正确的值。在第171页的示例中,fsd 和 addi 之间在寄存器 x1 上存在反依赖。
2. **输出依赖**:当指令 i 和指令 j 写入同一个寄存器或内存位置时,输出依赖就发生了。必须保留指令之间的顺序,以确保最终写入的值对应于指令 j。
反依赖和输出依赖都是名称依赖,而不是实际的数据依赖,因为指令之间并没有传输值。由于名称依赖不是真正的依赖,参与名称依赖的指令可以同时执行或重新排序,只要指令中使用的名称(寄存器编号或内存位置)发生更改,以避免冲突。
这种重命名在寄存器操作数中更容易实现,这被称为寄存器重命名。寄存器重命名可以由编译器静态完成,也可以由硬件动态完成。在描述因分支而产生的依赖之前,让我们先看看依赖与流水线数据危害之间的关系。
### 数据危害
数据危害存在于指令之间存在名称或数据依赖时,并且它们的执行重叠足够接近,以至于可能改变对依赖操作数的访问顺序。由于这种依赖,我们必须保持所谓的程序顺序——即如果按原始源程序逐条顺序执行指令时的执行顺序。我们软件和硬件技术的目标是通过仅在影响程序结果的地方保持程序顺序来利用并行性。检测和避免危害确保了必要的程序顺序得以保持。
数据危害(在附录C中进行了非正式描述)可以根据指令中读写访问的顺序分为三种类型。按照惯例,这些危害的命名基于管道必须保留的程序顺序。考虑两个指令 i 和 j,其中 i 在程序顺序中位于 j 之前。可能的数据危害如下:
- **RAW(写后读)**:j 尝试在 i 写入之前读取一个源,因此 j 错误地获取了旧值。这种危害是最常见的类型,对应于真正的数据依赖。必须保留程序顺序以确保 j 从 i 获取到正确的值。
- **WAW(写后写)**:j 尝试在 i 写入之前写入一个操作数。结果写入的顺序错误,导致目标位置中保存的是 i 写入的值,而不是 j 写入的值。这种危害对应于输出依赖。WAW 危害仅出现在写入超过一个管道阶段的管道中,或者允许指令在之前的指令被暂停时继续执行。
- **WAR(读后写)**:j 尝试在 i 读取之前写入一个目标,因此 i 错误地获取了新值。这种危害源于反依赖(或名称依赖)。在大多数静态发射管道中——甚至更深的管道或浮点管道中——WAR 危害不会发生,因为所有读取都是提前进行的(在附录C中的 ID 阶段),所有写入都是延迟进行的(在附录C中的 WB 阶段)。WAR 危害发生在某些指令在指令管道中较早写出结果,而其他指令在管道中较晚读取源,或者在指令重新排序时,如本章所述。
请注意,RAR(读后读)情况不是一种危害。
### 控制依赖
最后一种依赖类型是控制依赖。控制依赖确定指令 i 与分支指令之间的顺序,以确保指令 i 在正确的程序顺序中执行,并且仅在应该执行时才执行。每条指令(除了程序的第一个基本块中的指令)都依赖于某些分支,通常,这些控制依赖必须被保留以维护程序顺序。控制依赖的最简单例子之一是 if 语句“then”部分中语句对分支的依赖。例如,在代码段中:

一般来说,控制依赖 imposes 两个约束:
1. 依赖于分支的指令不能在分支之前移动,以确保其执行仍然受分支控制。例如,我们不能将 if 语句的 then 部分中的指令移动到 if 语句之前。
2. 不依赖于分支的指令不能在分支之后移动,以使其执行受分支控制。例如,我们不能将 if 语句之前的语句移动到 then 部分中。
当处理器维护严格的程序顺序时,它们确保控制依赖也得以保留。然而,如果我们能够在不影响程序正确性的情况下执行一些本不应执行的指令,这可能会违反控制依赖。因此,控制依赖并不是必须保留的关键属性。相反,与程序正确性密切相关的两个属性——通常通过同时保持数据和控制依赖来保留——是异常行为和数据流。
保持异常行为意味着指令执行顺序的任何更改都不得改变程序中异常的引发方式。通常,这一要求放宽为指令执行的重新排序不得导致程序中出现任何新异常。一个简单的例子展示了保持控制和数据依赖如何防止这种情况的发生。考虑以下代码序列:

在这种情况下,很容易看出,如果我们不维护涉及 x2 的数据依赖,程序的结果可能会发生变化。更不明显的是,如果我们忽略控制依赖并将加载指令移动到分支之前,加载指令可能会导致内存保护异常。请注意,没有任何数据依赖阻止我们交换 beqz 和 ld 指令;这仅仅是控制依赖的问题。为了允许我们重新排序这些指令(同时保留数据依赖),我们希望在分支被采取时忽略异常。在第 3.6 节中,我们将讨论一种硬件技术——猜测执行,它使我们能够克服这个异常问题。附录 H 则探讨了支持猜测执行的软件技术。
维护数据依赖和控制依赖保留的第二个属性是数据流。数据流是指在产生结果的指令与消费这些结果的指令之间实际的数据值流动。分支使得数据流变得动态,因为它们允许给定指令的数据源来自多个点。换句话说,仅仅维护数据依赖是不够的,因为一条指令可能依赖于多个前驱。程序顺序决定了哪个前驱将实际向指令提供数据值。通过维护控制依赖,可以确保程序顺序。
例如,考虑以下代码片段:

在这个例子中,or 指令使用的 x1 值依赖于分支是否被采取。单靠数据依赖不足以保证正确性。or 指令依赖于 add 和 sub 指令,但仅保持这种顺序并不足以确保正确执行。
相反,当指令执行时,数据流必须得到保留:如果分支未被采取,则 or 指令应使用由 sub 计算出的 x1 值;如果分支被采取,则 or 指令应使用由 add 计算出的 x1 值。通过保持 or 指令对分支的控制依赖,我们防止了对数据流的非法更改。出于类似原因,sub 指令也不能被移动到分支之上。猜测执行可以帮助解决异常问题,同时在保持数据流的情况下减轻控制依赖的影响,这将在第 3.6 节中讨论。
有时我们可以判断,违反控制依赖不会影响异常行为或数据流。考虑以下代码序列:

假设我们知道 sub 指令的寄存器目标 (x4) 在标记为 skip 的指令之后未被使用。(一个值是否会被后续指令使用的属性称为活跃性。)如果 x4 未被使用,那么在分支之前改变 x4 的值将不会影响数据流,因为在 skip 之后的代码区域中 x4 将是“死”的(而不是“活”的)。因此,如果 x4 是死的,并且现有的 sub 指令不会生成异常(除了处理器恢复相同进程的那些),我们可以将 sub 指令移动到分支之前,因为这种改变不会影响数据流。
如果分支被采取,sub 指令将执行但没有用处,但它不会影响程序的结果。这种代码调度类型也是一种猜测,通常称为软件猜测,因为编译器是在对分支结果进行押注;在这种情况下,押注的结果是该分支通常不会被采取。更雄心勃勃的编译器猜测机制在附录 H 中进行了讨论。
通常,当我们说到猜测或猜测执行时,很明显该机制是硬件机制还是软件机制;如果不明确,最好表述为“硬件猜测”或“软件猜测”。
通过实现控制冒险检测来保持控制依赖,这会导致控制停顿。控制停顿可以通过多种硬件和软件技术来消除或减少,我们将在第 3.3 节中进行探讨。


3.2 Basic Compiler Techniques for Exposing ILP

本节探讨了使用简单的编译器技术来增强处理器利用指令级并行性(ILP)的能力。这些技术对于采用静态发射或静态调度的处理器至关重要。借助这些编译器技术,我们将很快研究使用静态发射的处理器的设计和性能。附录 H 将研究更复杂的编译器及相关硬件方案,旨在使处理器能够利用更多的指令级并行性。
基础流水线调度与循环展开
为了保持流水线的满负荷运行,必须通过寻找可以在流水线中重叠的无关指令序列来利用指令之间的并行性。为了避免流水线停顿,依赖指令的执行必须与源指令之间保持一个时钟周期的距离,该距离等于源指令的流水线延迟。编译器进行这种调度的能力取决于程序中可用的指令级并行性(ILP)以及流水线中功能单元的延迟。图 3.2 显示了本章假设的浮点单元延迟,除非明确说明不同的延迟。我们假设标准的五级整数流水线,因此分支有一个时钟周期的延迟。我们假设功能单元是完全流水线化或复制的(与流水线深度一样多),这样任何类型的操作都可以在每个时钟周期内发射,并且没有结构危害。
在本节中,我们将探讨编译器如何通过转换循环来增加可用的 ILP。这一例子不仅用于说明一种重要的技术,同时也激励附录 H 中描述的更强大的程序转换。我们将依赖以下代码段,该代码段将标量值加到向量中:

我们可以通过注意到每次迭代的主体是独立的,从而看出这个循环是可并行的。我们在附录 H 中对这一概念进行了形式化,并描述了如何在编译时测试循环迭代是否独立。首先,让我们看看这个循环的性能,这展示了我们如何利用并行性来提升其在具有上述延迟的 RISC-V 流水线中的性能。
第一步是将前面的代码段翻译为 RISC-V 汇编语言。在下面的代码段中,x1 最初是数组中最高地址元素的地址,而 f2 包含标量值 s。寄存器 x2 是预计算的,这样 Regs[x2]+8 就是要操作的最后一个元素的地址。

图 3.2 本章中使用的浮点操作延迟。最后一列是为了避免停顿所需的中间时钟周期数。这些数字与我们在浮点单元上看到的平均延迟相似。浮点加载到存储的延迟为 0,因为加载的结果可以在不导致存储停顿的情况下被绕过。我们将继续假设整数加载的延迟为 1,整数算术逻辑单元(ALU)操作的延迟为 0(包括用于分支的 ALU 操作)。

示例:展示这个循环在 RISC-V 上的表现,包括调度和未调度的情况,以及任何停顿或空闲时钟周期。调度考虑浮点操作的延迟。
回答:在没有任何调度的情况下,循环将如下执行,耗时九个周期:

在前面的示例中,我们每七个时钟周期完成一次循环迭代并存储一个数组元素,但对数组元素的实际操作仅占这七个时钟周期中的三个(加载、加法和存储)。剩下的四个时钟周期是循环开销——即 addi 和 bne——以及两个停顿。为了消除这四个时钟周期,我们需要增加相对于开销指令的操作数。
一种简单的增加相对于分支和开销指令数量的方法是循环展开。循环展开只是将循环体复制多次,并调整循环终止代码。
循环展开也可以用于改善调度。因为它消除了分支,所以允许不同迭代中的指令一起调度。在这种情况下,我们可以通过在循环体内创建额外的独立指令来消除数据使用停顿。如果我们在展开循环时简单地复制指令,使用相同寄存器的结果可能会阻止我们有效地调度循环。因此,我们希望为每次迭代使用不同的寄存器,这样会增加所需的寄存器数量。
示例:展示我们展开的循环,使得循环体有四个副本,假设 x1 = x2(即数组的大小)最初是 32 的倍数,这意味着循环迭代的次数是 4 的倍数。消除任何明显冗余的计算,并且不重用任何寄存器。
回答:这是合并 addi 指令并去掉在展开过程中重复的多余 bne 操作后的结果。请注意,x2 现在必须设置为使得 Regs[x2] + 32 是最后四个元素的起始地址。

我们已消除了三个分支和三个 x1 的递减。加载和存储的地址已进行了调整,以允许对 x1 的 addi 指令进行合并。这种优化看似微不足道,但并非如此;它需要符号替换和简化。符号替换和简化将重新排列表达式,以便常量能够被合并,从而使得像 ((i + 1) + 1) 这样的表达式可以重写为 (i + (1 + 1)),然后简化为 (i + 2)。我们将在附录 H 中看到更一般形式的这些优化,这些优化消除了依赖计算。
在没有调度的情况下,展开循环中的每个浮点加载或操作后面都会跟随一个相关操作,因此会导致停顿。这个展开的循环将运行 26 个时钟周期——每个 fld 有 1 个停顿,每个 fadd.d 有 2 个停顿,加上 14 个指令发射周期——或者说每四个元素需要 6.5 个时钟周期,但可以通过调度显著提高性能。循环展开通常在编译过程的早期进行,以便暴露并消除冗余计算。
在实际程序中,我们通常不知道循环的上限。假设它是 n,并且我们想要展开循环以生成 k 个副本。我们生成一对连续的循环,而不是单个展开的循环。第一个循环执行 (n mod k) 次,循环体是原始循环。第二个循环是展开的循环体,外面有一个迭代 (n/k) 次的外部循环。(正如我们将在第四章看到的,这种技术类似于一种叫做“带状开采”的技术,用于向量处理器的编译器。)对于较大的 n 值,大部分执行时间将花费在展开的循环体中。
在前面的例子中,展开通过消除开销指令提高了这个循环的性能,尽管它显著增加了代码大小。展开的循环在之前描述的流水线中调度时表现如何呢?
示例:展示在图 3.2 中具有延迟的流水线调度后,前一个例子中的展开循环。

展开循环的执行时间降至总共 14 个时钟周期,即每个元素 3.5 个时钟周期,而在没有展开或调度之前,每个元素需 8 个周期,展开但未调度时为 6.5 个周期。对展开循环进行调度所带来的增益甚至比原始循环更大。这一提升源于展开循环暴露了更多可以调度的计算,从而最小化停顿;前面的代码没有停顿。以这种方式调度循环需要意识到加载和存储是独立的,可以互换。
### 循环展开与调度的总结
在本章及附录 H 中,我们将探讨多种硬件和软件技术,以利用指令级并行性,充分发挥处理器功能单元的潜力。这些技术的关键在于了解何时以及如何改变指令之间的顺序。在我们的示例中,我们进行了许多这样的更改,对于我们人类来说,这些更改显然是可行的。但在实际操作中,这一过程必须由编译器或硬件以系统化的方式进行。为了获得最终的展开代码,我们需要做出以下决策和变换:
- 确定展开循环是有用的,发现循环迭代之间是独立的,仅循环维护代码存在依赖。
- 使用不同的寄存器,以避免由于在不同计算中使用相同寄存器而带来的不必要约束(例如,名称依赖)。
- 消除额外的测试和分支指令,并调整循环终止和迭代代码。
- 通过观察不同迭代中的加载和存储是独立的,确定展开循环中的加载和存储可以互换。这一变换需要分析内存地址,确保它们不指向相同的地址。
- 调度代码,保留所需的依赖关系,以产生与原始代码相同的结果。
所有这些变换的关键要求是理解一个指令如何依赖于另一个指令,以及在给定依赖关系的情况下,如何改变或重新排列指令。
循环展开带来的增益受到三种不同效应的限制:(1)随着每次展开而摊销的开销减少,(2)代码大小限制,以及(3)编译器限制。首先考虑循环开销的问题。当我们将循环展开四次时,它在指令之间生成了足够的并行性,使得该循环可以在没有停顿周期的情况下进行调度。实际上,在 14 个时钟周期中,只有 2 个周期是循环开销:一个是维护索引值的 `addi` 指令,另一个是结束循环的 `bne` 指令。如果循环展开八次,开销从每个元素 1/2 个周期减少到 1/4 个周期。
展开的第二个限制是代码大小的增长。对于较大的循环,代码大小的增长可能会成为一个问题,特别是如果这导致指令缓存未命中率的增加。
另一个常常比代码大小更重要的因素是,由于激进的循环展开和调度而导致的寄存器短缺。这种由于在大代码段中进行指令调度所产生的次要效应被称为寄存器压力。它的产生是因为调度代码以增加指令级并行性(ILP)导致活跃值的数量增加。在激进的指令调度之后,可能无法将所有活跃值分配给寄存器。尽管经过变换的代码在理论上更快,但由于导致寄存器短缺,它可能失去部分或全部优势。如果不进行展开,激进调度受到分支的限制,使得寄存器压力很少成为问题。然而,展开和激进调度的结合可能会导致这个问题。在需要暴露更多独立指令序列并重叠执行的多发射处理器中,这个问题尤其棘手。
总体来说,使用复杂的高级变换,其潜在改进在详细代码生成之前难以衡量,已导致现代编译器复杂性的显著增加。循环展开是一种简单但有效的方法,可以增加可以有效调度的直线代码片段的大小。这种变换在多种处理器中都很有用,从我们迄今为止研究的简单流水线,到本章后面探讨的多发射超标量和VLIW处理器。


3.3 Reducing Branch Costs With Advanced Branch Prediction

由于需要通过分支冒险和停顿来强制执行控制依赖,分支会影响流水线性能。循环展开是一种减少分支冒险数量的方法;我们还可以通过预测分支的行为来降低分支带来的性能损失。在附录C中,我们研究了依赖于编译时信息或单个分支的观察动态行为的简单分支预测器。随着流水线更深、每个时钟周期发射的指令数量增加,更准确的分支预测的重要性也日益增长。在本节中,我们将探讨提高动态预测准确性的技术。本节广泛使用了第C.2节中介绍的简单2位预测器,因此读者在继续之前必须理解该预测器的工作原理。
### 相关分支预测器
附录C中的2位预测器方案仅利用单个分支的近期行为来预测该分支的未来行为。如果我们同时关注其他分支的近期行为,而不仅仅是我们试图预测的分支,可能会提高预测准确性。考虑来自eqntott基准测试的小代码片段,这是早期SPEC基准套件中的一个成员,表现出特别糟糕的分支预测行为:

让我们将这些分支标记为 b1、b2 和 b3。关键观察是,分支 b3 的行为与分支 b1 和 b2 的行为是相关的。显然,如果分支 b1 和 b2 都没有被采用(即,如果两个条件都评估为真并且 aa 和 bb 都被赋值为 0),那么 b3 将会被采用,因为 aa 和 bb 显然是相等的。仅使用单个分支的行为来预测该分支结果的预测器无法捕捉到这种行为。
使用其他分支行为来进行预测的分支预测器称为相关预测器或双级预测器。现有的相关预测器通过添加有关最近分支行为的信息来决定如何预测给定的分支。例如,(1,2) 预测器利用最后一个分支的行为,从一对 2 位分支预测器中选择以预测特定分支。在一般情况下,(m,n) 预测器使用最后 m 个分支的行为,从 2^m 个分支预测器中选择,每个分支预测器是一个单一分支的 n 位预测器。这种类型的相关分支预测器的吸引力在于,它可以提供比 2 位方案更高的预测率,并且只需要少量额外的硬件。
硬件的简易性来自一个简单的观察:最近 m 个分支的全局历史可以记录在一个 m 位移位寄存器中,每个位记录该分支是否被采用。然后,可以通过将分支地址的低位与 m 位全局历史连接来索引分支预测缓冲区。例如,在一个具有 64 个总条目的 (2,2) 缓冲区中,分支的 4 位低位地址和表示最近执行的两个分支行为的 2 位全局位形成一个 6 位索引,可以用于索引这 64 个计数器。通过连接(或简单的哈希函数)组合本地和全局信息,我们可以用结果索引预测器表,并以与标准 2 位预测器一样快的速度获得预测,就像我们很快将要做的那样。
相关分支预测器与标准的2位方案相比效果如何?为了公平比较,我们必须比较使用相同状态位数的预测器。一个 (m,n) 预测器中的位数为:
\[ 2^m \times n \]
其中,n 是由分支地址选择的预测条目数。
没有全局历史的2位预测器仅仅是一个 (0,2) 预测器。
**示例**:一个具有4K条目的 (0,2) 分支预测器中有多少位?具有相同位数的 (2,2) 预测器中有多少条目?
**答案**:具有4K条目的预测器有:
\[ 2^0 \times 2 \times 4K = 8K \text{ 位} \]
一个总共有8K位的 (2,2) 预测器中有多少由分支选择的条目?我们知道:
\[ 2^2 \times 2 \times \text{由分支选择的预测条目} = 8K \]
因此,由分支选择的预测条目数为1K。

图3.3比较了2位预测器。首先是一个4096位的非相关预测器,其次是一个具有无限条目的非相关2位预测器,以及一个具有2位全局历史和总共1024条目的2位预测器。尽管这些数据来自于较旧版本的SPEC,但对于更新的SPEC基准测试的数据也会显示出类似的准确性差异。
图3.3比较了早期的 (0,2) 预测器(具有4K条目)与 (2,2) 预测器(具有1K条目)的误预测率。正如你所看到的,这种相关预测器不仅在总状态位数相同的情况下优于简单的2位预测器,而且通常还优于条目数量无限的2位预测器。
也许最著名的相关预测器是McFarling的gshare预测器。在gshare中,索引用分支地址和最近的条件分支结果通过异或运算组合而成,这本质上充当了分支地址和分支历史的哈希。哈希结果用于索引一个2位计数器的预测数组,如图3.4所示。gshare预测器对于一个简单的预测器来说效果非常好,常常作为与更复杂预测器比较的基准。
结合局部分支信息和全局分支历史的预测器也被称为合金预测器或混合预测器。

**比赛预测器:自适应组合局部和全局预测器**
相关分支预测器的主要动机来源于观察到标准的2位预测器仅使用局部信息时,在一些重要分支上表现不佳。增加全局历史可以帮助改善这种情况。
比赛预测器将这一见解提升到了一个新层次,采用多个预测器,通常是全局预测器和局部预测器,并通过选择器在它们之间进行选择,如图3.5所示。全局预测器使用最近的分支历史来索引预测器,而局部预测器则使用分支地址作为索引。比赛预测器是另一种混合或合金预测器。
比赛预测器在中等规模(8K–32K位)时可以实现更好的准确性,并且有效利用非常大量的预测位。现有的比赛预测器为每个分支使用一个2位饱和计数器,以根据最近的预测效果在两个不同的预测器(局部、全局或某种时间变化的混合)之间进行选择。与简单的2位预测器一样,饱和计数器在改变首选预测器的身份之前需要两次误预测。

**图3.5** 一种比赛预测器,使用分支地址来索引一组2位选择计数器,该计数器在局部预测器和全局预测器之间进行选择。在这种情况下,选择器表的索引是当前的分支地址。两个表也是2位预测器,分别由全局历史和分支地址进行索引。选择器类似于2位预测器,当连续发生两次误预测时,会改变特定分支地址的首选预测器。用于索引选择器表和局部预测器表的分支地址位数等于用于索引全局预测表的全局分支历史长度。需要注意的是,误预测有点棘手,因为我们需要同时更改选择器表和全局或局部预测器。
比赛预测器的优点在于能够为特定分支选择合适的预测器,这对于整数基准测试尤为重要。对于SPEC整数基准,典型的比赛预测器大约40%的时间选择全局预测器,而对于SPEC FP基准则不到15%。除了首创比赛预测器的Alpha处理器外,几款AMD处理器也采用了比赛风格的预测器。

**图3.6** 显示了在SPEC89上三种不同预测器的误预测率与预测器大小(以千位为单位)之间的关系。这些预测器包括局部2位预测器、一个在图中每个点上都最优利用全局和局部信息的关联预测器,以及一个比赛预测器。尽管这些数据基于较旧版本的SPEC,但更新版本的SPEC基准测试显示出类似的行为,可能在稍大预测器尺寸时逐渐接近渐近极限。
**图3.6** 展示了在使用SPEC89作为基准时,三种不同预测器(局部2位预测器、关联预测器和比赛预测器)在不同位数下的性能表现。局部预测器首先达到其极限。关联预测器显示出显著的改进,而比赛预测器则提供了稍好一些的性能。对于更新版本的SPEC,结果会类似,但渐近行为直到稍大预测器尺寸时才会实现。
局部预测器由一个两级预测器组成。顶层是一个包含1024个10位条目的局部历史表;每个10位条目对应于最近10次的分支结果。也就是说,如果该分支连续被取用10次或更多次,局部历史表中的条目将全为1。如果分支交替被取用和未取用,则历史条目将由交替的0和1组成。这种10位历史能够发现和预测最多10个分支的模式。选定的局部历史表条目用于索引一个包含1K条目的表,该表由3位饱和计数器组成,从而提供局部预测。虽然这种分支预测所需的位数少于与其具有相同预测准确度的单级表(总共使用29K位),但它依然能够实现较高的准确性。
### 标签混合预测器
截至2017年,表现最佳的分支预测方案涉及结合多个预测器,以跟踪某个预测是否可能与当前分支相关。其中一个重要的预测器类别是基于一种名为PPM(部分匹配预测)的统计压缩算法。PPM(参见Jimenez和Lin,2001),类似于分支预测算法,试图根据历史数据预测未来行为。这类分支预测器被称为标签混合预测器(参见Seznec和Michaud,2006),它使用一系列以不同长度历史为索引的全局预测器。

图3.7 一个五组件的标签混合预测器具有五个独立的预测表,这些表通过分支地址的哈希和长度为0到4的最近分支历史的片段(在本图中标记为“h”)进行索引。哈希可以像gshare一样简单,例如使用异或操作。每个预测器是一个2位(或可能是3位)预测器。标签通常为4到8位。所选择的预测是具有最长历史且标签也匹配的预测。
例如,如图3.7所示,一个五组件的标签混合预测器有五个预测表:P(0)、P(1)、…、P(4),其中P(i)是通过对程序计数器(PC)和最近i个分支的历史(保存在移位寄存器h中,类似于gshare)进行哈希访问的。使用多种历史长度来索引独立预测器是第一个关键区别。第二个关键区别是在表P(1)到P(4)中使用标签。由于不需要100%的匹配,标签可以较短:4-8位的小标签似乎能获得大部分优势。只有当标签与分支地址和全局分支历史的哈希匹配时,才会使用来自P(1)、…、P(4)的预测。P(0…n)中的每个预测器可以是标准的2位预测器。在实践中,3位计数器比2位计数器稍微提供更好的结果,因为它需要三次错误预测才会改变预测。
对于给定分支的预测是具有最长分支历史且标签匹配的预测器。P(0)始终匹配,因为它不使用标签,如果P(1)到P(n)都不匹配,则P(0)成为默认预测。此预测器的标签混合版本在每个以历史为索引的预测器中还包含一个2位的使用字段。使用字段指示预测是否最近被使用,因此更可能更准确;使用字段可以定期重置所有条目,从而清除旧的预测。实现这种风格的预测器涉及许多更多细节,特别是如何处理错误预测。最佳预测器的搜索空间也非常大,因为预测器的数量、用于索引的确切历史以及每个预测器的大小都是可变的。
标签混合预测器(有时称为TAGE——标签几何预测器)和早期基于PPM的预测器在最近的国际分支预测竞赛中表现出色。这类预测器在内存相对适中的情况下(32–64 KiB)超越了gshare和锦标赛预测器。此外,这类预测器似乎能够有效利用更大的预测缓存,从而提高预测准确性。
对于较大的预测器,另一个问题是如何初始化预测器。可以随机初始化,这样会花费相当多的执行时间来填充有效预测。有些预测器(包括许多最新的预测器)包含一个有效位,指示预测器中的条目是否已被设置或处于“未使用状态”。在后者情况下,可以使用某种方法来初始化该预测条目,而不是使用随机预测。例如,一些指令集包含一个位,指示关联分支是否预期被取走。在动态分支预测之前,这些提示位就是预测;在最近的处理器中,该提示位可以用来设置初始预测。我们还可以根据分支方向设置初始预测:向前的分支初始化为未取,而向后的分支(可能是循环分支)初始化为已取。对于运行时间较短的程序和具有较大预测器的处理器,这种初始设置对预测性能可能产生可测量的影响。

图3.8展示了标签混合预测器与gshare的误预测率比较(以每执行1000条指令的误预测次数来衡量)。两个预测器使用相同总数的位,尽管标签混合预测器将部分存储用于标签,而gshare则不包含标签。这些基准测试由SPECfp和SPECint的追踪数据组成,以及一系列多媒体和服务器基准测试。后两者的行为更类似于SPECint。
图3.8显示,混合标签预测器显著优于gshare,特别是在SPECint和服务器应用等可预测性较低的程序中。在这个图中,性能以每千条指令的误预测次数来衡量;假设分支频率为20%-25%,gshare在多媒体基准测试中的误预测率(每个分支)为2.7%-3.4%,而混合标签预测器的误预测率为1.8%-2.2%,大约减少了三分之一的误预测。与gshare相比,标签混合预测器的实现更为复杂,并且由于需要检查多个标签并选择预测结果,可能稍微慢一些。然而,对于深度流水线处理器来说,分支误预测带来的高惩罚使得提高的准确性超过了这些缺点。因此,许多高端处理器的设计者选择在最新的实现中包含标签混合预测器。
### 英特尔Core i7分支预测器的演变
如前一章所述,从2008年(使用Nehalem微架构的Core i7 920)到2016年(使用Skylake微架构的Core i7 6700),共出现了六代英特尔Core i7处理器。由于深度流水线和每个时钟周期多发指令的组合,i7在运行时有许多指令同时处于执行状态(最多256条,通常至少30条)。这使得分支预测变得至关重要,且一直以来都是英特尔持续改进的领域。由于分支预测器对性能的关键影响,英特尔倾向于对其分支预测器的细节保持高度保密。即使是2008年推出的较旧处理器Core i7 920,他们发布的信息也非常有限。在本节中,我们简要描述已知的信息,并比较Core i7 920与最新的Core i7 6700的预测器性能。
Core i7 920使用了一个两级预测器,其中第一级预测器较小,旨在满足每个时钟周期预测一个分支的周期约束,而第二级预测器则作为备份。每个预测器结合了三种不同的预测器:(1)简单的2位预测器,如附录C中所介绍(并在前面的比赛预测器中使用);(2)全局历史预测器;以及(3)循环退出预测器。循环退出预测器使用计数器来预测检测为循环分支的确切被采取分支数量(即循环迭代次数)。对于每个分支,通过跟踪每个预测的准确性,从三个预测器中选择最佳预测,类似于赛事预测器。除了这个多级主预测器外,还有一个单独的单元用于预测间接分支的目标地址,同时也使用堆栈来预测返回地址。
虽然关于最新i7处理器中的预测器了解得更少,但有充分理由相信英特尔采用了标记混合预测器。这种预测器的一个优点是它结合了早期i7中所有三个第二级预测器的功能。带有不同历史长度的标记混合预测器包含了循环退出预测器以及局部和全局历史预测器。仍然使用一个单独的返回地址预测器。
正如其他案例中所示,猜测会给评估预测器带来一些挑战,因为错误预测的分支可能导致另一个分支被获取并错误预测。为了简化分析,我们将观察错误预测数量占成功完成的分支数量的百分比(即没有因误推断导致的分支)。图3.9展示了SPECpuint2006基准测试的数据。这些基准测试显著大于SPEC89或SPEC2000,因此错误预测率高于图3.6所示,即使在更强大的预测器组合下也是如此。由于分支错误预测会导致无效的猜测,它会导致工作浪费,正如我们将在本章后面看到的那样。

图3.9显示了Intel Core i7 920和6700在整数SPECCPU2006基准测试中的错误预测率。错误预测率是通过已完成的错误预测分支与所有已完成分支的比率来计算的。这可能会稍微低估错误预测率,因为如果一个分支被错误预测并导致另一个错误预测的分支(实际上不应该执行),则只会计为一个错误预测。平均来看,i7 920的分支错误预测率是i7 6700的1.3倍。


3.4 Overcoming Data Hazards With Dynamic Scheduling

一个简单的静态调度流水线会获取一条指令并发出它,除非在已经在流水线中的指令与获取的指令之间存在无法通过旁路或转发隐蔽的数据依赖关系。(转发逻辑减少了有效流水线延迟,因此某些依赖关系不会导致冒险。)如果存在无法隐蔽的数据依赖关系,则冒险检测硬件会从使用结果的指令开始暂停流水线。在依赖关系被清除之前,不会获取或发出新的指令。
在本节中,我们探讨动态调度,这是一种通过硬件重新排序指令执行以减少暂停,同时保持数据流和异常行为的技术。动态调度提供了几个优点。首先,它允许为了某种流水线编译的代码在不同的流水线上高效运行,消除了需要多个二进制文件和针对不同微架构重新编译的需求。在今天的软件环境中,许多软件来自第三方并以二进制形式分发,这一优势尤为显著。其次,它能够处理一些在编译时未知的依赖关系的情况;例如,它们可能涉及内存引用或数据相关的分支,或者可能是由于使用动态链接或调度的现代编程环境所导致的。第三,或许最重要的是,它允许处理器容忍不可预测的延迟,比如缓存缺失,通过在等待缺失解决时执行其他代码。在第3.6节中,我们将探讨硬件猜测,这是一种基于动态调度的附加性能优势的技术。正如我们将看到的,动态调度的优势是以显著增加硬件复杂性为代价的。
尽管动态调度的处理器无法改变数据流,但它会尽量避免在存在依赖关系时出现停顿。相比之下,编译器进行的静态流水线调度(在第3.2节中讨论)则试图通过将依赖指令分开来最小化停顿,从而避免产生冒险。当然,编译器的流水线调度也可以用于在具有动态调度流水线的处理器上运行的代码。
动态调度:概念  
简单流水线技术的一个主要限制是它们采用按顺序发出和执行指令:指令按照程序顺序发出,如果某条指令在流水线中停顿,则后面的指令无法继续。因此,如果流水线中的两条相邻指令之间存在依赖关系,就会导致冒险,从而产生停顿。如果有多个功能单元,这些单元可能会处于空闲状态。如果指令 j 依赖于正在流水线中执行的长时间运行的指令 i,那么 j 后面的所有指令必须停顿,直到 i 完成并且 j 可以执行。例如,考虑以下代码:

fsub.d 指令无法执行,因为 fadd.d 对 fdiv.d 的依赖导致流水线停顿;然而,fsub.d 并不依赖于流水线中的任何其他指令。这种冒险造成了性能限制,可以通过不要求指令按程序顺序执行来消除。
在经典的五级流水线中,结构冒险和数据冒险可以在指令解码(ID)阶段进行检查:当指令可以在没有冒险的情况下执行时,它会从 ID 阶段发出,同时确认所有数据冒险已解决。
为了让我们能够开始执行前面示例中的 fsub.d,我们必须将发出过程分为两个部分:检查任何结构冒险,并等待没有数据冒险。因此,我们仍然使用按顺序发出指令(即按照程序顺序发出的指令),但我们希望指令在其数据操作数可用时立即开始执行。这种流水线实现了乱序执行,这意味着完成也是乱序的。
乱序执行引入了写后读(WAR)和写后写(WAW)冒险的可能性,这在五级整数流水线及其逻辑扩展到按顺序浮点流水线中是不存在的。考虑以下 RISC-V 浮点代码序列:

在 fmul.d 和 fadd.d 之间存在反依赖(针对寄存器 f0),如果流水线在 fmul.d(正在等待 fdiv.d)之前执行 fadd.d,就会违反这种反依赖,从而导致 WAR 冒险。同样,为了避免违反输出依赖,例如在 fdiv.d 完成之前 fadd.d 写入 f0,必须处理 WAW 冒险。如我们所见,这两种冒险都可以通过寄存器重命名来避免。
乱序完成还在处理异常时带来了重大复杂性。动态调度与乱序完成必须保留异常行为,即如果程序以严格的程序顺序执行,那么确切地会出现那些异常。动态调度的处理器通过延迟关联异常的通知,直到处理器知道该指令应该是下一个完成的指令,从而保留异常行为。
尽管必须保留异常行为,动态调度的处理器可能会生成不精确的异常。如果在引发异常时处理器状态看起来并非完全按照严格程序顺序执行指令,那么这个异常就是不精确的。不精确异常可能由于以下两种情况发生:
1. 流水线可能已经完成了在程序顺序中位于引发异常的指令之后的某些指令。
2. 流水线可能尚未完成在程序顺序中位于引发异常的指令之前的某些指令。
不精确异常使得在异常发生后重新启动执行变得困难。我们将在第3.6节中讨论一个解决方案,该方案在具有预测功能的处理器上下文中提供精确异常,而不是在这一节中解决这些问题。对于浮点异常,已使用其他解决方案,如附录J中所述。
为了允许乱序执行,我们基本上将简单五级流水线的指令解码(ID)阶段拆分为两个阶段:
1. 发射—解码指令,检查结构性冒险。
2. 读取操作数—等待直到没有数据冒险,然后读取操作数。
指令获取阶段位于发射阶段之前,可以将指令获取到指令寄存器或待处理指令队列中;然后从寄存器或队列中发射指令。执行阶段跟随读取操作数阶段,就像在五级流水线中一样。执行可能需要多个周期,具体取决于操作。
我们区分指令开始执行和完成执行的时间;在这两个时间之间,指令处于执行状态。我们的流水线允许多条指令同时执行;如果没有这种能力,动态调度的一个主要优势将会丧失。一次执行多条指令需要多个功能单元、流水线功能单元,或两者兼具。由于这两种能力——流水线功能单元和多个功能单元——在流水线控制方面基本等效,我们将假设处理器具有多个功能单元。
在动态调度的流水线中,所有指令按照顺序通过发射阶段(按序发射);然而,它们可以在第二阶段(读取操作数)被延迟或互相绕过,从而以乱序方式进入执行。记分板是一种允许指令在资源足够且没有数据依赖时乱序执行的技术,其名称来源于CDC 6600的记分板,该系统开发了这种能力。在这里,我们重点讨论一种更复杂的技术,称为Tomasulo算法。其主要区别在于,Tomasulo算法通过动态重命名寄存器来处理反依赖和输出依赖。此外,Tomasulo算法可以扩展以处理预测,这是通过预测分支的结果来减少控制依赖影响的一种技术,执行预测目标地址的指令,并在预测错误时采取纠正措施。虽然使用记分板可能足以支持较简单的处理器,但更复杂的高性能处理器则利用了预测技术。
### 动态调度使用Tomasulo方法
IBM 360/91浮点单元采用了一种复杂的方案以支持乱序执行。该方案由罗伯特·托马苏洛发明,旨在跟踪指令所需操作数的可用性,以最小化RAW危险,并在硬件中引入寄存器重命名以减少WAW和WAR危险。尽管近年来的处理器中出现了许多该方案的变体,但它们都依赖于两个关键原则:动态确定指令何时准备执行,以及重命名寄存器以避免不必要的危险。
IBM的目标是通过针对整个360计算机系列设计的指令集和编译器来实现高浮点性能,而不是依靠针对高端处理器的专门编译器。360架构仅有四个双精度浮点寄存器,这限制了编译器调度的有效性;这一事实也是采用Tomasulo方法的另一个动机。此外,IBM 360/91具有较长的内存访问时间和浮点延迟,而Tomasulo算法正是为了解决这些问题而设计的。
在本节结束时,我们将看到Tomasulo算法还可以支持多个循环迭代的重叠执行。
我们将在RISC-V指令集的背景下解释该算法,主要集中于浮点单元和加载-存储单元。RISC-V与360之间的主要区别在于后者架构中存在寄存器-内存指令。由于Tomasulo算法使用加载功能单元,因此添加寄存器-内存寻址模式不需要重大更改。IBM 360/91也采用了流水线功能单元,而非多个功能单元,但我们将算法描述为如果有多个功能单元。这是一个简单的概念扩展,同样可以对这些功能单元进行流水线处理。
RAW危险通过仅在操作数可用时执行指令来避免,这正是更简单的记分板方法所提供的。WAR和WAW危险则源于名称依赖,通过寄存器重命名来消除。寄存器重命名通过重命名所有目标寄存器,包括那些尚待读取或写入的早期指令的寄存器,从而消除这些危险,使得乱序写入不会影响依赖于操作数早期值的任何指令。如果指令集架构中有足够的寄存器,编译器通常可以实现这种重命名。原始的360/91只有四个浮点寄存器,而Tomasulo的算法正是为了解决这一短缺。尽管现代处理器有32到64个浮点和整数寄存器,但近期实现中可用的重命名寄存器数量已达数百个。
为了更好地理解寄存器重命名如何消除WAR和WAW危险,考虑以下包含潜在WAR和WAW危险的代码序列:

有两个反依赖关系:一个是在 `fadd.d` 和 `fsub.d` 之间,另一个是在 `fsd` 和 `fmul.d` 之间。此外,`fadd.d` 和 `fmul.d` 之间还有一个输出依赖关系,这导致了三种可能的危险:`fadd.d` 使用 `f8` 时的WAR危险,以及其被 `fsub.d` 使用时的WAR危险,还有一个WAW危险,因为 `fadd.d` 可能会在 `fmul.d` 之后完成。还有三个真实的数据依赖关系:一个是在 `fdiv.d` 和 `fadd.d` 之间,一个是在 `fsub.d` 和 `fmul.d` 之间,以及一个是在 `fadd.d` 和 `fsd` 之间。
这三种名称依赖关系都可以通过寄存器重命名来消除。为简化起见,假设存在两个临时寄存器 S 和 T。使用 S 和 T,可以将该序列重写为没有任何依赖关系的形式:

此外,任何后续使用 `f8` 的地方都必须被寄存器 `T` 替换。在这个例子中,重命名过程可以由编译器静态完成。要找到代码中稍后使用 `f8` 的地方,需要复杂的编译器分析或硬件支持,因为在前面的代码段和后续使用 `f8` 之间可能存在干扰分支。正如我们将看到的,Tomasulo 算法可以处理跨分支的重命名。
在 Tomasulo 的方案中,寄存器重命名是通过保留站提供的,保留站缓冲等待发出的指令的操作数,并与功能单元相关联。基本思想是,当操作数可用时,保留站立即获取并缓冲该操作数,从而消除从寄存器获取操作数的需要。此外,待处理的指令指定将提供其输入的保留站。最后,当对寄存器的连续写入在执行中重叠时,实际上只有最后一个写入会用于更新寄存器。当指令被发出时,待处理操作数的寄存器说明符被重命名为保留站的名称,从而实现寄存器重命名。
由于保留站的数量可以超过实际寄存器的数量,这项技术甚至可以消除编译器无法消除的名称依赖引起的危险。在我们探讨 Tomasulo 方案的各个组件时,我们将再次回到寄存器重命名的话题,具体看看重命名是如何发生的,以及它如何消除 WAR 和 WAW 危险。
使用保留站而不是集中式寄存器文件带来了两个其他重要特性。首先,危险检测和执行控制是分布式的:每个功能单元中保留站中保存的信息决定了指令何时可以在该单元开始执行。其次,结果是直接从缓冲操作数的保留站传递给功能单元,而不是经过寄存器。这种旁路是通过一个公共结果总线实现的,允许所有等待加载操作数的单元同时进行(在 360/91 上,这被称为公共数据总线,或 CDB)。在每个时钟周期发出多条指令并且有多个执行单元的流水线中,需要多个结果总线。

图3.10展示了使用Tomasulo算法的RISC-V浮点单元的基本结构。指令从指令单元发送到指令队列,并以先进先出(FIFO)顺序发出。保留站包含操作和实际操作数,以及用于检测和解决冒险的信息。加载缓冲区有三个功能:(1)在有效地址计算完成之前,保存有效地址的组件;(2)跟踪等待内存的未完成加载;(3)保存已完成加载的结果,直到它们可以送往公共数据总线(CDB)。类似地,存储缓冲区也有三个功能:(1)在有效地址计算完成之前,保存有效地址的组件;(2)保存等待数据值以进行存储的未完成存储的目标内存地址;(3)保存待存储的地址和值,直到内存单元可用。来自浮点单元或加载单元的所有结果都放在公共数据总线上,传送到浮点寄存器文件以及保留站和存储缓冲区。浮点加法器执行加法和减法,而浮点乘法器执行乘法和除法。
图3.10显示了基于Tomasulo的处理器的基本结构,包括浮点单元和加载/存储单元;没有展示执行控制表。每个保留站保存了一条已发出的指令,正在等待功能单元的执行。如果该指令的操作数值已经计算出来,它们也会存储在该条目中;否则,保留站条目会保留将提供操作数值的保留站名称。
加载缓冲区和存储缓冲区保存来自内存的数据或地址,并且几乎与保留站的行为完全相同,因此我们仅在必要时加以区分。浮点寄存器通过一对总线连接到功能单元,并通过一根总线连接到存储缓冲区。来自功能单元和内存的所有结果都通过公共数据总线发送,该总线可以连接到除加载缓冲区以外的所有地方。所有保留站都有标签字段,由流水线控制使用。
在描述保留站和算法的细节之前,让我们看看指令所经过的步骤。虽然每个步骤现在可以占用任意数量的时钟周期,但总共有三个步骤:
1. **发出**—从指令队列的头部获取下一条指令,指令队列以先进先出(FIFO)顺序维护,以确保正确的数据流。如果有一个空的匹配保留站,则将指令发给该站,并提供操作数值(如果它们目前在寄存器中)。如果没有空的保留站,则出现结构冒险,指令发出将停滞,直到某个站或缓冲区被释放。如果操作数不在寄存器中,则跟踪将生成操作数的功能单元。此步骤重命名寄存器,消除写后读(WAR)和写后写(WAW)冒险。(这个阶段在动态调度的处理器中有时称为调度。)
2. **执行**—如果一个或多个操作数尚不可用,则在等待其计算时监控公共数据总线。当操作数变得可用时,将其放入任何等待该操作数的保留站。当所有操作数均可用时,可以在相应的功能单元上执行操作。通过延迟指令执行直到操作数可用,可以避免读后写(RAW)冒险。(一些动态调度的处理器将此步骤称为“发出”,但我们使用“执行”这个名称,这是在第一台动态调度处理器CDC 6600中使用的。)
注意到多个指令可能在同一个时钟周期内为同一个功能单元准备就绪。虽然独立的功能单元可以在同一个时钟周期内执行不同的指令,但如果有多个指令为同一个功能单元准备就绪,该单元将必须在它们之间进行选择。对于浮点保留站,这个选择可以任意作出;然而,加载和存储操作则带来了额外的复杂性。
加载和存储操作需要一个两步的执行过程。第一步是在基址寄存器可用时计算有效地址,然后将有效地址放入加载或存储缓冲区。加载缓冲区中的加载操作将在内存单元可用时立即执行。存储缓冲区中的存储操作则需要等待要存储的值可用后再发送到内存单元。通过有效地址计算,加载和存储操作按照程序顺序进行,这有助于防止内存中的冒险情况。
为了保持异常行为,任何指令在执行之前都不能启动,直到其前面的分支指令完成。这一限制确保了在执行过程中导致异常的指令确实是被执行过的。在使用分支预测的处理器(所有动态调度的处理器都是如此)中,这意味着处理器必须确认分支预测是正确的,才能允许分支之后的指令开始执行。如果处理器记录了异常的发生,但实际上并不触发它,则指令可以开始执行,但不会在进入写结果阶段之前停滞。
推测提供了一种更灵活、更完整的处理异常的方法,因此我们将推迟这一增强的实现,并将在后面展示推测如何处理这个问题。
3. 写入结果——当结果可用时,将其写入CDB,然后再写入寄存器和等待该结果的任意保留站(包括存储缓冲区)。存储操作在存储缓冲区中缓冲,直到要存储的值和存储地址都可用;然后,一旦内存单元空闲,结果将被写入。
检测和消除数据冒险的数据结构附加在预留站、寄存器文件以及加载和存储缓冲区上,针对不同对象附加了略有不同的信息。这些标签本质上是用于重命名的扩展虚拟寄存器集的名称。在我们的例子中,标签字段是一个4位的量,表示五个预留站或五个加载缓冲区中的一个。这个组合相当于产生10个寄存器(5个预留站 + 5个加载缓冲区),可以指定为结果寄存器(与360架构中的四个双精度寄存器相比)。在具有更多真实寄存器的处理器中,我们希望重命名能够提供一个更大的虚拟寄存器集,通常数量可达数百个。标签字段描述哪个预留站包含将生成作为源操作数所需结果的指令。一旦指令被发出并在等待源操作数时,它通过指派给写寄存器的指令的预留站编号来引用操作数。未使用的值(如零)表示操作数已经在寄存器中可用。由于预留站的数量超过实际寄存器的数量,因此通过使用预留站编号重命名结果消除了WAW和WAR冒险。尽管在Tomasulo的方案中,预留站被用作扩展虚拟寄存器,但其他方法可以使用具有额外寄存器的寄存器集或类似重排序缓冲区的结构,我们将在第3.6节中看到这些内容。
在Tomasulo的方案中,以及我们后续探讨的支持推测的方法中,结果通过总线(CDB)广播,预留站监控这一总线。公共结果总线与预留站从总线检索结果的结合,实现了在静态调度流水线中使用的转发和旁路机制。然而,这样做使得动态调度方案(如Tomasulo算法)在源操作数和结果之间引入了一个周期的延迟,因为结果与其使用的匹配直到写结果阶段结束后才能完成,而对于更简单的流水线来说,这通常是在执行阶段结束时。因此,在动态调度流水线中,生成指令与消费指令之间的有效延迟至少比产生结果的功能单元的延迟长一个周期。
重要的是要记住,Tomasulo方案中的标签指的是将产生结果的缓冲区或单元;当指令发出到预留站时,寄存器名称被舍弃。(这是Tomasulo方案与分数板之间的一个关键区别:在分数板中,操作数保留在寄存器中,并且只有在生成指令完成且消费指令准备执行后才会读取。)
每个预留站有七个字段:
- **Op**——对源操作数 S1 和 S2 执行的操作。
- **Qj, Qk**——将产生相应源操作数的预留站;值为零表示源操作数已经在 Vj 或 Vk 中可用,或者不需要该操作数。
- **Vj, Vk**——源操作数的值。注意,对于每个操作数,仅一个 V 字段或 Q 字段是有效的。对于加载指令,Vk 字段用于保存偏移量字段。
- **A**——用于保存加载或存储的内存地址计算信息。最初,这里存储的是指令的立即数字段;在地址计算后,有效地址将存储在这里。
- **Busy**——指示该预留站及其对应的功能单元正在占用中。
寄存器文件有一个字段 Qi:
- **Qi**——包含应存储到此寄存器结果的操作的预留站编号。如果 Qi 的值为空(或 0),则没有当前活动指令正在计算目标为此寄存器的结果,这意味着该值只是寄存器的内容。
加载和存储缓冲区各有一个字段 A,用于在执行的第一步完成后保存有效地址的结果。
在下一部分,我们将首先考虑一些示例,以展示这些机制是如何工作的,然后再详细审查算法。


3.5 Dynamic Scheduling: Examples and the Algorithm

在我们详细研究Tomasulo算法之前,让我们考虑几个示例,以帮助说明该算法的工作原理。
**示例**:展示在以下代码序列中,当仅第一个加载完成并写入其结果时,信息表的样子。

**回答**:图3.11显示了三个表中的结果。附加在Add、Mult和Load名称后的数字代表该预留站的标签——Add1是第一个加法单元结果的标签。此外,我们还包含了一个指令状态表。这个表仅用于帮助您理解算法,实际上并不是硬件的一部分。相反,预留站保持每个已发出操作的状态。
Tomasulo方案相比于早期更简单的方案有两个主要优势:(1)危害检测逻辑的分布,和(2)消除了WAW和WAR危害的停顿。
第一个优势来自分布式的预留站和CDB的使用。如果多个指令在等待单个结果,并且每条指令已经有了其他操作数,那么指令可以通过在CDB上广播结果同时释放。如果使用集中式寄存器文件,单位将不得不在寄存器总线可用时从寄存器中读取它们的结果。
第二个优势,即消除WAW和WAR危害,是通过使用预留站重命名寄存器以及在操作数可用时立即将其存储到预留站的过程来实现的。

图3.11显示了预留站和寄存器标签,当所有指令都已发出,但只有第一个加载指令完成并将其结果写入CDB时的状态。第二个加载指令已完成有效地址计算,但仍在等待内存单元。我们使用数组Regs[]来表示寄存器文件,使用数组Mem[]来表示内存。请记住,操作数在任何时候都由Q字段或V字段指定。注意,fadd.d指令在WB阶段存在WAR危害,但它已经发出,并且可以在fdiv.d指令启动之前完成。
例如,图3.11中的代码序列同时发出了fdiv.d和fadd.d,尽管存在涉及f6的WAR危害。这种危害可以通过两种方式消除。首先,如果提供fdiv.d值的指令已完成,那么Vk将存储结果,使得fdiv.d可以独立于fadd.d执行(这是所示的情况)。另一方面,如果fld尚未完成,那么Qk将指向Load1预留站,这样fdiv.d指令就会独立于fadd.d。因此,在这两种情况下,fadd.d都可以发出并开始执行。任何对fdiv.d结果的使用将指向预留站,从而允许fadd.d完成并将其值存储到寄存器中,而不影响fdiv.d。
我们将很快看到消除WAW危害的例子。但首先,让我们看看之前的例子是如何继续执行的。在这个例子以及本章后面的例子中,假设以下延迟:加载为1个时钟周期,加法为2个时钟周期,乘法为6个时钟周期,除法为12个时钟周期。
例子:使用与前一个例子相同的代码段(第201页),展示当fmul.d准备写入其结果时状态表的样子。

答案:结果显示在图3.12中的三个表格中。请注意,fadd.d已经完成,因为fdiv.d的操作数已被复制,从而克服了WAR危害。即使f6的加载是fdiv.d,向f6进行加法操作也可以执行,而不会触发WAW危害。
Tomasulo算法:详细信息

图3.13 算法步骤及每个步骤的要求。对于发出指令,rd是目标寄存器,rs和rt是源寄存器编号,imm是符号扩展的立即数字段,r是指令被分配到的预留站或缓冲区。RS是预留站数据结构。由FP单元或加载单元返回的值称为结果。RegisterStat是寄存器状态数据结构(与寄存器文件Regs[]不同)。当指令被发出时,目标寄存器的Qi字段设置为指令发出的缓冲区或预留站的编号。如果操作数在寄存器中可用,它们会存储在V字段中。否则,Q字段被设置以指示将生成所需源操作数的预留站。指令在预留站中等待,直到两个操作数都可用,这通过Q字段中的零来指示。Q字段在指令发出时或依赖于此指令的另一条指令完成并进行写回时被设置为零。当一条指令执行完成且CDB可用时,它可以进行写回。所有Qj或Qk的值与完成的预留站相同的缓冲区、寄存器和预留站将从CDB更新其值,并标记Q字段以指示已收到值。因此,CDB可以在一个时钟周期内将结果广播到多个目标,如果等待的指令有其操作数,它们都可以在下一个时钟周期开始执行。加载指令在执行过程中经历两个步骤,而存储指令在写结果阶段的表现稍有不同,可能需要等待要存储的值。请记住,为了保持异常行为,指令在程序顺序中较早的分支尚未完成时不应被允许执行。由于在发出阶段之后不维护程序顺序的概念,这一限制通常通过阻止任何指令离开发出步骤来实现,如果管道中已经存在待处理的分支。在第3.6节中,我们将看到猜测支持如何消除这一限制。
图3.13指定了每条指令必须经过的检查和步骤。如前所述,加载和存储在进入独立的加载或存储缓冲区之前,首先要通过功能单元进行有效地址计算。加载指令需要第二个执行步骤来访问内存,然后进入写结果阶段,将内存中的值发送到寄存器文件和/或任何等待的预留站。存储指令在写结果阶段完成其执行,将结果写入内存。请注意,无论目标是寄存器还是内存,所有写入操作都发生在写结果阶段。这一限制简化了Tomasulo算法,并对第3.6节中引入的猜测扩展至关重要。
**Tomasulo算法:基于循环的示例**
要理解通过动态重命名寄存器消除写后读(WAW)和读后写(WAR)冒险的全部威力,我们必须查看一个循环。考虑以下简单的序列,用于将数组的元素乘以标量f2:

如果我们预测分支会被执行,使用预留站将允许多个循环的执行同时进行。这一优势是在不改变代码的情况下获得的——实际上,通过重命名动态地将循环展开,硬件使用预留站作为额外的寄存器。

**图3.14** 显示了两个活动循环迭代,且尚未完成任何指令。乘法器预留站中的条目表明,未完成的加载操作是数据源。存储预留站则表明,乘法运算的目标是存储值的源。
假设我们已经发出了循环的两个连续迭代中的所有指令,但没有浮点加载/存储或操作完成。图3.14显示了此时的预留站、寄存器状态表以及加载和存储缓冲区。(整数ALU操作被忽略,并假设分支被预测为执行。)一旦系统达到这种状态,只要乘法运算能在四个时钟周期内完成,就可以维持两个循环的执行,CPI接近1.0。如果延迟为六个周期,那么在达到稳定状态之前需要处理更多的迭代。这需要更多的预留站来保存正在执行的指令。正如我们将在本章后面看到的,扩展多发指令时,Tomasulo的方法可以在每个时钟周期维持超过一条指令的执行。
只要访问不同的地址,加载和存储可以安全地无序执行。如果加载和存储访问相同的地址,将会发生以下两种情况之一:
- 如果加载在程序顺序中位于存储之前,交换它们会导致WAR(写后读)风险。
- 如果存储在程序顺序中位于加载之前,交换它们会导致RAW(读后写)风险。
同样,交换两个对同一地址的存储操作会导致WAW(写后写)风险。因此,为了确定加载是否可以在特定时间执行,处理器可以检查是否存在任何未完成的存储操作,该操作在程序顺序中位于加载之前,并且与加载共享相同的数据内存地址。同样,存储操作必须等到所有较早的未执行的加载或存储操作完成,并且这些操作共享相同的数据内存地址。我们将在第3.9节中考虑消除这一限制的方法。
为了检测此类风险,处理器必须计算与任何早期内存操作相关的数据内存地址。确保处理器拥有所有这些地址的一种简单但不一定最优的方法是按照程序顺序进行有效地址计算。(我们实际上只需要保持存储与其他内存引用之间的相对顺序;也就是说,加载可以自由重排序。)
首先考虑加载的情况。如果我们按照程序顺序进行有效地址计算,那么当加载完成有效地址计算时,我们可以通过检查所有活动存储缓冲区的A字段来检查是否存在地址冲突。如果加载地址与存储缓冲区中任何活动条目的地址匹配,则在冲突的存储完成之前,该加载指令不会被发送到加载缓冲区。(一些实现会直接将值从待处理的存储旁路到加载,从而减少这个RAW风险的延迟。)
存储操作类似,不过处理器必须检查加载缓冲区和存储缓冲区中的冲突,因为冲突的存储不能相对于加载或存储进行重排序。
动态调度的流水线可以提供非常高的性能,只要分支预测准确——这是我们在前一节中讨论过的问题。这种方法的主要缺点是Tomasulo方案的复杂性,这需要大量硬件。特别是,每个保留站必须包含一个关联缓冲区,该缓冲区必须以高速运行,并且还需要复杂的控制逻辑。性能也可能受到单个CDB的限制。虽然可以添加额外的CDB,但每个CDB必须与每个保留站交互,且每个保留站的关联标签匹配硬件必须为每个CDB重复。
在1990年代,只有高端处理器能够利用动态调度(及其对推测的扩展);然而,最近即使是为PMD设计的处理器也在使用这些技术,而高端桌面和小型服务器的处理器则有数百个缓冲区以支持动态调度。
在Tomasulo的方案中,结合了两种不同的技术:将架构寄存器重命名为更大的一组寄存器和从寄存器文件缓冲源操作数。源操作数缓冲解决了操作数在寄存器中可用时产生的WAR风险。正如我们稍后将看到的,通过重新命名寄存器并缓冲结果直到没有未完成的对早期版本寄存器的引用,也可以消除WAR风险。在讨论硬件推测时,我们将使用这种方法。
Tomasulo的方案在360/91之后多年未被使用,但在1990年代开始被广泛采用于多发射处理器,原因有几个:
1. 尽管托马苏洛算法是在缓存出现之前设计的,但由于缓存本身具有不可预测的延迟,其存在已成为动态调度的主要动力之一。乱序执行使处理器能够在等待缓存缺失完成时继续执行指令,从而隐藏了全部或部分缓存缺失的惩罚。
2. 随着处理器在发射能力上变得更加激进,以及设计师对难以调度代码(如大多数非数值代码)的性能日益关注,寄存器重命名、动态调度和推测等技术变得愈加重要。
3. 它可以在不要求编译器针对特定流水线结构进行代码优化的情况下实现高性能,这在包装精美的大众市场软件时代是一个宝贵的特性。


3.6 Hardware-Based Speculation

随着我们试图利用更多的指令级并行性,维护控制依赖性变得愈加繁重。分支预测减少了由分支引起的直接停顿,但对于每个时钟周期执行多条指令的处理器,仅仅准确预测分支可能不足以生成所需的指令级并行性。宽发射处理器可能需要在每个时钟周期执行一个分支,以维持最大性能。因此,利用更多的并行性要求我们克服控制依赖性的限制。
克服控制依赖性是通过对分支结果进行预测,并假设我们的猜测是正确的来实现的。这种机制是对动态调度中分支预测的一种微妙但重要的扩展。具体而言,通过推测,我们获取、发射和执行指令,仿佛我们的分支预测总是正确的;而动态调度仅获取和发射这些指令。当然,我们需要机制来处理推测不正确的情况。附录H讨论了支持编译器推测的各种机制。
在这一部分,我们探讨硬件推测,它扩展了动态调度的概念。基于硬件的推测结合了三个关键思想:(1)动态分支预测用于选择要执行的指令, (2)推测允许在控制依赖性解决之前执行指令(并能够撤销错误推测序列的影响),以及(3)动态调度用于处理不同基本块组合的调度。(相比之下,没有推测的动态调度仅部分重叠基本块,因为它要求在实际执行任何后续基本块中的指令之前先解决分支。)
硬件推测遵循预测的数据值流,以选择何时执行指令。这种执行程序的方法本质上是一种数据流执行:操作一旦其操作数可用便立即执行。
为了扩展Tomasulo算法以支持推测,我们必须将指令之间结果的旁路分离开,这对于推测性地执行指令是必要的,和指令的实际完成。通过这种分离,我们可以允许指令执行并将其结果旁路给其他指令,而不允许该指令进行任何无法撤销的更新,直到我们确认该指令不再是推测性的。
使用旁路值类似于进行推测性的寄存器读取,因为我们不知道提供源寄存器值的指令是否提供了正确的结果,直到该指令不再是推测性的。当一条指令不再是推测性时,我们允许它更新寄存器文件或内存;我们将这一指令执行序列中的额外步骤称为指令提交。
实现推测的关键思想是允许指令乱序执行,但强制它们顺序提交,并防止任何不可逆的操作(如更新状态或发生异常),直到指令提交。因此,当我们添加推测时,需要将执行完成的过程与指令提交分开,因为指令可能在准备提交之前就完成了执行。将这个提交阶段添加到指令执行序列需要额外的一组硬件缓冲区,用于保存已完成执行但尚未提交的指令的结果。我们称这些硬件缓冲区为重排序缓冲区,它还用于在可能被推测的指令之间传递结果。
重排序缓冲区(ROB)提供了额外的寄存器,类似于Tomasulo算法中的保留站如何扩展寄存器集。ROB在指令相关操作完成与指令提交之间保存指令的结果。因此,ROB为指令提供操作数,就像保留站在Tomasulo算法中提供操作数一样。关键区别在于,在Tomasulo算法中,一旦一条指令写入其结果,所有随后发出的指令都能在寄存器文件中找到该结果。而在推测执行中,寄存器文件在指令提交之前不会更新(直到我们明确知道该指令应该执行);因此,ROB在指令执行完成与指令提交之间提供操作数。ROB类似于Tomasulo算法中的存储缓冲区,为了简化,我们将存储缓冲区的功能集成到ROB中。

图3.15展示了一个使用Tomasulo算法的浮点单元(FP单元)基本结构,并扩展以处理推测执行。与第198页的图3.10(实现了Tomasulo算法)相比,主要变化在于增加了重排序缓冲区(ROB)并取消了存储缓冲区,其功能已集成到ROB中。通过扩展CDB的宽度,以支持每个时钟周期多个完成,这种机制可以进一步扩展以允许每个时钟周期进行多发射。
图3.15显示了包含ROB的处理器硬件结构。ROB中的每个条目包含四个字段:指令类型、目的地字段、值字段和就绪字段。指令类型字段指示该指令是分支(没有目的地结果)、存储(具有内存地址目的地),还是寄存器操作(算术逻辑单元操作或加载,具有寄存器目的地)。目的地字段提供寄存器编号(用于加载和ALU操作)或内存地址(用于存储),指示指令结果应写入的位置。值字段用于在指令提交之前保存指令结果的值。我们稍后会看到ROB条目的示例。最后,就绪字段指示该指令已完成执行,并且结果值已准备好。
重排序缓冲区(ROB)包含了存储缓冲区。存储操作仍然分为两个步骤执行,但第二个步骤由指令提交完成。尽管保留站的重命名功能被ROB取代,我们仍然需要一个地方来缓存操作(和操作数),直到它们发出和开始执行之间的时间。这一功能仍由保留站提供。由于每条指令在提交之前都有一个在ROB中的位置,我们使用ROB条目编号来标记结果,而不是使用保留站编号。这种标记方式要求跟踪分配给指令的ROB编号在保留站中。接下来,我们将探索一种替代实现,使用额外的寄存器进行重命名,并用队列替代ROB,以决定何时可以提交指令。
指令执行涉及以下四个步骤:
1. **发射**—从指令队列中获取一条指令。如果有空的保留站和ROB中的空槽,则发射该指令;如果操作数在寄存器或ROB中可用,则将其发送到保留站。更新控制条目以指示缓冲区正在使用中。分配给结果的ROB条目编号也会发送到保留站,以便在结果放置到CDB时使用该编号进行标记。如果所有保留站都已满或ROB已满,则指令发射将暂停,直到两者都有可用条目。
2. **执行**—如果一个或多个操作数尚不可用,则在等待寄存器计算时监控CDB。这一步检查读取后写入(RAW)风险。当保留站中两个操作数都可用时,执行操作。在这一阶段,指令可能需要多个时钟周期,并且加载操作仍需要两个步骤。存储操作在此步骤只需要基址寄存器,因为此时存储操作的执行仅是有效地址计算。
3. 写入结果——当结果可用时,将其写入CDB(带有指令发出时发送的ROB标签),并从CDB写入ROB,以及任何等待该结果的保留站。将保留站标记为可用。存储指令需要特殊处理。如果要存储的值可用,则将其写入存储的ROB条目的值字段。如果要存储的值尚不可用,则必须监视CDB,直到该值被广播,此时更新存储的ROB条目的值字段。为简化起见,我们假设这发生在存储的写入结果阶段;稍后我们会讨论放宽这一要求。
4. 提交——这是完成指令的最后阶段,此后只剩下其结果。(一些处理器将此提交阶段称为“完成”或“毕业”。)提交时的操作序列有三种不同情况,具体取决于提交的指令是错误预测的分支、存储,还是其他指令(正常提交)。正常提交的情况发生在指令到达ROB的头部且其结果存在于缓冲区时;此时,处理器用结果更新寄存器,并从ROB中移除该指令。提交存储的过程类似,只不过是更新内存而不是结果寄存器。当错误预测的分支到达ROB的头部时,表明推测是错误的。此时,ROB被清空,执行在分支的正确后继处重新启动。如果分支被正确预测,则分支完成。
一旦指令提交,其在ROB中的条目被回收,寄存器或内存目标被更新,从而消除了对ROB条目的需求。如果ROB满了,我们将停止发出指令,直到有条目变为空。现在让我们看看这个方案在我们用于Tomasulo算法的相同示例中是如何工作的。
示例 假设浮点功能单元的延迟与之前的示例相同:加法为2个时钟周期,乘法为6个时钟周期,除法为12个时钟周期。使用以下代码段,即我们用来生成图3.12的相同代码,展示当fmul.d准备提交时状态表的样子。

答案 图3.16显示了三个表中的结果。请注意,尽管fsub.d指令已经完成执行,但在fmul.d提交之前,它不会提交。保留站和寄存器状态字段包含与Tomasulo算法相同的基本信息(有关这些字段的描述见第200页)。不同之处在于,保留站编号在Qj和Qk字段以及寄存器状态字段中被ROB条目编号替代,并且我们在保留站中添加了Dest字段。Dest字段指定了该保留站条目的结果所对应的ROB条目。

图3.16显示了在fmul.d准备提交时,只有两个fld指令已经提交,尽管其他几条指令已经完成执行。fmul.d位于ROB的头部,两个fld指令的存在只是为了便于理解。fsub.d和fadd.d指令在fmul.d指令提交之前不会提交,尽管这些指令的结果是可用的,并且可以作为其他指令的源。fdiv.d正在执行,但由于其延迟时间长于fmul.d,因此尚未完成。值列指示当前保持的值;格式#X用于引用ROB条目的值字段X。重排序缓冲区1和2实际上已经完成,但出于信息目的而显示。我们没有展示加载/存储队列的条目,但这些条目是按顺序保留的。
前面的例子说明了一个带有猜测的处理器与一个具有动态调度的处理器之间的关键重要区别。请将图3.16的内容与第184页的图3.12进行比较,后者展示了在使用Tomasulo算法的处理器上执行相同代码序列的情况。主要区别在于,在前面的例子中,早期未完成指令(前面例子中的fmul.d)之后的任何指令都不被允许完成。相比之下,在图3.12中,fsub.d和fadd.d指令也已经完成。
这种差异的一个含义是,具有ROB的处理器可以动态执行代码,同时保持精确的中断模型。例如,如果fmul.d指令引发了中断,我们可以简单地等到它到达ROB的头部,然后处理中断,同时冲刷掉ROB中的其他挂起指令。由于指令提交是按顺序进行的,这样就能确保精确的异常处理。
相比之下,在使用Tomasulo算法的例子中,fsub.d和fadd.d指令可能在fmul.d引发异常之前就已经完成。这导致寄存器f8和f6(fsub.d和fadd.d指令的目标寄存器)可能会被覆盖,从而使得中断变得不精确。
一些用户和架构师认为,在高性能处理器中,不精确的浮点异常是可以接受的,因为程序很可能会终止;有关此主题的进一步讨论,请参见附录J。其他类型的异常,例如页面错误,如果不精确,就更难以处理,因为程序必须在处理此类异常后透明地恢复执行。
使用按顺序提交指令的重排序缓冲区(ROB)不仅提供了精确的异常处理,还支持投机执行,正如下一个示例所示。
示例:考虑之前用于Tomasulo算法的代码示例,如图3.14所示。

假设我们已经发出循环中的所有指令两次。还假设第一次迭代中的fld和fmul.d指令已经提交,所有其他指令都已完成执行。通常,存储操作会在重排序缓冲区(ROB)中等待有效地址操作数(本例中的x1)和值(本例中的f4)。由于我们只考虑浮点流水线,假设在指令发出时,有效地址已经计算完毕。
答案:图3.17显示了结果的两个表格。

图3.17中,只有fld和fmul.d指令已经提交,尽管所有其他指令都已完成执行。因此,没有保留站处于忙碌状态,也没有显示。其余指令将尽快提交。前两个重排序缓冲区是空的,但为了完整性而展示。
由于在指令提交之前,寄存器值和任何内存值实际上并不会被写入,因此处理器可以轻松地撤销其投机操作,当发现分支预测错误时。假设在图3.17中,第一次遇到的bne分支没有被采取。在分支之前的指令将会在到达重排序缓冲区(ROB)头部时提交;当分支到达该缓冲区的头部时,缓冲区将被清空,处理器开始从另一条路径获取指令。
在实际应用中,进行投机的处理器会尽早恢复,以应对分支预测错误。这种恢复可以通过清除在错误预测的分支之后的所有ROB条目来完成,从而允许在ROB中位于分支之前的指令继续执行,并在正确的分支后继处重新开始取指。在投机处理器中,性能对分支预测的敏感度更高,因为错误预测的影响将更大。因此,处理分支的各个方面——预测准确性、错误预测检测延迟和恢复时间——都变得愈发重要。
异常处理是通过在准备提交之前不识别异常来实现的。如果一个投机指令引发了异常,该异常会被记录在ROB中。如果发生了分支错误预测,并且该指令本不应执行,则在清除ROB时,该异常将与指令一起被刷新。如果指令到达ROB的头部,那么我们就知道它不再是投机的,异常应该真正被处理。我们也可以尝试在异常出现时尽快处理,但在所有早期分支解决之前,这在处理异常时比处理分支错误预测更具挑战性,而且由于这种情况发生得较少,所以其紧迫性不如分支错误预测。

图3.18 算法中的步骤及每个步骤所需的条件。对于发出指令,rd 是目标,rs 和 rt 是源操作数,r 是分配的保留站,b 是分配的ROB条目,h 是ROB的头部条目。RS 是保留站数据结构。保留站返回的值称为结果。Register-Stat 是寄存器数据结构,Regs 代表实际寄存器,ROB 是重排序缓冲区数据结构。
图3.18展示了指令执行的步骤,以及继续执行到下一步所需满足的条件和采取的操作。我们展示了在提交之前不解决错误预测分支的情况。尽管推测似乎只是对动态调度的简单补充,但将图3.18与图3.13中Tomasulo算法的相应图进行比较后,可以看出推测为控制增加了显著的复杂性。此外,请记住,分支错误预测的处理也相对复杂。
在推测处理器和Tomasulo算法中,存储的处理方式存在一个重要区别。在Tomasulo算法中,当存储达到写结果阶段(这确保有效地址已计算)且要存储的数据值可用时,它可以更新内存。而在推测处理器中,存储只有在到达重排序缓冲区(ROB)头部时才更新内存。这个区别确保了内存不会在指令仍然是推测性的情况下被更新。
图3.18对存储操作有一个显著的简化,这在实际中并不需要。图3.18要求存储在写结果阶段等待要存储的寄存器源操作数的值;然后该值从存储的保留站的Vk字段移动到存储的ROB条目的Value字段。然而,在实际情况中,要存储的值不必在存储提交之前就到达,而可以由源指令直接放入存储的ROB条目。这通过硬件跟踪要存储的源值在存储的ROB条目何时可用,并在每次指令完成时搜索ROB以查找依赖的存储来实现。
这项添加并不复杂,但有两个影响:我们需要在ROB中增加一个字段,此外,图3.18已经字体较小,长度会更长!尽管图3.18进行了简化,在我们的例子中,我们将允许存储通过写结果阶段,并在提交时等待值准备好。
与Tomasulo算法一样,我们必须避免内存中的冲突。通过推测消除了内存中的WAW和WAR冲突,因为内存的实际更新是按顺序发生的,当存储位于ROB的头部时,之前的加载或存储无法仍然处于挂起状态。内存中的RAW冲突则通过两个限制来维护:
1. 如果任何被存储占据的活动ROB条目的目标字段与加载的A字段值匹配,则不允许加载开始执行的第二步。
2. 维护加载的有效地址计算的程序顺序,确保其相对于所有早期存储保持一致。
这两个限制共同确保任何访问由早期存储写入的内存位置的加载,必须等到存储完成数据写入后才能进行内存访问。一些推测处理器在发生此类RAW冲突时,实际上会直接将存储的值绕过传递给加载。另一种方法是使用一种形式的值预测来预测潜在的冲突;我们将在第3.9节讨论这一点。
尽管对推测执行的解释集中在浮点运算上,但这些技术同样适用于整数寄存器和功能单元。实际上,由于这类程序往往具有分支行为较难预测的代码,因此在整数程序中,推测可能更加有用。此外,这些技术可以扩展到多发射处理器,通过允许每个时钟周期发射和提交多个指令。实际上,推测在这种处理器中可能最为有趣,因为在编译器的辅助下,较不激进的技术能够在基本块内利用足够的ILP。


3.7 Exploiting ILP Using Multiple Issue and Static Scheduling

前面几节讨论的技术可以用来消除数据和控制停顿,并实现理想的每条指令周期数(CPI)为1。为了进一步提高性能,我们希望将CPI降低到1以下,但如果每个时钟周期只发出一条指令,就无法将CPI降低到1以下。
接下来几节将讨论的多发射处理器的目标是允许在一个时钟周期内发出多条指令。多发射处理器主要有三种类型:
1. 静态调度超标量处理器
2. VLIW(非常长指令字)处理器
3. 动态调度超标量处理器
这两种类型的超标量处理器每个时钟周期发出不同数量的指令,如果是静态调度则使用顺序执行,如果是动态调度则使用乱序执行。
与此不同,VLIW处理器发出的是固定数量的指令,这些指令要么作为一条大指令格式化,要么作为固定指令包,指令之间的并行性由指令明确指示。VLIW处理器本质上是由编译器进行静态调度的。当英特尔和惠普创建IA-64架构(见附录H)时,他们还引入了“EPIC”(显式并行指令计算机)这一名称来描述这种架构风格。
尽管静态调度超标量处理器每个时钟周期发出的是变化而非固定的指令数量,但它们实际上在概念上更接近于VLIW,因为这两种方法都依赖于编译器为处理器调度代码。由于随着发射宽度的增加,静态调度超标量的优势逐渐减小,因此静态调度超标量处理器主要用于窄发射宽度,通常只有两条指令。在这个宽度之外,大多数设计者选择实现VLIW或动态调度超标量。由于硬件和所需编译器技术的相似性,本节将重点讨论VLIW,并将在第七章再次看到它们。本节的见解也可以很容易地推广到静态调度超标量处理器。
图3.19总结了多发射的基本方法及其特征,并展示了使用每种方法的处理器。

图 3.19 五种主要的多发射处理器方法及其主要特征 本章专注于硬件密集型技术,这些技术都是某种形式的超标量架构。附录 H 则侧重于基于编译器的方法。EPIC(扩展指令级并行)方法,如 IA-64 架构所体现的,扩展了早期 VLIW 方法的许多概念,提供了静态和动态方法的结合。
### 基本的 VLIW 方法
VLIW(超长指令字)架构使用多个独立的功能单元。与其尝试向这些单元发出多个独立指令,不如将多个操作打包成一条非常长的指令,或者要求发出包中的指令满足相同的约束条件。由于这两种方法之间没有根本性的区别,我们假设将多个操作放入一条指令中,这与原始的 VLIW 方法一致。
随着最大发射速率的增加,VLIW 的优势也随之增强,因此我们关注更宽的发射处理器。实际上,对于简单的双发射处理器,超标量的开销可能是最小的。许多设计师可能会认为四发射处理器的开销是可控的,但正如我们在本章后面所看到的,开销的增长是限制更宽发射处理器的主要因素。
让我们考虑一个 VLIW 处理器,其指令包含五个操作,包括一个整数操作(也可以是分支)、两个浮点操作和两个内存引用。该指令将为每个功能单元设置一组字段——每个单元可能占用 16 到 24 位,从而生成指令长度在 80 到 120 位之间。相比之下,Intel Itanium 1 和 2 每个指令包包含六个操作(即,它们允许并行发射两个三指令束,如附录 H 所述)。
为了保持功能单元的高效运转,代码序列中必须有足够的并行性来填满可用的操作槽。这种并行性可以通过展开循环并在单个较大循环体内调度代码来揭示。如果展开生成直线代码,则可以使用局部调度技术,这些技术在单个基本块内操作。如果发现和利用并行性需要跨分支调度代码,则必须使用更复杂的全局调度算法。全局调度算法不仅在结构上更复杂,还必须处理更复杂的优化权衡,因为在分支之间移动代码的成本高昂。
在附录 H 中,我们将讨论跟踪调度,这是一种专门为 VLIW 设计的全局调度技术;我们还将探讨一些特殊硬件支持,以消除某些条件分支,从而扩展局部调度的有效性并增强全局调度的性能。现在,我们将依赖循环展开生成长的直线代码序列,以便使用局部调度构建 VLIW 指令,并关注这些处理器的运行效果。
例子:假设我们有一个VLIW处理器,它可以在每个时钟周期发出两个内存引用、两个浮点操作和一个整数操作或分支。请展示循环 \( x[i] = x[i] + s \) 的展开版本(参见第158页的RISC-V代码),适用于这样的处理器。根据需要进行多次展开,以消除任何停顿。
答案:图3.20展示了代码。该循环已展开为七个主体副本,这消除了所有停顿(即完全空闲的发射周期),并且展开和调度的循环在9个周期内完成。这段代码的运行速率为在9个周期内获得七个结果,或每个结果1.29个周期,几乎是使用展开和调度代码的2发射超标量设计的两倍速度。

图3.20 显示了占据内循环的VLIW指令,并替换了展开序列。假设分支预测正确,该代码需要9个周期。这种发射速率为在9个时钟周期内执行23个操作,或每个周期2.5个操作。效率,即包含操作的可用插槽的百分比,大约为60%。要达到这种发射速率,需要比RISC-V通常在此循环中使用的寄存器数量更多。前面的VLIW代码序列至少需要八个浮点寄存器,而同样的代码序列对于基础RISC-V处理器则可以使用少至两个浮点寄存器,或者在展开和调度时使用多达五个。
原始的 VLIW(超长指令字)模型面临技术和后勤方面的问题,这些问题使得该方法效率较低。技术问题主要包括代码大小的增加和锁步操作的局限性。两个不同的因素大幅增加了 VLIW 的代码大小。首先,在直线代码片段中生成足够的操作需要进行积极的循环展开(如早期示例所示),从而增加了代码大小。其次,每当指令没有填满时,未使用的功能单元会导致指令编码中产生浪费的位。在附录 H 中,我们将探讨软件调度方法,如软件流水线,这些方法能够在不显著扩大代码的情况下实现循环展开的好处。
为了应对代码大小的增加,有时会使用巧妙的编码方式。例如,可能仅有一个大型立即数字段可供任意功能单元使用。另一种技术是在主内存中压缩指令,并在它们被读取到缓存或解码时进行扩展。在附录 H 中,我们展示了其他技术,并记录了 IA-64 中出现的显著代码扩展。
早期的 VLIW 以锁步方式运行;根本没有任何风险检测硬件。这种结构要求任何功能单元管道的停顿必须导致整个处理器停顿,因为所有功能单元必须保持同步。尽管编译器可能能够调度确定性的功能单元以防止停顿,但预测哪些数据访问会遇到缓存停顿并进行调度是非常困难的。因此,缓存需要是阻塞的,导致所有功能单元都停顿。随着发射率和内存引用数量的增加,这种同步限制变得不可接受。在最近的处理器中,功能单元更独立地操作,编译器用于在发射时避免风险,而硬件检查则允许在指令发射后进行不同步执行。
二进制代码兼容性也是通用 VLIW 或运行第三方软件的一个主要后勤问题。在严格的 VLIW 方法中,代码序列利用了指令集定义和详细的流水线结构,包括功能单元及其延迟。因此,不同数量的功能单元和单元延迟需要不同版本的代码。这一要求使得在连续实现之间迁移,或在具有不同发射宽度的实现之间迁移,比超标量设计更为困难。当然,从新的超标量设计中获得更好的性能可能需要重新编译。然而,运行旧的二进制文件的能力是超标量方法的一个实际优势。在我们在第七章中研究的领域特定架构中,由于应用程序是针对特定架构配置编写的,因此这个问题并不严重。
EPIC 方法,IA-64 架构是其主要例子,提供了对早期通用 VLIW 设计中遇到的许多问题的解决方案,包括更激进的软件猜测的扩展以及在保持二进制兼容性的同时克服硬件依赖限制的方法。
所有多发射处理器面临的主要挑战是尝试利用大量的指令级并行性(ILP)。当并行性来自于展开浮点程序中的简单循环时,原始循环可能本可以在向量处理器上高效运行(将在下一章中介绍)。对于这类应用,多发射处理器是否优于向量处理器尚不明确;两者的成本相似,而且向量处理器通常速度相同或更快。多发射处理器相较于向量处理器的潜在优势在于前者能够从结构较少的代码中提取一些并行性,并能够轻松缓存所有形式的数据。基于这些原因,多发射方法已成为利用指令级并行性的主要手段,而向量处理器则主要成为这些处理器的扩展。


3.8 Exploiting ILP Using Dynamic Scheduling, Multiple Issue, and Speculation

到目前为止,我们已经看到了动态调度、多发射和推测执行的各个机制是如何工作的。在这一部分,我们将三者结合在一起,这产生了一种与现代微处理器非常相似的微架构。为了简化起见,我们只考虑每个时钟周期发射两条指令的情况,但这些概念与现代处理器发射三条或更多指令的情况并无不同。
假设我们希望扩展Tomasulo算法,以支持具有独立整数、加载/存储和浮点单元(包括浮点乘法和浮点加法)的多发射超标量流水线,每个单元可以在每个时钟周期发起一次操作。我们不希望以乱序方式将指令发射到保留站,因为这可能导致程序语义的违反。为了充分利用动态调度的优势,我们将允许流水线在一个时钟周期内发射任意组合的两条指令,并使用调度硬件实际将操作分配给整数和浮点单元。由于整数和浮点指令之间的相互作用至关重要,我们还扩展了Tomasulo方案,以处理整数和浮点功能单元及寄存器,同时纳入推测执行。如图3.21所示,基本组织与每个时钟周期发射一条指令的带推测的处理器相似,只是发射和完成逻辑必须增强,以允许每个时钟周期处理多条指令。

图3.21 显示了带有推测的多发射处理器的基本组织。在这种情况下,该组织可以同时发射一个浮点乘法、一个浮点加法、一个整数指令,以及一个加载/存储指令(假设每个功能单元每个时钟周期发射一条指令)。请注意,必须扩展几个数据通路以支持多发射:CDB(常数数据总线)、操作数总线,以及关键的指令发射逻辑,但在此图中没有显示。最后一点是一个复杂的问题,如我们在文本中讨论的那样。
在动态调度处理器中每个时钟周期发射多条指令(无论是否有推测)是非常复杂的,原因很简单:多条指令可能相互依赖。因此,必须并行更新这些指令的表格;否则,表格将不正确,或者依赖关系可能会丢失。
在动态调度处理器中发射多条指令的两种不同方法都依赖于一个观察,即关键在于分配保留站并更新流水线控制表。一种方法是在半个时钟周期内完成这个步骤,以便在一个时钟周期内处理两条指令;然而,这种方法无法轻松扩展以处理每个时钟周期四条指令。
第二种选择是构建处理两条或更多指令的逻辑,包括指令之间可能存在的任何依赖关系。现代超标量处理器在每个时钟周期发射四条或更多指令时,可能同时采用这两种方法:它们对发射逻辑进行流水线处理并加宽发射逻辑。一个关键的观察是,我们不能仅仅通过流水线来解决这个问题。由于每个时钟周期都有新指令发射,指令发射需要多个时钟周期,因此我们必须能够分配保留站并更新流水线表,以便在下一个时钟周期发射的依赖指令能够使用更新的信息。
这个发射步骤是动态调度超标量处理器中最基本的瓶颈之一。为了说明这一过程的复杂性,图3.22展示了一种情况的发射逻辑:发射一个加载指令后跟随一个依赖的浮点操作。该逻辑基于图3.18,但仅表示一种情况。在现代超标量处理器中,必须考虑在同一时钟周期内允许发射的所有可能的依赖指令组合。由于可以在一个时钟周期内发射的指令数量的平方会导致可能性急剧增加,因此发射步骤可能成为超越每个时钟周期四条指令尝试中的瓶颈。

**图3.22** 说明了一对依赖指令(称为指令1和指令2)的发射步骤,其中指令1是浮点加载,指令2是一个浮点操作,其第一个操作数是加载指令的结果;x1和x2是分配给这些指令的保留站;b1和b2是分配给重排序缓冲区(ROB)条目的。对于发射的指令,rd1和rd2是目标寄存器;rs1、rs2和rt2是源操作数(加载指令只有一个源);x1和x2是分配的保留站,b1和b2是分配的ROB条目。RS是保留站数据结构。RegisterStat是寄存器数据结构,Regs代表实际寄存器,ROB是重排序缓冲区数据结构。请注意,为了使这一逻辑正常运作,我们需要为这些操作分配重排序缓冲区条目,并且要记住,所有这些更新都是在一个时钟周期内并行发生的,而不是顺序进行的。
我们可以将图3.22的细节概括为描述在动态调度超标量处理器中,每个时钟周期最多可发射n条指令的基本策略,具体步骤如下:
1. **分配保留站和重排序缓冲区**:为可能在下一个发射包中发射的每条指令分配一个保留站和一个重排序缓冲区。这种分配可以在指令类型未知的情况下完成,通过顺序预分配重排序缓冲区条目给数据包中的指令,使用可用的n个重排序缓冲区条目,并确保有足够的保留站可用以发射整个束,无论其包含什么。通过限制指定类别指令的数量(例如,一个浮点指令、一个整数指令、一个加载指令和一个存储指令),可以预分配所需的保留站。如果没有足够的保留站可用(例如,当程序中的后续几条指令都是同一种指令类型时),则会打破该发射束,只发射一些指令,保持原始程序顺序。该束中的其余指令可以放入下一个束中以备潜在发射。
2. **分析指令之间的依赖关系**:检查发射束中所有指令之间的依赖关系。
3. **更新保留表**:如果束中的一条指令依赖于束中较早的指令,则使用分配的重排序缓冲区编号来更新该依赖指令的保留表。否则,使用现有的保留表和重排序缓冲区信息来更新发射指令的保留表条目。
当然,使前述过程复杂的是,这一切都是在单个时钟周期内并行完成的!
在流水线的后端,我们必须能够在每个时钟周期内完成并提交多条指令。这些步骤相较于发射问题稍微简单一些,因为实际上可以在同一个时钟周期提交的多条指令已经处理并解决了所有依赖关系。正如我们将看到的,设计师们已经找到了解决这种复杂性的方法:我们在第3.12节中讨论的Intel i7基本上采用了我们所描述的用于投机性多发射的方案,包括大量的保留站、重排序缓冲区,以及用来处理非阻塞缓存失效的加载和存储缓冲区。
从性能的角度来看,我们可以通过一个示例展示这些概念是如何结合在一起的。
示例:考虑在一个双发射处理器上执行以下循环,该循环对整数数组的每个元素进行自增操作,一次是在没有推测的情况下,另一次是在有推测的情况下:

假设有独立的整数功能单元用于有效地址计算、算术逻辑单元(ALU)操作和分支条件评估。为这两个处理器创建一个表,展示循环的前三个迭代。假设每个时钟周期最多可以提交两条任意类型的指令。
答案:图 3.23 和 3.24 显示了双发射、动态调度处理器在没有推测和有推测情况下的性能。在这种情况下,分支可能是关键的性能限制因素,而推测显著提高了性能。在推测处理器中,第三个分支在时钟周期13执行,而在非推测管道中则在时钟周期19执行。由于非推测管道的完成率迅速落后于发射率,因此在发射更多迭代时,非推测管道将会停滞。如果允许负载指令在决策分支之前完成有效地址计算,非推测处理器的性能可能有所改善,但除非允许推测内存访问,否则这种改进每次迭代只会提高1个时钟周期。

图 3.23 显示了没有推测的双发射管道中指令的发射、执行和写结果的时间。请注意,紧跟在分支不等(bne)后的加载指令无法提前开始执行,因为它必须等待分支结果的确定。这种类型的程序具有数据依赖的分支,无法提前解决,突显了推测的优势。独立的功能单元用于地址计算、ALU 操作和分支条件评估,使得多个指令能够在同一个周期内执行。图 3.24 则展示了使用推测的示例。

图 3.24 显示了带有推测的双发射管道中指令的发射、执行和写结果的时间。请注意,紧跟在分支不等(bne)后的加载指令可以提前开始执行,因为它是推测性的。
这个例子清楚地展示了在存在数据依赖分支时,推测如何带来优势,否则会限制性能。然而,这种优势依赖于准确的分支预测。错误的推测不仅不会提高性能,实际上通常会损害性能,并且如我们将看到的,会显著降低能效。


3.9 Advanced Techniques for Instruction Delivery and Speculation

在高性能管道中,尤其是多发射处理器,仅仅良好地预测分支是不够的;我们实际上需要能够提供高带宽的指令流。在近期的多发射处理器中,这意味着每个时钟周期需要交付4到8条指令。我们首先研究提高指令交付带宽的方法。然后,我们将关注实施高级推测技术的一系列关键问题,包括寄存器重命名与重排序缓冲区的使用、推测的激进程度,以及一种称为值预测的技术,该技术试图预测计算结果,并可能进一步增强指令级并行性(ILP)。
### 增加指令获取带宽
一个多发射处理器要求每个时钟周期平均获取的指令数量至少与平均吞吐量相等。当然,获取这些指令需要足够宽的路径通向指令缓存,但最困难的部分是处理分支。在这一节中,我们将探讨两种处理分支的方法,并讨论现代处理器如何集成指令预测和预取功能。
#### 分支目标缓冲区
为了减少简单五级流水线以及更深流水线的分支惩罚,我们必须知道尚未解码的指令是否为分支,如果是的话,下一个程序计数器(PC)应该是什么。如果指令是分支且我们知道下一个PC应该是什么,那么我们的分支惩罚可以降为零。存储分支后下一条指令预测地址的分支预测缓存称为分支目标缓冲区或分支目标缓存。图3.25展示了一个分支目标缓冲区。

图3.25 分支目标缓冲区 正在获取的指令的程序计数器(PC)与第一列中存储的一组指令地址进行匹配;这些地址表示已知分支的地址。如果PC与其中一个条目匹配,则正在获取的指令是一个被采取的分支,第二个字段“预测PC”包含了分支后下一条PC的预测。获取过程将立即从该地址开始。第三个字段是可选的,可以用于额外的预测状态位。
由于分支目标缓冲区预测下一条指令地址并会在解码指令之前发送该地址,我们必须知道获取的指令是否被预测为被采取的分支。如果获取的指令的PC与预测缓冲区中的地址匹配,则使用相应的预测PC作为下一个PC。这个分支目标缓冲区的硬件本质上与缓存的硬件相同。
如果在分支目标缓冲区中找到匹配条目,获取过程将立即从预测的PC开始。注意,与分支预测缓冲区不同,预测条目必须与这条指令匹配,因为预测的PC将在知道该指令是否为分支之前发送出去。如果处理器不检查条目是否与该PC匹配,那么错误的PC将被发送给非分支指令,从而导致性能下降。我们只需在分支目标缓冲区中存储被预测为采取的分支,因为未采取的分支应简单地获取下一条顺序指令,就像它不是分支一样。

图3.26展示了在简单五级流水线中使用分支目标缓冲区的步骤。从图中可以看出,如果在缓冲区中找到分支预测条目且预测正确,则不会出现分支延迟。否则,将会有至少两个时钟周期的惩罚。处理错误预测和未命中的问题是一个重大挑战,因为我们通常需要暂停指令获取,以便重新写入缓冲区条目。因此,我们希望尽快完成这个过程,以最小化惩罚。
为了评估分支目标缓冲区的工作效果,我们首先必须确定所有可能情况的惩罚时间。图3.27包含了简单五级流水线中这一信息。

图3.27展示了在仅存储已采取分支的情况下,分支是否在缓冲区及其实际效果的所有可能组合所带来的惩罚。如果一切预测正确并且分支在目标缓冲区中,则没有分支惩罚。如果分支预测不正确,则惩罚为1个时钟周期,用于更新缓冲区以获取正确信息(在此期间无法提取指令),如果需要,还需1个时钟周期重新提取下一个正确的分支指令。如果分支未找到且被采取,则会遇到2个时钟周期的惩罚,在此期间缓冲区将进行更新。
### 示例
确定分支目标缓冲区的总分支惩罚,假设图3.27中每个错误预测的惩罚周期。我们做以下关于预测准确性和命中率的假设:
- **预测准确率**:90%(对于缓冲区中的指令)。
- **缓冲区的命中率**:90%(对于预测为“采取”的分支)。
### 答案
我们通过考虑两个事件的概率来计算惩罚:分支被预测为“采取”但最终并未被采取,以及分支被实际采取但在缓冲区中未找到。两者都带有两个周期的惩罚。

动态分支预测的改进将随着流水线长度的增加而增长,因此分支延迟也会增加;此外,更好的预测器将带来更大的性能优势。现代高性能处理器的分支误预测延迟大约为15个时钟周期;显然,准确的预测至关重要!
分支目标缓冲区的一种变体是存储一个或多个目标指令,而不是或除了预测的目标地址。这种变体有两个潜在的优点。首先,它允许分支目标缓冲区的访问时间超过连续指令提取之间的时间,从而可能允许更大的分支目标缓冲区。其次,缓冲实际的目标指令使我们能够执行一种称为分支折叠的优化。分支折叠可以用于实现0周期的无条件分支,有时也能实现0周期的条件分支。正如我们将看到的,Cortex A-53使用一个单入口的分支目标缓存来存储预测的目标指令。
考虑一个从预测路径中缓冲指令的分支目标缓冲区,并且正在使用无条件分支的地址进行访问。无条件分支的唯一功能是改变程序计数器(PC)。因此,当分支目标缓冲区信号命中并指示该分支是无条件时,流水线可以简单地用分支目标缓冲区中的指令替换从缓存返回的指令(即无条件分支)。如果处理器每个周期发出多条指令,则缓冲区需要提供多条指令以获得最大收益。在某些情况下,可能有可能消除条件分支的成本。
### 专用分支预测器:预测过程返回、间接跳转和循环分支
随着我们努力提高推测的机会和准确性,我们面临着预测间接跳转的挑战,即目标地址在运行时变化的跳转。高级语言程序会为间接过程调用、选择语句或 FORTRAN 计算的 goto 生成这样的跳转,尽管许多间接跳转实际上来自过程返回。例如,在 SPEC95 基准测试中,过程返回占分支的15%以上,并且在平均情况下占绝大多数间接跳转。对于 C++ 和 Java 等面向对象语言,过程返回更加频繁。因此,专注于过程返回似乎是合适的。
虽然可以通过分支目标缓冲区来预测过程返回,但如果过程是从多个位置调用的,并且来自某个位置的调用没有在时间上聚集,这种预测技术的准确性可能较低。例如,在 SPEC CPU95 中,一个激进的分支预测器对这些返回分支的准确率低于60%。为了克服这个问题,一些设计使用一个小型的返回地址缓冲区,作为堆栈来操作。该结构缓存最近的返回地址,在调用时将返回地址推入堆栈,在返回时弹出一个返回地址。如果缓存足够大(即最大调用深度),它将完美地预测返回。图3.28展示了在多个 SPEC CPU95 基准测试中,具有0至16个元素的返回缓冲区的性能。当我们在第3.10节研究ILP时,将使用类似的返回预测器。英特尔 Core 处理器和 AMD Phenom 处理器都具有返回地址预测器。

图3.28展示了在多个SPEC CPU95基准测试中,作为堆栈操作的返回地址缓冲区的预测准确性。准确性是指正确预测的返回地址所占的比例。0个条目的缓冲区意味着使用标准的分支预测。由于调用深度通常不大(尽管有一些例外),因此一个适中的缓冲区效果良好。这些数据来自Skadron等人(1999年),并使用修复机制以防止缓存的返回地址被破坏。
在大型服务器应用中,间接跳转也发生在各种函数调用和控制转移中。预测这种分支的目标并不如过程返回那样简单。一些处理器选择为所有间接跳转添加专用预测器,而其他处理器则依赖于分支目标缓冲区。
尽管像 gshare 这样的简单预测器在预测许多条件分支方面表现良好,但它并不针对循环分支,特别是对于长时间运行的循环。如前所述,英特尔 Core i7 920 使用了专用的循环分支预测器。随着带有标签的混合预测器的出现,它们在预测循环分支方面同样表现出色,一些近期的设计者选择将资源投入到更大的带标签的混合预测器中,而不是单独的循环分支预测器。
### 集成指令获取单元
为了满足多发射处理器的需求,许多近期的设计者选择将集成指令获取单元实现为一个独立的自主单元,以向管道的其余部分提供指令。本质上,这表明将指令获取简单地视为一个单一的管道阶段已不再有效,因为多发射的复杂性使得这种看法不再成立。
相反,近期的设计采用了集成指令获取单元,该单元整合了多个功能:
1. **集成分支预测**—分支预测器成为指令获取单元的一部分,并不断预测分支,以驱动获取管道。
2. **指令预取**—为了每个时钟周期交付多条指令,指令获取单元可能需要提前获取。该单元自主管理指令的预取(参见第2章讨论的相关技术),并将其与分支预测集成。
3. **指令内存访问与缓冲**—在每个周期获取多条指令时,会遇到各种复杂性,包括获取多条指令可能需要访问多个缓存行的困难。指令获取单元封装了这些复杂性,利用预取来尽量隐藏跨越缓存块的成本。指令获取单元还提供缓冲,基本上充当一个按需单元,根据需要和所需数量向发射阶段提供指令。
几乎所有高端处理器现在都使用与管道其余部分通过包含待处理指令的缓冲区连接的独立指令获取单元。
### 预测:实现问题与扩展
在本节中,我们探讨了涉及多发射和投机设计权衡与挑战的五个问题,首先讨论寄存器重命名的使用,这是一种有时用来替代重排序缓冲区(ROB)的方法。接着,我们讨论了对控制流投机的一种重要可能扩展:一种称为值预测的想法。
#### 预测支持:寄存器重命名与重排序缓冲区
使用重排序缓冲区(ROB)的一个替代方案是显式使用更大的物理寄存器集合,并结合寄存器重命名。这种方法建立在Tomasulo算法中使用的重命名概念之上,并对其进行了扩展。在Tomasulo算法中,任何时刻可见的架构寄存器(x0, …, r31 和 f0, …, f31)的值都包含在寄存器集和保留站的某种组合中。随着投机的引入,寄存器值也可以暂时驻留在ROB中。在这两种情况下,如果处理器在一段时间内没有发出新指令,所有现有指令将会提交,寄存器值将出现在寄存器文件中,这直接对应于可见的架构寄存器。
在寄存器重命名的方法中,使用扩展的物理寄存器集来保存可见的架构寄存器以及临时值。因此,扩展寄存器替代了大部分ROB和保留站的功能;只需要一个队列来确保指令按顺序完成。在指令发射期间,重命名过程将架构寄存器的名称映射到扩展寄存器集中的物理寄存器编号,为目标分配一个新的未使用寄存器。通过对目标寄存器进行重命名,避免了写后读(WAW)和写后写(WAR)危害,投机恢复也得以处理,因为持有指令目标的物理寄存器在指令提交之前不会变成架构寄存器。
在寄存器重命名的设计中,重命名映射表是一个简单的数据结构,它提供当前对应于特定架构寄存器的物理寄存器编号。这一功能类似于Tomasulo算法中的寄存器状态表。当指令提交时,重命名表会被永久更新,以指示某个物理寄存器对应于实际的架构寄存器,从而有效地完成处理器状态的更新。
尽管使用寄存器重命名的方法不需要重排序缓冲区(ROB),硬件仍然必须以队列的方式跟踪指令,并按严格顺序更新重命名表。与ROB方法相比,寄存器重命名的一个优势在于指令提交稍微简化,因为它只需要两个简单的操作:(1) 记录架构寄存器编号和物理寄存器编号之间的映射不再是投机性的;(2) 释放任何用于保存“旧”架构寄存器值的物理寄存器。
在带有保留站的设计中,当使用保留站的指令完成执行时,该站会被释放;而ROB条目则在相应指令提交时释放。使用寄存器重命名时,释放寄存器的过程更加复杂,因为在释放物理寄存器之前,必须确认它不再对应于任何架构寄存器,并且没有未完成的对该物理寄存器的引用。
一个物理寄存器直到其对应的架构寄存器被重写之前,都将保持对应关系,这会导致重命名表指向其他位置。因此,如果没有重命名条目指向特定的物理寄存器,则该寄存器不再对应于任何架构寄存器。然而,该物理寄存器可能仍存在未完成的使用情况。处理器可以通过检查功能单元队列中所有指令的源寄存器说明符来判断是否存在这种情况。如果某个物理寄存器没有出现在源中,并且未被指定为架构寄存器,则可以回收并重新分配。
作为另一种选择,处理器可以等到另一个写入相同架构寄存器的指令提交。在那时,旧值的进一步使用就不再可能。尽管这种方法可能会稍微延长物理寄存器的占用时间,但实现简单,在最近的大多数超标量处理器中得到了应用。
一个你可能会问的问题是,如果架构寄存器不断变化,我们如何知道哪些寄存器是架构寄存器?在程序执行的大多数时候,这并不重要。然而,确实存在一些情况,例如操作系统等其他进程,必须准确知道某个架构寄存器的内容位于何处。为了理解这一能力是如何提供的,假设处理器在一段时间内不发出指令。最终,流水线中的所有指令都会提交,架构可见寄存器与物理寄存器之间的映射将变得稳定。此时,一部分物理寄存器包含架构可见寄存器,而任何未与架构寄存器关联的物理寄存器的值都是不需要的。因此,将架构寄存器移动到固定的物理寄存器子集中,以便将这些值传递给另一个进程,就变得很简单。
寄存器重命名和重排序缓冲区仍然在高端处理器中使用,这些处理器现在能够同时处理多达100条或更多的指令(包括在缓存中等待的加载和存储)。无论是使用重命名还是重排序缓冲区,对于动态调度的超标量处理器来说,关键的复杂性瓶颈依然是发出内部存在依赖关系的指令束。特别是,指令束中的依赖指令必须与其所依赖的指令的虚拟寄存器一起发出。可以采用类似于多重发射与重排序缓冲区的指令发射策略,具体步骤如下:
1. 发射逻辑为整个发射束保留足够的物理寄存器(例如,对于一个最多有四个指令的束,保留四个寄存器,每条指令最多一个寄存器结果)。
2. 发射逻辑确定束内存在的依赖关系。如果在束内不存在依赖关系,则使用寄存器重命名结构来确定持有或将要持有依赖指令结果的物理寄存器。当束内没有依赖关系时,结果来自于早期的发射束,寄存器重命名表将具有正确的寄存器编号。
3. 如果一条指令依赖于束中较早的指令,则使用预先保留的物理寄存器来更新发射指令的信息。
请注意,和重排序缓冲区的情况一样,发射逻辑必须在一个时钟周期内确定束内的依赖关系并更新重命名表,而对于每个时钟周期处理更多指令的复杂性会成为发射宽度的主要限制因素。
### 每个时钟周期更多发射的挑战
在没有推测的情况下,几乎没有动力去尝试将发射速率提高到每个时钟超过两个、三个或可能四个发射,因为解决分支会将平均发射速率限制在较小的数字。一旦处理器具备准确的分支预测和推测能力,我们可能会得出提高发射速率是有吸引力的结论。假设硅的容量和功耗允许,复制功能单元是简单的;真正的复杂性出现在发射阶段及其对应的提交阶段。提交阶段是发射阶段的对偶,要求类似,因此让我们看看使用寄存器重命名的六发射处理器需要发生什么。
图3.29展示了一段六条指令的代码序列以及发射步骤必须完成的工作。请记住,如果处理器要维持每个时钟六个发射的峰值速率,这一切都必须在一个时钟周期内完成!所有依赖关系必须被检测,物理寄存器必须被分配,指令必须使用物理寄存器编号进行重写:这一切都在一个时钟周期内完成。这个例子清楚地表明了为什么在过去20年中,发射速率从3-4增长到仅仅4-8。发射周期中所需的分析复杂性随发射宽度的平方增长,而新处理器的目标通常是比上一代具有更高的时钟频率!由于寄存器重命名和重排序缓冲区方法是对偶关系,因此无论实现方案如何,相同的复杂性都会出现。

### 图3.29:六条指令的发射示例
图3.29展示了六条指令在同一个时钟周期内发射的示例,以及需要发生的事情。这些指令按程序顺序排列:1–6;然而,它们是在一个时钟周期内发射的!使用符号 \( p_i \) 来表示物理寄存器;该寄存器在任何时刻的内容由重命名映射确定。为了简化,我们假设最初持有架构寄存器 \( x1, x2, \) 和 \( x3 \) 的物理寄存器分别是 \( p1, p2, \) 和 \( p3 \)(它们可以是任何物理寄存器)。指令用物理寄存器编号发射,如第四列所示。最后一列的重命名映射显示如果指令顺序发射,映射将如何变化。困难在于,所有这些重命名和用物理重命名寄存器替换架构寄存器的操作实际上是在一个周期内完成的,而不是顺序进行。发射逻辑必须同时找到所有依赖关系并“重写”指令。
### 多少程度的投机
投机的一个显著优势是它能够提前发现可能会导致管道停滞的事件,例如缓存未命中。然而,这种潜在的优势也伴随着显著的潜在劣势。投机并不是免费的,它需要时间和能量,而且恢复错误的投机会进一步降低性能。此外,为了支持更高的指令执行率,以便从投机中获益,处理器必须拥有额外的资源,这会占用硅面积和功耗。最后,如果投机导致发生异常事件,例如缓存或翻译后备缓冲(TLB)未命中,如果该事件在没有投机的情况下不会发生,那么性能损失的潜在风险会增加。
为了在最大程度上保持优势的同时最小化劣势,大多数具有投机的管道只允许处理低成本的异常事件(例如一级缓存未命中)以投机模式进行处理。如果发生昂贵的异常事件,例如二级缓存未命中或TLB未命中,处理器将在导致事件的指令不再是投机性之前,等待处理该事件。虽然这可能会稍微降低某些程序的性能,但可以避免在其他程序中出现显著的性能损失,尤其是那些经历频繁高成本事件且分支预测效果不佳的程序。
在1990年代,投机的潜在缺点并不那么明显。随着处理器的发展,投机的真实成本变得更加明显,更广泛发射和投机的局限性也显而易见。我们将很快回到这个问题。
### 同时通过多个分支进行投机
在本章中我们讨论的例子中,通常可以在需要对另一个分支进行投机之前解决当前的分支。然而,有三种情况可以从同时对多个分支进行投机中受益:(1)非常高的分支频率,(2)分支的显著聚集,以及(3)功能单元的长延迟。在前两种情况下,实现高性能可能意味着需要对多个分支进行投机,甚至可能需要在每个时钟周期内处理多个分支。数据库程序和其他结构较少的整数计算通常具备这些特性,使得对多个分支进行投机变得重要。同样,功能单元的长延迟也提高了对多个分支进行投机的必要性,以避免因更长管道延迟而造成的停滞。
对多个分支进行投机稍微复杂化了投机恢复的过程,但在其他方面相对简单。截至2017年,尚无处理器能够将全面投机与每个周期解析多个分支相结合,而从性能、复杂性和功耗的角度来看,这样做的成本不太可能得到合理化。
### 投机与能效挑战
投机对能效的影响是什么?乍一看,似乎可以认为使用投机总是会降低能效,因为每当投机失败时,会以两种方式消耗过量的能量:
1. **不必要的指令执行**:被投机的指令及其结果未被需要,这会导致处理器额外的工作,从而浪费能量。
2. **恢复状态**:撤销投机并恢复处理器状态以继续在适当地址执行所需的额外能量,这在没有投机的情况下是无需的。
毫无疑问,投机会增加功耗,如果我们能够控制投机,就可以测量其成本(或至少动态功耗成本)。但是,如果投机所降低的执行时间超过了其增加的平均功耗,那么整体能耗可能会减少。
因此,要理解投机对能效的影响,我们需要关注投机导致的不必要工作的频率。如果执行了大量不需要的指令,那么投机不太可能以相应的程度改善运行时间。图3.30显示了使用复杂分支预测器的SPEC2000基准测试中,因错误投机而执行的指令比例。可以看到,在科学代码中,这一比例较小,而在整数代码中则显著(平均约30%)。因此,对于整数应用而言,投机不太可能是能效高的,而且Dennard缩放的结束使得不完美的投机问题更加突出。
设计者可以选择避免投机,尝试减少错误投机的发生频率,或考虑新方法,例如仅对已知高度可预测的分支进行投机。

### 地址别名预测
地址别名预测是一种技术,用于预测两个存储操作或一个加载操作与一个存储操作是否指向相同的内存地址。如果这两个引用不指向相同的地址,那么它们可以安全地互换。否则,我们必须等待被指令访问的内存地址确定后再进行处理。由于我们实际上不需要预测地址值,只需判断这些值是否存在冲突,因此使用小型预测器时,预测可以相当准确。地址预测依赖于投机处理器在误预测后恢复的能力;也就是说,如果实际的地址被预测为不同(因此不属于同一别名),但结果却是相同(因此属于同一别名),处理器只需重新开始执行序列,就像它误预测了一个分支一样。
地址值投机已经在几款处理器中得到应用,并可能在未来变得普遍。地址预测是一种简单且受限的值预测形式,试图预测指令将产生的值。如果值预测能够高度准确,它可以消除数据流限制,从而实现更高的指令级并行性(ILP)。尽管过去15年中,许多研究者在数十篇论文中专注于值预测,但结果从未足够吸引人,以至于在实际处理器中推广一般值预测。


3.10 Cross-Cutting Issues

### 硬件与软件投机
本章中的硬件密集型投机方法和附录H中的软件方法为利用指令级并行性(ILP)提供了替代方案。以下是这些方法的一些权衡和局限性:
- **内存引用的消歧义**:要进行广泛的投机,我们必须能够消歧义内存引用。这一能力在包含指针的整数程序编译时很难实现。在基于硬件的方案中,使用我们之前看到的Tomasulo算法的技术进行动态运行时内存地址消歧义。这种消歧义允许我们在运行时将加载操作移动到存储操作之后。对投机内存引用的支持可以帮助克服编译器的保守性,但如果不谨慎应用,这些恢复机制的开销可能会抵消其优势。
- **控制流不可预测性**:硬件投机在控制流不可预测且硬件分支预测优于编译时软件分支预测时效果更好。这种特性在许多整数程序中成立,其中动态预测器的错误预测率通常低于静态预测器的一半。由于当预测不正确时,投机指令可能会减慢计算,因此这一差异是显著的。这个差异的一个结果是,即使是静态调度的处理器通常也会包含动态分支预测器。
- **精确异常模型**:硬件投机即使对于投机指令也能维护完全精确的异常模型。最近的软件基础方法也添加了特殊支持以实现这一点。
- **无需补偿或记账代码**:硬件投机不需要雄心勃勃的软件投机机制所需的补偿或记账代码。
- **编译器驱动的调度**:基于编译器的方法可能受益于对代码序列的更深入分析,从而实现比纯硬件驱动方法更好的代码调度。
- **架构实现的灵活性**:具有动态调度的硬件投机不需要不同的代码序列来为架构的不同实现获得良好的性能。尽管这一优势最难量化,但从长远来看可能是最重要的。有趣的是,这也是IBM 360/91的一大动力。另一方面,更现代的显式并行架构,如IA-64,增加了降低代码序列中固有硬件依赖性的灵活性。
支持硬件投机的主要缺点是所需的复杂性和额外的硬件资源。这种硬件成本必须与软件基础方法的编译器复杂性以及依赖于此类编译器的处理器的简化程度和实用性进行评估。
一些设计者尝试结合动态和基于编译器的方法,以达到各自的最佳效果。这种组合可能会产生有趣而微妙的交互。例如,如果将条件移动与寄存器重命名结合起来,就会出现微妙的副作用。被取消的条件移动仍然必须将值复制到目标寄存器,因为它在指令流水线中已被重命名。这些微妙的交互增加了设计和验证过程的复杂性,同时也可能降低性能。
Intel Itanium处理器是基于ILP和投机的软件支持设计的最雄心勃勃的计算机。然而,它没有实现设计者的期望,尤其是在通用非科学代码方面。随着设计者在第244页描述的困难下对利用ILP的雄心降低,大多数架构选择了基于硬件的机制,指令发出率为每个时钟周期三到四条指令。
### 投机执行与内存系统
支持投机执行或条件指令的处理器固有地存在生成无效地址的可能性,这种情况在没有投机执行的情况下是不会发生的。如果发生保护异常,这将是错误的行为,并且投机执行的好处也会被虚假异常的开销所淹没。因此,内存系统必须识别投机执行和条件执行的指令,并抑制相应的异常。
同样,我们不能允许这些指令在缓存未命中时导致停顿,因为不必要的停顿可能会掩盖投机的好处。因此,这些处理器必须与非阻塞缓存相匹配。
实际上,未命中并需要访问DRAM的惩罚非常大,因此投机未命中仅在下一层是片上缓存(L2或L3)时处理。第84页的图2.5显示,对于一些表现良好的科学程序,编译器可以维持多个未决的L2未命中,以有效降低L2未命中的惩罚。再次强调,为了使这一点有效,缓存背后的内存系统必须在同时内存访问的数量上与编译器的目标相匹配。


3.11 Multithreading: Exploiting Thread-Level Parallelism to Improve Uniprocessor Throughput

本节讨论的主题是多线程,它确实是一个交叉主题,因为它与流水线、超标量架构、图形处理单元(第4章)和多处理器(第5章)都有关系。线程类似于进程,因为它有状态和当前程序计数器,但线程通常共享单个进程的地址空间,从而使得一个线程能够轻松访问同一进程中其他线程的数据。多线程是一种技术,允许多个线程共享一个处理器,而不需要进行过程切换。快速在线程之间切换的能力使得多线程能够用来隐藏流水线和内存延迟。
在下一章中,我们将看到多线程如何在GPU中提供相同的优势。最后,第5章将探讨多线程与多处理器的结合。这些主题紧密相连,因为多线程是向硬件暴露更多并行性的一种主要技术。从严格意义上讲,多线程使用线程级并行性,因此它应当被归入第5章的内容,但它在提高流水线利用率和GPU中的作用促使我们在此介绍这一概念。
尽管通过使用指令级并行性(ILP)来提高性能有着对于程序员相对透明的巨大优势,但正如我们所看到的,ILP在某些应用中可能非常有限或难以利用。特别是在合理的指令发射速率下,缓存未命中导致的内存访问或外部缓存的延迟不太可能被可用的ILP所隐藏。当然,当处理器因等待缓存未命中而停顿时,功能单元的利用率会显著下降。
由于用更多的ILP来掩盖长时间的内存停顿效果有限,因此自然而然地就会问,应用中的其他形式的并行性是否可以用来隐藏内存延迟。例如,在线事务处理系统在多个查询和更新请求之间具有天然的并行性。当然,许多科学应用也包含天然的并行性,因为它们常常模拟自然界的三维并行结构,这种结构可以通过使用独立的线程来利用。即便是使用现代Windows操作系统的桌面应用程序,通常也会同时运行多个活动应用,从而提供并行性的来源。
多线程允许多个线程以重叠的方式共享单个处理器的功能单元。相比之下,利用线程级并行性(TLP)的更一般的方法是使用具有多个独立线程同时并行工作的多处理器。然而,与多处理器不同,多线程并不会复制整个处理器。相反,多线程在一组线程之间共享处理器核心的大部分,只复制私有状态,如寄存器和程序计数器。正如我们将在第5章看到的,许多最近的处理器在单个芯片上集成了多个处理器核心,并在每个核心内提供多线程功能。
复制处理器核心的每个线程状态意味着为每个线程创建一个独立的寄存器文件和一个独立的程序计数器(PC)。内存本身可以通过虚拟内存机制共享,这些机制已经支持多道程序设计。此外,硬件必须支持相对快速地切换到不同线程的能力;特别是,线程切换应该比进程切换高效得多,后者通常需要数百到数千个处理器周期。当然,为了实现性能提升,多线程硬件所需的程序必须包含多个可以并发执行的线程(我们有时称应用程序为多线程)。这些线程由编译器(通常来自具有并行构造的语言)或程序员识别。
多线程的主要硬件方法有三种:细粒度、多粒度和同时多线程。细粒度多线程在每个时钟周期之间切换线程,导致多个线程的指令执行交错。这种交错通常采用轮询方式进行,跳过任何当时处于阻塞状态的线程。细粒度多线程的一个关键优点是它可以隐藏由短时间和长时间停顿引起的吞吐量损失,因为在一个线程停顿时,可以执行其他线程的指令,即使停顿仅持续几个周期。细粒度多线程的主要缺点是它会减慢单个线程的执行,因为准备好执行而没有停顿的线程会受到其他线程指令的延迟。它以提高多线程吞吐量为代价,牺牲了单个线程的性能(按延迟衡量)。
SPARC T1 到 T5 处理器(最初由Sun制造,现在由Oracle和Fujitsu制造)使用细粒度多线程。这些处理器针对事务处理和网络服务等多线程工作负载。T1 支持每个处理器8个核心和每个核心4个线程,而T5支持16个核心和每个核心128个线程。后来的版本(T2–T5)还支持4到8个处理器。我们在下一章中讨论的NVIDIA GPU也利用了细粒度多线程。
粗粒度多线程作为细粒度多线程的替代方案被发明。粗粒度多线程仅在高成本停顿(如二级或三级缓存未命中)时切换线程。由于只有在线程遇到高成本停顿时才会发出其他线程的指令,粗粒度多线程减轻了线程切换几乎是免费的需求,并且更不容易减慢任何一个线程的执行。
粗粒度多线程存在一个主要缺点:它在克服吞吐量损失方面的能力有限,尤其是对于较短的停顿。这一限制源于粗粒度多线程的流水线启动成本。因为配备粗粒度多线程的处理器从单个线程发出指令,当发生停顿时,流水线会出现气泡,然后新线程才开始执行。由于这种启动开销,粗粒度多线程在减少高成本停顿的惩罚方面更为有效,在这种情况下,流水线的重填时间与停顿时间相比可以忽略不计。一些研究项目探讨了粗粒度多线程,但目前没有主要的处理器使用这种技术。
最常见的多线程实现被称为同时多线程(SMT)。同时多线程是在多个发射、动态调度处理器上实现细粒度多线程的自然变体。与其他形式的多线程一样,SMT利用线程级并行性来隐藏处理器中的长延迟事件,从而增加功能单元的使用率。SMT的关键在于寄存器重命名和动态调度允许来自独立线程的多个指令得以执行,而不考虑它们之间的依赖关系;这些依赖关系的解决可以通过动态调度能力来处理。

图3.31展示了四种不同方法如何使用超标量处理器的功能单元执行槽。横轴表示每个时钟周期的指令执行能力,纵轴表示时钟周期的序列。空白(白色)框表示在该时钟周期中相应的执行槽未被使用。灰色和黑色的阴影对应于多线程处理器中的四个不同线程。在没有多线程支持的超标量情况下,黑色也用于表示被占用的发射槽。Sun T1 和 T2(又称 Niagara)处理器是细粒度多线程处理器,而 Intel Core i7 和 IBM Power7 处理器则使用同时多线程(SMT)。T2 拥有 8 个线程,Power7 拥有 4 个线程,Intel i7 拥有 2 个线程。在所有现有的 SMT 中,指令一次只从一个线程发出。SMT 的不同之处在于,后续执行指令的决策是解耦的,可以在同一时钟周期内执行来自多个不同指令的操作。
图3.31概念性地展示了不同处理器配置中处理器利用超标量资源的能力的差异:
- 不支持多线程的超标量
- 具有粗粒度多线程的超标量
- 具有细粒度多线程的超标量
- 具有同时多线程的超标量
在不支持多线程的超标量中,发射槽的使用受到缺乏指令级并行性(ILP)的限制,包括隐藏内存延迟的ILP。由于L2和L3缓存未命中的时间较长,处理器的许多部分可能会处于闲置状态。
在具有粗粒度多线程的超标量中,通过切换到另一个使用处理器资源的线程来部分隐藏长时间停顿。这种切换减少了完全闲置的时钟周期数量。然而,在粗粒度多线程处理器中,线程切换仅在发生停顿时进行。由于新线程有一个启动期,因此仍然可能会存在一些完全闲置的周期。
在细粒度的情况下,线程的交错可以消除完全空闲的槽位。此外,由于每个时钟周期发射的线程不同,较长的延迟操作可以被隐藏。由于指令发射和执行是相互连接的,一个线程只能发射已准备好的指令。在发射宽度较窄的情况下,这并不是问题(一个周期要么被占用,要么不被占用),这就是为什么细粒度多线程对于单发射处理器非常有效,而同时多线程(SMT)则没有意义。实际上,在 Sun T2 中,每个时钟周期有两个发射,但它们来自不同的线程。这消除了实现复杂动态调度方法的需要,而是依赖于通过更多线程来隐藏延迟。
如果在多发射、动态调度的处理器上实现细粒度多线程,结果就是 SMT。在所有现有的 SMT 实现中,所有发射都来自同一个线程,尽管来自不同线程的指令可以在同一周期内启动执行,利用动态调度硬件来确定哪些指令已准备好。尽管图3.31大大简化了这些处理器的实际操作,但它确实展示了多线程的一般性能优势以及在更宽发射、动态调度处理器中 SMT 的潜力。
同时多线程利用了一个见解,即动态调度的处理器已经具备支持该机制所需的许多硬件机制,包括一个大的虚拟寄存器集。可以通过在乱序处理器上添加每个线程的重命名表、保持独立的程序计数器(PC)以及提供来自多个线程的指令提交能力,来构建多线程。
**同时多线程在超标量处理器上的有效性**
一个关键问题是,通过实现 SMT 可以获得多少性能提升?当这个问题在 2000-2001 年被探讨时,研究人员假设动态超标量处理器在接下来的五年会变得更宽,支持每个时钟六到八个发射,具备投机动态调度、许多同时加载和存储、大容量主缓存,以及四到八个上下文的同时发射和退役。然而,没有任何处理器接近这种组合。
因此,模拟研究结果显示多程序工作负载的性能提升为两倍或更多是不现实的。实际上,现有的 SMT 实现提供的上下文只有两个到四个,且仅从一个上下文中获取和发射指令,最多每个时钟四个发射。因此,SMT 带来的性能提升也更加温和。
Esmaeilzadeh 等人(2011)进行了一系列广泛而深刻的测量,考察了在单个 i7 920 核心上运行的一组多线程应用程序中使用 SMT 的性能和能量收益。Intel i7 920 支持每个核心两个线程的 SMT,这一特性在最近的 i7 6700 中也得到了保留。i7 920 和 6700 之间的变化相对较小,不太可能显著改变本节所示的结果。
使用的基准测试包括一系列并行科学应用和来自 DaCapo 和 SPEC Java 套件的多线程 Java 程序,如图 3.32 所总结。图 3.31 显示了这些基准在关闭和开启 SMT 时,在 i7 920 的一个核心上运行的性能和能效比率。(我们绘制能效比,也就是能耗的倒数,因此,与加速比一样,更高的比率更好。)

图 3.32 显示了用于检查多线程的并行基准测试,以及在第 5 章中用于检查与 i7 的多处理的基准。图表的上半部分由 Bienia 等人(2008)收集的 PARSEC 基准组成。PARSEC 基准旨在代表适合多核处理器的计算密集型并行应用程序。下半部分包含来自 DaCapo 集合的多线程 Java 基准(见 Blackburn 等,2006)和来自 SPEC 的 pjbb2005。这些基准都包含一定的并行性;在 DaCapo 和 SPEC Java 工作负载中的其他 Java 基准使用多个线程,但几乎没有真正的并行性,因此在这里不予使用。有关这些基准特性及其与此处和第 5 章测量的更多信息,请参见 Esmaeilzadeh 等人(2011)。
Java 基准的加速比的调和平均值为 1.28,尽管有两个基准的增益较小。这两个基准 pjbb2005 和 tradebeans 虽然是多线程的,但其并行性有限。它们被纳入考虑,因为它们是典型的可以在 SMT 处理器上运行的多线程基准,希望能够提取一些性能,但实际获得的提升有限。PARSEC 基准的加速比略优于完整的 Java 基准(调和平均值为 1.31)。如果去掉 tradebeans 和 pjbb2005,Java 工作负载的加速比实际上会显著提高(1.39),超过 PARSEC 基准。(请参见图 3.33 标注中关于使用调和平均总结结果的讨论。)
能耗由加速比和功耗增加的组合决定。对于 Java 基准,平均而言,SMT 提供的能效与非 SMT 相同(平均值为 1.0),但受到两个表现不佳的基准的影响;如果不考虑 pjbb2005 和 tradebeans,Java 基准的平均能效为 1.06,几乎与 PARSEC 基准相当。在 PARSEC 基准中,SMT 将能耗减少了约 7%(1/1.08 的四次方)。这种节能性能提升非常难以获得。当然,与 SMT 相关的静态功耗在这两种情况下都是存在的,因此结果可能稍微夸大了能量收益。
这些结果清楚地表明,在一个具有广泛支持的激进投机处理器中,SMT 可以以节能的方式提升性能。2011 年,提供多个简单核心与较少复杂核心之间的平衡向更多核心倾斜,每个核心通常是一个三到四发射的超标量核心,并支持两个到四个线程的 SMT。事实上,Esmaeilzadeh 等人(2011)显示,在 Intel i5(与 i7 类似,但缓存较小、时钟频率较低的处理器)和 Intel Atom(原本为上网本和 PMD 市场设计的 80 x 86 处理器,现在专注于低端 PC,如第 3.13 节所述)上,SMT 带来的能量提升甚至更大。


3.12 Putting It All Together: The Intel Core i7 6700 and ARM Cortex-A53

在本节中,我们探讨两种多发射处理器的设计:ARM Cortex-A53 核心,该核心作为多个平板电脑和手机的基础,以及 Intel Core i7 6700,这是一款高端的动态调度、推测执行处理器,旨在用于高端桌面和服务器应用。我们将从更简单的处理器开始。

图 3.33 显示了在 i7 处理器上使用单核多线程的加速效果,对于 Java 基准,其平均加速比为 1.28,而 PARSEC 基准的平均加速比为 1.31(使用未加权的调和平均,意味着在单线程基准集中执行每个基准的总时间相同)。能量效率分别平均为 0.99 和 1.07(使用调和平均)。请注意,能量效率超过 1.0 表示该特性减少的执行时间大于其增加的平均功耗。有两个 Java 基准几乎没有加速,并且由于这个问题,它们的能量效率显著为负。所有情况下都关闭了 Turbo Boost。这些数据由 Esmaeilzadeh 等人(2011)使用 Oracle(Sun)HotSpot build 16.3-b01 Java 1.6.0 虚拟机和 gcc v4.4.1 原生编译器收集和分析。
### ARM Cortex-A53
A53 是一款双发射、静态调度的超标量处理器,具备动态发射检测功能,使得处理器每个时钟周期可以发射两条指令。图 3.34 显示了该流水线的基本结构。对于非分支整数指令,流水线包含八个阶段:F1、F2、D1、D2、D3/ISS、EX1、EX2 和 WB,如标题所述。该流水线是顺序执行的,因此指令只能在其结果可用且前面的指令已启动执行时才会开始执行。因此,如果接下来的两条指令相互依赖,虽然它们可以进入适当的执行流水线,但在到达流水线的起始位置时会被串行化。当基于记分板的发射逻辑指示第一条指令的结果可用时,第二条指令可以发射。

图 3.34 A53 整数流水线的基本结构包含 8 个阶段:F1 和 F2 用于获取指令,D1 和 D2 进行基本解码,D3 解码一些更复杂的指令,并与执行流水线的第一阶段(ISS)重叠。在 ISS 之后,EX1、EX2 和 WB 阶段完成整数流水线。分支根据类型使用四种不同的预测器。浮点执行流水线深度为 5 个周期,加上获取和解码所需的 5 个周期,总共形成 10 个阶段。
指令获取的四个周期包括一个地址生成单元,它通过递增最后的程序计数器(PC)或从四个预测器之一生成下一个 PC:
1. **单入口分支目标缓存**,包含两个指令缓存获取(假设预测正确,获取分支后接下来的两条指令)。在第一次获取周期中检查此目标缓存,如果命中,则从目标缓存中提供接下来的两条指令。在命中且预测正确的情况下,分支执行时没有延迟周期。
2. **3072条目混合预测器**,用于所有未命中分支目标缓存的指令,并在 F3 阶段工作。由此预测器处理的分支会产生 2 个周期的延迟。
3. **256条目间接分支预测器**,在 F4 阶段工作;由此预测器预测的分支在预测正确时会产生 3 个周期的延迟。
4. **8 深度返回栈**,在 F4 阶段工作,会产生 3 个周期的延迟。
分支决策在 ALU 管道 0 中进行,导致分支误预测的惩罚为 8 个周期。图 3.35 显示了 SPECint2006 的误预测率。浪费的工作量取决于误预测率和在跟随错误分支期间维持的发射速率。如图 3.36 所示,浪费的工作通常与误预测率相关,尽管有时会更大或更小。

### A53 流水线性能
A53 的理想 CPI 为 0.5,得益于其双发射结构。流水线停顿可能来自三种来源:
1. **功能危害**:当两个相邻指令同时选择发射并使用相同的功能流水线时,会发生功能危害。由于 A53 是静态调度的,编译器应尽量避免此类冲突。当这样的指令顺序出现时,它们将在执行流水线的开始进行串行处理,此时只有第一条指令会开始执行。
2. **数据危害**:数据危害在流水线早期被检测到,可能导致两条指令都停顿(如果第一条无法发射,第二条总是会停顿)或只停顿一对中的第二条。同样,编译器应尽可能防止这种停顿。
3. **控制危害**:仅在分支被误预测时才会出现控制危害。
TLB 未命中和缓存未命中也会导致停顿。在指令侧,TLB 或缓存未命中会导致填充指令队列的延迟,可能导致流水线下游的停顿。当然,这取决于未命中是否为 L1 未命中,如果在未命中时指令队列已满,则可能会大部分隐藏;而 L2 未命中则需要更长的时间。在数据侧,缓存或 TLB 未命中将导致流水线停顿,因为导致未命中的加载或存储无法继续向下推进。所有其他后续指令将因此被停顿。图 3.37 显示了 CPI 及各种来源的估计贡献。
A53 使用了浅流水线和相对积极的分支预测器,导致适度的流水线损失,同时允许处理器在适度的功耗下实现高时钟频率。与 i7 相比,A53 的四核处理器功耗大约为其 1/200!

图 3.37 A53 CPI 组成的估计 图 3.37 显示 ARM A53 中 CPI 的估计组成,表明流水线停顿虽然显著,但在表现最差的程序中被缓存未命中的影响所超过。该估计是通过使用 L1 和 L2 的未命中率及其惩罚来计算每条指令生成的 L1 和 L2 停顿。这些停顿从详细模拟器测得的 CPI 中减去,以获得流水线停顿的数量。流水线停顿包括所有三种类型的危害。
### Intel Core i7
i7 采用一种激进的乱序推测微架构,具有深流水线,旨在通过结合多发射和高时钟频率来实现高指令吞吐量。首款 i7 处理器于 2008 年推出;i7 6700 是第六代。i7 的基本结构相似,但后续几代通过改变缓存策略(例如,预取的激进性)、增加内存带宽、扩展正在执行的指令数量、增强分支预测以及改善图形支持来提升性能。早期的 i7 微架构使用保留站和重排序缓冲区来实现其乱序推测流水线。后来,包括 i7 6700 在内的微架构采用了寄存器重命名,保留站作为功能单元队列,而重排序缓冲区则仅用于跟踪控制信息。

图3.38 显示了Intel Core i7的流水线结构及其内存系统组件。整个流水线深度为14个阶段,分支预测错误通常会导致17个周期的损失,额外的几个周期可能是由于重置分支预测器所需的时间。六个独立的功能单元可以在同一个周期内同时开始执行准备好的微操作。注册重命名表中最多可以处理四个微操作。
图3.38展示了i7流水线的整体结构。我们将从指令获取开始,逐步检查流水线,按照图中标记的步骤进行。
1. **指令获取**—处理器使用一种复杂的多级分支预测器,以在速度和预测准确性之间达到平衡。同时还设有返回地址堆栈,以加快函数返回。错误预测会导致大约17个周期的惩罚。使用预测地址,指令获取单元从指令缓存中获取16个字节。
2. **将16个字节放入预解码指令缓冲区**—在此步骤中,执行一种称为宏操作融合的过程。宏操作融合将比较后跟分支等指令组合融合为单一操作,从而可以作为一条指令发出和调度。只有某些特殊情况可以融合,因为必须确保第一个结果的唯一用途是由第二条指令使用(即比较和分支)。在对Intel Core架构的研究中(该架构缓冲区较少),Bird等人(2007)发现宏融合对整数程序性能有显著影响,导致平均性能提高8%–10%,但一些程序显示出负面结果。对浮点程序的影响较小;实际上,大约一半的SPECFP基准测试显示宏操作融合产生负面结果。预解码阶段还将16个字节拆分为单个x86指令。这个预解码并不简单,因为x86指令的长度可以从1到17字节,预解码器必须查看多个字节才能确定指令长度。单个x86指令(包括一些融合指令)被放入指令队列中。
3. **微操作解码**—单个x86指令被翻译成微操作。微操作是简单的类似RISC-V的指令,可以直接由流水线执行;将x86指令集翻译成更易于流水线化的简单操作的方法是在1997年Pentium Pro中引入的,并一直沿用至今。三个解码器处理直接翻译为单个微操作的x86指令。对于具有更复杂语义的x86指令,使用微代码引擎生成微操作序列;它每个周期最多可以生成四个微操作,并持续进行,直到生成所需的微操作序列。微操作根据x86指令在64条微操作缓冲区中的顺序进行放置。
4. 微操作缓冲区执行循环流检测和微融合——如果有一小段指令序列(少于64条指令)构成一个循环,循环流检测器将找到该循环,并直接从缓冲区发出微操作,从而消除激活指令获取和指令解码阶段的需要。微融合将指令对结合在一起,例如ALU操作和一个依赖的存储,并将它们发出到一个单一的保留站(在这里它们仍然可以独立发出),从而增加缓冲区的使用率。微操作融合对整数程序产生较小的增益,而对浮点程序则产生较大的增益,但结果差异很大。整数程序和浮点程序在宏融合和微融合中的不同结果,可能源于识别和融合的模式,以及在整数与浮点程序中出现的频率。在i7中,由于重排序缓冲区的条目数量更大,这两种技术的收益可能会更小。
5. 执行基本的指令发出——查找寄存器表中的寄存器位置,重命名寄存器,分配一个重排序缓冲区条目,并在将微操作发送到保留站之前,从寄存器或重排序缓冲区获取任何结果。每个时钟周期最多可以处理四个微操作;它们被分配到下一个可用的重排序缓冲区条目。
6. i7使用一个由六个功能单元共享的集中式保留站。每个时钟周期最多可以向功能单元调度六个微操作。
7. 微操作由各个功能单元执行,结果然后被发送回任何等待的保留站以及寄存器退休单元,在确认指令不再是推测性之后,将更新寄存器状态。与重排序缓冲区中指令对应的条目被标记为完成。
8. 当重排序缓冲区头部的一条或多条指令被标记为完成时,寄存器退休单元中的待处理写入会被执行,指令也会从重排序缓冲区中移除。
除了分支预测器的变化,第一代i7(920,Nehalem微架构)和第六代(i7 6700,Skylake微架构)之间的主要变化在于各种缓存、重命名寄存器和资源的大小,以便允许更多的未完成指令。图3.39总结了这些差异。

图3.39展示了第一代i7和最新一代i7中的缓冲区和队列。Nehalem架构使用了保留站加重排序缓冲区的组织。在后来的微架构中,保留站作为调度资源,而采用寄存器重命名取代重排序缓冲区;在Skylake微架构中,重排序缓冲区仅用于缓冲控制信息。各种缓冲区和重命名寄存器的大小选择虽然有时看似任意,但很可能是基于广泛的仿真结果。
i7的性能
在前面的部分中,我们考察了i7的分支预测器性能以及SMT的性能。在本节中,我们将关注单线程流水线性能。由于存在激进的猜测和非阻塞缓存,很难准确归因于理想性能与实际性能之间的差距。6700上的广泛队列和缓冲区显著降低了因缺乏保留站、重命名寄存器或重新排序缓冲区而导致的停顿概率。事实上,即使在较早的i7 920上,由于没有可用的保留站,只有大约3%的负载被延迟。
因此,大多数性能损失要么来自分支错误预测,要么来自缓存未命中。分支错误预测的代价为17个周期,而L1未命中的代价约为10个周期。L2未命中的成本稍微超过L1未命中的三倍,而L3未命中的成本大约是L1未命中的13倍(130-135个周期)。尽管处理器会尝试在L2和L3未命中期间寻找替代指令执行,但在未命中完成之前,一些缓冲区可能会填满,导致处理器停止发出指令。

图3.40显示了19个SPECCPUint2006基准测试的整体CPI与早期i7 920的CPI的比较。i7 6700的平均CPI为0.71,而i7 920的CPI几乎好了一倍,达到了1.06。这一差异源于改进的分支预测和需求未命中率的降低(见第135页的图2.26)。

为了理解6700如何实现显著的CPI改善,让我们看看那些取得最大改善的基准测试。图3.41显示了五个基准测试,它们在920上的CPI比6700高出至少1.5倍。有趣的是,另有三个基准测试显示出显著的分支预测准确性提升(1.5倍或更高);然而,这三个基准测试(HMMER、LIBQUANTUM和SJENG)在i7 6700上的L1需求未命中率相等或略高。这些未命中可能是由于激进的预取策略替换了实际使用的缓存块。这种行为提醒设计者,在复杂的投机多发射处理器中最大化性能的挑战:通常仅仅通过调整微架构的某一部分很难实现显著的性能提升!


3.13 Fallacies and Pitfalls

我们的几个谬误集中在预测性能和能效的难度以及从单一指标(如时钟频率或CPI)进行外推上。我们还展示了不同的架构方法在不同基准测试下可能表现出截然不同的行为。
谬误:如果我们保持技术不变,预测同一指令集架构的两个不同版本的性能和能效是容易的。
英特尔提供了一款针对低端上网本和PMD市场的处理器,称为Atom 230,它实现了x86架构的64位和32位版本。Atom是一款静态调度、2发射超标量的处理器,在微架构上与ARM A8(A53的单核前身)相似。有趣的是,Atom 230和Core i7 920都是在相同的45纳米英特尔工艺下制造的。图3.42总结了Intel Core i7 920、ARM Cortex-A8和Intel Atom 230。这些相似之处提供了一个罕见的机会,可以在保持基础制造技术不变的情况下,直接比较针对同一指令集的两种截然不同的微架构。在进行比较之前,我们需要对Atom 230多说几句。

Figure 3.42概述了四核Intel i7 920处理器、典型的ARM A8处理器(配备256 MiB L2缓存、32 KiB L1缓存且不具备浮点运算)以及Intel Atom 230处理器。这些处理器之间的设计哲学差异明显:ARM A8主要面向PMD市场,而Atom则针对上网本领域,而i7则为服务器和高端桌面计算而设计。需要注意的是,i7包含四个核心,每个核心的性能均高于单核的A8或Atom。这些处理器都在相似的45纳米工艺下制造。
Atom处理器使用将x86指令转换为类似RISC指令的标准技术来实现x86架构(自1990年代中期以来,每个x86实现都是如此)。Atom使用了一种稍微强大的微操作,允许将算术操作与加载或存储配对;这一能力通过宏融合被添加到后来的i7中。这意味着,在典型指令组合的平均情况下,只有4%的指令需要多个微操作。这些微操作在一个深度为16的流水线中执行,每个时钟周期能够顺序发射两条指令,类似于ARM A8。它具有双整数ALU、用于FP加法和其他FP操作的独立流水线,以及两个内存操作流水线,支持比ARM A8更通用的双重执行,但仍受限于顺序发射能力。Atom 230拥有32 KiB的指令缓存和24 KiB的数据缓存,二者都由同一芯片上的共享512 KiB L2缓存支持。(Atom 230还支持双线程多任务,但我们只考虑单线程比较。)
我们可能会期待这两款处理器在相同技术实现和相同指令集下,表现出可预测的行为,即在相对性能和能耗方面,功率和性能的关系接近线性。我们使用三组基准测试来检验这一假设。第一组是来自DaCapo基准和SPEC JVM98基准的一组Java单线程基准测试(有关基准测试和测量的讨论,请参见Esmaeilzadeh等人(2011))。第二组和第三组基准测试来自SPEC CPU2006,分别由整数和浮点基准组成。

图3.43展示了一组单线程基准测试的相对性能和能效,显示i7 920的速度是Atom 230的4到10倍以上,但在平均功率效率上约低2倍!性能通过柱状图表示为i7相对于Atom的执行时间比(i7执行时间/Atom执行时间)。能效则通过线图表示为能量比(Atom能量/i7能量)。尽管在四个基准测试中,i7的表现与Atom相当,其中三个是浮点测试,但在能效方面,i7从未超过Atom。这里的数据由Esmaeilzadeh等人(2011年)收集。SPEC基准测试使用标准Intel编译器进行了优化,而Java基准测试使用的是Sun(Oracle)Hotspot Java虚拟机。i7仅激活一个核心,其余核心处于深度节能模式。i7使用了Turbo Boost,这增强了其性能优势,但略微降低了其相对能效。
如图3.43所示,i7的性能显著优于Atom。所有基准测试在i7上的速度至少快四倍,其中两个SPECFP基准测试的速度超过十倍,而一个SPECINT基准测试的运行速度超过八倍!由于这两款处理器的时钟频率比为1.6,大部分优势来自于i7 920的CPI显著更低:Java基准测试的CPI降低了2.8倍,SPECINT基准测试降低了3.1倍,SPECFP基准测试降低了4.3倍。
然而,i7 920的平均功耗刚好低于43瓦,而Atom的平均功耗为4.2瓦,约为i7的十分之一!将性能和功耗结合起来,Atom在能效方面通常比i7好1.5倍,甚至常常达到2倍!这一对使用相同基础技术的处理器的比较清楚地表明,采用动态调度和推测的积极超标量架构在性能上占优势,但在能效上却面临显著劣势。
谬误:CPI较低的处理器总是更快。
谬误:时钟频率较高的处理器总是更快。
关键在于,决定性能的是CPI与时钟频率的乘积。通过深度流水线获得的高时钟频率必须保持低CPI,以充分发挥更高时钟的优势。同样,尽管简单处理器具有高时钟频率但CPI较低,性能可能反而较慢。
正如我们在之前的谬误中所看到的,尽管处理器具有相同的指令集架构(ISA),但为不同环境设计的处理器之间的性能和能效差异可能非常显著。实际上,即使在同一公司为高端应用设计的处理器系列中,性能也可能存在较大差异。图3.44展示了Intel的两种不同实现的x86架构的整数和浮点性能,以及Intel的Itanium架构的一个版本。

Pentium 4是Intel迄今为止最具侵略性的流水线处理器。它使用了超过20个阶段的流水线,拥有七个功能单元,并缓存微操作而非x86指令。尽管其实施极具攻击性,但相对较低的性能清楚地表明,试图利用更多的指令级并行性(ILP)失败了(在运行时可能有多达50条指令)。Pentium的功耗与i7相似,尽管其晶体管数量较少,因为它的主缓存大小仅为i7的一半,并且只包含一个2 MiB的二级缓存,没有三级缓存。
Intel Itanium是一种VLIW风格的架构,尽管与动态调度超标量处理器相比,其复杂性有可能降低,但从未能在时钟频率上与主流x86处理器竞争(尽管其整体CPI似乎与i7相似)。在审视这些结果时,读者应注意它们使用了不同的实现技术,这使得i7在晶体管速度及其时钟频率方面具有优势,即使是对于具有相同流水线深度的处理器。尽管如此,性能上的巨大差异——Pentium与i7之间超过三倍的差距——仍然令人惊讶。接下来的陷阱将解释这一优势来自何方。
### 陷阱:有时候更大更简单更好
在2000年代初期,许多关注点集中在构建激进的处理器上,以利用指令级并行性(ILP),其中包括采用史上最深管线的Pentium 4架构和具有最高时钟峰值发射率的Intel Itanium。很快就清楚,利用ILP的主要限制往往是内存系统。尽管投机性乱序管线能够相对有效地隐藏约10到15个周期的一级缓存缺失惩罚,但它们对于二级缓存缺失的惩罚几乎无能为力,后者如果需要访问主内存,延迟可能会达到50到100个时钟周期。
因此,这些设计从未接近实现其理论峰值指令吞吐量,尽管它们拥有大量的晶体管和极其复杂巧妙的技术。第3.15节讨论了这一困境,以及从更激进的ILP方案转向多核的趋势,但还有另一个变化体现了这一陷阱。设计者没有继续尝试通过ILP来隐藏更多的内存延迟,而是简单地用晶体管构建了更大的缓存。Itanium 2和i7都使用了三级缓存,而Pentium 4只使用了二级缓存,且这两款处理器的三级缓存分别为9 MiB和8 MiB,相较于Pentium 4的2 MiB二级缓存。
毫无疑问,构建更大的缓存比设计20多个阶段的Pentium 4管线要简单得多,基于图3.44中的数据,这种做法似乎更为有效。
### 陷阱
有时,聪明的设计比庞大且愚蠢的设计更好。
过去十年中,最令人惊讶的结果之一是分支预测的进展。混合标记预测器的出现表明,使用更复杂的预测器能够超越具有相同位数的简单gshare预测器(见第171页的图3.8)。这一结果之所以令人惊讶,是因为标记预测器实际存储的预测数量较少,因为它需要消耗位来存储标签,而gshare仅拥有一个大型预测数组。尽管如此,避免将一个分支的预测错误地用于另一个分支所带来的优势,似乎足以证明在标签和预测之间分配位的合理性。
### 陷阱
相信如果我们有正确的技术,就会有大量的指令级并行性(ILP)可供利用。
尝试利用大量ILP的努力因多种原因而失败,但其中一个重要原因是,对于结构化常规程序而言,即使利用了推测,发现大量ILP也非常困难。这一点一些设计师最初并未接受。1993年,David Wall进行了一项著名研究(见Wall, 1993),分析了在各种理想条件下可用的ILP数量。我们总结了他针对一种配置能力大约是2017年最先进处理器五到十倍的处理器的结果。Wall的研究广泛记录了多种不同的方法,感兴趣于开发ILP挑战的读者应阅读完整研究。
我们考虑的激进处理器具有以下特征:
1. 每个时钟周期可发出和调度多达64条指令,没有发出限制,这是2016年最宽处理器(IBM Power8)的发出宽度的8倍,并且每个时钟周期允许多达32倍的加载和存储!正如我们所讨论的,大量发出率带来了严重的复杂性和功耗问题。
2. 一个具有1000个条目的比赛预测器和一个具有16个条目的函数返回预测器。该预测器与2016年的最佳预测器相当;预测器不是主要瓶颈。误预测在一个周期内处理,但会限制推测能力。
3. 动态完美的不明确内存引用——这是雄心勃勃的,但对于小窗口大小来说可能是可实现的。
4. 注册重命名,增加64个整数寄存器和64个浮点寄存器,相比2011年最激进的处理器略少。由于该研究假设所有指令的延迟仅为一个周期(而像i7或Power8这样的处理器则超过15个周期),因此有效的重命名寄存器数量比这两种处理器大约多五倍。

图3.45 显示了在每个时钟周期最多可发出64条任意指令的情况下,各种整数和浮点程序可用的并行性与窗口大小之间的关系。尽管重命名寄存器的数量少于窗口大小,但所有操作都有1个周期的延迟,并且重命名寄存器的数量等于发出宽度,这使得处理器能够在整个窗口内利用并行性。
图3.45展示了在我们改变窗口大小时该配置的结果。与现有实现相比,这种配置更复杂且成本更高,尤其是在指令发出数量方面。尽管如此,它为未来的实现可能达到的上限提供了有用的参考。这些图中的数据可能出于另一个原因过于乐观:在64条指令之间没有发出限制;例如,它们可能都是内存引用。在不久的将来,没人会考虑在处理器中实现这种能力。此外,请记住,在解释这些结果时,没有考虑缓存未命中和非单位延迟,而这两种效应都具有显著影响。
图3.45中最引人注目的观察是,在前述现实的处理器约束下,整数程序的窗口大小效果并不像浮点程序那样严重。这一结果指出了这两类程序之间的关键区别。在两个浮点程序中,循环级并行性的可用性意味着可利用的指令级并行性(ILP)更高,但对于整数程序来说,其他因素——如分支预测、寄存器重命名以及较少的并行性——都是重要的限制。这一观察至关重要,因为过去十年市场增长的大部分——事务处理、网页服务器等——依赖于整数性能,而不是浮点性能。
虽然有人不相信Wall的研究,但10年后,现实已经显现,适度的性能提升与显著的硬件资源结合,再加上因错误推测而产生的重大能耗问题,迫使我们改变方向。我们将在总结部分回到这一讨论。


3.14 Concluding Remarks: What’s Ahead?

随着2000年的到来,利用指令级并行性的关注达到了顶峰。在新世纪的头五年,显然ILP方法可能已达到顶峰,需要新的方法。到2005年,英特尔和所有其他主要处理器制造商都重新调整了他们的策略,重点转向多核。更高的性能将通过线程级并行性而非指令级并行性来实现,而有效利用处理器的责任大部分将从硬件转移到软件和程序员身上。这一变化是自25年前流水线和指令级并行化早期以来处理器架构最重要的变化。
在同一时期,设计师们开始探索更多数据级并行性作为获得性能的另一种方法。SIMD扩展使得桌面和服务器微处理器在图形及类似功能上实现了适度的性能提升。更重要的是,图形处理单元(GPU)积极利用SIMD,针对具有广泛数据级并行性的应用获得了显著的性能优势。对于科学应用,这些方法代表了一种可行的替代方案,相较于在多核中利用的更通用但效率较低的线程级并行性。下一章将探讨数据级并行性使用中的这些发展。
许多研究人员预测ILP的使用将出现重大缩减,认为双发射超标量处理器和更多核心将是未来。然而,稍高的发射率以及推测性动态调度处理不可预测事件(如一级缓存未命中)的能力,使得适度的ILP(通常约为4条指令/时钟)成为多核设计的主要构建块。SMT的加入及其在性能和能效方面的有效性进一步巩固了适度发射、乱序和推测性方法的地位。事实上,即使在嵌入式市场,最新的处理器(如ARM Cortex-A9和Cortex-A73)也引入了动态调度、推测和更宽的发射率。

图3.46 五代IBM Power处理器的特性。除了静态且按顺序执行的Power6外,其他处理器均为动态调度;所有处理器都支持两个加载/存储流水线。Power6的功能单元与Power5相同,唯一不同的是缺少十进制单元。Power7和Power8使用嵌入式DRAM作为L3缓存。Power9进行了简要描述,进一步扩展了缓存并支持离芯片的HBM。
未来的处理器不太可能大幅增加发射宽度。从硅的利用率和功耗效率来看,这种做法效率太低。考虑图3.46中显示的IBM Power系列的五款处理器。在十多年的时间里,Power处理器在指令级并行性(ILP)支持方面有了适度的改善,但晶体管数量的主要增加(从Power4到Power8超过10倍)主要用于增加缓存和每个芯片上的核心数量。甚至SMT支持的扩展似乎比提高ILP吞吐量更为重要:从Power4到Power8,ILP结构从5条发射增加到8条,从8个功能单元增加到16个(但未从最初的2个加载/存储单元增加),而SMT支持则从不存在发展到每个处理器支持8个线程。
在六代i7处理器中也可以观察到类似的趋势,几乎所有额外的硅都用于支持更多的核心。接下来的两章将重点讨论利用数据级和线程级并行性的方法。

4 Data-Level Parallelism in Vector, SIMD, and GPU Architectures

我们称这些算法为数据并行算法,因为它们的并行性来自于对大量数据集的同时操作,而不是来自多个控制线程。  
——W. Daniel Hillis和Guy L. Steele,《数据并行算法》,《ACM通信》(1986)
如果你在耕地,你更愿意使用:两头强壮的牛,还是1024只鸡?  
—— Seymour Cray,超级计算机之父  
(主张使用两个强大的向量处理器而不是许多简单的处理器)


4.1 Introduction

关于单指令多数据(SIMD)架构的问题,一直以来都是有多少种应用程序具有显著的数据级并行性(DLP)。在SIMD分类提出五年后(Flynn, 1966),答案不仅包括科学计算中的矩阵运算,还包括面向媒体的图像和声音处理以及机器学习算法,如我们将在第七章中看到的。由于多指令多数据(MIMD)架构需要为每个数据操作获取一条指令,而单指令多数据(SIMD)则可能更具能效,因为一条指令可以启动许多数据操作。这两个原因使得SIMD对个人移动设备和服务器都具有吸引力。最后,也许SIMD相比MIMD最大的优势在于,程序员仍然可以顺序思考,却通过并行数据操作实现并行加速。本章将介绍三种SIMD的变体:向量架构、多媒体SIMD指令集扩展和图形处理单元(GPU)。
第一种变体,比其他两种早了30多年,扩展了许多数据操作的流水线执行。这些向量架构比其他SIMD变体更易于理解和编译,但在最近之前,它们被认为对微处理器来说过于昂贵。这部分费用来自晶体管,部分来自于满足常规微处理器内存性能需求所需的足够动态随机存取内存(DRAM)带宽,因为普遍依赖缓存。
第二种SIMD变体基本上是指同时并行的数据操作,现在大多数支持多媒体应用的指令集架构中都有体现。对于x86架构,SIMD指令扩展始于1996年的MMX(多媒体扩展),接着在下一个十年中推出了多个SSE(流式SIMD扩展)版本,直到今天仍在继续更新AVX(高级向量扩展)。为了从x86计算机中获得最高的计算速率,通常需要使用这些SIMD指令,尤其是在浮点程序中。
第三种SIMD变体来自图形加速器社区,提供了比传统多核计算机更高的潜在性能。虽然GPU与向量架构共享一些特征,但它们有自己独特的特点,部分原因是它们发展的生态系统。这个环境中除了GPU及其图形内存外,还有系统处理器和系统内存。实际上,为了识别这些区别,GPU社区将这种类型的架构称为异构架构。
对于具有大量数据并行性的问题,所有三种SIMD变体都比经典的MIMD并行编程对程序员更友好。本章的目标是让架构师理解为什么向量架构比多媒体SIMD更具通用性,以及向量架构与GPU架构之间的相似性和差异。由于向量架构是多媒体SIMD指令的超集,具有更好的编译模型,并且GPU与向量架构有许多相似之处,因此我们从向量架构开始,为后面两个部分奠定基础。接下来的部分将介绍向量架构,而附录G将对此主题进行更深入的探讨。


4.2 Vector Architecture

执行可向量化应用程序的最有效方式是使用向量处理器。  
——吉姆·史密斯,《国际计算机架构研讨会》(1994)
向量架构从内存中抓取分散的数据元素,将它们放入大型顺序寄存器文件中,在这些寄存器文件中的数据上进行操作,然后将结果分散回内存。单条指令在数据向量上工作,这导致对独立数据元素进行数十次寄存器间的操作。
这些大型寄存器文件充当编译器控制的缓冲区,既可以隐藏内存延迟,又可以利用内存带宽。由于向量加载和存储深度流水线化,程序只需为每个向量加载或存储支付一次长内存延迟,而不是每个元素一次,从而将延迟分摊到例如32个元素上。实际上,向量程序力求保持内存的高效利用。
能量墙促使架构师重视能在不增加高能耗和设计复杂度的情况下提供良好性能的架构。向量指令自然符合这一趋势,因为架构师可以利用它们来提高简单的顺序标量处理器的性能,而不会大幅增加能耗和设计复杂度。实际上,开发人员可以以向量指令的形式更有效地表达许多在复杂的乱序设计上运行良好的程序,正如Kozyrakis和Patterson(2002)所示。
### RV64V 扩展

图 4.1 向量架构 RV64V 的基本结构,其中包含 RISC-V 标量架构。该架构有 32 个向量寄存器,所有功能单元都是向量功能单元。向量寄存器和标量寄存器具有大量的读写端口,以支持多个同时进行的向量操作。一组交叉开关(厚灰色线条)将这些端口连接到向量功能单元的输入和输出。
我们首先介绍一个由主要组件组成的向量处理器,如图 4.1 所示。它在某种程度上基于已有 40 年历史的 Cray-1,这是一台早期的超级计算机。在本版撰写时,RISC-V 的向量指令集扩展 RVV 仍在开发中。(向量扩展本身被称为 RVV,因此 RV64V 指的是 RISC-V 基础指令加上向量扩展。)我们展示了 RV64V 的一个子集,试图在几页中捕捉其精髓。
RV64V 指令集架构的主要组成部分包括:
- **向量寄存器**——每个向量寄存器保存一个单一的向量,RV64V 一共有 32 个寄存器,每个寄存器宽度为 64 位。向量寄存器文件需要提供足够的端口,以供所有向量功能单元使用。这些端口将允许不同向量寄存器之间的向量操作高度重叠。读写端口总共有至少 16 个读端口和 8 个写端口,通过一对交叉开关与功能单元的输入或输出连接。增加寄存器文件带宽的一种方法是将其由多个银行组成,这对于相对较长的向量来说效果良好。
- **向量功能单元**——在我们的实现中,每个单元都是完全流水线化的,并且可以在每个时钟周期开始一个新的操作。需要一个控制单元来检测冒险情况,包括功能单元的结构冒险和寄存器访问的数据冒险。图 4.1 显示我们假设 RV64V 的实现有五个功能单元。为了简化,我们在本节中集中讨论浮点功能单元。
- **向量加载/存储单元**——向量内存单元负责从内存加载或存储向量。在我们假设的 RV64V 实现中,向量加载和存储是完全流水线化的,因此可以在初始延迟后,以每个时钟周期一个字的带宽在向量寄存器和内存之间移动字。这一单元通常还会处理标量加载和存储。
- **一组标量寄存器**——标量寄存器同样可以作为输入提供数据给向量功能单元,并计算地址传递给向量加载/存储单元。这些是 RV64G 的 31 个通用寄存器和 32 个浮点寄存器。向量功能单元的一个输入在从标量寄存器文件读取标量值时会锁存这些值。

图 4.2 RV64V 向量指令。所有指令使用 R 指令格式。每个具有两个操作数的向量操作均显示为两个向量(.vv),但也有版本允许第二个操作数为标量寄存器(.vs),以及在出现区别时,第一操作数为标量寄存器而第二个为向量寄存器(.sv)。操作数的类型和宽度是通过配置各个向量寄存器来确定的,而不是由指令提供。此外,除了向量寄存器和谓词寄存器外,还有两个向量控制和状态寄存器(CSR),即 vl 和 vctype,后面将进行讨论。步长和索引数据传输也将在后面解释。一旦完成,RV64 一定会有更多指令,但图中所列的指令将被包含在内。
图 4.2 列出了我们在本节中使用的 RV64V 向量指令。图 4.2 中的描述假设输入操作数都是向量寄存器,但这些指令也有版本可以接受标量寄存器(xi 或 fi)作为操作数。当两个操作数都是向量时,RV64V 使用后缀 .vv;当第二个操作数是标量时,使用 .vs;当第一个操作数是标量寄存器时,使用 .sv。因此,这三种都是有效的 RV64V 指令:vsub.vv、vsub.vs 和 vsub.sv。(加法和其他可交换操作只有前两种版本,因为 vadd.sv 和 vadd.sv 会显得冗余。)由于操作数决定了指令的版本,我们通常让汇编器提供合适的后缀。向量功能单元在指令发出时会获取标量值的副本。
尽管传统的向量架构并不高效地支持窄数据类型,但向量自然适应不同的数据大小(Kozyrakis 和 Patterson, 2002)。因此,如果一个向量寄存器有 32 个 64 位元素,那么 128 个 16 位元素,甚至 256 个 8 位元素都是同样有效的视图。这种硬件的多重性使得向量架构对多媒体应用和科学应用都很有用。
请注意,图 4.2 中的 RV64V 指令省略了数据类型和大小!RV64V 的一个创新是将数据类型和数据大小与每个向量寄存器关联,而不是采用指令提供该信息的常规方法。因此,在执行向量指令之前,程序会配置所使用的向量寄存器,以指定它们的数据类型和宽度。图 4.3 列出了 RV64V 的选项。

图 4.3 支持的 RV64V 数据大小,假设它还具有单精度和双精度浮点扩展 RVS 和 RVD。将 RVV 添加到这样的 RISC-V 设计中意味着标量单元也必须增加 RVH,这是一个支持半精度(16 位)IEEE 754 浮点的标量指令扩展。由于 RV32V 不会有双字标量操作,因此可以从向量单元中省略 64 位整数。如果一个 RISC-V 实现不包括 RVS 或 RVD,则可以省略向量浮点指令。
动态寄存器类型的一个原因是,传统向量架构需要许多指令来支持如此多样的数据类型和大小。考虑到图 4.3 中的数据类型和大小组合,如果没有动态寄存器类型,图 4.2 的内容将会长达数页之久!
动态类型还允许程序禁用未使用的向量寄存器。因此,启用的向量寄存器可以分配整个向量内存作为长向量。例如,假设我们有 1024 字节的向量内存,如果启用了 4 个向量寄存器且它们为 64 位浮点类型,则处理器会为每个向量寄存器分配 256 字节,即 256/8 = 32 个元素。这个值称为最大向量长度(mvl),由处理器设置,无法通过软件更改。
关于向量架构的一个抱怨是,它们更大的状态意味着上下文切换时间更慢。我们对 RV64V 的实现使状态增加了 3 倍:从 2^32 × 8 = 512 字节增加到 2^32 × 1024 = 1536 字节。动态寄存器类型的一个好处是,当向量寄存器不被使用时,程序可以将它们配置为禁用,因此在上下文切换时不需要保存和恢复它们。
动态寄存器类型的第三个好处是,根据寄存器的配置,操作数之间的大小转换可以是隐式的,而不是额外的显式转换指令。我们将在下一节中看到这个好处的一个例子。
vld 和 vst 分别表示向量加载和向量存储,它们用于加载或存储整个数据向量。一个操作数是要加载或存储的向量寄存器;另一个操作数是一个 RV64G 通用寄存器,它是向量在内存中的起始地址。向量操作需要更多的寄存器,除了向量寄存器本身。向量长度寄存器 vl 在自然向量长度不等于 mvl 时使用,向量类型寄存器 vctype 记录寄存器类型,而谓词寄存器 pi 在循环涉及 IF 语句时使用。我们将在接下来的例子中看到它们的实际应用。
通过向量指令,系统可以以多种方式对向量数据元素执行操作,包括同时对多个元素进行操作。这种灵活性使得向量设计能够使用慢但宽的执行单元,以在低功耗下实现高性能。此外,向量指令集内元素的独立性允许功能单元的扩展,而无需执行额外的代价高昂的依赖性检查,这是超标量处理器所要求的。
### 向量处理器的工作原理:一个示例
我们可以通过查看 RV64V 的向量循环来最好地理解向量处理器。让我们以一个典型的向量问题为例,这个问题将贯穿本节:
Y = a x X + Y
这里,X 和 Y 是向量,最初驻留在内存中,而 a 是一个标量。这个问题是 SAXPY 或 DAXPY 循环,它构成了 Linpack 基准测试的内层循环(Dongarra 等,2003)。SAXPY 代表单精度 \( a \cdot X + Y \),而 DAXPY 代表双精度 \( a \cdot X + Y \)。Linpack 是一组线性代数例程,Linpack 基准测试包括执行高斯消元的例程。
现在,假设向量寄存器的元素数量(32)与我们感兴趣的向量操作的长度相匹配。(这个限制将在稍后解除。)
### 示例
展示 DAXPY 循环的 RV64G 和 RV64V 代码。在此示例中,假设 X 和 Y 各有 32 个元素,且 X 和 Y 的起始地址分别在 x5 和 x6 中。(后续示例将涵盖它们不具有 32 个元素的情况。)
### 回答
以下是 RISC-V 代码:

注意,汇编器决定生成哪种版本的向量操作。由于乘法操作有一个标量操作数,因此生成 `vmul.vs`,而加法操作没有标量操作数,因此生成 `vadd.vv`。初始指令配置前四个向量寄存器以存储 64 位浮点数据。最后一条指令禁用所有向量寄存器。如果在最后一条指令后发生上下文切换,就没有额外的状态需要保存。
前面的标量代码和向量代码之间最显著的区别是,向量处理器大大降低了动态指令带宽,仅执行 8 条指令,而 RV64G 则执行 258 条。这种减少发生是因为向量操作针对 32 个元素进行工作,而构成 RV64G 循环近一半的开销指令在 RV64V 代码中不存在。当编译器为这样的序列生成向量指令,并且生成的代码大部分时间在向量模式下运行时,该代码被称为向量化或可向量化。循环可以在不具有循环迭代间依赖关系的情况下进行向量化,这些依赖关系称为循环携带依赖(见第 4.5 节)。
RV64G 和 RV64V 之间另一个重要的区别是 RV64G 简单实现中的流水线互锁频率。在简单的 RV64G 代码中,每个 `fadd.d` 必须等待 `fmul.d`,而每个 `fsd` 必须等待 `fadd.d`。在向量处理器上,每个向量指令只会因第一个元素而停顿,然后后续元素将顺利流过流水线。因此,每条向量指令只需一次流水线停顿,而不是每个向量元素一次。向量架构师称元素依赖操作的转发为“链式”,因为依赖操作“链”在一起。在这个例子中,RV64G 上的流水线停顿频率将比 RV64V 高出约 32 倍!软件流水线、循环展开(见附录 H)或乱序执行可以减少 RV64G 上的流水线停顿;然而,指令带宽的巨大差异无法大幅减少。
在讨论代码性能之前,让我们展示一下动态寄存器类型。
示例  
乘法-累加操作的一个常见用途是使用较小的数据进行乘法运算,并以更宽的大小进行累加,以提高乘积和的准确性。  
如果 X 和 a 是单精度浮点数而不是双精度浮点数,前面的代码会如何变化?接下来,如果我们将 X、Y 和 a 从浮点类型切换为整数类型,这段代码又有何改变?
回答  
以下代码中的变化已加下划线。令人惊讶的是,经过两个小改动后,相同的代码仍然可以工作:配置指令包含一个单精度向量,而标量加载现在是单精度的。

请注意,在此设置中,RV64V 硬件将隐式地执行从较窄的单精度到较宽的双精度的转换。  
切换到整数类型几乎也很简单,但我们现在必须使用整数加载指令和整数寄存器来保存标量值:

### 向量执行时间
一系列向量操作的执行时间主要取决于三个因素:(1)操作数向量的长度,(2)操作之间的结构危险,以及(3)数据依赖关系。给定向量长度和启动速率(即向量单元消耗新操作数并生成新结果的速率),我们可以计算单个向量指令的时间。
所有现代向量计算机都有多个并行管道(或通道)的向量功能单元,可以每个时钟周期产生两个或更多结果,但它们也可能有一些不是完全流水线化的功能单元。为了简化,我们的 RV64V 实现有一个通道,单个操作的启动速率为每个时钟周期一个元素。因此,单个向量指令的执行时间大约是向量长度。
为了简化向量执行和性能的讨论,我们使用“车队”这一概念,它是可以潜在一起执行的向量指令集。车队中的指令不得包含任何结构危险;如果存在这种危险,指令将需要被序列化,并在不同的车队中启动。因此,前面的例子中的 `vld` 和后面的 `vmul` 可以在同一个车队中。正如我们很快将看到的,通过计算车队的数量,可以估算一段代码的性能。为了使这个分析简单,我们假设一车队的指令必须在其他任何指令(标量或向量)开始执行之前完成执行。
看起来,除了包含结构危险的向量指令序列外,具有读后写依赖危险的序列也应在单独的车队中。然而,链式操作允许它们在同一车队中,因为它允许向量操作在其向量源操作数的各个元素可用时立即开始:链中第一个功能单元的结果会“转发”给第二个功能单元。在实践中,我们通常通过允许处理器同时读取和写入特定的向量寄存器来实现链式操作,尽管是对不同的元素。早期的链式实现与标量流水线中的转发工作方式相同,但这限制了链中源指令和目标指令的时序。最近的实现使用灵活链式,这允许一个向量指令与基本上任何其他活动向量指令链式,只要不产生结构危险。所有现代向量架构都支持灵活链式,我们在本章中假设这一点。
为了将车队转换为执行时间,我们需要一个度量来估算车队的长度。这个度量称为“节拍”(chime),它只是执行一个车队所需的时间单位。因此,由 m 个车队组成的向量序列将在 m 个节拍内执行;对于长度为 n 的向量,在我们的简单 RV64V 实现中,大约是 m! n 个时钟周期。
节拍近似忽略了一些处理器特定的开销,其中许多与向量长度相关。因此,在长向量情况下,使用节拍来测量时间比短向量更准确。我们将使用节拍测量,而不是每个结果的时钟周期,以明确表示我们忽略某些开销。
如果我们知道向量序列中的车队数量,我们就能知道节拍中的执行时间。在测量节拍时忽略的一个开销来源是启动多个向量指令在单个时钟周期中的任何限制。如果在一个时钟周期内只能启动一个向量指令(这是大多数向量处理器中的现实情况),则节拍计数将低估车队的实际执行时间。由于向量的长度通常远大于车队中的指令数量,我们将简单假设车队在一个节拍内执行。
示例:展示以下代码序列如何在车队中布局,假设每个向量功能单元只有一个副本。

这个向量序列需要多少个时钟周期?每个浮点运算(FLOP)需要多少个周期,忽略向量指令发射的开销?
回答:第一个车队从第一个 `vld` 指令开始。`vmul` 依赖于第一个 `vld`,但通过链式调用,它可以在同一个车队中。第二个 `vld` 指令必须放在一个单独的车队中,因为它与之前的 `vld` 指令存在结构性冲突。`vadd` 依赖于第二个 `vld`,但它可以通过链式调用再次放在同一个车队中。最后,`vst` 与第二个车队中的 `vld` 有结构性冲突,因此必须放在第三个车队中。这个分析得出如下的向量指令车队布局:

该序列需要三个车队。因为序列耗时三个时钟周期,而每个结果有两个浮点运算,所以每个 FLOP 所需的周期数为 1.5(忽略任何向量指令发射的开销)。需要注意的是,尽管我们允许 `vld` 和 `vmul` 都在第一个车队中执行,但大多数向量机器需要 2 个时钟周期来启动这些指令。
这个例子表明,对于长向量,时钟周期的近似值是相当准确的。例如,对于 32 元素的向量,耗时为 3 个时钟周期,因此该序列大约需要 32 × 3 或 96 个时钟周期。在两个单独的时钟周期中发射车队的开销将是微小的。
另一个开销来源比发射限制更为显著。时钟周期模型忽略的最重要的开销来源是向量启动时间,即管道满载之前的延迟周期数。启动时间主要由向量功能单元的流水线延迟决定。对于 RV64V,我们将使用与 Cray-1 相同的流水线深度,尽管在现代处理器中,延迟通常有所增加,尤其是在向量加载方面。所有功能单元都是完全流水线的。浮点加法的流水线深度为 6 个时钟周期,浮点乘法为 7 个,浮点除法为 20 个,向量加载为 12 个。
鉴于这些向量基础知识,接下来的几个部分将提供优化方案,以提高性能或增加能够在向量架构上良好运行的程序类型。具体来说,它们将回答以下问题:
- 向量处理器如何在每个时钟周期执行一个元素以上的单个向量?每个时钟周期处理多个元素可以提高性能。
- 向量处理器如何处理向量长度不等于最大向量长度(mvl)的程序?由于大多数应用向量与架构向量长度不匹配,我们需要一种高效的解决方案来处理这种常见情况。
- 当代码中有 IF 语句需要向量化时会发生什么?如果我们能有效处理条件语句,更多代码将能够向量化。
- 向量处理器从内存系统中需要什么?如果没有足够的内存带宽,向量执行可能会变得毫无意义。
- 向量处理器如何处理多维矩阵?这种流行的数据结构必须向量化,以便在向量架构中表现良好。
- 向量处理器如何处理稀疏矩阵?这种流行的数据结构也必须向量化。
- 如何编程向量计算机?与编程语言及其编译器不匹配的体系结构创新可能不会得到广泛使用。
本节的其余部分将介绍这些向量架构优化的每一项,附录 G 将深入探讨。
### 多通道:超越每个时钟周期一个元素
向量指令集的一个关键优势是,它允许软件通过单条简短指令将大量并行工作传递给硬件。一个向量指令可以包含数十个独立操作,并且与传统的标量指令编码在相同数量的位中。向量指令的并行语义使得实现可以使用深度流水线的功能单元(如我们到目前为止研究的 RV64V 实现)、一组并行功能单元,或并行和流水线功能单元的组合来执行这些基本操作。图 4.4 说明了如何通过使用并行流水线来执行向量加法指令,从而提高向量性能。

图 4.4 显示了使用多个功能单元来提高单个向量加法指令(C5A + B)的性能。左侧的向量处理器 (A) 具有单个加法流水线,每个时钟周期完成一次加法。右侧的向量处理器 (B) 具有四个加法流水线,可以每个时钟周期完成四次加法。单个向量加法指令中的元素在四个流水线之间交错分布。通过流水线一起移动的元素集合称为元素组。经 Asanovic, K. 许可转载,1998 年。向量微处理器(博士论文)。加州大学伯克利分校计算机科学系。
RV64V 指令集的一个特点是,所有向量算术指令只允许一个向量寄存器的元素 N 与其他向量寄存器的元素 N 进行操作。这大大简化了高并行向量单元的设计,可以将其结构化为多个并行通道。类似于交通高速公路,我们可以通过增加更多通道来提高向量单元的峰值吞吐量。图 4.5 显示了一个四通道向量单元的结构。因此,从一个通道增加到四个通道将将一个响铃所需的时钟周期从 32 减少到 8。为了使多通道具有优势,应用程序和架构必须支持长向量;否则,它们将执行得如此迅速,以至于指令带宽将不足,需要使用 ILP 技术(见第 3 章)来提供足够的向量指令。

图 4.5:包含四个通道的向量单元结构 该向量寄存器内存在各个通道之间分配,每个通道保存每个向量寄存器中每第四个元素。图中显示了三个向量功能单元:一个浮点加法单元、一个浮点乘法单元和一个加载-存储单元。每个向量算术单元包含四个执行流水线,每个通道对应一个,这些流水线协同工作以完成单个向量指令。注意,每个向量寄存器文件的部分仅需要提供足够的端口,以支持其通道本地的流水线。该图未显示为向量-标量指令提供标量操作数的路径,但标量处理器(或控制处理器)会将标量值广播到所有通道。

图 4.6 使用条带挖掘处理的任意长度向量 在此图中,除了第一个块之外,所有块的长度均为 MVL(最大向量长度),充分利用了向量处理器的全部性能。在这里,我们用变量 m 表示表达式 (n % MVL)。(C 语言中的操作符 % 表示取模运算。)
每个通道包含向量寄存器文件的一部分和来自每个向量功能单元的一个执行流水线。每个向量功能单元以每个周期一个元素组的速率执行向量指令,使用多个流水线,每个通道一个。第一个通道保存所有向量寄存器的第一个元素(元素 0),因此,在任何向量指令中,第一个元素的源操作数和目标操作数将位于第一个通道中。这种分配使得本地于通道的算术流水线可以在不与其他通道通信的情况下完成操作。避免通道间通信减少了构建高度并行执行单元所需的布线成本和寄存器文件端口,这也解释了为什么向量计算机能够在每个时钟周期内完成多达 64 次操作(在 16 个通道上有 2 个算术单元和 2 个加载/存储单元)。
添加多个通道是一种提高向量性能的流行技术,因为它只需增加少量控制复杂性,并且不需要更改现有的机器代码。它还允许设计师在不牺牲峰值性能的情况下,权衡芯片面积、时钟频率、电压和能量。如果向量处理器的时钟频率减半,那么将通道数量加倍将保持相同的峰值性能。
### 向量长度寄存器:处理不等于 32 的循环
向量寄存器处理器具有由最大向量长度(mvl)决定的自然向量长度。在我们上述的例子中,这个长度是 32,但它不太可能与程序中的实际向量长度匹配。此外,在实际程序中,特定向量操作的长度通常在编译时是未知的。事实上,一段代码可能需要不同的向量长度。例如,考虑以下代码:

所有向量操作的大小取决于 n,这个值可能在运行时才会被确定。n 的值也可能是包含前面循环的过程的参数,因此在执行过程中可能会发生变化。
解决这些问题的方法是增加一个向量长度寄存器(vl)。vl 控制任何向量操作的长度,包括向量加载或存储。然而,vl 中的值不能大于最大向量长度(mvl)。只要实际长度小于或等于最大向量长度(mvl),这就解决了我们的问题。这个参数意味着向量寄存器的长度可以在后来的计算机世代中增长,而不需要更改指令集。正如我们将在下一节中看到的,多媒体 SIMD 扩展没有 mvl 的等效项,因此每次增加向量长度时都会扩展指令集。
如果 n 的值在编译时未知,从而可能大于 mvl,该怎么办?为了解决向量长度超过最大长度的第二个问题,传统上使用了一种称为条带挖掘的技术。条带挖掘生成的代码使得每个向量操作的大小小于或等于 mvl。一个循环处理任意数量的迭代,即 mvl 的倍数,另一个循环处理剩余的迭代,并且必须小于 mvl。RISC-V 提供了比单独循环条带挖掘更好的解决方案。指令 setvl 将 mvl 和循环变量 n 中较小的值写入 vl(以及另一个寄存器)。如果循环的迭代次数大于 n,那么循环每次能计算的最大值是 mvl,因此 setvl 将 vl 设置为 mvl。如果 n 小于 mvl,则它应仅在循环的最后一次迭代中计算最后 n 个元素,因此 setvl 将 vl 设置为 n。setvl 还会写入另一个标量寄存器,以帮助后续的循环记录。以下是针对任意 n 值的向量 DAXPY 的 RV64V 代码。

### 谓词寄存器:在向量循环中处理 IF 语句
根据阿姆达尔法则,我们知道在低到中等水平的向量化程序中,加速效果非常有限。循环内部存在条件语句(IF 语句)和使用稀疏矩阵是导致向量化水平较低的两个主要原因。包含循环中的 IF 语句的程序无法使用我们迄今讨论的技术以向量模式运行,因为 IF 语句在循环中引入了控制依赖性。同样,使用我们目前所见的任何能力也无法高效地实现稀疏矩阵。
在这里,我们探讨处理条件执行的策略,暂时不讨论稀疏矩阵的问题。
考虑以下用 C 语言编写的循环:

这个循环通常无法向量化,因为循环体的条件执行;然而,如果内部循环能够仅对 X[i] = 0 的迭代进行运算,那么减法操作就可以被向量化。
这种能力的常见扩展是向量掩码控制。在 RV64V 中,谓词寄存器保存掩码,基本上为向量指令中的每个元素操作提供条件执行。这些寄存器使用布尔向量来控制向量指令的执行,就像条件执行的指令使用布尔条件来决定是否执行标量指令(见第 3 章)。当谓词寄存器 p0 被设置时,所有后续向量指令仅对谓词寄存器中对应条目为 1 的向量元素进行操作。目标向量寄存器中与掩码寄存器中的 0 对应的条目不受向量操作的影响。与向量寄存器一样,谓词寄存器也可以配置并禁用。启用谓词寄存器会将其初始化为全 1,这意味着后续的向量指令将对所有向量元素进行操作。我们现在可以使用以下代码来处理前面的循环,假设 X 和 Y 的起始地址分别在 x5 和 x6 中:


编译器开发者使用“IF 转换”一词将 IF 语句转换为使用条件执行的直线代码序列。
然而,使用向量掩码寄存器确实有开销。在标量架构中,当条件不满足时,条件执行的指令仍然需要时间来执行。尽管如此,消除分支及其相关的控制依赖性可以使条件指令的执行速度更快,即使有时它们会执行无用的工作。同样,使用向量掩码执行的向量指令在掩码为零的元素上也会花费相同的执行时间。尽管掩码中可能有大量的零,但使用向量掩码控制仍然可能比使用标量模式显著更快。
正如我们在第 4.4 节中所看到的,向量处理器与 GPU 之间的一个区别在于它们处理条件语句的方式。向量处理器将谓词寄存器作为架构状态的一部分,并依赖编译器显式操作掩码寄存器。相比之下,GPU 则通过硬件操作对 GPU 软件不可见的内部掩码寄存器,实现相同的效果。在这两种情况下,硬件都会花时间执行向量元素,无论对应的掩码位是 0 还是 1,因此使用掩码时 GFLOPS 速率会下降。
### 内存银行:为向量加载/存储单元提供带宽
加载/存储向量单元的行为比算术功能单元复杂得多。加载的启动时间是将第一个字从内存获取到寄存器所需的时间。如果其余的向量能够在不造成停顿的情况下提供,那么向量的启动速率就等于新字被获取或存储的速率。与更简单的功能单元不同,启动速率不一定是1个时钟周期,因为内存银行的停顿会降低有效吞吐量。
通常,加载/存储单元的启动惩罚高于算术单元——在许多处理器上超过100个时钟周期。对于RV64V,我们假设启动时间为12个时钟周期,与Cray-1相同。(最近的向量计算机使用缓存来降低向量加载和存储的延迟。)
为了保持每个时钟周期获取或存储一个字的启动速率,内存系统必须能够产生或接受如此多的数据。将访问分散到多个独立的内存银行通常能提供所需的速率。正如我们将要看到的,拥有大量银行对于处理访问数据行或列的向量加载或存储是非常有用的。
大多数向量处理器使用内存银行,这允许多个独立访问,而不是简单的内存交错,原因有三:
1. 许多向量计算机支持每个时钟周期进行多次加载或存储,而内存银行的周期时间通常比处理器的周期时间大几倍。为了支持来自多个加载或存储的并发访问,内存系统需要多个银行,并且能够独立控制对这些银行的地址。
2. 大多数向量处理器支持加载或存储非顺序的数据字。在这种情况下,需要独立的银行寻址,而不是交错。
3. 大多数向量计算机支持多个处理器共享同一内存系统,因此每个处理器将生成自己独立的地址流。
结合这些特性,这些需求促使了对大量独立内存银行的渴望,以下示例将进一步说明这一点。
### 示例
Cray T90(Cray T932)的最大配置有32个处理器,每个处理器每个时钟周期能够生成4个加载和2个存储。处理器的时钟周期为2.167纳秒,而用于内存系统的SRAM的周期时间为15纳秒。计算所需的最小内存银行数量,以使所有处理器能够以满内存带宽运行。
**答案**
每个周期的最大内存引用次数为192次:32个处理器乘以每个处理器6次引用。每个SRAM银行在15/2.167≈6.92个时钟周期内处于忙碌状态,我们将其向上取整为7个处理器时钟周期。因此,我们需要至少192 ÷ 7 ≈ 1,344个内存银行。
实际上,Cray T932有1,024个内存银行,因此早期的型号无法同时支持所有处理器的满带宽。后来的内存升级用流水线同步SRAM替换了15纳秒的异步SRAM,从而将内存周期时间减少了一半以上,提供了足够的带宽。
从更高的层面来看,向量加载/存储单元在某种程度上类似于标量处理器中的预取单元,因为两者都试图通过为处理器提供数据流来提供数据带宽。
### 步幅:在向量架构中处理多维数组
向量中相邻元素在内存中的位置可能不是连续的。考虑以下用于矩阵乘法的简单 C 代码:

我们可以对矩阵 B 的每一行与矩阵 D 的每一列进行向量化乘法,并使用 k 作为索引变量来进行内循环的条带挖掘。为此,我们必须考虑如何寻址 B 中的相邻元素和 D 中的相邻元素。当一个数组被分配内存时,它是线性化的,必须按行优先顺序(如 C 中)或列优先顺序(如 Fortran 中)布局。这种线性化意味着行中的元素或列中的元素在内存中不是相邻的。例如,上述 C 代码按行优先顺序分配内存,因此在内循环迭代中访问的 D 的元素之间相隔行大小乘以 8(每个条目的字节数),总共为 800 字节。在第二章中,我们看到块分配能够改善基于缓存的系统中的局部性。对于没有缓存的向量处理器,我们需要另一种技术来获取在内存中不相邻的向量元素。
将要聚集到单个向量寄存器中的元素之间的距离称为步幅。在这个例子中,矩阵 D 的步幅为 100 个双字(800 字节),而矩阵 B 的步幅为 1 个双字(8 字节)。对于 Fortran 使用的列优先顺序,步幅则会反转。矩阵 D 的步幅为 1,或者说 1 个双字(8 字节),用于分隔连续元素,而矩阵 B 的步幅为 100,或者说 100 个双字(800 字节)。因此,在不重新排序循环的情况下,编译器无法隐藏 B 和 D 中连续元素之间的长距离。
一旦向量加载到向量寄存器中,它的表现就像具有逻辑相邻的元素。因此,向量处理器可以仅使用具有步幅能力的向量加载和向量存储操作来处理大于 1 的步幅,即非单位步幅。访问不连续内存位置并将其重塑为稠密结构的能力是向量架构的一大优势。
缓存本质上处理单位步幅数据;增加块大小可以帮助减少大型科学数据集的缺失率,但对于以非单位步幅访问的数据,增加块大小甚至可能产生负面影响。虽然块技术可以解决其中一些问题(参见第二章),但有效访问不连续数据的能力仍然是某些问题上向量处理器的优势,正如我们将在第 4.7 节中看到的那样。
在 RV64V 中,地址单位为字节,我们的例子中的步幅将是 800。这个值必须动态计算,因为矩阵的大小在编译时可能未知,或者就像向量长度一样,可能在同一语句的不同执行中变化。向量步幅和向量起始地址一样,可以放入通用寄存器中。然后,RV64V 指令 VLDS(带步幅加载向量)将向量提取到一个向量寄存器中。同样,当存储非单位步幅向量时,使用指令 VSTS(带步幅存储向量)。
支持大于1的步幅会使内存系统变得复杂。一旦引入非单位步幅,频繁请求来自同一存储银行的访问就变得可能。当多个访问请求争用同一个银行时,会发生内存银行冲突,从而导致一个访问被阻塞。如果出现银行冲突,则会导致停顿。

示例:假设我们有 8 个内存银行,每个银行的忙碌时间为 6 个时钟周期,总内存延迟为 12 个时钟周期。完成一次步幅为 1 的 64 元素向量加载需要多长时间?步幅为 32 呢?
答案:由于银行数量大于银行忙碌时间,对于步幅为 1 的情况,加载将花费 12 + 64/8 = 76 个时钟周期,即每个元素 1.2 个时钟周期。最糟糕的步幅是内存银行数量的倍数,在这种情况下,步幅为 32 且有 8 个内存银行。每次对内存的访问(在第一次之后)都会与之前的访问发生冲突,并且必须等待 6 个时钟周期的银行忙碌时间。总时间将是 12 + 1 + 6 * 63 = 391 个时钟周期,即每个元素 6.1 个时钟周期,速度降低了 5 倍!
聚集-分散:在向量架构中处理稀疏矩阵
如前所述,稀疏矩阵是常见的,因此拥有允许稀疏矩阵程序以向量模式执行的技术非常重要。在稀疏矩阵中,向量的元素通常以某种紧凑的形式存储,然后间接访问。假设一个简化的稀疏结构,我们可能会看到如下代码:

这段代码实现了对数组 A 和 C 的稀疏向量求和,使用索引向量 K 和 M 来指定 A 和 C 的非零元素。(A 和 C 必须具有相同数量的非零元素——n 个,因此 K 和 M 具有相同的大小。)
支持稀疏矩阵的主要机制是使用索引向量的聚集-分散操作。这类操作的目标是支持在稀疏矩阵的压缩表示(即不包括零)和正常表示(即包括零)之间移动。聚集操作通过一个索引向量获取向量,其元素位于通过将基地址与索引向量中给出的偏移量相加得到的地址上。结果是一个在向量寄存器中的密集向量。在对这些元素进行密集形式的操作后,可以通过分散存储将稀疏向量以扩展形式存储,使用相同的索引向量。此类操作的硬件支持称为聚集-分散,它出现在几乎所有现代向量处理器上。RV64V 指令包括 vldi(加载向量索引或聚集)和 vsti(存储向量索引或分散)。例如,如果 x5、x6、x7 和 x28 包含前一个序列中向量的起始地址,我们可以用如下的向量指令编码内部循环:

这种技术允许稀疏矩阵的代码以向量模式运行。一个简单的向量化编译器无法自动向量化前面的源代码,因为编译器无法知道 K 的元素是不同的值,因此不存在依赖关系。相反,程序员指令会告诉编译器安全地以向量模式运行循环。
虽然索引加载和存储(聚集和分散)可以进行流水线处理,但它们通常比非索引加载或存储慢得多,因为在指令开始时内存银行并不确定。寄存器文件还必须提供向量单元各个通道之间的通信,以支持聚集和分散。
每个聚集或分散的元素都有一个独立的地址,因此不能以组的方式处理,并且在内存系统的许多地方可能会出现冲突。因此,即使在基于缓存的系统中,每个单独的访问也会产生显著的延迟。然而,正如第 4.7 节所示,内存系统可以通过为这种情况设计并使用更多硬件资源来提供更好的性能,而不是在架构师对这种不可预测的访问采取放任态度时。
正如我们将在第 4.4 节看到的,所有加载都是聚集,而所有存储都是分散,因为在 GPU 中没有单独的指令限制地址为顺序。为了将潜在缓慢的聚集和分散转换为更高效的单位步幅内存访问,GPU 硬件必须在执行期间识别顺序地址,同时 GPU 程序员需确保聚集或分散中的所有地址都指向相邻位置。
### 编程向量架构
向量架构的一个优势是编译器可以在编译时告诉程序员某段代码是否会向量化,并通常提供未能向量化的原因提示。这种简单明了的执行模型使得其他领域的专家能够通过修改代码或在进行聚集-分散数据传输时向编译器提供操作独立性假设的提示,从而提升性能。正是这种编译器与程序员之间的对话,彼此提供如何提高性能的建议,简化了向量计算机的编程。
如今,影响程序在向量模式下成功运行的主要因素是程序本身的结构:循环是否存在真实的数据依赖(参见第4.5节),或者它们是否可以重构以消除这些依赖?这一因素受到所选算法的影响,并在某种程度上取决于编码方式。
作为科学程序中可实现向量化水平的一个指标,我们来看一下在Perfect Club基准测试中观察到的向量化水平。图4.7显示了在Cray Y-MP上运行的两种版本代码中以向量模式执行的操作百分比。第一种版本是仅通过编译器优化原始代码获得的,而第二种版本则利用了Cray Research编程团队提供的大量提示。多项对向量处理器上应用程序性能的研究显示,编译器向量化水平存在较大差异。

图4.7 Perfect Club基准测试在Cray Y-MP上执行时的向量化水平(Vajapeyam, 1991) 第一列显示了在没有提示的情况下,编译器获得的向量化水平;第二列则展示了在Cray Research编程团队提供的提示帮助下,代码改进后的结果。
提示丰富的版本在编译器本身无法良好向量化的代码中显示出显著的向量化水平提升,所有代码的向量化水平均超过了50%。中位数向量化水平从约70%提高到约90%。


4.3 SIMD Instruction Set Extensions for Multimedia

SIMD多媒体扩展始于一个简单的观察,即许多媒体应用处理的数据类型比32位处理器优化的类型要窄。图形系统通常使用8位来表示三种主要颜色,再加上8位用于透明度。根据应用的不同,音频样本通常用8位或16位表示。通过在256位加法器内部划分进位链,处理器可以对短向量同时执行操作,这些向量可以是三十二个8位操作数、十六个16位操作数、八个32位操作数或四个64位操作数。这种分区加法器的额外成本很小。图4.8总结了典型的多媒体SIMD指令。与向量指令类似,SIMD指令指定对数据向量执行相同的操作。不同于具有大型寄存器文件的向量机器,如RISC-V RV64V向量寄存器,每个寄存器可以容纳三十二个64位元素,SIMD指令通常指定较少的操作数,因此使用更小的寄存器文件。

与向量架构相比,后者提供了一个优雅的指令集,旨在成为向量化编译器的目标,SIMD扩展有三个主要的缺失:没有向量长度寄存器、没有跨步或聚合/分散数据传输指令,以及没有掩码寄存器。
1. 多媒体SIMD扩展在操作码中固定了数据操作数的数量,这导致x86架构的MMX、SSE和AVX扩展增加了数百条指令。向量架构有一个向量长度寄存器,用于指定当前操作的操作数数量。这些可变长度的向量寄存器能够轻松适应自然比架构支持的最大大小短的程序。此外,向量架构在架构中隐含一个最大向量长度,这与向量长度寄存器结合使用,避免了许多操作码的使用。
2. 直到最近,多媒体SIMD并未提供向量架构的更复杂的寻址模式,即跨步访问和聚合-分散访问。这些特性增加了向量编译器能够成功向量化的程序数量(见第4.7节)。
3. 尽管这种情况正在改变,但多媒体SIMD通常不提供掩码寄存器,以支持像向量架构那样的条件执行元素。
这样的遗漏使得编译器生成SIMD代码变得更加困难,也增加了使用SIMD汇编语言编程的难度。对于x86架构来说,1996年新增的MMX指令重新利用了64位浮点寄存器,因此基本指令可以同时执行八个8位操作或四个16位操作。这些指令还包括并行的最大值和最小值操作,以及多种掩码和条件指令,通常在数字信号处理器中找到的操作,以及被认为在重要媒体库中有用的特定指令。需要注意的是,MMX重用了浮点数据传输指令来访问内存。
1999年推出的流式SIMD扩展(SSE)增加了16个128位宽的独立寄存器(XMM寄存器),使得指令可以同时执行十六个8位操作、八个16位操作或四个32位操作。它还支持并行单精度浮点运算。由于SSE有独立的寄存器,因此需要独立的数据传输指令。英特尔很快通过SSE2(2001年)、SSE3(2004年)和SSE4(2007年)添加了双精度SIMD浮点数据类型。具有四个单精度浮点操作或两个并行双精度操作的指令提高了x86计算机的峰值浮点性能,只要程序员将操作数并排放置。每一代还增加了一些特定指令,旨在加速被认为重要的特定多媒体功能。
2010年推出的高级向量扩展(AVX)再次将寄存器宽度加倍至256位(YMM寄存器),从而提供了对所有较窄数据类型的操作数数量加倍的指令。图4.9展示了适用于双精度浮点计算的AVX指令。2013年,AVX2增加了30条新指令,如聚合(VGATHER)和向量移位(VPSLL、VPSRL、VPSRA)。2017年的AVX-512再次将宽度加倍至512位(ZMM寄存器),寄存器数量也加倍至32,并增加了约250条新指令,包括散射(VPSCATTER)和掩码寄存器(OPMASK)。AVX还为未来版本的架构准备了将寄存器扩展至1024位的方案。
总体而言,这些扩展的目标是加速经过精心编写的库,而不是让编译器生成这些库(见附录H),但最近的x86编译器正在尝试生成此类代码,特别是对于浮点密集型应用。由于操作码决定了SIMD寄存器的宽度,因此每次宽度加倍时,SIMD指令的数量也必须加倍。

图4.9展示了在x86架构中适用于双精度浮点程序的AVX指令。对于256位AVX,打包双精度意味着以SIMD模式执行四个64位操作数。随着AVX的宽度增加,添加数据置换指令变得越来越重要,这些指令允许从宽寄存器的不同部分组合窄操作数。AVX包括在256位寄存器内对32位、64位或128位操作数进行洗牌的指令。例如,BROADCAST指令可以在AVX寄存器中将一个64位操作数复制四次。AVX还包括多种融合乘加/减指令,这里仅展示其中两个。
鉴于这些弱点,为什么多媒体SIMD扩展如此受欢迎?首先,它们最初添加到标准算术单元的成本很低,而且实现起来相对简单。其次,与向量架构相比,它们所需的额外处理器状态很少,这在上下文切换时间中始终是一个关注点。第三,支持向量架构需要大量的内存带宽,而许多计算机并不具备这样的条件。第四,SIMD不必处理虚拟内存中的问题,因为一个指令可以生成32个内存访问,而任何一个都可能导致页面错误。最初的SIMD扩展使用独立的数据传输,每组操作数在内存中对齐,因此它们不能跨越页面边界。SIMD的另一个优势是短固定长度的“向量”使得引入能够支持新媒体标准的指令变得简单,比如执行排列的指令或消耗比向量能产生的操作数更少或更多的指令。最后,人们对向量架构与缓存的兼容性表示担忧。然而,更现代的向量架构已经解决了所有这些问题。总体而言,由于向后兼容性的极端重要性,一旦一种架构开始走上SIMD路径,就很难再脱离它。
示例:为了了解多媒体指令的样子,假设我们为RISC-V添加了一个256位的SIMD多媒体指令扩展,暂称为RVP(代表“打包”)。在这个例子中,我们专注于浮点运算。我们在操作四个双精度操作数的指令上添加后缀“4D”。像向量架构一样,你可以把SIMD处理器想象成有多个通道,这里有四个。RV64P将F寄存器扩展到全宽,即256位。这个例子展示了DAXPY循环的RISC-V SIMD代码,对RISC-V SIMD代码的修改部分用下划线标出。我们假设X和Y的起始地址分别在x5和x6中。
答案:以下是RISC-V SIMD代码:

这些变化包括用相应的4D指令替换每条RISC-V双精度指令,将增量从8增加到32,并添加了将a的4个副本放入f0的256位中的splat指令。尽管与RV64V动态指令带宽减少32倍相比不那么显著,RISC-V SIMD仍然实现了近4倍的减少:执行的指令数从258减少到67。这段代码知道元素的数量,而这个数量通常在运行时确定,这将需要一个额外的分块循环来处理数量不是4的倍数的情况。
编程多媒体SIMD架构
由于SIMD多媒体扩展的临时性,使用这些指令的最简单方法是通过库或编写汇编语言。最近的扩展变得更加规范,使编译器有了更合理的目标。通过借鉴向量化编译器的技术,编译器开始自动生成SIMD指令。例如,现代高级编译器能够生成SIMD浮点指令,从而为科学代码提供更高的性能。然而,程序员必须确保内存中的所有数据都与运行代码的SIMD单元的宽度对齐,以防止编译器为本可以向量化的代码生成标量指令。
## 屋顶线可视性能模型
一种直观的方式来比较不同SIMD架构潜在浮点性能的是屋顶线模型(Williams et al., 2009)。该模型生成的图表中的水平和对角线赋予了它这个简单模型的名称,并指出了它的价值(见图4.11)。它将浮点性能、内存性能和算术强度结合在一个二维图中。算术强度是每字节内存访问的浮点操作的比率。它可以通过将程序的总浮点操作数除以程序执行期间传输到主内存的总数据字节数来计算。图4.10显示了几个示例内核的相对算术强度。

图4.10 算术强度 算术强度被定义为运行程序所需的浮点操作数与在主内存中访问的字节数之比(Williams et al., 2009)。某些内核的算术强度会随着问题规模的变化而变化,例如稠密矩阵,但也有许多内核的算术强度与问题规模无关。

图4.11 屋顶线模型 图4.11展示了左侧的NEC SX-9向量处理器和右侧的Intel Core i7 920多核计算机的屋顶线模型(Williams et al., 2009)。该模型针对单位步幅内存访问和双精度浮点性能。NEC SX-9是一款于2008年发布的向量超级计算机,价格高达数百万美元,其峰值双精度浮点性能为102.4 GFLOP/s,峰值内存带宽为162 GB/s(通过Stream基准测试得出)。而Core i7 920的峰值双精度浮点性能为42.66 GFLOP/s,峰值内存带宽为16.4 GB/s。
在算术强度为4 FLOP/字节的虚线处,两种处理器均以峰值性能运行。在这种情况下,SX-9的性能为102.4 GFLOP/s,比Core i7的42.66 GFLOP/s快了2.4倍。在算术强度为0.25 FLOP/字节时,SX-9的性能为40.5 GFLOP/s,而Core i7仅为4.1 GFLOP/s,显示SX-9快了10倍。
峰值浮点性能可以通过硬件规格来找到。在这个案例研究中,许多内核并不适合放入片上缓存,因此峰值内存性能由缓存后面的内存系统定义。需要注意的是,我们需要的是处理器可用的峰值内存带宽,而不仅仅是在图4.27(第328页)中显示的DRAM引脚处。查找(实际)峰值内存性能的一种方法是运行Stream基准测试。
图4.11展示了左侧NEC SX-9向量处理器的屋顶线模型和右侧Intel Core i7 920多核计算机的模型。纵轴表示可实现的浮点性能,从2到256 GFLOPS/s。横轴表示算术强度,在两个图表中变化范围从1/8 FLOP/DRAM字节访问到16 FLOP/DRAM字节访问。请注意,图表采用对数-对数尺度,并且每台计算机的屋顶线只绘制一次。
对于给定的内核,我们可以根据其算术强度在X轴上找到一个点。如果我们通过该点画一条垂直线,则该计算机上内核的性能必须位于该线的某个位置。我们还可以绘制一条水平线,显示计算机的峰值浮点性能。显然,实际的浮点性能不可能高于水平线,因为那是硬件限制。
如何绘制峰值内存性能?因为X轴是FLOP/字节,Y轴是FLOP/s,所以在这个图中,字节/s仅是一个45度角的对角线。因此,我们可以绘制第三条线,表示该计算机的内存系统在给定算术强度下可以支持的最大浮点性能。我们可以用公式来表达这些限制,从而在图4.11中绘制这些线:

“屋顶线”模型为内核的性能设定了一个上限,这个上限取决于其算术强度。如果我们把算术强度想象成一个触碰屋顶的柱子,那么它要么触碰到屋顶的平坦部分,意味着性能受到计算能力的限制;要么触碰到屋顶的倾斜部分,意味着性能最终受到内存带宽的限制。在图4.11中,右侧的垂直虚线(算术强度为4)是前者的例子,而左侧的垂直虚线(算术强度为1/4)是后者的例子。给定一台计算机的屋顶线模型,你可以反复应用它,因为它不因内核而异。
注意,“脊点”是对角线与水平屋顶相交的地方,这为计算机提供了有趣的见解。如果它位于右侧,则只有具有非常高算术强度的内核才能达到该计算机的最大性能。如果它位于左侧,则几乎任何内核都可以潜在地达到最大性能。如我们所见,这款向量处理器与其他SIMD处理器相比,具有更高的内存带宽和更靠左的脊点。
图4.11显示,SX-9的峰值计算性能比Core i7快2.4倍,但内存性能快10倍。对于算术强度为0.25的程序,SX-9快10倍(40.5对比4.1 GFLOP/s)。更高的内存带宽使得脊点从Core i7的2.6移动到SX-9的0.6,这意味着更多的程序能够在向量处理器上达到峰值计算性能。


4.4 Graphics Processing Units 

人们可以花几百美元购买一块拥有数千个并行浮点单元的GPU芯片,并将其插入到桌面PC中。这种经济实惠和便利性使得高性能计算对许多人变得可用。当这种潜力与一种更易于编程的编程语言结合时,GPU计算的兴趣迅速增长。因此,今天很多科学和多媒体应用的程序员都在考虑是使用GPU还是CPU。对于对机器学习感兴趣的程序员(第七章的主题),目前GPU是首选平台。
GPU和CPU在计算机架构的谱系中并没有共同的祖先;没有“缺失的环节”可以解释两者的关系。如4.10节所述,GPU的主要祖先是图形加速器,因为出色的图形处理是GPU存在的原因。虽然GPU正在向主流计算发展,但它们不能放弃继续在图形处理方面表现卓越的责任。因此,当架构师思考如何利用投资于图形处理的硬件来补充其他应用性能时,GPU的设计可能会更加合理。
请注意,本节重点讨论使用GPU进行计算。要了解GPU计算如何与传统的图形加速角色结合,请参见John Nickolls和David Kirk的“图形与计算GPU”(该书的第五版附录A由与本书相同的作者撰写)。
由于术语和一些硬件特性与矢量和SIMD架构有很大不同,我们认为在描述架构之前,从简化的GPU编程模型开始会更容易。
### 编程GPU
CUDA是一个优雅的解决方案,能够有效地表示算法中的并行性,并不是所有算法,但足够多的算法使其具有重要意义。它在某种程度上与我们的思维和编码方式产生共鸣,使得在任务层级之外,可以更容易、更自然地表达并行性。
—— Vincent Natol,《Kudos for CUDA》,HPC Wire(2010)
对GPU程序员来说,挑战不仅仅是如何在GPU上获得良好的性能,还包括协调系统处理器和GPU之间的计算调度,以及系统内存与GPU内存之间的数据传输。此外,正如我们将在本节后面看到的,GPU几乎具备所有可以通过编程环境捕捉到的并行性类型:多线程、MIMD、SIMD,甚至指令级并行。
NVIDIA决定开发一种类似C语言的语言和编程环境,通过解决异构计算和多层次并行性的挑战,来提高GPU程序员的生产力。该系统的名称是CUDA,代表计算统一设备架构。CUDA为系统处理器(主机)生成C/C++代码,同时为GPU(设备)提供一种C和C++的方言。类似的编程语言还有OpenCL,多个公司正在开发它,以提供一种平台无关的语言。
NVIDIA认为,所有这些并行形式的统一主题是CUDA线程。利用这一最低级别的并行性作为编程原语,编译器和硬件可以将成千上万的CUDA线程组合在一起,以利用GPU内各种样式的并行性:多线程、MIMD、SIMD和指令级并行。因此,NVIDIA将CUDA编程模型分类为单指令、多线程(SIMT)。出于一些原因,这些线程被一起阻塞,并以称为线程块(Thread Block)的线程组执行。我们称执行整个线程块的硬件为多线程SIMD处理器。
在给出CUDA程序示例之前,我们需要几个细节:
- 为区分用于GPU(设备)的函数和用于系统处理器(主机)的函数,CUDA使用`__device__`或`__global__`表示前者,使用`__host__`表示后者。
- 使用`__device__`声明的CUDA变量分配到GPU内存(见下文),所有多线程SIMD处理器都可以访问。
- 在GPU上运行的函数的扩展函数调用语法为:
  ```
  name<<<dimGrid, dimBlock>>>(…参数列表…)
  ```
  其中,`dimGrid`和`dimBlock`指定代码的维度(以线程块为单位)和一个块的维度(以线程为单位)。
- 除了块标识符(`blockIdx`)和块中每个线程的标识符(`threadIdx`)外,CUDA还提供了一个关键字表示每个块中的线程数(`blockDim`),该值来自上一个要点中的`dimBlock`参数。
在查看CUDA代码之前,让我们先看一下第4.2节中DAXPY循环的常规C代码:

以下是CUDA版本。我们启动n个线程,每个向量元素一个线程,每个线程块中包含256个CUDA线程,运行在多线程SIMD处理器上。GPU函数首先根据块ID、每块的线程数和线程ID计算相应的元素索引i。只要该索引在数组范围内(i < n),它就执行乘法和加法操作。

比较C和CUDA代码,我们可以看到一个将数据并行CUDA代码并行化的共同模式。C版本有一个循环,其中每次迭代相互独立,这使得循环可以直接转换为并行代码,每次循环迭代变成一个单独的线程。(如前所述,并在第4.5节中详细描述,向量化编译器也依赖于循环迭代之间的无依赖性,这被称为循环传递依赖。)程序员通过指定网格维度和每个SIMD处理器的线程数来明确确定CUDA中的并行性。通过将每个元素分配给一个线程,在将结果写入内存时不需要在线程之间进行同步。
GPU硬件负责并行执行和线程管理;这不是由应用程序或操作系统完成的。为了简化硬件调度,CUDA要求线程块能够独立执行且顺序不限。不同的线程块不能直接通信,尽管它们可以使用全局内存中的原子内存操作进行协调。
正如我们很快会看到的,许多GPU硬件概念在CUDA中并不明显。编写高效的GPU代码要求程序员以SIMD操作的方式思考,尽管CUDA编程模型看起来像是MIMD。性能导向的程序员在编写CUDA代码时必须考虑GPU硬件。这可能会影响程序员的生产力,但大多数程序员使用GPU而非CPU以获得更好的性能。由于稍后解释的原因,他们知道需要将32个线程分组在控制流中,以便从多线程SIMD处理器中获得最佳性能,并创建更多线程以隐藏对DRAM的延迟。他们还需要保持数据地址在一个或几个内存块中本地化,以获得预期的内存性能。
与许多并行系统一样,CUDA在生产力和性能之间的折衷是包含内在函数,以便让程序员对硬件进行显式控制。在并行计算中,生产力与允许程序员表达硬件能够执行的任何操作之间的斗争经常发生。观察这个语言在经典的生产力与性能斗争中如何演变,以及CUDA是否会在其他GPU甚至其他架构风格中变得流行,将会很有趣。
### NVIDIA GPU 计算结构
上述独特的遗产有助于解释为什么GPU拥有自己独特的架构风格和与CPU独立的术语。理解GPU的一大障碍是行话,有些术语甚至具有误导性的名称。这一障碍出乎意料地难以克服,正如本章的多次重写所证明的那样。
为了桥接使GPU架构易于理解与学习许多具有非传统定义的GPU术语这两个目标,我们的方法是对软件使用CUDA术语,但最初对硬件使用更具描述性的术语,有时借用OpenCL中的术语。一旦我们用自己的术语解释了GPU架构,就会将其映射到NVIDIA GPU的官方行话中。
从左到右,图4.12列出了本节中使用的描述性术语、主流计算中最接近的术语、NVIDIA GPU的官方术语(如果你感兴趣的话),以及该术语的简短解释。本节的其余部分将使用图左侧的描述性术语来解释GPU的微架构特征。
我们以NVIDIA系统作为例子,因为它们代表了GPU架构。具体而言,我们遵循前面CUDA并行编程语言的术语,并以NVIDIA Pascal GPU作为示例(见第4.7节)。
像向量架构一样,GPU 仅在数据级并行问题上表现良好。这两种风格都有聚集-散布的数据传输和掩码寄存器,并且 GPU 处理器的寄存器数量甚至比向量处理器还要多。有时,GPU 在硬件中实现某些特性,而向量处理器则会在软件中实现。这种差异是因为向量处理器有一个可以执行软件函数的标量处理器。与大多数向量架构不同,GPU 还依赖于单个多线程 SIMD 处理器中的多线程来隐藏内存延迟(见第 2 章和第 3 章)。然而,对于向量架构和 GPU 的高效代码,程序员需要以 SIMD 操作组的方式进行思考。

图4.12 本章中使用的GPU术语快速指南。第一列用于硬件术语。这11个术语分为四组,从上到下分别是:程序抽象、机器对象、处理硬件和内存硬件。图4.21(第312页)将向量术语与此处最接近的术语关联起来,图4.24(第317页)和图4.25(第318页)展示了官方CUDA/NVIDIA和AMD的术语及定义,以及OpenCL使用的术语。
Grid 是在 GPU 上运行的代码,由一组线程块组成。图 4.12 比较了 grid 和向量化循环之间的类比,以及线程块和该循环主体之间的关系(在经过剥离后,使其成为完整的计算循环)。举个具体的例子,假设我们想将两个长度为 8192 的向量相乘:A = B * C。我们将在本节中反复提到这个例子。图 4.13 显示了这个例子与前两个 GPU 概念之间的关系。处理整个 8192 元素乘法的 GPU 代码称为 Grid(或向量化循环)。为了将其分解为更易管理的大小,Grid 由线程块组成(或向量化循环的主体),每个线程块最多包含 512 个元素。请注意,SIMD 指令一次执行 32 个元素。由于向量中有 8192 个元素,因此这个例子有 16 个线程块,因为 16 × 512 = 8192。Grid 和线程块是实现于 GPU 硬件中的编程抽象,有助于程序员组织他们的 CUDA 代码。(线程块类似于向量长度为 32 的剥离向量循环。)

图4.13 显示了网格(可向量化循环)、线程块(SIMD基本块)和SIMD指令线程在向量-向量乘法中的映射, 每个向量长8192个元素。每个SIMD指令线程每条指令计算32个元素, 在本例中,每个线程块包含16个SIMD指令线程,网格包含16个线程块。硬件线程块调度器将线程块分配给多线程SIMD处理器,而硬件线程调度器选择在每个时钟周期内在SIMD处理器中运行的SIMD指令线程。只有同一线程块中的SIMD线程可以通过本地内存进行通信。(对于Pascal GPU,每个线程块可以同时执行的最大SIMD线程数为32。)
线程块被分配给执行该代码的处理器,我们称之为多线程 SIMD 处理器,由线程块调度器进行调度。程序员告诉线程块调度器(它是在硬件中实现的)要运行多少个线程块。在这个例子中,它将发送 16 个线程块到多线程 SIMD 处理器,以计算该循环的所有 8192 个元素。
图4.14 显示了一个多线程SIMD处理器的简化框图。它类似于向量处理器,但拥有多个并行功能单元,而不是像向量处理器那样只有少数深度流水线的单元。在图4.13中的编程示例中,每个多线程SIMD处理器被分配512个向量元素进行处理。SIMD处理器是完整的处理器,具有独立的程序计数器(PC),并使用线程进行编程(见第3章)。

GPU硬件包含一组多线程SIMD处理器,它们执行线程块的网格(向量化循环的主体);也就是说,GPU是由多线程SIMD处理器组成的多处理器系统。一个GPU可以有从一个到几十个多线程SIMD处理器。例如,Pascal P100系统有56个,而较小的芯片可能只有一两个。为了在具有不同数量多线程SIMD处理器的GPU模型之间提供透明的可扩展性,线程块调度器将线程块(向量化循环的主体)分配给多线程SIMD处理器。

图4.15 显示了P100实现的Pascal架构的平面图。进一步细化一下,硬件创建、管理、调度和执行的机器对象是SIMD指令的线程。这是一个传统的线程,专门包含SIMD指令。这些SIMD指令的线程有自己的程序计数器,并在多线程SIMD处理器上运行。SIMD线程调度器知道哪些SIMD指令线程准备好运行,然后将它们发送到调度单元,在多线程SIMD处理器上执行。因此,GPU硬件有两个级别的硬件调度器:(1)将线程块(向量化循环的主体)分配给多线程SIMD处理器的线程块调度器,以及(2)在SIMD处理器内的SIMD线程调度器,用于调度SIMD指令线程何时运行。
这些线程的SIMD指令宽度为32,因此在这个示例中,每个SIMD指令线程将计算32个计算元素。在这个示例中,线程块将包含512/32=16个SIMD线程(见图4.13)。
由于线程由SIMD指令组成,SIMD处理器必须具有并行功能单元来执行操作。我们称它们为SIMD通道,它们与第4.2节中的向量通道非常相似。
在Pascal GPU中,每个32宽的SIMD指令线程映射到16个物理SIMD通道,因此每条SIMD指令在一个SIMD指令线程中需要2个时钟周期才能完成。每个SIMD指令线程以锁步方式执行,并仅在开始时进行调度。继续将SIMD处理器类比为向量处理器,可以说它有16个通道,向量长度为32,时钟周期为2。(这种宽而浅的特性是我们使用“SIMD处理器”这一更准确术语而不是“向量”的原因。)
请注意,GPU SIMD处理器中的通道数量可以是线程块中线程数量的任何值,就像向量处理器中的通道数量可以在1到最大向量长度之间变化一样。例如,在不同的GPU代际中,每个SIMD处理器的通道数量在8到32之间波动。
由于定义上SIMD指令线程是独立的,SIMD线程调度器可以选择任何准备好的SIMD指令线程,而不必坚持于线程内的下一个SIMD指令。SIMD线程调度器包括一个分数板(见第3章),用于跟踪最多64个SIMD指令线程,以查看哪些SIMD指令已准备好执行。内存指令的延迟是可变的,因为缓存和TLB中的命中和未命中,因此需要分数板来确定这些指令何时完成。图4.16展示了SIMD线程调度器随时间选择不同顺序的SIMD指令线程。GPU架构师的假设是,GPU应用程序具有如此多的SIMD指令线程,以至于多线程可以隐藏对DRAM的延迟并提高多线程SIMD处理器的利用率。

图4.16 显示了SIMD指令线程的调度。调度器选择一个准备好的SIMD指令线程,并同步地向所有执行该SIMD线程的SIMD通道发出指令。由于SIMD指令线程是独立的,因此调度器每次可以选择不同的SIMD线程。
继续我们的向量乘法示例,每个多线程SIMD处理器必须从内存中加载两个向量的32个元素到寄存器中,执行乘法操作,通过读取和写入寄存器来完成,并将乘积从寄存器存回内存。为了存储这些内存元素,SIMD处理器根据Pascal GPU的型号,拥有32,768到65,536个32位寄存器(如图4.14所示,每个通道1024个)。就像向量处理器一样,这些寄存器在逻辑上被分配到向量通道,或者在这种情况下是SIMD通道。
每个SIMD线程最多限制使用256个寄存器,因此可以把一个SIMD线程视为拥有最多256个向量寄存器,每个向量寄存器有32个元素,每个元素为32位宽。(因为双精度浮点操作数使用两个相邻的32位寄存器,另一种看法是每个SIMD线程有128个32元素的向量寄存器,每个都是64位宽。)
寄存器使用和最大线程数量之间存在权衡;每个线程的寄存器越少,可能的线程数量就越多,而更多的寄存器则意味着线程数量减少。也就是说,并非所有SIMD线程都需要达到最大寄存器数量。Pascal架构师认为,如果所有线程都拥有最大数量的寄存器,很多宝贵的硅区域将处于闲置状态。
为了能够执行许多SIMD指令线程,在创建SIMD指令线程时,每个线程动态分配一组物理寄存器,并在SIMD线程退出时释放。例如,程序员可以有一个线程块,每个线程使用36个寄存器,假设有16个SIMD线程,同时还有另一个线程块,每个线程使用20个寄存器,有32个SIMD线程。后续的线程块可以以任何顺序出现,寄存器需要按需分配。虽然这种可变性可能导致碎片化并使某些寄存器无法使用,但在实际应用中,大多数线程块对于给定的可向量化循环(“网格”)使用相同数量的寄存器。硬件必须知道每个线程块在大型寄存器文件中的寄存器位置,这在每个线程块的基础上进行记录。这种灵活性需要硬件中的路由、仲裁和银行机制,因为特定线程块的寄存器可能在寄存器文件中的任何位置。
请注意,CUDA线程只是SIMD指令线程的垂直切片,对应于一个由一个SIMD通道执行的元素。请注意,CUDA线程与POSIX线程非常不同;您不能在CUDA线程中进行任意系统调用。
现在我们准备来看一下GPU指令的样子。
NVIDIA GPU 指令集架构
与大多数系统处理器不同,NVIDIA 编译器的指令集目标是硬件指令集的抽象。PTX(并行线程执行)为编译器提供了一个稳定的指令集,并确保跨代 GPU 的兼容性。硬件指令集对程序员是隐藏的。
PTX 指令描述了单个 CUDA 线程上的操作,通常与硬件指令一一对应,但一个 PTX 指令可以扩展为多个机器指令,反之亦然。PTX 使用无限数量的只写寄存器,编译器必须运行寄存器分配程序,将 PTX 寄存器映射到实际设备上固定数量的读写硬件寄存器。随后,优化器运行并可以进一步减少寄存器的使用。该优化器还消除死代码,合并指令,并计算可能分支的地方和可能收敛的分支路径的位置。
尽管 x86 微架构和 PTX 之间存在一些相似性(都是转换为内部形式,x86 的微指令),但两者的不同之处在于,这种转换在 x86 上是在运行时的硬件中完成,而在 GPU 上则是在软件加载时完成。
PTX 指令的格式为:
```
opcode.type d, a, b, c;
```
其中,d 是目标操作数;a、b 和 c 是源操作数;操作类型是以下之一:

源操作数可以是 32 位或 64 位寄存器或常量值。目标通常是寄存器,存储指令除外。

图 4.17 显示了基本的 PTX 指令集。所有指令都可以通过 1 位谓词寄存器进行预测,这些寄存器可以通过设置谓词指令(setp)进行设置。控制流指令包括函数调用和返回、线程退出、分支和线程块内的屏障同步(bar.sync)。在分支指令前放置一个谓词可以实现条件分支。编译器或 PTX 程序员可以将虚拟寄存器声明为 32 位或 64 位的有类型或无类型值。例如,R0、R1 等用于 32 位值,而 RD0、RD1 等用于 64 位寄存器。请注意,虚拟寄存器到物理寄存器的分配是在加载时与 PTX 一起进行的。
以下是一组 PTX 指令,表示我们 DAXPY 循环的一次迭代:

如上所示,CUDA 编程模型将每次循环迭代分配给一个 CUDA 线程,并为每个线程块(blockIdx)和每个块内的 CUDA 线程(threadIdx)提供唯一的标识符。因此,它创建了 8192 个 CUDA 线程,并使用这些唯一编号来访问数组中的每个元素,因此没有递增或分支代码。前面的三条 PTX 指令计算出在 R8 中的唯一元素字节偏移量,该偏移量加到数组的基地址上。后续的 PTX 指令加载两个双精度浮点操作数,进行乘法和加法运算,并存储结果。(我们将在下面描述与 CUDA 代码“if (i < n)”对应的 PTX 代码。)
值得注意的是,与向量架构不同,GPU 没有用于顺序数据传输、跨步数据传输和聚集-分散数据传输的单独指令。所有数据传输都是聚集-分散!为了恢复顺序(单位跨步)数据传输的效率,GPU 包括特殊的地址合并硬件,以识别 SIMD 指令线程内的 SIMD 通道何时共同发出顺序地址。该运行时硬件随后通知内存接口单元请求 32 个顺序字的块传输。为了获得这一重要的性能提升,GPU 程序员必须确保相邻的 CUDA 线程同时访问附近地址,以便它们可以合并为一个或几个内存或缓存块,这正是我们的示例所做的。
### GPU中的条件分支
与单位跨步数据传输类似,向量架构和GPU在处理IF语句方面有很强的相似性,前者主要通过软件实现机制,硬件支持有限,而后者则利用更多的硬件。正如我们所看到的,除了显式的谓词寄存器外,GPU的分支硬件还使用内部掩码、分支同步栈和指令标记来管理何时分支到多个执行路径以及这些路径何时汇合。
在PTX汇编级别,一个CUDA线程的控制流由PTX指令(如branch、call、return和exit)描述,此外,还包括每条指令的逐线程预测,由程序员通过每线程的1位谓词寄存器指定。PTX汇编器分析PTX分支图并将其优化为最快的GPU硬件指令序列。每个线程可以独立决策分支,而不需要严格同步。
在GPU硬件指令级别,控制流包括分支、跳转、索引跳转、调用、索引调用、返回、退出以及管理分支同步栈的特殊指令。GPU硬件为每个SIMD线程提供自己的栈;栈条目包含一个标识符令牌、目标指令地址和目标线程活动掩码。有特殊指令用于为SIMD线程推送栈条目,以及用于弹出栈条目或将栈回退到指定条目的特殊指令和指令标记,并以目标指令地址和目标线程活动掩码进行分支。GPU硬件指令还具有逐通道的个别预测(启用/禁用),每个通道都有1位的谓词寄存器进行指定。
PTX汇编器通常将简单的外层IF-THEN-ELSE语句优化为仅由预测的GPU指令构成,而不使用任何GPU分支指令。更复杂的控制流通常会导致预测和GPU分支指令的混合,同时使用特殊指令和标记,当某些通道跳转到目标地址而其他通道落空时,会使用分支同步栈推送栈条目。NVIDIA称这种情况为“分支分歧”。当SIMD通道执行同步标记或汇合时,也会使用这种混合,这会弹出栈条目并根据栈条目的线程活动掩码进行分支到栈条目地址。
PTX汇编器识别循环分支,并生成跳转到循环顶部的GPU分支指令,以及特殊的栈指令,以处理单个通道从循环中退出并在所有通道完成循环时汇聚SIMD通道。GPU索引跳转和索引调用指令会在栈上推送条目,这样当所有通道完成switch语句或函数调用时,SIMD线程就会汇聚。
GPU设置谓词指令(如图4.17中的setp)评估IF语句的条件部分。PTX分支指令随后依赖于该谓词。如果PTX汇编器生成不带GPU分支指令的谓词指令,它会使用每个通道的谓词寄存器来启用或禁用每条指令的每个SIMD通道。在IF语句的THEN部分,线程中的SIMD指令将操作广播到所有SIMD通道。那些谓词设置为1的通道执行操作并存储结果,而其他SIMD通道则不执行操作或存储结果。对于ELSE语句,指令使用谓词的补码(相对于THEN语句),因此之前空闲的SIMD通道现在执行操作并存储结果,而之前活跃的通道则不执行。在ELSE语句结束时,指令变为无谓词状态,以便原始计算可以继续。因此,对于等长路径,IF-THEN-ELSE的效率为50%或更低。
IF语句可以嵌套,因此需要使用栈,PTX汇编器通常会为复杂的控制流生成混合的谓词指令、GPU分支指令和特殊同步指令。请注意,深度嵌套可能意味着在执行嵌套条件语句期间大多数SIMD通道处于空闲状态。因此,具有等长路径的双重嵌套IF语句的效率为25%,三重嵌套为12.5%,依此类推。类似的情况是,向量处理器的掩码位只有少数为1。
在更详细的层面上,PTX汇编器在适当的条件分支指令上设置“分支同步”标记,该标记会在每个SIMD线程内部的栈上推送当前活动的掩码。如果条件分支发生分歧(某些通道采取分支而其他通道落空),它会推送一个栈条目,并根据条件设置当前内部活动掩码。分支同步标记弹出分歧的分支条目,并在ELSE部分之前翻转掩码位。在IF语句结束时,PTX汇编器添加另一个分支同步标记,将之前的活动掩码从栈中弹出到当前活动掩码中。
如果所有的掩码位都设置为1,那么THEN部分末尾的分支指令将跳过ELSE部分的指令。对于THEN部分,如果所有的掩码位都是0,也有类似的优化,因为条件分支会跳过THEN指令。并行的IF语句和PTX分支通常使用所有通道一致的分支条件(即所有通道同意遵循相同的路径),这样SIMD线程就不会在不同的通道控制流中发生分歧。PTX汇编器会优化这些分支,跳过未由任何SIMD线程的通道执行的指令块。这种优化在条件错误检查中非常有用,例如,在测试必须进行但很少被采纳的情况下。
类似于第4.2节中条件语句的代码是:

这个IF语句可以编译成以下PTX指令(假设R8已经具有缩放的线程ID),其中 *Push、*Comp、*Pop 表示由PTX汇编器插入的分支同步标记,它们推送旧的掩码、取反当前掩码,并弹出以恢复旧的掩码:

再次强调,通常情况下,IF-THEN-ELSE语句中的所有指令都是由SIMD处理器执行的。只不过,只有一些SIMD通道被启用以执行THEN指令,而其他通道则用于ELSE指令。如前所述,在个别通道对条件分支达成一致的常见情况下——例如基于一个对所有通道都相同的参数值进行分支,使得所有活跃的掩码位都是0或都是1——该分支将跳过THEN指令或ELSE指令。
这种灵活性使得看起来每个元素都有自己的程序计数器;然而,在最慢的情况下,每两个时钟周期中只有一个SIMD通道可以存储其结果,其余通道处于空闲状态。对于向量架构而言,类似的最慢情况是仅设置一个掩码位为1。这种灵活性可能会导致天真的GPU程序员出现性能不佳的问题,但在程序开发的早期阶段,它是有帮助的。然而,请记住,SIMD通道在一个时钟周期内唯一的选择是执行PTX指令中指定的操作或保持空闲;两个SIMD通道不能同时执行不同的指令。
这种灵活性也有助于解释为什么在SIMD指令线程中,每个元素被称为CUDA线程,因为它给人以独立操作的错觉。天真的程序员可能认为这种线程抽象意味着GPU可以更优雅地处理条件分支。一些线程走一条路径,其他线程走另一条路径,这似乎是正确的,只要你不着急。每个CUDA线程要么在执行与线程块中其他线程相同的指令,要么处于空闲状态。这种同步使得处理带有条件分支的循环变得更加简单,因为掩码功能可以关闭SIMD通道,并且能够自动检测循环的结束。
最终的性能有时会与这种简单的抽象形成鲜明对比。在这种高度独立的MIMD模式下编写操作SIMD通道的程序,就像在物理内存较小的计算机上编写使用大量虚拟地址空间的程序一样。两者都是正确的,但它们运行得如此缓慢,以至于程序员对结果不会满意。
条件执行是GPU在运行时硬件中实现的一个案例,而矢量架构则在编译时完成这一操作。矢量编译器进行双重IF转换,生成四个不同的掩码。其执行过程基本与GPU相同,但对于矢量来说,会执行一些额外的开销指令。矢量架构的优势在于它们与标量处理器集成,能够避免在计算中占主导地位时的零情况所耗费的时间。尽管这将取决于标量处理器与矢量处理器的速度,但当掩码位中1的比例少于20%时,使用标量的交叉点可能会更好。GPU在运行时可用的一种优化,而矢量架构在编译时不可用,是在掩码位全部为0或全部为1时跳过THEN或ELSE部分。
因此,GPU执行条件语句的效率取决于分支发散的频率。例如,某个特征值的计算具有深层的条件嵌套,但代码的测量显示,约82%的时钟周期中,有29到32个掩码位被设置为1,因此GPU执行此代码的效率超出了预期。请注意,相同的机制处理矢量循环的条带挖掘——当元素数量与硬件不完全匹配时。本节开始的示例展示了一个IF语句检查这个SIMD通道元素编号(在前面的示例中存储在R8中)是否小于限制(i < n),并相应地设置掩码。
### NVIDIA GPU 内存结构

图 4.18 GPU 内存结构 GPU 内存被所有网格(向量化循环)共享,局部内存则被线程块内的所有 SIMD 指令线程共享(即向量化循环的主体),而私有内存是单个 CUDA 线程的私有内存。Pascal 架构允许对网格进行抢占,这要求所有局部内存和私有内存能够保存到全局内存中,并从中恢复。为了完整性,GPU 还可以通过 PCIe 总线访问 CPU 内存。当最终结果存储在主机内存中时,此路径经常被使用。这一选项消除了从 GPU 内存到主机内存的最终复制过程。
图 4.18 展示了 NVIDIA GPU 的内存结构。每个多线程 SIMD 处理器中的 SIMD 通道都有一块私有的离芯 DRAM 区域,我们称之为私有内存。它用于堆栈帧、溢出寄存器和存储那些无法放入寄存器的私有变量。SIMD 通道之间不共享私有内存。GPU 会在 L1 和 L2 缓存中缓存这些私有内存,以帮助寄存器溢出并加速函数调用。
我们称每个多线程 SIMD 处理器本地的片上内存为局部内存。它是一种具有低延迟(几十个时钟周期)和高带宽(128 字节/时钟)的小型临时内存,程序员可以在其中存储需要重用的数据,无论是同一线程还是同一线程块中的其他线程。局部内存的大小通常有限制,通常为 48 KiB。它在同一处理器上执行的线程块之间不携带任何状态。局部内存在多线程 SIMD 处理器内的 SIMD 通道之间共享,但在不同的多线程 SIMD 处理器之间不共享。当多线程 SIMD 处理器创建线程块时,会动态分配局部内存的一部分给该线程块,并在所有线程退出时释放该内存。这部分局部内存对该线程块是私有的。
最后,我们称整个 GPU 及所有线程块共享的离芯 DRAM 为 GPU 内存。我们的向量乘法示例仅使用了 GPU 内存。系统处理器(称为主机)可以读取或写入 GPU 内存。局部内存对主机不可用,因为它是每个多线程 SIMD 处理器的私有内存。私有内存同样对主机不可用。
GPU 传统上并不依赖于大型缓存来容纳应用程序的整个工作集,而是使用较小的流式缓存。由于它们的工作集可以达到数百兆字节,因此需要大量多线程的 SIMD 指令线程来隐藏 DRAM 的长延迟。考虑到使用多线程来隐藏 DRAM 延迟,系统处理器中用于大型 L2 和 L3 缓存的芯片面积被转而用于计算资源和大量寄存器,以保存许多 SIMD 指令线程的状态。相较之下,如前所述,向量加载和存储通过在多个元素之间摊平延迟,因为它们只需支付一次延迟,然后就能对其余的访问进行流水线处理。
虽然通过多个线程隐藏内存延迟是 GPU 和向量处理器的初始理念,但所有近期的 GPU 和向量处理器都配备了缓存以降低延迟。这一论点遵循排队理论中的利特尔定律:延迟越长,在内存访问时需要运行的线程就越多,这又需要更多的寄存器。因此,GPU 缓存被添加进来,以降低平均延迟,从而掩盖寄存器数量的潜在不足。
为了提高内存带宽并减少开销,如前所述,PTX 数据传输指令与内存控制器合作,将来自同一 SIMD 线程的单个并行线程请求合并为一个内存块请求,当地址位于同一块时。这些限制施加在 GPU 程序上,有点类似于系统处理器程序参与硬件预取的指南(见第 2 章)。GPU 内存控制器还会保存请求,并将请求一起发送到同一开放页面,以提高内存带宽(见第 4.6 节)。第 2 章详细描述了 DRAM,以帮助读者理解相关地址分组的潜在好处。
Pascal GPU 架构中的创新
Pascal 的多线程 SIMD 处理器比图 4.20 中的简化版本更为复杂。为了提高硬件利用率,每个 SIMD 处理器都有两个 SIMD 线程调度器,每个调度器配备多个指令分发单元(一些 GPU 还有四个线程调度器)。双 SIMD 线程调度器选择两个 SIMD 指令线程,并从每个线程中发出一条指令到两组 16 个 SIMD 通道、16 个加载/存储单元或 8 个特殊功能单元。由于有多个执行单元可用,每个时钟周期可以调度两条 SIMD 指令线程,使得 64 条通道可以同时处于激活状态。由于线程是独立的,无需检查指令流中的数据依赖性。这一创新类似于能够从两个独立线程中发出向量指令的多线程向量处理器。图 4.19 显示了双调度器发出指令的过程,而图 4.20 则展示了 Pascal GP100 GPU 的多线程 SIMD 处理器的框图。
每一代新 GPU 通常增加一些新特性,以提升性能或简化程序员的开发工作。以下是 Pascal 的四项主要创新:


图 4.20 Pascal GPU 的多线程 SIMD 处理器框图。每个 64 个 SIMD 通道(核心)都拥有一个流水线浮点单元、一个流水线整数单元、一部分用于将指令和操作数分配给这些单元的逻辑,以及一个用于保存结果的队列。这 64 个 SIMD 通道与 32 个双精度算术逻辑单元(DP 单元)相互作用,执行 64 位浮点运算,还有 16 个加载-存储单元(LD/ST)和 16 个特殊功能单元(SFU),用于计算平方根、倒数、正弦和余弦等函数。
■ 快速的单精度、双精度和半精度浮点运算——Pascal GP100 芯片在三种浮点数大小上具有显著的浮点性能,均符合 IEEE 浮点标准。该 GPU 的单精度浮点峰值性能达到 10 TeraFLOP/s,双精度约为 5 TeraFLOP/s(速度大约为单精度的一半),而半精度以 2 元素向量表示时,其性能约为 20 TeraFLOP/s(速度大约为单精度的两倍)。原子内存操作包括三种大小的浮点加法。Pascal GP100 是首个在半精度下具备如此高性能的 GPU。
■ 高带宽内存——Pascal GP100 GPU 的下一个创新是采用堆叠的高带宽内存(HBM2)。这种内存具有宽总线,配备 4096 条数据线,以 0.7 GHz 的频率运行,提供峰值带宽为 732 GB/s,这比之前的 GPU 快两倍以上。
■ 高速芯片间互连——考虑到 GPU 的协处理器特性,PCI 总线在尝试使用多个 GPU 与单个 CPU 时可能成为通信瓶颈。Pascal GP100 引入了 NVLink 通信通道,支持每个方向最高达 20 GB/s 的数据传输。每个 GP100 拥有 4 个 NVLink 通道,提供每个芯片最高 160 GB/s 的聚合芯片间带宽。可用于多 GPU 应用的系统有 2、4 和 8 个 GPU,每个 GPU 可以对任何通过 NVLink 连接的 GPU 执行加载、存储和原子操作。此外,在某些情况下,NVLink 通道还可以与 CPU 通信。例如,IBM Power9 CPU 支持 CPU-GPU 通信。在这个芯片中,NVLink 提供了所有连接的 GPU 和 CPU 之间的内存一致视图,并且提供了缓存到缓存的通信,而不是内存到内存的通信。
■ 统一虚拟内存和分页支持——Pascal GP100 GPU 增加了在统一虚拟地址空间内的页错误功能。此功能允许在单个系统中,所有 GPU 和 CPU 共享相同的数据结构的单一虚拟地址。当线程访问远程地址时,会将一页内存传输到本地 GPU 以供后续使用。统一内存通过提供按需分页简化了编程模型,避免了 CPU 和 GPU 之间或 GPU 之间的显式内存复制。它还允许分配比 GPU 上实际存在的内存更多的内存,以解决大内存需求的问题。与任何虚拟内存系统一样,必须小心避免过度的页面移动。
### 向量架构与GPU之间的相似性与差异
正如我们所看到的,向量架构与GPU之间确实存在许多相似之处。加上GPU特有的术语,这些相似性导致了架构领域在理解GPU新颖性方面的混淆。现在,您已经了解了向量计算机和GPU的内部结构,可以更好地理解它们之间的相似性和差异。这两种架构都旨在执行数据级并行程序,但采取了不同的方法,因此进行深入比较,以便更好地理解DLP硬件的需求。图4.21首先显示向量术语,然后是GPU中最接近的等效项。
SIMD处理器类似于向量处理器。GPU中的多个SIMD处理器充当独立的MIMD核心,就像许多向量计算机具有多个向量处理器一样。在此视角下,我们将NVIDIA Tesla P100视为一台具有硬件支持的56核机器,每个核心拥有64条通道。最大的区别在于多线程,这是GPU的基本特征,而大多数向量处理器则缺乏这一特性。
在两个架构的寄存器方面,我们的实现中的RV64V寄存器文件保存整个向量,即一个连续的元素块。相比之下,GPU中的单个向量将分布在所有SIMD通道的寄存器中。RV64V处理器有32个向量寄存器,每个寄存器可能有32个元素,总共1024个元素。GPU的SIMD指令线程最多可以拥有256个寄存器,每个寄存器32个元素,总共8192个元素。这些额外的GPU寄存器支持多线程。
图4.22是左侧的向量处理器执行单元和右侧的多线程SIMD处理器的方框图。为了教学目的,我们假设向量处理器具有四条通道,而多线程SIMD处理器也有四条SIMD通道。该图显示,这四条SIMD通道协同工作,类似于一个四通道的向量单元,并且SIMD处理器的功能与向量处理器类似。
实际上,GPU中有更多的通道,因此GPU的“鸣响”时间更短。虽然一个向量处理器可能有2到8条通道,向量长度为32,这使得鸣响时间为4到16个时钟周期,但一个多线程SIMD处理器可能有8或16条通道。SIMD线程宽度为32个元素,因此GPU的鸣响时间仅为2或4个时钟周期。这一差异使得我们使用“SIMD处理器”这一术语,因为它更接近SIMD设计,而非传统向量处理器设计。

图4.22 左侧是一个具有四条通道的向量处理器,右侧是一个具有四条SIMD通道的多线程SIMD处理器(GPU)。 (GPU通常具有16或32条SIMD通道。)控制处理器为标量-向量操作提供标量操作数,增加对内存的单位和非单位跨度访问的寻址,并执行其他类型的会计操作。仅当地址合并单元能够发现局部地址时,GPU才能达到峰值内存性能。同样,当所有内部掩码位相同设置时,计算性能也能达到峰值。请注意,SIMD处理器为每个SIMD线程提供一个程序计数器,以支持多线程处理。
与向量化循环最接近的GPU术语是“网格”,而PTX指令是与向量指令最接近的,因为SIMD线程会将PTX指令广播到所有SIMD通道。
在这两种架构中,内存访问指令方面,所有GPU加载都是聚集指令,所有GPU存储都是分散指令。如果CUDA线程的数据地址引用的是同时落在同一缓存/内存块中的相邻地址,GPU的地址合并单元将确保高内存带宽。向量架构的显式单位步幅加载和存储指令与GPU编程的隐式单位步幅之间的区别,使得编写高效的GPU代码要求程序员以SIMD操作的方式进行思考,尽管CUDA编程模型看起来像是MIMD。
由于CUDA线程可以生成自己的地址,包括步幅以及聚集-分散,寻址向量在向量架构和GPU中都可以找到。
如我们多次提到的,这两种架构在隐藏内存延迟方面采取了非常不同的方法。向量架构通过深度流水线的访问将延迟分摊到向量的所有元素上,因此每次向量加载或存储仅支付一次延迟。因此,向量的加载和存储类似于内存与向量寄存器之间的块传输。相比之下,GPU通过多线程来隐藏内存延迟。(一些研究人员正在探索将多线程添加到向量架构中,以期同时获得两者的优势。)
关于条件分支指令,两种架构都使用掩码寄存器来实现。即使不存储结果,两条条件分支路径也会占用时间和/或空间。不同之处在于,向量编译器在软件中显式管理掩码寄存器,而GPU硬件和汇编器则通过分支同步标记和内部栈隐式管理它们,以保存、取反和恢复掩码。
向量计算机中的控制处理器在执行向量指令时发挥了重要作用。它将操作广播到所有的向量通道,并为向量-标量操作广播标量寄存器值。它还进行一些在GPU中显式的隐式计算,例如自动递增单位步幅和非单位步幅加载和存储的内存地址。GPU中没有控制处理器。最接近的类比是线程块调度器,它将线程块(向量循环的主体)分配给多线程SIMD处理器。
GPU中的运行时硬件机制既生成地址又发现这些地址是否相邻,这在许多DLP应用中是常见的,可能比使用控制处理器的方式效率低下。
向量计算机中的标量处理器执行向量程序的标量指令;也就是说,它执行在向量单元中进行运算太慢的操作。尽管与GPU相关联的系统处理器是向量架构中标量处理器的最接近类比,但由于存在独立的地址空间以及通过PCIe总线传输,意味着将它们一起使用会产生数千个时钟周期的开销。在浮点计算方面,标量处理器的速度可能比向量处理器慢,但与系统处理器和多线程SIMD处理器之间的比率不同(考虑到开销)。
因此,GPU中的每个“向量单元”必须执行你期望在向量计算机的标量处理器上进行的计算。也就是说,与其在系统处理器上计算并沟通结果,禁用除一个SIMD通道以外的所有通道,使用谓词寄存器和内置掩码,在一个SIMD通道上完成标量工作,可能会更快。向量计算机中相对简单的标量处理器可能比GPU解决方案更快且更节能。如果未来系统处理器和GPU更加紧密地结合在一起,那么观察系统处理器是否能发挥类似于标量处理器在向量和多媒体SIMD架构中的作用将会很有趣。
### 多媒体 SIMD 计算机与 GPU 的相似性与差异
从高层次来看,具有多媒体 SIMD 指令扩展的多核计算机确实与 GPU 存在相似之处。以下是相似性和差异的总结。
两者都是多处理器系统,其处理器使用多个 SIMD 通道,尽管 GPU 拥有更多的处理器和更多的通道。两者都利用硬件多线程来提高处理器的利用率,但 GPU 对更多线程提供了硬件支持。两者在单精度和双精度浮点运算的峰值性能之间大致存在 2:1 的性能比率。它们都使用缓存,尽管 GPU 使用较小的流式缓存,而多核计算机使用尝试完全容纳整个工作集的大型多级缓存。两者都支持 64 位地址空间,但 GPU 的物理主存要小得多。两者都支持页面级的内存保护以及需求分页,这使它们能够寻址远超过其板载内存的内存。
除了处理器、SIMD 通道、硬件线程支持和缓存大小等方面的显著数值差异外,还有许多架构上的区别。标量处理器和多媒体 SIMD 指令在传统计算机中紧密集成;而在 GPU 中,它们通过 I/O 总线分开,甚至拥有独立的主存。GPU 中的多个 SIMD 处理器使用单一的地址空间,并且在某些系统上可以支持对所有内存的一致视图,前提是 CPU 厂商提供支持(如 IBM Power9)。与 GPU 不同,多媒体 SIMD 指令历史上并不支持聚合-散布内存访问,正如第 4.7 节所示,这是一个显著的缺陷。

### 摘要
现在真相大白,我们可以看到,GPU 实际上只是多线程 SIMD 处理器,尽管它们拥有更多的处理器、每个处理器更多的通道以及比传统多核计算机更多的多线程硬件。例如,Pascal P100 GPU 具有 56 个 SIMD 处理器,每个处理器有 64 个通道,并且支持 64 个 SIMD 线程的硬件。Pascal 通过从两个 SIMD 线程向两个 SIMD 通道发出指令来实现指令级并行性。GPU 的缓存内存也较少——Pascal 的 L2 缓存为 4 MiB,并且可以与合作的远程标量处理器或远程 GPU 保持一致性。
CUDA 编程模型将所有这些并行形式封装在一个单一的抽象概念——CUDA 线程中。因此,CUDA 程序员可以考虑编写成千上万的线程,虽然它们实际上是在许多 SIMD 处理器的众多通道上执行每个 32 个线程的块。希望获得良好性能的 CUDA 程序员需要记住,这些线程是以块的形式组织,并且是一次执行 32 个,同时为了从内存系统获得良好的性能,地址需要是相邻的。
虽然我们在本节中使用了 CUDA 和 NVIDIA GPU,但请放心,同样的理念也适用于 OpenCL 编程语言及其他公司的 GPU。
现在您对 GPU 的工作原理有了更深入的理解,我们揭示真正的术语。图 4.24 和 4.25 将本节中的描述性术语和定义与官方 CUDA/NVIDIA 和 AMD 的术语和定义进行了匹配。我们还包括了 OpenCL 的术语。我们认为,GPU 的学习曲线陡峭,部分原因在于使用了诸如“流式多处理器”来指代 SIMD 处理器,“线程处理器”来指代 SIMD 通道,以及“共享内存”来指代局部内存——尤其是因为局部内存并不在 SIMD 处理器之间共享!我们希望这种两步法能够帮助您更快地适应这条曲线,尽管这可能有点间接。

图 4.25 本章中使用的术语与官方 NVIDIA/CUDA 和 AMD 行话之间的转换。请注意,我们使用的描述性术语“局部内存”和“私有内存”采用了 OpenCL 的术语。NVIDIA 使用 SIMT(单指令多线程)而不是 SIMD 来描述流式多处理器。由于每个线程的分支和控制流与任何 SIMD 机器都不同,因此更倾向于使用 SIMT 而不是 SIMD。


4.5 Detecting and Enhancing Loop-Level Parallelism

程序中的循环是我们之前讨论的多种并行性类型的源泉。在本节中,我们将讨论用于发现程序中可以利用的并行性数量的编译器技术,以及这些编译器技术所需的硬件支持。我们将准确定义何时一个循环是并行的(或可向量化的),依赖关系如何阻止循环并行,以及消除某些类型依赖关系的技术。发现和操作循环级并行性对于利用数据级并行性(DLP)和线程级并行性(TLP)至关重要,同时也对我们在附录 H 中讨论的更激进的静态指令级并行性(ILP)方法(例如,超长指令字架构 VLIW)具有重要意义。
循环级并行性通常在源代码级或接近源代码的地方进行研究,而大多数指令级并行性(ILP)的分析是在编译器生成指令后进行的。循环级分析涉及确定在循环的各个迭代中操作数之间存在哪些依赖关系。目前,我们将仅考虑数据依赖,这种依赖发生在某个时刻写入操作数而在稍后时刻读取时。还存在名称依赖,这可以通过第 3 章中讨论的重命名技术来消除。
循环级并行性的分析侧重于确定后续迭代中的数据访问是否依赖于先前迭代中产生的数据值;这种依赖称为循环传递依赖。我们在第 2 章和第 3 章中考虑的大多数示例没有循环传递依赖,因此是循环级并行的。要判断一个循环是否并行,首先让我们看看其源表示:

在这个循环中,x[i] 的两个使用是相互依赖的,但这种依赖发生在单个迭代内,而不是循环传递依赖。不同迭代中 i 的连续使用之间存在循环传递依赖,但这种依赖涉及一个可以轻松识别和消除的归纳变量。我们在第 2 章第 2.2 节中看到过如何在循环展开过程中消除涉及归纳变量的依赖,稍后在本节中我们将查看更多示例。
由于查找循环级并行性涉及识别诸如循环、数组引用和归纳变量计算等结构,因此编译器可以在源代码级或接近源代码的地方更容易地进行此分析,而不是在机器代码级别。让我们来看一个更复杂的例子。

假设 A、B 和 C 是不同的、不重叠的数组。(在实际中,这些数组有时可能是相同的或可能重叠。由于数组可能作为参数传递给包含此循环的过程,因此确定数组是否重叠或是否相同通常需要复杂的跨过程分析。)在循环中,语句 S1 和 S2 之间存在哪些数据依赖关系?
回答:存在两种不同的依赖关系:
1. S1 使用了在早期迭代中由 S1 计算出的值,因为迭代 i 计算了 A[i + 1],而这个值在迭代 i + 1 中被读取。S2 对于 B[i] 和 B[i + 1] 也是如此。
2. S2 在同一迭代中使用了由 S1 计算出的值 A[i + 1]。
这两种依赖关系是不同的,并且具有不同的影响。为了看出它们的区别,我们假设每次只存在其中一种依赖关系。由于语句 S1 的依赖关系来自于 S1 的早期迭代,因此这种依赖关系是循环传递依赖。这种依赖关系强制该循环的连续迭代串行执行。
第二种依赖关系(S2 依赖于 S1)发生在同一迭代内,并且不是循环传递依赖。因此,如果这是唯一的依赖关系,那么只要每对语句在迭代中保持顺序,多个循环迭代就可以并行执行。我们在第 2.2 节中的一个例子中看到过这种类型的依赖关系,在该例子中,循环展开可以揭示出并行性。
这些循环内的依赖关系是常见的;例如,使用链式操作的矢量指令序列正好展示了这种依赖关系。
也有可能存在一种循环传递依赖关系,但它并不妨碍并行性,正如下一个例子所示。

S1 和 S2 之间有什么依赖关系?这个循环可以并行吗?如果不能,如何使其并行?
回答:语句 S1 使用了在前一个迭代中由语句 S2 赋值的值,因此 S2 和 S1 之间存在循环传递依赖关系。尽管存在这种循环传递依赖,但这个循环仍然可以被并行化。与之前的循环不同,这种依赖关系不是循环的;两个语句都不依赖于自身,虽然 S1 依赖于 S2,但 S2 并不依赖于 S1。如果一个循环可以在没有依赖循环的情况下书写,那么它就是并行的,因为缺乏循环意味着依赖关系为语句提供了部分排序。
尽管前面的循环中没有循环依赖,但必须对其进行转换,以符合部分排序并揭示并行性。以下两个观察对于这种转换至关重要:
1. S1 对 S2 没有依赖关系。如果有,那么依赖关系中会出现循环,循环就无法并行化。由于不存在这种其他依赖关系,因此交换两个语句不会影响 S2 的执行。
2. 在循环的第一次迭代中,语句 S2 依赖于在启动循环之前计算出的 B[0] 的值。
这两个观察使我们能够用以下代码序列替换前面的循环:


这两个语句之间的依赖关系不再是循环传递的,因此可以重叠循环的迭代,只要每次迭代中的语句保持顺序。
我们的分析需要从查找所有循环传递依赖关系开始。这些依赖信息是不精确的,因为它只是告诉我们可能存在这种依赖关系。考虑以下示例:

这个例子中对 A 的第二次引用不需要转换为加载指令,因为我们知道该值是由前一个语句计算并存储的。因此,对 A 的第二次引用可以简单地是对存储 A 的寄存器的引用。执行这种优化需要知道这两个引用始终指向相同的内存地址,并且没有对同一位置的干预访问。通常,数据依赖分析只告诉我们一个引用可能依赖于另一个引用;要确定两个引用必须指向完全相同的地址,需要更复杂的分析。在前面的例子中,由于这两个引用位于同一个基本块中,因此简单版本的分析就足够了。
循环传递依赖通常呈现为递归。当一个变量的定义基于该变量在早期迭代中的值时,就会发生递归,通常是直接前一个迭代,如以下代码片段所示:

检测递归可能很重要,原因有两个:一些架构(特别是向量计算机)对执行递归有特殊的支持,并且在指令级并行(ILP)上下文中,仍然可能利用相当多的并行性。
### 查找依赖关系
显然,查找程序中的依赖关系对于确定哪些循环可能包含并行性以及消除名称依赖关系非常重要。依赖分析的复杂性还源于像 C 或 C++ 这样的语言中数组和指针的存在,或者 Fortran 中按引用传递参数的方式。由于标量变量引用明确指向一个名称,因此通常可以相对容易地分析它们,而指针和引用参数则会在分析中引发一些复杂性和不确定性。
编译器通常如何检测依赖关系?几乎所有的依赖分析算法都假设数组索引是仿射的。简单来说,一维数组索引是仿射的,如果它可以写成形式 \(a \cdot i + b\),其中 \(a\) 和 \(b\) 是常数,\(i\) 是循环索引变量。如果多维数组的每个维度的索引都是仿射的,则该多维数组的索引也是仿射的。稀疏数组访问通常具有形式 \(x[y[i]]\),是非仿射访问的主要示例之一。
因此,在循环中确定对同一数组的两个引用之间是否存在依赖关系,相当于确定两个仿射函数在循环边界之间是否可以为不同索引具有相同值。例如,假设我们对索引值为 \(a \cdot i + b\) 的数组元素进行了存储,并从同一数组中加载了索引值为 \(c \cdot i + d\) 的元素,其中 \(i\) 是从 \(m\) 到 \(n\) 的 for 循环索引变量。如果满足以下两个条件,则存在依赖关系:
1. 存在两个迭代索引 \(j\) 和 \(k\),它们都在 for 循环的限制范围内。也就是说,\(m < j < n\) 且 \(m < k < n\)。
2. 循环将值存储到索引为 \(a \cdot j + b\) 的数组元素中,然后在索引为 \(c \cdot k + d\) 时从同一数组元素中获取值,即 \(a \cdot j + b = c \cdot k + d\)。
一般来说,我们无法在编译时确定是否存在依赖关系。例如,a、b、c 和 d 的值可能未知(它们可能是其他数组中的值),这使得判断是否存在依赖关系变得不可能。在其他情况下,依赖性测试可能在编译时是可决定的但代价昂贵;例如,访问可能依赖于多个嵌套循环的迭代索引。然而,许多程序主要包含简单索引,其中 a、b、c 和 d 都是常数。对于这些情况,可以设计合理的编译时依赖性测试。
作为一个例子,判断不存在依赖关系的一个简单且充分的测试是最大公约数(GCD)测试。这个测试基于以下观察:如果存在循环传递依赖关系,则 GCD(c, a) 必须能整除 (d - b)。 (请记住,当我们进行 y/x 的除法时,如果得到一个整数商且没有余数,那么整数 x 就能整除另一个整数 y。)

GCD 测试足以保证不存在依赖关系;然而,在某些情况下,尽管 GCD 测试成功,但实际上并不存在依赖关系。这种情况可能出现,因为 GCD 测试没有考虑循环边界。
一般来说,确定依赖关系是否实际存在是 NP 完全问题。然而,在实践中,许多常见情况可以以较低的成本进行精确分析。最近,采用逐渐增加通用性和成本的精确测试层次的方法被证明既准确又高效。(如果一个测试能精确判断是否存在依赖关系,则称其为精确测试。尽管一般情况是 NP 完全,但在受限情况下存在更便宜的精确测试。)
除了检测依赖关系的存在外,编译器还希望对依赖关系类型进行分类。这种分类使编译器能够识别名称依赖关系,并通过重命名和复制在编译时消除它们。
示例:以下循环具有多种类型的依赖关系。找出所有真实依赖关系、输出依赖关系和反依赖关系,并通过重命名消除输出依赖关系和反依赖关系。

回答:在这四个语句之间存在以下依赖关系:
1. 从 S1 到 S3 和从 S1 到 S4 存在真实依赖关系,因为涉及 Y[i]。这些依赖关系不是循环携带的,因此不会阻止循环被视为并行。这些依赖关系将迫使 S3 和 S4 等待 S1 完成。
2. 从 S1 到 S2 存在反依赖关系,基于 X[i]。
3. 从 S3 到 S4 存在反依赖关系,基于 Y[i]。
4. 从 S1 到 S4 存在输出依赖关系,基于 Y[i]。
以下版本的循环消除了这些虚假(或伪)依赖关系。

在循环结束后,变量 X 被重命名为 X1。在循环之后的代码中,编译器可以简单地将名称 X 替换为 X1。在这种情况下,重命名不需要实际的复制操作,因为可以通过替换名称或寄存器分配来完成。然而,在其他情况下,重命名将需要复制。依赖分析是利用并行性的重要技术,也是第二章所涵盖的类似变换的阻塞技术。对于检测循环级别的并行性,依赖分析是基本工具。有效编译面向向量计算机、SIMD 计算机或多处理器的程序在很大程度上依赖于这种分析。依赖分析的主要缺点是它仅适用于有限的情况,即在单个循环嵌套内的引用,并使用仿射索引函数。因此,在许多情况下,面向数组的依赖分析无法告诉我们想要知道的信息;例如,分析使用指针而不是数组索引进行的访问可能要困难得多。(这也是为什么 Fortran 在许多为并行计算机设计的科学应用中仍然优于 C 和 C++ 的原因之一。)类似地,跨过程调用的引用分析也是极其困难的。因此,尽管对用顺序语言编写的代码进行分析仍然重要,我们还需要像 OpenMP 和 CUDA 这样的显式并行循环编写的方法。
消除依赖计算
如前所述,依赖计算的一个重要形式是递归。点积就是递归的一个完美例子:

这个循环不是并行的,因为它对变量 sum 存在循环传递依赖。然而,我们可以将其转换为一组循环,其中一个是完全并行的,另一个是部分并行的。第一个循环将执行此循环中完全并行的部分。它看起来是这样的:

注意,sum 已经从一个标量扩展为一个向量量(这种变换称为标量扩展),并且这一变换使得这个新循环完全并行。然而,当我们完成后,还需要进行归约步骤,以求和向量的元素。它看起来是这样的:

尽管这个循环不是并行的,但它具有一种非常特定的结构,称为归约。归约在线性代数中很常见,正如我们将在第六章看到的,它们也是大规模计算机中主要并行原语 MapReduce 的关键部分。一般来说,任何函数都可以用作归约运算符,常见的情况包括诸如最大值和最小值等运算符。
在向量和 SIMD 架构中,归约有时会由专门的硬件处理,这使得归约步骤的速度比标量模式下要快得多。这些通过实现类似于多处理器环境中可以完成的技术来工作。虽然通用变换适用于任意数量的处理器,但为了简单起见,我们假设有 10 个处理器。在归约求和的第一步中,每个处理器执行以下操作(p 为处理器编号,范围从 0 到 9):

这个循环在每个 10 个处理器上汇总 1000 个元素,完全并行。然后,可以使用一个简单的标量循环来完成最后 10 个求和的总和。在向量处理器和 SIMD 处理器中也使用类似的方法。
重要的是要注意,前面的变换依赖于加法的结合性。尽管无限范围和精度的算术是结合的,但计算机算术并不是结合的,原因是整数算术由于范围有限,浮点算术则因为范围和精度的限制。因此,使用这些重构技术有时可能导致错误的行为,尽管这种情况较少发生。出于这个原因,大多数编译器要求显式启用依赖于结合性的优化。


4.6 Cross-Cutting Issues

能量与数据级并行性:慢而宽与快而窄
数据级并行架构的一个基本功耗优势来自于第一章的能量方程。假设有充足的数据级并行性,如果我们将时钟频率减半并将执行资源翻倍(例如,对向量计算机而言,增加两倍的通道数;对多媒体 SIMD 而言,使用更宽的寄存器和算术逻辑单元;对 GPU 而言,增加更多的 SIMD 通道),性能是相同的。如果我们可以在降低时钟频率的同时降低电压,我们实际上可以在保持相同峰值性能的情况下减少计算的能量和功耗。因此,GPU 的时钟频率往往低于依赖高时钟频率来提升性能的系统处理器(见第 4.7 节)。
与乱序处理器相比,数据级并行处理器可以拥有更简单的控制逻辑,以便在每个时钟周期内启动大量操作;例如,在向量处理器中,所有通道的控制是相同的,不需要决定多个指令发射或推测执行的逻辑。它们还提取和解码的指令数量要少得多。向量架构还可以更容易地关闭芯片上未使用的部分。每条向量指令明确描述了其在发射时所需的所有资源及其循环次数。
### 存储器分区和图形内存
第 4.2 节提到,对于向量架构而言,支持单位步幅、非单位步幅和聚集-散射访问所需的显著内存带宽的重要性。为了实现最高的内存性能,AMD 和 NVIDIA 的高端 GPU 使用堆叠 DRAM。英特尔在其 Xeon Phi 产品中也使用了堆叠 DRAM。这种内存芯片被称为高带宽内存(HBM,HBM2),它们被堆叠并与处理芯片放在同一封装中。其广泛的宽度(通常为 1024–4096 数据线)提供了高带宽,同时将内存芯片与处理器芯片放在同一封装中可以减少延迟和功耗。堆叠 DRAM 的容量通常为 8–32 GB。
考虑到计算任务和图形加速任务对内存的各种潜在需求,内存系统可能会面临大量不相关的请求。不幸的是,这种多样性会影响内存性能。为了应对这一挑战,GPU 的内存控制器维护着不同存储区的流量独立队列,直到有足够的流量可以合理地打开一行,并一次性传输所有请求的数据。这种延迟提高了带宽,但拉长了延迟,控制器必须确保没有处理单元在等待数据时处于饥饿状态,否则相邻的处理器可能会变得空闲。第 4.7 节显示,聚集-散射技术和内存银行感知访问技术相比于传统的基于缓存的架构可以显著提升性能。
### 步幅访问与 TLB 未命中
步幅访问存在的一个问题是它们与虚拟内存中的转换后备缓冲区(TLB)之间的交互,尤其是在向量架构或 GPU 中。(GPU 也使用 TLB 进行内存映射。)根据 TLB 的组织方式和被访问数组的大小,有可能导致每次访问数组中的元素时都会产生一个 TLB 未命中!同样类型的冲突也可能发生在缓存中,但对性能的影响可能较小。


4.7 Putting It All Together: Embedded Versus Server GPUs and Tesla Versus Core i7

考虑到图形应用的普及,GPU 现在不仅出现在移动客户端,还广泛应用于传统服务器和高性能桌面计算机。图 4.26 列出了 NVIDIA Tegra Parker 嵌入式客户端系统芯片的主要特性,该芯片在汽车中非常受欢迎,以及用于服务器的 Pascal GPU。GPU 服务器工程师希望在电影发布五年内能够实现实时动画。与此同时,嵌入式 GPU 工程师希望在再过五年后,能够在他们的硬件上实现今天服务器或游戏主机所能做到的功能。

NVIDIA Tegra P1 拥有六个 ARMv8 核心和一颗较小的 Pascal GPU(能够达到 750 GFLOPS),内存带宽为 50 GB/s。它是 NVIDIA DRIVE PX2 计算平台的关键组件,该平台用于汽车的自动驾驶。NVIDIA Tegra X1 是上一代产品,广泛用于几款高端平板电脑,如 Google Pixel C 和 NVIDIA Shield TV。它配备了一颗 Maxwell 类 GPU,能够达到 512 GFLOPS。
NVIDIA Tesla P100 是本章 extensively 讨论的 Pascal GPU。(Tesla 是 NVIDIA 针对通用计算产品的命名。)其时钟频率为 1.4 GHz,包含 56 个 SIMD 处理器。通往 HBM2 内存的路径宽度为 4096 位,并且在 0.715 GHz 时钟的上升沿和下降沿都能传输数据,这意味着峰值内存带宽为 732 GB/s。它通过 PCI Express 16 Gen 3 连接到主机系统处理器和内存,具有 32 GB/s 的峰值双向速率。
P100 芯片的所有物理特性令人印象深刻:它包含 153 亿个晶体管,芯片面积为 645 mm²,采用 16 纳米 TSMC 工艺,典型功耗为 300 W。
### GPU与多媒体SIMD的MIMD比较
一组英特尔研究人员(Lee等,2010)发表了一篇论文,比较了配有多媒体SIMD扩展的四核Intel i7与Tesla GTX 280。尽管该研究未比较最新版本的CPU和GPU,但这是两种架构风格之间最深入的比较,解释了性能差异背后的原因。此外,这些架构的当前版本与研究中的版本有许多相似之处。

图4.27 显示了英特尔Core i7-960和NVIDIA GTX 280。最右列显示了GTX 280与Core i7的比率。在GTX 280上的单精度SIMD FLOPS中,较高的速度(933)来源于一种非常罕见的双发射融合乘加和乘法的情况。对于单个融合乘加而言,更合理的值是622。注意,这些内存带宽比图4.28中的要高,因为这些是DRAM引脚带宽,而图4.28中的带宽是通过基准测试程序在处理器上测量的。摘自Lee, W.V.等人,2010年。《揭穿100!GPU与CPU神话:对CPU和GPU吞吐量计算的评估》。收录于:第37届国际计算机体系结构年会(ISCA)论文集,2010年6月19日至23日,法国圣马洛。
图4.27列出了这两种系统的特性。这两款产品均在2009年秋季购买。Core i7采用英特尔的45纳米半导体技术,而GPU则使用台积电的65纳米技术。尽管由中立方或双方共同进行比较可能更为公正,但本节的目的并不是确定哪款产品更快,而是试图理解这两种对比架构风格特性的相对价值。

图4.28 屋顶线模型(Williams等,2009)。这些屋顶线显示了顶部行的双精度浮点性能和底部行的单精度性能。(双精度浮点性能上限也在底部行中,以提供参考。)左侧的Core i7 920具有42.66 GFLOP/s的峰值双精度浮点性能,85.33 GFLOP/s的峰值单精度浮点性能,以及16.4 GB/s的峰值内存带宽。NVIDIA GTX 280的双精度浮点峰值为78 GFLOP/s,单精度浮点峰值为624 GFLOP/s,内存带宽为127 GB/s。左侧的虚线垂直线表示算术强度为0.5 FLOP/字节。它受到内存带宽的限制,在Core i7上不超过8 DP GFLOP/s或8 SP GFLOP/s。右侧的虚线垂直线的算术强度为4 FLOP/字节。它在Core i7上仅受到计算能力的限制,为42.66 DP GFLOP/s和64 SP GFLOP/s,而在GTX 280上则为78 DP GFLOP/s和512 SP GFLOP/s。要在Core i7上达到最高计算速率,需要使用所有4个核心和SSE指令,并且乘法和加法的数量需相等。对于GTX 280,需要在所有多线程SIMD处理器上使用融合乘加指令。
图4.28中的Core i7 920和GTX 280的屋顶线展示了这两台计算机的差异。920的时钟频率低于960(2.66 GHz对比3.2 GHz),但系统的其他部分是相同的。GTX 280不仅具有更高的内存带宽和双精度浮点性能,其双精度峰值点也显著偏左。正如之前提到的,屋顶线的峰值点越靠左,达到峰值计算性能就越容易。GTX 280的双精度峰值点为0.6,而Core i7为2.6。在单精度性能方面,峰值点则向右移动,因为达到单精度性能的上限要困难得多,毕竟其值要高得多。需要注意的是,内核的算术强度是基于进入主内存的字节,而不是进入缓存内存的字节。因此,缓存可以改变特定计算机上内核的算术强度,前提是大多数引用确实是指向缓存的。屋顶线帮助解释了这一案例研究中的相对性能。还要注意,这种带宽适用于两种架构中的单位步幅访问。我们将看到,未合并的真实聚集-散布地址在GTX 280和Core i7上的速度较慢。
研究人员表示,他们通过分析四个最近提出的基准套件的计算和内存特性,选择了基准程序,并“制定了一组能够捕捉这些特性的吞吐量计算内核”。图4.29描述了这14个内核,图4.30显示了性能结果,数字越大表示速度越快。

图4.29 吞吐量计算内核特征 括号中的名称标识本节中的基准名称。作者建议两台机器的代码优化工作量相同。来源于Lee, W.V., et al., 2010年,揭穿100倍GPU与CPU神话:对CPU和GPU上吞吐量计算的评估。在:第37届国际计算机架构年会(ISCA),2010年6月19日至23日,法国圣马洛。

**图4.30 两个平台测得的原始性能和相对性能**。在本研究中,SAXPY仅用作内存带宽的测量,因此右侧单位为GB/s,而不是GFLOP/s。来源于Lee, W.V., et al., 2010年,揭穿100倍GPU与CPU神话:对CPU和GPU上吞吐量计算的评估。在:第37届国际计算机架构年会(ISCA),2010年6月19日至23日,法国圣马洛。
考虑到GTX 280的原始性能规格在2.5倍较慢(时钟频率)到7.5倍较快(每芯片核心数)之间变化,而性能在2.0倍较慢(Solv)到15.2倍较快(GJK)之间变化,英特尔研究人员探讨了这些差异的原因:
- **内存带宽**。GPU的内存带宽是Core i7的4.4倍,这有助于解释为何LBM和SAXPY分别快5.0倍和5.3倍;它们的工作集达到数百兆字节,因此无法放入Core i7的缓存。(为了密集访问内存,它们在SAXPY上没有使用缓存阻塞。)因此,屋顶线的斜率解释了它们的性能。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倍。(如图4.27所示,GTX 280比Core i7快9倍的情况仅在非常特殊的情况下发生,即GTX 280能够在每个时钟周期发出融合乘加和乘法指令。)MC使用双精度,这解释了它仅快1.8倍,因为双精度性能仅快1.5倍。Bilat使用超越函数,GTX 280直接支持这些函数(见图4.17)。Core i7在计算超越函数时花费了三分之二的时间,因此GTX 280快5.7倍。这一观察强调了硬件对工作负载中出现的操作(如双精度浮点和超越函数)提供支持的重要性。
--缓存优势。光线投射(RC)在GTX上仅快1.6倍,因为Core i7的缓存通过缓存阻塞防止其受到内存带宽的限制,而这正是GPU所面临的情况。缓存阻塞对搜索也有帮助。如果索引树较小,能够适应缓存,那么Core i7的速度会快两倍。较大的索引树则使其受到内存带宽的限制。总体而言,GTX 280的搜索速度快1.8倍。缓存阻塞还对排序有帮助。尽管大多数程序员不会在SIMD处理器上运行排序,但可以使用一种称为split的1位排序原语来实现。然而,split算法执行的指令比标量排序多得多。因此,GTX 280的速度仅为Core i7的0.8倍。需要注意的是,缓存同样对Core i7上的其他内核有帮助,因为缓存阻塞使SGEMM、FFT和SpMV变得计算受限。这一观察再次强调了第2章中缓存阻塞优化的重要性。
--收集-散布。若数据分散在主内存中,多媒体SIMD扩展几乎无济于事;只有当数据对齐到16字节边界时,才能获得最佳性能。因此,GJK在Core i7上从SIMD中获得的好处很少。正如之前提到的,GPU提供了在向量架构中找到的收集-散布寻址,而在SIMD扩展中被省略。地址合并单元通过将对同一DRAM行的访问组合起来,减少了收集和散布的次数,从而提供帮助。内存控制器也将对相同DRAM页面的访问进行批处理。这一组合意味着GTX 280运行GJK的速度比Core i7快15.2倍,这一比例超过了图4.27中任何单一物理参数的值。这一观察进一步强化了收集-散布对向量和GPU架构的重要性,而这些在SIMD扩展中是缺失的。
**同步**。Hist的性能同步受限于原子更新,这在Core i7上占总运行时间的28%,尽管它具有硬件获取-增量指令。因此,Hist在GTX 280上的速度仅快1.7倍。Solv解决一批独立约束,涉及少量计算,然后进行屏障同步。Core i7得益于原子指令和内存一致性模型,即使不是所有先前对内存层次结构的访问都已完成,也能确保正确的结果。如果没有内存一致性模型,GTX 280版本则从系统处理器启动一些批次,导致GTX 280的运行速度仅为Core i7的0.5倍。这一观察指出,同步性能对于某些数据并行问题的重要性。
有趣的是,向量架构的收集-散布支持在SIMD指令出现几十年前就已经存在,这对这些SIMD扩展的有效性至关重要,而在比较之前有人曾预测到这一点(Gebis和Patterson,2007)。英特尔研究人员指出,14个内核中有6个在Core i7上更有效地利用SIMD,得益于更高效的收集-散布支持。
需要注意的是,这次比较中缺少一个重要特征,即描述两个系统获得结果所需的努力程度。理想情况下,未来的比较应公开在两个系统上使用的代码,以便他人可以在不同硬件平台上重现相同的实验,并可能改进结果。
**比较更新**
在这几年间,Core i7和Tesla GTX 280的弱点已被其后继者所解决。英特尔的ACV2添加了收集指令,而AVX/512则添加了散布指令,这两种指令都在英特尔Skylake系列中得到应用。Nvidia Pascal的双精度浮点性能为单精度的一半,而非八分之一,同时具备快速的原子操作和缓存。
**图4.31**列出了这两款后继产品的特性,**图4.32**使用原始论文中的14个基准中的3个进行了性能比较(这些是我们能找到源代码的基准),而**图4.33**展示了两个新的屋顶线模型。新的GPU芯片比其前身快15至50倍,而新的CPU芯片比前代快50倍,新GPU的速度是新CPU的2到5倍。

4.8 Fallacies and Pitfalls

虽然从程序员的角度来看,数据级并行性是仅次于指令级并行性(ILP)最简单的并行形式,从架构师的角度来看,这可能是最简单的,但它仍然存在许多谬误和陷阱。
**谬误**:GPU作为协处理器受到限制。虽然主内存与GPU内存之间的划分存在缺点,但远离CPU也带来了某些优势。例如,PTX的存在部分是因为GPU具有I/O设备的特性。这种编译器与硬件之间的间接层使GPU架构师比系统处理器架构师拥有更大的灵活性。通常很难提前判断某项架构创新是否会得到编译器和库的良好支持,并对应用程序重要。有时,一种新机制甚至会在一两代中证明有用,但随着IT世界的变化而逐渐失去重要性。PTX允许GPU架构师进行创新尝试,如果这些创新未能令人满意或逐渐失去重要性,可以在后续代中放弃,这鼓励了实验。对于系统处理器,包含新特性的合理性显然要高得多,因此可进行的实验就少得多,因为分发二进制机器代码通常意味着新特性必须受到所有未来该架构代的支持。
PTX的一个价值体现是,不同代的架构在硬件指令集方面发生了根本变化——从像x86那样以内存为导向转变为像RISC-V那样以寄存器为导向,同时将地址大小加倍至64位——而不干扰NVIDIA的软件栈。
### 陷阱 **关注向量架构的峰值性能,而忽视启动开销。**
早期的内存-内存向量处理器,如TI ASC和CDC STAR-100,具有较长的启动时间。对于某些向量问题,向量长度必须超过100,才能使向量代码比标量代码更快!在CYBER 205(源自STAR-100)上,DAXPY的启动开销为158个时钟周期,这显著增加了盈亏平衡点。如果Cray-1和CYBER 205的时钟频率相同,则Cray-1会更快,直到向量长度大于64。由于Cray-1的时钟频率也更高(尽管205更新),交叉点是在向量长度超过100时。
**陷阱** **提高向量性能而不相应提高标量性能。**
这种不平衡是许多早期向量处理器面临的问题,也是西摩·克雷(Cray计算机的架构师)重写规则的地方。许多早期的向量处理器具有相对较慢的标量单元(以及较大的启动开销)。即使在今天,具有较低向量性能但更好标量性能的处理器仍然可以超越具有更高峰值向量性能的处理器。良好的标量性能可以降低开销成本(例如条带挖掘),并减少阿姆达尔法则的影响。
一个很好的例子是比较一个快速的标量处理器和一个标量性能较低的向量处理器。利弗莫尔Fortran内核是24个不同科学内核的集合,具有不同程度的向量化。图4.34显示了这两个处理器在该基准测试上的性能。尽管向量处理器的峰值性能更高,但其较低的标量性能使其在调和均值测量下比快速标量处理器慢。

图 4.34 Livermore Fortran 核心在两种不同处理器上的性能测量。MIPS M/120-5 和 Stardent-1500(前称 Ardent Titan-1)均使用 16.7 MHz 的 MIPS R2000 芯片作为主 CPU。Stardent-1500 在标量浮点运算中使用其向量单元,其标量性能约为 MIPS M/120-5 的一半(以最小速率衡量),后者使用 MIPS R2010 浮点芯片。在一个高度向量化的循环中,向量处理器的速度快于 2.5 倍!然而,Stardent-1500 较低的标量性能在对所有 24 个循环的总性能进行调和平均时,抵消了较高的向量性能。
今天这一危险的反面是提高向量性能——例如,通过增加通道数量——而不提高标量性能。这种短视是另一条通往不平衡计算机的道路。
下一个谬误与此密切相关。
### 谬误 **你可以在不提供内存带宽的情况下获得良好的向量性能。**
正如我们在DAXPY循环和Roofline模型中看到的,内存带宽对所有SIMD架构都相当重要。DAXPY每个浮点操作需要1.5次内存引用,而这个比率在许多科学代码中是典型的。即使浮点操作不消耗时间,Cray-1也无法提高使用的向量序列的性能,因为其受限于内存。当编译器使用分块技术改变计算,以便将值保留在向量寄存器中时,Cray-1在Linpack上的性能大幅提升。这种方法降低了每次浮点运算(FLOP)的内存引用次数,使性能几乎提高了两倍!因此,Cray-1上的内存带宽变得足以支持之前需要更多带宽的循环。
### 谬误 *在GPU上,如果内存性能不足,只需增加更多线程。**
GPU使用许多CUDA线程来隐藏主内存的延迟。如果内存访问在CUDA线程之间分散或不相关,则内存系统对每个单独请求的响应会逐渐变慢。最终,即使有很多线程也无法覆盖延迟。要使“更多CUDA线程”策略有效,不仅需要大量的CUDA线程,而且这些CUDA线程在内存访问的局部性方面也必须表现良好。


4.9 Concluding Remarks

数据级并行性在个人移动设备上的重要性不断增加,因为应用程序的流行突显了音频、视频和游戏在这些设备上的重要性。与比任务级并行性更易于编程的模型相结合,并且可能具有更好的能效,明显可以看到在这一年代数据级并行性经历了复兴。
我们看到系统处理器越来越多地呈现出 GPU 的特征,反之亦然。在传统处理器与 GPU 之间,性能差异最大的之一是聚集-分散寻址。传统向量架构展示了如何将此类寻址添加到 SIMD 指令中,我们预计随着时间的推移,将有更多来自成熟的向量架构的理念被引入到 SIMD 扩展中。
正如我们在第 4.4 节开头所提到的,GPU 的问题不仅仅是哪个架构最好,而是在进行良好图形处理时,如何利用硬件投资来增强其支持更通用计算的能力。尽管向量架构在理论上具有许多优势,但仍需证明向量架构是否能成为图形处理的良好基础,像 GPU 一样。RISC-V 已经选择了向量而非 SIMD。因此,就像过去的架构辩论一样,市场将帮助确定这两种数据并行架构风格的优缺点的重要性。

http://www.lqws.cn/news/558361.html

相关文章:

  • 鸿蒙HarmonyOS 5小游戏实践:数字记忆挑战(附:源代码)
  • 信号处理学习——文献精读与code复现之TFN——嵌入时频变换的可解释神经网络(下)
  • 给定一个整型矩阵map,求最大的矩形区域为1的数量
  • Insar 相位展开真实的数据集的生成与下载(随机矩阵放大,zernike 仿真包裹相位)
  • Launcher3中的CellLayout 和ShortcutAndWidgetContainer 的联系和各自职责
  • 剑指offer50_0到n-1中缺失的数字
  • python -日期与天数的转换
  • autoas/as 工程的RTE静态消息总线实现与端口数据交换机制详解
  • 解决flash-attn安装报错的问题
  • 【C】陷波滤波器
  • 鸿蒙开发:资讯项目实战之底部导航封装
  • MySQL之MVCC实现原理深度解析
  • 类和对象(中)
  • springboot+Vue驾校管理系统
  • 开疆智能ModbusTCP转CClinkIE网关连接台达DVP-ES3 PLC配置案例
  • Java-正则表达式
  • 测量 Linux 中进程上下文切换需要的时间
  • cocos creator 3.8 - 精品源码 - 挪车超人(挪车消消乐)
  • 同步日志系统深度解析【链式调用】【宏定义】【固定缓冲区】【线程局部存储】【RAII】
  • 蚂蚁百宝箱体验:如何快速创建“旅游小助手”AI智能体
  • LINUX628 NFS 多web;主从dns;ntp;samba
  • AlphaGenome:基因组学领域的人工智能革命
  • Linux离线搭建Redis (centos7)详细操作步骤
  • 深入解析 Electron 核心模块:构建跨平台桌面应用的关键
  • 《Go语言高级编程》玩转RPC
  • Vue.js 中的 v-model 和 :value:理解父子组件的数据绑定
  • 网络 : 传输层【UDP协议】
  • (线性代数)矩阵的奇异值Singular Value
  • WPS之PPT镂空效果实现
  • 笔记07:网表的输出与导入