目录
Preface
[1 关于作者](#1 关于作者)
[2 作者的话](#2 作者的话)
[3 目标读者](#3 目标读者)
[1 Introduction](#1 Introduction)
[1.1 Why Do We Still Need Performance Tuning?](#1.1 Why Do We Still Need Performance Tuning?)
[1.2 Who Needs Performance Tuning?](#1.2 Who Needs Performance Tuning?)
[1.3 What Is Performance Analysis?](#1.3 What Is Performance Analysis?)
[1.4 What is discussed in this book?](#1.4 What is discussed in this book?)
[1.5 What is not in this book?](#1.5 What is not in this book?)
[1.6 Chapter Summary](#1.6 Chapter Summary)
[Part1. Performance analysis on a modern CPU](#Part1. Performance analysis on a modern CPU)
[2 Measuring Performance](#2 Measuring Performance)
[2.1 Noise In Modern Systems](#2.1 Noise In Modern Systems)
[2.2 Measuring Performance In Production](#2.2 Measuring Performance In Production)
[2.3 Automated Detection of Performance Regressions //自动化检测性能回归](#2.3 Automated Detection of Performance Regressions //自动化检测性能回归)
[2.4 Manual Performance Testing](#2.4 Manual Performance Testing)
[2.5 Software and Hardware Timers](#2.5 Software and Hardware Timers)
[2.6 Microbenchmarks](#2.6 Microbenchmarks)
[2.7 Chapter Summary](#2.7 Chapter Summary)
[3 CPU Microarchitecture](#3 CPU Microarchitecture)
[3.1 Instruction Set Architecture](#3.1 Instruction Set Architecture)
[3.2 Pipelining //流水线](#3.2 Pipelining //流水线)
[3.3 Exploiting Instruction Level Parallelism (ILP) //利用指令级并行性](#3.3 Exploiting Instruction Level Parallelism (ILP) //利用指令级并行性)
[3.4 Exploiting Thread Level Parallelism //利用线程级并行性](#3.4 Exploiting Thread Level Parallelism //利用线程级并行性)
[3.5 Memory Hierarchy](#3.5 Memory Hierarchy)
[3.6 Virtual Memory](#3.6 Virtual Memory)
[3.7 SIMD Multiprocessors](#3.7 SIMD Multiprocessors)
[3.8 Modern CPU design](#3.8 Modern CPU design)
[3.9 Performance Monitoring Unit](#3.9 Performance Monitoring Unit)
[4 Terminology and metrics in performance analysis](#4 Terminology and metrics in performance analysis)
[4.1 Retired vs. Executed Instruction](#4.1 Retired vs. Executed Instruction)
[4.2 CPU Utilization](#4.2 CPU Utilization)
[4.3 CPI & IPC](#4.3 CPI & IPC)
[4.4 UOPs (micro-ops)](#4.4 UOPs (micro-ops))
[4.5 Pipeline Slot //流水线阶段](#4.5 Pipeline Slot //流水线阶段)
[4.6 Core vs. Reference Cycles //核心时钟周期与参考时钟周期](#4.6 Core vs. Reference Cycles //核心时钟周期与参考时钟周期)
[4.7 Cache miss //缓存缺失](#4.7 Cache miss //缓存缺失)
[4.8 Mispredicted branch //错误的分支预测](#4.8 Mispredicted branch //错误的分支预测)
[5 Performance Analysis Approaches](#5 Performance Analysis Approaches)
[5.1 Code Instrumentation //代码插桩](#5.1 Code Instrumentation //代码插桩)
[5.2 Tracing //跟踪](#5.2 Tracing //跟踪)
[5.3 Workload Characterization //工作负载特征分析](#5.3 Workload Characterization //工作负载特征分析)
[5.4 Sampling //采样](#5.4 Sampling //采样)
[5.5 Rooflfline Performance Model //屋顶线性能模型](#5.5 Rooflfline Performance Model //屋顶线性能模型)
[5.6 Static Performance Analysis //静态性能分析](#5.6 Static Performance Analysis //静态性能分析)
[5.7 Compiler Optimization Reports](#5.7 Compiler Optimization Reports)
[5.8 Chapter Summary](#5.8 Chapter Summary)
[6 CPU Features For Performance Analysis](#6 CPU Features For Performance Analysis)
[6.1 Top-Down Microarchitecture Analysis //TMA](#6.1 Top-Down Microarchitecture Analysis //TMA)
[6.2 Last Branch Record //最后分支记录](#6.2 Last Branch Record //最后分支记录)
[6.3 Processor Event-Based Sampling //处理器事件采样](#6.3 Processor Event-Based Sampling //处理器事件采样)
[6.4 Intel Processor Traces](#6.4 Intel Processor Traces)
[6.5 Chapter Summary](#6.5 Chapter Summary)
Preface
1 关于作者
丹尼斯·巴赫瓦洛夫(Denis Bakhvalov)是英特尔的高级开发人员,他致力于为各种不同架构生成最优代码的C++编译器项目。性能工程和编译器一直是他最主要的兴趣之一。丹尼斯于2008年开始了他的软件开发生涯,并在多个领域工作,包括开发桌面应用程序、嵌入式系统、性能分析和编译器开发。2016年,丹尼斯开始了他的easyperf.net 博客,其中写了关于性能分析和调优、C/C++编译器和CPU微架构等方面的文章。丹尼斯非常提倡积极的生活方式,在空闲时间里打足球、网球、跑步和下棋。此外,丹尼斯还是两个美丽女儿的父亲。
2 作者的话
我写这本书起初只有一个简单的目标,就是帮助软件开发人员更好地了解其应用程序在现代硬件上的性能。对于初学者或甚至经验丰富的开发人员来说,这个主题可能会非常困惑。这种困惑通常发生在没有处理与性能相关任务的经验的开发人员身上。但这没关系,因为每一位专家都曾是初学者。
我还记得刚开始进行性能分析时的日子。我盯着陌生的度量标准,试图匹配不匹配的数据,心里很困扰。花了我多年时间,才终于让所有的拼图成为了完整的画面。那时,唯一的好资料是软件开发人员手册,但这并不是大部分开发人员想要阅读的内容。因此,我决定写这本书,希望它能让开发人员更容易地学习性能分析概念。
那些认为自己是性能分析初学者的开发人员可以从本书的开头开始,逐章地阅读。第2章至第4章为后面章节所必需的最小知识点。已经熟悉这些概念的读者可以选择跳过这些章节。此外,本书也可以用作优化软件应用程序的参考或检查清单。开发人员可以使用第7-11章作为调整其代码的思路来源。
3 目标读者
本书主要面向那些从事性能敏感应用和低级别优化的软件开发人员。仅举几个领域为例:高性能计算(HPC)、游戏开发、数据中心应用(如Facebook、Google等)、高频交易等。但是,本书的范围不限于上述行业。本书将对任何想要更好地了解其应用程序性能、了解如何诊断和改进其性能的开发人员有所帮助。作者希望本书中提供的材料能够帮助读者发展新的技能,并将其应用于日常工作中。读者需要具备基本的C/C++编程语言背景,以理解本书的示例。可以阅读基本的x86汇编语言是有帮助的,但不是强制要求。作者还期望读者熟悉基本的计算机体系结构和操作系统概念,例如中央处理器、内存、进程、线程、虚拟和物理内存、上下文切换等。如果您对这些术语中的任何一个是新鲜事物,建议您先学习这些材料。
3.1 上下文切换
上下文切换是指计算机在操作系统内核中,从一个线程或进程切换到另一个线程或进程时,保存并恢复相关的执行状态(即上下文)的过程。执行状态包括了程序计数器、寄存器和栈。当一个进程或线程被切换出去时,系统必须保存当前进程或线程的执行状态,并加载并恢复下一个进程或线程的执行状态。这个过程可能会带来一定的开销,因为在保存和加载执行状态期间,CPU需要花费一些时间,而这段时间在执行程序时并没有为应用程序的运行做出贡献。上下文切换通常是操作系统中调度器进行任务调度时的必要操作,以保证多个任务之间的独立性和并发性。
4 SIMD
SIMD是一种指令集扩展技术,全称为"单指令多数据"。它允许处理器同时对多个数据元素执行相同的算术或逻辑操作,从而加速数据并行处理。这种技术主要用于图像处理、音频和视频编解码、物理模拟等需要高性能计算的领域。
SIMD指令集已经被广泛应用于现代CPU和GPU中,并且已成为许多程序性能优化的关键技术之一。常见的SIMD指令集包括Intel SSE(Streaming SIMD Extensions)、AMD MMX(Multi-Media Extensions)、AVX(Advanced Vector Extensions)等。此外,还有许多使用SIMD指令编写的加速库和工具,如Intel MKL(Math Kernel Library)、OpenCV(Open Source Computer Vision Library)等。
4.1 SIMD原理
SIMD的原理是在每个指令周期内,同时对多个数据元素进行相同的算术或逻辑操作。这种操作可以利用硬件并行性,同时处理多个数据,从而提高数据处理的效率。
例如,假设我们要对两个向量A和B中的每个元素进行相加操作,可以使用以下代码实现:
cpp
复制代码
```
for (int i = 0; i < n; ++i) {
C[i] = A[i] + B[i];
}
```
这个循环结构需要执行n次迭代,每次迭代只能处理一个向量元素。如果我们使用SIMD指令集来实现向量加法,可以将该循环结构转换为以下代码:
cpp
复制代码
```
for (int i = 0; i < n; i += 4) {
__m128 va = _mm_load_ps(&A[i]);
__m128 vb = _mm_load_ps(&B[i]);
__m128 vc = _mm_add_ps(va, vb);
_mm_store_ps(&C[i], vc);
}
```
这里,我们使用了SSE指令集中的_mm_load_ps函数和_mm_add_ps函数来加载和相加两个4个浮点数组成的向量。这样一来,每个指令周期内就可以同时处理四个向量元素,从而提高向量加法的效率。
总之,SIMD的原理就是减少CPU对内存的访问次数,尽可能多地利用处理器内部并行性,从而提高数据处理的效率。
4.2 SIMD适用于单核cpu还是多核cpu
SIMD(Single Instruction Multiple Data)指令集通常用于多核CPU。这是因为SIMD在一个单独的指令周期内可以对多个数据值执行相同的操作,从而提高了并行性能。而在单核CPU上使用SIMD指令集也是可行的,但是由于单核CPU只有一个处理器核心,所以不能同时执行多个操作。因此,在大多数情况下,使用SIMD指令集的意义不如在多核CPU上。
1 Introduction
Figure 1: 40 Years of Microprocessor Trend Data. © Image by K. Rupp via karlrupp.net
transistors:
晶体管(transistor)是现代电子设备中最基本的组成部分之一。它是由半导体材料制成的三层或多层结构,可以放大或开关电流。晶体管的出现推动了现代电子技术的发展,而摩尔定律也是基于晶体管技术的发展而提出的。晶体管的大小越小,它们可以承受的电压就越低,因此变得更加节能。随着硅芯片工艺的不断进步,晶体管的尺寸越来越小,数量也越来越多,这使得计算机性能得到了快速提升。
当不再是每一代硬件都提供显著的性能提升[Leiserson等人,2020]时,我们必须开始更加关注我们的代码运行速度。在寻求提高性能的方法时,开发人员不应该依赖于硬件,而应该开始优化他们应用程序的代码。
马克·安德森(Marc Andreessen),美国企业家和投资家曾说过,"今天的软件效率极低;软件程序员再次有机会真正擅长优化。"(摘自a16z Podcast, 2020)。在我在英特尔工作期间,我也经常听到同样的故事:当英特尔的客户在他们的应用程序中遇到缓慢时,他们立即而无意识地开始责怪英特尔的CPU速度慢。但是当英特尔派出一位性能专家与他们合作并帮助他们改善应用程序时,往往可以将其加速5倍甚至10倍。达到高水平的性能很具挑战性,通常需要大量的努力,但希望这本书能够为您提供工具,帮助您实现这一目标。
现代CPU每年都会有越来越多的核心。截至2019年底,您可以购买一款高端的服务器处理器,其逻辑核心数将超过100个。这非常令人印象深刻,但这并不意味着我们不再需要关注性能。很多时候,应用程序的性能可能不会随着更多的CPU核心而变得更好。典型的通用多线程应用程序的性能并不总是随着我们为任务分配的CPU核心数量呈线性扩展。理解为什么会发生这种情况以及可能的解决办法对产品未来的增长至关重要。不能进行正确的性能分析和调整,会失去很多性能和金钱,并可能导致产品死亡。
根据[Leiserson et al.,2020]所述,在短期内,大多数应用程序的性能提升主要来自软件栈。遗憾的是,应用程序默认情况下并不能获得最佳性能。[Leiserson et al.,2020]还提供了一个很好的例子,说明可以在源代码级别进行性能改进的潜力。在该文章中,列举了对两个4096x4096矩阵相乘的程序进行性能工程的加速效果,总结在表1中。使用多种优化的最终结果是,程序运行速度快了60,000倍以上。提供这个例子的原因并不是针对Python或Java(它们都是很好的编程语言),而是打破一种信仰:即默认情况下软件拥有"足够好"的性能。
表1:对在具有总计60 GB内存的双插槽Intel Xeon E5-2666 v3系统上运行的两个4096x4096矩阵相乘的程序进行性能工程加速的结果。来自[Leiserson et al.,2020]。
以下是阻止系统默认实现最佳性能的一些最重要因素:
CPU限制。这很容易引发疑问:"为什么硬件不能解决我们所有的问题?"现代CPU以惊人的速度执行指令,并且每一代都在不断改进。但是,如果用于执行任务的指令不是最优或甚至是冗余的话,它们就无能为力。处理器不能将次优代码神奇地转换为性能更好的代码。例如,如果我们使用BubbleSort算法来实现排序例程,CPU将不会尝试识别它并使用更好的替代方案,例如QuickSort。它将盲目执行它被告知要做的任何事情。
编译器限制。"但难道这不是编译器的任务吗?编译器为什么不能解决我们所有的问题?"的确,现在的编译器非常聪明,但仍然可能生成次优的代码。编译器擅长消除冗余工作,但是当涉及到更复杂的决策,例如函数内联、循环展开等时,它们可能无法生成最佳的代码。例如,对于编译器是否应该始终将函数内联到它的调用位置中,没有二进制的"是"或"否"的答案。它通常取决于许多因素,编译器应该考虑这些因素。通常,编译器依赖于复杂的成本模型和启发式方法,这些模型和方法可能不适用于每种可能的情况。此外,除非编译器确定安全且不影响生成的机器代码的正确性,否则无法执行优化。对于编译器开发人员来说,确保特定的优化在所有可能的情况下都能生成正确的代码可能非常困难,因此他们通常需要保守并避免执行某些优化。最后,编译器通常不会转换程序使用的数据结构,这些数据结构在性能方面也非常重要。
编译器会根据硬件做不同优化么:
是的,编译器可以针对不同的硬件平台进行处理器优化,以实现更好的性能。在编译过程中,编译器会尽可能利用硬件特性来生成更高效的代码。例如,针对不同的CPU架构,编译器可以使用不同的指令集,或者对代码进行向量化操作以支持SIMD指令等。同时,编译器也会通过其他优化手段,如调整内存访问等,使代码适应不同的硬件特征,从而获得更好的性能表现。因此,在编写高性能代码时,需要考虑到目标硬件平台,并结合编译器提供的优化选项,以获得更好的性能。
AI编译器:
AI编译器是使用人工智能技术的编译器,可以通过学习和分析代码、硬件架构和优化策略等数据来生成高效的优化代码。与传统的编译器相比,AI编译器可以更加个性化地优化代码,并且可以快速适应不同的硬件平台和优化方法。AI编译器可以使用机器学习和深度学习技术来进行自我学习和优化,以提高代码的性能表现。AI编译器的出现为软件开发提供了更高效、更准确的优化方案,并且越来越受到广大开发者的重视和青睐。
3 算法复杂度分析的局限性。开发人员经常过于关注算法的复杂度分析,导致他们选择具有最优算法复杂度的流行算法,即使对于给定的问题来说,这种算法可能并不是效率最高的。比如在排序算法中,InsertionSort和QuickSort,后者在平均情况下的Big O表示法上明显胜出:InsertionSort是O(N2),而QuickSort只有O(N log N)。然而,对于相对较小的数据规模N,InsertionSort的实际表现会优于QuickSort。复杂度分析不能考虑到各种算法的分支预测和缓存效果等因素,因此它们只是将这些因素封装在隐含常数C中,这有时会对性能产生重大影响。如果盲目依赖Big O表示法而没有在目标工作负载上进行测试,那么可能会使开发人员走上错误的道路。因此,对于特定问题,最佳算法不一定在所有情况下都是最优的。
上述描述的局限性为我们优化软件性能留下了空间。广义上讲,软件堆栈包含许多层,例如固件、BIOS、操作系统、库以及应用程序源代码。但由于大多数较低层的软件不在我们直接控制之下,因此我们主要关注源代码。另一个我们将会涉及很多的重要软件部分是编译器。通过各种提示,使编译器生成所需的机器代码可以获得令人满意的加速效果。在整本书中,您将发现许多这样的例子。
个人经验:要成功地实现应用程序所需的改进,您不必成为一个编译器专家。根据我的经验,至少90%的所有转换都可以在源代码级别完成,而无需深入挖掘编译器源代码。尽管如此,在性能相关工作中,了解编译器的工作原理以及如何使其执行所需的操作始终是有优势的。
突然想到的:
1080p@60fps camera录像预览 mtk/qcom为何比apple功耗高出一大截子?
iphone的用户画像只是iphone,soc包括里面的camea isp hw pipeline和软件都是自研的,目标客户只是服务于iphone,hal层偏客制化,hal层的cpu mips预期不会很大。
而mtk/qcom的用户画像不同,他们的用户画像是各个品牌商,目标客户是各个品牌商,
hal层偏非客制化,更注重的是兼容性和扩展性,hal层的cpu mips预期会较大。
此外,现在,让应用程序通过分布在多个核心上进行扩展是必不可少的,因为单线程性能往往会达到一个瓶颈。这样的启用需要应用程序线程之间的高效通信,消除多线程程序中典型的资源浪费和其他问题。
需要提到的是,性能提升不仅仅来自于对软件的调整。根据[Leiserson等人,2020]的说法,未来潜在速度提升的另外两个主要来源是算法(尤其是针对新的问题域,如机器学习)和简化的硬件设计。算法在应用程序的性能中显然发挥着重要的作用,但我们不会在本书中涉及该主题。我们也不会讨论新的硬件设计主题,因为大多数时候,软件开发人员必须处理现有硬件。然而,了解现代CPU设计对于优化应用程序是很重要的。
"在摩尔定律结束后的时代,使代码运行得更快,特别是将其调整到运行硬件上变得越来越重要。" [Leiserson等人,2020]
本书中的方法论侧重于从应用程序中挤出最后一丝性能。这些转换可以归结为表1的第6和第7行。讨论的改进类型通常不大,并且往往不超过10%。但是不要低估10%速度提升的重要性。对于在云配置中运行的大型分布式应用程序尤其重要。根据[Hennessy,2018]的说法,在2018年,谷歌在实际运行云的计算服务器上花费的钱与其在电力和冷却基础设施上的花费大致相同。能源效率是一个非常重要的问题,通过优化软件可以改善能源效率。
"在这样的规模下,了解性能特征变得至关重要-即使是性能或利用率的微小改进也可能转化为巨大的成本节约。" [Kanev等人,2015]
在高性能计算(HPC)、云服务、高频交易(HFT)、游戏开发和其他对性能要求严格的领域,性能工程无需进行太多的证明就是非常必要的。例如,谷歌报告称,搜索速度慢2%导致每个用户搜索次数减少2%。对于Yahoo!而言,页面加载时间快400毫秒会导致5-9%的流量增加。在大数字的竞赛中,小幅改进可以产生重大影响。这些例子证明了服务运行得越慢,使用它的人就越少。
有趣的是,性能工程不仅在前面提到的领域中需要,现在在通用应用程序和服务领域也需要。如果无法满足性能要求,许多我们每天使用的工具根本不存在。例如,集成到Microsoft Visual Studio IDE中的Visual C++ IntelliSense功能具有非常严格的性能限制。为了使IntelliSense自动完成功能正常工作,它们必须在毫秒级别的时间内解析整个源代码库。
如果需要几秒钟才能建议自动完成选项,就没有人使用源代码编辑器了。这样的功能必须非常响应,并且随着用户输入新代码而提供有效的续写。类似应用程序的成功只能通过考虑性能和周到的性能工程来实现。
有时,快速工具会在其最初设计的领域之外找到用途。例如,现在像虚幻引擎和Unity这样的游戏引擎被用于建筑、3D可视化、电影制作和其他领域。由于它们的性能非常好,因此对于需要2D和3D渲染、物理引擎、碰撞检测、声音、动画等应用程序来说,它们是天然的选择。
"快速工具不仅可以让用户更快地完成任务,还可以以全新的方式完成全新类型的任务。"- Nelson Elhage在他的博客文章中写道(2020年)。
我希望无需明确说明,人们讨厌使用慢速软件。应用程序的性能特征可能是客户转向竞争对手产品的唯一因素。通过强调性能,您可以为产品赢得竞争优势。性能工程是重要且有回报的工作,但可能非常耗时。事实上,性能优化是一个永无止境的游戏。总会有一些可以优化的地方。不可避免地,开发人员将会达到收益递减点,进一步改善会付出非常高的工程成本,可能并不值得努力。从这个角度来看,知道何时停止优化是性能工作的关键方面。一些组织通过将此信息整合到代码审查过程中来实现:源代码行附有相应的"成本"指标。使用这些数据,开发人员可以决定是否值得提高特定代码片段的性能。
在开始性能调整之前,请确保您有足够的理由这么做。仅为了优化而进行优化是没有意义的,如果它不为您的产品增加价值。注意性能工程从明确定义性能目标开始,说明您正在尝试实现什么以及为什么这样做。此外,您应该选择用于衡量是否达到目标的指标。您可以在[Gregg,2013]和[Akinshin,2019]中了解有关设置性能目标的主题的更多信息。
尽管如此,练习和掌握性能分析和调整的技巧总是很棒的。如果您出于这个原因阅读本书,那么欢迎继续阅读。
如果您曾经与同事辩论某个代码片段的性能问题,那么您可能知道预测哪个代码将最有效有多难。由于现代处理器内部存在如此多的移动部件,即使对代码进行微小的调整也可能触发显着的性能变化。这就是为什么本书中的第一个建议是:始终进行测量。个人经验:我看到很多人在尝试优化其应用程序时依靠直觉。通常,这会导致散乱地修复一些问题,而不对应用程序的性能产生任何真正的影响。
经验不丰富的开发人员通常会在源代码中进行更改,希望它们会改善程序的性能。其中一个例子是将i++替换为++i,假设之前的i值未被使用。在一般情况下,这个变化不会对生成的代码产生任何影响,因为每个体面的优化编译器都会认识到之前使用的i值未被使用,并且将消除重复的副本。许多在世界范围内流传的微观优化技巧过去有效,但当前的编译器已经学会了它们。此外,有些人倾向于过度使用传统的位操作技巧。其中一个例子是使用基于异或的交换惯用语,而实际上,简单的std::swap可以产生更快的代码。这样的意外更改可能不会改善应用程序的性能。找到正确的修复位置应该是谨慎的性能分析的结果,而不是凭直觉和猜测。
有许多性能分析方法可能会或可能不会带领您发现问题所在。本书介绍的特定于CPU的性能分析方法有一个共同之处:它们是基于收集有关程序执行方式的某些信息。最终对程序源代码进行的任何更改都是通过分析和解释收集的数据来驱动的。找到性能瓶颈只是工程师工作的一半。第二个部分是适当地修复它。有时在程序源代码中更改一行就可以大大提高性能。性能分析和调整都是关于如何找到并修复这条线。错过这样的机会可能会造成很大的浪费。
1.4 What is discussed in this book?
本书旨在帮助开发人员更好地理解其应用程序的性能,学习发现效率低下的地方并消除它们。为什么我的手写压缩程序比传统的压缩程序慢两倍?为什么我对函数的更改导致性能下降了两倍?客户抱怨我的应用程序很慢,我不知道从哪里开始?我已经将程序优化到了最大潜力吗?我该如何处理所有这些缓存未命中和分支预测错误?希望通过本书结束时,您将对这些问题有所回答。
本书包含以下内容概要:
• 第2章讨论如何进行公正的性能实验并分析其结果。介绍性能测试和比较结果的最佳实践。
• 第3章和第4章介绍了CPU微体系结构和性能分析术语的基础知识;如果您已经掌握了这些知识,可以跳过这部分内容。
• 第5章探讨了几种最流行的性能分析方法。它解释了分析技术的工作原理以及可以收集哪些数据。
• 第6章介绍了现代CPU提供的支持和增强性能分析的功能。它解释了它们的工作原理以及可以解决哪些问题。
• 第7-9章包含了典型性能问题的解决方案。它以最方便与本书中最重要的概念之一 - Top-Down微体系结构分析(见6.1节)一起使用。
• 第10章包含了优化主题,这些主题并不特别与前三章涵盖的任何类别相关,但仍然重要到足以在本书中找到位置。
• 第11章讨论了分析多线程应用程序的技术。概述了优化多线程应用程序性能的一些最重要的挑战以及可用于分析多线程应用程序的工具。这个主题本身相当大,因此本章只集中讨论硬件特定问题,如"假共享"。
本书提供的示例主要基于开源软件:Linux作为操作系统,基于LLVM的Clang编译器用于C和C++语言,以及Linux Perf作为性能分析工具。选择这些技术的原因不仅在于它们的流行,还在于它们的源代码是开放的,这使我们更好地理解它们的工作原理。这对于学习本书中介绍的概念尤其有用。有时,我们还会展示一些专有工具,例如Intel® VTune™Profiler等,这些工具在其领域是"大牌"。
1.5 What is not in this book?
系统性能取决于不同的组件:CPU、操作系统、内存、I/O设备等。应用程序可以通过调整系统的各个组件来获得效益。一般来说,工程师应该对整个系统的性能进行分析。然而,在系统性能中最重要的因素是其核心------CPU。这就是为什么本书主要从CPU的角度着眼于性能分析,偶尔涉及操作系统和内存子系统。本书的范围不超出单个CPU,因此我们不会讨论针对分布式、NUMA和异构系统的优化技术。利用OpenCL和openMP等解决方案将计算异构到加速器(GPU、FPGA等)上也不在本书的讨论范围之内。
本书的核心是Intel x86 CPU架构,不提供针对AMD、ARM或RISC-V芯片的具体调整方法。尽管如此,本书中讨论的许多原则同样适用于这些处理器。此外,本书选择Linux作为操作系统,但对于大多数示例来说并不重要,因为相同的技术有利于在Windows和Mac操作系统上运行的应用程序。
本书中所有的代码片段都是用C、C++或x86汇编语言编写的,但在很大程度上,本书中的思想也可以应用于其他编译成本地代码的语言,如Rust、Go甚至Fortran。由于本书针对运行接近硬件的用户模式应用程序,我们将不讨论托管环境,例如Java。
最后,作者假设读者完全掌握他们开发的软件,包括所选择的库和编译器。因此,本书不是关于调整已购买的商业软件包的内容,例如调整SQL数据库查询。
1.6 Chapter Summary
总的来说,在过去的几年中,硬件单线程性能的提升已经不像以前那样显著了。这就是为什么性能调优变得比过去40年更加重要的原因。当前计算机行业正在发生比自90年代以来任何时候都更大的变化。
根据[Leiserson et al., 2020]的研究结果,软件调优将成为未来性能提升的关键驱动之一。不应低估性能调优的重要性。对于大型分布式应用程序,每个小的性能改进都会带来巨大的成本节约。
软件并没有默认的最佳性能。某些限制存在阻止应用程序达到其完全的性能潜力。硬件和软件环境都具有这样的限制。CPU不能加速缓慢的算法。编译器远未生成每个程序的最佳代码。由于硬件特定性,某个问题的最佳已知算法并不总是最有效的。所有这些都为我们优化应用程序性能留下了空间。
对于某些类型的应用程序,性能不仅仅是一个功能。它使用户以新的方式解决新的问题。
软件优化应该得到强有力的商业需求的支持。开发人员应该设定量化的目标和指标,这些目标和指标必须用于衡量进展。
由于影响现代平台性能的因素有太多,预测某段代码片段的性能几乎是不可能的。在实施软件优化时,开发人员不应该依靠直觉,而是使用仔细的性能分析。
理解应用程序性能的第一步是了解如何衡量它。有些人把性能看作是应用程序的一个特性。但与其他特性不同,性能不是一个布尔属性:应用程序总是具有某种程度的性能。这就是为什么无法对是否具有性能这个问题回答"是"或"否"。
性能问题通常比大多数功能问题更难追踪和复现。每次基准测试运行都不同于其他运行。例如,在解压缩zip文件时,我们会一遍又一遍地得到相同的结果,这意味着这个操作是可重现的。但是,无法完全重现这个操作的相同性能配置文件。
对于关注性能评估的任何人来说,都知道进行公平性能测量并从中得出准确结论有多么困难。性能测量有时可能会非常意外。改变源代码中看似不相关的部分可能会对程序性能产生显着影响。这种现象被称为测量偏差。由于测量中存在误差,性能分析需要使用统计方法进行处理。这个话题本身就值得一本书来讨论。在这个领域有很多边角案例和大量的研究。我们不会深入探讨这个话题。相反,我们只会关注高层次的想法和方向。
进行公正的性能实验是获得准确和有意义结果的关键步骤。设计性能测试和配置环境都是评估性能过程中重要的组成部分。本章将简要介绍现代系统为什么会产生嘈杂的性能测量结果以及您可以采取什么措施。我们将涉及在实际生产部署中测量性能的重要性。
没有一个长寿命的产品能够没有性能回归。这对于具有大量贡献者并且变化非常快的大型项目尤其重要。本章专门介绍了在持续集成和持续交付(CI / CD)系统中跟踪性能变化的自动化过程,同时也提供了有关开发人员在实现源代码更改时如何正确收集和分析性能测量结果的一般指导。
本章末尾描述了可以由开发人员用于基于时间的测量的SW和HW计时器以及设计和编写良好的微基准测试时常见的陷阱。
2.1 Noise In Modern Systems
硬件和软件中有许多旨在提高性能的功能,但并不是所有功能都具有确定性行为。让我们考虑动态频率缩放(DFS):这是一项功能,允许CPU在短时间间隔内增加频率,使其运行速度显著提高。然而,CPU不能长时间保持在"超频"模式下,因此稍后会将频率降回基准值。DFS通常非常依赖核心温度,这使得难以预测其对实验的影响。
如果我们在"冷"处理器上连续进行两次基准测试运行,第一次运行可能会在"超频"模式下运行一段时间,然后将频率降回基准水平。然而,第二次运行可能没有这个优势,并且以基准频率运行而没有进入"turbo模式"。即使我们两次运行完全相同版本的程序,它们运行的环境也不同。图2展示了动态频率缩放可能导致测量差异的情况。这种情况在笔记本电脑上进行基准测试时经常发生,因为它们通常具有有限的散热能力。
Figure 2: Variance in measurements caused by frequency scaling.
频率缩放是一种硬件特性,但是测量变化也可能来自软件特性。让我们考虑一个文件系统缓存的例子。如果我们对一个进行大量文件操作的应用程序进行基准测试,文件系统可以在性能方面发挥重要作用。当第一个基准测试迭代运行时,文件系统缓存中所需的条目可能会丢失。然而,当运行同样的基准测试第二次时,文件系统缓存将被预热,使其比第一次运行显着更快。
不幸的是,测量偏差并不仅来自环境配置。Mytkowicz等人在2009年的论文中展示了UNIX环境大小(即存储环境变量所需的总字节数)和链接顺序(给链接器提供的目标文件的顺序)可能以无法预测的方式影响性能。此外,还有许多其他影响内存布局并可能影响性能测量的方法。为了在现代架构上实现统计上正确的软件性能分析,Curtsinger和Berger在2013年提出了一种方法。这项工作表明,通过在运行时高效地重复随机放置代码、堆栈和堆对象,可以消除来自内存布局的测量偏差。可惜的是,这些想法没有得到更进一步的发展,目前该项目几乎已被放弃。
个人经验:记住,即使运行像Linux top这样的任务管理器工具,也会影响测量,因为某些CPU核心将被激活并分配给它。这可能会影响实际基准测试运行的核心频率。
要保持一致的测量结果,需要在相同的条件下运行基准测试的所有迭代。然而,不可能复制完全相同的环境并完全消除偏差:可能存在不同的温度条件、电力供应峰值、运行的邻近进程等。追踪系统中所有潜在的噪声和变化可能是一个无休止的故事。有时候这是不可能实现的,例如,在对大规模分布式云服务进行基准测试时。
因此,在定义明确、稳定的性能测试(例如微基准测试)中,消除系统中的非确定性是有帮助的。例如,当您实现了一些代码更改并想通过基准测试程序的两个不同版本来比较其相对加速比时。在这种情况下,您可以控制基准测试中的大多数变量,包括其输入、环境配置等。在这种情况下,消除系统中的非确定性有助于获得更一致和准确的比较结果。完成本地测试后,必须确保预期的性能提高在真实世界的测量中得到体现。读者可以在附录A中找到一些可能会引入性能测量噪声的功能以及如何禁用它们的示例。此外,还有工具可以设置环境,以确保基准测试结果具有较低的方差;其中之一是temci24。
当估计真实世界性能提高时,不建议消除系统中的非确定性行为。工程师应尝试复制其正在优化的目标系统配置。向被测试的系统引入任何人为调整都会使结果与服务的用户在实践中看到的结果不同。此外,任何性能分析工作,包括分析(见第5.4节),都应在与将在实际部署中使用的系统相似的系统上完成。
最后,需要记住,即使特定的硬件或软件功能具有非确定性行为,也不意味着它是有害的。它可能会给出不一致的结果,但它被设计来提高整个系统的性能。禁用这样的功能可能会降低微基准测试中的噪声,但会使整个测试套件运行时间更长。这对于CI/CD性能测试尤其重要,因为对于运行整个基准测试套件的时间有时间限制。
当应用程序运行在共享基础设施上(通常在公共云中),通常会有其他客户的工作负载在同一台服务器上运行。随着虚拟化和容器等技术变得越来越流行,公共云提供商试图充分利用其服务器的容量。不幸的是,这为在这样的环境中进行性能测量带来了额外的障碍。与邻近进程共享资源可能会以不可预测的方式影响性能测量。
分析生产工作负载并在实验室中重新创建它们可能很棘手。有时候,无法合成"内部"性能测试的确切行为。这就是为什么越来越多的云提供商和超大规模提供商选择在生产系统上直接进行性能分析和监控的原因[Rene et al.,2010]。在没有"其他参与者"的情况下进行性能测量可能不反映真实世界的情况。在实验室环境中执行良好但在生产环境中执行不佳的代码优化将是一种浪费时间的做法。话虽如此,这并不消除早期捕捉性能问题的持续"内部"测试的需求。并不是所有的性能回归都能在实验室中被捕捉到,但工程师应该设计真实世界场景的性能基准测试。
越来越多的大型服务提供商正在实现遥测系统来监控用户设备上的性能,其中一个例子是Netflix的Icarus25遥测服务,它在全球各地的成千上万个设备上运行。这种遥测系统有助于Netflix了解真实用户对Netflix应用程序性能的感知。它允许工程师分析从多个设备收集的数据,并找到无法通过其他方式发现的问题。这种数据可以帮助更好地决定优化工作的重点。
然而,在监视生产部署时,要注意测量开销。由于任何类型的监视都会影响正在运行的服务的性能,因此建议仅使用轻量级的分析方法。根据[Rene et al.,2010]的说法:"对于为实际流量服务的数据中心机器进行连续分析,极低的开销至关重要"。通常,可接受的聚合开销被认为低于1%。可以通过限制进行分析的计算机集合以及缩短时间间隔来减少性能监视开销。
在这样的生产环境中进行性能测量意味着必须接受它嘈杂的本质并使用统计方法来分析结果。LinkedIn等大公司如何在生产环境中使用统计方法来测量和比较基于quantile的指标(如90分位数页面加载时间)的A/B测试的很好的例子可以在[Liu et al.,2019]中找到。
软件vendor供应商试图增加部署频率已经成为一种趋势。公司不断寻求加速产品推向市场的速度。不幸的是,这并不意味着每次新版本发布都会使软件产品变得更好。特别是,软件性能缺陷往往以惊人的速度泄露到生产软件中[Rene et al.,2012]。对于软件中的大量变化,分析所有这些结果和历史数据来检测性能回归带来了挑战。
软件性能回归是错误地引入到软件中的缺陷,而这些缺陷在软件从一个版本演变到下一个版本时产生。捕捉性能缺陷和改进意味着检测哪些提交改变了软件的性能(如性能测试所测量的)在测试基础设施的噪声存在下。从数据库系统到搜索引擎再到编译器,在其持续的演进和部署生命周期中,几乎所有大规模软件系统都会经历性能回归。虽然在软件开发过程中完全避免性能回归可能是不可能的,但通过适当的测试和诊断工具,可以将这类缺陷泄漏到生产代码中的可能性最小化。
什么是性能回归:
性能回归是指在软件演化过程中引入错误的性能变化。当软件从一个版本发展到下一个版本时,由于引入了新代码和修改了旧代码等原因,软件的性能可能会发生变化。而这种变化可能导致软件的性能变差,即出现性能回归。性能回归在软件开发和测试中是非常常见的问题,特别是在大规模软件系统中。因此,需要采用适当的测试和诊断工具来检测性能回归并尽量避免引入这种缺陷。
人工分析图表并比较结果是最容易想到的选项之一。但很快我们就会发现,这种方法并不是很理想。人们往往会很快失去注意力,并可能错过回归,尤其是在像图3所示那样嘈杂的图表上。人们可能会发现发生在8月5日左右的性能回归,但人不能保证能够检测到后来的性能回归。除了容易出错之外,人为因素也是一个耗时而乏味的工作,必须每天进行。因此,我们需要更高效和准确的方式来检测和诊断性能回归,以替代人工分析的方法。
Figure 3: Performance trend graph for four tests with a small drop in performance on August 5th
第二个选项是使用简单的阈值。这种方法比第一个选项稍微好一些,但仍然有其自身的缺点。性能测试中的波动是不可避免的:有时,即使是一个无害的代码更改,也可能触发基准测试中的性能变化。选择正确的阈值非常困难,也无法保证低误报和漏报警告率。将阈值设置得太低可能会导致分析一堆由随机噪声而非源代码更改引起的小型回归问题,将阈值设置得太高可能会过滤掉真正的性能回归。小的变化可能会慢慢积聚成更大的回归,被忽视不被发现。从图3中可以看出,阈值需要进行每次测试的调整。适用于绿色(上线)测试的阈值不一定适用于紫色(下线)测试,因为它们的噪声水平不同。LUCI 是 Chromium 项目的一部分,是一个需要为每个测试设置显式阈值以警报回归的 CI 系统的例子。因此,简单的阈值方法也不是一个完美的选项来检测性能回归。
最近发现性能回归的一种方法是在[Daly et al., 2020]中提出的。MongoDB开发人员实现了变化点分析,以识别他们数据库产品不断变化的代码库中的性能变化。根据[Matteson and James, 2014],变化点分析是在时间有序观察中检测分布变化的过程。 MongoDB开发人员使用了一种"E-Divisive means"算法,该算法通过分层选择分布变化点将时间序列划分为多个集群。他们的开源CI系统称为Evergreen,将此算法纳入其中,在图表上显示变化点并打开Jira票务。有关此自动化性能测试系统的更多详细信息,请参见[Ingo和Daly,2020]。
Alam et al., 2019\]中提出了另一种有趣的方法。该论文的作者介绍了AutoPerf,它使用硬件性能计数器(PMC,请参见第3.9.1节)来诊断修改后的程序中的性能回归。首先,它基于从原始程序收集的PMC配置文件数据学习修改后函数的性能分布。然后,它基于从修改后程序收集的PMC配置文件数据,将性能偏差检测为异常值。AutoPerf表明,这种设计可以有效地诊断一些最复杂的软件性能错误,例如在并行程序中隐藏的性能错误。
无论检测性能回归的底层算法如何,典型的CI系统应自动执行以下操作:
1.设置待测试的系统。
2.运行工作负载。
3.报告结果。
4.决定性能是否更改。
5.可视化结果。
CI系统应支持自动和手动基准测试,产生可重复的结果,并为发现的性能回归打开票务。及时检测回归非常重要。首先,因为自回归发生以来合并的更改较少。这使我们可以有一个负责回归的人在转向另一个任务之前处理问题。此外,对于开发人员来说,接近回归更容易,因为所有细节仍然新鲜在他们的头脑中,而不是几周之后。
##### **2.4 Manual Performance Testing**
在开发过程中,工程师可以利用现有的性能测试基础设施是很好的。在前一节中,我们讨论了CI系统的一个很好的功能是能够将性能评估作业提交给它。如果支持这种功能,那么系统会返回开发人员想要提交到代码库的补丁测试结果。由于各种各样的原因,如硬件不可用、设置对于测试基础设施来说太过复杂、需要收集附加指标等,这并不总是可能的。本节中,我们提供了进行本地性能评估的基本建议。
当我们在代码中进行性能改进时,我们需要一种方法来证明我们确实使其更好了。此外,当我们提交常规代码更改时,我们希望确保性能没有回归。通常,我们通过以下方式实现:1)度量基准性能,2)度量修改后程序的性能,3)将它们互相比较。在这种情况下,目标是比较同一个功能程序的两个不同版本的性能。例如,我们有一个递归计算斐波那契数列的程序,并且我们决定以迭代的方式重写它。两者都是功能正确的,产生相同的数字。现在我们需要比较两个程序的性能。
建议不仅要进行单次测量,而是要运行多次基准测试。因此,我们有N次基础测试的测量结果和N次修改后程序的测量结果。现在我们需要一种比较这两组测量结果的方法,以决定哪个更快。这个任务本身是难以处理的,而且很容易被测量值欺骗,从而可能得出错误的结论。如果你问任何数据科学家,他们会告诉你不应该依赖单一的度量标准(如最小值/平均值/中位数等)。
考虑图4中为两个程序版本收集到的性能测量分布。该图显示了对于给定版本的程序,我们获得特定时间的概率。例如,在A版本中完成约102秒的概率约为32%。很容易说A比B更快,但这只是在某种概率P下成立。这是因为B的某些测量值可能比A更快。即使在B的所有测量值都慢于A的每个测量值的情况下,概率P也不等于100%。这是因为我们总是可以为B产生一个额外的样本,它可能比A的某些样本更快。

Figure 4: Comparing 2 performance measurement distributions.
使用分布图的一个有趣优点是,它允许您发现基准测试的不良行为。如果分布是双峰的,则基准测试可能经历了两种不同类型的行为。造成双峰分布的常见原因是代码具有快速和慢速路径,例如访问缓存(缓存命中 vs. 缓存未命中)和获取锁(竞争锁 vs. 非竞争锁)。为了"修复"这个问题,应将不同的功能模式隔离并分别进行基准测试。
数据科学家通常通过绘制分布并避免计算加速比来展示测量结果。这消除了偏见结论,并允许读者自己解释数据。绘制分布的流行方式之一是使用箱线图(请参见图5),它允许在同一图表中比较多个分布。

Figure 5: Box plots.
什么是缓存命中:
缓存命中指的是当计算机在运行程序时,需要访问内存中的数据,如果所需数据已经存在于高速缓存(cache)中,则缓存直接提供该数据给CPU处理,避免了从较慢的主内存中读取数据的时间和开销。这种情况下,处理器可以直接从缓存中获取所需数据,速度更快,能够提高程序的执行速度。
什么是缓存未命中:
缓存未命中是指CPU在需要访问某个内存地址时,发现该地址对应的数据并未存在于高速缓存中,需要从主内存中获取所需数据。这时处理器会将相应的内存块从主内存读取到缓存中,以便下次使用。由于主内存的访问速度相比高速缓存较慢,因此缓存未命中会导致程序执行效率下降,影响程序的整体性能。
什么是竞争锁:
竞争锁是指在多线程或并发程序中,多个线程试图同时访问同一资源(例如共享内存或文件),但只有一个线程能够获得访问权限。为了保证资源的正确性和一致性,在多个线程或进程之间必须使用锁机制来协调访问顺序。竞争锁表示多个线程在等待获取同一个锁时相互竞争,只有一个线程可以成功获取锁并执行操作。其他线程则必须等待获取锁的线程释放该锁才能继续运行。
什么是非竞争锁:
非竞争锁是指当多个线程或进程同时访问资源时,它们不会相互竞争、不必等待或阻塞。这种锁的使用场景通常是在读操作非常多而写操作比较少的情况下。在这种情况下,如果多个线程同时读取同一个资源,不会有数据一致性的问题,因此可以使用非竞争锁来提高程序的并发性和效率。常见的非竞争锁包括读写锁(Reader-Writer Lock),它允许多个线程同时读取同一个资源,但在写入资源时仍然需要竞争锁来保证数据的一致性。
本文主要讨论了在性能评估过程中如何计算速度提升比率,以便更好地优化程序性能。虽然可视化性能分布图可以帮助发现一些异常情况,但不能用于计算速度提升比率。通常,我们需要使用假设检验方法来确定两个程序版本的性能分布之间的统计关系。如果分布是高斯分布,则可以使用参数假设检验方法(例如学生T检验)来比较分布。如果分布不是高斯分布,则可以使用非参数假设检验方法(例如曼-惠特尼检验),以确定速度提升是否是随机的。此外,本文还介绍了统计学中的一些相关概念,例如模态分布和偏态分布。对于想深入了解性能工程领域统计学知识的人,可以参考Dror G. Feitelson的著作《Workload Modeling for Computer Systems Performance Evaluation》。
当确定差异在假设检验中是显著的时,就可以计算速度提升比率。一般来说,可以使用平均值或几何平均值计算速度提升比率,但是需要注意以下几点。在样本数量较小的情况下,平均值和几何平均值可能会受到异常值的影响。除非分布的方差很小,否则不要仅考虑平均值。如果测量值的方差与均值相同数量级,则平均值并不是一个代表性的指标。图6展示了一个程序的两个版本的例子。通过观察平均值(6a),人们可能会认为A比B快20%。然而,考虑到测量结果的变化范围(6b),我们可以看出这并不总是正确的,有时候B可能比A快20%。对于正态分布的情况,可以使用均值、标准偏差和标准误差的组合来评估程序两个版本之间的速度提升情况。否则,在偏态或多峰分布的样本中,需要使用更适合该基准测试的百分位数,例如最小值、中位数、90th、95th、99th和最大值等,或者这些指标的某个组合。

Figure 6: Two histograms showing how averages could be misleading.
在计算准确的速度提升比率时,收集丰富的样本是至关重要的,即需要大量地运行基准测试。这可能听起来很明显,但并不总是可行的。例如,一些 SPEC 基准测试会在现代计算机上运行超过10分钟。这意味着只需要三个样本就需要花费1小时:每个程序版本30分钟。如果您的测试套件不止一个基准测试,而是有数百个,那么即使将工作分配到多台计算机上,也很难收集足够的数据。
那么如何知道收集多少个样本才可以获得足够的统计数据呢?答案取决于您想要比较具有多少精度。样本分布中方差越小,您需要的样本数就越少。标准偏差是告诉您分布中测量值一致性的度量。可以通过动态限制基准测试迭代次数来实现自适应策略,即根据标准偏差收集样本,直到标准偏差位于某个范围内。一旦标准偏差低于某个阈值,您可以停止收集测量数据。该策略在\[Akinshin, 2019, Chapter 4\]中更详细地解释。
另一个需要注意的重要事项是异常值的存在。使用置信区间可以丢弃一些异常值(例如,冷启动),但不要故意从测量集中删除不想要的样本。对于某些类型的基准测试,离群值可能是最重要的指标之一。例如,在基准测试具有实时约束条件的软件中,99分位数非常有趣。Gil Tene 在 YouTube 上有一系列关于测量延迟的讲座,涵盖了这个主题。
SPEC benchmarks是什么:
http://spec.org/cpu2017/Docs/overview.html#benchmarks
SPEC 基准测试是一系列标准化的计算机基准测试,旨在评估计算机系统在处理计算密集型任务方面的性能。 SPEC 基准测试由一个非盈利组织 SPEC(Standard Performance Evaluation Corporation)开发和维护。SPEC 标准由多个基准测试套件组成,每个套件包含一组基准测试,用于测试处理器、操作系统、编译器和其他系统组件的性能。常见的 SPEC 套件包括 SPEC CPU 以及 SPECjbb(Java Business Benchmark)。这些基准测试对于计算机硬件和软件开发人员来说都具有很高的参考价值,可以帮助他们评估和优化计算机系统的性能和稳定性。
如何使用SPEC CPU:
使用 SPEC CPU 套件来测试系统性能通常需要一些准备工作,以下是一般的步骤:
1. 下载和安装 SPEC CPU 套件。
您需要从 SPEC 网站上下载适合您的系统的 SPEC CPU 套件,并按照指示进行安装。每个套件都包含多个基准测试。
2. 配置和编译基准测试。
在开始测试之前,您需要选择要运行的基准测试,并对其进行配置和编译。这涉及到指定测试选项和参数,以及选择编译器等工具。 SPEC CPU 提供了一些脚本和工具来帮助您完成这些任务。
3. 运行基准测试。
一旦编译完成,您可以通过运行 SPEC CPU 提供的执行脚本来运行基准测试。测试脚本将运行多个测试迭代并记录执行时间等指标。您可以根据需要自定义测试选项。
4. 分析测试结果。
测试运行完成后,您可以使用 SPEC CPU 提供的工具来分析测试结果。这些工具可以生成报告,显示各种指标(例如运行时间、运行速度、吞吐量等)以及与其他系统的比较结果。
注意:SPEC CPU 的完整测试需要大量时间和资源。在运行测试之前,请确保系统具有足够的资源,并考虑使用一些优化策略来提高测试运行的效率。此外,为了获得可靠的比较结果,请确保在进行测试时采用相同的测试选项和参数,并在相同的硬件和软件环境中运行测试。
下载SPEC CPU 套件的网站是:
您可以在 SPEC 网站上下载 SPEC CPU 套件,网址为 https://www.spec.org/cpu/. 在 SPEC 网站上,您可以找到每个套件的详细信息、下载链接和安装指南。请注意,SPEC CPU 套件是商业软件,需要购买许可证才能使用。如果您计划在商业环境中使用 SPEC CPU 套件,请联系 SPEC 获取更多信息,并获取适当的许可证。
##### **2.5 Software and Hardware Timers**
为了对执行时间进行基准测试,工程师通常使用两个不同的计时器,所有现代平台都提供这些计时器:
• System-wide high-resolution timer. //• 系统级别的高分辨率计时器。
这是一个系统计时器,通常实现为自某个任意起点日期(称为纪元)以来发生的时钟滴答数的简单计数。该时钟是单调的,即它总是向上增加。系统计时器具有纳秒级分辨率,并在所有CPU之间保持一致。它适用于测量超过一微秒的事件。可以通过系统调用从操作系统中获取系统时间。系统级别计时器与CPU频率无关。在Linux系统上,可以通过clock_gettime系统调用访问系统计时器。在C ++中访问系统计时器的事实标准是使用std::chrono,如清单1所示。
Listing 1 Using C++ std::chrono to access system timer
```cpp
#include
#include
// returns elapsed time in nanoseconds
uint64_t timeWithChrono() {
using namespace std::chrono;
uint64_t start = duration_cast
(steady_clock::now().time_since_epoch()).count();
// run something
uint64_t end = duration_cast
(steady_clock::now().time_since_epoch()).count();
uint64_t delta = end - start;
return delta;
}
```
• Time Stamp Counter (TSC). //• 时间戳计数器(TSC)。
这是一个以硬件寄存器形式实现的硬件计时器。TSC是单调的,并具有恒定的速率,即它不考虑频率变化。每个CPU都有自己的TSC,它只是经历的参考时钟周期数(见第4.6节)。它适用于测量持续时间为纳秒级至一分钟的短事件。可以使用编译器内置函数__rdtsc来检索TSC的值,如清单2所示,该函数在底层使用了RDTSC汇编指令。关于使用RDTSC汇编指令对代码进行基准测试的更多低级细节可以在一份白皮书中找到\[Paloeni,2010\]。
Listing 2 Using __rdtsc compiler builtins to access TSC
```cpp
#include
#include
// returns the number of elapsed reference clocks
uint64_t timeWithTSC() {
uint64_t start = __rdtsc();
// run something
return __rdtsc() - start;
}
```
选择使用哪个计时器非常简单,取决于您要测量的时间长短。如果您测量的时间非常短,则TSC将为您提供更高的准确性。相反,使用TSC来测量运行数小时的程序是毫无意义的。除非您真正需要周期精度,否则系统计时器应足以满足大部分情况下的需求。需要记住的是,访问系统计时器通常比访问TSC具有更高的延迟。进行clock_gettime系统调用可能比执行RDTSC指令慢十倍,后者需要20+ CPU周期。这对于最小化测量开销尤其在生产环境中非常重要。可以在CppPerformanceBenchmarks存储库的wiki页面46上找到不同平台访问计时器的不同API的性能比较。
##### **2.6 Microbenchmarks**
可以编写自包含的微基准测试以快速测试某些假设。通常,微基准测试用于在优化某些特定功能时跟踪进度。几乎所有现代语言都有基准测试框架:对于C++,可以使用Google benchmark库;对于C#,可以使用BenchmarkDotNet库;Julia有BenchmarkTools包;Java有JMH(Java微基准测试工具),等等。
在编写微基准测试时,非常重要的是确保您想要测试的场景在运行时由您的微基准测试实际执行。优化编译器可能会消除关键代码,这可能使实验无用,甚至更糟糕的是,导致错误结论。在下面的示例中,现代编译器可能会消除整个循环:
```cpp
// foo DOES NOT benchmark string creation
void foo() {
for (int i = 0; i < 1000; i++)
std::string s("hi");
}
```
测试这种情况的一种简单方法是检查基准测试的概要,看看预期的代码是否作为热点突出显示。有时可以立即发现异常时间,因此在分析和比较基准运行时请使用常识。让编译器不要优化掉重要代码的流行方法之一是使用类似DoNotOptimize的辅助函数,其在幕后执行必要的内联汇编魔法:
```cpp
// foo benchmarks string creation
void foo() {
for (int i = 0; i < 1000; i++) {
std::string s("hi");
DoNotOptimize(s);
}
}
```
如果编写得好,微基准测试可以成为性能数据的良好来源。它们通常用于比较关键功能的不同实现的性能。定义一个好的基准测试的关键在于它是否在实际使用功能的真实条件下测试性能。如果基准测试使用与实践中提供的不同的合成输入,则该基准测试可能会误导您,并带您做出错误的结论。此外,当基准测试在没有其他需求的进程的系统上运行时,它拥有所有可用的资源,包括DRAM和缓存空间。即使某个版本消耗更多的内存,但这样的基准测试也很可能成为更快版本的冠军。但是,如果有消耗DRAM重要部分的相邻进程,则结果可能相反,导致属于基准测试进程的内存区域被交换到磁盘。
出于同样的原因,在从单元测试函数得出的结果时要小心。现代单元测试框架(如GoogleTest)提供每个测试的持续时间。然而,这些信息不能替代仔细编写的基准测试,该测试使用实际输入在实际条件下测试函数(请参见\[Fog,2004,第16.2章\])。并不总是可能复制粘贴到实践中的确切输入和环境,但这是开发人员在编写良好的基准测试时应考虑的事情。
##### **2.7 Chapter Summary**
调试性能问题通常比调试功能错误更难,因为存在测量不稳定性。
除非设置特定目标,否则您永远无法停止优化。为了知道是否达到了期望的目标,您需要想出有意义的定义和指标来衡量它。根据您所关心的内容,可能是吞吐量、延迟、每秒操作数(roofline性能)等。
现代系统具有非确定性性能。消除系统中的不确定性有助于定义明确、稳定的性能测试,例如微基准测试。在生产部署中测量性能需要使用统计方法分析结果,以应对嘈杂的环境。
越来越多大型分布式软件供应商选择在生产系统上直接进行性能分析和监控,这需要使用轻量级的性能分析技术。
采用自动化的性能跟踪系统可以防止性能回归泄漏到生产软件中。这些CI系统应运行自动化性能测试、可视化结果并标记潜在的缺陷。
可视化性能分布可以帮助发现性能异常,并且是向广泛受众呈现性能结果的安全方式。
Hypothesis Testing方法,例如学生T检验,可用于确定性能分布之间的统计关系。一旦确定差异具有统计显著性,那么速度提升可以计算为平均值或几何平均值之间的比率。
可以放弃冷启动以确保所有内容都在热运行状态下,但不要故意丢弃不需要的数据。如果选择放弃某些样本,请对所有分布进行统一处理。
为了对执行时间进行基准测试,工程师可以使用现代平台提供的两个不同计时器。系统范围的高分辨率计时器适用于测量持续时间超过一微秒的事件。为了在高精度下测量短时间事件,请使用时间戳计数器。
微基准测试是快速验证某些内容的好方法,但您应该始终在实际条件下的实际应用程序上验证您的想法。通过检查性能配置文件,确保基准测试有意义的代码。
#### 3 CPU Microarchitecture
本章简要概述了对性能有影响的关键CPU架构和微架构特征。本章的目标不是详细介绍CPU架构的细节和权衡,在文献中已有广泛介绍\[Hennessy和Patterson,2011\]。我们将快速回顾对软件性能产生直接影响的CPU硬件特征。
##### **3.1 Instruction Set Architecture**
指令集是软件与硬件通信时使用的词汇。指令集架构(ISA)定义了软件和硬件之间的约定。Intel x86、ARM v8、RISC-V是当前最广泛部署的ISA的示例。所有这些都是64位体系结构,即所有地址计算都使用64位。ISA开发人员通常确保符合规范的软件或固件将在使用规范构建的任何处理器上执行。广泛部署的ISA特许经营权通常也会确保向后兼容性,从而使为处理器的GenX版本编写的代码将继续在GenX+i上执行。
大多数现代体系结构都可以归类为通用寄存器架构的装载-存储体系结构,操作数是显式指定的,并且只使用装载和存储指令访问内存。除了提供ISA中的基本功能,例如使用整数和浮点数执行标量算术操作、加载、存储、控制等之外,广泛部署的体系结构继续增强其ISA以支持新的计算范例。这些包括增强的向量处理指令(例如Intel AVX2、AVX512、ARM SVE)和矩阵/张量指令(Intel AMX)。软件映射到使用这些高级指令通常可以提供数量级的性能改进。
随着深度学习领域的快速发展,行业对于替代数值格式以实现显著的性能提升再次产生了兴趣。研究表明,使用更少的位数来表示变量深度学习模型的性能表现同样好,可以节省计算和内存带宽。因此,除了传统的32位和64位格式之外,几个CPU特许经营权最近还添加了对低精度数据类型的支持,例如8位整数(int8,例如Intel VNNI)、16位浮点数(fp16、bf16)在ISA中。
##### **3.2 Pipelining //流水线**
流水线是使CPU快速的基础技术,其中多个指令在执行过程中重叠。 CPU中的流水线从汽车装配线中获得启发。指令的处理被划分为不同的阶段。这些阶段并行运作,处理不同指令的不同部分。 DLX是\[Hennessy and Patterson,2011\]定义的简单5级流水线的示例,包括:
1. 指令获取(IF)
2. 指令解码(ID)
3. 执行(EXE)
4. 存储器访问(MEM)
5. 写回(WB)

Figure 7: Simple 5-stage pipeline diagram.
图7显示了5级流水线CPU的理想流水线视图。在第1个时钟周期中,指令x进入流水线的IF阶段。在下一个时钟周期中,当指令x移动到ID阶段时,程序中的下一条指令进入IF阶段,以此类推。一旦流水线被填满,例如在上面的第5个时钟周期中,CPU的所有流水线阶段都正在处理不同的指令。如果没有流水线技术,指令x + 1将无法开始执行,直到指令1完成其工作为止。
CPU流水线技术是需要多核并行是么?
CPU流水线技术不需要多核并行。相反,流水线技术是通过将处理器的指令执行过程分为多个阶段,将各个阶段的操作重叠,在单个核心上实现指令级并行,从而提高CPU的性能。流水线技术利用了指令之间的独立性,通过同时执行多条指令来提高CPU吞吐量。因此,即使在单核CPU上,流水线技术也可以显著提高指令执行的速度和效率。
//可能是单核CPU处理IF/ID/EXE/MEM/WB的模块只有一个,同一时刻只能处理一个IF/ID/EXE/MEM/WB
大多数现代CPU都是采用深度流水线技术,也称为超级流水线技术。流水线CPU的吞吐量定义为每单位时间内完成并退出流水线的指令数量。任何给定指令的延迟是流水线中所有阶段的总时间。由于流水线中的所有阶段都互相链接,因此每个阶段必须准备好步调一致地移动到指令处。将指令从一个阶段移动到另一个阶段所需的时间定义了CPU的基本机器周期或时钟。对于一个给定的流水线,所选择的时钟值由最慢的流水线阶段定义。CPU硬件设计师努力平衡可以在一个阶段中完成的工作量,这直接定义了CPU的操作频率。增加频率会提高性能,并通常涉及平衡和重新流水线化,以消除由最慢的流水线阶段引起的瓶颈。
在理想的、完全平衡且不会产生任何停顿的流水线中,流水线机器中每条指令的时间由以下公式给出:
流水线机器中每条指令的时间 = 非流水线机器中每条指令的时间 / 流水线阶段数
但在实际实现中,流水线引入了几个限制条件,限制了上述理想模型。流水线故障会阻止理想的流水线行为导致停顿。故障分三类:结构故障、数据故障和控制故障。幸运的是,对于程序员来说,在现代CPU中,所有类型的故障都由硬件处理。
//所谓的非流水线机器中每条指令的时间指的是instruction完全串行指令的时间
结构性故障是由于资源冲突所造成的。在很大程度上,它们可以通过复制硬件资源来消除,例如使用多端口寄存器或内存。然而,消除所有这些故障可能会在硅芯片面积和功耗方面变得相当昂贵。因此,在设计中需要权衡不同的需求和限制,以最小化产生结构性故障的可能性,并且尽可能减少额外的硬件成本。现代CPU采用了各种技术来减少结构故障的影响,如乱序执行、数据预取和缓存等技术。这些技术可以提高CPU的性能,降低延迟,同时还能保持设计的经济性和可行性。
数据故障是由程序中的数据依赖关系引起的,并分为三种类型:
1. Read-after-write(RAW)故障需要依赖读取在写入之后才执行。当指令x+1在先前的指令x写入源之前读取源时,就会发生这种情况,导致读取错误的值。CPU实现了从管道后期到管道前期的数据转发(称为"bypassing")来减轻RAW故障所带来的惩罚。其基本思想是,可以在指令x完全执行之前将指令x的结果转发给指令x+1。在上面的例子中,对于寄存器R1存在RAW依赖性。可以直接在执行R0 ADD 1操作之后(从EXE管道阶段),获取该值并写入寄存器文件,无需等待WB阶段完成。Bypassing有助于节省几个时钟周期。管道越长,bypassing的效果也就越明显。
2. Write-after-read(WAR)故障需要依赖写操作在读操作之后执行。当指令x+1在先前的指令x读取源之前写入源时,就会发生WAR故障,导致读取错误的新值。WAR故障不是真正的依赖关系,可以通过一种称为寄存器重命名的技术来消除。这种技术可以将逻辑寄存器从物理寄存器中抽象出来。CPU通过保留大量的物理寄存器支持寄存器重命名。逻辑寄存器是由ISA定义的,只是更宽的寄存器文件中的别名。通过这种对体系结构状态的解耦,可以轻松地解决WAR故障。例如:
R1 = R0 ADD 1
R0 = R2 ADD 2
对于寄存器R0存在WAR依赖性。由于有大量物理寄存器可用,因此我们可以从写操作开始并向下重命名所有R0寄存器的所有出现。一旦重命名了R0寄存器以消除WAR故障,就可以安全地以任何顺序执行这两个操作。
3. Write-after-write(WAW)故障需要依赖的写操作在另一个写操作之后才执行。当指令x+1在指令x写入源之前写入源时,会导致写入顺序错误。WAW故障也可以通过寄存器重命名来消除,允许两个写操作以任何顺序执行,同时保留正确的最终结果。
控制故障是由程序流程的改变引起的。这些故障来自于加入了流水线技术的分支和其他改变程序流程的指令。确定分支方向(成功或失败)的分支条件在执行管道阶段中解析。因此,除非消除控制故障,否则下一条指令的获取将无法进行流水线处理。使用动态分支预测和推测执行等技术可以克服控制故障。详细内容请参考下一节。
##### **3.3 Exploiting Instruction Level Parallelism (ILP) //利用指令级并行性**
程序中的大多数指令都适合进行流水线处理并并行执行,因为它们是独立的。现代CPU实现了大量的额外硬件特性来利用这种指令级并行性(ILP)。这些硬件特性与先进的编译器技术协同工作,可以显著提高性能。
**3.3.1 OOO Execution //乱序执行(Out-of-Order Execution,OOO Execution)**
图7中的流水线示例展示了所有指令按照程序中的顺序有序地通过不同的流水线阶段。然而,大多数现代CPU支持乱序执行(OOO Execution)------即顺序指令可以以任意顺序进入执行流水线阶段,只受它们之间的依赖关系限制。OOO Execution的CPU仍然必须给出与按程序顺序执行所有指令相同的结果。当指令被最终执行且其结果在体系结构状态中是正确的并可见时,称其为"已重命名"。为确保正确性,CPU必须按照程序顺序重命名并发射所有指令。乱序执行主要用于避免由依赖关系引起的停滞所导致的CPU资源利用不足,特别是在下一节中描述的超标量引擎中。
使用先进的硬件结构诸如记分牌和技术诸如寄存器重命名可以实现这些指令的动态调度,以减少数据冲突。记分牌被用来调度顺序的重命名以及所有机器状态更新。它跟踪每个指令的数据依赖关系以及数据在流水线中的可用位置。大多数实现均努力平衡硬件成本和潜在收益。通常,记分牌的大小决定了硬件可以向前看多远以调度这些独立指令。

Figure 8: The concept of Out-Of-Order execution.
图8详细说明了一个示例下乱序执行的基本概念。假设由于某种冲突,指令x+1不能在4和5周期中执行。在一个顺序执行的CPU中,所有后续指令都会被阻塞不允许进入执行(EXE)流水线阶段。但在乱序执行的CPU中,不具有任何冲突的后续指令(例如指令x+2)可以进入并完成执行。所有指令仍然以程序顺序重命名且执行,即这些指令按照程序的顺序完成写回(WB)阶段。
**3.3.2 Superscalar Engines and VLIW //超标量引擎和VLIW**
Superscalar Engines和VLIW(Very Long Instruction Word)是现代CPU设计中两种常见的指令级并行处理技术。在Superscalar Engines中,CPU通过同时发射多个指令来提高执行效率。这些指令可以相互独立,从而可以通过乱序执行和动态调度来充分利用处理器资源。VLIW则使用一个长指令字(Very Long Instruction Word)包含多个指令,并且这些指令共享一个执行单元和总线,从而实现高吞吐量的指令流水线。
这两种技术都依赖于处理器能够在单个时钟周期内执行多个指令,并且需要具有复杂的硬件结构来支持指令突发执行。因此,他们通常更适合用于执行大量并行任务的应用程序,例如多媒体处理或科学计算。
现代大多数CPU都是超标量的,即它们可以在同一周期内发射多于一个指令。发射宽度(Issue-width)是在同一周期内可以发射的最大指令数。当前一代CPU的典型发射宽度范围为2-6。为了确保正确的平衡,这种超标量引擎还支持多个执行单元和/或流水线执行单元。CPU还将超标量性能与深度流水线和乱序执行相结合,以提取给定软件的最大指令级并行性(Instruction-Level Parallelism)。
图9展示了一款支持2个宽度的超标量CPU的示例,即在每个流水线阶段处理两条指令。超标量CPU通常支持多个独立的执行单元,以避免冲突并保持流水线中的指令流动性。相比图7所示的简单流水线处理器,复制的执行单元可以增加机器的吞吐量。

Figure 9: The pipeline diagram for a simple 2-way superscalar CPU.
像英特尔的Itanium这样的架构使用了一种称为VLIW(Very Long Instruction Word)的技术,将超标量、多执行单元机器的调度负担从硬件转移到编译器。其基本原理是通过要求编译器选择正确的指令组合来保持机器的充分利用,从而简化硬件。编译器可以使用诸如软件流水线、循环展开等技术,向前查找更远的指令,找到正确的指令级并行性(ILP),超出硬件结构的合理支持范围。
这种架构对编译器的要求更高,需要编译器能够优化代码以充分利用硬件资源,因此编译器设计变得更加复杂。VLIW架构的优点是可以避免在CPU内部进行复杂的依赖分析和调度,从而使CPU硬件更加简单、高效。
**3.3.3 Speculative Execution //预测执行**
如前一节所述,如果指令因为控制相关风险而被阻塞,将会对流水线产生显著的性能损失。为了避免这种性能损失,一种技术是利用硬件分支预测逻辑来预测分支的可能方向,并允许从预测路径中执行指令(即预测执行)。
我们考虑一个简短的代码示例,如清单3所示。为了让处理器知道它应该执行哪个函数,它应该知道条件a \< b是false还是true。不知道这一点,CPU就会等待分支指令的结果确定,如图10a所示。
Listing 3 Speculative execution
```cpp
if (a < b)
foo();
else
bar();
```
使用预测执行,CPU对分支的结果进行猜测,并开始处理从所选择的路径中选择的指令。假设处理器预测条件a \< b将被评估为true。它在不等待分支结果的情况下继续执行,并推测性地调用函数foo(见图10b,用\*标记了推测性的工作)。为了确保机器的架构状态不会受到推测式执行指令的影响,机器状态的更改不能被提交。在上面的例子中,分支指令比较了两个标量值,这很快。但实际上,分支指令可能依赖于从内存中加载的值,这可能需要数百个周期。如果预测是正确的,它可以节省很多周期。然而,有时预测是错误的,应该调用函数bar。在这种情况下,预测执行的结果必须被取消并丢弃。这就是所谓的分支预测失误惩罚,我们将在第4.8节中讨论。

Figure 10: The concept of speculative execution.
为了跟踪预测执行的进度,CPU支持一种称为重新排序缓冲区(ROB)的结构。ROB维护所有指令执行的状态,并按顺序退役指令。来自预测执行的结果被写入ROB,并且只有在预测正确的情况下才按程序流程的相同顺序提交到架构寄存器中。CPU还可以将预测执行与乱序执行相结合,并使用ROB跟踪预测和乱序执行。
##### **3.4 Exploiting Thread Level Parallelism //利用线程级并行性**
之前描述的技术依赖程序中可用的并行性来加速执行。此外,CPU支持利用在CPU上执行的进程和/或线程之间的并行性的技术。硬件多线程CPU支持专用硬件资源,在CPU内独立地跟踪每个线程的状态(也称为上下文),而不是仅跟踪单个执行线程或进程的状态。采用这种多线程CPU的主要动机是在线程由于长延迟活动(如内存引用)而被阻塞时,以最小的延迟从一个上下文切换到另一个上下文(而无需承担保存和恢复线程上下文的成本)。
**3.4.1 Simultaneous Multithreading //同时多线程(Simultaneous Multithreading,SMT)**
现代CPU通过支持同时多线程来结合ILP技术和多线程,从可用的硬件资源中挖掘最高的效率。来自多个线程的指令在同一周期内同时执行。同时从多个线程调度指令可以增加利用可用超标量资源的概率,提高CPU的整体性能。为了支持SMT,CPU必须复制硬件以存储线程状态(程序计数器、寄存器)。跟踪乱序和预测执行的资源可以复制或分区到线程之间。通常,缓存资源在硬件线程之间动态共享。
##### **3.5 Memory Hierarchy**
为了有效地利用CPU中预留的所有硬件资源,机器需要在适当的时间提供正确的数据。理解内存层次结构对于发挥CPU的性能能力至关重要。大多数程序表现出局部性的特性;它们不会均匀地访问所有代码或数据。CPU内存层次结构建立在两个基本属性上:
• 时间局部性: 当给定的内存位置被访问时,很可能在不久的将来再次访问相同的位置。理想情况下,我们希望下次需要该信息时可以在缓存中找到它。
• 空间局部性: 当给定的内存位置被访问时,很可能在不久的将来访问附近的位置。这是指将相关数据放置在彼此附近。当程序从内存读取单个字节时,通常会获取更大的内存块(缓存行),因为程序很可能很快需要访问该数据。
本节提供了现代CPU支持的内存层次结构系统的关键属性概述。
**3.5.1 Cache Hierarchy**
缓存是CPU流水线发出的任何请求(用于代码或数据)的内存层次结构的第一级。理想情况下,流水线带有无限缓存和最小访问延迟会有最佳表现。实际上,任何缓存的访问时间都会随着其大小的增加而增加。因此,缓存被组织为由靠近执行单元的小型、快速存储块组成的层次结构,并由较大、较慢的块支持。缓存层次结构的特定级别可以专门用于代码(指令缓存,i-cache)或数据(数据缓存,d-cache),或共享代码和数据(统一缓存)。此外,层次结构的某些级别可以专用于特定CPU,而其他级别可以在多个CPU之间共享。
缓存被组织为块,具有定义好的块大小(缓存行)。现代CPU中典型的缓存行大小为64字节。最靠近执行流水线的缓存大小通常在8KiB到32KiB之间。在层次结构中更远的缓存可以是64KiB到16MiB。任何缓存级别的架构由以下四个属性定义。
**3.5.1.1 Placement of data within the cache.**
请求的地址用于访问缓存。在直接映射(direct-mapped)缓存中,给定的块地址只能出现在缓存中的一个位置,并由下面的映射函数定义。
缓存中的块数 = 缓存大小 / 缓存块大小
直接映射位置 = (块地址) mod (缓存中的块数)
在完全关联(fully associative)的缓存中,给定的块可以放置在缓存中任何位置。
直接映射和完全关联映射之间的中间选项是集合关联映射。在这样的缓存中,块被组织为集合,通常每个集合包含2、4或8个块。给定的地址首先映射到一个集合。在一个集合中,该地址可以放置在该集合中的任何块中。一个每个集合具有m个块的缓存被描述为m路集合关联缓存。集合关联缓存的公式为:
缓存中的集合数 = 缓存中的块数 / 每个集合中的块数(关联度)
集合(m路)关联位置 = (块地址) mod (缓存中的集合数)
**3.5.1.2 Finding data in the cache.**
m路集合关联缓存中的每个块都有一个与之相关联的地址标记(address tag)。此外,标记还包含状态位,如有效位(valid bit),用于指示数据是否有效。标记还可以包含其他位,用于指示访问信息、共享信息等,在后续章节中将进行描述。

Figure 11: Address organization for cache lookup.
图11展示了如何使用管线生成的地址来检查缓存。低阶地址位定义给定块内的偏移量;块偏移位(32字节缓存行为 5 位,64字节缓存行为 6 位)。使用上述公式基于索引位选择集合(set)。一旦选择了集合,标记位(tag)被用于与该集合中的所有标记比较。如果一个标记(tag)与输入请求的标记匹配并且有效位被置位,那么就会发生缓存命中。与该块条目相关联的数据(从缓存的数据数组中并行读出)将提供给执行管线。当标记不匹配时,会发生缓存未命中。
**3.5.1.3 Managing misses. //处理缓存未命中**
当发生缓存未命中时,控制器必须选择一个要替换的缓存块来分配产生未命中的地址。对于直接映射缓存,由于新地址只能在单个位置中分配,因此映射到该位置的先前条目被取消分配,并将新条目安装在其位置。对于集合关联缓存,由于新的缓存块可以放置在集合中的任何块中,因此需要使用替换算法。通常使用的替换算法是LRU(最近最少使用)策略,在此策略中,最近最少访问的块将被淘汰以为未命中地址腾出空间。另一种选择是随机选择一个块作为被替换的块。大多数CPU在硬件上定义了这些功能,使得执行软件更加容易。
**3.5.1.4 Managing writes.**
读取缓存是最常见的情况,因为程序通常读取指令,而数据的读取要比数据的写入更大。处理缓存中的写入更加困难,CPU实现使用各种技术来处理这种复杂性。软件开发人员应特别注意硬件支持的各种写缓存流程,以确保其代码具有最佳性能。
CPU设计使用两个基本机制来处理在缓存中命中的写入:
• 在写穿透缓存中,命中的数据被写入缓存块和层次结构下一级。
• 在写回缓存中,命中的数据只写入缓存。随后,较低层次结构包含陈旧的数据。修改行的状态通过标记中的脏位进行跟踪。当修改的缓存行最终从缓存中驱逐时,写回操作强制将数据写回到下一级。
解释下CPU的写穿透缓存和写回缓存:
CPU的写穿透缓存和写回缓存是两种不同的处理器缓存写入的机制。
1. 写穿透缓存:当CPU执行写操作时,如果该数据在缓存中已经存在,则该数据被同时写入到缓存和下一级存储器(如内存)中。因此,写穿透缓存将确保每次写操作都会立即更新缓存,并且会将数据保存到下一级存储器中,以保持缓存和下一级存储器之间的同步。这种方法可以保证缓存中的数据与下一级存储器中的数据一致性,但是在写入速度上会比较慢,因为每次写操作都需要写入到缓存和下一层存储器中。
2. 写回缓存:当CPU执行写操作时,如果该数据在缓存中已经存在,则该数据仅被写入到缓存中,而不会立即更新下一级存储器(如内存)。在写回缓存中,修改行的状态通过标记中的脏位进行跟踪。只有当缓存行最终从缓存中驱逐出去时,才会进行写回操作,将修改的数据写回到下一级存储器中。这种方法可以提高写操作的速度,因为它省去了每次写操作都需要写入到下一级存储器的步骤,但是会导致缓存中的数据和下一级存储器中的数据不同步,因此需要进行额外的机制来处理这种不同步问题。
写操作时缓存未命中的情况可以使用两种不同的选项来处理:
• 写分配或写未命中时获取缓存,未命中位置的数据从层次结构的下一级加载到缓存中,并且随后的写操作会像写命中一样处理。
• 如果缓存使用无写分配策略,则缓存未命中事务将直接发送到层次结构的较低级别,且块不会加载到缓存中。
在这些选项中,大多数设计通常选择采用写回缓存和写分配策略,因为这两种技术都试图将后续的写事务转换为缓存命中,而无需向层次结构的较低级别添加额外的流量。写穿透缓存通常使用无写分配策略。
//写回缓存--写分配策略,写穿透缓存--无写分配策略
**3.5.1.5 Other cache optimization techniques.**
对于程序员来说,了解缓存层次结构的行为对于从任何应用程序中提取性能至关重要。当CPU时钟频率增加而内存技术速度落后时,这一点尤其明显。从流水线的角度来看,访问任何请求的延迟由以下公式给出,可以递归地应用于缓存层次结构的所有级别,直到主存储器:
平均访问延迟=命中时间+失效率×失效代价
硬件设计师通过许多新颖的微架构技术来减少命中时间和失效代价,以应对这一挑战。从根本上讲,缓存未命中会使流水线停滞并损害性能。任何缓存的失效率高度依赖于缓存架构(块大小、关联度)和运行在机器上的软件。因此,优化失效率成为硬件-软件协同设计的努力。如前面所述,CPU为缓存提供了最佳的硬件组织。下面描述了可以在硬件和软件中实现以最小化缓存失效率的其他技术。
**3.5.1.5.1 HW and SW Prefetching. //硬件和软件预取**
减少缓存失效和随后的停顿的一种方法是,在流水线要求之前将指令和数据预取到不同级别的缓存层次结构中。假设预取请求可以足够提前发出,那么处理失效代价的时间就可以被隐藏起来。大多数CPU都支持基于硬件的预取器,程序员可以控制它们。
硬件预取器观察运行应用程序的行为,并在缓存失效的重复模式上启动预取。硬件预取可以自动适应应用程序的动态行为,例如不同的数据集,并且不需要优化编译器或分析支持。此外,硬件预取不需要额外的地址生成和预取指令的开销。然而,硬件预取仅限于学习和预取实现在硬件中的有限的一组缓存失效模式。软件内存预取是对硬件预取的补充。开发人员可以通过专用的硬件指令(参见8.1.2节)提前指定哪些内存位置是需要的。编译器也可以自动将预取指令添加到代码中,以在所需之前请求数据。预取技术需要在需求和预取请求之间取得平衡,以防止预取流量减慢需求流量。
**3.5.2 Main Memory**
主存是从缓存向下的层次结构中的下一个级别。主存使用支持大容量和合理成本的动态RAM(DRAM)技术。主存由三个主要属性描述,即延迟、带宽和容量。延迟通常由两个组件来指定。内存访问时间是从请求到数据字可用之间经过的时间。内存周期时间定义了连续两个访问内存之间所需的最小时间。
DDR(双倍数据率)DRAM技术是大多数CPU支持的主要DRAM技术。历史上,DRAM带宽在每一代中都有所提高,而DRAM延迟则保持不变甚至有所增加。表2显示了最近三代DDR技术的最高数据传输速率和相应的延迟。数据速率以每秒百万次传输(MT/s)为单位进行测量。
表2: 最近三代DDR技术的最高数据传输速率和相应的延迟。

新型DRAM技术,如GDDR(图形DDR)和HBM(高带宽内存),被使用于需要更高带宽,而不受DDR接口支持的定制处理器中。现代CPU支持多个独立的DDR DRAM内存通道。通常,每个内存通道的宽度为32位或64位。
##### **3.6 Virtual Memory**
虚拟内存是一种机制,用于将连接到CPU的物理内存与在CPU上执行的所有进程共享。虚拟内存提供了一种保护机制,限制其他进程从分配给特定进程的内存中访问该内存。虚拟内存还提供了重定位功能,即能够在物理内存的任何位置加载程序,而无需更改程序的寻址方式。
在支持虚拟内存的CPU中,程序使用虚拟地址进行访问。这些虚拟地址通过专用的硬件表进行翻译,这些表提供了虚拟地址和物理地址之间的映射关系,也被称为页表。地址转换机制如下所示。虚拟地址分为两部分。虚拟页号用于索引到页表(页表可以是单层或嵌套的)以生成虚拟页号和相应物理页之间的映射。然后使用来自虚拟地址的页偏移量来访问映射到的物理页中相同偏移量的物理内存位置。如果请求的页面不在主存中,则会导致页面错误。操作系统负责向硬件提供提示以处理页面错误,使得最近最少使用的一页可以被替换以为新页面腾出空间。

Figure 12: Address organization for cache lookup.
CPU通常使用分层页表格式,以高效地将虚拟地址位映射到可用的物理内存。在这样的系统中,页面缺失是昂贵的,需要遍历整个分层结构以获取对应的物理地址。为了降低地址转换时间,CPU支持一种称为"转换后备缓冲器"(Translation Lookaside Buffer,TLB)的硬件结构,以缓存最近使用的地址转换。
##### **3.7 SIMD Multiprocessors**
什么是SIMD:
SIMD是"单指令流多数据流"(Single Instruction Multiple Data)的简称,它是一种并行计算的技术,旨在同时对多个数据执行相同的操作。这种技术可以通过在一次执行中处理多条数据来提高处理器性能,特别是在涉及大量数据的情况下。常用于图形处理、信号处理、科学计算以及人工智能等领域。

Figure 13: Example of scalar and SIMD operations.
//scalar标量
Listing 4 SIMD execution
```cpp
double *a, *b, *c;
for (int i = 0; i < N; ++i) {
c[i] = a[i] + b[i];
}
```
图13展示了List 4中代码在标量模式和SIMD执行模式下的情况。在传统的SISD(单指令,单数据)模式下,加法操作会分别应用于数组a和b的每个元素。然而,在SIMD模式下,添加操作可以同时应用于多个元素。SIMD CPU支持执行单元,能够对矢量元素执行不同的操作。数据元素本身可以是整数或浮点数。SIMD架构允许更高效地处理大量的数据,并且最适合涉及矢量操作的数据并行应用程序。
大多数流行的CPU架构都具有矢量指令,包括x86、PowerPC、ARM和RISC-V。在1996年,英特尔发布了一个新的指令集------MMX,它是一种针对多媒体应用程序设计的SIMD指令集。随着MMX的推出,英特尔推出了新增能力和增加矢量大小的新指令集:SSE、AVX、AVX2和AVX512。新的SIMD指令集一经问世,就开始了让软件工程师使用它们的工作。起初,这些新的SIMD指令是通过汇编语言编写的。后来,特殊的编译器内嵌函数被引入。今天,所有主要的编译器都支持针对流行处理器的矢量化。
##### **3.8 Modern CPU design**
图14中的块图显示了英特尔第六代核心Skylake的详细信息,该核心于2015年发布并广泛传播于全世界。Skylake核心被分成一个按顺序的前端和一个8路超标量的乱序后端,前端会将x86指令提取和解码为u-op(微操作)。该核心支持2路SMT。它具有32KB、8路第一级指令缓存(L1 I-cache)和32KB、8路第一级数据缓存(L1 D-cache)。L1缓存由一个统一的1MB二级缓存L2缓存支持。L1和L2缓存是各自核心私有的。

Figure 14: Block diagram of a CPU Core in the Intel Skylake Microarchitecture.
什么是superscalar超标量:
Superscalar是指CPU具有同时执行多个指令的能力。具体来说,它可以在一个时钟周期内发射多个指令并对它们进行指令级并行(ILP)执行。超标量是一种广义上的超标量架构,它的主要特点是使用了多个指令调度单元(ISU),允许多条指令同时执行。与传统的单流水线处理器相比,超标量CPU可在给定的时间内执行更多的指令。
什么是SMT:
SMT全称为超线程技术(Simultaneous Multi-Threading),是一种多线程技术。SMT可以在单一CPU核心上同时支持两个线程,在同一个时钟周期中交替执行这两个线程,从而提高处理器的利用率和吞吐量。对于具有SMT技术的处理器来说,操作系统会将其视为拥有多个逻辑处理器,可以为每个逻辑处理器分配不同的任务,从而实现更高效的并行计算。
SMT和传统理解的软件多线程的区别是?
SMT和传统软件多线程的区别在于他们的实现层面。传统的软件多线程是使用软件来管理多个线程的执行,这些线程共享同一个物理CPU核心,每个线程在时间片轮流占用CPU时间。而SMT则是在硬件层面上实现多线程的,物理CPU核心会被复制成多个逻辑处理器,并为每个逻辑处理器分配不同的任务,从而提高整个系统的并行运行效率。在操作系统中,支持SMT技术的CPU通常被视为拥有多个逻辑处理器,因此可以更加高效地利用处理器资源。
通过什么命令能看出当前cpu是否支持SMT:
可以通过Linux系统中的lscpu命令来查看当前CPU是否支持SMT技术。具体命令为:\`lscpu \| grep "Thread(s) per core"\`。如果输出结果大于1,就表明该CPU支持SMT。输出结果为1则表示该CPU不支持SMT。
**3.8.1 CPU Front-End //CPU前端**
CPU前端和CPU北桥一个意思么?
不是一个意思。CPU前端是CPU核心的一部分,它包括指令预取、解码、分派等操作。CPU北桥则是芯片组的一部分,它连接了CPU、内存和其他扩展插槽,负责数据传输和协调各个组件之间的工作。
CPU前端由多个数据结构组成,其主要目的是高效地从内存中获取和解码指令。它的主要作用是将准备好的指令传送到CPU后端,后者负责实际执行指令。CPU前端每个周期从L1 I-cache中抓取16字节的x86指令。这些是复杂的、可变长度的x86指令,由两个线程共享。前端的预解码和解码阶段将这些复杂的x86指令转换为微操作(UOPs, 见第4.4节),并排队进入分配队列(IDQ)。
首先,预解码阶段通过检查指令确定并标记可变指令的边界。在x86中,指令长度可以从1字节到15字节不等。这个阶段还识别分支指令。预解码阶段最多将6个(也称为宏指令)指令移到两个线程之间分割的指令队列中。指令队列还支持一个宏指令融合单元,可以检测到两个宏指令可以合并为一个指令(见第4.4节)。这种优化可以节省管道的带宽。
每个时钟周期最多向解码器发送五个预解码指令。两个线程共享此接口,并且每个周期都可以访问接口。5路解码器将复杂的宏指令转换为固定长度的UOPs。
前端的一个主要性能提升特性是解码流缓存(DSB)或UOP缓存。其动机是将宏指令转换为UOPs的过程缓存到一个单独的结构(DSB)中,与L1 I-cache并行工作。在指令获取期间,还会检查DSB,以查看UOP转换是否已经可用。经常出现的宏指令会命中DSB,因此管道将避免针对16字节捆绑重复执行昂贵的预解码和解码操作。DSB提供了六个UOPs,与前段到后端接口的容量相匹配,并有助于维持整个核心的平衡。DSB与分支预测单元(BPU)协同工作。BPU预测所有分支指令的方向,并根据此预测引导下一条指令获取。
某些非常复杂的指令可能需要比解码器处理的UOPs更多的UOPs。这些指令的UOPs来自微码序列器(MSROM)。此类指令的示例包括支持硬件操作的字符串操作、加密、同步等。此外,MSROM保留了处理异常情况的微码操作,例如分支预测错误(需要清空管道)、浮点辅助等(例如指令使用异常的浮点值)。
指令解码队列(IDQ)为有序前端与乱序后端之间提供了接口。IDQ按顺序排列UOPs。IDQ共有128个UOPs,每个硬件线程64个UOPs。
**3.8.2 CPU Back-End**
CPU后端采用了一个乱序引擎,执行指令并存储结果。
CPU后端的核心是224个条目的重排缓冲区(ROB)。该单元处理数据依赖性。ROB将在调度/保留站单元中使用的物理寄存器映射到结构可见寄存器。ROB还提供了寄存器重命名和跟踪推测执行。ROB条目始终按程序顺序退役。
保留站/调度器(RS)是跟踪给定UOP所有资源可用性并在准备就绪后将其分派到分配的端口的结构。该核心是8道超标量。因此,RS可以每个周期分派多达8个UOPs。如图14所示,每个分派端口支持不同的操作:
• 端口0、1、5和6提供所有整数、FP和向量ALU。发往这些端口的UOPs不需要内存操作。
• 端口2和3用于地址生成和加载操作。
• 端口4用于存储操作。
• 端口7用于地址生成。
//端口2 3 4 7涉及内存操作
##### **3.9 Performance Monitoring Unit**
每个现代CPU都提供了监视性能的手段,这些手段被合并到性能监视单元(PMU)中。它包括帮助开发人员分析其应用程序性能的功能。现代英特尔CPU中PMU的一个示例如图15所示。大多数现代PMU都有一组性能监视计数器(PMC),可以用于收集程序执行过程中发生的各种性能事件。稍后在第5.3节中,我们将讨论如何使用PMCs进行性能分析。此外,还可能存在其他增强性能分析的功能,如LBR、PEBS和PT,整个第6章专门讨论这些内容。

Figure 15: Performance Monitoring Unit of a modern Intel CPU.
随着每个新一代CPU的设计不断演进,它们的PMUs也在不断发展。可以使用cpuid命令确定CPU中PMU的版本,如清单5所示。
每个Intel PMU版本的特征以及与前一个版本的变化可以在\[Int,2020,Volume 3B,Chapter 18\]中找到。
Listing 5 Querying your PMU
```cpp
$ cpuid
...
Architecture Performance Monitoring Features (0xa/eax):
version ID = 0x4 (4)
number of counters per logical processor = 0x4 (4)
bit width of counter = 0x30 (48)
...
Architecture Performance Monitoring Features (0xa/edx):
number of fixed counters = 0x3 (3)
bit width of fixed counters = 0x30 (48)
...
```
**3.9.1 Performance Monitoring Counters**
如果我们想象一个处理器的简化视图,它可能看起来像图16所示的样子。正如本章前面所讨论的那样,现代CPU具有缓存、分支预测器、执行流水线和其他单元。当连接到多个单元时,PMC可以从中收集有趣的统计信息。例如,它可以计算经过了多少个时钟周期,执行了多少条指令,在这段时间内发生了多少次缓存未命中或分支预测错误等性能事件。

Figure 16: Simplifified view of a CPU with a performance monitoring counter.
通常,PMC的宽度为48位,这允许分析工具在不中断程序执行的情况下运行更长时间。性能计数器是作为模型特定寄存器(MSR)实现的硬件寄存器。这意味着计数器的数量和宽度可以因型号而异,并且您不能在CPU中依靠相同数量的计数器。应该首先查询使用工具如cpuid等计算机命令,以了解可用的性能计数器。
PMC可以通过RDMSR和WRMSR指令访问,但只能从内核空间执行这些指令。工程师们经常想要计算已执行的指令数量和经过的时钟周期,因此英特尔的PMU专门设有PMC来收集此类事件。英特尔的PMU具有固定的和可编程的PMC。固定计数器始终在CPU核心内测量相同的内容。对于可编程计数器,用户可以选择他们想要测量的内容。通常每个逻辑核心有四个完全可编程的计数器和三个固定功能的计数器。固定计数器通常设置为计数核时钟、参考时钟和已退役指令(更多细节请参见第4节有关这些指标的详细信息)。
PMU具有大量的性能事件并不罕见。图15仅显示了现代英特尔CPU上监控的所有性能事件中的一小部分。很容易注意到可用PMC的数量远小于性能事件的数量。不可能同时计数所有事件,但分析工具通过在程序执行期间在性能事件组之间进行复用来解决这个问题(详见第5.3.3节)。
英特尔CPU的完整性能事件列表可以在\[Int,2020,Volume 3B,Chapter 19\]中找到。对于ARM芯片来说,它没有那么严格的定义。供应商根据ARM架构实现核心,但性能事件在意义和支持的事件方面差异很大。
#### 4 Terminology and metrics in performance analysis
//术语和性能分析指标
对于初学者来说,查看由分析工具(如Linux perf和Intel VTune Profiler)生成的性能剖面可能非常困难。这些配置文件具有许多复杂的术语和指标。本章是关于性能分析中使用的基本术语和指标的简介。
##### **4.1 Retired vs. Executed Instruction**
解释下Retired vs. Executed Instruction:
在性能分析中,"Retired"和"Executed Instruction"是两个不同的指标。
"Retired Instructions"是指完成指令操作并更新寄存器状态的指令数。这意味着它们已经被确认为已经执行。因此,这是计算机实际上执行的指令数量。这通常是衡量程序执行时间和CPU效率的最直接的指标。
"Executed Instructions"是指计算机已经尝试执行的指令数,包括已经执行和未执行的指令。这些指令可能会因为一些错误而没有成功地执行,例如缓存未命中或分支预测失败。因此,该指标通常被用来衡量指令流水线的效率和质量。
现代处理器通常执行比程序流程要求的指令更多。这是因为其中一些指令会被作为预测执行(如第3.3.3节所讨论的)。对于通常的指令,CPU在结果可用后提交并退役所有先前的指令。但是,对于执行的预测指令,CPU会保留它们的结果而不会立即提交它们的结果。当预测正确时,CPU将解除阻塞并像正常情况一样继续进行这些指令。但是,当推测错误时,CPU将放弃由预测指令所做的所有更改,并不退役它们。因此,CPU处理的指令可以被执行但不一定被退役。考虑到这一点,我们通常可以期望执行的指令数高于退役的指令数。有一个固定的性能计数器(PMC)可以收集退役指令的数量。可以通过在Linux perf中运行以下命令轻松获得它:
```bash
$ perf stat -e instructions ./a.exe
2173414 instructions # 0.80 insn per cycle
# or just simply do:
$ perf stat ./a.exe
```
##### **4.2 CPU Utilization**
CPU利用率是指在某个时间段内,CPU忙碌的时间所占的百分比。从技术上讲,当CPU不运行内核空闲线程时,它被认为是被利用的。
CPU利用率的计算公式如下:
CPU Utilization = CPU_CLK_UNHALTED.REF_TSC / TSC
其中,CPU_CLK_UNHALTED.REF_TSC PMC计数器记录了核心处于非挂起状态时参考周期的数量。TSC是时间戳计数器(在第2.5节中讨论),它始终在不断地递增。
如果CPU利用率较低,通常意味着应用程序的性能较差,因为CPU的某些时间被浪费了。然而,高CPU利用率也并非总是好的。这表明系统正在做一些工作,但并不确切地说明它在做什么:即使CPU在等待内存访问而停顿不前,它仍可能被高度利用。在多线程环境中,线程也可以在等待资源继续之际自旋,因此有"Effective CPU utilization"来过滤自旋时间(参见第11.2节)。Linux perf会自动计算系统上所有CPU的CPU利用率:
```bash
$ perf stat -- a.exe
0.634874 task-clock (msec) #0.773 CPUs utilized
```
如何通过simpleperf得到CPU利用率:
可以通过以下步骤使用simpleperf来获取CPU利用率:
1. 首先,使用以下命令启动simpleperf:
````bash
```
sudo simpleperf record -a -e cpu-clock sleep 10
```
````
该命令将使用cpu-clock事件在所有CPU上对系统执行采样,并记录10秒钟的数据。
2. 然后,使用以下命令生成报告:
````bash
```
sudo simpleperf report --show-utilization -i perf.data
```
````
该命令将生成一个带有CPU利用率的报告,展示了在记录期间各个进程和线程的CPU利用率。如果想要了解更多关于simpleperf报告的信息,可以尝试使用 \`--full-callgraph\` 和 \`-g\` 参数来生成更详细的报告。
注意,在使用simpleperf时需要root权限。
##### **4.3 CPI \& IPC**
这是两个非常重要的指标,分别代表:
• CPI(Cycles Per Instruction):平均每条指令需要多少个周期才能退役。CPI越小,表明CPU性能越好,因为它可以在更少的周期内完成更多的操作。
• IPC(Instructions Per Cycle):平均每个周期可以退役多少条指令。IPC越高,表明CPU性能越好,因为CPU可以在一个周期内执行更多的操作。
CPI和IPC之间有着对立的关系,这是因为如果CPI很高,那么IPC就会相应地降低。因此,通过监控这两个指标,可以了解CPU的效率和性能,并找到可能存在的性能瓶颈。同时,CPI和IPC也可以用于比较不同CPU架构或不同优化级别的代码的性能。
IPC(Instructions Per Cycle)和CPI(Cycles Per Instruction)可以通过以下公式计算:
IPC = INST_RETIRED.ANY / CPU_CLK_UNHALTED.THREAD
其中,INST_RETIRED.ANY PMC计数器记录了退役指令的数量,CPU_CLK_UNHALTED.THREAD计数器记录了CPU核心处于非挂起状态时的周期数。
CPI = 1 / IPC
CPI是IPC的倒数,因为CPI表示平均每条指令需要的周期数,而IPC表示平均每个周期可以退役多少条指令。因此,CPI和IPC是CPU性能的两个重要度量值,可以用于比较不同架构或不同优化级别的代码的性能,并找到可能存在的性能瓶颈。
基于这些指标,可以进行许多类型的分析。它对于评估硬件和软件的效率都非常有用。硬件工程师使用这个指标来比较不同供应商的CPU和不同的CPU代。软件工程师在优化他们的应用程序时会关注IPC和CPI。普遍来说,我们希望CPI尽可能低,IPC尽可能高。Linux性能分析工具(perf)用户可以通过运行以下命令来了解他们的工作负载的IPC:
```bash
$ perf stat -e cycles,instructions -- a.exe
2369632 cycles
1725916 instructions # 0,73 insn per cycle
# or just simply do:
$ perf stat ./a.exe
```
##### **4.4 UOPs (micro-ops)**
解释下UOPs (micro-ops):
UOPs(微操作,micro-ops)是CPU执行指令时的一个内部阶段,也称为指令解码阶段。在这个阶段,CPU将指令分解成多个小的微操作,然后将它们发送到执行单元中执行。每个微操作都只是一个非常小的基本操作,例如将两个寄存器相加或者将一个字节加载到内存中。
通过将指令分解成多个微操作,CPU可以更好地进行流水线处理,从而提高指令的执行效率和吞吐量。因此,现代CPU都使用微操作作为其内部运行方式。
x86体系结构的微处理器将复杂的类CISC指令转换为简单的类RISC微操作(缩写为µops或uops)。这样做的主要优势在于,µops可以乱序执行\[Fog,2012年,第2.1章\]。例如,一个简单的加法指令,如ADD EAX,EBX只会生成一个µop,而更复杂的指令,如ADD EAX,\[MEM1\]可能会生成两个µops:一个用于从内存中读取到一个临时(未命名)寄存器中,另一个用于将临时寄存器中的内容与EAX相加。指令ADD \[MEM1\],EAX可能会生成三个µops:一个用于从内存中读取,一个用于加法,一个用于将结果写回内存中。不同CPU代之间指令和它们被分解为微操作的关系可能会有所不同。
如何理解µops可以乱序执行?
乱序执行是指CPU可以在不改变程序执行结果的前提下重新排列指令的执行顺序。这种技术允许CPU在等待某些指令执行的过程中,通过执行其他指令来保持其同时应用并发性。而µops是可以独立执行的最小指令单元,它们之间没有数据依赖关系或顺序限制。因此,CPU可以将µops以任意顺序投入执行单元,从而达到执行指令的最快速度。
通过乱序执行µops,CPU可以更有效地利用其内部硬件资源。例如,如果一个微操作需要访问内存,CPU可以在等待该微操作完成的同时执行另一个不需要等待内存的微操作,从而在指令执行期间实现高效利用CPU资源的目的。
总之,µops可以乱序执行,可以提高CPU的执行效率和并发性能,从而使CPU在相同时间内执行更多的指令。
与将复杂的CISC指令分解为类RISC微操作(uops)相反,后者也可以进行融合。现代英特尔CPU中有两种类型的融合:
- 微融合 - 将来自同一机器指令的µops融合在一起。微融合只能应用于两种类型的组合:内存写入操作和读-修改操作。例如:
```bash
# Read the memory location [ESI] and add it to EAX
# Two uops are fused into one at the decoding step.
add eax, [esi]
```
- 宏融合 - 将不同机器指令的µops融合在一起。对于某些情况下,解码器可以将算术或逻辑指令与随后的条件跳转指令融合成一个单独的计算和分支µop。例如:
```bash
# Two uops from DEC and JNZ instructions are fused into one
.loop:
dec rdi
jnz .loop
```
uops是什么的缩写:
"uops"是micro-operations的缩写,意为微操作。
Microfusion和Macrofusion都可以在CPU的所有阶段,从解码到退役中节省带宽。融合操作共享重新排序缓冲区(ROB)中的单个条目,当一个融合uop仅使用一个条目时,ROB的容量将增加。这个单一的ROB条目代表了两个需要由两个不同的执行单元完成的操作。融合ROB条目被分派到两个不同的执行端口,但再次作为单个单元退役。
Linux perf用户可以通过运行以下命令来收集其工作负载中发出、执行和退役的µops数量:
```bash
$ perf stat -e uops_issued.any,uops_executed.thread,uops_retired.all -- a.exe
2856278 uops_issued.any
2720241 uops_executed.thread
2557884 uops_retired.all
```
对于最近的x86微架构,可以在uops.info网站上找到指令的延迟、吞吐量、端口使用和µops的数量。该网站收集了许多现代CPU架构中处理器的微操作统计数据,并提供了一个方便的查询工具,使用户可以查找单个指令的性能特征。这些数据包括每个指令所需的µops数、它们各自的延迟和吞吐量、以及每个端口的使用率等信息。这些信息对于编写高效的代码和优化性能非常有用。
https://uops.info/ //Intel Core CPU
uops_issued.any uops_executed.thread uops_retired.all的区别:
"uops_issued.any"、"uops_executed.thread"和"uops_retired.all"是Linux perf事件中针对µops计数的不同指标。
- "uops_issued.any"表示发出的µops数量,包括控制流和数据流µops,但不考虑它们是否实际执行。
- "uops_executed.thread"表示线程执行的µops数量,只包括实际执行的µops,跳过了因分支预测失败或其他原因被丢弃的µops。
- "uops_retired.all"表示最终退役的µops数量。
这些指标都描述了µops在CPU中的不同阶段的行为,并提供有关程序或工作负载对CPU的利用率和效率的信息。通常情况下,"uops_retired.all"是最实用的指标,因为它反映了真正完成的µops的数量,可以用来衡量CPU处理工作负载的能力。
"uops_retired.all"比"uops_executed.thread"数值要低,怎么解释:
如果 "uops_retired.all" 的数值比 "uops_executed.thread" 低,这通常意味着执行单元正在陷入一些等待状态,从而导致某些指令没有真正执行。这可能有多种原因,如下所述:
1. 异常和其他中断:当CPU执行到一个异常指令(如缺页或除以零等)时,它就会停止当前线程的执行,并进入异常处理程序。在此期间,CPU可能不会发出任何新的 µops,因此这些µops不会被计入 "uops_retired.all" 中。
2. 数据相关性和控制相关性:某些指令依赖于先前指令的结果,或者需要等待跳转目标的计算结果。如果指令的依赖项尚未准备好,或者分支预测失败,则该指令将无法执行,并且不会被计入 "uops_retired.all"。
3. 指令缓存未命中:如果CPU无法在指令缓存中找到要执行的指令,则需要从内存中读取指令。这可能需要一定的时间,而在此期间,CPU不能发出新的 µops。这些µops也不会被计入 "uops_retired.all"。
总之,如果 "uops_retired.all" 的数值比 "uops_executed.thread" 低,那么这可能说明CPU正在等待某些事件的完成,并且尚未发出该线程计划中的所有 µops。
再解释下"uops_retired.all":
"uops_retired.all"是一种Linux perf事件,用于针对指定的CPU核心统计退役的微操作(µops)数量。微操作是处理器中的操作单元所理解的最低级别操作,通常指一条指令被解码后生成的多个µops。
在CPU执行指令时,它首先将指令分解成微操作,然后按照顺序将它们派发到相应的执行单元上执行。随着微操作的执行完成,它们将被标记为"退役",表示它们已经完成了它们预期的功能,并从CPU中删除。因此,"uops_retired.all"表示在一个特定时间段内从CPU中完全退出的µops的总数。
与其他类似的计数器(如"uops_executed.thread")不同,"uops_retired.all"并不关心微操作是否实际上被执行。例如,如果发生分支预测失败或数据缓存未命中等意外事件,某些µops可能会被丢弃而不被执行,但仍然会被计入"uops_retired.all"。因此,这个事件通常用来衡量一个工作负载在CPU上运行所需的总 µops 数量。
需要注意的是,尽管"uops_retired.all"可以用来衡量CPU处理工作负载的能力,但它并不提供有关µops执行效率和吞吐量的详细信息。对于想要更深入了解CPU性能的用户,还需要结合其他类似的微操作计数器,如"uops_issued.any"和"uops_executed.thread",进行分析。
##### **4.5 Pipeline Slot //流水线阶段**
Pipeline Slot的翻译可以是"流水线槽位",也可以是"流水线阶段"。在CPU中,指令执行过程被划分为多个阶段或步骤,并通过流水线的方式执行,从而提高了CPU的执行效率。每个流水线阶段通常由一个或多个物理单元组成,这些单元一起协同工作来完成指令的执行。
在流水线中,每个指令需要被分解为多个操作,并逐步执行每个操作。每个操作需要在相应的硬件物理单元(如整数单元、浮点数单元等)上执行,而这些单元通常被划分为不同的流水线槽位或阶段。例如,在Intel CPU中,整型单元通常有三个流水线阶段,分别是取指令(IF)、解码指令(ID)和执行指令(EX)。
因此,实现高效的流水线处理通常需要优化每个流水线阶段的效率,并确保每个阶段都能够充分利用硬件资源。对于一些特别密集的计算或者具有高度数据可并行性的工作负载,还可能需要通过添加更多的流水线阶段来进一步提高CPU的执行效率。
一个流水线槽位表示处理一个µop所需的硬件资源。图17展示了一个CPU的执行流水线,每个周期可以处理四个µops。几乎所有现代的x86 CPU都使用4个µops(4-wide)作为其流水线宽度。在图中连续的6个周期中,仅利用了可用流水线槽位的一半。从微体系结构的角度来看,执行这样的代码的效率只有50%。
流水线槽位是Top-Down微体系结构分析中的核心指标之一(参见第6.1节)。例如,前端瓶颈和后端瓶颈指标均表达为由于各种原因导致未利用流水线槽位的百分比。

Figure 17: Pipeline diagram of a 4-wide CPU.
##### **4.6 Core vs. Reference Cycles //核心时钟周期与参考时钟周期**
Core vs. Reference Cycles的翻译可以是"核心时钟周期与参考时钟周期"。
CPU中有许多不同类型的时钟周期,其中包括核心时钟周期和参考时钟周期。核心时钟周期是指这个特定CPU核心中执行的实际指令周期的数量,而参考时钟周期是指这个CPU的参考时钟信号发生的周期数。
核心时钟周期通常用于衡量CPU执行任务所需的时间,而参考时钟周期通常用于测量CPU的整体性能和稳定性。在大多数情况下,参考时钟周期要比核心时钟周期更长,因为它包括了一些额外的处理时间,如操作系统调度、缓存和内存访问等。
在性能评估和微体系结构分析中,核心时钟周期和参考时钟周期通常被广泛使用以提供对CPU的性能和效率的深入理解。例如,在性能基准测试中,可以使用核心时钟周期来衡量CPU的处理速度和执行能力,而使用参考时钟周期来衡量CPU整体性能的稳定性和可靠性。
CPU参考时钟周期是指CPU与其他系统组件(如系统总线和内存)进行通信所需的时钟周期。与此不同,CPU核心时钟周期是指CPU内核处理每条指令所需的时钟周期。
simpleperf上如何查看核心时钟周期和参考时钟周期?
在simpleperf上查看核心时钟周期和参考时钟周期,可以使用以下两个命令:
1. 查看核心时钟周期:
````bash
```
$ simpleperf stat -e cpu-clock
```
````
这个命令将会运行给定的 \`\\` 命令,并在其结束时输出指令执行期间核心时钟周期的数量。执行该命令后,simpleperf 将显示指令执行时的 CPU 频率、指令周期数以及其他计数器。在计数器列表中搜索 "cpu-clock" 项,即可获取核心时钟周期的计数值。
2. 查看参考时钟周期:
````bash
```
$ simpleperf stat -e ref-cycles
```
````
这个命令将会运行给定的 \`\\` 命令,并在其结束时输出参考时钟周期的数量。执行该命令后,simpleperf 将显示指令执行过程中参考时钟周期的数量及其他相关计数器。在计数器列表中搜索 "ref-cycles" 项,即可获取参考时钟周期的计数值。
需要注意的是,不是所有的CPU支持 core clock 计数器或 reference clock 计数器。如果你尝试运行上述命令但没有看到相应计数器的值,则说明你的CPU可能不支持这些计数器。
计算IPC的时钟周期是参考时钟周期还是核心时钟周期?
计算IPC(Instructions Per Cycle,每个时钟周期的指令数)时,我们通常使用核心时钟周期而不是参考时钟周期。这是因为核心时钟周期只统计实际执行指令所用的时间,而不包括等待资源或上下文切换等操作所需的时间。因此,使用核心时钟周期作为分母可以更好地反映出CPU的运行性能。
计算IPC的公式为:
IPC = Instructions executed / Core clock cycles elapased
其中,分母使用的是核心时钟周期,代表CPU实际执行指令所用的时间。分子为指令执行数量,代表CPU在这段时间内实际执行的指令数目。
需要注意的是,IPC只是一个相对的指标,具体数值会受到很多因素的影响,如指令类型、数据依赖性、缓存/内存访问等。因此,IPC并不总是越高越好,需要根据具体情况进行评估。
线程上下文切换影响的是CPU参考时钟周期还是CPU核心时钟周期?
线程上下文切换通常会影响CPU参考时钟周期而不是CPU核心时钟周期。
线程上下文切换是指CPU在执行一个线程时,暂停该线程的执行并切换到另一个线程。当CPU执行一个线程时,它需要保存该线程的寄存器状态、程序计数器和其他有关信息。在切换到另一个线程时,CPU需要加载新的寄存器状态和程序计数器以继续执行。这个过程就是线程上下文切换。
由于线程上下文切换需要CPU在两个线程之间进行切换并保存和恢复寄存器状态等信息,因此会产生额外的CPU开销,从而影响参考时钟周期。但是,这并不会直接影响CPU内核处理指令的速度,也就是不会影响CPU核心时钟周期。
需要注意的是,在一些特殊情况下,如线程数过多、线程设计不合理等情况下,线程上下文切换可能会成为系统的瓶颈,导致CPU利用率降低、系统响应时间变慢等问题。因此,在进行系统设计和优化时,需要合理规划线程的数量和使用方式,以减少线程上下文切换的影响。
多线程同步影响的是CPU参考时钟周期还是CPU核心时钟周期?
多线程同步通常会影响CPU参考时钟周期而不是CPU核心时钟周期。
多线程同步是指在多线程编程中,为了避免多个线程同时对同一资源进行访问而引发的竞态条件问题,需要采取同步控制措施。常用的同步控制手段包括互斥锁、信号量、条件变量等。
在多线程同步过程中,由于需要对共享资源进行访问控制,因此需要进行加锁解锁等操作,这会增加线程的执行时间和CPU的参考时钟周期。但是,它并不会直接影响CPU内核处理指令的速度,也就是不会影响CPU核心时钟周期。
需要注意的是,在多线程编程中,过多的同步控制可能会降低系统的性能。因此,在进行系统设计和优化时,需要合理规划线程的数量和使用方式,以减少多线程同步所带来的开销。
缓存和内存访问影响的是CPU参考时钟周期还是CPU核心时钟周期?
缓存和内存访问的影响是在CPU参考时钟周期上的,而不是在CPU核心时钟周期上的。
CPU参考时钟周期指的是CPU与主板时钟之间的交互,这个时钟周期考虑了一切与CPU有关的延迟,包括内部数据总线延迟、前端总线延迟、二级缓存延迟、内存引擎延迟等等。根据CPU的架构不同,这个参考时钟周期的值也会有所不同。
而CPU核心时钟周期是指CPU内部核心运行的时钟周期,每个核心都有自己的时钟周期。计算机系统中各种资源的工作效率都以CPU核心时钟周期为基准,因此IPC的计算公式也是基于CPU核心时钟周期的。
缓存和内存访问对CPU参考时钟周期的影响很大。由于内存带宽有限,每次内存访问都需要等待一定的时间才能获得数据,这个等待的时间称为内存延迟。CPU会在等待内存的同时继续执行其他指令,但是如果等待时间过长,就会导致CPU的效率降低,从而影响整个系统的性能。为了降低内存延迟,CPU会采用一系列技术,如缓存、预取等。缓存是一个高速的临时存储器,用于存放最常使用的数据和指令,以减少CPU等待内存的时间,提高数据访问效率。内存和缓存之间的数据传输通常需要消耗一定的参考时钟周期,所以缓存和内存访问都会影响CPU的参考时钟周期。
因此,在进行系统设计和优化时,需要合理配置和优化缓存和内存访问,以提高系统的整体性能。
线程上下文切换影响的是CPU的参考时钟周期,而IPC的计算公式是CPU的核心时钟周期,那么为什么线程上下文切换与IPC有关?
确实,IPC的计算公式为每个CPU核心执行的指令数除以核心时钟周期,而线程上下文切换涉及到CPU参考时钟周期的影响。
事实上,线程上下文切换和IPC之间的关系比较复杂,不是简单的一对一的关系。在计算IPC时,需要考虑CPU的各种硬件资源的利用率,包括指令流水线、缓存效率、内存带宽等等。而线程上下文切换会占用CPU的时间和资源,降低CPU的硬件资源的利用率,进而降低IPC。
//即不是通过影响CPU核心时钟周期来影响IPC的,是通过影响CPU硬件利用率而影响到每个时钟周期内能够完成的指令数量,进而影响IPC的
具体来说,线程上下文切换的开销通常是在软件层面产生的,涉及线程之间的切换、临界区保护等操作。在进行线程上下文切换时,CPU需要保存和恢复线程状态,这会占用一定的参考时钟周期。这些开销会导致CPU在处理每个线程时的效率降低,从而降低IPC。
当然,线程上下文切换只是影响IPC的众多因素之一,还有很多其他因素也会对IPC产生重要的影响,比如指令流水线的效率、缓存的命中率、内存带宽等等。在进行系统设计和优化时,需要综合考虑多个因素,并寻找最佳的性能平衡点,以提高系统的IPC和性能。
多线程同步影响的是CPU的参考时钟周期,而IPC的计算公式是CPU的核心时钟周期,那么为什么多线程同步与IPC有关?
多线程同步对CPU参考时钟周期的影响是通过降低指令执行的效率来实现的。在多线程同步的情况下,线程之间需要协调和等待,这样会导致每个线程在执行指令时需要暂停或等待某些资源的释放,进而增加指令的延迟和执行时间。这就意味着,在多线程同步的情况下,每个核心在单位时间内可以执行的指令数(IPC)会减少。
虽然IPC的计算公式中使用的是CPU的核心时钟周期,但是实际上IPC是衡量CPU性能的指标之一,它受到CPU的总体性能、架构设计、指令并行度等因素的影响。而多线程同步会降低每个核心的实际效率,从而导致IPC的下降。因此,多线程同步与IPC是有关联的。
当多个线程同时运行时,它们需要共享不同的资源(如内存、I/O等),这些资源的访问需要进行同步。如果同步操作效率低下,将导致线程之间的竞争变得更加激烈,进而影响整个系统的性能。因此,优化多线程同步的效率和机制可以有效提高系统的整体性能,从而提高IPC。
多线程同步暂停时也会占用CPU吧?
是的,多线程同步暂停时也会占用CPU资源。当某个线程需要进行同步操作时,它需要通过锁、信号量或者其他同步机制来协调线程之间的动作。这种同步机制在实现上通常会涉及到一些等待操作,比如线程挂起、加锁等,这些过程都需要占用CPU资源。
当一个线程被挂起等待同步操作完成时,它会进入睡眠状态,此时不再占用CPU时间片。但是,一旦同步操作完成,线程就会被唤醒并重新获得CPU时间片,从而开始继续执行。在这个过程中,线程需要等待一定的时间,这就导致了它在这段时间内无法执行任何指令,虽然此时线程不再占用CPU时间片,但是CPU还是需要对其进行调度,使其进入等待队列和唤醒队列中。
这种等待和唤醒操作的时间会影响整个系统的性能,会降低线程的效率,进而影响IPC。因此,在设计和实现多线程应用程序时,需要使用合适的同步机制和算法,避免不必要的等待和唤醒操作,从而提高整个系统的性能和吞吐量。
缓存和内存访问影响的是CPU的参考时钟周期,而IPC的计算公式是CPU的核心时钟周期,那么为什么缓存和内存访问与IPC有关?
对于IPC(Instructions Per Cycle),它的计算公式是指令数除以时钟周期数,其中时钟周期数是指CPU核心时钟周期。而缓存和内存访问虽然不会直接影响CPU核心时钟周期,但它们确实会影响每个时钟周期内CPU所执行指令数量的多少,从而间接地影响IPC的值。
由于缓存和内存访问的速度和效率不同,每个时钟周期内能够完成的指令数量也会不同。如果CPU缓存命中率高、缓存访问速度快,或者能够高效处理内存访问,那么每个时钟周期内能够完成的指令数量就会相应地增加,从而提高IPC的值;反之,如果缓存失效率高或内存访问速度慢,每个时钟周期内能够完成的指令数量就会降低,从而降低IPC的值。
因此,虽然缓存和内存访问影响的是CPU的参考时钟周期,但它们确实会影响每个时钟周期内能够完成的指令数量,间接地影响CPU的性能和IPC的值。
翻译:
大多数CPU使用一个时钟信号来控制它们的顺序操作。时钟信号由外部发生器产生,每秒提供一定数量的脉冲。时钟脉冲的频率确定了CPU执行指令的速率。因此,时钟信号越快,CPU每秒执行的指令就越多。
时钟信号是计算机系统中非常关键的一个组成部分,它对于保证计算机系统的稳定性和正确性起着至关重要的作用。时钟信号可以看作是CPU和其他系统组件之间通信的基础,在时钟信号的作用下,CPU能够准确地分配资源,按照正确的顺序执行操作,并在正确的时间点完成各种任务。
除了影响CPU的处理速度以外,时钟信号还可以影响CPU的电源消耗、散热等方面。因为随着时钟信号频率的提高,CPU需要更多的电力和更好的散热系统才能保持正常工作。因此,在设计CPU时需要权衡处理速度、功耗和散热等多个因素,以提供最佳的性能和可靠性。
公式 Frequency = Clockticks / Time ,其中的频率指的是CPU运行的频率,通常是指CPU核心的时钟频率。根据该公式,我们可以通过计算单位时间内CPU核心运行的总时钟周期数来得到CPU的实际工作频率。
然而,现代的大多数CPU,包括Intel和AMD的CPU,都采用了动态频率调整技术,也就是所谓的Turbo Boost和Turbo Core等技术。这些技术允许CPU动态地增加和减少其工作频率,以在功耗节约和性能之间进行权衡。这意味着CPU的工作频率不再是固定的,因此上述公式无法准确计算CPU的实际工作频率。
对于这种情况,计算器样例给出了一个实验,使用了Skylake i7-6000处理器作为测试对象。该处理器的基础频率为3.4 GHz,但在实验中发现,当CPU核心处于负载高峰时,处理器会自动提高其工作频率,达到更高的性能水平。因此,在使用动态频率调整技术的CPU中,实际工作频率将随着负载的变化而不断变化,这也是为什么现代CPU不再有固定工作频率的原因。
```bash
$ perf stat -e cycles,ref-cycles ./a.exe
43340884632 cycles # 3.97 GHz
37028245322 ref-cycles # 3.39 GHz
10,899462364 seconds time elapsed
```
//-e cycles应该是包含动态频率调整技术的cycles
//-e ref-cycles应该是设置这个cpu freq后的cycles
//所以说,实际的准确的cycles应该是这个-e cycles???
ref-cycles是一种事件计数器,它记录了CPU参考时钟周期数,也就是排除了动态频率调整等因素导致的额外时钟周期数,只记录了CPU执行指令所用的"主动"时钟周期数。在Skylake i7-6000处理器中,它的基础频率为3.4 GHz,即每个外部时钟脉冲对应的内部周期数为34。因此,通过乘以34,可以得到内部时钟周期数。
cycles也是一种事件计数器,它记录了真实的CPU时钟周期数,包括了动态频率调整等因素导致的额外时钟周期数。使用cycles来统计程序的性能表现可以更加准确,同时也可以计算出Turbo Boost的利用率。
在进行代码测试的时候,使用ref-cycles可以避免时钟频率上下波动但实际代码运行次数相同的情况下因时钟变化造成的误差,更好地判断哪个版本的代码更快。而使用cycles可以更加全面地了解程序的实际执行效率和性能瓶颈,包括动态频率调整等因素对性能的影响。
##### **4.7 Cache miss //缓存缺失**
正如第3.5节所讨论的,任何在特定级别缓存中丢失的内存请求都必须由更高级别的缓存或DRAM提供服务。这意味着这种内存访问的延迟会显著增加。典型的内存子系统组件延迟如表3所示。

Table 3: Typical latency of a memory subsystem.
当内存请求在最后一级缓存(LLC)中丢失并到达主存储器(DRAM)时,性能会大大下降。Intel® Memory Latency Checker(MLC)是一种用于测量内存延迟和带宽以及它们随着系统负载的增加而如何变化的工具。 MLC可用于为测试系统建立基线,进行性能分析。
缓存未命中可能发生在指令和数据两种情况下。根据Top-Down微架构分析(见第6.1节),指令(I-cache)缓存未命中被称为前端停顿(Front-End stall),而数据缓存(D-cache)未命中被称为后端停顿(Back-End stall)。当指令获取期间出现I-cache未命中时,这被归因于前端问题。因此,当所请求的数据在D-cache中找不到时,这将被归类为后端问题。
Linux perf用户可以通过运行以下命令收集L1高速缓存未命中数:
```bash
$ perf stat -e mem_load_retired.fb_hit,mem_load_retired.l1_miss,mem_load_retired.l1_hit,mem_inst_retired.all_loads -- a.exe
29580 mem_load_retired.fb_hit
19036 mem_load_retired.l1_miss
497204 mem_load_retired.l1_hit
546230 mem_inst_retired.all_loads
```
以上是L1数据缓存的所有加载项的细分。我们可以看到,只有3.5%(19036/546230)的加载项在L1缓存中未命中。我们可以进一步细分L1数据缺失,并通过运行以下命令分析L2缓存行为:
```bash
$ perf stat -e mem_load_retired.l1_miss,mem_load_retired.l2_hit,mem_load_retired.l2_miss -- a.exe
19521 mem_load_retired.l1_miss
12360 mem_load_retired.l2_hit
7188 mem_load_retired.l2_miss
```
从这个例子中,我们可以看到37%(7188/19521)在L1 D-cache未命中的加载项也在L2缓存中未命中。同样地,L3缓存的细分也可以进行。
##### **4.8 Mispredicted branch //错误的分支预测**
现代CPU尝试预测分支指令(跳转或不跳转)的结果。例如,当处理器看到以下代码时:
```bash
dec eax
jz .zero
# eax is not 0
...
zero:
# eax is 0
```
指令jz是一个分支指令,为了提高性能,现代CPU架构尝试预测这种分支的结果。这也称为"推测执行"。处理器会猜测,例如,该分支不会被执行,并将执行对应于eax不为0的情况的代码。但是,如果猜测错误,这就被称为"分支预测失败",CPU需要撤消最近所做的所有推测性工作。这通常涉及10到20个时钟周期的惩罚。
Linux perf用户可以通过运行以下命令来检查分支预测失败的数量:
```bash
$ perf stat -e branches,branch-misses -- a.exe
358209 branches
14026 branch-misses #3,92% of all branches
```
#### 5 Performance Analysis Approaches
进行高级优化时,通常很容易判断性能是否得到改善。当您编写算法的更好版本时,您希望看到程序运行时间的明显差异。但是,有时候您会看到执行时间的变化,但是您不知道它来自哪里。单独的时间无法提供任何关于发生这种情况的原因的见解。在这种情况下,我们需要更多关于程序执行方式的信息。这是我们需要进行性能分析以了解所观察到的减速或加速的底层本质的情况。
我们的程序运行时,硬件和软件都会跟踪性能数据。在这种情况下,所谓的硬件是指执行程序的CPU,而所谓的软件是指操作系统和所有用于分析的工具。通常,软件堆栈提供高级度量标准,如时间、上下文切换次数和页面错误,而CPU能够观察缓存未命中、分支预测错误等。根据我们试图解决的问题,一些指标比其他指标更有用。因此,硬件指标并不意味着总是能给我们一个更精确的程序执行概述。例如,某些指标(例如上下文切换次数)CPU无法提供。性能分析工具(例如Linux perf)可以从操作系统和CPU中消费数据。
我们将在本书中广泛使用Linux perf,因为它是最受欢迎的性能分析工具之一。该工具适用于大多数Linux发行版,因此对于广泛的用户来说非常方便。作者喜欢展示Linux perf的另一个原因是它是开源的,这使我们能够看到典型分析工具中正在发生的机制。这对于学习本书中提出的概念尤其有用,因为基于GUI的工具(例如Intel® VTune™ Profiler)往往会隐藏所有复杂性。有关Linux perf的更多信息,可参考其Wiki页面。
在本章中,我们将介绍一些最受欢迎的性能分析技术:代码插装、跟踪、特征化和采样。我们还将讨论不涉及运行实际应用程序的静态性能分析技术和编译器优化报告。
simpleperf如何查看线程上下文切换次数?
在使用simpleperf工具时,要查看线程上下文切换次数,可以使用\`report\`命令结合\`--show-sched-events\`选项来生成报告。具体操作如下:
1. 使用\`perf record\`命令记录程序的性能数据,例如:
````bash
```
$ simpleperf record -p -e context-switches
```
````
2. 使用\`perf report\`命令结合\`--show-sched-events\`选项来生成报告,例如:
````bash
```
$ simpleperf report --show-sched-events
```
````
运行以上命令后,会显示出有关程序的性能数据报告,其中包括线程上下文切换次数等信息。
值得注意的是,需要在运行简单性能工具(simpleperf)时使用root权限或者配置kernel.perf_event_paranoid参数的值为-1才能记录一些事件,例如线程上下文切换次数。
simpleperf如何查看多线程同步时间?
在使用simpleperf工具时,要查看多线程同步时间,可以使用\`report\`命令结合\`--show-mutex-contention\`选项来生成报告。具体操作如下:
1. 使用\`perf record\`命令记录程序的性能数据,例如:
````bash
```
$ simpleperf record -p -e cycles -e sched:sched_switch -e sched:sched_stat_sleep
```
````
上面的命令中,我们同时记录了程序的CPU周期数、进程切换以及进程睡眠事件。
2. 使用\`perf report\`命令结合\`--show-mutex-contention\`选项来生成报告,例如:
````bash
```
$ simpleperf report --show-mutex-contention
```
````
运行以上命令后,会显示出有关程序的性能数据报告,其中包括多线程同步时间等信息。\`--show-mutex-contention\`选项会显示互斥量竞争的相关信息,其中包括持有者、等待者、竞争次数和等待时间等信息。
注意:需要在编译程序时加入 \`-phtreads\` 选项,才能记录对多线程互斥锁的竞争。
//contention 竞争
##### **5.1 Code Instrumentation //代码插桩**
Code Instrumentation的中文含义是"代码插装",它是在程序代码中插入额外的代码以收集和分析执行时间和性能数据的过程。Code Instrumentation能够帮助开发人员识别性能瓶颈、调试代码错误、分析代码行为等。常见的Code Instrumentation技术包括手动插装、运行时插装和静态插装等。手动插装是通过在代码中手动添加计时器或日志语句来收集数据的方法,但是这种方法会增加代码复杂性,并且难以扩展到大型代码库中。运行时插装是通过在程序运行时动态地插入代码来收集数据的方法,需要使用特殊的编译器或工具支持。静态插装是在编译时将插装代码注入到源代码中,可以自动化生成代码并保持代码基础结构的完整性,但是需要一些高级编译器的支持。
Code Instrumentation可能是最早发明的性能分析方法之一,它是一种通过向程序中插入额外的代码来收集运行时信息的技术。如代码清单6所示,最简单的插装方式就是在函数开头插入printf语句来计算函数调用次数。我相信世界上的每个程序员都至少做过一次这样的事情。当你需要了解程序执行的特定情况时,这种方法提供了非常详细的信息。Code Instrumentation使我们能够追踪程序中每个变量的任何信息。
Listing 6 Code Instrumentation
```cpp
int foo(int x) {
printf("foo is called");
// function body...
}
```
基于插装的性能分析方法主要用于宏观层面,而不是微观层面。使用这种方法通常可以在优化大型代码块时提供最好的洞察力,因为你可以采用自上而下的方法(从插装主函数开始,然后深入到它的被调用函数)来定位性能问题。虽然对于小型程序,代码插装并不是非常有帮助,但它通过让开发人员观察应用程序的架构和流程来提供最有价值和深入的见解。这种技术对于正在处理陌生代码库的人尤其有帮助。
值得一提的是,在具有许多不同组件的复杂系统中,代码插装显示出了其优越性,因为这些组件根据输入或时间的不同反应也会不同。采样技术(在第5.4节中讨论)压缩了有价值的信息,无法帮助我们检测异常行为。例如,在游戏中,通常有渲染线程、物理线程、动画线程等。插装这样的大型模块可以帮助我们相对快速地了解问题的源头。有时候,优化不仅仅是优化代码,还涉及到数据方面的问题。例如,渲染过慢是因为网格未压缩,或者场景中对象太多导致物理计算过慢等等。这种技术在实时场景中广泛应用,例如视频游戏和嵌入式开发。许多性能分析工具将插装与本章讨论的其他技术(跟踪、采样)混合使用。
虽然代码插装在许多情况下非常有用,但它无法提供关于代码从操作系统或CPU角度的执行方式的任何信息。例如,它无法告诉你进程被调度执行的频率(由操作系统获知)或分支预测出现了多少错误(由CPU获知)。插装代码是应用程序的一部分,并具有与应用程序本身相同的特权。它在用户空间运行,无法访问内核。
但更重要的是,这种技术的缺点是,每次需要插装新内容(例如另一个变量)时,都需要重新编译。这可能会成为工程师的负担,并增加分析时间。不幸的是,这并不是全部的缺点。因为通常情况下,您关心的是应用程序中的热点路径,所以您会插装驻留在代码性能关键部分的内容。将插装代码插入热点代码中可能会轻松导致整个基准测试速度减慢2倍。最后,通过对代码进行插装,您将改变程序的行为,因此您可能看不到先前看到的效果。
所有这些都增加了实验之间的时间,并消耗了更多的开发时间,这就是为什么工程师现在很少手动插装他们的代码。然而,自动代码插装仍然被编译器广泛使用。编译器能够自动插装整个程序,并收集关于执行的有趣统计信息。最广为人知的用例是代码覆盖率分析和基于分析的优化(见第 7.7 节)。
在谈论插装时,提及二进制插装技术非常重要。二进制插装的思想类似,但是是针对已构建的可执行文件而不是源代码级别进行的。有两种类型的二进制插装:静态(预先完成)和动态(插装代码在程序执行时根据需要插入)。动态二进制插装的主要优点是它不需要程序重新编译和重新链接。此外,通过动态插装,可以将插装的数量限制在仅有趣的代码区域中,而不是整个程序。
二进制插装在性能分析和调试中非常有用。最流行的二进制插装工具之一是 Intel Pin 工具。Pin 拦截程序在发生有趣事件时的执行,并生成从程序中该点开始的新插装代码。它允许收集各种运行时信息,例如:
• 指令计数和函数调用计数。
• 拦截应用程序中的函数调用和任何指令的执行。
• 允许通过在区域开始处捕获内存和硬件寄存器状态来"记录和回放"程序区域。
与代码插装一样,二进制插装仅允许插装用户级代码,并且可能非常慢。
##### **5.2 Tracing //跟踪**
跟插装相比,追踪的概念非常相似但有所不同。代码插装假定用户可以编排应用程序的代码。另一方面,追踪依赖于程序外部依赖项的现有插装。例如,strace 工具允许我们跟踪系统调用,并可以视为 Linux 内核的插装。Intel 处理器跟踪(见第 6.4 节)允许记录程序执行的指令,并可以视为 CPU 的插装。追踪可以从事先适当插装的组件中获取,并且不受更改的影响。追踪通常用作黑箱方法,其中用户无法修改应用程序的代码,但他们想了解程序在幕后正在做什么。
使用 Linux strace 工具跟踪系统调用的示例在清单 7 中演示。该清单显示运行 git status 命令时输出的前几行。通过使用 strace 追踪系统调用,可以知道每个系统调用的时间戳(最左侧的列),它的退出状态以及每个系统调用的持续时间(在尖括号中)。
Listing 7 Tracing system calls with strace.
```bash
$ strace -tt -T -- git status
17:46:16.798861 execve("/usr/bin/git", ["git", "status"], 0x7ffe705dcd78 /* 75 vars */) = 0 <0.000300>
17:46:16.799493 brk(NULL) = 0x55f81d929000 <0.000062>
17:46:16.799692 access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory) <0.000063>
17:46:16.799863 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) <0.000074>
17:46:16.800032 openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 <0.000072>
17:46:16.800255 fstat(3, {st_mode=S_IFREG|0644, st_size=144852, ...}) = 0 <0.000058>
17:46:16.800408 mmap(NULL, 144852, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f6ea7e48000 <0.000066>
17:46:16.800619 close(3) = 0 <0.000123>
...
```
追踪的开销非常取决于我们尝试追踪的内容。例如,如果我们追踪几乎从不进行系统调用的程序,则在 strace 下运行它的开销将接近零。相反,如果我们追踪严重依赖系统调用的程序,开销可能非常大,如 100x。此外,追踪可能会生成大量数据,因为它不会跳过任何采样。为了弥补这一点,追踪工具提供了仅为特定时间片或代码片段收集数据的手段。
通常,与插装类似,追踪被用于探索系统中的异常情况。例如,您可能想查找应用程序在不响应的 10 秒钟期间发生了什么。性能分析不是为此设计的,但是通过追踪,可以看到导致程序不响应的原因。例如,使用 Intel PT(见第 6.4 节),我们可以重构程序的控制流,并确切地知道执行了哪些指令。
追踪还非常有用于调试。其底层特性使得基于记录的追踪进行"记录和重放"用例成为可能。其中一种工具是 Mozilla rr74 调试器,该调试器记录和重放进程,允许后退单步执行等。大多数追踪工具都能够使用时间戳装饰事件(请参见清单 7 中的示例),这样可以使我们与在那段时间发生的外部事件进行关联。也就是说,当我们观察到程序中出现故障时,我们可以查看应用程序的追踪,并将此故障与整个系统在那段时间内发生的情况进行关联。
##### **5.3 Workload Characterization //工作负载特征分析**
工作负载特性分析是通过定量参数和函数描述工作负载的过程。其目的是定义工作负载的行为及其最重要的特征。从高层次上看,应用程序可以属于以下一种或多种类型:交互式、数据库、基于网络、并行等。针对不同的应用领域,可以使用不同的度量和参数来表征不同的工作负载。
在第6.1节中,我们将仔细研究自上而下微架构分析(TMA)方法,该方法试图通过将应用程序分为以下4个部分之一来描述应用程序:前端受限、后端受限、退役和恶意投机。TMA使用性能监控计数器(PMC,请参见第3.9.1节)来收集所需信息并确定CPU微架构的低效使用。
**5.3.1 Counting Performance Events //计数性能事件**
PMCs是低级别性能分析的非常重要工具。它们可以提供关于程序执行的独特信息。PMCs通常使用两种模式:"计数"和"采样"。计数模式用于工作负载特性分析,而采样模式用于查找热点,在第5.4节中我们将讨论这一点。计数的思想非常简单:我们想在程序运行期间统计某些性能事件的数量。图18说明了在时间维度上计数性能事件的过程。

Figure 18: Counting performance events.
图18中概述的步骤大致代表了典型分析工具用于计数性能事件的过程。这个过程在perf stat工具中实现,该工具可以用于计数各种硬件事件,如指令数、周期数、缓存失效等。下面是perf stat输出的示例:
```bash
$ perf stat -- ./a.exe
10580290629 cycles # 3,677 GHz
8067576938 instructions # 0,76 insn per cycle
3005772086 branches # 1044,472 M/sec
239298395 branch-misses # 7,96% of all branches
```
了解这些数据非常有益。首先,它允许我们快速发现一些异常,比如高缓存失效率或低IPC。其次,当您刚刚进行了代码改进并且想要验证性能增益时,它可能会很有用。查看绝对数字可以帮助您证明或拒绝代码更改。
个人经验:我将"perf stat"用作简单的基准包装器。由于计数事件的开销最小,我几乎自动在"perf stat"下运行所有基准。它为我提供了性能调查的第一步。有时可以立即发现异常,这可以节省一些分析时间。
**5.3.2 Manual performance counters collection //手动收集性能计数**
现代CPU有数百个可计数的性能事件。记住它们及其含义非常困难。理解何时使用特定的PMC甚至更难。这就是为什么通常我们不建议手动收集特定的PMC,除非您确实知道自己在做什么。相反,我们建议使用像Intel Vtune Profiler这样的工具来自动化这个过程。然而,在某些情况下,您可能有兴趣收集特定的PMC。
所有Intel CPU世代的性能事件的完整列表可以在\[Int,2020,Volume 3B,Chapter 19\]中找到。每个事件都用事件和Umask十六进制值进行编码。有时性能事件还可以使用其他参数进行编码,例如Cmask和Inv等。下表4显示了对Intel Skylake微架构编码两个性能事件的示例。

Table 4: Example of encoding Skylake performance events.
Linux perf为常用的性能计数器提供了映射。它们可以通过伪名称访问,而不是指定事件和Umask十六进制值。例如,branches只是BR_INST_RETIRED.ALL_BRANCHES的同义词,将测量相同的事物。perf list可以查看可用映射名称的列表。
```bash
$ perf list
branches [Hardware event]
branch-misses [Hardware event]
bus-cycles [Hardware event]
cache-misses [Hardware event]
cycles [Hardware event]
instructions [Hardware event]
ref-cycles [Hardware event]
```
然而,Linux perf并没有为每个CPU架构的所有性能计数器提供映射。如果您要查找的PMC没有映射,可以使用以下语法进行收集:
```bash
$ perf stat -e cpu/event=0xc4,umask=0x0,name=BR_INST_RETIRED.ALL_BRANCHES/ -- ./a.exe
```
此外,还有一些Linux perf的包装程序可以进行映射,例如oprofile和ocperf.py。以下是它们的使用示例:
```bash
$ ocperf -e uops_retired ./a.exe
$ ocperf.py stat -e uops_retired.retire_slots -- ./a.exe
```
性能计数器并不是在每个环境中都可以使用的,因为访问PMCs需要root权限,而运行在虚拟化环境中的应用程序通常不具备root权限。对于在公共云中执行的程序,如果虚拟机管理器没有正确地向客户端公开PMU编程接口,则直接在客户端容器中运行基于PMU的分析器将无法生成有用的输出。因此,基于CPU性能计数器的分析器在虚拟化和云环境中的效果并不好\[Du et al., 2010\]。尽管情况正在改善。VmWare®是最早支持虚拟CPU性能计数器(vPMC)的虚拟机管理器之一。AWS EC2云为专用主机启用了PMC。
**5.3.3 Multiplexing and scaling events //多路复用和事件扩展**
有时我们需要同时计算许多不同的事件。但是只有一个计数器,每次只能计算一个事情。这就是为什么PMU内置了多个计数器(通常每个硬件线程有4个)。即使这样,固定和可编程计数器的数量也不总是足够的。Top-Down分析方法需要在单个程序执行中收集多达100个不同的性能事件。显然,CPU没有那么多计数器,这就需要使用多路复用。
如果事件比计数器多,分析工具使用时间多路复用,让每个事件有机会访问监视硬件。图19显示了在只有4个PMCs可用的情况下,8个性能事件之间的多路复用示例。

Figure 19: Multiplexing between 8 performance events with only 4 PMCs available.
通过多路复用,事件并非始终被测量,而只在其中的某个时段内进行测量。在运行结束时,性能分析工具需要根据总启用时间对原始计数进行缩放:
最终计数 = 原始计数 \* (运行时间 / 启用时间)
例如,在进行性能分析时,我们可以在五个时间间隔内测量某个计数器。每个测量间隔为100ms(启用时间)。程序运行时间为1秒(运行时间)。此计数器的总事件数测量为10000(原始计数)。因此,我们需要将最终计数缩放2倍,即20000:
最终计数 = 10000 \* (1000ms/500ms) = 20000
这提供了一个估计值,如果该事件在整个运行期间内被测量,它将是多少。非常重要的是要理解,这只是一个估计值,而不是一个实际计数。多路复用和缩放可以安全地应用于执行长时间间隔内相同代码的稳定工作负载上。相反,如果程序经常在不同的热点之间跳转,就会出现盲区,在缩放过程中可能会产生错误。为避免缩放,可以尝试将要测量的事件数量不超过可用物理 PMCs 的数量。然而,这将需要多次运行基准测试来测量所感兴趣的所有计数器。
##### **5.4 Sampling //采样**
采样是执行性能分析时最常用的方法。人们通常将其与在程序中找到热点相关联。一般来说,采样给出了一个问题的答案:哪个代码位置对某些性能事件做出了最大的贡献。如果我们考虑寻找热点,这个问题可以重新定义为:哪个代码位置消耗了最多的 CPU 周期。人们通常使用"分析"一词来代替技术上称为"采样"的术语。根据维基百科的定义,分析是一个更广泛的术语,包括收集数据的各种技术,例如中断、代码插装和 PMC 等。
可能会让人惊讶的是,人们可以通过调试器实现最简单的采样分析工具。事实上,您可以通过以下步骤识别热点:a)在调试器下运行程序,b)每隔10秒暂停程序,c)记录它停止的地方。如果您重复执行 b)和 c)很多次,您将构建一个样本集合。您停止的代码行将是程序中最热的位置。当然,这只是实际分析工具如何工作的简单描述。现代分析工具能够每秒收集数千个样本,这可为基准测试中最热门的位置提供相当精确的估计。与调试器的示例类似,每当捕获一个新样本时,分析工具都会中断要分析的程序的执行。在中断时,分析工具会收集程序状态的快照,构成一个样本。收集的信息可能包括中断时执行的指令地址、寄存器状态、调用堆栈(参见第5.4.3节)等。收集的样本数据存储在数据采集文件中,可以进一步用于显示调用图、程序中最耗时部分和统计重要代码部分的控件流。
**5.4.1 User-Mode And Hardware Event-based Sampling //用户模式和基于硬件事件的采样**
采样可以以两种不同的模式进行,即使用用户模式或基于硬件事件的采样(EBS)。用户模式采样是一种纯软件方法,它将代理库嵌入到被检测应用程序中。代理为应用程序中的每个线程设置操作系统计时器。当计时器到期时,应用程序接收处理采集器处理的 SIGPROF 信号。EBS 使用硬件性能监视计数器(PMCs)来触发中断。特别地,使用 PMU 的计数器溢出特性,我们将在下一节中讨论。用户模式采样只能用于识别热点,而 EBS 则可用于涉及 PMCs 的其他分析类型,例如基于缓存未命中的采样、TMA(请参见第6.1节)等。
用户模式采样比 EBS 产生更多的运行时开销。当使用默认间隔10ms进行采样时,用户模式采样的平均开销约为5%。在1ms采样间隔下,基于事件的采样的平均开销约为2%。通常,EBS 更准确,因为它允许以更高的频率收集样本。但是,用户模式采样生成的数据要少得多,分析数据所需的时间也更短。
**5.4.2 Finding Hotspots**
在本节中,我们将讨论使用 PMCs 与 EBS 的情况。图20说明了采用 PMU 的计数器溢出特性来触发性能监视中断(PMI)。

Figure 20: Using performance counter for sampling
首先,我们需要配置要进行采样的事件。识别热点意味着知道程序花费大部分时间的地方。因此,基于循环采样是非常自然的选择,并且它是许多性能分析工具的默认配置。但这并不一定是严格的规则;我们可以对任何想要的性能事件进行采样。例如,如果我们想知道程序遇到最多L3缓存未命中的位置,我们将在相应事件上进行采样,即MEM_LOAD_RETIRED.L3_MISS。
准备工作完成后,我们启用计数并让基准测试运行。我们将PMC配置为计算周期数,因此每个周期都会增加计数器的值。最终,计数器将溢出。计数器溢出时,硬件将引发PMI。性能分析工具被配置为捕获PMI,并具有用于处理它们的中断服务例程(ISR)。在这个例程内部,我们需要执行多个步骤:首先,我们需要禁用计数;然后,记录在计数器溢出时CPU执行的指令;接着,将计数器重置为N并恢复基准测试。
现在,回到值N。使用这个值,我们可以控制想要多频繁获得新的中断。假设我们希望有更细的粒度,并且每1百万条指令采集一次样本。为了实现这一点,我们可以将计数器设置为-1百万,这样它将在每1百万条指令后溢出。这个值通常被称为"采样后"的值。
我们重复这个过程很多次,以建立足够的示例集合。如果以后聚合这些样本,我们可以构建程序中最热门地方的直方图,如下面从Linux perf record/report的输出所示。这给出了程序函数开销的按降序排序的分解(热点)。
以下是对来自Phoronix测试套件的x264基准测试进行采样的示例:
```bash
$ perf record -- ./x264 -o /dev/null --slow --threads 8
Bosphorus_1920x1080_120fps_420_8bit_YUV.y4m
$ perf report -n --stdio
# Samples: 364K of event 'cycles:ppp'
# Event count (approx.): 300110884245
# Overhead Samples Shared Object Symbol
# ....................................................................
#
6.99% 25349 x264 [.] x264_8_me_search_ref
6.70% 24294 x264 [.] get_ref_avx2
6.50% 23397 x264 [.] refine_subpel
5.20% 18590 x264 [.] x264_8_pixel_satd_8x8_internal_avx2
4.69% 17272 x264 [.] x264_8_pixel_avg2_w16_sse2
4.22% 15081 x264 [.] x264_8_pixel_avg2_w8_mmx2
3.63% 13024 x264 [.] x264_8_mc_chroma_avx2
3.21% 11827 x264 [.] x264_8_pixel_satd_16x8_internal_avx2
2.25% 8192 x264 [.] rd_cost_mb
...
```
那么,我们自然希望知道热点列表中出现的每个函数内部的热点代码。要查看已内联的函数的分析数据以及特定源代码区域生成的汇编代码,需要使用带有调试信息(-g编译器标志)构建应用程序。用户可以使用-gline-tables-only选项将调试信息减少为符号在源代码中出现的行号。像Linux perf这样没有完整、丰富图形支持的工具通常会将源代码与生成的汇编混合在一起,如下所示:
```bash
# snippet of annotating source code of 'x264_8_me_search_ref' function
$ perf annotate x264_8_me_search_ref --stdio
Percent | Source code & Disassembly of x264 for cycles:ppp
----------------------------------------------------------
...
: bmx += square1[bcost&15][0]; <== source code
1.43 : 4eb10d: movsx ecx,BYTE PTR [r8+rdx*2] <== corresponding
machine code
: bmy += square1[bcost&15][1];
0.36 : 4eb112: movsx r12d,BYTE PTR [r8+rdx*2+0x1]
: bmx += square1[bcost&15][0];
0.63 : 4eb118: add DWORD PTR [rsp+0x38],ecx
: bmy += square1[bcost&15][1];
...
```
大多数带有图形用户界面(GUI)的性能分析工具,例如Intel VTune Profiler,可以将源代码和相关汇编代码并排显示,如图21所示。

Figure 21: Intel® VTune™ Profifiler source code and assembly view for x264 benchmark.
iphone上可以运行simpleperf么?
简短的回答是不行。simpleperf是一个基于Linux内核性能计数器的工具,因此它不能在iPhone上运行。另外,iPhone OS是一个专有的操作系统,它具有与Linux内核不同的底层架构和API,因此simpleperf在其上的运行是不可能的。
iphone上类似simpleperf的工具有什么?
对于iPhone设备,有一些类似simpleperf的性能分析工具可供选择。以下是其中的一些工具:
1. Xcode Instruments:这是苹果官方提供的工具,它可以帮助开发者对CPU、内存、网络等方面进行分析,同时提供了各种详细的图表和数据报告。
2. Shark:Shark是苹果早期提供的一个性能分析工具,现在已经不再更新,但仍然可以使用。它提供了丰富的分析选项和图表,并支持多种文件输出格式。
3. Instruments-lite:这是一个第三方开发的工具,它基于Xcode Instruments的API,提供了与简单的界面和一些额外的功能,例如查看进程信息、日志输出等。
4. Sysdiagnose:这也是一个由苹果官方提供的工具,它主要用于收集系统和应用程序的日志信息以进行分析。可以通过在设备上输入特定的命令来启动Sysdiagnose。
请注意,这些工具可能在不同版本的iOS系统上有所差异,因此建议仔细查看每个工具的文档以了解其功能和使用方式。
**5.4.3 Collecting Call Stacks**
在进行采样时,我们经常会遇到这样一种情况:程序中最热门的函数被多个调用者调用。例如,图22展示了这种情况的一个示例。分析工具的输出可能会显示,函数foo是程序中最热门的函数之一,但如果它有多个调用者,我们希望知道哪一个调用者调用foo的次数最多。对于像memcpy或sqrt这样出现在热点函数中的库函数,这是一个典型的情况。为了理解为什么某个特定的函数出现在热点中,我们需要知道程序的控制流图(CFG)中哪条路径引起了它的出现。

Figure 22: Control Flow Graph: hot function "foo" has multiple callers.
分析所有调用foo的调用者的逻辑可能非常耗时。我们只想关注那些导致foo成为热点的调用者。换句话说,我们想知道程序控制流图中最热门的路径。性能分析工具通过在收集性能样本时捕获进程的调用堆栈以及其他信息来实现这一点。然后,所有收集到的堆栈会被分组,使我们能够看到导致特定函数的热门路径。
在Linux perf中收集调用堆栈有三种方法:
1. Frame Pointers (FP):使用Frame Pointers方法收集调用堆栈需要在编译时为代码添加--fno-omit-frame-pointer选项来禁用省略框架指针。历史上,框架指针(RBP)用于调试,因为它允许我们获取调用堆栈而不必从堆栈中弹出所有参数(堆栈展开)。框架指针可以直接告诉返回地址。然而,它消耗了一个寄存器来执行这个任务,因此很昂贵。它也被用于性能分析,因为它使得堆栈展开变得简单。
2. DWARF调试信息(perf record --call-graph dwarf)。需要二进制文件构建时使用-g(-gline-tables-only)选项来添加DWARF调试信息。
3. Intel Last Branch Record (LBR)硬件特性(perf record --call-graph lbr)。与前两种方法相比,调用链不是很深。关于LBR的更多信息详见第6.2节。
DWARF如何发音:
DWARF在计算机领域中是一个非常常见的术语,它通常被读作\[dwɔːrf\],即"多夫"。其中"DW"代表"Debugging With",而"ARF"则代表"Advanced Representation Format"。
以下是使用Linux perf收集程序中调用堆栈的示例。通过查看输出,我们可以知道55%的时间foo是从func1调用的。我们可以清楚地看到在foo的调用者之间的开销分布,并且现在可以将注意力集中在程序的CFG中最热门的路径上。

在使用Intel Vtune Profiler时,可以在配置分析时勾选相应的"Collect stacks"框来收集调用堆栈数据。在使用命令行界面时,可以指定-knob enable-stack-collection=true选项。
个人经验:了解收集调用堆栈数据的机制非常重要。我见过一些不熟悉这个概念的开发人员尝试使用调试器来获取这些信息。他们通过中断程序执行并分析调用堆栈(如"gdb"调试器中的"backtrace"命令)来完成此操作。开发人员应该让性能分析工具完成这项工作,这样更快速且提供更准确的数据。
iphone上有没有什么性能分析工具可以打印调用堆栈,而不是hook抓单点的调用堆栈?
在iPhone上,可以使用Instruments应用程序进行性能分析。通过选择"Time Profiler"Instrument并开始采样,可以收集整个应用程序的 CPU 使用情况,并捕获所有函数的调用堆栈。在Instruments中,可以将CPU使用率显示为函数级别,并且可以查看每个函数被调用的次数和执行时间。
另一个工具是 Xcode 的调试器,可以在其下方的"Debug Navigator"窗格中查看方法的调用堆栈。调试器可以捕获断点处的调用堆栈,也可以在代码执行时查看当前的调用堆栈。
这两个工具都可以在iPhone上打印完整的调用堆栈,而不是仅针对单个方法或函数。
介绍下iphone的Instruments应用程序:
Instruments是苹果公司为macOS和iOS开发的一款性能分析工具。它提供了一些内置的Instrument,用于检测CPU使用率、内存使用率、网络流量、能量消耗等等。其中,"Time Profiler" Instrument可以用于捕获 CPU 使用情况和检测应用程序中的性能瓶颈。
在Instruments中,可以选择要跟踪的应用程序,并使用所选 Instrument 进行采样。然后,工具会收集数据并生成可视化图表,以帮助开发人员分析应用程序的性能问题。图表主要分为两个部分:左侧是时间轴,右侧是函数列表。时间轴显示了应用程序的运行时间线,而函数列表则显示了被调用的函数及其所用的 CPU 时间。开发人员可以点击某一个函数来查看它的调用堆栈及其他相关信息。
除了内置的 Instrument 之外,Instruments 还支持自定义 Instrument。开发人员可以使用编程语言创建自己的 Instrument,以实现特定的性能分析需求。
总之,Instruments 是一个强大的性能分析工具,可以帮助开发人员诊断并解决应用程序中的性能问题。
iphone的Instruments应用程序哪里下载?
Instruments是Xcode自带的工具之一,在安装Xcode时会自动安装。若您的Mac上已经安装了Xcode,则可以在/Applications/Xcode.app/Contents/Applications/Instruments.app路径下找到Instruments应用程序。
如果您还没有安装Xcode,可以前往Apple Developer官网(https://developer.apple.com/downloads/)下载并安装最新版本的Xcode。请注意,在Xcode安装过程中,可能需要同意许可协议并输入管理员密码。安装完成后,您就可以找到Instruments应用程序并开始使用了。
**5.4.4 Flame Graphs //火焰图**
一种流行的可视化性能分析数据和最常见代码路径的方法是使用火焰图。它可以让我们看到哪些函数调用占用了执行时间的大部分。图23展示了x264基准测试的火焰图示例。从上述火焰图中,我们可以看出占用最多执行时间的路径是x264 -\> threadpool_thread_internal -\> slices_write -\> slice_write -\> x264_8_macroblock_analyse。原始输出是交互式的,允许我们缩放到特定的代码路径。这个火焰图是由Brendan Gregg开发的开源脚本86生成的。还有其他能够生成火焰图的工具,也许KDAB Hotspot87是最流行的替代品。

Figure 23: A Flame Graph for x264 benchmark.
##### **5.5 Rooflfline Performance Model //屋顶线性能模型**
Roofline Performance Model是在2009年由加州大学伯克利分校开发的一个以吞吐量为导向的性能模型,广泛应用于HPC领域。该模型中的"屋顶线"表达了这样一个事实:应用程序的性能不能超过计算机的能力。程序中的每个函数和每个循环都受到计算或存储容量的限制。如图24所示,这个概念被表示为一个应用程序的性能始终会受到某个"屋顶线"函数的限制。

Figure 24: Rooflfline model. © Image taken from NERSC Documentation.
//Arithmetic Intensity(算术强度)
//Attainable Flop/s(可达到的每秒浮点运算次数)
硬件有两个主要限制:它可以进行计算的速度(峰值计算性能,FLOPS)和它可以移动数据的速度(峰值内存带宽,GB/s)。应用程序的最大性能受到峰值FLOPS(水平线)和平台带宽乘以算术密度(对角线)之间的最小限制。
如图24中所示,Roofline图将两个应用程序A和B的性能与硬件限制进行了比较。程序中的不同部分可能具有不同的性能特点。Roofline模型考虑到了这一点,并允许在同一张图表上显示应用程序的多个函数和循环。
Listing 8 Naive parallel matrix multiplication.
```cpp
1 void matmul(int N, float a[][2048], float b[][2048], float c[][2048]) {
2 #pragma omp parallel for
3 for(int i = 0; i < N; i++) {
4 for(int j = 0; j < N; j++) {
5 for(int k = 0; k < N; k++) {
6 c[i][j] = c[i][j] + a[i][k] * b[k][j];
7 }
8 }
9 }
10}
```
算术强度(AI)是一个FLOPS和字节数之间的比率,可以为程序中的每个循环提取。让我们计算清单8中代码的算术强度。在最内层的循环体中,我们有一次加法和一次乘法;因此,我们有2次FLOPS。此外,我们有三个读操作和一个写操作;因此,我们传输了4个操作\*4个字节=16个字节。该代码的算术强度为2/16=0.125。AI作为给定性能点的X轴上的值。

Figure 25: Rooflfline model.
优化应用程序性能的传统方法是充分利用机器的SIMD和多核能力。通常情况下,我们需要优化许多方面:向量化、内存、线程。Roofline方法可以帮助评估应用程序的这些特性。在一个Roofline图表上,我们可以绘制标量单核、SIMD单核和SIMD多核性能的理论最大值(见图25)。这将使我们了解到提高应用程序性能的空间。如果我们发现我们的应用程序受到计算限制(即具有高算术强度)且低于峰值标量单核性能,我们应考虑强制向量化(参见第8.2.3节)并将工作分配到多个线程中。相反,如果应用程序的算术强度较低,则应寻求改进内存访问的方法(参见第8.1节)。使用Roofline模型优化性能的最终目标是将点向上移动。向量化和线程将点向上移动(应该是对应图25的向量化和多核CPU能力),通过增加算术强度优化内存访问将使点向右移动(FLOPS/字节数,这里是通过优化内存来减少字节数),并可能提高性能。
解释下SIMD和向量化的关系:
SIMD(单指令多数据)和向量化是密切相关的概念。简而言之,向量化是指将单个指令应用于多个数据元素,从而使计算机执行相同的操作时能够同时处理多个数据元素。而SIMD是实现向量化的硬件技术之一,它允许在一个CPU周期内同时处理多个数据元素,从而加速程序的执行。
具体来说,编写向量化代码意味着将循环中的操作组织成向量形式,即在一次指令中同时处理多个数据元素。这可以通过使用特定的指令集或库(如SSE、AVX等)来实现。而SIMD则提供了硬件支持,以便CPU可以在一次指令中并行处理多个数据,使向量化代码能够充分利用硬件加速,并实现更高效的程序执行。因此,向量化是一种技术方法,而SIMD是一种支持向量化的计算机硬件实现。
向量化code举例:
以下是一个将向量化应用于计算两个向量点积的示例C++代码:
````cpp
```c++
#include
float dot_product(float *a, float *b, int n) {
__m256 sum_vec = _mm256_setzero_ps();
for (int i = 0; i < n; i += 8) {
__m256 a_vec = _mm256_loadu_ps(&a[i]);
__m256 b_vec = _mm256_loadu_ps(&b[i]);
sum_vec = _mm256_add_ps(sum_vec, _mm256_mul_ps(a_vec, b_vec));
}
float sum = 0.0f;
for (int i = 0; i < 8; i++) {
sum += ((float*)&sum_vec)[i];
}
return sum;
}
```
````
这个函数利用了256位AVX向量指令集以每次处理8个单精度浮点数。在循环中,它使用_mm256_loadu_ps函数加载a和b向量中的8个元素,使用_mm256_mul_ps函数进行乘法操作,然后使用_mm256_add_ps函数计算它们的和。这些操作都在一个单独的向量寄存器中执行,从而加速点积计算。最后,函数将结果从向量寄存器中提取出来,并通过标量代码计算点积的最终值。
需要注意的是,当向量长度不是向量寄存器大小的倍数时,需要特殊处理。在上面的代码中,我们使用了一个for循环来处理余数部分,但这是比较低效的。一种更好的方法是使用向量掩码并使用_mm256_maskload_ps函数来加载未对齐的剩余元素。
向量化代码可以提高执行速度,并利用现代CPU中的SIMD指令以实现更高效的计算。
计算机的理论最大性能(roof lines)可以根据使用的机器的参数来进行估算。对于Intel Core i5-8259U处理器,使用AVX2指令集和2个Fused Multiply Add(FMA)单元(加乘运算)的单精度浮点峰值FLOPS(每秒浮点运算次数)可以按照以下公式进行计算:
Peak FLOPS = 8(逻辑核心数)× 256(AVX位宽)/ 32位(浮点数大小)× 2(FMA)× 3800 GHz(最大睿频)= 486.4 GFLOPs
该计算的结果为486.4 GFLOPs。这个数字表示了该处理器在理想情况下的最大性能,但实际上它可能会受到各种因素的影响而低于这个值,例如内存带宽、操作系统开销等等。然而,了解理论最大性能(roof lines)是一个非常有用的指标,因为它可以帮助程序员评估他们的代码是否已经充分利用了底层硬件的性能,并进行必要的优化。
睿频什么意思:
睿频(Turbo Boost)是一种由英特尔公司开发的技术,它可以使处理器在满负荷时自动提高时钟频率,从而提高性能。当处理器需要更高的性能时,睿频技术会自动增加时钟频率以达到更高的处理能力,当处理器不再需要更高的性能时,时钟频率则会自动降低以减少功耗和热量。这种技术可以让处理器更加智能化地运行,根据需要进行自适应调整,从而在保证性能的同时提高能效。可以通过BIOS设置或操作系统软件来控制睿频技术的启用和关闭,并且不同型号的处理器所支持的睿频倍频数不尽相同。
对于使用DDR4内存、2个内存通道,每次内存访问8个字节的Intel NUC Kit NUC8i5BEH,最大内存带宽可以按以下方式计算:
Peak Memory Bandwidth = 2400(DDR4内存传输速率)× 2(内存通道数)× 8(每次内存访问字节数)× 1(插座数)= 38.4 GiB/s
该计算的结果为38.4 GiB/s。这个数字表示了该设备在理想情况下的最大内存传输速率,但实际上它可能会受到各种因素的影响而低于这个值,例如内存延迟、内存排列方式等等。然而,了解设备的最大内存带宽是一个非常有用的指标,因为它可以帮助程序员评估和优化他们的代码中的内存访问模式并充分利用底层硬件的性能。
自动化工具如 Empirical Roofline Tool 和 Intel Advisor 可以通过运行一组准备好的基准测试来经验性地确定理论最大值。如果计算可以重用缓存中的数据,则可以实现更高的FLOP速率。 Roofline 可以通过为内存层次结构的每个级别引入专用 roofline 来考虑这一点(参见图26)。这些工具可以帮助程序员识别和消除代码中的内存瓶颈,从而充分利用底层硬件的性能,并优化代码以提高计算性能。使用这些工具需要一定的技术能力和经验,并且需要针对特定的硬件和应用程序进行调整和配置。
https://bitbucket.org/berkeleylab/cs-roofline-toolkit/src/master/

Figure 26: Rooflfline analysis for matrix multiplication on Intel NUC Kit NUC8i5BEH with 8GB RAM using clang 10 compiler.
在确定硬件限制后,我们可以开始评估应用程序相对于 Roofline 的性能。自动收集 Roofline 数据的两种最常用方法是采样(由 likwid 工具使用)和二进制插装(由 Intel Software Development Emulator (SDE) 使用)。采样产生较低的数据收集开销,而二进制插装提供更准确的结果。Intel Advisor 能够自动构建 Roofline 图表,甚至为特定循环的性能优化提供提示。图26展示了Intel Advisor生成的图表示例。请注意,Roofline 图表具有对数刻度。
https://github.com/RRZE-HPC/likwid
Roofline 方法允许通过在同一图表上打印"之前"和"之后"点来跟踪优化进度。因此,这是一个迭代的过程,可以指导开发人员使他们的应用程序充分利用硬件能力。图26反映了通过对清单8中的代码进行两次代码转换所获得的性能提升(图中红色圆框和红色方框):
• 交换两个最内层循环(交换第4行和第5行)。这样可以进行友好的缓存内存访问(见第8.1节)。 //cache-access优化
• 使用AVX2指令向量化最内层循环。 //向量化优化
//这两个优化都是使曲线向上移动
总之,Roofline 性能模型可帮助:
• 识别性能瓶颈。
• 指导软件优化。
• 确定何时完成优化。
• 评估相对于机器能力的性能。
Additional resources and links:
• NERSC Documentation, URL: https://docs.nersc.gov/development/performancedebugging-tools/roofline/.
• Lawrence Berkeley National Laboratory research, URL: https://crd.lbl.gov/departments/computer-science/par/research/rooflfline/
• Collection of video presentations about Rooflfline model and Intel Advisor, URL: https://techdecoded.intel.io/ (search "Rooflfline").
• Perfplot is a collection of scripts and tools that allow a user to instrument performance counters on a recent Intel platform, measure them, and use the results to generate rooflfline and performance plots. URL: https://github.com/GeorgOfenbeck/perfplot
##### **5.6 Static Performance Analysis //静态性能分析**
现在我们有广泛的静态代码分析工具。对于 C 和 C++ 语言,我们有 Clang 静态分析器、Klocwork、Cppcheck 等著名工具。它们旨在检查代码的正确性和语义。同样,还有一些工具尝试解决代码的性能方面问题。静态性能分析器不运行实际的代码。相反,它们模拟代码,就像在真正的硬件上执行一样。静态预测性能几乎是不可能的,因此这种类型的分析存在许多限制。首先,不可能对 C/C++ 代码进行性能静态分析,因为我们不知道它将被编译成的机器代码。因此,静态性能分析只能对汇编代码进行。
其次,静态分析工具模拟工作负载而不是执行它。显然非常慢,所以不能对整个程序进行静态分析。相反,工具会选择一些汇编代码片段,并尝试预测它在真实硬件上的行为。用户应该选择特定的汇编指令(通常是小循环)进行分析。因此,静态性能分析的范围非常狭窄。静态分析器的输出相当低级别,有时会将执行分解成 CPU 周期。通常,开发人员将其用于关键代码区域的细粒度调整,在那里每个周期都很重要。
**5.6.1 Static vs. Dynamic Analyzers**
静态工具不运行实际的代码,而是尝试模拟执行过程,并保留尽可能多的微架构细节。它们无法进行实际测量(如执行时间、性能计数器),因为它们不运行实际代码。其中的好处是您不需要真正的硬件,可以模拟不同 CPU 代的代码。另一个好处是您无需担心结果的一致性:与在真实硬件上执行相比,模拟(与执行)不会有任何偏差。静态工具的缺点是,它们通常无法预测和模拟现代 CPU 中的所有内容:它们基于一些可能存在错误和局限性的模型。静态性能分析器的例子包括 IACA 和 llvm-mca。
动态工具基于在真实硬件上运行代码并收集有关执行的各种信息。这是证明任何性能假设的唯一100%可靠的方法。作为缺点,通常需要特权访问权限才能收集低级性能数据,例如 PMCs。编写良好的基准测试并测量要测量的内容并不总是容易的。最后,您需要过滤噪声和各种副作用。动态性能分析器的例子包括 Linux perf、likwid 和 uarch-bench 等。可以在 easyperf 的博客上找到上述工具的使用和输出示例。这里还提供了用于静态和动态微架构性能分析的大量工具的集合。
个人经验是,我在需要探索一些有趣的 CPU 微架构效果时使用这些工具。静态和低级别的动态分析器(如 likwid 和 uarch-bench)允许我们在实践中观察硬件效果,同时进行性能实验。它们对于构建您对CPU工作方式的心理模型非常有帮助。
likwid是什么?
likwid是一个开源工具,用于在Linux系统上对多核处理器进行性能分析。使用likwid,您可以测量程序的总体性能、缓存性能和内存带宽使用情况,还可以度量各种硬件性能计数器(如PMC)。它还包括一个benchmarker,用于测试基准测试并比较多个不同实现之间的性能。likwid被广泛用于并行编程和高性能计算中,提供了许多有用的功能和API以进行性能分析。
likwid和simpleperf的区别是?
likwid和simpleperf都是用于在Linux系统上进行性能分析的工具,但它们之间存在一些区别。
首先,likwid和simpleperf的设计目标不同。likwid主要面向并行编程和高性能计算,提供了许多API和功能以进行硬件性能计数器的测量,并且可以自动确定可伸缩性瓶颈。而simpleperf则专注于提供一个易于使用的命令行界面,可以轻松地对C/C++、Java和Python等语言编写的应用程序进行基准测试和性能分析。
其次,两者的使用方式和输出格式也有所不同。使用likwid,您需要显式地在代码中插入测量指令,然后运行代码并读取输出文件。使用simpleperf,您只需在命令行中指定要分析的程序和选项,就可以获得详细的性能数据和结果。
最后,二者支持的硬件平台和操作系统也有所不同。虽然likwid可以在许多不同的处理器体系结构上运行,但主要针对x86和ARM架构。而simpleperf则适用于所有支持Linux内核性能事件子系统的处理器体系结构,包括x86、ARM、PowerPC和MIPS等。
因此,选择使用likwid还是simpleperf取决于您的具体需求和应用场景。如果您需要进行高级的硬件性能计数器测量和并行性分析,则likwid可能更适合您。如果您需要一个易于使用和直观的工具来快速进行性能分析,则simpleperf可能更适合您。
##### **5.7 Compiler Optimization Reports**
现今,软件开发非常依赖编译器进行性能优化。编译器在加速我们的软件方面发挥着极为重要的作用。通常情况下,开发者会把这一工作交给编译器,只有在发现编译器无法完成某些优化时才进行干预。可以说,这是一种不错的默认策略。为了更好地进行交互,编译器提供了优化报告,开发者可以用来进行性能分析。有时,开发者想知道某个函数是否被内联、循环是否被向量化、展开等等,如果它被展开了,展开因子是多少?这个信息可以通过查看生成的汇编指令来获得,但不是所有人都能轻易地阅读汇编语言,特别是函数很大、调用其他函数或者有很多循环被向量化,或者编译器创建了多个相同循环的版本时,这个任务就更难了。幸运的是,大多数编译器(包括GCC、ICC和Clang)都提供了优化报告,以便开发者检查特定代码片段所进行的优化。另一个编译器提供的提示案例是Intel® ISPC101编译器(请参见8.2.3.7节),它会针对编译为相对低效代码的代码结构发出大量性能警告。示例9展示了一个由Clang 6.0没有向量化的循环。要在Clang中生成优化报告,需要使用-Rpass\*标志:

Listing 9 a.c
```cpp
1 void foo(float* __restrict__ a,
2 float* __restrict__ b,
3 float* __restrict__ c,
4 unsigned N) {
5 for (unsigned i = 1; i < N; i++) {
6 a[i] = c[i-1]; // value is carried over from previous iteration
7 c[i] = b[i];
8 }
9 }
```
通过检查上述优化报告,我们可以看到该循环没有被向量化,而是被展开了。对于开发者来说,有时很难识别出示例9中第5行循环中的向量依赖关系。c\[i-1\]加载的值依赖于前一次迭代的存储(请参见图27中的操作#2和#3)。通过手动展开前几次迭代,可以揭示这种依赖性:
```cpp
// iteration 1
a[1] = c[0];
c[1] = b[1]; // writing the value to c[1]
// iteration 2
a[2] = c[1]; // reading the value of c[1]
c[2] = b[2];
...
```

Figure 27: Visualizing the order of operations in Listing 9.
如果我们要将示例9中的代码向量化,将会导致在数组a中写入错误的值。假设CPU的SIMD单元可以一次处理四个浮点数,我们将得到可以用以下伪代码表示的代码:
```cpp
// iteration 1
a[1..4] = c[0..3]; // oops, a[2..4] get the wrong values
c[1..4] = b[1..4];
...
```
示例9中的代码无法进行向量化,因为循环内部的操作顺序很重要。可以通过交换第6行和第7行来修复此示例,而不改变函数的语义,如示例10所示。有关发现向量化机会以及使用编译器优化报告的示例的更多信息,请参见8.2.3节。
Listing 10 a.c
```cpp
1 void foo(float* __restrict__ a,
2 float* __restrict__ b,
3 float* __restrict__ c,
4 unsigned N) {
5 for (unsigned i = 1; i < N; i++) {
6 c[i] = b[i];
7 a[i] = c[i-1];
8 }
9 }
```
在优化报告中,我们可以看到该循环现在已被向量化:

编译器报告是针对每个源文件生成的,这可能很大。用户可以在输出中简单地搜索感兴趣的源代码行。基于LLVM的编译器的Compiler Explorer网站提供了"优化输出"工具,在你将鼠标悬停在相应的源代码行上时会报告所执行的转换。在LTO模式下,一些优化是在链接阶段进行的。要从编译和链接阶段发出编译器报告,应向编译器和链接器传递专用选项。有关更多信息,请参阅LLVM "Remarks"指南。编译器优化报告不仅有助于发现错过的优化机会并解释其原因,而且还对测试假设很有用。编译器通常根据其成本模型分析决定某个特定转换是否有益。但它并不总是做出最优选择,我们可以进一步调整。可以通过使用#pragma、属性、编译器内建函数等,在报告中检测缺少的优化并向编译器提供提示。请参见easyperf博客上使用此类提示的示例。像往常一样,在实际环境中测量并验证您的假设。
个人经验:编译器优化报告可能是您工具箱中的关键项目之一。这是一种快速检查特定热点进行了哪些优化、是否有一些重要的优化失败的方法。我使用优化报告发现了许多改进机会。
##### **5.8 Chapter Summary**
• 延迟和吞吐量通常是程序性能的最终指标。在寻找改进它们的方法时,我们需要获取更详细的关于应用程序执行的信息。硬件和软件都提供可用于性能监控的数据。
• 代码仪表化允许我们跟踪程序中的许多内容,但会在开发和运行时两方面带来相对较大的开销。虽然现在开发人员很少手动仪表化他们的代码,但这种方法仍然适用于自动化流程,例如PGO。
• 跟踪在概念上类似于仪表化,对于探索系统中的异常情况非常有用。跟踪允许我们捕获带有时间戳的每个事件的完整事件序列。
• 工作负载特征化是一种比较和分组应用程序基于他们的运行时行为的方法。一旦特征化,可以遵循特定的配方来查找程序中的优化空间。
• 抽样跳过程序执行的大部分,只取一个样本来代表整个时间间隔。虽然抽样通常可以给出足够准确的分布,但最为广为人知的用例是在代码中查找热点。由于不需要重新编译程序并且运行时开销非常小,因此抽样是最受欢迎的分析方法。
• 一般来说,计数和抽样的运行时开销非常低(通常低于2%)。一旦开始在不同事件之间进行多路复用,计数将变得更加昂贵(5-15%开销),随着抽样频率的增加,抽样将变得更加昂贵\[Nowak and Bitzes, 2014\]。在分析长时间运行的工作负载或者不需要非常精确的数据时,考虑使用用户模式抽样。
• 屋顶线是一个面向吞吐量的性能模型,在HPC领域被广泛使用。它允许绘制应用程序的性能与硬件限制之间的关系。屋顶线模型有助于确定性能瓶颈,指导软件优化并跟踪优化进展。
• 有一些工具尝试静态分析代码的性能。这些工具模拟代码片段而不是运行它。这种方法有许多限制和约束,但是可以得到非常详细和低级的报告。
• 编译器优化报告有助于找到缺失的编译器优化。它也可以指导开发人员组合新的性能实验。
//HPC:高性能计算。这本书很多东西涉及到优化code细节,属于HPC领域的技能
#### 6 CPU Features For Performance Analysis
性能分析的最终目标是确定瓶颈并找到与之相关联的代码位置。不幸的是,没有预定的步骤可以遵循,因此可以以许多不同的方式进行处理。
通常,对应用程序进行分析可以快速了解应用程序的热点。有时,这是开发人员需要做的一切来修复性能低效。尤其是高级性能问题通常可以通过分析揭示。例如,考虑这样一种情况,你使用感兴趣的特定函数对应用程序进行分析。根据你对应用程序的心理模型,你预计该函数是不经常使用的。但是,当你打开分析文件时,你会发现它消耗了大量的时间并且被调用了很多次。基于这些信息,你可以应用缓存或记忆化等技术来减少对该函数的调用次数,并期望看到显著的性能提升。
什么是缓存/记忆化技术:
缓存/记忆化技术是一种优化技术,用于提高计算机程序的性能。其基本思想是将计算的结果保存在内存中,以便在后续的计算中能够快速访问,从而避免重复地执行相同的计算。
缓存通常是通过将计算的结果存储在内存中的数据结构中实现的,这样可以更快地访问结果,而不需要重新计算。在某些情况下,可能需要使用特定的缓存算法来平衡存储空间和访问时间之间的权衡。
记忆化则是一种特殊的缓存技术,它通常涉及对递归函数进行优化。该技术通过将递归调用的结果存储在一个数据结构中,避免了多次进行相同的递归计算,从而大幅提高了递归函数的性能。
然而,当你已经修复了所有主要的性能低效问题,但仍需要从应用程序中挤出更多的性能时,仅仅知道特定函数的执行时间等基本信息是不够的。这时,你需要CPU提供的额外支持,以了解性能瓶颈所在的位置。因此,在使用本章节中提供的信息之前,请确保你正在尝试优化的应用程序没有严重的性能缺陷。因为如果存在这样的问题,使用CPU性能监控功能进行低级调整是没有意义的。它很可能会把你引向错误的方向,而且你只会浪费时间来调整糟糕的代码,而不是修复真正的高级性能问题。
个人经验:当我开始进行性能优化工作时,通常只是对应用程序进行分析并尝试通过基准测试中的热点来理解应用程序。这经常导致我进行无序的试验,如展开、矢量化、内联等操作。我并不是说这总是一种失败的策略。有时候你会幸运地从这些随机实验中获得巨大的性能提升。但通常情况下,你需要有很好的直觉和运气。
现代CPU不断推出新特性,以不同的方式增强性能分析。使用这些特性极大地简化了查找低级问题,如缓存未命中、分支预测错误等。在本章中,我们将介绍一些现代Intel CPU上可用的HW性能监控功能。其中大部分也存在于其他供应商的CPU(如AMD、ARM等)中。请查看相应部分以获取更多详细信息。
• Top-Down微架构分析方法(TMA)-一种强大的技术,用于识别程序对CPU微架构的无效使用。它表征了工作负载的瓶颈,并允许定位它发生的源代码的确切位置。它抽象了CPU微架构的复杂性,即使对于经验不足的开发人员也很容易使用。
• Last Branch Record(LBR)-一种机制,连续记录与执行程序并行的最近分支结果。它用于收集调用堆栈、识别热点分支、计算单个分支的误判率等。
• 处理器事件采样(PEBS)-一种增强采样的功能。其主要优点包括:降低采样开销和提供"精确事件"功能,允许确定导致特定性能事件的确切指令。
• Intel处理器跟踪(PT)-一种记录和重构每条指令执行时间戳的设施。它的主要用途是事后分析和查找性能故障。
上述功能从CPU角度提供了有关程序效率及如何使其更适合CPU的见解。性能分析工具利用它们提供许多不同类型的性能分析。
##### **6.1 Top-Down Microarchitecture Analysis //TMA**
TMA是一种非常强大的技术,用于识别程序中CPU的瓶颈。它是一个健壮和正式的方法,即使对于经验不足的开发人员也很容易使用。该方法最好的部分是它不需要开发人员对系统中的微架构和PMC有深入的了解,仍然能够有效地找到CPU的瓶颈。但是,它并不会自动解决问题;否则,这本书也不会出现。

Figure 28: The concept behind TMA's top-level breakdown. © Image from \[Yasin, 2014
在高层次上,TMA确定了程序中每个热点执行所遇到的障碍。瓶颈可能与四个组件之一有关:前端瓶颈,后端瓶颈,退役和错误预测。图28阐明了这个概念。以下是如何阅读此图表的简短指南。从第3节中我们所知道的,CPU中有内部缓冲区来跟踪正在执行的指令的信息。每当新指令被提取和解码时,将分配这些缓冲区的新条目。如果某个指令的uop在特定的执行周期内未被分配,原因可能是我们无法提取和解码它(前端瓶颈),或者后端被过载且无法分配新的uop工作和资源(后端瓶颈)。已分配并安排执行但未退役的uop与错误预测桶有关。这样的uop的一个示例可以是某个被推测执行但后来被证明是在错误的程序路径上,没有退役的指令。最后,退役是我们希望所有uop都达到的目标,尽管也有例外情况。对于非矢量化代码来说,高退役值可能是用户将代码矢量化的好提示(请参见第8.2.3节)。在程序操作denormal浮点值使得此类操作非常缓慢的情况下,我们可能会看到高退役值但总体性能较慢的情况(请参见第10.4节)。
Figure 29: The TMA hierarchy of performance bottlenecks. © Image by Ahmad Yasin.
图28针对程序中的每个指令进行了详细的分析。然而,分析工作负载中的每个单独指令肯定是过度的,当然,TMA也不会这样做。相反,我们通常对整个程序被阻塞的原因感兴趣。为了实现这个目标,TMA通过收集特定的指标(PMC的比率)来观察程序的执行。基于这些指标,它将应用程序与四个高级桶之一相关联。每个高级桶都有嵌套类别(参见图29),可以更好地分解程序中的CPU性能瓶颈。我们多次运行工作负载,每次关注特定的指标并逐层分析,直到得出更详细的性能瓶颈分类。例如,最初,我们收集四个主要桶的指标:前端瓶颈、后端瓶颈、退役、错误预测。假设我们发现程序执行的大部分被内存访问阻塞了(这是一个后端瓶颈,见图29)。下一步是再次运行工作负载,并仅收集与Memory Bound桶相关的指标(逐层分析)。这个过程重复进行,直到我们知道确切的根本原因,例如L3 Bound。
non-stalled stalled解释下:
在计算机领域,非停顿(non-stalled)是指CPU正在执行指令的状态,没有被任何阻止因素所影响。而停顿(stalled)则是指CPU在执行中遇到了某些阻止因素,无法继续前进,需要等待某些条件满足后才能继续执行。CPU停顿时会浪费宝贵的时间,导致程序执行速度变慢。
在现实世界的应用中,性能可能会受到多个因素的限制。例如,程序可能同时遇到大量分支预测错误(Bad Speculation)和缓存未命中(Back End Bound)等问题。在这种情况下,TMA将同时对多个桶进行逐层分析,并确定每种性能瓶颈对程序性能的影响。像Intel VTune Profiler、AMD uprof和Linux perf等分析工具可以在单次运行基准测试时计算出所有指标。
TMA指标的前两级以可用于程序执行的所有管道插槽的百分比表示(见第4.5节)。它允许TMA提供CPU微架构利用率的准确表示,考虑到处理器的全部带宽。
在确定了程序的性能瓶颈后,我们会想知道它具体发生在代码的哪一行。TMA的第二阶段是跟踪性能问题的源头,直到精确定位到代码的确切行和汇编指令。分析方法提供了准确的PMC,供每类性能问题使用。然后,开发人员可以使用该PMC来查找在由第一阶段确定的最关键性能瓶颈所在的源代码区域。这种对应关系可以在"Locate-with"列的TMA指标表中找到。例如,在Intel Skylake处理器上运行的应用程序中定位与高DRAM_Bound指标相关的性能瓶颈,应该在MEM_LOAD_RETIRED.L3_MISS_PS性能事件上进行采样。
//下面有些还是没分清,先记录着
用simpleperf分析CPU Backend Bound,可以采样哪些事件?
在使用Simpleperf分析CPU后端瓶颈时,可以采样以下事件:
cpu-cycles:记录CPU时钟周期,这是一种通用的事件,可以用于衡量CPU活跃度。
instructions:记录已经执行的指令数,可以与CPU周期事件一起使用来计算每个指令的平均周期数。
cache-references:记录对缓存的所有引用操作数量。
cache-misses:记录未能从缓存中找到数据而导致的缓存访问错误的数量。
branch-misses:记录分支预测错误的数量。
page-faults:记录由于缺失页表项而导致的缺页故障数量。
context-switches:记录进程上下文切换的数量。
以上这些事件可以帮助开发者分析程序CPU Backend Bound。例如,通过分析 cache-misses 事件,可以确定程序中哪些部分频繁地出现缓存未命中,导致 CPU 等待缓存访问而降低了性能。开发者可以根据这些信息来优化程序和代码结构,提高程序性能。
用simpleperf分析CPU Fronted Bound,可以采样哪些事件?
在使用Simpleperf分析CPU Frontend Bound时,可以采样以下事件:
instructions: 记录已经执行的指令数,用于衡量CPU前端活跃度。
branch-instructions: 记录所有分支指令的执行次数。
branch-misses: 记录因为分支预测错误而导致的不必要指令流失处理器流水线的次数。
L1-icache-load-misses: 记录由于读取L1指令缓存未命中而导致的L1缓存加载开销。
front-end-stalls: 记录因为CPU前端问题而导致的CPU空转或停顿的时间。
以上这些事件可以帮助开发者分析程序的 CPU 前端瓶颈。例如,通过分析 branch-misses 事件,可以确定程序中哪些部分会经常导致分支预测错误,从而导致CPU浪费时间等待正确的代码路径。开发者可以根据这些信息来优化程序和代码结构,以提高性能并降低CPU前端瓶颈。
用simpleperf分析CPU Bad Speculation,可以采样哪些事件?
在使用Simpleperf分析 CPU Bad Speculation 时,可以采样以下事件:
branch-misses:记录分支预测错误的数量,这是最常用的 Bad Speculation 事件。
misalignment-faults:记录由于内存地址对齐问题导致的缺陷故障(Alignment Fault)的数量。
bus-cycles:记录由于总线竞争而浪费的时钟周期数。
stalled-cycles-frontend:记录因为CPU前端瓶颈而导致的CPU空转或停顿的时钟周期数。
stalled-cycles-backend:记录因为CPU后端瓶颈而导致的CPU空转或停顿的时钟周期数。
cycles:记录CPU时钟周期数,这是一个通用的事件,可以用于衡量程序执行时间及CPU活跃度。
以上这些事件可以帮助开发者分析CPU Bad Speculation。例如,通过分析 branch-misses 事件,可以确定程序中哪些部分经常出现分支预测错误,从而导致性能损失。开发者可以根据这些信息来优化程序和代码结构,以减少 Bad Speculation 事件并提升程序性能。
用simpleperf分析CPU Retiring,可以采样哪些事件?
在使用Simpleperf分析 CPU Retiring 时,可以采样以下事件:
instructions:记录已经执行的指令数,用于衡量CPU效率。
cpu-cycles:记录CPU时钟周期数,这是一个通用的事件,可以用于衡量CPU活跃度。
branches:记录已经执行的所有分支指令数目。
branch-misses:记录因为分支预测错误而导致的不必要指令流失处理器流水线的次数。
stalled-cycles-backend: 记录因为后端瓶颈而导致的CPU空转或停顿的时钟周期数。
retired-instructions:记录已经从CPU中成功退役(Retire)并完成处理的指令数目。
以上这些事件可以帮助开发者分析程序的CPU Retiring情况。例如,通过分析 retired-instructions 事件,可以确定程序中哪些指令顺利完成了退役,从而优化应用程序的效率和性能。开发者可以根据这些信息来优化程序和代码结构,以提高Retiring情况并提升程序性能。
6.1.1 TMA in Intel® VTune™ Profifiler
Figure 30: Intel VTune Profifiler "Microarchitecture Exploration" analysis.
Figure 31: "Microarchitecture Exploration" Bottom-up view.
Figure 32: "Microarchitecture Exploration" source code and assembly view.
TMA可通过最新的Intel VTune Profiler中的"微架构探索"分析功能进行特征分析。图30显示了7-zip基准测试的分析摘要。从图表中可以看出,由于CPU Bad Speculation,特别是误预测的分支,导致了大量的执行时间浪费。
这个工具的美妙之处在于,您可以点击您感兴趣的指标,工具将带您到显示对该特定指标做出贡献的顶级函数的页面。例如,如果您点击Bad Speculation指标,您将看到类似于图31所示的内容。
从那里,如果您双击LzmaDec_DecodeReal2函数,Intel® VTune™ Profiler将带您进入源代码级别视图,如图32所示。突出显示的行导致了LzmaDec_DecodeReal2函数中分支预测错误数量最多。
6.1.2 TMA in Linux Perf //重点
从Linux内核4.8开始,perf有一个选项--topdown,用于在perf stat命令中打印TMA一级指标,即只有四个高级桶:
bash
复制代码
$ perf stat --topdown -a -- taskset -c 0 ./7zip-benchmark b
retiring bad speculat FE bound BE bound
S0-C0 30.8% 41.8% 8.8% 18.6% <==
S0-C1 17.4% 2.3% 12.0% 68.2%
S0-C2 10.1% 5.8% 32.5% 51.6%
S0-C3 47.3% 0.3% 2.9% 49.6%
...
simpleperf中有类似perf中--topdown的指令么?
是的,simpleperf 中也有类似于 perf 的 --topdown 选项。通过在 simpleperf stat 命令中使用 --sort comm,dso,topdown 选项,可以按照 TMA 的层次结构进行排序,输出针对每个区段的时间、CPU 循环数、Retired 指令、缓存失效等信息,提供了一种快速分析程序性能的工具。该选项可用于快速查看和诊断程序中的性能瓶颈,并确定哪些函数最大程度地影响了程序的整体性能。
为了获取高级的 TMA 指标值,Linux perf 需要对整个系统进行剖析(-a 选项)。这就是为什么我们会看到所有核心的度量值。但是,由于我们使用 taskset -c 0 将基准测试固定在 core0 上,因此只需关注与 S0-C0 对应的第一行即可。
要获取 Top-Down 的二级、三级等指标,可以使用 toplev 工具。toplev 工具是 pmu-tools 中的一部分,由 Andi Kleen 编写,它是用 Python 实现的,底层调用 Linux perf。您将在下一节中看到使用它的示例。必须启用特定的 Linux 内核设置才能使用 toplev,请查看文档以获取更多详细信息。为了更好地呈现工作流程,下一节提供了一个逐步说明如何使用 TMA 来改善面向内存的应用程序性能的示例。
个人经验: Intel® VTune™ Profiler 是一种非常强大的工具,毫无疑问。然而,对于快速实验,我经常使用 Linux perf,因为它在我工作的每个 Linux 发行版上都可用。因此,下一节中的示例使用 Linux perf 进行探索。
https://github.com/andikleen/pmu-tools/wiki/toplev-manual
https://github.com/andikleen/pmu-tools
//整体思路是根据toplev先判断出CPU中哪个部分出现问题比较大,然后找出这个部分对应的perf cmd,然后分析汇编和源代码,最后优化code!!!
//toplev是python写的,手机上可以运行么?
//用simpleperf分析Memory Bound,可以采样哪些事件?
在使用 simpleperf 分析面向内存的应用程序时,可以采样以下事件:
以上事件可提供有关程序在内存中读取和写入数据时发生的系统事件的信息。通过收集这些事件的指标,可以确定性能瓶颈和优化机会。选择哪些事件采样取决于特定的应用程序场景和目标。在进行采样之前,建议先进行实验和基准测试,以确定应用程序的性能瓶颈和最佳优化策略。
//Intel CPU的perf MEM_LOAD_RETIRED.L3_MISS_PS指令,ARM CPU上simpleperf的指令是什么?
在 ARM CPU 上,可以使用 simpleperf 工具采样 L3 缓存未命中事件,对应的指令是 `mem-loads,l3-cache-misses`。该指令旨在计算所有内存加载操作且 L3 缓存未命中的次数,并可以帮助分析面向内存的应用程序的性能瓶颈。
以下是针对 ARM CPU 的示例命令:
bash
复制代码
```
sudo simpleperf stat -e mem-loads,l3-cache-misses program
```
这将对 `program` 应用程序进行采样,并显示包括 L3 缓存未命中事件在内的指定事件的计数、占总时间的百分比等信息。需要注意的是,`mem-loads,l3-cache-misses` 指令可能不适用于所有 ARM CPU 架构,具体情况需要根据特定的硬件和系统配置来判断。
6.1.3 Step1: Identify the bottleneck
假设我们有一个小型的基准测试程序(a.out),运行时间为 8.5 秒。这个基准测试程序的完整源代码可以在 GitHub 上找到。
bash
复制代码
$ time -p ./a.out
real 8.53
作为第一步,我们运行我们的应用程序并收集特定的度量指标,以帮助我们对它进行表征,即我们试图检测我们的应用程序属于哪个类别。以下是我们基准测试程序的一级度量指标:
bash
复制代码
$ ~/pmu-tools/toplev.py --core S0-C0 -l1 -v --no-desc taskset -c 0 ./a.out
...
# Level 1
S0-C0 Frontend_Bound: 13.81 % Slots
S0-C0 Bad_Speculation: 0.22 % Slots
S0-C0 Backend_Bound: 53.43 % Slots <==
S0-C0 Retiring: 32.53 % Slots
请注意,该进程被固定到 CPU0 上(使用 `taskset -c 0` 命令),并且 toplev 的输出仅限于此核心(`--core S0-C0`)。通过查看输出,我们可以判断应用程序的性能受到 CPU 后端的限制。而现在不需要分析它,让我们深入了解一级细节:
bash
复制代码
$ ~/pmu-tools/toplev.py --core S0-C0 -l2 -v --no-desc taskset -c 0 ./a.out
...
# Level 1
S0-C0 Frontend_Bound: 13.92 % Slots
S0-C0 Bad_Speculation: 0.23 % Slots
S0-C0 Backend_Bound: 53.39 % Slots
S0-C0 Retiring: 32.49 % Slots
# Level 2
S0-C0 Frontend_Bound.FE_Latency: 12.11 % Slots
S0-C0 Frontend_Bound.FE_Bandwidth: 1.84 % Slots
S0-C0 Bad_Speculation.Branch_Mispred: 0.22 % Slots
S0-C0 Bad_Speculation.Machine_Clears: 0.01 % Slots
S0-C0 Backend_Bound.Memory_Bound: 44.59 % Slots <==
S0-C0 Backend_Bound.Core_Bound: 8.80 % Slots
S0-C0 Retiring.Base: 24.83 % Slots
S0-C0 Retiring.Microcode_Sequencer: 7.65 % Slots
我们看到应用程序的性能受到内存访问的限制。将近一半的 CPU 执行资源被浪费在等待内存请求完成上。现在让我们深入挖掘一层:
bash
复制代码
$ ~/pmu-tools/toplev.py --core S0-C0 -l3 -v --no-desc taskset -c 0 ./a.out
...
# Level 1
S0-C0 Frontend_Bound: 13.91 % Slots
S0-C0 Bad_Speculation: 0.24 % Slots
S0-C0 Backend_Bound: 53.36 % Slots
S0-C0 Retiring: 32.41 % Slots
# Level 2
S0-C0 FE_Bound.FE_Latency: 12.10 % Slots
S0-C0 FE_Bound.FE_Bandwidth: 1.85 % Slots
S0-C0 BE_Bound.Memory_Bound: 44.58 % Slots
S0-C0 BE_Bound.Core_Bound: 8.78 % Slots
# Level 3
S0-C0-T0 BE_Bound.Mem_Bound.L1_Bound: 4.39 % Stalls
S0-C0-T0 BE_Bound.Mem_Bound.L2_Bound: 2.42 % Stalls
S0-C0-T0 BE_Bound.Mem_Bound.L3_Bound: 5.75 % Stalls
S0-C0-T0 BE_Bound.Mem_Bound.DRAM_Bound: 47.11 % Stalls <==
S0-C0-T0 BE_Bound.Mem_Bound.Store_Bound: 0.69 % Stalls
S0-C0-T0 BE_Bound.Core_Bound.Divider: 8.56 % Clocks
S0-C0-T0 BE_Bound.Core_Bound.Ports_Util: 11.31 % Clocks
我们发现瓶颈在 DRAM_Bound 中。这告诉我们许多内存访问在所有缓存层次上都未命中并且直接到达主存储器。如果我们收集了该程序的 L3 缓存未命中(DRAM 命中)的绝对数,也可以加以确认。对于 Skylake 架构,DRAM_Bound 指标使用 CYCLE_ACTIVITY.STALLS_L3_MISS 性能事件进行计算。让我们来收集它:
bash
复制代码
$ perf stat -e cycles,cycle_activity.stalls_l3_miss -- ./a.out
32226253316 cycles
19764641315 cycle_activity.stalls_l3_miss
根据 CYCLE_ACTIVITY.STALLS_L3_MISS 的定义,它计算的是当执行被卡住时(即 L3 缓存未命中且尚未完成加载操作)发生的周期数。我们可以看到大约有 60% 的这种周期,这很糟糕。
6.1.4 Step2: Locate the place in the code
作为 TMA 过程的第二步,我们将定位代码中瓶颈最频繁出现的位置。为此,我们应该使用与在第 1 步中确定的瓶颈类型相对应的性能事件对工作负载进行采样。建议找到这种事件的一种方法是使用 toplev 工具并使用 --show-sample 选项,这将提供可用于定位问题的 perf record 命令行。为了理解 TMA 的机制,我们还提供了手动找到与特定性能瓶颈相关联的事件的方法。在本章早些时候介绍的 TMA metrics120 表中,性能瓶颈和应该用于定位此类瓶颈发生位置的性能事件之间的对应关系可以得到。 Locate-with 列指示应该使用哪种性能事件来定位代码中确切的问题出现位置。对于我们的例子,在找到导致 DRAM_Bound 指标(L3 缓存未命中)值过高的内存访问时,应使用 MEM_LOAD_RETIRED.L3_MISS_PS 精确事件进行采样,如上面的清单所示:
bash
复制代码
$ perf record -e cpu/event=0xd1,umask=0x20,name=MEM_LOAD_RETIRED.L3_MISS/ppp
./a.out
$ perf report -n --stdio
...
# Samples: 33K of event 'MEM_LOAD_RETIRED.'L3_MISS
# Event count (approx.): 71363893
# Overhead
Samples
Shared Object
Symbol
# ........
......... .................
.................
#
99.95% 33811 a.out [.] foo
0.03% 52 [kernel] [k] get_page_from_freelist
0.01% 3 [kernel] [k] free_pages_prepare
0.00% 1 [kernel] [k] free_pcppages_bulk
大多数 L3 未命中是由可执行文件 a.out 中的函数 foo 中的内存访问引起的。为避免编译器优化,函数 foo 采用汇编语言实现,如清单11所示。基准测试的"驱动程序"部分在主函数中实现,如清单12所示。我们分配了足够大的数组 a,以使其不适合 L3 缓存。基准测试基本上生成一个随机索引到数组 a,并将其与数组 a 的地址一起传递给 foo 函数。稍后,foo 函数会读取这个随机的内存位置。
Listing 11 Assembly code of function foo.
bash
复制代码
$ perf annotate --stdio -M intel foo
Percent | Disassembly of a.out for MEM_LOAD_RETIRED.L3_MISS
------------------------------------------------------------
: Disassembly of section .text:
:
: 0000000000400a00 <foo>:
: foo():
0.00 : 400a00: nop DWORD PTR [rax+rax*1+0x0]
0.00 : 400a08: nop DWORD PTR [rax+rax*1+0x0]
...
100.00 : 400e07: mov rax,QWORD PTR [rdi+rsi*1] <==
...
0.00 : 400e13: xor rax,rax
0.00 : 400e16: ret
Listing 12 Source code of function main.
cpp
复制代码
extern "C" { void foo(char* a, int n); }
const int _200MB = 1024*1024*200;
int main() {
char* a = (char*)malloc(_200MB); // 200 MB buffer
...
for (int i = 0; i < 100000000; i++) {
int random_int = distribution(generator);
foo(a, random_int);
}
...
}
从清单11中可以看出,函数 foo 中的所有 L3 缓存未命中都标记为单个指令。既然我们知道哪个指令引起了这么多 L3 未命中,让我们来修复它。
6.1.5 Step3: Fix the issue
因为在获取下一个将要访问的地址和实际读取指令之间存在一个时间窗口,所以我们可以添加预取提示,如清单13所示。有关内存预取的更多信息,请参见第8.1.2节。这个提示将执行时间提高了2秒,速度提高了30%。请注意,在 CYCLE_ACTIVITY.STALLS_L3_MISS 事件中的值少了10倍:
Listing 13 Inserting memory prefetch into main.
cpp
复制代码
for (int i = 0; i < 100000000; i++) {
int random_int = distribution(generator);
+ __builtin_prefetch ( a + random_int, 0, 1);
foo(a, random_int);
}
bash
复制代码
$ perf stat -e cycles,cycle_activity.stalls_l3_miss -- ./a.out
24621931288 cycles
2069238765 cycle_activity.stalls_l3_miss
6,498080824 seconds time elapsed
TMA 是一个迭代过程,因此现在我们需要从步骤1开始重复该过程。可能会将瓶颈移动到其他 bucket 中,在本例中是 Retiring。这是一个演示 TMA 方法工作流程的简单示例。分析实际应用程序不太可能那么容易。本书的下一个完整章节是按照方便与TMA过程一起使用的方式组织的(7~11章节)。例如,其各个部分分解为反映每个高级别性能瓶颈类别的内容。这种结构背后的想法是提供某种检查列表,开发人员可以在发现性能问题后使用它来推动代码更改。例如,当开发人员发现他们正在处理的应用程序是内存限制时,他们可以参考第8.1节中的想法。
6.1.6 Summary
Figure 33: Top Level TMA metrics for SPEC CPU2006. © Image by Ahmad Yasin, http:// cs.hai fa.ac.il/ ~yosi/PARC/ yasin.pdf .
TMA 对于识别代码中的 CPU 性能瓶颈非常有效。理想情况下,当我们在某个应用程序上运行它时,希望看到 Retiring 指标达到 100%。这意味着该应用程序完全饱和了 CPU。在玩具程序上可以接近这个结果。然而,在现实世界中,实现这个目标还有很长的路要走。图33显示了Skylake CPU代的SPEC CPU2006124基准测试的顶级TMA指标。请记住,由于架构师不断努力改进 CPU 设计,这些数字可能会因其他 CPU 代变化。这些数字也可能会因其他指令集架构(ISA)和编译器版本的变化而发生变化。
不建议在具有重大性能缺陷的代码上使用 TMA,因为这可能会引导您走向错误的方向,而不是解决真正的高级性能问题,您将调整不好的代码,这只是浪费时间。同样,确保环境不会干扰分析。例如,如果您删除文件系统缓存并在 TMA 下运行基准测试,它可能会显示应用程序受到内存限制,而实际上,当文件系统缓存已经取暖时,可能是错误的。
由 TMA 提供的工作负载特征化可以将潜在优化的范围扩展到源代码以外。例如,如果应用程序受到内存限制,并且已经检查了所有可能的软件水平加速方式,则可以通过使用更快的内存来改进内存子系统。这样可以进行有教育意义的实验,因为只有在发现程序受到内存限制并且将从更快的内存中受益时,才会花钱。
在撰写本文时,AMD 处理器的 TMA 指标的第一级也可以使用。
Additional resources and links:
这是一些关于性能分析和计数器体系结构的顶部方法的参考资料:
• Ahmad Yasin 的论文"一种自上而下的性能分析和计数器体系结构方法"[Yasin,2014]。
• Ahmad Yasin 在 IDF'15 上的演讲"通过 Skylake 上的自上而下分析简化软件优化",URL: https://youtu.be/kjufVhyuV_A。
• Andi Kleen 的博客 - pmu-tools,第二部分:toplev,URL: http://halobates.de/blog/p/262。
• toplev 手册,URL: https://github.com/andikleen/pmu-tools/wiki/toplev-manual。
• 了解 Intel® VTune™ Profiler 中通用探索如何工作,URL: https://software.intel.com/en-us/articles/understanding-how-general-exploration-works-in-intel-vtune-amplifier-xe。
6.2 Last Branch Record //最后分支记录
现代的 Intel 和 AMD CPU 都具有称为最后分支记录(LBR)的功能,在其中CPU会持续记录许多先前执行的分支。但在深入了解详情之前,人们可能会问:为什么我们如此关注分支?这是因为这是我们能够确定程序控制流的方式。在基本块中,我们主要忽略其他指令(请参见第7.2节),因为分支总是基本块中的最后一个指令。由于基本块中的所有指令都保证至少被执行一次,因此我们只能关注"代表"整个基本块的分支。因此,如果我们跟踪每个分支的结果,就可以重构程序的整个逐行执行路径。实际上,这就是 Intel 处理器跟踪(PT)功能能够执行的操作,这将在第6.4节中讨论。 LBR 功能早于 PT,并具有不同的用例和特殊功能。
Figure 34: 64-bit Address Layout of LBR MSR. © Image from [Int, 2020].
由于 LBR 机制,CPU 可以在执行程序的同时不影响性能地持续记录分支到一组特定模型的寄存器(MSR),从而引起最小的减速。硬件会记录每个分支的"来自"和"去往"地址以及一些附加的元数据(见图34)。这些寄存器就像一个环形缓冲区,不断被覆盖,并且只提供最近的32个分支结果。如果我们收集足够长的源目标对的历史记录,我们就可以像有限深度的调用堆栈一样展开程序的控制流程。
有了 LBR,我们可以对分支进行采样,但在每个样本中,要查看已执行的 LBR 堆栈中的先前分支。这在热代码路径中可以提供合理的控制流覆盖,但不会给我们带来太多信息,因为只检查了总分支的较小的数量。需要记住的重要一点是,这仍然是采样,因此不能检查每个执行的分支。 CPU通常执行速度太快,无法进行检查。[Kleen, 2016]
• 最后分支记录(LBR)堆栈-由于 Skylake 提供了32对存储最近执行分支的源和目标地址的 MSR。
• 最后分支记录堆栈顶部(TOS)指针-包含指向包含最近记录的分支、中断或异常的 LBR 栈中的 MSR 的指针。
需要非常注意的是,只有被执行的分支才会使用 LBR 机制记录下来。下面是一个示例,展示了如何在 LBR 堆栈中跟踪分支结果。
以下是我们在执行 CALL 指令时所期望在 LBR 堆栈中看到的内容。由于 JNS 分支(4eda14 -> 4eda1e)没有被执行,因此没有被记录下来,因此不会出现在 LBR 堆栈中:
bash
复制代码
FROM_IP TO_IP
... ...
4eda2d 4eda10
4eda1e 4edb26 <== LBR TOS
个人经验:未采取的分支未被记录可能会增加一些额外的分析负担,但通常不会使其过于复杂。我们仍然可以展开 LBR 堆栈,因为我们知道控制流是从 TO_IP(N-1)顺序执行到 FROM_IP(N) 的。
从 Haswell 开始,LBR 输入增加了用于检测分支错误预测的组件。LBR 记录中有一个专用位用于它(请参见[Int, 2020,卷3B,第17章])。自 Skylake 起,还向 LBR 记录中添加了额外的 LBR_INFO 组件,其中包含 Cycle Count 字段,该字段计算自上一次更新 LBR 堆栈以来经过的核心时钟数。这些增加的组件有重要的应用,我们将在后面进行讨论。特定处理器的 LBR 记录的确切格式可以在 [Int,2020,卷3B,第17,18章] 中找到。
用户可以通过执行以下命令来确保其系统启用了 LBR:
bash
复制代码
$ dmesg | grep -i lbr
[ 0.228149] Performance Events: PEBS fmt3+, 32-deep LBR, Skylake events,
full-width counters, Intel PMU driver.
6.2.1 Collecting LBR stacks
使用 Linux perf,可以使用以下命令收集 LBR 堆栈:
bash
复制代码
$ ~/perf record -b -e cycles ./a.exe
[ perf record: Woken up 68 times to write data ]
[ perf record: Captured and wrote 17.205 MB perf.data (22089 samples) ]
使用perf record --call-graph lbr命令也可以收集LBR堆栈,但所收集的信息量比使用perf record -b要少。例如,当运行perf record --call-graph lbr时,不会收集分支预测失误和周期数据。由于每个收集的样本都捕获了整个LBR堆栈(32个最新的分支记录),因此所收集的数据(perf.data)的大小要比不使用LBR采样要大得多。下面是Linux perf命令,可以用来转储所收集的分支堆栈的内容:
bash
复制代码
$ perf script -F brstack &> dump.txt
如果我们查看dump.txt文件的内容(它可能很大),我们将看到类似于下面所示的东西:
bash
复制代码
...
0x4edabd/0x4edad0/P/-/-/2 0x4edaf9/0x4edab0/P/-/-/29
0x4edabd/0x4edad0/P/-/-/2 0x4edb24/0x4edab0/P/-/-/23
0x4edadd/0x4edb00/M/-/-/4 0x4edabd/0x4edad0/P/-/-/2
0x4edb24/0x4edab0/P/-/-/24 0x4edadd/0x4edb00/M/-/-/4
0x4edabd/0x4edad0/P/-/-/2 0x4edb24/0x4edab0/P/-/-/23
0x4edadd/0x4edb00/M/-/-/1 0x4edabd/0x4edad0/P/-/-/1
0x4edb24/0x4edab0/P/-/-/3 0x4edadd/0x4edb00/P/-/-/1
0x4edabd/0x4edad0/P/-/-/1 0x4edb24/0x4edab0/P/-/-/3
...
在上面的文本块中,我们展示了LBR堆栈的八个条目,该堆栈通常由32个LBR条目组成。每个条目都有FROM和TO地址(十六进制值)、预测标志(M/P)127以及一定数量的周期数(每个条目的最后一个位置上的数字)。带有"-"标记的组件与事务性内存(TSX)相关,这里我们不会讨论。感兴趣的读者可以查阅perf脚本规范中解码LBR条目的格式。
使用LBR有许多重要的用途。在接下来的章节中,我们将介绍其中最重要的几个。
6.2.2 Capture call graph //捕获调用图
在5.4.3节中,已经讨论了收集调用图及其重要性。即使您使用编译器选项-fomit-frame-pointer(默认为ON)或没有调试信息,也可以使用LBR来收集调用图信息:
-fomit-frame-pointer是GCC的编译选项之一,该选项可以让编译器在生成可执行文件时省略掉函数调用过程中的帧指针。这样可以减小可执行文件的大小并提高程序的运行效率,但是这也会影响到程序的调试。
正如你所看到的,我们确定了程序中最热门的函数(即bar函数)。此外,我们还发现了调用bar函数所耗费时间最多的调用者(即foo函数)。在这种情况下,我们可以看到在bar函数中有91%的样本与其调用函数foo相关。使用LBR特性,我们可以确定一个超级块(Hyper Block,有时也称为超级块),它是整个程序中执行最频繁的基本块链。该链中的基本块不一定按顺序排列,但它们是按顺序执行的。
6.2.3 Identify hot branches
LBR特性还可以让我们知道哪些分支被最频繁地执行:
从这个例子中,我们可以看到超过50%的分支在bar函数内,22%的分支是来自于foo到bar的函数调用等等。请注意,perf从周期事件切换到分析LBR堆栈:仅收集了670个样本,但是我们有一个完整的LBR堆栈与每个样本一起捕获。这为我们提供了670 * 32 = 21440个LBR条目(分支结果)进行分析。大多数情况下,我们可以根据代码行和目标符号确定分支的位置。然而,理论上,代码可能会有两个if语句写在同一行上。此外,在扩展宏定义时,所有扩展的代码都具有相同的源行,这也是可能发生这种情况的另一种情况。这个问题并不完全阻碍分析,但只会使其变得更加困难。为了消除两个分支之间的歧义,您可能需要自己分析原始的LBR堆栈(请参见easyperf博客上的示例)。
6.2.4 Analyze branch misprediction rate
还可以知道热门分支的预测错误率:
在这个例子中,与LzmaDec函数对应的行特别引起我们的兴趣。使用第6.2.3节的推理,我们可以得出源代码行dec.c:36上的分支是基准测试中执行最多的分支。在Linux Perf提供的输出中,我们可以发现两个与LzmaDec函数相对应的条目:一个带有Y字母,一个带有N字母。分析这两个条目可以给我们提供分支的预测错误率。在本例中,我们知道dec.c:36行上的分支被预测了303391次(对应于N),其中41665次预测错误(对应于Y),这给我们提供了88%的预测率。
Linux Perf通过分析每个LBR条目并从中提取预测错误位来计算预测错误率。因此,对于每个分支,我们都有一个正确预测的次数和错误预测的次数。同样,由于采样的性质,有些分支可能具有N条目但没有相应的Y条目。这可能意味着该分支没有被预测错误的LBR条目,但并不一定意味着预测率等于100%。
6.2.5 Precise timing of machine code //机器码的精确计时
正如第6.2节所述,从Skylake架构开始,LBR条目具有Cycle Count信息。该附加字段为我们提供了在两个已采取的分支之间经过的周期数。如果前一个LBR条目中的目标地址是某个基本块(BB)的开头,并且当前LBR条目的源地址是相同基本块的最后一条指令,则循环计数是此基本块的延迟时间。例如:
bash
复制代码
400618: movb $0x0, (%rbp,%rdx,1) <= start of a BB
40061d: add $0x1, %rdx
400621: cmp $0xc800000, %rdx
400628: jnz 0x400644 <= end of a BB
假设我们在LBR堆栈中有两个条目:
bash
复制代码
FROM_IP TO_IP Cycle Count
... ... ...
40060a 400618 10
400628 400644 5 <== LBR TOS
Figure 35: Probability density function for latency of the basic block that starts at address 0x400618.
根据这些信息,我们知道基本块从偏移量400618开始执行时有一次在5个周期内完成的情况。如果我们收集足够的样本,可以绘制该基本块延迟时间的概率密度函数(见图35)。此图表是通过分析满足上述规则的所有LBR条目编制的。例如,基本块只在约75个周期内执行4%的时间,但更常见的是在260到314个周期之间执行。该块中有一个随机的数组加载,该数组无法适配CPU L3缓存,因此基本块的延迟很大程度上取决于此加载。图35中显示了两个重要峰值:第一个峰值约为80个周期,对应于L3缓存命中,而第二个峰值约为300个周期,对应于L3缓存未命中,其中加载请求一直到达主存储器。
这些信息可以用于进一步微调此基本块。例如,可以使用内存预取技术来优化此示例,我们将在第8.1.2节中讨论。此外,这些周期信息可以用于计时循环迭代,其中每个循环迭代均以已采取的分支(反向边)结束。
关于如何为任意基本块构建延迟时间的概率密度函数的示例可以在easyperf博客中找到。但是,在较新版本的Linux perf中,获取此信息要容易得多。
为了使输出适合页面,删除了几行不重要的信息。现在,如果我们专注于源和目标是dec.c:174的分支,则可以找到与之关联的多个行。Linux perf首先按开销对条目进行排序,这需要我们手动过滤我们感兴趣的分支的条目。实际上,如果我们过滤它们,就会得到以该分支结束的基本块的延迟时间分布,如表5所示。稍后用户可以绘制此数据并获得类似于图35的图表。
目前,定时LBR是系统中最精确的按周期计时信息源。
6.2.6 Estimating branch outcome probability //估计分支结果概率
稍后在第7节中,我们将讨论代码布局对性能的重要性。更进一步地说,以穿过方式提供热路径通常会提高程序的性能。考虑单个分支,知道该条件99%的时间是假还是真对编译器做出更好的优化决策至关重要。LBR功能使我们能够在不插桩代码的情况下获得此数据。作为分析结果,用户将获得条件真实和假的结果之间的比率,即分支被执行的次数和未执行的次数。此功能在分析间接跳转(switch语句)和间接调用(虚拟调用)时特别有用。可以在easyperf博客上找到在真实应用程序中使用它的示例。
6.2.7 Other use cases
• 面向性能的配置。LBR功能可为优化编译器提供配置反馈数据。在考虑运行时开销时,与静态代码插装相比,LBR可能是更好的选择。
• 捕获函数参数。当LBR特性与PEBS一起使用时(参见第6.3节),可以捕获函数参数,因为根据x86调用约定,调用者的前几个参数落在寄存器中,这些寄存器被PEBS记录所捕获。【Int,2020年,附录B,第B.3.3.4章】
• 基本块执行计数。由于在LBR堆栈中分支IP(源)和之前的目标之间执行所有基本块恰好一次,因此可以评估程序内部基本块的执行率。这个过程涉及构建每个基本块起始地址的映射,然后向后遍历收集的LBR堆栈。【Int,2020年,附录B,第B.3.3.4章】
6.3 Processor Event-Based Sampling //处理器事件采样
处理器事件采样(PEBS)是CPU中的另一个非常有用的功能,提供了许多不同的方式来增强性能分析。与最后分支记录(参见第6.2节)类似,在对程序进行分析时使用PEBS来捕获每个收集样本的附加数据。在英特尔处理器中,PEBS功能是在NetBurst微架构中引入的。AMD处理器上类似的功能称为基于指令的采样(IBS),从Family 10h核心(代号为" Barcelona"和" Shanghai")开始提供。
一组附加数据具有定义的格式,称为PEBS记录。当为PEBS配置性能计数器时,处理器将保存PEBS缓冲区的内容,随后存储到内存中。记录包含处理器的架构状态,例如通用寄存器(EAX,EBX,ESP等)的状态,指令指针寄存器(EIP),标志寄存器(EFLAGS)等。不同实现支持PEBS的PEBS记录布局在内容布局方面有所不同。有关枚举PEBS记录格式的详细信息,请参见[Int, 2020,卷3B,第18.6.2.4章处理器事件基础采样(PEBS)]。英特尔Skylake CPU的PEBS记录格式如图36所示。
Figure 36: PEBS Record Format for 6th Generation, 7th Generation and 8th Generation Intel
Core Processor Families. © Image from [Int, 2020, Volume 3B, Chapter 18].
用户可以通过执行dmesg命令来检查PEBS是否已启用:
bash
复制代码
$ dmesg | grep PEBS
[ 0.061116] Performance Events: PEBS fmt1+, IvyBridge events, 16-deep
LBR, full-width counters, Intel PMU driver.
与LBR不同,Linux perf不能像LBR一样导出原始PEBS输出。相反,它处理PEBS记录并根据特定需求提取仅子集数据。因此,无法使用Linux perf访问原始PEBS记录的集合。虽然,Linux perf提供了从原始样本中处理的一些PEBS数据,可以通过perf report -D访问。要转储原始的PEBS记录,可以使用pebs-grabber工具。
PEBS机制在性能监测方面带来了许多好处,我们将在下一节中讨论。
6.3.1 Precise events //精确事件
在分析性能时,一个主要的问题是确定导致特定性能事件的确切指令。如第5.4节所述,基于中断的采样基于计数特定的性能事件,并等待直到它溢出。当发生溢出中断时,处理器需要一定的时间来停止执行并标记导致溢出的指令。这对于现代复杂的乱序CPU架构尤其困难。
它引入了滑动(skid)的概念,其定义为导致事件的IP与事件被标记的IP之间的距离(在PEBS记录内的IP字段中)。滑动使得难以发现实际导致性能问题的指令。考虑一个具有大量缓存未命中和热门汇编代码的应用程序:
bash
复制代码
; load1
; load2
; load3
分析器可能会将load3归因为导致大量缓存未命中的指令,而实际上,应该归咎于load1指令。这通常会给初学者带来很多困惑。有兴趣的读者可以在英特尔开发者社区网站上了解更多关于此类问题的根本原因。
通过让处理器本身在PEBS记录中存储指令指针(以及其他信息),可以缓解skid所带来的问题。PEBS记录中的EventingIP字段指示引起事件的指令。这需要硬件支持,并且通常仅适用于被称为"Precise Events"的一组支持的事件子集。特定微体系结构的精确事件列表可以在[Int, 2020, Volume 3B, Chapter 18]中找到。下面列出了Skylake微架构的精确事件:
bash
复制代码
INST_RETIRED.*
OTHER_ASSISTS.*
BR_INST_RETIRED.*
BR_MISP_RETIRED.*
FRONTEND_RETIRED.*
HLE_RETIRED.*
RTM_RETIRED.*
MEM_INST_RETIRED.*
MEM_LOAD_RETIRED.*
MEM_LOAD_L3_HIT_RETIRED.*
其中.*表示该组内的所有子事件都可以配置为精确事件。TMA方法(见第6.1节)在定位代码执行效率低下的源头时严重依赖于精确事件。通过使用精确事件减轻滑动的例子可以在easyperf博客中找到。Linux perf的用户应该给事件添加ppp后缀以启用精确标记:
bash
复制代码
$ perf record -e cpu/event=0xd1,umask=0x20,name=MEM_LOAD_RETIRED.L3_MISS/ppp
-- ./a.exe
6.3.2 Lower sampling overhead //降低采样开销
频繁生成中断并且分析工具本身捕获中断服务例程内的程序状态非常昂贵,因为它涉及操作系统交互。这就是为什么一些硬件允许自动多次采样到专用缓冲区而不需要任何中断。只有当专用缓冲区满时,处理器才会引发中断,并且将缓冲区刷新到内存中。这比传统的基于中断的采样具有更低的开销。
当性能计数器配置为PEBS时,计数器溢出条件将启动PEBS机制。在溢出后的下一个事件上,在处理器将生成一个PEBS事件。在PEBS事件上,处理器将PEBS记录存储在PEBS缓冲区域中,清除计数器溢出状态并重新加载计数器的初始值。如果缓冲区已满,则CPU会引发中断。[Int, 2020,Volume 3B, Chapter 18]请注意,PEBS缓冲区本身位于主内存中,其大小是可配置的。同样,性能分析工具的工作是为CPU分配和配置内存区域,以便能够将PEBS记录转储到其中。
6.3.3 Analyzing memory accesses
内存访问是许多应用程序性能的关键因素。通过PEBS,可以收集关于程序中内存访问的详细信息。实现这一功能的特性被称为数据地址分析(Data Address Profiling)。为了提供有关采样加载和存储的附加信息,它利用了PEBS设施内部的以下字段(参见图36):
如果性能事件支持数据线性地址(DLA)机制,并且已启用,CPU将转储采样的内存访问的内存地址和延迟值。请记住,这个功能并不追踪所有的存储和加载操作,否则开销将会很大。相反,它在内存访问上进行采样,即仅分析每1000次访问中的一次。可以根据需要自定义每秒采样数的数量。
PEBS扩展中最重要的用例之一是检测真/假共享(True/False sharing)[145],我们将在第11.7节中讨论。Linux perf c2c工具在查找可能出现真/假共享的有争议内存访问时,大量依赖DLA数据。
此外,借助数据地址分析,用户可以获取程序中关于内存访问的一般统计信息:
bash
复制代码
$ perf mem record -- ./a.exe
$ perf mem -t load report --sort=mem --stdio
# Samples: 656
of event 'cpu/mem-loads,ldlat=30/P'
# Total weight : 136578
# Overhead Samples Memory access
# ............................................
44.23% 267 LFB or LFB hit
18.87% 111 L3 or L3 hit
15.19% 78 Local RAM or RAM hit
13.38% 77 L2 or L2 hit
8.34% 123 L1 or L1 hit
根据输出结果,我们可以看到应用程序中有8%的加载操作命中了L1缓存,15%来自DRAM等等。
6.4 Intel Processor Traces
英特尔处理器追踪(Intel Processor Traces,简称PT)是一项CPU功能,通过以高度压缩的二进制格式编码数据包来记录程序执行情况,并在每条指令上附加时间戳,可以用于重构执行流程。PT具有广泛的覆盖范围和相对较小的开销[146],通常低于5%。它的主要用途是进行事后分析和定位性能故障的根本原因。
6.4.1 Workflow
与采样技术类似,PT不需要对源代码进行任何修改。您只需在支持PT的工具下运行程序以收集追踪数据。一旦启用了PT并启动了基准测试,分析工具就会开始将跟踪数据包写入DRAM。
Figure 37: Intel Processor Traces encoding
与LBR类似,Intel PT通过记录分支来工作。在运行时,每当CPU遇到分支指令时,PT将记录该分支的结果。对于简单的条件跳转指令,CPU将使用1位记录是否跳转(T)或不跳转(NT)。对于间接调用,PT将记录目标地址。需要注意的是,我们会忽略无条件分支,因为我们在静态分析中已经知道它们的目标地址。
以下是一个小指令序列的编码示例,如图37所示。像PUSH、MOV、ADD和CMP这样的指令被忽略,因为它们不改变控制流程。然而,JE指令可能会跳转到.label,所以需要记录其结果。之后有一个间接调用,需要保存目标地址。
Figure 38: Intel Processor Traces decoding
在分析时,我们将应用程序二进制文件和收集的PT追踪数据结合起来。软件解码器需要应用程序二进制文件以重构程序的执行流程。它从入口点开始,然后使用收集的追踪数据作为查找参考来确定控制流程。图38展示了Intel Processor Traces的解码示例。
假设PUSH指令是应用程序二进制文件的入口点。然后,PUSH、MOV、ADD和CMP指令将按原样重构,而无需查看编码的追踪数据。然后,软件解码器遇到了JE指令,它是一个条件分支,我们需要查找其结果。根据图38中的追踪数据,JE被执行(T),因此我们跳过下一个MOV指令并转到CALL指令。同样,CALL(edx)是一个改变控制流程的指令,因此我们在编码的追踪数据中查找目标地址,即0x407e1d8。在我们的程序运行时,以黄色高亮显示的指令被执行。请注意,这是对程序执行的精确重构;我们没有跳过任何指令。随后,我们可以使用调试信息将汇编指令映射回源代码,并记录一行接一行执行的源代码日志。
6.4.2 Timing Packets //时序数据包
使用Intel PT,不仅可以追踪执行流程,还可以获得时序信息。除了保存跳转目标外,PT还可以发出时序数据包。图39展示了如何使用时序数据包来恢复指令的时间戳。与前面的示例类似,首先我们可以看到JNZ没有被执行,因此我们将它和上面的所有指令的时间戳更新为0纳秒。然后我们看到一个2纳秒的时间更新和JE被执行,所以我们将它和JE上面(以及JNZ下面)的所有指令的时间戳更新为2纳秒。之后,有一个间接调用,但没有附带时序数据包,所以我们不会更新时间戳。然后我们看到100纳秒经过了,JB没有被执行,所以我们将它上面的所有指令的时间戳更新为102纳秒。
Figure 39: Intel Processor Traces timings
在图39中显示的示例中,指令数据(控制流)是完全准确的,但时序信息不太准确。显然,CALL(edx)、TEST和JB指令并不是同时发生的,然而我们没有更准确的时序信息来描述它们。有时间戳可以使我们将程序的时间间隔与系统中的其他事件对齐,并且可以轻松与挂钟时间进行比较。在某些实现中,跟踪的时序信息可以通过精确的周期模式进一步提高,其中硬件在正常数据包之间记录周期计数(更多详细信息参见[Int,2020,Volume 3C,Chapter 36])。
6.4.3 Collecting and Decoding Traces
可以使用Linux perf工具轻松收集Intel PT跟踪数据:
bash
复制代码
$ perf record -e intel_pt/cyc=1/u ./a.out
在上述命令行中,我们要求PT机制每个时钟周期更新时序信息。但是,由于时序数据包只会在与其他控制流数据包配对时发送,因此这可能不会极大地提高准确性(参见第6.4.2节)。
收集完成后,可以通过执行以下命令获取原始的PT跟踪数据:
bash
复制代码
$ perf report -D > trace.dump
PT在发出时序数据包之前会捆绑多达6个条件分支。自从Intel Skylake CPU一代以来,时序数据包记录的是与上一个数据包相对的周期计数。如果我们查看trace.dump文件,可能会看到类似以下的内容:
bash
复制代码
000073b3: 2d 98 8c TIP 0x8c98 // target address (IP)
000073b6: 13 CYC 0x2 // timing update
000073b7: c0 TNT TNNNNN (6) // 6 conditional branches
000073b8: 43 CYC 0x8 // 8 cycles passed
000073b9: b6 TNT NTTNTT (6)
在上面,我们展示了原始的PT数据包,这对于性能分析来说并不是非常有用。要将处理器跟踪解码为可读形式,可以执行以下操作:
bash
复制代码
$ perf script --ns --itrace=i1t -F time,srcline,insn,srccode
下面是可能得到的解码跟踪的示例:
bash
复制代码
timestamp srcline instruction srccode
...
253.555413143: a.cpp:24 call 0x35c foo(arr, j);
253.555413143: b.cpp:7 test esi, esi for (int i = 0; i <= n; i++)
253.555413508: b.cpp:7 js 0x1e
253.555413508: b.cpp:7 movsxd rsi, esi
...
上面只显示了执行日志的一小部分。在这个日志中,我们记录了程序运行时执行的每条指令的跟踪信息。我们可以逐步观察程序的每一步操作。这为进一步的分析提供了非常坚实的基础。
6.4.4 Usages
以下是 PT 在以下情况下可能有用的几个案例:
分析性能故障。由于 PT 捕获了整个指令流,因此可以分析在应用程序无响应的短时间内发生了什么。在 easyperf 博客的一篇文章中可以找到更详细的示例。
后期调试。PT 跟踪数据可以由传统的调试器(如 gdb)进行回放。除此之外,PT 还提供了调用栈信息,即使堆栈已损坏,这些信息也始终有效。可以在远程机器上收集 PT 跟踪数据,然后进行离线分析。这在问题难以复现或系统访问受限时特别有用。
检查程序的执行情况。
即使考虑到跟踪数据的压缩格式,编码数据可能会占用大量的磁盘空间。通常情况下,每条指令的编码数据少于1字节,但考虑到CPU执行指令的速度,仍然非常大。根据工作负载的不同,CPU对PT进行编码的速度通常为100MB/s。解码后的跟踪数据可能会是编码数据的十倍(约1GB/s)。这使得在长时间运行的工作负载上使用PT变得不切实际。但是,在小型工作负载上运行一小段时间是可以接受的,即使工作负载很大。在这种情况下,用户可以只在故障发生的时间段内附加到运行中的进程。或者他们可以使用循环缓冲区,其中新的跟踪数据将覆盖旧的数据,即始终保留最近大约10秒的跟踪数据。
用户可以通过多种方式进一步限制收集。他们可以只限制收集用户/内核空间代码的跟踪数据。此外,还有一个地址范围过滤器,可以动态选择性地开启和关闭跟踪以限制内存带宽。这使我们可以跟踪单个函数甚至单个循环。
解码 PT 跟踪数据可能需要很长时间。在一个搭载 Intel Core i5-8259U 的机器上,对于运行时间为7毫秒的工作负载,编码后的 PT 跟踪数据约占用1MB的磁盘空间。使用 perf script 解码这条跟踪数据大约需要20秒。perf script -F time,ip,sym,symoff,insn 的解码输出占用约1.3GB的磁盘空间。
个人经验:Intel PT被认为是性能分析的终极解决方案。由于其低运行时开销,它是一个非常强大的分析功能。然而,目前(2020年2月),使用'perf script -F'命令和'+srcline'或'+srccode'选项来解码跟踪数据的速度非常慢,不适合日常使用。Linux perf的实现可能需要改进。Intel VTune Profiler对于PT的支持仍然是实验性的。
参考资料和链接:
• Intel出版物《处理器跟踪》:[链接](https://software.intel.com/en-us/blogs/2013/09/18/processor-tracing )
• Intel® 64和IA-32体系结构软件开发人员手册[Int, 2020,卷3C,第36章]
• 白皮书《硬件辅助的指令分析和延迟检测》[Sharma和Dagenais,2016]
• Andi Kleen在LWN上的文章:[链接](https://lwn.net/Articles/648154 )
• Intel PT微教程:[链接](https://sites.google.com/site/intelptmicrotutorial/ )
• simple_pt:用于Linux的简单Intel CPU处理器跟踪工具:[链接](https://github.com/andikleen/simple-pt/ )
• Linux内核中的Intel PT文档:[链接](https://github.com/torvalds/linux/blob/master/tools/perf/Documentation/intel-pt.txt )
• Intel处理器跟踪速查表:[链接](http://halobates.de/blog/p/410 )
这些参考资料和链接提供了关于Intel PT的更多信息和工具,以及与之相关的开发和调试技术。希望对您有所帮助!如果您有更多问题,请随时提问。
6.5 Chapter Summary
• 推荐在解决了所有高级性能问题之后,才利用硬件功能进行低级调优。对于设计不良的算法进行调优是一种错误的投资开发者时间的做法。一旦消除了所有主要性能问题,就可以使用CPU性能监测功能来分析和进一步调优应用程序。
• Top-Down微体系结构分析(TMA)方法是一种非常强大的技术,用于识别程序对CPU微体系结构的无效使用。这是一种健壮且正式的方法,即使对于经验不足的开发人员来说也很容易使用。TMA是一个迭代的过程,包括多个步骤,包括对工作负载进行表征,并定位瓶颈出现的确切位置在源代码中。我们建议TMA应该是每个低级调优努力的分析起点。TMA在Intel和AMD处理器上都可用。
• 最后分支记录(LBR)机制在执行程序的同时连续记录最近的分支结果,造成最小的减速。它使我们能够为我们收集的每个分析样本具有足够深的调用堆栈。此外,LBR还有助于识别热分支、错误预测率,并允许对机器码进行精确计时。LBR在Intel和AMD处理器上都受支持。
• 处理器事件采样(PEBS)功能是另一种用于分析的增强功能。它通过自动在专用缓冲区中多次进行采样来降低采样开销,无需中断。然而,PEBS更广为人知的是引入了"精确事件",可以精确定位导致特定性能事件的确切指令。该功能在Intel处理器上受支持。AMD CPU具有类似功能称为基于指令采样(IBS)。
• Intel处理器跟踪(PT)是一种CPU功能,通过以高度压缩的二进制格式记录程序执行,可以用于在每条指令上重构执行流程,并带有时间戳。PT具有广泛的覆盖范围和相对较小的开销。其主要用途是事后分析和查找性能故障的根本原因。基于ARM架构的处理器也具有称为CoreSight的跟踪能力,但主要用于调试而不是性能分析。
性能分析工具利用本章介绍的硬件功能实现了许多不同类型的分析。
//目前看,能借鉴的是TMA和LBR!!!