CPU性能分析与优化(三)

2024-08-07 16:06:25 浏览数 (2)

本章讲性能分析中的术语和指标。如果略过本章节,很难看懂linux perf 或者 intel vTune。Linux perf 是一个性能分析器,您可以使用它来查找程序中的热点、收集各种低级 CPU 性能事件、分析调用堆栈以及许多其他事情。为什么暂时没有使用vTune,因为vTune基于GUI,隐藏了复杂性。

已退役(Retired) vs. 已执行(Executed)指令

对于大多数指令,CPU 在结果可用后立即提交结果,并且所有先前的指令均已停用。但对于推测执行的指令,CPU 会保留其结果而不立即提交其结果。当推测被证明是正确的时,CPU 会解除对此类指令的阻塞并正常进行。但是,当推测结果是错误的时,CPU 会丢弃推测指令所做的所有更改,并且不会撤销它们。

因此,CPU 处理过的指令可以执行,但不一定会退出。考虑到这一点,通常预期执行的指令数量会高于退休指令的数量

有一个例外。某些指令被识别为idioms并在没有实际执行的情况下得到解决。这方面的一些例子是 NOP、move elimination 和 zeroing。此类指令不需要执行单元,但仍会retire。因此,理论上,可能存在退休指令数高于执行指令数的情况。

置零(Zeroing):为了将零赋值给一个寄存器,编译器通常使用XOR / PXOR / XORPS / XORPD等指令,例如XOR RAX, RAX,编译器更喜欢使用这些指令,而不是等效的MOV RAX, 0x0指令,因为XOR编码使用的编码字节较少。这种置零习惯用法不会像其他常规指令一样执行,而是在CPU前端解析,这样可以节省执行资源。指令随后像通常一样被退役(retires)。 移动消除(Move elimination):类似于前一个,寄存器到寄存器的mov操作,例如MOV RAX, RBX,可以在零周期延迟内执行。

大多数现代处理器都有一个性能监视计数器(PMC),用于收集已退役指令的数量。虽然没有性能事件来收集已执行的指令,但有一种方法可以收集已执行和已退役的微操作。

使用perf 获取已退役指令的数量

代码语言:javascript复制
$ perf stat -e instructions ./a.exe
  2173414  instructions  #    0.80  insn per cycle 
# 或者简单地执行:
$ perf stat ./a.exe

CPU利用率

CPU利用率是在一段时间内CPU处于忙碌状态的百分比。从技术上讲,当CPU不运行内核的idle线程时,CPU被认为是忙碌。

CPU_UTIL= CPU_CLK_UNHALTED.REF_TSC / TSC

CPU_CLK_UNHALTED.REF_TSC计算了核心处于非停顿状态时的参考周期数,TSC代表时间戳计数器。

如果CPU利用率低,通常意味着应用程序性能较差,因为CPU浪费了一部分时间。然而,高CPU利用率并不总是高性能。这仅仅是系统正在进行一些工作的迹象,但并不表示正在做什么:即使CPU由于等待内存访问而被阻塞,它仍然可能被高度利用。在多线程环境中,线程在等待资源继续进行时也可以自旋。这些都是高利用率,但是性能低下。

perf会自动计算系统上所有CPU的CPU利用率:

代码语言:javascript复制
$ perf stat -- a.exe
  0.634874  task-clock (msec) #    0.773 CPUs utilized

CPI 和 IPC

每周期指令数 (IPC) - 平均每个周期完成的指令数。

IPC = INST_RETIRED.ANY / CPU_CLK_UNHALTED.THREAD

其中,INST_RETIRED.ANY 计算已完成指令的数量,CPU_CLK_UNHALTED.THREAD 计算线程不在stall状态时的核心周期数。

每条指令周期数 (CPI) - 平均执行一条指令所需的周期数。

CPI = 1/IPC

本书的主要作者更喜欢使用IPC,因为它更容易比较。使用 IPC,我们希望每个周期尽可能多地执行指令,因此 IPC 越高越好。使用CPI则相反:我们希望每个指令的周期越少越好,所以 CPI 越低越好。使用“越高越好”的指标进行比较更简单。

IPC 和 CPU 时钟频率之间的关系非常有趣。从广义上讲,性能 = 工作 / 时间,我们可以将工作表示为指令数,时间表示为秒。程序运行的秒数是总周期 / 频率

性能 = 指令x频率/周期 = IPC x 频率

从基准测试的角度来看,IPC 和频率是两个独立的指标。许多工程师错误地将它们混为一谈,认为如果增加频率,IPC 也会上升。但事实并非如此,增加频率,IPC 将保持不变

如果将处理器的时钟设置为 1 GHz 而不是 5Ghz,仍然会拥有相同的 IPC。这非常令人困惑,尤其是因为 IPC 与 CPU 时钟密切相关。频率只告诉单个时钟的快慢,而 IPC 不考虑时钟变化的速度,它计算每个周期完成的工作量。因此,从基准测试的角度来看,IPC 完全取决于处理器的设计,与频率无关。乱序内核通常具有比顺序内核更高的 IPC。当增加 CPU 缓存的大小或改进分支预测时,IPC 通常会上升。

但是对于硬件架构师,他们眼中的IPC 和频率之间存在依赖关系。从 CPU 设计的角度来看,可以故意降低处理器时钟频率,这将使每个周期更长,并且可以在每个周期中塞入更多工作。最终,将获得更高的 IPC 但更低的频率。

硬件供应商以不同的方式处理性能公式。例如,英特尔和 AMD 芯片通常具有非常高的频率,最近的英特尔 13900KS 处理器开箱即用即可提供 6Ghz 的睿频频率,无需超频。另一方面,Apple M1/M2 芯片的频率较低,但通过更高的 IPC 进行补偿。较低的频率有助于降低功耗。另一方面,更高的 IPC 通常需要更复杂的设计、更多的晶体管和更大的芯片尺寸。

perf可以通过运行以下命令测量其工作负载的 IPC:

代码语言:javascript复制
$ perf stat -e cycles,instructions -- a.exe
2369632 cycles
1725916 instructions # 0,73 insn per cycle
# 或更简单地:
$ perf stat ./a.exe

微操作

x86 架构的微处理器将复杂的 CISC 类指令转换为简单的 RISC 类微操作,缩写为μops。例如,像ADD rax, rbx这样的简单加法指令只会生成一个μop,而更复杂的指令比如ADD rax, [mem]可能生成两个:一个用于从mem内存位置读取到临时(未命名)寄存器,另一个用于将其添加到rax寄存器。指令ADD [mem], rax会生成三个μops:一个用于从内存读取,一个用于相加,一个用于将结果写回内存。

将指令分割成微操作的主要优点是μops 可以执行 乱序 、并行、指令融合、宏融合。(前两个是分割,后两个是融合)

乱序: 考虑 PUSH rbx 指令,它将栈指针减少 8 字节,然后将源操作数存储在栈顶。假设在解码后 PUSH rbx 被“破解”成两个依赖的微操作:

代码语言:javascript复制
SUB rsp, 8
STORE [rsp], rbx
代码语言:javascript复制

下一个PUSH指令可以在前一个PUSH指令的SUBμop 完成后开始执行,而不必等待STOREμop。

并行: 考虑 HADDPD xmm1, xmm2 指令,它将在 xmm1xmm2 中对两个双精度浮点数进行求和,并将两个结果存储在 xmm1 中,如下所示:

代码语言:javascript复制
xmm1[63:0] = xmm2[127:64]   xmm2[63:0]
xmm1[127:64] = xmm1[127:64]   xmm1[63:0]
代码语言:javascript复制

微代码化此指令的一种方法是执行以下操作:

1) 去掉xmm2并将结果存储在xmm_tmp1[63:0]中,

2) 去掉xmm1并将结果存储在xmm_tmp2[63:0]中,

3) 将xmm_tmp1xmm_tmp2合并到xmm1中。总共三个μops。

步骤 1) 和 2) 是独立的,因此可以并行完成。(如果是基于寄存器还行,如果加入内存操作,就得不偿失)

指令融合: 融合来自同一机器指令的 μops。指令融合只能应用于两种类型的组合:内存写操作和读改操作。例如:

代码语言:javascript复制
add eax, [mem]
代码语言:javascript复制

这条指令中有两个μops:

1) 读取内存位置mem

2) 将其添加到eax

宏融合: 融合来自不同机器指令的μops。在某些情况下,解码器可以将算术或逻辑指令与 subsequent 条件跳转指令融合成单个计算和分支μop。例如

代码语言:javascript复制
.loop:
  dec rdi
  jnz .loop
代码语言:javascript复制

使用宏融合,将来自DECJNZ指令的两个 μops 融合成一个。

指令融合和宏融合都可以节省从解码到退休的所有管道阶段的带宽。融合操作在重新排序缓冲区 (ROB) 中共享单个条目。当一个融合的 μop 只使用一个条目时,ROB 的容量得到更好的利用。这样的一个融合的 ROB 条目稍后会分派到两个不同的执行端口,但作为单个单元再次退休。

要收集应用程序发出的、执行的和退休的 μops 数量,可以使用 perf,如下所示:

代码语言:javascript复制
$ perf stat -e uops_issued.any,uops_executed.thread,uops_retired.slots -- ./a.exe
2856278 uops_issued.any
2720241 uops_executed.thread
2557884 uops_retired.slots
代码语言:javascript复制

从中能看出执行的指令数量 > 退休的指令数量。

指令被分解成微操作的方式可能会随着 CPU 的不同而有所差异。通常,用于一条指令的μops 数量越少,意味着硬件对其支持越好,并且可能具有更低的延迟和更高的吞吐量。

对于最新的 Intel 和 AMD CPU,绝大多数指令都会生成恰好一个μop。有关最近微架构中 x86 指令的延迟、吞吐量、端口使用情况和μops 数量,可以参考 https://uops.info/table.html网站。

pipeline slot


另一个一些性能工具使用的重要指标是管道槽 (pipeline slot)的概念。管道槽代表处理一个微操作所需的硬件资源。

下图展示了一个每周期有 4 个分配槽的 CPU 的执行管道。这意味着核心可以在每个周期将执行资源(重命名的源和目标寄存器、执行端口、ROB 条目等)分配给 4 个新的微操作。这样的处理器通常被称为4 宽机器。在图中连续的六个周期中,只利用了一半可用槽位。从微架构的角度来看,执行此类代码的效率只有 50%。

英特尔的 Skylake 和 AMD Zen3 内核具有 4 宽分配。英特尔的 SunnyCove 微架构采用 5 宽设计。截至 2023 年,最新的 Goldencove 和 Zen4 架构都采用 6 宽分配。Apple M1 的设计没有官方披露,但测得为 8 宽

管道槽是自顶向下微架构分析的核心指标之一。例如,前端受限和后端受限指标由于各种瓶颈而表示为未使用的管道槽的百分比。

核心周期与参考周期

大多数CPU都使用时钟信号来控制它们的顺序操作。时钟信号由外部发生器产生,每秒提供一致数量的脉冲。时钟脉冲的频率决定了CPU执行指令的速率。因此,时钟越快,CPU每秒执行的指令就越多。

大多数现代CPU,包括英特尔和AMD的CPU,没有固定的运行频率。相反,它们实现了动态频率缩放,在英特尔的CPU中称为Turbo Boost,在AMD处理器中称为Turbo Core。它使CPU能够动态增加和减少频率。降低频率可以减少功耗,但会牺牲性能,增加频率可以提高性能,但会牺牲节能。

perf可以查看频率

代码语言:javascript复制
$ perf stat -e cycles,ref-cycles ./a.exe
  43340884632  cycles        # 3.97 GHz
  37028245322  ref-cycles    # 3.39 GHz
      10.899462364 seconds time elapsed

指标ref-cycles统计的周期数是如果没有频率缩放的情况下。设置的外部时钟频率为100 MHz,如果乘以时钟倍频,我们将得到处理器的基础频率。Skylake i7-6000处理器的时钟倍频为34:这意味着对于每个外部脉冲,当CPU运行在基础频率上时,它执行34个内部周期。

指标cycles统计的是真实的CPU周期数,即考虑了频率缩放。使用上述公式,我们可以确认平均运行频率为43340884632个周期 / 10.899秒 = 3.97 GHz。当比较两个版本的小段代码的性能时,以时钟周期计时比以纳秒计时更好,因为避免了时钟频率上下波动的问题。

缓存失效


首先了解访问各个部件所需要的时间。

网址在这里 https://colin-scott.github.io/personal_website/research/interactive_latency.html

缓存失效可能会发生在指令和数据上。根据Top-down Microarchitecture Analysis,指令缓存(I-cache)失效被定义为前端停顿,而数据缓存(D-cache)失效被定义为后端停顿。指令缓存失效在CPU流水线的早期阶段(指令获取阶段)发生。数据缓存失效则发生在后期,即指令执行阶段。

perf通过运行以下命令来收集L1缓存失效的数量:

代码语言:javascript复制
$ 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
代码语言:javascript复制

以上是针对L1数据缓存和填充缓冲区的所有加载操作的细分。加载操作可能命中已分配的填充缓冲区(fb_hit),或者命中L1缓存(l1_hit),或者两者都未命中(l1_miss),因此all_loads = fb_hit l1_hit l1_miss。我们可以看到,只有3.5%的所有加载操作在L1缓存中未命中,因此L1命中率为96.5%。

进一步分析L1数据缺失并分析L2缓存行为,方法是运行:

代码语言:javascript复制
$ 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
代码语言:javascript复制

从这个例子中,我们可以看到,在L1 D-cache中缺失的加载操作中有37%也在L2缓存中缺失,因此L2命中率为63%。以类似的方式,可以对L3缓存进行细分。

这些信息都是从硬件直接读到的。

分支误预测

错误预测的分支通常会导致10到20个时钟周期的惩罚。首先,根据错误预测获取和执行的所有指令都需要从流水线中清除。之后,一些缓冲区可能需要清理,恢复开始的状态。最后,流水线需要等待确定正确的分支目标地址,这会导致额外的执行延迟。

perf 使用如下命令

代码语言:javascript复制
$ perf stat -e branches,branch-misses -- a.exe
   358209  branches
    14026  branch-misses #    3.92% 的分支错误预测率
# 或者简单地执行:
$ perf stat -- a.exe
代码语言:javascript复制

性能指标

内存延迟和带宽

低效的内存访问通常是主要的性能瓶颈,英特尔内存延迟检查器(MLC)在Windows和Linux上都可以免费使用。MLC可以使用不同的访问模式和负载来测量缓存和内存的延迟和带宽。在基于ARM的系统上没有类似的工具,但是用户可以从源代码中下载并构建内存延迟和带宽基准测试。

MLC下载地址 https://www.intel.com/content/www/us/en/download/736633/intel-memory-latency-checker-intel-mlc.html

我们只关注一个子集指标,即空闲读取延迟和读取带宽。MLC通过进行相关加载(也称为指针追踪)来测量空闲延迟。一个测量线程分配一个非常大的缓冲区,并对其进行初始化,以便缓冲区内的每个(64字节)缓存行包含指向该缓冲区内另一个非相邻缓存行的指针。通过适当调整缓冲区的大小,我们可以确保几乎所有的加载都命中某个级别的缓存或主存。

我们的测试系统是一台英特尔Alderlake主机,配备Core i7-1260P CPU和16GB DDR4 @ 2400 MT/s双通道内存。该处理器有4个P(性能)超线程核心和8个E(高效)核心。每个P核心有48KB的L1数据缓存和1.25MB的L2缓存。每个E核心有32KB的L1数据缓存,而四个E核心组成一个集群,可以访问共享的2MB L2缓存。系统中的所有核心都由18MB的L3缓存支持。如果我们使用一个10MB的缓冲区,我们可以确保对该缓冲区的重复访问会在L2中未命中,但在L3中命中。以下是示例 mlc 命令:

代码语言:javascript复制
$ ./mlc --idle_latency -c0 -L -b10m
Intel(R) Memory Latency Checker - v3.10
Command line parameters: --idle_latency -c0 -L -b10m

Using buffer size of 10.000MiB
*** Unable to modify prefetchers (try executing 'modprobe msr')
*** So, enabling random access for latency measurements
Each iteration took 31.1 base frequency clocks (    12.5    ns)

选项--idle_latency测量读取延迟而不加载系统。MLC具有--loaded_latency选项,用于在由其他线程生成的内存流量存在时测量延迟。选项-c0将测量线程固定在逻辑CPU 0上,该CPU位于P核心上。选项-L启用大页以限制我们的测量中的TLB效应。选项-b10m告诉MLC使用10MB缓冲区,在我们的系统上可以放在L3缓存中。

下图是基于MLC获得的L1、L2和L3缓存的读取延迟。图中有四个不同的区域。从1KB到48KB缓冲区大小。(根据下图可以判断各个cache的大小)

左侧的第一个区域对应于L1d缓存,该缓存是每个物理核心私有的。我们可以观察到E核心的延迟为0.9ns,而P核心稍高为1.1ns。此外,我们可以使用此图来确认缓存大小。请注意,当缓冲区大小超过32KB时,E核心的延迟开始上升,但是在48KB之前E核心的延迟保持不变。这证实了E核心的L1d缓存大小为32KB,而P核心的L1d缓存大小为48KB。(其余的大小可以类推)

使用类似的技术,我们可以测量内存层次结构的各个组件的带宽。为了测量带宽,MLC执行的加载请求不会被任何后续指令使用。这允许MLC生成可能的最大带宽。MLC在每个配置的逻辑处理器上生成一个软件线程。每个线程访问的地址是独立的,线程之间没有数据共享。与延迟实验一样,线程使用的缓冲区大小确定了MLC是在测量L1/L2/L3缓存带宽还是内存带宽。

一个基于带宽的架构图如下:

请注意,我们用纳秒测量延迟,用GB/s测量带宽,因此它们还取决于核心运行的频率。在各种情况下,观察到的数字可能不同。例如,假设当仅在系统上以最大睿频运行时,P核心的L1延迟为X,L1带宽为Y。当系统负载满时,我们可能观察到这些指标分别变为1.25X0.75Y。为了减轻频率效应,与其使用纳秒,延迟和度量可以使用核心周期来表示,并归一化为一些样本频率,比如3Ghz。

案例研究:分析四个基准测试的性能指标

主要是 pmu-tools的使用,可以根据测试结果自动生成图表

pmu-tools 地址 https://github.com/andikleen/pmu-tools

如果获得了对应的数据,如何分析性能的瓶颈?下面两张图对应的是4个程序 Blender Stockfish Clang15核cloverleaf的性能数据。

Blender ,P-core, E-core ipc接近,性能不错。L1 L2 L3 MPKI也很低,说明cache 不是瓶颈。Br. Misp. Ratio是分支误预测,2%,查看IpImspredict,每610条指令就有一个误预测,也不算瓶颈。接着看TLB,Code stlb MPKI 核 St stlb MPKI都是0,不是瓶颈。DRAM BW带宽也远低于上限,不是瓶颈。存在不少的AVX指令,说明是浮点运算,且每90条指令就有一个IpSWPF(软件预取)。结论:Blender的性能受到FP计算的限制,偶尔会出现分支误预测。案例 https://weedge.github.io/perf-book-cn/zh/chapters/4-Terminology-And-Metrics/4-11_Case_Study_of_4_Benchmarks_cn.html

总结:学会使用性能分析工具!perf MLC pmu-tools 等,并根据性能数据分析瓶颈。

0 人点赞