性能优化方法
- 不要做:消除不必要的工作。
- 做,但不要再做:缓存。
- 做少点:将刷新、轮询或更新的频率调低。
- 稍后再做:回写缓存。
- 在不注意的时候做:安排工作或在非工作时间进行。
- 同时做:从单线程切换到多线程。
- 做得更便宜:购买更快的硬件。
应用程序优化
- On CPU 分析:基于 Perf Record + 火焰图
- Off CPU 分析:针对 schedule 进行 trace,通过 ebpf 设置 off cpu 阈值(大于 1us 才记录)并在内核空间聚合调用栈可以降低对应分析带来的开销。除此之外,追踪器们(除了 strace)还利用缓冲跟踪大大减少了跟踪的开销,其中事件被写入一个共享的内核环形缓冲区,用户级别的跟踪器定期读取缓冲区,减少了用户和内核之间的上下文切换,降低了开销.同时由于存在 RingBuffer,因此也可以调整对应的 RingBuffer 的到校记录最近 30s 的记录,并在出现问题的时候停止 Trace,并从 RringBuffer 读取
- USE 方法,针对关键组件分析,比如:线程池,文件数量,缓存大小等
- 线程状态分析
- 锁状态分析
- 系统调用分析,主要针对:网络(tcp_),文件系统(ext4_),磁盘 IO(blk_),锁(futex_),内存分配(mmap,brk)
- 业务负载画像:业务负载画像的目的是理解实际运行的业务负载。你不需要对最终的性能结果进行分析,比如系统的延迟到底受到多少影响。“消除不必要的工作”是在性能优化中收益最显著的一种,通过研究业务负载的构成就可以找到这样的优化点。
开展业务负载画像的推荐步骤如下:- 负载是谁产生的(比如,进程ID、用户ID、进程名、IP地址)?
- 负载为什么会产生(代码路径、调用栈、火焰图)?
3.负载的组成是什么(IOPS、吞吐量、负载类型)? - 负载怎样随着时间发生变化(比较每个周期的摘要信息)?
Linux 60s 分析
- uptime
- dmesg | tail
- vmstat 1
- mpstat -P ALL 1
- pidstat 1
- iostat -xz 1
- free -m
- sar -n DEV 1
- sar -n TCP, ETCP 1
10.top
Tracing 相关
原理介绍
Trace 的本质其实就是带有 Timestamp 的事件的记录,核心事件主要有三类:
- 调用事件
- 返回事件
- 切换事件
后处理程序会将一个个事件点转化为一个个时间段,每个事件都会结束上一个时间段,并开始新的时间段(调用事件开始的时间段就是对应的调用名,返回事件和切换事件开始的时间段则通过栈顶获取对应的调用名)
其中对偶的调用事件和返回事件(系统调用,中断,异常等)可以很方便地转化为连续的时间段。不过由于存在中断,时间段会被切割成多段,因此在处理返回事件的时候,需要基于每个 PID 对应的栈进行恢复(抛出并结束当前栈顶对应调用事件的时间段,并重新开始新栈顶对应调用事件的时间段)
而对于切换事件(比如:进程切换),虽然无法转化为时间段,但是它可以用来切换当前 CPU 上的 PID 以及栈,并且分割时间段,另外需要注意的是,A CPU 上的一个调用事件会由于调度(切换事件)而在 B CPU 上重新执行,这里需要好好注意各个时间段的生成
使用 Trace 工具的时候一定要考虑好如下三点:
- 跟踪的目标是什么
- 跟踪的频率与时长是多少
- 跟踪的开销是多大(额外的 CPU 与存储空间),好的经验法则是不超过 1%,2% ~ 10% 可能也是可以接受的,但是 20% 肯定不行
飞行器记录模式
这里可以介绍一下飞行记录器模式:为了减少开销, trace 产生的事件都是直接记录在内存中的(为了避免写竞争,每个 CPU 会有一批独属的64KB 的缓存块)。当停止 trace 后,对应的处理函数会将二进制数据落盘到磁盘,再进行后处理(事件点转化为带时间序列的 Tracing 图)并重新存储到新的可读文件。由于内存大小有限,因此一般只能存短期的事件,而考虑到某些偶发性的延迟问题,飞行记录器模式就是将事件记录到 RingBuffer 中,并不断丢弃超过大小的事件直到遇到异常事件,此时往往能保存对应事件前一小段时间的序列
追踪点
tracepoint、 kprobes、USDT、uprobes 是注入 probe handler 调用的机制。
-
tracepoint 是在 Linux 内核的一些关键函数中开发时埋下的 hook 点,这样在 tracing 的时候,我们就可以在这些固定的点上挂载 probe handler,然后查看内核的信息
-
kprobe 相较于 tracepoint 通过动态指令替换实现了可以动态地在所有的内核函数(除了 inline 函数)上挂载任意 probe handler 进而获取对应的信息
-
USDT 是一些大型的用户态应用,比如:glibc, mysql,jvm 等事先在用户态 lib 中打入的点
-
uprobes 则是再用户态函数上动态打入的点,其实现有点类似于 kprobe
probe handler 在内核态执行,抓取到的追踪数据往往需要传递到用户态做分析使用,perf_event、trace_event_ring_buffer、eBPF Map 都是从内核态向用户态传递数据的方式。
trace_event_ring_buffer 是通过虚拟文件系统 TraceFS 的方式暴露追踪数据。eBPF Map 有多种实现方式,有基于 perf event 的、有基于系统调用的,有基于 BPF ringbuffer 的。
Perf 使用
perf 底层通过 perf_event_open系统调用,结合硬件事件(PMC)和软件事件记录,开启对于特定事件的记录和采样。该系统调用返回一个 fd 用于对记录到的事件进行进一步加工,从而用于分析性能。event 是 perf 工作的基础,主要有两种:有使用硬件的 PMU 里的 event,也有在内核代码中注册的 event。而perf 使用这些 event ,也有计数和采样两种方式。
perf stat 这个命令就是来查看 event 的数目的,这里我们可以额外加上"-e"参数,指定某一个 event 来看它的计数,比如 page-faults
perf record 命令就是采样记录事件发生时的一些额外信息,比如记录当前栈结构,从而找到热点函数(一直在栈顶的函数),记录当时的 IP 记录的执行的指令,记录当时的进程等。perf record 在不加 -e 指定 event 的时候,它缺省的 event 就是 Hardware event cycles, 但是 cycles 事件对应的 PMC(Performance Monitoring Counter 寄存器)在每个 cpu cycle 都会增加,因此实际上,perf 利用硬件性能计数器的溢出中断机制,设置一个初始计数值,当 cycles 计数达到这个值时触发中断,再记录样本通过增加 -g 参数可以获取整个栈帧结构从而生成火焰图
由于每次事件发生都记录导致的开销过大,因此采用了采样去实现数据获取,采样本身也主要有两种实现(彼此互斥):
第一种是按照 event 的数目(-c),比如每发生 10000 次 cycles event 就记录一次 IP、进程等信息, perf record 中的 -c 参数可以指定每发生多少次,就做一次记录。
第二种是定义一个频率(-F), perf record 中的 -F 参数就是指定频率的,比如 perf record -e cycles -F 99 -- sleep 1 ,就是指采样每秒钟做 99 次,后面的 sleep 1 并不是 sleep 命令,而是参数,代表总共采样 1 秒。
第一种方式可以进一步地和 -e 参数结合:
-
-e 参数的作用:
- -e 用于指定要监测的事件类型。
- 可以是硬件事件(如 cycles, instructions)或软件事件(如 context-switches)。
-
-c 参数与 -e 的关系:
- -c 指定触发一次采样所需的事件计数。
- 对于硬件事件,-c 直接控制采样频率。
- 对于软件事件,-c 的作用可能有所不同。
-
软件事件(如 context-switches)的特殊性:
- 软件事件通常不像硬件事件那样频繁。
- 对于 context-switches,每次发生上下文切换时都会触发事件。
-
不指定 -c 的默认行为:
- 对于 context-switches 这样的软件事件,如果不指定 -c,perf 通常会记录每一个事件。
- 即默认 -c 1,每发生一次上下文切换就记录一次。
-
指定 -c 的效果:
- 如果指定 -c N(N > 1),则每 N 次上下文切换才记录一次。
- 例如:perf record -e context-switches -c 100 会每 100 次上下文切换记录一次。
在 perf record 运行结束后,会在磁盘的当前目录留下 perf.data 这个文件,里面记录了所有采样得到的信息。然后我们再运行 perf report 命令,查看函数或者指令在这些采样里的分布比例,不过很多时候,为了更加直观地看到各个函数的占比,我们会用 perf script 命令把 perf record 生成的 perf.data 转化成分析脚本,然后用 FlameGraph 工具来读取这个脚本,生成火焰图。
ftrace 使用
如果我们通过 perf 发现了一个内核函数的调用频率比较高,就可以通过 function tracer 工具继续深入,这样就能大概知道这个函数是在什么情况下被调用到的
perf 和 ftrace 的区别在于前者是通过采样的方式实现的,并可以用来生成火焰图,它更加关注粗粒度的哪些函数耗时更多,后者则是精确地记录每一个事件,然后以时间轴生成 Tracing 图,它更加关注细粒度的执行逻辑关系
# echo nop > current_tracer 关闭并清空ftrace
# echo do_mount > set_ftrace_filter 设置 filter 并追踪 do_mount 方法(可以追踪任何内核方法)
# echo ‘!do_mount ‘ > set_ftrace_filter 关闭 filter
# echo 1 > options/func_stack_trace 打开 stack 功能
# echo function > current_tracer 开启 ftrace 并记录 function
ftrace 实现
"-pg -mfentry"这两个参数的作用是,给编译出来的每个函数开头都插入一条指令"callq
而"-mrecord-mcount"参数在最后的内核二进制文件 vmlinux 中附加了一个 mcount_loc 的段,这个段里记录了所有"callq
为了记录额外信息,在内核启动初始化的时候,ftrace 又申请了新的内存来存放 mcount_loc 段中原来的地址信息,外加对每个地址的控制信息,最后释放了原来的 mcount_loc 段。
为了避免 call
我们需要用 function tracer 来 trace 某一个函数的时候,比如"echo do_mount > set_ftrace_filter"命令执行之后,do_mount() 函数的第一条指令就会被替换成调用 ftrace_caller 的指令。
eBPF 实现
一个 eBPF 的程序分为两部分,第一部分是内核态的代码,这部分的代码之后会在内核 eBPF 的虚拟机中执行。第二部分是用户态的代码,它的主要功能是负责加载内核态的代码,以及在内核态代码运行后通过 eBPF maps 从内核中读取数据。
然后我们看看 eBPF 内核态程序的编译,因为内核部分的代码需要被编译成 eBPF bytecode 二进制文件,也就是 eBPF 的虚拟机指令,而在 Linux 里,最常用的 GCC 编译器不支持生成 eBPF bytecode,所以这里必须要用 Clang/LLVM 来编译,编译后的文件就是 foo_kern.o。
foo_user.c 编译链接后就会生成一个普通的用户态程序,它会通过 bpf() 系统调用做两件事:第一是去加载 eBPF bytecode 文件 foo_kern.o,使 foo_kern.o 这个 eBPF bytecode 在内核 eBPF 的虚拟机中运行;第二是创建 eBPF maps,用于内核态与用户态的通讯。
接下来,在内核态,eBPF bytecode 会被加载到 eBPF 内核虚拟机中,执行 BPF 程序之前,BPF Verifier 先要对 eBPF bytecode 进行很严格的指令检查。检查通过之后,再通过 JIT(Just In Time)编译成宿主机上的本地指令。
编译成本地指令之后,eBPF 程序就可以在内核中运行了,比如挂载到 tracepoints hook 点,或者用 kprobes 来对内核函数做分析,然后把得到的数据存储到 eBPF maps 中,这样 foo_user 这个用户态程序就可以读到数据了。
BCC 提供了一套基于 c + python 去快去编写并编译 eBPF 程序的框架,并提供了一系列基于上述框架实现的工具,通过 apt install bpfcc-tools 安装,存在 /sbin 目录下,并且都以 -bcc 为后缀
Bpftrace 则提供了一种基于类似 awk 的语法,从而快速利用 eBPF 实现动态追踪的方法,可以作为简单的命令行工具或者入门级编程工具来使用。
Perfetto
Perfetto 是一个集成了多个数据源(ftrace, /proc, /sys, customize)的收集器,分析处理器和可视化界面。
在分析处理收集到的各个 Event 记录点的时候,Perfetto 会自动对数据进行处理并尽可能将对偶的事件(sys_enter, sys_exit)和有前后关联的事件(sched/switch)的时间戳转化为时间段,并且也会根据一些关键信息(相同的 TID, PID)对于不同数据源的信息做合并。
如果对于 Perfetto 自动分析处理的数据不满意,我们也可以通过写 SQL 得到对应的 Metric,表格,时间线并进一步作可视化.
参考
- 《性能之巅》
- 《BPF 之巅》