“性能”漫谈

Updated on with 0 views and 0 comments

1 什么是性能?

我们一般把性能,定义成响应时间的倒数,也就是:

4270c0617c224656a94bc003298b5628.png

因此,响应时间越短,性能的数值就越大

计算机可能同时运行着好多个程序,CPU实际上不停地在各个程序之间进行切换。CPU还有可能等待从网络或者硬盘去读取数据,我们需要把这些时间给刨除掉,才能准确统计某个程序运行时间,进而去比较两个程序的实际性能。

e289d2bbe57948db9c940b55e2f15d7b.png

我们看一个 time 命令栗子:

163b7a0077494695b9f8900e467fb2e3.png

说明:

  • real,也就是我们说的 Wall Clock Time,是运行程序整个过程中流逝掉的时间
  • user,也就是 CPU 在运行你的程序,在用户态运行指令的时间
  • sys,是 CPU 在运行你的程序,在操作系统内核里运行指令的时间

我们的程序的CPU执行时间的公式如下:

b31a396230274e2b87b5907b40b9514f.png

因此,如果我们想要解决性能问题,其实就是要优化这三个点:

  • 时钟周期时间,就是计算机主频,这个取决于计算机硬件。我们所熟知的摩尔定律就一直在不停地提高我们计算机的主频。

  • 每条指令的平均时钟周期数CPI,就是一条指令到底需要多少 CPU Cycle。CPI的倒数叫作IPC(Instruction Per Clock),也就是一个时钟周期里面能够执行的指令数,代表了 CPU 的吞吐率。一般情况下IPC最多能到1,但是现在用的Intel或者ARM的CPU,能把IPC做到2以上。

  • 指令数,代表执行我们的程序到底需要多少条指令、用哪些指令。很多时候把这个Challenge交给了编译器,比方说,同样的代码编译成计算机指令时候,就有各种不同的表示方式。

2 什么是指令流水线?

假设要执行3条指令:1条整数的加法,需要 200ps;1条整数的乘法,需要 300ps;一条浮点数的乘法,需要 600ps。如果我们是在单指令周期的CPU上运行,最复杂的指令是一条浮点数乘法,那就需要600ps。那这三条指令,都需要 600ps。执行时间对比如下:

6c7e5307ec6d4c9d895c60a0460087a5.png

而流水线架构的核心,就是在前一个指令还没有结束的时候,后面的指令就要开始执行。如果发现后面执行的指令,会对前面执行的指令有数据层面的依赖关系,那最简单的办法就是“再等等”,也就是流水线停顿(Pipeline Stall),或者叫流水线冒泡(Pipeline Bubbling)。

在实践中,各个指令不需要的阶段,并不会直接跳过,而是会运行一次NOP操作,如下图所示:

e6ebcaceeb394c5bbf8f68ba1526627f.png

通过插入一个NOP操作,我们可以使后一条指令的每一个Stage,一定不和前一条指令的同Stage在一个时钟周期执行。这样,就不会发生先后两个指令,在同一时钟周期竞争相同的资源了。

4e3bddfce8004ae7b6f9b75ff7a272b6.png

3 为什么会乱序执行?

有些时候流水线“阻塞”是没有必要的。因为尽管代码生成的指令是顺序的,但是如果后面的指令不需要依赖前面指令的执行结果,完全可以不必等待前面的指令运算完成。看下面这个栗子:

a = b + 1
c = a * 3
x = y * z

27a5c02e29dc4a9ab4f7415268e47f8b.png

如何理解乱序执行呢?其实,从今天软件开发的维度来思考,乱序执行好像是在指令的执行阶段,引入了一个“线程池”。我们下面就来看一看,在CPU里乱序执行的过程究竟是怎样的:

cfc07f04fcd74ec8b96b3e1152311043.png

4 什么是超线程?

在一个物理CPU核心内部,会有双份的PC寄存器、指令寄存器乃至条件码寄存器。这样,这个CPU核心就可以维护两条并行的指令的状态。在外面看起来,似乎有两个逻辑层面的CPU在同时运行。

bb831bda5b32457aa443144d2dd1bad5.png

如果指令之间存在依赖关系,很多时候就不得不把流水线停顿下来,插入很多NOP操作,这种情况下超线程就有发挥的空间了。假设一个线程A的指令在流水线里停顿的时候,CPU的译码器和ALU就空出来了,那么另外一个线程B就可以拿来执行自己的指令。简单来说,超线程是为了充分利用CPU,不让CPU空等。但是,超线程只在特定的应用场景下效果比好,比方说在那些各个线程“等待”时间比较长的应用场景下

5 什么是SIMD?

SIMD,即单指令多数据流(Single Instruction Multiple Data)。ClickHouse引以为傲的向量化执行引擎就用到了SIMD,那为什么SIMD能提升CPU性能呢?这是因为SIMD在获取数据和执行指令的时候,都做到了并行,看一个栗子:

829f93d163684105ae072c2365044e02.png

就以我们上面的程序为例,数组里面的每一项都是一个integer,也就是需要 4 Bytes 的内存空间。Intel在引入SSE指令集的时候,在CPU里面添上了8个 128 Bits 的寄存器。128 Bits 等于 16 Bytes ,也就是说,一个寄存器一次性可以加载4个整数。比起循环分别读取4次对应的数据,时间就省下来了。

0addb6665d854e79ad9a6f2bb166ae74.png

在数据读取到了之后,在指令的执行层面,SIMD也是可以并行进行的。4个整数各自加1,互相之间完全没有依赖,也就没有冒险问题需要处理。只要CPU里有足够多的功能单元FU,能够同时进行这些计算,这个加法就是4路同时并行的,自然也就省下了时间。

6 什么是循环转换?

最重要的两种转换就是循环展开循环分离

6.1 循环展开

循环展开是一种循环转换技术,它试图以牺牲程序二进制码大小为代价来优化程序的执行速度,是一种用空间换时间的优化手段。

循环展开通过减少或消除控制程序循环的指令,来减少计算开销,这种开销包括增加指向数组中下一个索引或者指令的指针算数等。如果编译器可以提前计算这些索引,并且构建到机器代码指令中,那么程序运行时就可以不必进行这种计算。也就是说有些循环可以写成一些重复独立的代码。比如下面这个循环:

e1e06d2b70b04b848cac34c134c3c957.png

上面的代码需要循环删除200次,通过循环展开可以得到下面这段代码:

66d5eb3be4d94fe7a14214b3f0739886.png

这样展开就可以减少循环的次数,每次循环内的计算也可以利用CPU的流水线提升效率。

在Java中,JVM会去评估展开带来的收益,再决定是否进行展开。

6.2 循环分离

循环分离也是循环转换的一种手段。它把循环中一次或多次的特殊迭代分离出来,在循环外执行。举个栗子:

9a9cb8a02c6a4f798e7fb2480746193f.png

可以看出这段代码除了第一次循环 a = 10 以外,其他的情况a都等于 i-1。所以可以把特殊情况分离出去,变成下面这段代码:

5952497a2fdf41c6869abcd76b7bbac2.png

这种等效的转换消除了在循环中对a变量的需求,从而减少了开销。

7 什么是分支预测?

流水线的前两个阶段,也就是取指令(IF)和指令译码(ID)的阶段,一般是不需要停顿的。CPU会在流水线里面直接去取下一条指令,然后进行译码。但是,一旦遇到 if…else 这样的条件分支,或者 for/while 循环,CPU可能会跳转去执行其他指令,而不再是顺序取下一条指令。也就是说这个时候CPU需要等待当前指令执行的结果。来看一个经典的栗子:

99536b5cbae045b58b3f907f484ad66a.png

两个3层循环总数相等,但是上边的3层循环就是比下边的快,为什么?这个差异就来自分支预测,看下图:

9ac01ea6af5c4a34b451b85563cebafa.png

这里引申一个知识点:JVM逆优化

为了提高热点代码(Hot Spot Code)的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT)。热点代码分为两类:

  • 被多次调用的方法
  • 被多次执行的循环体,尽管编译动作是由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象

编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,但当优化的假设不成立,就会出现“罕见陷阱”(Uncommon Trap),这时需要通过逆优化(Deoptimization)退回到解释状态继续执行。看一个栗子:

b9b18983866542758de3086ed0d2e18a.png

一旦在JDK8中出现逆优化问题,可能会严重影响性能。其实,这跟JDK一个 Bug 有关,这个Bug分为两阶段,第一阶段就是低版本的JDK8触发逆优化后,会触发 Deoptimization::Action_none,啥事不干,无限循环的状态,新版本JDK8修复了这个问题,但是依然会导致性能变慢。在JDK 11.0.6好像已经解决了这个Bug。

如果继续使用JDK8,解决办法是修改JVM参数,不让达到阈值:

-XX:ReservedCodeCacheSize=512M
-XX:PerMethodRecompilationCutoff=10000
-XX:PerBytecodeRecompilationCutoff=10000

8 什么是平均负载?

fd2c13b4a8af4e30a60897077cebfc5e.png

平均负载是指单位时间内,系统处于可运行状态不可中断状态的平均进程数,也就是平均活跃进程数,它和CPU使用率并没有直接关系。更准确的说法,平均负载实际上是活跃进程数的指数衰减平均值。内核中用EMA算法来计算平均负载,目的是让平均负载的趋势化更明显。

所谓可运行状态的进程,是指正在使用CPU或者正在等待CPU的进程,也就是我们常用 ps 命令看到的,处于R状态(Running或Runnable)的进程。

不可中断状态的进程则是正处于内核态关键流程中的进程,并且这些流程是不可打断的,比如最常见的是等待硬件设备的I/O响应,也就是我们在 ps 命令中看到的D状态(Uninterruptible Sleep,也称为Disk Sleep)的进程。不可中断状态实际上是系统对进程和硬件设备的一种保护机制

简而言之,平均负载不仅包括了正在使用CPU的进程,还包括等待CPU等待I/O的进程。

那么,在实际生产环境中,平均负载多高时,需要我们重点关注呢?一般地,当平均负载高于CPU数量70%的时候,就应该分析排查负载高的问题了。一旦负载过高,就可能导致进程响应变慢,进而影响服务的正常功能。

现实工作中我们会经常混淆平均负载和CPU使用率的概念,其实两者并不完全对等:

  • CPU密集型进程,大量CPU使用会导致平均负载升高,此时两者一致
  • I/O密集型进程,等待I/O也会导致平均负载升高,此时CPU使用率并不一定高
  • 大量等待CPU的进程调度会导致平均负载升高,此时CPU使用率也会比较高

平均负载高时可能是CPU密集型进程导致,也可能是I/O繁忙导致。具体分析时可以结合 mpstatpidstat 工具辅助分析负载来源。

9 什么是IOWAIT?

top 命令的输出结果里面,有一行是以 %Cpu 开头的。这一行里,有一个叫作 wa 的指标,这个指标就代表着iowait,也就是CPU等待IO完成操作花费的时间占CPU的百分比。

b80d01dfddd347ad9d14f4629b42ea6b.png

一般地,如果iowait很大,那么我们就要去看一看实际的 I/O 操作情况是什么样的。这个时候,可以用 iostat 这个命令,如下图所示:

634ec21940a942138b1860e6a5e8b5bc.png

对iowait常见的误解有两个:

  • 一是误以为iowait表示CPU不能工作的时间。这种误解太低级了,iowait的首要条件就是CPU空闲,既然空闲当然就可以接受运行任务,只是因为没有可运行的进程,CPU才进入空闲状态的。那为什么没有可运行的进程呢?因为进程都处于休眠状态、在等待某个特定事件:比如等待定时器、或者来自网络的数据、或者键盘输入、或者等待I/O操作完成,等等。

  • 二是误以为iowait表示I/O有瓶颈。第二种误解更常见,为什么大家会认为iowait偏高是有I/O瓶颈的迹象呢?他们的理由是:iowait的第一个条件是CPU空闲,也就是说所有的进程都在休眠,第二个条件是仍有未完成的I/O请求,意味着进程休眠的原因是等待I/O,而iowait升高则表明因等待I/O而休眠的进程数量更多了、或者进程因等待I/O而休眠的时间更长了。

我们重点说说第二种误解,来看下面图示:

38c7479ff7da4ab7a309f0db11954345.png

上面这张图演示的是,在I/O完全一样的情况下,CPU忙闲状态的变化就能够影响iowait的大小。

a7e3a387e99946aab8e2dc76d30cf350.png

第二张图,假设CPU的繁忙状况保持不变的条件下,即使iowait升高也不能说明I/O负载加重了。

实际上,iowait所含的信息量非常少,它是一个非常模糊的指标,如果看到iowait升高,还需检查IOPS和吞吐量有没有明显增加,svctm/await/avgqu-sz等指标有没有明显增大,应用有没有感觉变慢,如果都没有,就没什么好担心的。

这里再给大家一个参考:

17586d8eb77f4dfda1a43d42b4a42e70.png

对照上面表格中的参考数据,我们可以简单计算一下,HDD硬盘的IOPS大概是 1s/10ms=100,而SSD硬盘的IOPS可以达到2~4万。

10 什么是DMA?

DMA,即直接内存访问(Direct Memory Access),本质上就是一块独立的芯片,可以认为它是一个协处理器(Co-Processor)。在进行内存和I/O设备的数据传输的时候,我们不再通过CPU来控制数据传输,而直接通过DMA控制器(DMA Controller,简称 DMAC)。

比如说,用千兆网卡或者硬盘传输大量数据的时候,如果都用CPU来搬运的话,肯定忙不过来,这个时候DMAC就派上用场了。当数据传输很慢的时候,DMAC可以等数据到齐了,再发送信号给到CPU去处理,而不是让CPU在那里干等着。

注意,DMAC是在协助CPU,完成对应的数据传输工作。在DMAC控制数据传输的过程中,还是需要CPU的。

除此之外,DMAC其实也是一个特殊的I/O设备,它和CPU以及其他I/O设备一样,通过连接到总线来进行实际的数据传输。不过,DMAC既是一个主设备,又是一个从设备。对于CPU来说,它是一个从设备;对于硬盘这样的IO设备来说呢,它又变成了一个主设备。

如今各种I/O设备越来越多,数据传输的需求越来越复杂,使用的场景各不相同,加之显示器、网卡、硬盘对于数据传输的需求都不一样,所以各个设备里面都有自己的DMAC芯片了。

0ec85d9aa90548b3a208e67c37032d7d.png

一直被Kafka标榜的零拷贝就有用到DMA技术,如下图所示:

1d9dfe3212734b2fa368aa99df7b0470.png


标题:“性能”漫谈
作者:yanghao
地址:http://solo.fancydigital.com.cn/articles/2022/02/18/1645115280410.html