.. Kenneth Lee 版权所有 2016-2020
:Authors: Kenneth Lee :Version: 1.0
在Linux下做性能分析
本Blog开始介绍一下在Linux分析性能瓶颈的基本方法。主要围绕一个基本的分析模型,介 绍perf和ftrace的使用技巧,然后东一扒子,西一扒子,逮到什么说什么,也不一定会严 谨。主要是把这个领域的一些思路和技巧串起来。如果读者来讨论得多,我们就讨论深入 一点,如果讨论得少,那就当作一个提纲给我做一些对外交流用吧。
我们需要有一套完整的方法来发现系统的瓶颈。“瓶颈”这个概念,来自业务目标,不考虑 业务目标就没有“瓶颈”这个说法。从业务目标角度,通常我们的瓶颈出现在业务的通量( Throughput)和时延(Latency)两个问题上。(手机等领域常常还会考虑功耗这个要素, 但那个需要另外的模型,这里暂时忽略)
比如一个MySQL数据库,你从其他机器上对它发请求,每秒它能处理10万个请求,这个就是 通量性能,每个请求的反应时间是0.5ms,这个就是时延性能。
通量和时延是相互相承的两个量,当通量达到系统上限,时延就会大幅提高。我们从下面 这个例子开始来看这个模型:
.. figure:: _static/通讯模型1.png
请求从客户端发到计算机上,花t1的时间,用t2的时间完成计算,然后用t3的时间把结果 送回到客户端,这个时延是t1+t2+t3,如果我们在t3发过来后,发下一个请求,这样系统 的通量就是1/(t1+t2+t3)。
当然,现实中我们不可能使用这样的方法来处理业务请求,这样处理通量肯定是很低的。 因为t2和t1/t3作用在两个不同的运行部件上,完全没有必要让他们串行(在同一个会话中 有必要,但整体上没有必要。其原理就是CPU流水线)。所以这个模型应该这样来实现(图 2):
.. figure:: _static/通讯模型2.png
t2变成一个队列的处理,这样时延还是会等于t1+t2+t3,但t2的含义变了。只要调度能平 衡,而且认为通讯管道的流量无限,通量可以达到1/t2(和CPU流水线中,一个节拍可以执 行一条指令的原理相同)。1/t2是CPU清队列库存的速度,数据无限地供给到队列上,如果 瞬时队列的长度是len,CPU处理一个包的时间是ti,t2=len*ti,如果请求无限上升,len 就会越来越长,时延就会变大,所以,通常我们对队列进行流控,流控有很多方法,反压 丢包都可以,但最终,当系统进入稳态的时候,队列的长度就会维持在一个稳态,这时的 t2就是可以被计算的了。
对于上面这样一个简单模型,如果CPU占用率没有到100%,时延会稳定在len*ti。len其实 就等于接收线程(或者中断)一次收入的请求数。但如果CPU达到100%,队列的长度就无限 增加,时延也会跟着无限增加。所以我们前面说,当通量达到上限的时候,时延会无限增 加,直到发生流控。
现代CPU是一个多核多部件系统,这种队列关系在整个系统中会变得非常复杂,比如,它有 可能是这样的(图3):
.. figure:: _static/通讯模型3.png
虽然很多系统看起来不是这样一个接一个队列的模型,但其实如果你只考虑主业务流,几 乎大部分情形都是这样的
对这样的系统,我们仍有如下结论:系统的时延等于路径上所有队列的len[i]*t[i]的和。 其实你会发现,这个模型和前一个简单模型本质上并没有区别。只是原来的原则作用在了 所有的队列上。
也就是说:如果CPU没有占满,队列也没有达到流控,则时延会稳定在len[i]*t[i]上。我 们只要保证输入短可以供数据到系统中即可
这个结论为我们的性能优化提供了依据。就是说,我们可以先看CPU是否满载,满载就优化 CPU,没有满载就找流控点,通过流控点调整时延和通量就可以了。
在多核的情况下,CPU无法满载还会有一个原因,就是业务线程没有办法在多个CPU上展开 ,这需要通过增加处理线程来实现。
我们一般看系统是从“模块”的角度来看的,但看系统的性能模型,我们是从线程的角度来 看的。
这里的线程是广义线程,表示所有CPU可以调度以使用自己的执行能力的实体,包括一般意 义的线程,中断,signal_handler等。
好比前面的MySQL的模型,请求从网卡上发送过来。首先是中断向量收到包请求,然后是 softirq收包,调用napi的接口来收包,比如napi_schedule。这时调用进入网卡框架层了 ,但线程上下文还是没有变,napi_schedule回过头调用网卡的polling函数,网卡收包, 通过napi_skb_finish()一类的函数向IP层送包,然后顺着比如 netif_receive_skb_internal->netif_receive_skb->netif_receive_skb_core->deliver_skb->... 这样的路径一路进去到某种类型的socket buffer中,这个过程跨越多个模块,跨越多个协 议栈的层,甚至在极端情况下可以跨越内核态和用户态。但我们仍认为是一个线程搬移, 是softirq线程把网卡上的buffer,搬移到CPU socket buffer的一个过程。调整网卡和 socket buffer之间的大小,流控时间,部署给这些队列的线程的数目和调度时间,就可以 调整好整个系统的性能。
从线程模型上看性能,处理模型就会比较简单:我们如果希望提高一个队列清库存的效率 ,只要增加在这个队列上的搬移线程,或者提高部署在上面的线程(实际上是CPU核)的数 量,就可以平衡它的流控时间。如果我们能平衡所有队列的效率和长度,我们就比较容易 控制整个系统的通量和时延了。
这其中当然还会涉及很多细节的设计技巧,但大方向是这个。
在线程这个问题上,最后要补充几句:线程是为了驱动系统的运行。不少新手很容易把线 程和模块的概念搞到一起,甚至给每个模块配备一个线程。这是不少系统调度性能差的原 因。初学者应该要时刻提醒自己,线程和模块是两个独立的,正交的概念,是不应该绑定 的。模块是为了实现上的内聚,而线程是为了:
把计算压力分布到多个核上
匹配不同执行流程的速率
当线程的执行流程中有同步IO的时候,增加线程以便减少在同步IO上的等待时间(本质 上是增加流水线层次提升执行部件的同步效率)
后面我们谈具体的优化技巧的时候我们会重新提到线程的这些效果。
所以,当我们遭遇一个通量性能瓶颈的时候,我们通常分三步来发现瓶颈的位置:
CPU占用率是否已经满了,这个用top就可以看到,比如下面这个例子:
.. figure:: _static/top.png
有两个CPU的idle为0,另两个基本上都是接近100%的idle,我们基于这个就可以决定我们 下一步的分析方向是什么了。
如果CPU没有满,有三种可能:
1.1 某个队列提早流控了。我们通过查看每个独立队列的统计来寻找这些流控的位置,决 定是否需要提升队列长度来修复这种流控。例如,我们常常用ifconfig来观察网卡是否有 丢包:::
wlan0 Link encap:以太网 硬件地址 7c:7a:91:xx:xx:xx
inet 地址:192.168.0.103 广播:192.168.0.255 掩码:255.255.255.0
inet6 地址: fe80::7e7a:91ff:fefe:5a1a/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 跃点数:1
接收数据包:113832 错误:0 丢弃:0 过载:0 帧数:0
发送数据包:81183 错误:0 丢弃:0 过载:0 载波:0
碰撞:0 发送队列长度:1000
接收字节:47850861 (47.8 MB) 发送字节:18914031 (18.9 MB)
在高速网卡场景中,我们常常要修改/proc/sys中的网络参数保证收包缓冲区足够处理一波 netpolling的冲击。
我们自己写业务程序的时候,也应该对各个队列的水线,丢包数等信息进行统计,这样有 助于我们快速发现队列的问题。
1.2 调度没有充分展开,比如你只有一个线程,而你其实有16个核,这样就算其他核闲着 ,你也不能怎么样。这是需要想办法把业务hash展开到多个核上处理。上面那个top的结果 就是这种情形。
1.3 配套队列的线程有IO空洞,要通过异步设计把空洞填掉,或者通过在这个队列上使用 多个线程把空洞修掉。具体的原理,后面谈分析方法的时候再深入介绍。
这里例子中的CPU占用率已经全部占满了。但时间中只有15.43%落在主业务流程上,下面有 大量的时间花在了锁和调度上。如果我们简单修改一下队列模式,我们就可以把这个占用 率提升到23.39%:
当我们发现比如schedule调度特别频繁的时候,我们可以通过ftrace观察每次切换的原因 ,比如下面这样:
你可以看到业务线程执行3个ns就直接切换为Idle了,我们可以在业务线程上加mark看具体 是什么流程导致这个切换的(如果系统真的忙,任何线程都应该用完自己的时间片,否则 就是有额外的问题引起额外的代价了)
在后面几篇博文中,我们会逐一看看Linux的perf和ftrace功能如何对这些分析提供技术支 持的。
流控是个很复杂的问题,这里没有准备展开,但需要补充几个值得考量的问题。
第一,流控应该出现在队列链的最前面,而不是在队列的中间。因为即使你在中间丢包了 ,前面几个队列已经浪费CPU时间在这些无效的包上了,这样的流控很低效。所以,中间的 队列,只要可能,一般不设置自身的流控
第二,在有流控的系统上,需要注意一种很常见的陷阱:就是队列过长,导致最新的包被 排到很后才处理,等完成整个系统的处理的时候,这个包已经被请求方判断超时了。这种 情况会导致大量的失效包。所以,流控的开始时间要首先于每个包的超时时间。
性能分析应该是一个有针对性的工作,我们大部分情况都不可能通过“调整这个参数看看结 果,再调整调整那个参数看一个结果,然后寄望于运气。我们首先必须从一开始就建立系 统的运行模型,并有意识地通过程序本身的统计以及系统的统计,对程序进行profiling, 并针对性地解决问题。
在我们进一步介绍更多模型分析技巧前,我们先要对基本工具有一些了解。这一篇先介绍 ftrace的基本用法。
ftrace在内核的Documentation目录下已经有文档了,我这里不是要对那个文档进行翻译, 而是要说明这个工具的设计理念和使用策略。细节的东西读者要自己去看手册。
ftrace通过一个循环队列跟踪内核的执行过程。这个循环队列在内存中,大小是固定的( 可以动态设置),所以写入的速度可以很快,在没有ftrace的时候,我经常通过类似的方 式人工跟踪系统的执行过程,以便定位调度引起的各种问题。调度的问题对执行时间非常 敏感,所以进行跟踪需要尽量避免把IO,等待等各种额外的要素加进来。而直接写内存就 成为影响最小的一种模式了,ftrace很好地满足了这个要求。所以基本上现在我已经不需 要再写额外的人工跟踪代码来跟踪系统的执行序列了。
ftrace通过debugfs对外提供接口,所以不需要额外的工具进行支持。ftrace在内核中的配 置选项是CONFIG_FTRACE,除了这个基本选项外,下面还有很多子特定可以单独选,用户可 以自己去看对应的Kconfig文档,一般的发行版都会开启这个特性,所以大部分情况下你也 不需要为了使用这个功能重新编译内核。debugfs在大部分发行版中都mount在 /sys/kernel/debug目录下,而ftrace就在这个目录下的tracing目录中。如果系统没有 mount这个文件系统,你也可以手工mount。作为虚拟文件系统,它和procfs一样,给定类 型就可以mount,比如你可以这样:::
mount -t debugfs none /home/kenneth-lee/debug
tracing目录中的内容有点乱:::
available_events current_tracer function_profile_enabled options saved_cmdlines_size set_ftrace_pid stack_trace trace_options tracing_on
available_filter_functions dyn_ftrace_total_info instances per_cpu set_event set_graph_function stack_trace_filter trace_pipe tracing_thresh
available_tracers enabled_functions kprobe_events printk_formats set_event_pid set_graph_notrace trace trace_stat uprobe_events
buffer_size_kb events kprobe_profile README set_ftrace_filter snapshot trace_clock tracing_cpumask uprobe_profile
buffer_total_size_kb free_buffer max_graph_depth saved_cmdlines set_ftrace_notrace stack_max_size trace_marker tracing_max_latency
里面虽然有一个README文件在解释,但这个文档更新得不快,很多时候和实际的内容对不 上。但不要紧,只要我们抓住重点逻辑,就很容易理解了。ftrace的目录设置和sysfs类似 ,都是把目录当作对象,把里面的文件当作这个对象的属性。所以,虽然这个目录中的文 件众多,我们只要先理解以下几个概念就很容易抓住重点了:
一个用来跟踪的缓冲区(内存)称为一个instance,缓冲区的大小由文件buffer_size_kb 和buffer_total_size_kb文件指定。有了缓冲区,你就可以启动行为跟踪,跟踪的结果会 分CPU写到缓冲区中。缓冲区的数据可以通过trace和trace_pipe两个接口读出。前者通常 用于事后读,后者是个pipe,可以让你动态读。为了不影响执行过程,我更推荐前一个接 口。
trace等文件的输出是综合所有CPU的,如果你关心单个CPU可以进入per_cpu目录,里面有 这些文件的分CPU版本。
我们不要看到trace文件中输出那么多文本,就觉得这个跟踪效率不高。那些文本都是事后 (就是你读这个文件的时候)format出来的,跟踪的时候仅仅记录了必须的数据信息,所 以不需要担心跟踪时的效率问题。
所以读者应该已经明白了/sys/kernel/debug/tracing这个目录本身就代表一个instance。 如果你需要更多的instance,你可以进入到这个目录下面的instances目录中,创建一个任 意名字的目录,那个目录中就也会有另一套buffer_size_kb啦,trace啦这些文件,那里就 是另一个instance了。通过多instance,你可以隔离多个独立的跟踪任务。当然,这也很 浪费内存。
和所有面向对象设计一样,instance通过操作对应属性来控制其行为,比如,向trace文件 写一个空字符串可以清空对应的缓冲区:::
echo > trace
又比如,向tracing_on文件写1启动跟踪,写0停止跟踪等。向set_ftrace_pid写pid可以限 制只根据某个pid的事件等。
更复杂的控制在trace_options中,这个文件也是很好理解的,类似vim,你看看它的内容 ,要启动某个功能就echo某个字符串进去,要关闭它只要echo带“no”前缀的那个字符串进 去就可以了。options目录可以提供更精细的控制。
ftrace有两种主要跟踪机制可以往缓冲区中写数据,一种是函数,一种是事件。前者比较 酷,很多教程都会先讲前者。但对我来说,后者才比较可靠实用,所以我先讲后者。
事件是固定插入到内核中的跟踪点,我们看Linux代码的时候,经常看到这种trace_开头的 函数调用:::
if (likely(prev != next)) {
rq->nr_switches++;
rq->curr = next;
++*switch_count;
trace_sched_switch(preempt, prev, next);
rq = context_switch(rq, prev, next, cookie); /* unlocks the rq */
} else {
lockdep_unpin_lock(&rq->lock, cookie);
raw_spin_unlock_irq(&rq->lock);
}
这个地方就是一个事件,也就是打在程序中的一个桩,如果你使能这个桩,程序执行到这 个地方就会把这个点(就是一个整数,而不是函数名),加上后面的三个参数(preempt, prev, next)都写到缓冲区中。到后面你要输出的时候,它会用一个匹配的解释函数来把内 容解释出来,然后你在trace文件中看到的就是这样的:
.. figure:: _static/ftrace1.png
启动事件跟踪的方法很简单:
先查available_events中有哪些可以用的事件(查events目录也可以)。
把那个事件的名称写进set_event,可以写多个,可以写sched:* 这样的通配符
通过trace_on文件启动跟踪。启动之前可以通过比如tracing_cpumask这样的文件限制 跟踪的CPU,通过set_event_pid设置跟踪的pid,或者通过其他属性进行更深入的设定 。
剩下的事情就是执行跟踪程序和分析跟踪结果了。
我通常把ftrace的设置和启动等命令和业务程序的启动写到一个脚本中,一次运行足够的 时间然后直接取结果。然后就专心手工或者通过python脚本分析输出结果(ftrace的输出 用awk很不好拆,还是python比较实际)
事件跟踪的另一个更强大的功能是可以设定跟踪条件,要做这种精细化的设置,你需要直 接操作events目录下面的事件参数,比如仍是跟踪前面这个sched_switch,你可以先看看 events/sched/sched_switch/format文件:::
name: sched_switch
ID: 273
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:char prev_comm[16]; offset:8; size:16; signed:1;
field:pid_t prev_pid; offset:24; size:4; signed:1;
field:int prev_prio; offset:28; size:4; signed:1;
field:long prev_state; offset:32; size:8; signed:1;
field:char next_comm[16]; offset:40; size:16; signed:1;
field:pid_t next_pid; offset:56; size:4; signed:1;
field:int next_prio; offset:60; size:4; signed:1;
你就可以看到这个事件可以支持的域,你只要向同级目录中的filter中写一条类C的表达式 就可以对这个事件进行过滤。
比如,我注意到这个跟踪点支持next_comm这个域,我就可以这样写:::
echo 'next_comm ~ "cs"' > events/sched/sched_switch/filter
这样我就可以仅跟踪调度器切换到cs这个线程的场景了。
对于性能分析,我用得最多的是这个线程switch事件(还有softirq的一组事件)。因为从 考量通量的角度,主业务CPU要不idle,它要不在处理业务,要不在调度。一个“不折腾”的 系统,主业务进程应该每次都用完自己的时间片,如果它总用不完,要不是它实时性要求 很高(主业务这种情况很少),要不是线程调度设计有问题。我们常常看到的一种模型是 ,由于业务在线程上安排不合理,导致一个线程刚执行一步,马上要等下一个线程完成, 那个线程又执行一步,又要回来等前一个线程完成,这样CPU的时间都在切换上,整个通量 就很低了。
这种模型后面专门论述,但通过ftrace看调度过程,我们很容易用python分析这个调度过 程,捕获所有“提前切换”的情况,并对高几率的提前切换进行分析,就可以针对性地解决 问题了。
事件上还可以安装trigger,用来触发特定的动作,我很少用这个功能,读者可以自己看手 册看看有没有什么实际的用途。 其中手册中提到一个特别酷的功能叫“hist”(输出柱状图 ),它可以通过这样的命令:::
echo 'hist:key=call_site:val=bytes_req' > /sys/kernel/debug/tracing/events/kmem/kmalloc/trigger
实现下面这样的效果:
.. figure:: _static/ftrace2.png
这相当于给你提供一个各个内存分配点的内存分配次数和数量的一个分布图。这很爽,不过老实说,有了trace文件,要产生这样的数据也是分分钟的事,所以我也不是很需要这个功能。
事件跟踪需要根据我们对Kernel业务流有清晰的认识,我们才能合理设置事件。功能跟踪就会简单得多,功能跟踪可以直接使能某种跟踪功能,具体用什么事件,设置什么参数等,都默认设置好,这种预定义功能在available_tracers中列出,只要选择其中一个,把对应的名字写入current_tracer文件中就可以启动这个功能
我的机器上支持如下功能:::
blk mmiotrace function_graph wakeup_dl wakeup_rt wakeup function nop
比如我要跟踪系统唤醒的时延,我们可以:::
echo wakeup > current_tracer
echo 1 > tracing_on
wakeup跟踪的输出是这个样子的:
.. figure:: _static/ftrace3.png
它可以跟踪在你跟踪的期间里,最高优先级的任务的调度最大时延。比如上面这个统计统 计到的最大时延是52us(算不错了),下面给出的跟踪点是这个任务( irq/49-iwlwifi-1625)被唤醒后,执行了哪些动作,才轮到它执行了。通过分析这些动作 ,你就可以知道你可以优化哪些流程来提升整个系统的实时性了。
我很少使用这种成套跟踪,读者可以自己一个个试用一下,看看是否有趁手的工具,可以 帮助你解决你的环境中面的的问题。
成套跟踪中有两个功能其实是相对独立的,就是function和function_graph。这个功能可 以和事件一样单独使用(可惜的是不能同时使用,其实照理说这没有什么难度的)。
函数跟踪和事件跟踪一样,相当于在函数入口那里增加了一个trace_函数, 函数跟踪的效 果类似这样:
.. figure:: _static/ftrace4.png
加堆栈跟踪的话,可以变成这样:
.. figure:: _static/ftrace5.png
函数跟踪也可以做类似事件工作一样的过滤功能,这个用户可以看手册,我用这个功能一 般是用来跟踪和性能无关的执行过程,
ftrace一个比较明显的缺点是没有用户态的跟踪点支持,作为补救,instance中提供了一 个文件,trace_marker,写这个文件可以在跟踪中产生一条记录。类似这样:
.. figure:: _static/ftrace6.png
你可以注意到,这其中 tracing_mark_write就是一个marker,我在我的程序做 pthread_yield()的时候加了一个marker,这样我就可以跟踪当我yield出去的时候,系统 是否发生了重新调度。
这个跟踪方法的缺点是需要额外的系统调用,没有内核跟踪那么高效,但聊胜于无,至少 这个方法帮助我解决过不少问题。
使用ftrace的另一个缺点是它会话跟踪能力比较差,比如你在网卡上收到一个包,这个包 调度到了Socket的队列,然后再送到用户队列。虽然你可以在这些位置人工增加跟踪点, 但你不知道这个包属于哪个会话,你也不知道这个包在会话上的时延是什么。这个问题没 有非常好的解决方案,关键在于你的跟踪点必须能从这个包上提取出会话有关的信息,比 如你能取出这个包的类型和端口号,你就可以从跟踪结果上匹配出整个会话的流程来。
现在网络层的预置事件比较少,我们可以首先考虑是否可以通过函数来跟踪,如果不行, 就自己加跟踪点吧。
ftrace还可以通过uprobe/kprobe设置跟踪点,我对这两个东西不是很信任,所以用得很少 ,原理也很容易猜出来,所以,读者有兴趣可以自己去看,这里就不深入介绍了。
本文介绍了ftrace的基本功能和用法。ftrace主要用于跟踪时延和行为,它让我们可以很 深入地了解系统的运行行为。是进行Linux性能调优必须掌握的基本工具。
ftrace的跟踪方法是一种总体跟踪法,换句话说,你统计了一个事件到下一个事件所有的 时间长度,然后把它们放到时间轴上,你可以知道整个系统运行在时间轴上的分布。
这种方法很准确,但跟踪成本很高。所以,我们也需要一种抽样形态的跟踪方法。perf提 供的就是这样的跟踪方法。
perf的原理是这样的:每隔一个固定的时间,就在CPU上(每个核上都有)产生一个中断, 在中断上看看,当前是哪个pid,哪个函数,然后给对应的pid和函数加一个统计值,这样 ,我们就知道CPU有百分几的时间在某个pid,或者某个函数上了。这个原理图示如下:
.. figure:: _static/perf1.png
很明显可以看出,这是一种采样的模式,我们预期,运行时间越多的函数,被时钟中断击 中的机会越大,从而推测,那个函数(或者pid等)的CPU占用率就越高。
这种方式可以推广到各种事件,比如上一个博文我们介绍的ftrace的事件,你也可以在这 个事件发生的时候上来冒个头,看看击中了谁,然后算出分布,我们就知道谁会引发特别 多的那个事件了。
当然,如果某个进程运气特别好,它每次都刚好躲过你发起探测的位置,你的统计结果可 能就完全是错的了。这是所有采样统计都有可能遇到的问题了。
还是用我们介绍ftrace时用到的那个sched_switch为例,我们可以用tracepoint作为探测 点,每次内核调用这个函数的时候,就上来看看,到底谁引发了这个跟踪点(这个只能用 来按pid分类,按函数分类没有用,因为tracepoint的位置是固定的),比如这样:::
sudo perf top -e sched:sched_switch -s pid
输出:
.. figure:: _static/perf2.jpg
当然,perf使用更多是CPU的PMU计数器,PMU计数器是大部分CPU都有的功能,它们可以用 来统计比如L1 Cache失效的次数,分支预测失败的次数等。PMU可以在这些计数器的计数超 过一个特定的值的时候产生一个中断,这个中断,我们可以用和时钟一样的方法,来抽样 判断系统中哪个函数发生了最多的Cache失效,分支预测失效等。
下面是一个分支预测失效的跟踪命令和动态结果:::
sudo perf top -e branch-misses
输出:
.. figure:: _static/perf3.jpg
我们从这里就可以看到系统中哪些函数制造了最多的分支预测失败,我们可能就需要在那 些函数中考虑一下有没有可能塞进去几个likely()/unlikely()这样的宏了。
而且读者应该也注意到了,perf比起ftrace来说,最大的好处是它可以直接跟踪到整个系 统的所有程序(而不仅仅是内核),所以perf通常是我们分析的第一步,我们先看到整个 系统的outline,然后才会进去看具体的调度,时延等问题。而且perf本身也告诉你调度是 否正常了,比如内核调度子系统的函数占用率特别高,我们可能就知道我们需要分析一下 调度过程了。
perf的源代码就是Linux的源代码目录中,因为它在相当程度上和内核是关联的。它会使用 Linux内核的头文件。但你编译内核的时候并不会编译它,你必须主动进入tools/perf目录 下面,执行make才行。
perf支持很多功能,make的时候它会自动检查这些功能是否存在。比如前面我们用了 tracepoint进行事件收集,你就要保证你的系统中有libtracepoint这个库。perf的自由度 设计得相当高,很多功能你都可以没有,并不会影响你的基本功能。
由于perf和内核关联,所以理论上,你用哪个内核,就应该使用对应内核的perf,这能保 证接口的一致。所以很多类似Ubuntu这样的发行版,你装哪个内核,就要装对应内核的 perf命令,而通过的perf命令入其实只是个脚本,根据你当前的perf命令,调用不同perf 版本。
但那只是理论上,实践中,其实perf的用户-内核接口相当稳定,很多时候跨版本使用是没 有问题的,由于perf的版本还在高速发展中,而且很多发行版的perf版本没有使能很多功 能,我在实践中经常直接找最新的内核自己重新编译版本,好像也没有出过什么问题。读 者可以有限度参考这个经验。perf也没有很多的路径依赖,你编译完以后连安装都不用, 直接用绝对路径调用你编译的版本即可。
前面我们已经看了几个perf工作的例子了。类似git,docker等多功能工具,perf也是使用 perf <子命令>这种模式。所有人首先需要学习的是两个最简单的命令:perf list和perf top。
perf list列出perf可以支持的所有事件。例如这样:::
.. figure:: _static/perf4.jpg
旧版本还会列出所有的tracepoint,但那个列表太长了,新版本已经不列这个东西了,读 者可以直接到ftrace那边去看就好了。
perf top可以动态收集和更新统计列表,和很多其他perf命令一样。它支持很多参数,但 我们关键要记住两个参数:
-e可以指定前面perf list提供的所有事件(包括没有列出的tracepoint),可以用多个-e 指定多个事件同时跟踪(但显示的时候会分开显示)
一个-e也可以直接指定多个事件,中间用逗号隔开即可:::
sudo perf top -e branch-misses,cycles
(perf list给出的事件是厂家上传上去给Linux社区的,但有些厂家会有自己的事件统计 ,没有上传出去,这你需要从厂家的用户手册中获得,这种事件,可以直接用编号表示, 比如格式是rXXXX,比如在我们的芯片里面,0x13号表示跨芯片内存访问,你就可以用-e r0013来跟踪软件的跨片访问次数)
事件可以指定后缀,比如我想只跟踪发生在用户态时产生的分支预测失败,我可以这样: ::
sudo perf top -e branch-misses:u,cycles
全部事件都有这个要求,我还可以:::
sudo perf top -e ‘{branch-misses,cycles}:u'
看看perf-list的手册,会找到更多的后缀,后缀我也用得比较少,读者对这个有兴趣,可 以自己深入挖掘一下,如果有什么好的使用经验,希望也可以告诉我。
-s参数可以不使用,默认会按函数进行分类,但如果你想按pid来分,就需要靠-s来进行分 类了。前面我们已经看过这样的例子了。-s也可以指定多个域(用逗号隔开),例如这样 :::
sudo perf top -e 'cycles' -s comm,pid,dso
输出: .. figure:: _static/perf5.jpg
perf-top用来理解,体会perf的功能是比较好的,但实践中用得不多,用得比较多的是 perf-record和perf-report命令。perf-record用来启动一次跟踪,而perf-report用来输 出跟踪结果。
一般的过程是:::
sudo perf record -e 'cycles' -- myapplication arg1 arg2
sudo perf report
下面是一个报告的例子:
.. figure:: _static/perf6.jpg
perf record在当前目录产生一个perf.data文件(如果这个文件已经存在,旧的文件会被 改名为perf.data.old),用来记录过程数据。之后运行的perf report命令会输出统计的 结果。perf.data只包含原始数据,perf report需要访问本地的符号表,pid和进程的对应 关系等信息来生成报告。所以perf.data不能直接拷贝到其他机器上用的。但你可以通过 perf-archive命令把所有这些数据打包,这样移到另一个机器上就可以用了。
请注意,perf-archive是指perf-archive这个命令,不是指perf archive这个子命令。这 个命令在编译perf源代码的时候会产生的,如果你的发行版不支持,可以自己编译一个。 比较可惜的是,perf-archive备份的代码不能跨平台使用(比如你从arm平台上备份的数据 ,在x86上是分析不了的)。
perf.data保留前一个版本,可以支持perf diff这个命令,这个命令比较两次两次运行的 区别。这样你可以用不同参数运行你的程序,看看运行结果有什么不同,用前面这个cs程 序为例,我用4线程对比2线程,就有如下结果:
.. figure:: _static/perf7.jpg
我们这里看到,增加线程后,heavy_cal的占比大幅下降了10.70%,其他的变化不大。
perf record不一定用于跟踪自己启动的进程,通过指定pid,可以直接跟踪固定的一组进 程。另外,大家应该也注意到了,上面给出的跟踪都仅仅跟踪发生在特定pid的事件。但很 多模型,比如一个webserver,你其实关心的是整个系统的性能,网络上会占掉一部分CPU ,WebServer本身占一部分CPU,存储子系统也会占据部分的CPU,网络和存储不一定就属于 你的WebServer这个pid。所以,对于全系统调优,我们常常给record命令加上-a参数,这 样可以跟踪整个系统的性能。比如,还是前面这个cs程序的跟踪,如果我用-a命令去跟踪 ,得到的结果就和原来很不一样了:
.. figure:: _static/perf8.jpg
大家注意一下Command那一列。那里已经不仅仅有cs这个进程了。
perf report是一个菜单接口,可以一直展开到每个函数的代码的,例如我们要展开上面这 个heavy_cal()函数的具体计数,我们在上面回车,选择代码分析,我们可以得到:
.. figure:: _static/perf9.jpg
perf record还有其他参数可以控制,例如可以通过-c指定事件的触发的事件次数等,那个 读者们可以自己看手册。
和perf record/report类似的还有一个perf stat命令,这个命令不计算分布,仅仅进行统 计,类似这样:
.. figure:: _static/perf10.jpg
一般情况下,我觉得这个功能用不上。
perf的跟踪有一个错觉需要我们注意,假设我们有一个函数abc(),调用另一个函数def() ,在perf的统计中,这两者是分开统计的,就是说,执行def的时间,是不计算abc的时间 的,图示如下:
.. figure:: _static/perf11.png
这里,abc()被击中5次,def()被击中5次,ghi被击中1次。这会给我们不少错觉,似乎abc 的计算压力不大,实际上不是,你要把def和ghi计算在内才行。
但这又带来另一个问题:可能def不仅仅是abc这个函数调用啊,别人也会调用它呢,这种 情况,我们怎么知道是谁导致的?
这种情况我们可以启动堆栈跟踪,也就是每次击中的时候,向上回溯一下调用栈,让调用 者也会被击中,这样就就更容易看出问题来,这个原理类似这样:
.. figure:: _static/perf12.png
这种情况,abc击中了11次,def击中了6次,而ghi击中了1次。这样我们可以在一定程度上 更容易判断瓶颈的位置。-g命令可以实现这样的跟踪,下面是一个例子:
.. figure:: _static/perf13.jpg
使用堆栈跟踪后,start_thread上升到前面去了,因为正是它调的heavy_cal。
使用堆栈跟踪要注意的是,堆栈跟踪受扫描深度的限制,太深的堆栈可能回溯不过去,这 是有可能影响结果的。
另一个问题是,有些我们从源代码看来是函数调用的,其实在汇编一级并不是函数调用。 比如inline函数,宏,都不是函数调用。另外,gcc在很多平台中,会自动把很短的函数变 成inline函数,这也不产生函数调用。还有一种是,fastcall函数,通过寄存器传递参数 ,不会产生调用栈,也有可能不产生调用栈,这个通过调用栈回溯是有可能看不到的。
还有一种更奇葩的情况是,部分平台使用简化的堆栈回溯机制,在堆栈中看见一个地址像 是代码段的地址,就认为是调用栈,这些情况都会引起堆栈跟踪上的严重错误。使用者应 该对系统的ABI非常熟悉,才能很好驾驭堆栈跟踪这个功能的。
perf是现在Linux中主推的性能分析工具,几乎每次升级都会有重大更新,连什么 benchmarking的功能都做进来了,还有用于专项分析perf-mem这样的命令,用来产生脚本 的perf script命令,帮助你用不同的脚本语言分析操作结果。这个用户可以自己看手册去 ,有前面的基础,这些功能都是很好理解的。
不过特别提一下script命令,虽然它的功能看起来只是用来产生分析脚本的,但我们还常 常用来导出原始分析数据,读者可以在perf-record后直接用这个命令来导出结果:::
sudo perf script
输出: .. figure:: _static/perf14.jpg
这里列出每个击中点,你爱怎么处理这些击中点的数据,就全凭你的想象力了。
前面已经强调过了,perf跟踪是一种采样跟踪,所以我们必须非常小心采样跟踪本身的问 题,一旦模型设置不好,整个分析结果可能都是错的。我们要时刻做好这种准备。
我特别提醒的是,你每次看perf report的报告,首先要去注意一下总共收集了多少个点, 如果你只有几十个点,你这个报告就可能很不可信了。
另外,我们要清楚,现代CPU基本上已经不用忙等的方式进入等待了,所以,如果CPU在 idle(就是没有任务调度,这种情况只要你的CPU占用率不是100%,必然要发生的),击中 任务也会停止,所以,在Idle上是没有点的(你看到Idle函数本身的点并非CPU Idle的点 ,而是准备进入Idle前后花的时间),所以,perf的统计不能用来让你分析CPU占用率的。 ftrace和top等工具才能看CPU占用率,perf是不行的。
perf还有一个问题是对中断的要求,perf很多事件都依赖中断,但Linux内核是可以关中断 的,关中断以后,你就无法击中关中断的点了,你的中断会被延迟到开中断的时候,所以 ,在这样的平台上,你会看到很多开中断之后的函数被密集击中。但它们是无辜的。但更 糟糕的是,如果在关中断的时候,发生了多个事件,由于中断控制器会合并相同的中断, 你就会失去多次事件,让你的统计发生错误。
现代的Intel平台,基本上已经把PMU中断都切换为NMI中断了(不可屏蔽),所以前面这个 问题不存在。但在大部分ARM/ARM64平台上,这个问题都没有解决,所以看这种平台的报告 ,都要特别小心,特别是你看到_raw_spin_unlock()一类的函数击中极高,你就要怀疑一 下你的测试结果了(注意,这个结果也是能用的,只是看你怎么用)。
这一篇我们介绍了perf的基本用法,perf通常是我们进行性能分析的第一步,但这一步, 要用好也不是那么容易的,我们首先应该掌握它的原理,然后基于一个分析模型逐步用 perf来验证我们的猜测,我们才有可能真正发现问题。
性能分析常常是一种战地分析,所以,在我们可以端起咖啡慢慢想怎么进行分析之前,我 们要先说说我们在战地上的套路。
战地分析是说在实用环境中发现问题,我们真正需要进行性能分析的场合,通常都没有机 会让你反复运行程序,重试等等的。几千万用户,几百万在线,几百万个Socket连接,上T 的数据库记录,这些场景通常你回家以后就再也没有机会建出来了。而且客户现场的运维 工程师可能很Nice,但通常他们的领导都不Nice。领导说,再弄一个小时,他们再搞不定 就让他们回家,我们上备份系统……你就得抱着你的便携,手忙脚乱地滚出人家的实验室。
所以现场的机会很宝贵, 针对不同的现场,你最好手上有一套脚本,上去不管三八二十四 ,先把什么dmesg啦,dpkg -l啦, /proc/cpu, /proc/ingterrupts, /proc/mem啦, ifconfig啦,ps -ef -L啦,/var/log啦,统统先给他扯一套出来,这样你后面怎么都好分 析,现场数据对于后面的分析非常重要。你连那个系统有多少内存都不知道,你分析条毛 的性能啊?
第二步就是性能了,我们通常先看top,不要用什么交互模式了,直接用top -b -n 3取一 个结果出来再说,至少你可以备份。但这个方法有个缺点,它不会显示每个CPU的分布,我 的方法是先用交互模式进去(直接运行top),然后按1,展开CPU,然后W,把当前配置写 进去,然后再运行top -b -n 3即可。
对top有谱了,我们大概就能知道问题主要出现在哪里了,如果整个系统都闲得很,通量还 是上不去,那就是在什么地方丢包或者入口通道带宽不足了了,开始找丢包点把。
如果只是时延太大,就要回去画整个包的调度流程图,看看包括那些步骤,然后用ftrace 跟踪这些步骤吧。
(补充一句,在现场的话,如果要定位的是启动速度问题,读者可以考虑一下使用strace 或者ltrace attach来跟踪启动效率的问题)
如果有CPU占用率很高,这时就要靠perf来画像了,先查基于时间的perf分布。看看问题, 顺便最好把perf-archive打包带回去。
这样,现场的工作就差不多了,出去和开始请客户的运维人员吃饭喝酒套近乎吧。
离线分析的第一件事是——点杯咖啡?
好吧,那个不重要,对我来说,最重要最重要的事情是:他妈的给老子写份文档出来!这 个实在太重要的,我不知道遇到多少次,有人在现场搞不定了,找我出马,然后屁颠屁颠 跑过来,说“Kenneth我给你讲讲我们的进展”……讲你老母!!!
交分析报告!
交分析报告!!
交分析报告!!!
他么老子不是你秘书好不好。整个性能分析的工作,就是建立模型,猜测瓶颈,和数据对 照,再采样,再分析瓶颈,修正设计,再采样……这样的一个个循环。不写文档不断整合我 们看到的现象,整个分析就像建在沙子上一样。你来给我讲讲?讲完我给你写报告是吧?
所以,我们的整个分析过程,应该是一个不断记录我们对模型的修正的过程,文档是整个 工作中最重要的一环。
同时,写文档也是提醒我们保存数据。很多人很不在乎数据的记录,在工作环境上这个运 行一下,那个运行一下,然后就完事了。 浪费了不知道多少东西,我每次接触工作环境, 第一件事是创建一个目录,放一个BRIEF文件,写上当前时间,测试人,环境,原因,然后 才开始数据收集,过程中不覆盖任何“稍有点用的”原始数据。这是基本的工作技巧,很多 工程师不肯学这种基本素养,不能守弱,工作起来乱七八糟的,这样写出来的报告一钱不 值。
怎么写文档这个不是我这里要教的东西,这是你中学语文应该学好的东西,但我还是提一 句很多工程师经常犯的错误:判断这种分析报告写得好不好的一个基本原则是,你是否始 终围绕着“瓶颈的证据是什么”来表述观点。这个原则非常简单,但很多报告写出来就会忘 掉这个。他们写着写着就喜欢收集各种很好看的分布图,趋势图,然后彻底忘掉到底现在 系统到底到达瓶颈没有,以及到达瓶颈的理由是什么。很多人给我夸夸奇谈半天,我问一 句,“你根据什么判断现在压力不能上去了?”,然后他就傻了。这个说到底是个守弱的问 题,我们还是少点想建功立业,先做点基本的东西比较好。
我们还是用我的cs程序为例(我晚点注册个新的github帐号共享出来),这个例子很简单 ,它模拟了一组线程产生数据,写入队列,然后另一组线程把数据取出来,完成整个计算 的过程,计算用heavy_cal函数来模拟。我们的目标是尽量提高计算的通量。所以,我们首 先看4线程的一般运行的结果:
.. figure:: _static/tune1.png
这个每秒处理175K的任务。但CPU还有空闲。可能是因为我们在每个线程计算的时候有IO, 导致效率上不去,我们用更多的线程(40个)来填掉这些IO的等待,结果提升非常有限:
.. figure:: _static/tune2.png
简单解决不了这个问题了,我们看看ftrace的数据:
.. figure:: _static/tune3.png
看见没有,cs的线程执行不到5个微秒就休眠了,搞什么飞机?
这个函数这样写的:::
void * pro_routin(void * arg) {
struct task * tsk = arg;
int ret;
srand((intptr_t)tsk->arg);
while(1) {
ret = heavy_cal(rand(), n_p_cal);
en_q(ret);
marker("yield here");
yield_method_f();
}
}
heavy_cal是纯计算,不会引起无意义的休眠,marker在内核中是用spin_lock保护的,不 会引起休眠,唯一有可能休眠的是yield和en_q()(写入队列),我们清掉yield试试,发 现没有效果。那就只有怀疑en_q()了,我们预期provider en_q可以写上几十个,然后才切 换给consumer再处理几十个。
但实际上根据Linux的调度算法,consumer会因此被逐步提权为interactive线程(Linux调 度算法总是把总用不完时间片的进程的调度优先级提高,让他们成为interactive线程,这 样那些用来处理鼠标,键盘的任务可以优先得到调度,从而提高响应速度。
这样修改以后,单核CPU占用率提升到92%以上,处理效率就提升到311K了。这时我们再看 ftrace数据,它是这样的:
这个跟踪我们还跟踪了futex的调用,我们可以看到大量的pthread_mutex_unlock的调用, 但都没有引起调度,整体性能就提上去了。
我们还有办法可以把剩下的那些时间用起来,不过这只是个例子,就到此为止吧。
本文介绍了最基本的性能分析流程,后面我们会具体讨论一些常见的分析模型,加深对这 些模型的理解。
前一个Blog我们使用了一个叫cs的程序作为例子,那个程序是我为了举例子临时写的,这 个代码我共享在这里:GitHub - nekin2012/btest。后面我要再举例子的话,就都加到这 个地方来。由于这些代码没有经过最基本的软件质量保证工艺,所以质量相当低,读者不 要直接使用这些代码。另外,这个代码中的cs程序已经经过上次推演的调整,现在的性能 已经可以达到调度最优了,CPU占用率会全部100%,和上一个程序中的样子已经不太一样了 。
cs这个程序的模型,是我们很多软件的基础模型。虽然经过很多模块和队列的分解,我们 的程序会变得愈加难以辨认,但模型永远都是为不同的队列安排多组线程池的问题。这种 模型的大部分优化工作是平衡线程的数量来保证CPU的利用率,然后通过限制每个队列的长 度,来控制时延和和通量之间的关系。
而这里面需要特别小心的就是那个Provider-Consumer陷阱,也就是前面提到的,如果一个 线程总用不完它的时间片,这个线程就会被自动提权为交互线程,这样,只要发生调度它 就会抢占,这样会大幅降低整个系统的性能。解决这个问题的方法通常是两个:一个就是 在那个cs中看到的,控制队列的长度,没有足够的长度根本不要发起调度。第二个就是我 们要有意识控制线程的设置,特别是不要一个模块一个线程(这是最失败的设计),如果 某个线程的执行时间特别短,这个工作就应该和其他线程合并,而不是独立线程。比如你 发一个消息,仅仅是为了分配一个会话号,这个时间可能就是几十个时钟周期,你就不能 图方便为了排队,使用一个模块队列+线程来完成这样的工作。正确的做法是把这个模块的 接口直接做成函数,然后用锁保护起来。
前面说的这种不使用短时线程会话的策略,在大部分时候是不会引起问题的,除非你线程 配置不平衡,让你的调度序列又出现交互线程。这些问题,都可以通过ftrace跟踪出来。
但不少程序员没有从这个角度考虑这个问题,他们就会试图通过spinlock来降低这种调度 的可能性。当所有线程共同分享这个公共的模块的时候,我们就会形成Amdahl定律所描述 的模型了。
Amdahl定律是并行计算最基础的理论了,所有学计算机的人都学过,我这里就不专门介绍 了,读者如果不知道自己上网查去。
现在大家都不怎么把Amdahl当回事,因为现在大部分系统的核数远远没有达到让Amdahl触 顶的规模,下面这个是我用999:1的比例配置并行-串行比时(程序参考btest的amdahl的例 子),在72核的x86平台上得到的效果:
.. figure:: _static/tune4.png
这时增加核数基本上就会达到提升处理能力的目的。
但时延上仍是有影响的:
.. figure:: _static/tune5.png
在串行的密度非常低的时候,我们还感觉一切受控,但如果我们把并行串行比提升到99:1 ,乃至90:10的时候,情况就变得非常糟糕了:
.. figure:: _static/tune6.png
因为这不再是一个Amdahl模型了,Amdahl模型的依赖是在等待的时候,你的CPU还能干其他 并行的工作,而使用spinlock,你在等待的时候什么都干不了。这实际上是一个马可夫链 的排队模型,
这里有四条曲线,我们先看spin的两条曲线,你会发现,当你把串行的配比增加的时候, 系统在20个核左右就开始进入拐点,性能大幅跳水。而且串行的比例越大,跳水就越早。
根据一些研究报告,如果这是个标准的马可夫链的排队模型,曲线影响应该像后面两条标 记为MCS曲线那样,仅仅是接近瓶颈。而这个跳水是因为,纯粹的spinlock不但引起等待时 间的增加,而且因为有更多的等待者,会导致Cache更新时间的延长,从而得到一个修正的 马可夫链模型,形成了跳水。如果用perf对这两种情况进行跟踪,你会发现,在系统发生 跳水后,系统在的指令执行效率10个cycle执行不了一条指令:
.. figure:: _static/tune7.png
很低的指令执行率,表明执行指令本身的执行效率低(基本在stalled-cycles上,这个指 标的含义,我们后面专门写一篇blog介绍),问题要不出现在cache/总线上,要不出现在 处理器自身的调度器上。
我在一台64核的ARM64服务器上做同样的实验,得到同样的结果。有论文提出使用MCS锁来 避免这种情况,我在那个btest工程中快速用spinlock临时封装了一个MCS锁,可以拟合出 类似马可夫链的模型(当然,这个实现没有使用原子指令,速度肯定是比较慢的,只是为 了拟合模型)。
使用MCS锁后,上面的测试结果是这样的:
.. figure:: _static/tune8.png
同样的行为在ARM64上测试结果也是一致的,理论上说,如果用ticket锁,在ARM上可以获 得MCS锁一样的结果,我晚点加一个测试看看。
我们很多人更愿意花时间去反复尝试各种设置参数,尝试这样提高系统的性能。我个人收 到不少性能分析报告都是这样的。我觉得这样的分析报告相对来说价值是比较低的(当然 也有其作用),因为即使一个参数进行调整带来了好处,但这种好处和其他参数组合后可 能就会消失。仅仅关注一个参数的效果,最多就是给我们建立模型提供参考,我们的分析 还是要聚焦到模型上,发现系统真正的瓶颈是什么。我前面的perf介绍下面,有人说perf 的数据也就只是能“看看”,我认为抱有这种观点,是因为他从来只是关注效果,而不关注 模型,而在系统性能优化的时候,模型远远比效果重要。效果你确实可以拿去报功,但在 软件自身的架构进展上是没有用的,要得到正确的构架调整方向,模型才是第一位的。
回到Amdahl这个模型,很多系统在进行架构调整的时候,决策下得非常早,一看见mutex发 生了切换,就考虑用一个“不会切换”的spin_lock取代“有可能切换”的mutex锁,而不愿意 花时间去分析“为什么调度器”要做这个切换。这样头痛医头,脚痛医脚的方法(对,我说 的就是MySQL),会让整个系统陷入更深层次的混乱之中,这样整个架构就变得不可控了。
在调度上,除了IO导致的等待外,消费者-使用者模型和Amdahl模型是两个最常见的陷阱, 好好基于btest类似的简化模型对CPU和调度器的行为进行分析,会有助于我们正确理解更 复杂系统的运行模型。
调度模型平衡了,我们就有可能进行下一步,针对CPU执行效率的分析了。
前面介绍了两个典型的调度模型,如果调度没有问题,剩下的问题就是正面刚算法了。那 个不是我这里要介绍的主题的。
但,Not Really。其实除了算法在消耗CPU,CPU还是有很多余力可以挖掘的,这一篇我们 专门讨论一下CPU的执行模型,看看我们在算法本身以外,还可以怎么优化我们程序的执行 模型。
在不少软件人员的想象中,似乎只要保证CPU的占用率是100%,CPU应该是很忙的,应该在 执行完一条指令,然后执行下一条指令,没有空干别的事情。
但如果我们深入进去看,实际上CPU里面也不是只有一个执行部件。假设有一个CPU上有4个 执行部件,这些执行部件在CPU时钟的驱动下,一跳一跳地完成每一个动作,并完成一个指 令一个指令的执行,这个执行流程就会是这样的:
.. figure:: _static/in_cpu1.png
看见了把,如果CPU真的这样执行,“取指”这个部件在一条指令的执行中,有三跳(CPU称 为时钟周期)其实是“闲”着的。
所以,合理的模型应该是这样的:
.. figure:: _static/in_cpu2.png
也就是说,i1(被取指这个部件)执行后,取指部件反正闲着也是闲着,不如就直接执行 下一条指令的取指就好了。这样算起来,其实不是4个时钟周期执行一条指令的,实际上是 一个时钟周期执行一条指令的。这个执行模型,我们就称为“流水线”。它和工厂中的生产 流水线的调度原理几乎是一样的。每个执行部件在一条指令中执行占用的那个时间,称为 一个Stage,一条指令包含多个Stage,但第二条指令并不需要等待上一条指令的所有Stage 都完成了才开始自己的stage。每条指令包含的Stage数目,我们称为流水线的长度。流水 线的长度决定了一条指令要多长时间才能完成,但如果流水线一切正常,平均起来,我们 只需要一个stage的时间,就可以执行一条指令。
现代CPU的流水线是很长的,比如ARM的A57,流水线长度超过15。所以,看起来一条指令需 要15个时钟周期,实际上你只需要1个时钟周期就可以执行一条指令。
前面这个模型看起来很美,但实际上不是这样的,有很多问题会破坏流水线。最常见的破 坏是指令依赖。比如你写如下汇编:::
add r1, r2, r3 #r1=r2+r3
add r1, 1 #r1=r1+1
add r4, 2
这里第一条指令计算r1,第二条指令使用r1,第一条指令没有执行完,第二条指令译码完 了,一看,我靠,要用r1,前面的还没有搞完,等等吧,就成这样了:
.. figure:: _static/in_cpu3.png
你看,CPU其实又闲下来了。(注:有人说,这个地方处理器可以通过寄存器改名实现不用 等待,这句话没有错,但其实芯片不止这一种优化方法,我们要理解核心逻辑,在主线逻 辑上拉这种逻辑没有意义,我们还是先聚焦原理,如果你有兴趣讨论这种细节,我们可以 单独拉线索来讨论)
高级的CPU,编译器,都会进行指令调度。比如我们看到第三条指令跟谁没有没有依赖,我 们可以把它调整到第二条的前面,这样可以填补一定的时间空间,这个执行会变成这样:
.. figure:: _static/in_cpu4.png
这个效率就又高了一点了。
流水线是个很麻烦的事情,而且你在玩这种小聪明,芯片设计师也在玩这种小聪明,所以 ,不到严重破坏的程度,我们不会在设计的时候就考虑它,尽量把它交给编译器和CPU自己 。很多半桶水的程序员,会以为用汇编写的程序比用C写的程序效率高,其实这个基本上都 是错的。因为你写汇编代码很难考虑流水线(特别是这里不光有指令的调度,还有寄存器 的调度,用不同的寄存器,可能可以造成不同的依赖,从而优化流水线的执行),如果你 强行考虑流水线了,你的代码也没法看了,因为它不是以人脑为对象的了,完全是机器的 思维),而编译器考虑这样的东西,毫无压力。所以,我们只在流水线特别糟糕的地方考 虑用汇编优化一下,而不会吃饱没事到处写汇编。这也是为什么多言数穷,不如守中。大 家都想耍小聪明,这个系统就不聪明了,各守本分才“合道”。
跳转指令也会引起流水线的破坏。考虑如下序列:::
1:
...
add r1, r2, r3
jmp 1b #jump back to label 1
add r2, 1
add r3, 2
流水线确实把4条指令都执行了,但没有什么鬼用,因为第二条指令跳转到别的地方去了, 后面两条指令执行了也是白执行。这种情况叫“指令预测失效”,也是破坏流水线的行为。
所以你经常看到一些高性能程序里面写这样的代码:::
for(i=0; i<800; i+=4) {
a[i] = x;
a[i+1] = x;
a[i+2] = x;
a[i+3] = x;
}
这个代码看起来完全可以用这样一个简单的代码代替:::
for(i=0; i<800; i++) {
a[i] = x;
}
而作者要写成上面那个鬼样子,很多时候就是为了优化流水线。让跳转不要那么快发生。 但还是那句话,不要在开始设计的时候就优化,否则自取其辱。
如果读者习惯Linux的代码,会经常看到likely和unlikely这个宏,它的作用也是这个,考 虑一下如下汇编:::
xor r1, r1, r1
jz 2f #jump forward to label 2 if zero
add r2, r2, 1
...
2:
我们把分支放在jz后面还是放在2:后面呢?放在jz后面预测就会成功,放到2:后面预测就 会失败。那我们就应该把最可能的结果放在jz后面,所以我们才有likely和unlikely,通 知编译器,谁才是最有可能的,这样也能有效提高CPU的执行效率。
指令依赖中, 有一种依赖是要特别注意的,就是访存指令。访问内存是很慢的,你这样想 象一下吧:我们执行一条指令可能就是几个时钟周期,但访问一次内存的时间可能就是几 百个时钟周期。想象一下下面这个执行过程:::
ldr r1, [addr1]
add r1, r1, 3
add r2, r2, 4
add r3, r3, 5
你以为这4条指令在一个流水线周期里就可以执行完了,实际是几百个时钟周期。这个效率 一下就慢下来了。
我们当然可以把第三,四条指令提前,勉强填补一下中间的等待,但杯水车薪,也没有什 么用。
这种时候,我们就要依赖Cache了,现代CPU系统有多级Cache,类似这样:
.. figure:: _static/in_cpu5.png
L1 Cache中有的,就从L1取,没有的就从L2取,……如此类推。这个问题考虑到他们的速度 的时候,你就会发现其实是很严重的。
我们这样考虑这个问题吧:L1 Cache的访问速度是几个时钟周期(常常会是1个),L2是十 几个,L3是几十个,到了内存上,就是几百个,如果是多道系统,插几个CPU,跨Socket的 时候,就会更慢。
如果我们保证我们的执行尽量都在Cache的范围内,我们的性能就会提高。Cache Line的长 度常常比寄存器的长度长,比如64位系统一个寄存器是8个字节,而Cache Line的长度常常 可以达到128个字节。如果你的访问是对齐的,很多一次内存操作可以完成了动作,就不需 要两次才能完成,这会大大提高执行的效率。另外,如果你在访存之前还有很多准备动作 要做(memcpy一类的程序经常如此),你还可以通过Cache预取指令提前把内存的数据拉到 Cache中,这也能大大提高效率。
还有一种会严重破坏性能的模型。称为Cache污染,大概的模型是:你的算法做得不好,总 是访问一个Cache刚刚干掉的数据,每次访问都导致一次Cache刷新,性能就会严重下降。 这个有很多论文了,我就不介绍了。基本上不是专业的研究者,我们也不用专门去记住这 些模型,我们只要按功能,按软件构架的要求,把代码写出来,然后通过profiling工具去 发现密集出现branch-miss,cache-miss的地方,根据情况作出优化就好了。
上面我们给了一个基本的流水线模型。但实际上……呵呵,又来了……这种叫同步模型,现代 的CPU,基本上不是这样的同步模型。现在CPU是异步调度模型。类似下面这样(网上随便 找的图,侵删):
.. figure:: _static/in_cpu6.png
从中间开始,CPU的执行分成了两段。前面一段是取指有关的操作,后面一段是执行有关的 操作。CPU有很多的执行通道,可以有多个定点或者浮点加法器,几个取指器等等。这样, 实际上整个CPU就像一个多线程的软件程序:有一组线程负责把指令读出来,解码,然后送 入队列,另一组线程负责从队列中把指令取出来,投入执行。这个执行并非严格的流水线 模型,而更像我们这个系列文章最前面提到的那个队列模型。
在芯片的优化手册中,会给出前端和后端有哪些执行部件(比如一个比较新的RISC CPU上的前端是Fetch, Decode/Rename/Dispatch和Issue,后端是Branch, Int0, Int1, Int Multi-Cycle,FP0, FP1, Load, Store)。然后它还会给出每个指令需要占用哪些执行部件,已经这个指令的执行时延和执行通量。如果你要进行汇编一级的优化(比如为这个CPU配套编译器),你就需要根据这个优化手册对指令进行重排。而对于优化者,则首先看重程序的IPC(每个cycle执行多少条指令),然后查对应的stall参数,看有没有机会特别重排程序特定的部分,从而加快执行效率。
下面这个是Intel的一个Top Down模型(侵删):
.. figure:: _static/in_cpu7.jpg
前面一段就是取指有关的,是In-Order的操作,这部分是符合原来的流水线模型的。后面 一段就是纯粹的调度。这个性能就不能完全按严格的流水线模型来考虑(加上超线程技术 就会更加复杂)。所以现在你用perf stat执行一个程序,它会给你这个总结(不是每个 CPU都支持这两个统计):
.. figure:: _static/in_cpu8.png
你可以看到了,它首先给你统计了一个stalled-cycles-frontend,和一个 stalled-cycles-backend,通过这两个统计,你可以看到,无论你如何执行,你的系统到 底有多少花在了前端的等待上,多少花在在后端的执行上。前者说明你供指令的速度不够 快,后者说明你CPU处理不过来。我们可以以这个为基础,进一步找到系统的执行瓶颈。
Intel处理器上,这个模型称为Top-Down模型(现在ARMv8也开始用一样的名字了,据说这 个名字原来来自IBM),以这个为分界,可以一步步分解下去,最终找到执行瓶颈在什么地 方,我们从而可以找到合适的软件执行模型,提高系统的执行效率。这些模型首先和CPU的 微架构是相关的,但基于我们原来的流水线中形成的经验,我们在一定程度上,到都有相 当的机会了解到我们可以调整软件的什么设计来让CPU执行得更快。
本文介绍了一些基础的CPU执行模型,一定程度上了解CPU的执行模型,有助于我们正确找 到系统的性能瓶颈。但从这些模型中,我们也看到了,其实整个系统的每个模块都在尝试 优化自己的执行效率,而作为最高层的软件,其实是最需要遵守“多言数穷,不如守中”策 略的角色。从设计的角度,软件引导了整个需求的响应方法,软件守不稳,所有其他的小 九九都是水月镜花,留不住的,我们不能理解这一点,也就不能理解为什么软件架构这么 重要。
越是混沌的系统,越需要我们守得住基本面。
很多人也许觉得这里讨论的问题都很简单,但越是简单的东西你越守不住,当你被眼花缭 乱的变化吸引了大部分的注意力的时候,你回过头来想一想,你当初的需求到底是什么。 这就是我们说的:执古之道,以御今只有,能知古始,是谓道纪。我们能掌握现在的复杂 局面,我们必须回到最开始解决的问题上,我们才有可能理解和控制现在的一切变化。
IO问题其实也就是前面我们提到的队列问题。队列的作用是做速度匹配,让执行速度不同 步的两个系统可以匹配在一起。在自己的业务程序中,可能就是因为计算压力不一样,你 调度不同数量的CPU来处理计算的不同部分,所以你通过队列让不同数量的CPU可以平衡计 算资源。
IO问题和这个没有什么两样,只是计算资源换成了IO资源。CPU准备好了数据,IO设备没有 准备好,或者反过来,最终也是先把数据送入队列,等队列搬运者(CPU的线程或者IO设备 自己的“线程”)把数据消化掉。监控队列的长度和“清库存”的速度,就可以观察系统的性 能压力。
比如在top中,我们最常见的一个参数是load:
.. figure:: _static/iotune1.png
这个就是运行队列的长度(top显示了1,5,15分钟的平均值),根据我们有多少个CPU, 我们就大概知道系统有多忙了(不过这个值不太容易用,因为消化进程的速度不是个定值 ,取决于这个进程的需求)。
其他IO系统也有相应的统计参数可以参考。我们用得最多的是网络和存储子系统。本篇介 绍这两个子系统的跟踪,我对这两个子系统不是太熟悉,这里仅仅是通过写作,把一些破 碎的知识组合起来,不当之处,请读者指正。
网络子系统很复杂,但只考虑队列就会相对简单。网络子系统的队列在socket上,当你没 有对应socket的时候,收到的包要不要转发,要不直接就drop了,只有你有Socket的时候 ,包才会留在Socket的buffer中,等待socket的应用程序把数据取走,Socket Buffer的大 小可以通过setsockopt()来设置(SO_RCVBUF / SO_SNDBUF),默认值在/proc/sys/net中 可以设。还有一些其他协议(比如PRS)也会对进入的包的长度进行控制。但无论如何,你 可以在ethtool -S和netstat -s中看到由于这些原因drop掉的包,前者是硬件层的统计, 后者是协议栈中的统计。下面是我们一款自产网卡的统计结果:
.. figure:: _static/iotune2.jpg
_ .. figure:: _static/iotune3.jpg
(ethtool -S的结果是实现相关的,每款网卡都不一样)
如果真的发生sock队列的丢包,ftrace的sock:* 事件可以具体跟踪到这个事件。
网卡驱动通过NAPI执行收报操作,NAPI的原理是根据上层的水线控制,在网卡的中断的驱 动下,一次收入一定数量的包,然后停止,再等下一次中断。每次收一个包的时候,要分 配一个skb作为收报的基础。这个结果就是,网卡polling的时候,skb的数量仅受内存大小 的控制(sky分配直接从内存空间分),但当这个包被送入协议栈,同步调度到socket或者 转发缓冲的时候,流控机制就会起作用,这样就会反向压制网卡的收报,最后包要不在协 议栈一层丢弃,要不就在网卡上丢掉。我们要看这个地方是否发生丢包,要同时看netstat -i以及ethtool中特定网卡自己报上来的网卡自己丢掉的包。
网络子系统没有单独的ftrace tracer,但我们可以通过event跟踪来跟,sock,napi, net 三个子系统和网络协议栈相关,其中sock可以跟踪socket超限的事件,napi可以跟踪网卡 收报的调度,net可以跟踪收发的动作。
.. figure:: _static/iotune4.jpg
存储子系统主要依靠块设备子系统发挥作用,通过iostat我们可以有一个初步的统计结果 :
.. figure:: _static/iotune5.png
这其中有三类参数,一个是实际的带宽,我们可以用这个来比较物理设备的线速。第二个 是队列的平均长度,avgqu-sz。还有一个是队列中每个元素从进入队列到离开队列的平均 时间。监控后面两个值基本上就可以获知系统的瓶颈。
和网络子系统不同,存储子系统都有反压(都是同步调用,或者异步会直接让使用者一方 丢包,而不是直接在自己一方丢包),所以,我们基本上没有丢包的问题。要支持设备是 否开始反压,看%utils参数就可以看出来,设备反压这里这个参数应该是100%。
所以,我们的问题通常不是在线速上,就是在调度(io层自己的调度)上,这个可以用 ftrace的blk tracer来跟。echo blk > current_tracer中就可以实施专门针对块设备层的 跟踪。这个跟踪器的控制是放在每个块设备上的,你需要到/sys/block/<块设备>/trace/ 下面对这个块设备的跟踪进行支持(比如enable,filter等),下面是我随便对一台PC的 sda的跟踪:
.. figure:: _static/iotune5.png
其中那个动作标记的含义从代码上就可以找出来:::
[__BLK_TA_QUEUE] = {{ "Q", "queue" }, blk_log_generic },g
[__BLK_TA_BACKMERGE] = {{ "M", "backmerge" }, blk_log_generic },g
[__BLK_TA_FRONTMERGE] = {{ "F", "frontmerge" }, blk_log_generic },g
[__BLK_TA_GETRQ] = {{ "G", "getrq" }, blk_log_generic },g
[__BLK_TA_SLEEPRQ] = {{ "S", "sleeprq" }, blk_log_generic },g
[__BLK_TA_REQUEUE] = {{ "R", "requeue" }, blk_log_with_error },g
[__BLK_TA_ISSUE] = {{ "D", "issue" }, blk_log_generic },g
[__BLK_TA_COMPLETE] = {{ "C", "complete" }, blk_log_with_error },g
[__BLK_TA_PLUG] = {{ "P", "plug" }, blk_log_plug },g
[__BLK_TA_UNPLUG_IO] = {{ "U", "unplug_io" }, blk_log_unplug },g
[__BLK_TA_UNPLUG_TIMER] = {{ "UT", "unplug_timer" }, blk_log_unplug },g
[__BLK_TA_INSERT] = {{ "I", "insert" }, blk_log_generic },g
[__BLK_TA_SPLIT] = {{ "X", "split" }, blk_log_split },g
[__BLK_TA_BOUNCE] = {{ "B", "bounce" }, blk_log_generic },g
[__BLK_TA_REMAP] = {{ "A", "remap" }, blk_log_remap },g
基本如果你对Linux的IO调度器比较熟悉,很容易就找到整个调度中的时延在什么地方引起 的。这里有一个简单的解释,读者可以参考:blktrace User Guide
如果你看了前面这个文档,就知道blk tracer有封装好的工具可以使用,安装blktrace你 就可以直接通过blktrace -d /dev/sda跟踪sda的行为,然后用blkparse sda来看结果,比 如这样:
.. figure:: _static/iotune6.png
live输出的命令是:::
blktrace -d /dev/sda -o - | blkparse -i -
具体这个调度模型如何理解,我们在后面的文档中介绍。
本文介绍了IO相关的跟踪的基本知识,原来是打算在这一篇中回答有个读者问到的IO子系 统调优问题的,但他的问题其实更多关注在虚拟机上,虚拟机的问题和IO是两个相对独立 的主题,所以,这里先独立介绍IO有关的跟踪方法,虚拟机我们独立讨论。
本篇讨论一下在docker环境中,前面说到的各种性能分析策略的变化。
Docker基于容器技术,容器包括多个技术,其中核心是namespaces。这些概念,包括 namespaces本身都还在发展中,并没有非常清晰的定义。但无论如何,我们初步可以认为 Docker本质上是一种内核模块级别的隔离技术。
所谓namespace,名称空间,是针对进程来说的。简单说,系统明明有100个线程,但我告 诉你只有10个,你也只能认为只有10个,你管不了我其他线程的存在。同样,系统的 hostname明明是kenneth-host,但我告诉你这是Nick-host,你也只能认为你的主机名是 Nick-host。这就叫名称空间。
一个简单的体验名称空间的方法是运行::
unshare -u /bin/bash
然后你用hostname mynewname修改一下你的hostname,你会发现在这个bash中hostname确 实已经修改了,但你从其他console进入你的系统,再看看hostname,它还是原来的值。
所以名称空间是依赖内核每个子系统的支持的,现在的内核(我看的是4.9),只支持6个 子系统的名称空间,读者可以到/proc/<pid>/ns目录中看到现在支持的不同子模块的名称 空间,按namespace作者的说法,他们打算实现10个子系统的名称空间。从这里我们也可以 看到,namespace和虚拟化技术很不一样。一般的虚拟化技术给你模拟了一个世界,你甚至 可能不会知道你自己是工作在一个虚拟机中的。而namespace就像给你模拟了一个舞台,你 可以在上面表演,但如果你仔细向两边看看,你还可以看到外面的世界的,你只是“认为” 它是真的,而不是“以为”它是真的。
比如说,你启动了一个docker的shell,例如这样:::
busy_app
docker run -ti --rm centos /bin/bash
你现在主系统上运行了一个busy_app,然后你进入你的docker,之后你在docker中用运行 top,你会发现你的docker中看不见busy_app,但top显示的load参数仍然很高。系统没有 对top程序封装相关的数据。
所以,至少现在这个阶段,指望在Docker内部分析系统的性能是很不靠谱的。但令人高兴 的是,容器中的进程并不会对主系统隐藏。所以,原来在主机实施的所有跟踪方法,在主 系统上同样可以用。我们唯一的问题是搞清楚对应关系,因为docker中看到的pid和从主系 统看到的pid是不同的,但ps看到的用户名,又和docker中一致,而不是和主系统的配置一 致。但只要能解决好这个问题,跟踪docker的性能问题和跟踪一般系统的方法是一样的。
Docker的网络有可能是veth,也有使用SR-IOV的,veth完全是一个rlnt_link,通过br驱动 和真实的本地网卡联结,跟踪本地网卡的技巧基本上可以同样用于这个场合。(fixme:后 面补一个例子),而如果用的是SR-IOV,那根本就是本地网卡,完全用本地一样的调优技 巧即可。
Docker使用overlayfs(文件类型是overlay)作为基础文件系统支持(具体可以通过 --storage-driver切换的),Docker自称性能可以超过aufs和devicemapper,在特定情况 下甚至可以超过btrfs,在我的环境中,大部分性能统计都不在这个文件系统中(而是直接 落在SAS驱动上)。但无论如何,这个我们仍可以使用传统的磁盘跟踪方法来发现这部分的 性能瓶颈。
这一篇看KVM环境的性能优化技巧。我没有怎么做过KVM环境的调优,但后面就要开始做了 ,所以这一篇也只是把资料整合一下,后面会逐步补充。
KVM和Docker不同,KVM是有Hypervisor的。也就是说,一旦KVM陷入Guest中,Host是完全 看不见被占用的CPU的。
这个执行模型类似这样:
.. figure:: _static/kvmtune1.png
这里的横坐标是CPU时间。我做了很多的简化,以便读者更容易基于一个相对稳固的模型思 考相关变化。
从这个图上我们可以看到,除了掌握更多的资源(IO资源),Host的地位和Guest地位几乎 是对等的。这造成一个很有趣的现象:如果我们在host上用top来看进程的CPU的占用率, Guest占掉的CPU是算在qemu头上的,因为从时间上来说,host确实看到CPU进入qemu后,就 没有出来了。但如果用perf top来看,你却看不见qemu占用CPU,因为PMU的中断打进来后 ,如果调度到Guest中,Host是看不到这个打的点的。所以perf top的报告是qemu占用并不 高。
反过来,如果我们在Guest看,top看不见Host抢去的CPU,Guest从这个角度是看不到Host 或者其他Guest抢去的CPU的时间的。Perf top同样看不到,因为Guest的中断基本上都是 Host种进去的,它只能统计它自己看到的点。
同时我们要知道,很多平台都没有实现Guest一侧的PMU事件,所以perf的功能在Guest中相 当受限。
不过,新版本的top和vmstat都支持steal time功能了,注意一下CPU占用率中那个st的值 ,它表示了本VM有多少时间被Hypervisor“偷走了”:
.. figure:: _static/kvmtune2.png
同时,绝对时间是可以被Guest感知的,所以,如果其他Guest很忙,本Guest的执行时间会 延长,感觉就好像某些指令被降低了执行速度一样。
从Host一侧跟踪KVM的行为,我们可以跟踪到进入和离开guest的时间,这个可以通过跟踪 ftrace的kvm:* 事件得到:
.. figure:: _static/kvmtune3.png
kvm_entry就是进入虚拟机的入口点,kvm_exit是离开点,里面会给出离开的原因,很大一 部分会是因为中断或者IO,一旦进入IO,这部分处理的性能就是可以被Host系统监控到了 ,就成为我们进行性能分析的基础了。
所以,如果你处理得好,保证Guest不会做无意义的死循环,你监控Host一侧的性能,就可 以监控到整个系统的性能。前面有读者问到如何分析多虚拟机情况下的性能瓶颈。我没有 干过这个工作,但我想如果监控虚拟机的切换频度(用perf的ftrace event来跟,频度可 以通过比如virt-io或者SR-IOV一类的方法来降低的),以及Host本地的磁盘跟踪,应该就 大概可以监控到位置了。
perf(在host一侧)提供了完整的KVM跟踪功能(但很可惜,这还在开始阶段,不是所有平 台都支持得好,所以,不要太以来这个特性),通过perf kvm命令提供,这个命令后面带 的子命令和标准的perf一样,支持独立perf的stat, top, record, report等命令。不同的 是,如果你可以指定单独跟踪host还是guest(通过--host和--guest来指定,默认是 --guest)。如果跟踪的是guest,我们需要通过--guestmodules,--guestksymbols, --guestvmlinux指定Guest一侧的符号信息。前面两个可以直接从guest的/proc文件系统中 取,后面是编译内核的时候生成的基础elf文件,部分发行版会提供,部分没有,你要找发 行版提供商要,要不自己编译一个内核好了。
比如,这样可以启动一个完整的3秒跟踪:::
sudo perf kvm --guestmodules the_modules --guestkallsyms the_kallsyms --guestvmlinux the_vmlinux --guest --host record -a -- sleep 3
如何报告,估计也不需要我来解释了。
上面这个record的例子,指定了很多符号,但并没有指定用户态程序的符号,要指定这个 符号,你需要用 --guestmount指定整个guest的根文件系统,用了这个你也就不需要 --guestmodules和--guestkallsyms了。一般的操作方法是通过sshfs把guest文件系统整个 牟尼它到本地:::
sudo sshfs -o allow_other,direct_io ken@192.168.122.84:/ /tmp/sshfs
之后指定这个/tmp/sshfs就可以了。
这一篇介绍一下Linux的调度模型,作为调优考虑问题的参考。
这不是说要介绍Linux现在具体调度算法,Linux代码最大的特点就是“不断变化”,所以, 介绍一个两个的调度算法不能解决调优的问题,我们要理解的是这些算法背后不变的东西 ,然后根据情况看代码才有意义。
Linux的调度算法换过很多次了,最新的调度算法称为CFS(完全公平调度器),在我们介 绍这个算法的特点前,我们先理解一下调度器到底解决什么问题。
从一个线程切换到一个线程是平台相关代码,原理也比较死板简单,就是把上一个线程的 CPU环境全部保存在TCB上,然后把下一个线程的TCB恢复到CPU寄存器中。这个不是调度器 的重点。调度器的重点是选哪个线程投入运行。所以,严格来说,调度器是个数学问题: 我给你一组参数(比如线程的优先级,使用了的时间等),你告诉我下一个要运行的线程 是谁。
首先,休眠或者挂起的任务是不需要调度的,所以,理论上,我们可以认为,调度器只需 要考虑需要运行的线程,这种线程的数量,就称为CPU当前的load。正如我们在这个系列的 第一个文档中说的,这是调度队列的“长度”。
CPU把什么线程投入运行呢?这个问题在RTOS中是很好解决的,就是按优先级呗,谁的优先 级高就谁运行,一直运行到这个线程不需要CPU为止。我们以前招过一位来自老牌服务器OS 的专家来带领我们的OS开发团队,这个专家和我们讨论调度算法的时候就说:RTOS的调度 器有什么好做的?玩来玩去不就那么点东西?这种说法显得有点骄傲了,但至少说明,在 这些老牌服务器OS的人眼中,是不把实时调度算法看作是调度器要解决的主要问题的。
我们真正要解决的是SCHED_NORMAL的线程如何调度。自然的想法是按时间片,每人50ms, 一路执行过去即可。 这就是CFS的所谓“公平”的含义:大家都用那么多时间,谁也不要亏 欠谁。但这样带来一个基本的问题:比如你有200个线程,每人执行50ms,其中你有一个编 辑器,那么你在这个编辑器上敲一个a,这个a得10秒以后回显给你——这个系统怎么用?
一个简单的补救是,把这个线程定义为实时线程,这样,这个线程会优先得到调度。但服 务器不是嵌入式系统,服务器是不能这么干的。因为服务器是通用系统,如果随便一个编 辑器就是实时线程,那很轻松就可以用一个编辑把这个CPU完全占用了,别的程序就不用跑 了。所以每一代Linux调度器都要解决这个最基本的问题:找出谁是“编辑器”。这种线程, 调度器称为“交互线程”,如果你看的是Linux传统的O(1)调度器,或者更早的调度器实现, 调度器是有明确的交互线程的概念在算法中的。其基本原理是挑出每次都用不完自己时间 片的线程。提升这种线程的优先级,这种就是交互线程。
CFS没有这个问题,CFS的算法是这样的:给每个在调度队列中的线程一个vruntime的变量 ,记录这个线程运行了多久,每次调度,都调度vruntime最小的线程,这样,自动优先执 行的就是交互线程了。
这是理解Linux调度器的基础,无论哪个算法,Linux必须保证交互线程优先得到调度。然 后才考虑其他问题。这是我们调优调度问题的基础。
有了这个基础,线程的nice值就仅仅是个如何加权的问题了。比如在CFS算法中,nice值作 为vruntime流逝的加权, 这个nice大的进程时间流逝就快,它占据的时间片就少了。
多核调度其实是单核调度算法的复制。多核的CPU其实互相是看不到对方的,CPU不过就是 一条指令一条指令向下执行。所谓多核调度,就是每个CPU有一个run queue(下面简称rq ),创建线程(下面简称task)的时候把线程扔到一个rq中,那个CPU就对这个rq执行单核 的调度算法而已。
当然,那个仅仅是基础。多核调度还有一个任务是平衡调度。就是一个核特别忙,另一个 核特别闲,就需要把task从一个核迁移到另一个核的runqueue中。
迁移要考虑的问题比前面说的这个模型复杂,因为有很多额外的要素要考虑:比如,一个 核的两个超线程,它们共享相同的执行部件,很多时候平衡它们是没有意义的。又比如说 ,两个CPU,有不同的L2 Cache,你轻易切换它们,就有可能发生大量的Cache失效。还有 ,如果你把线程从NUMA系统的一个Node迁移到另一个,就把它的内存和它运行CPU拉远了, 这会直接降低这个线程的执行性能。
所以Linux把线程组织在多个不同的sched_domain中,每个domain包含一组线程,每个 domain有自己独立的算法,这些算法自行决定是否进行调度平衡。我们可以通过lscpu看 CPU的组成结构,但真正有哪些domain,以及domain的参数,则需要看 /proc/sys/kernel/sched_domain(或者简单一点可以看/proc/schedstat)。
不过要我说,如果你不能看懂调度算法,其实只要记住两个技巧就好了:
通过ftrace跟踪sched:sched_migrate_task跟踪任务转移的频度
把老切换的任务绑定在特定的核上
其他问题还是留给调度器专家吧。
在服务器上,我们一般只关注通量和时延,但现在的调度器开始要关心功耗问题了。这个 真的让这个问题变成一个数学问题了。功耗要考虑的要素包括几个:
休眠:CPU休眠就可以降功耗,那么你是把两个任务放两个CPU上以便提高速度呢?还是 把它们合并到一个CPU上让其中一个CPU休眠呢?
DVFS:CPU可以调频来降功耗,还是那句话,你是把两个任务合并到一个CPU上,让另一 个休眠呢,还是分在两个CPU上,让两个一起降频呢?
CPU热插拔:休眠可以降功耗,但比不上把这个CPU直接下电,但下了电,要恢复就很不 容易了,那你要更快恢复还是要降功耗呢?
ARM现在尝试通过AWS项目(Energy-Aware Scheduling (EAS) Project)把这三个要素整合 在一起。但最终内核会修改成什么样子,还有待观察。在EAS可以商用前,每个手机还要考 虑自己用那种策略去聚合前面的要素。
谢天谢地,我现在不用搞手机了:)
这一篇讲磁盘IO的模型。
Linux的文件IO子系统是Linux中最复杂的一个子系统(没有之一)。读者可以参考以下这 个图: https://www.thomas-krenn.com/de/wikiDE/images/2/2d/Linux-storage-stack-diagram_v4.0.pdf
如果你懒得跳过去,这里贴一个:
.. figure:: _static/linuxio.png
你什么细节都看,这个图肯定把你搞晕了。我们还是用找“道纪”的方式一点点复盘这个设 计。
存储系统可以认为有两个部分,第一个部分站在用户的角度,提供读写的接口。这里的名 称空间是以流为中心的。第二部分站在存储设备的角度,提供读写接口,这里的名称空间 是以块为中心。插在两者中间提供承接的是文件系统(不是指VFS,我指的是Ext4这样的文 件系统)。
简单理解,你的应用程序发出一个读写请求,文件系统负责定位这个读写请求的位置,换 成块设备的块,然后把这个请求发到设备上,把文件写入内存中,你的应用程序就从内存 中获得数据。
所以,你会发现,上下两个部分是个异步的运行模型,对上半部分来说,不到没有办法的 时候(比如你第一次要磁盘中的数据),让数据一直留在内存中是最好的。因为谁知道你 后面还会不会修改这个地方呢?如果你还要读写,同步到磁盘中不是白干了?所以,数据 什么时候和磁盘同步,是个独立的逻辑,和你的应用的要求(比如主动sync),内存空间的 大小(比如你Pagecache占据太多的内存了),都有关系。
这个文档不是讨论Cache部分的行为,这个部分理解到这里为止。稍深入一点的介绍可以看 这里:Linux 中 mmap() 函数的内存映射问题理解? - in nek 的回答。这部分的模型是 纯软件模型,用一般热点方法分析就可以了。
本文主要关注下半部分的通用部分的模型,也就是上面图中Block Layer的运行模型。
数据从Pagecache同步到磁盘上,发出的请求称为一个request,一个request包含一组bio ,每个bio包含要同步的数据pages,你要把Page和磁盘的数据进行同步。
和网络子系统不同,磁盘的调度是有要求的,不是说你发一个page,我就帮你写进去,你 再发一个page,我就给你再写一个进去。你要写磁盘的一个地方,磁盘要先把磁头物理上 移动到那个轨道上,然后才能写,你让磁头这样移来移去的,磁盘的性能就很难看了。
Linux的IO调度器称为evelator(电梯),因为Linus开始实现这个系统的时候,使用的就 是电梯算法。
坐过电梯很容易理解什么是电梯算法,电梯的算法是:电梯总是从一个方向,把人送到有 需要的最高的位置,然后反过来,把人送到有需要的最低一个位置。这样效率是最高的, 因为电梯不用根据先后顺序,不断调整方向,走更多的冤枉路。
为了实现这个算法,我们需要一个plug的概念。这个概念类似马桶的冲水器,你先把冲水 器用塞子堵住,然后开始接水,等水满了,你一次把塞子拔掉,冲水器中的水就一次冲出 去了。在真正冲水之前,你就有机会把数据进行合并,排序,保证你的“电梯”可以从一头 走到另一头,然后从另一头回来。
我们前面讲IO系统的时候就提过磁盘调度子系统的ftrace跟踪,这里我们深入看看 blktrace跟踪到的事件的含义:
请求相关::
Q - queued:bio请求进入调度
G - get request:分配request
I - inserted:request进入io调度器
调度相关::
B - bounced:硬件无法访问内存,需要把内存降低到硬件可访问
M - back merge:请求和前一个从后面合并
F - front merge:请求和前一个从前面合并
X - split:请求分析为多个request(很可能是因为硬件不支持太大的请求)
A - remap:基于分区等,重新映射request的位置
R - requeue: Request重新回到调度队列
S - sleep:调度器进入休眠P - plug:调度队列插入设备(准备合并)
U - unplug:调度队列离开设备(全部一次写入设备中)
T - unplug due to timer超时,而不是数据足够发起的unplug
发出相关::
C - complete:完成一个request的调度(无论成功还是失败)
D - issued:发送到设备,这个是从下层硬件驱动发起的
我们通过对这些事件的跟踪,对照硬件的特性大概就可以知道运行的模型是否正常了。
但情况千变万化,不是每种磁盘,每个场景都可以用一样的算法。所以,现在Linux可以支 持多个IO调度器,你可以给每个磁盘制定不同的调度算法,这个在 /sys/block/<dev>/queue/scheduler中设置。它的用户接口设计得很好,你看看它的内容 就明白我的意思了:::
noop [deadline] cfq
上面三个是我的PC上支持的三个算法,其中被选中的算法是deadline,写另一个名字进去 可以选定另一个调度算法。
我们简单理解一下这三个算法:
noop是no operation,就是不调度的算法,有什么请求都直接写下去。这通常用于两种情 形:你的磁盘是比如SSD那样的内存存储设备,根本不需要调度,往下写就对了。第二种情 形是你的磁盘比较高级,自带调度器,OS不需要自作聪明,有什么请求直接往下扔就好了 。这两种情况就应该选noop算法
deadline是一个改良的电梯算法,基本上和电梯算法一样,但加了一条,如果部分请求等 太久了(deadline到了,默认读请求500ms,写请求5s),电梯就要立即给我掉头,先处理 这个请求。
CFQ,呵呵,这个名字是不是有种特别熟悉的感觉?对了,它就是我们前面我们讲CPU调度 器时提到的CFS,完全公平调度器。这个算法按任务分成多个队列,按队列的“完全公平”进 行调度。
利用这个算法,可以通过ionice设定每个任务不同的优先级,提供给调度器进行分级调度 。例如:::
ionice -c1 -n3 dd if=/dev/zero of=test.hd bs=4096 count=1000
这个命令要求后面的dd命令按三级实时策略进行调度(实时策略需要root权限)。
ionice不需要CFS就可以工作,只是没有CFQ,策略并不能很好得到执行。通常我们在 Desktop上使用deadline,在服务器上用QFQ。
对调优来说,切换调度器和相关调度参数是最简单的方案。更多的其他考虑,那就是看你 怎么跟踪了。
另一方面,一个块设备一个队列的策略,面对新的存储设备,也开始变得不合时宜了。我 们这样来体会一下这个问题:
普通的物理硬盘,每秒可以响应几百个request(IOPS)
SSD1,这个大约是6万
SSD2,~9万
SSD3,~50万
SSD4,~60万
SSD5,~78.5万
一个队列?开玩笑!
一个队列会带来如下问题:
全部IO中断会集中到一个CPU上
不同NUMA节点也会集中到一个CPU上
所有CPU访问一个队列会造成前面提到的Amdahl模型的spinlock问题
所以,和网络一样,现在块设备子系统也支持多队列模型,称为mq-blk。也和队列网卡一 样,这个特性需要你的硬件支持多队列,如果你使用scsi库,你需要你的设备支持mq-scsi 。使用m-blk后,io调度器直接作用在每个queue上。
AIO的名字叫异步IO,其实本质上并非是对文件系统的异步封装,而是从块设备层直接出访 问接口,只是这个接口恰好是异步的,所以才叫AIO。
AIO的实现在内核fs/aio.c中,现在好像还没有对应的库封装(请注意,posix的aio(7)文 档描述的AIO接口是POSIX库自己对文件系统的封装,而Linux内核的AIO现在没有库的封装 ,需要用户自己做系统调用来实现。除非特别指出,本文涉及的AIO接口都指向Linux内核 的AIO,而不是Posix的AIO。
AIO包括如下系统调用:
io_setup() 创建一个context上下文
io_destroy()删除context
io_submit() 发出命令
io_get_events() 接收命令(事件)
io_cancel() 取消
这个接口不用过多解释,猜都能猜到怎么用(具体细节自己上网搜),这里想说明的是调 度模型。本质上,AIO的“事件”就是我们前面分析块设备接口中的一个“命令”(读写都可以 ),一个io_submit()就是一次plug(),unplug()的过程,也就是你发起一个submit,发出 一组iocb,每个iocb就是一个读写操作,submit的时候就先plug,把请求堵住,然后把 iocb灌进去,然后unplug,把请求发出去。
所以,对AIO的跟踪不会比一般的跟踪复杂,因为都是跟踪块设备层的方法。
这一篇总结Linux网络IO的调度模型。还是那句话,这里仅仅是建一个便于讨论问题的初步 模型,更多的信息我们在初稿出来后,有机会就慢慢调整。
还是老规矩,我们从最简单的模型谈起。我们把重点放在队列和线程的模型上。
先看发送,用户程序用socket的send之类的函数给协议栈写入数据,协议栈使用用户线程 来驱动第一步的行为:分配skb,把数据拷贝进去,经过协议栈的处理,直到到达网卡驱动 ,这之前都不需要队列。但到了网卡驱动就不行了,因为我们并不能确认网卡现在就绪了 。所以到这里需要一个等待队列(具体队列的形态,我们在qdisc的时候再讨论)。
第二个执行流是网卡的中断,当网卡完成了上一波数据的发送,它给CPU(驱动)发来一个 中断,中断raise一个软中断完成发送的动作。
这段执行有两个线程(我这里把具有独立执行能力的软中断也看作是一个线程),两个队 列:网卡硬件上的队列和协议栈的发送队列。
接收则反过来,网卡收到足够的消息了,给CPU发一个中断,CPU分配skb,接收报文(实际 上现代的网卡通常是把skb预分配给硬件,让硬件自己填skb,收的时候只是替换一批空的 skb,并把收好的包往上送。但这两者在调度上原理一样,所以我们这里认为是一回事的) ,然后把skb送入协议栈(注意,这里仍使用softirq的上下文),最后到达socket的队列 ,通知用户态的线程从队列中获得数据)
这里仍是两个队列两个线程。
其实协议栈常常还有第三种执行流,就是把Linux作为一个路由器或者交换机,做转发,但 对于大型商业应用,我们不靠CPU来做转发,这个部分的压力分析没有什么价值,我这里就 不讨论了。
现代的Linux网卡驱动通常使用napi来实现上面的流程。NAPI提供一个相对统一的处理机制 ,但其实不怎么改变前面提到的整个处理过程。
NAPI的主要作用是应对越来越快的网络IO的需要:网络变快以后,从网络送上来的包可能 会超过CPU的处理能力,如果让接收程序持续占据CPU,CPU就完全没有时间处理上层的协议 了。所以NAPI每次接收(或者发送)不允许超过特定的budget,保证有一定时间是可以让 给上层协议的。
使用NAPI后,网卡驱动的收发IRQ不再使用自己的收发处理,而是调用napi_schedule(), 让napi_schedule()激活softirq,在里面决定每次poll多少数据,以及在polling超限的时 候,等待多长时间再进行下一波调用。(每次polling有tracepoint的,trace_napi_poll ,我们可以跟踪这个点来判断状况)。
NAPI比较有趣的地方是,很多网卡的实现,无论是发还是收,其实用的都是 NET_RX_SOFTIRQ。这个是我们分析的时候要注意的。
网卡的Offload特性,比如GRO等,能在很大程度上把很多CPU协议栈必须完成的工作 Offload到网卡上,但那个和调度无关,这里忽略。
有前面看CPU调度和存储调度的经验,我们应该很容易就看到网卡这个模型的问题了:只有 4个线程,基本上无法充分利用多核的能力。所以现代网卡通常支持多队列。用ethtool -l 可以查看网卡的多队列支持情况,下面是我们的网卡的多队列支持输出(每个网卡多少个 队列可以通过BIOS配置):
.. figure:: _static/nettune1.jpg
网卡和协议栈会把数据通过特定的Hash算法分解到不同的队列上,从而实现性能的提升。 发送方向上,协议栈通过设置skb的queue_map参数为一个包选定使用的queue,网卡驱动也 可以根据需要调整已经被选择的队列。接收方向上,网卡一侧的算法称为RSS(Receive Side Scaling),它通过源、目标的IP地址和端口组成等进行Hash,算的结果通过一个转 发表调度到不同的queue上,我们可以通过ethtool -x/X来查看或者就该这个转发表:
.. figure:: _static/nettune2.jpg
网卡的多队列的模型其实挺简单的,最后就是看我们怎么分布不同网卡和内存的距离,保 证对一个的业务在靠近的Numa Node上就好了。
RSS主要是针对接收方向的,发送方向的情况会更复杂一些,因为发送方向上我们需要做 QoS(接收方向上你没法做,因为你没法随便调整送过来的消息的顺序)。Linux网络的QoS 特性称为TC(Traffic Control,实际上TC比一般意义的QoS强大得多),它工作在协议栈 和网卡驱动之间。当协议栈最终决定把一个skb发到设备上的时候,它调用 dev_queue_xmit()启动调度,这时进入的不是网卡的发送函数,而是qdisc子系统(它是 netdev的一部分),qdisc可以使用不同的算法把消息分到不同的队列(注意,这个队列不 是网卡的队列,而是qdisc的队列,为了区分,我们后面简称disc_q,每个网卡的队列有至 少一个disc_q对应)上,然后用napi的发送函数来触发qdisc_run()(这个函数会在 netif_tx_wake_queue()等函数中自动被调用)来实现真正的驱动中的发送回调。
qdisc支持的调度算法极多,读者们自己搜一下register_qdisc()这个函数的调用就可以领 略一下。要了解所有这些算法,可以参考man 8 tc (或者tc-<算法名>)。
我们这里简单看看默认的算法pfifo_fast体会一下:
.. figure:: _static/nettune3.jpg
FIFO很好理解,就是先入先出(有一个独立的算法叫pfifo),pfifo的队列长度可以用tc 手工设置,pfifo的队列长度由驱动直接指明(netdev->tx_queue_len),如果超过长度没 有发出去,后续的包就会丢弃。pfifo_fast和pfifo不同的地方是,pfifo_fast可以根据报 文的ToS域把报文分发到三个队列,然后按优先级出队列(具体优先级的算法可以参考man 8 tc-prio)。这在队列没有发生积累的时候等于没有用,但在队列有积累的时候,高优先 级的包就会优先得到保证。
qdisc可以玩出延迟(比如通过netem强行delay每个包的发出,随机丢包等),限流,控制 优先级等很多花样。它可以通过class进行叠加:
.. figure:: _static/nettune4.jpg
(这个配置把sfq,tbf,sfq三个算法叠加在prio算法中,实现不同分类的包用不同的方式调 度)
这也给调优带来很多的变数,这些我们只能在具体的环境中做ftrace/perf才能跟踪出来了 (幸运的是,网络子系统的tracepoint还是很完善的)。
ODP (http://opendataplane.org) 是ARM体系结构的协同社区Linaro推出的网络处理构架 ,这个构架其实和我们讨论到的Linux系统调优的关系不大。因为它几乎和Linux一点关系 没有。ODP一般用于固定使用几个核用于数据处理的场景。比如你做一个路由器,管理,路 由协议的部分使用Linux的协议栈处理,但转发的部分用Linux这么复杂的框架进行处理就 不合适了。这种情况你可以写一个ODP的应用程序,独占其中几个核,这个应用程序直接从 网卡上把包拉出来,然后处理,然后转发出去。这个就称为“数据面”处理。ODP设计上就预 期和DPDK的网卡兼容,它对网卡的包的格式没有强制要求,它提供一整套编程接口(内存 分配,定时器,任务管理等),整个平台工作在用户态,默认的业务模型不是基于中断的 ,而是基于polling的(反正这个核除了做转发也不做别的事情),调度方法就是多个任务 (线程)调度“Queue-分发-下一个Queue”这样的模型,所以一般应用的调优手段和优化原 则可以直接用于ODP。我们这里也没有什么可以特别补充的了。