ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

CPU 性能剖析实战指南:coder-kung-fu 仓库 tests/cpu 测试集源码深度解析

CPU 性能剖析实战指南:coder-kung-fu 仓库 tests/cpu 测试集源码深度解析 技术博客操作系统文档【免费下载链接】coder-kung-fu开发内功修炼项目地址https://gitcode.com/gh_mirrors/co/coder-kung-fu点击查看免费下载本篇技术指南以开源仓库 coder-kung-fu开发内功修炼中 tests/cpu/index.md 为核心的 CPU 专题测试集为骨架系统讲解分支预测优化likely/unlikely、CPU 利用率计算、硬件性能计数器、火焰图构造、跨语言调用C 调 Go以及 ptrace 系统调用追踪等底层能力。读者读完可以掌握一套看得懂汇编、读得懂 /proc、用得起 perf、写得出 strace 雏形的完整 CPU 内功修炼路径并能在自己的机器上直接复现每个实验。一、测试集总览七个主题、十一组实验tests/cpu/index.md 是该专题的导航骨架它把 CPU 方向的动手实验组织为七个主题索引条目对应目录核心内容likely 和 unlikely 汇编结果对比test01__builtin_expect对编译器分支布局的影响物理机 cpu 系统利用率的计算过程test06基于/proc/stat的整机 CPU 利用率脚本容器 cpu 系统利用率的计算过程test07容器场景下的 CPU 利用率统计思路直接使用 perf_event_open 获取硬件计数test08绕过 perf 命令行直取硬件计数器火焰图专用测试代码test09构造多调用栈、多热度的火焰图素材C语言调用Golang函数原理test10cgo 导出 共享库实现 C 调 Go模拟 strace 命令捕获其它进程系统调用test11基于 ptrace 的系统调用追踪器雏形此外tests/cpu/目录下还有 test02test05 四组与本专题同源的补充实验系统调用开销、管道上下文切换、Go 协程调度等将在后文对应环节一并给出便于把整条 CPU 内功修炼线补齐。下面逐主题展开每个实验都给出可复制的完整代码与底层原理解析。二、分支预测与编译优化likely / unlikely 的汇编对比test01内核和各类高性能代码中随处可见likely()/unlikely()宏它们本质是 GCC 内建函数__builtin_expect的封装。本实验用两组仅宏不同的程序对比优化结果。实验文件tests/cpu/test01/likely.c 与 tests/cpu/test01/unlikely.c二者代码完全相同仅判断宏不同#define likely(x) __builtin_expect(!!(x), 1) #define unlikely(x) __builtin_expect(!!(x), 0) int main(int argc, char *argv[]) { int n; n atoi (argv[1]); if (likely(n 10)){ // unlikely.c 中为 unlikely(n 10) n n 2; } else { n n - 2; } printf(%d\n, n); return 0; }配套 tests/cpu/test01/Makefile 给出了标准的对比实验流程.PHONY: likely likely: gcc -O2 likely.c -o likely objdump -d -S likely likely.txt .PHONY: unlikely unlikely: gcc -O2 unlikely.c -o unlikely objdump -d -S unlikely unlikely.txt原理分支布局决定指令预取效率__builtin_expect(x, 1)向编译器声明表达式 x 大概率成立。现代 CPU 有分支预测单元但编译器层面的静态分支布局同样关键声明为很可能的分支编译器会把它放在**顺序执行fall-through**的位置条件跳转指令指向不太可能的分支声明为不太可能的分支编译器会把它挪出主路径主路径上只保留一条跳转指令。运行make likely/make unlikely后对比生成的likely.txt与unlikely.txt可以直观看到likely(n10)版本中nn2紧跟在比较指令之后顺序执行、无跳转代价而nn-2被放到跳转目标处unlikely版本则完全相反。这就是同样的业务逻辑在不同预期下生成不同机器码的原因——也解释了内核源码中为什么错误路径、异常路径普遍用unlikely包裹。使用要点两段代码必须用-O2及以上优化等级编译否则编译器不采纳该提示-O0下分支布局无差异!!(x)的作用是把任意表达式归一为 0/1 布尔值避免非布尔表达式被错误优化该宏只是提示编译器可以自行决定是否采纳切勿依赖它改变程序语义。三、系统调用开销微基准read 循环实验test02在 CPU 内功修炼里系统调用是用户态与内核态切换的典型成本来源。tests/cpu/test02/main.c 提供了一个极简的纯系统调用微基准#include fcntl.h #include stdio.h #include stdlib.h int main() { char c; int in; int i; in open(in.txt, O_RDONLY); for(i0; i100; i){ read(in,c,1); } return 0; }运行前先在 test02 目录准备一个in.txt如echo hello in.txt然后编译执行。该程序连续执行 100 次read每次只读 1 字节把系统调用本身的开销从业务逻辑中剥离出来。从源码结构看这个实验的用途是配合其他测试一起量化系统调用的成本read每次都要触发用户态→内核态→用户态的往返包括syscall指令、内核入口/出口处理、文件系统与调度逻辑。通过perf stat或strace -c可以进一步统计这 100 次调用的耗时与次数从而建立一次系统调用约多少微秒的直觉。它也是后续 test11模拟 strace天然的追踪对象。四、上下文切换成本管道乒乓测试test03、test05进程/线程切换是 CPU 调度开销的另一个核心指标。仓库用两个实验分别测量进程级与线程级切换成本。4.1 进程上下文切换test03tests/cpu/test03/main.c 用一对管道在父子进程间做乒乓传递并用SCHED_FIFO实时调度器避免普通 CFS 调度抖动int main() { int x, i, fd[2], p[2]; char send s; char receive; pipe(fd); pipe(p); struct timeval tv; struct sched_param param; param.sched_priority 0; while ((x fork()) -1); if (x0) { sched_setscheduler(getpid(), SCHED_FIFO, param); gettimeofday(tv, NULL); printf(Before Context Switch Time%u s, %u us\n, tv.tv_sec, tv.tv_usec); for (i 0; i 10000; i) { read(fd[0], receive, 1); write(p[1], send, 1); } exit(0); } else { sched_setscheduler(getpid(), SCHED_FIFO, param); for (i 0; i 10000; i) { write(fd[1], send, 1); read(p[0], receive, 1); } gettimeofday(tv, NULL); printf(After Context SWitch Time%u s, %u us\n, tv.tv_sec, tv.tv_usec); } return 0; }实验要点pipe(fd)与pipe(p)两根管道形成父子间的双向通道父写fd、子读fd后写p父再读p——每次循环产生两次进程切换sched_setscheduler(..., SCHED_FIFO, ...)让两个进程使用实时先进先出调度切换确定性更强测量结果更稳定前后两次gettimeofday之差除以10000 * 2每次循环含两次切换即得到单次进程上下文切换的近似开销。4.2 线程上下文切换test05tests/cpu/test05/main.c 把实验升级为 20 根管道 19 个线程组成环形拓扑测量线程间传递数据的单跳成本double self_test() { int i 20000; struct timeval start, end; gettimeofday(start, NULL); while(i--) { if(write(pipes[0][1],buffer,10)-1) exit(1); read(pipes[0][0],buffer,10); } gettimeofday(end, NULL); return (double)(1000000*(end.tv_sec-start.tv_sec) end.tv_usec-start.tv_usec)/20000; } double threading_test() { int i 20; struct timeval start, end; pthread_t tid; while(--i) { pthread_create(tid,NULL,_test,(void *)pipes[i]); } i 10000; gettimeofday(start, NULL); while(i--) { if(write(pipes[1][1],buffer,10)-1) exit(1); read(pipes[0][0],buffer,10); } gettimeofday(end, NULL); running 0; if(write(pipes[1][1],buffer,10)-1) exit(1); return (double)(1000000*(end.tv_sec-start.tv_sec) end.tv_usec-start.tv_usec)/10000/20; }self_test()同一线程内管道写读往返作为基线不含线程切换20000 次往返平均得到单次管道读写 内核同步开销threading_test()19 个线程各自从自己的管道读、向下一个线程的管道写组成环形主线程注入 10000 条消息绕环一圈总耗时除以10000 × 20近似得到每条消息跨越 20 个线程每次跨线程都伴随内核调度切换的单跳成本主程序依次打印两个数值二者之差可粗略剥离出线程切换本身的成本。两组实验共同回答了性能优化中最常被问的问题一次进程/线程切换到底值多少钱从而指导该不该用多线程要不要拆进程等架构决策。五、Go 协程调度成本Gosched 与 GOMAXPROCStest04作为补充实验之一tests/cpu/test04/main.go 从 C 的世界切换到 Go测量 goroutine 协作式调度的成本package main import ( fmt time runtime ) func cal() { for i :0 ; i1000000 ;i{ //fmt.Printf(call:%d\n,i) runtime.Gosched() } } func main() { runtime.GOMAXPROCS(1) currentTime:time.Now() fmt.Println(currentTime) go cal() for i :0 ; i1000000 ;i{ //fmt.Printf(main:%d\n,i) runtime.Gosched() } currentTimetime.Now() fmt.Println(currentTime) }原理Go 在 1.5 之后默认使用 GOMAXPROCS 个 P处理器运行 goroutine当 GOMAXPROCS1 时只有一个 P所有 goroutine 在单线程内靠runtime.Gosched()主动让出进行协作式调度。主 goroutine 与calgoroutine 各自执行 100 万次Gosched前后两次time.Now()之差即这 200 万次让出再调度的总耗时可折算单次 goroutine 切换成本。从源码结构可以推断本实验的价值在于把 Go 调度器GMP 模型的切换成本与前面 test03/test05 的进程、线程切换成本放在同一张成本表里对比从而理解为什么goroutine 比线程轻量——它省去了内核态切换纯用户态完成栈与上下文的切换。六、物理机 CPU 利用率/proc/stat 解析test06test06 的核心是 tests/cpu/test06/cpu_stat.sh一个不依赖任何外部工具、纯 Bash 实现的 CPU 利用率计算脚本。/proc/stat 字段含义内核持续在/proc/stat输出整机及各核的 CPU 时间累计值单位 jiffies脚本头部注释完整给出了各列含义cpu 52635657 657000 57094567 7675992570 422057 0 545206 0 0 0user用户态花费的 CPU 时间nice用户态低优先级花费的 CPU 时间system系统态花费的 CPU 时间idle空闲任务上花费的 CPU 时间iowait等待 I/O 花费的 CPU 时间irq硬中断花费的 CPU 时间softirq软中断花费的 CPU 时间steal虚拟化环境中被其他虚拟机占用的 CPU 时间guest/guest_nice运行虚拟机及低优先级虚拟机花费的 CPU 时间核心脚本与计算公式T1_CPU_INFO$(cat /proc/stat | grep -w cpu | awk {print $2,$3,$4,$5,$6,$7,$8}) T1_IDLE$(echo $T1_CPU_INFO | awk {print $4}) T1_TOTAL$(echo $T1_CPU_INFO | awk {print $1$2$3$4$5$6$7}) sleep 10 T2_CPU_INFO$(cat /proc/stat | grep -w cpu | awk {print $2,$3,$4,$5,$6,$7,$8}) T2_IDLE$(echo $T2_CPU_INFO | awk {print $4}) T2_TOTAL$(echo $T2_CPU_INFO | awk {print $1$2$3$4$5$6$7}) CPU_UTILIZATIONecho ${T1_IDLE} ${T1_TOTAL} ${T2_IDLE} ${T2_TOTAL}| awk {printf %.2f, (1-($3-$1)/($4-$2))*100} echo Host CPU Utiliztion:${CPU_UTILIZATION}%计算原理CPU 利用率必须用两个时间点的差值而非瞬时值。核心公式cpu 总时间 user nice system idle iowait irq softirq cpu 利用率 100 - (idle2 - idle1) / (总时间2 - 总时间1) * 100脚本在sleep 10前后各采样一次用空闲时间增量 / 总时间增量反推忙碌比例grep -w cpu精确匹配聚合行cpu避免误匹配到cpu0、cpu1等每核行$4是 idle 列$1~$7求和得到脚本口径下的 CPU 总时间从源码看脚本的 total 未计入steal/guest/guest_nice在纯物理机场景下该口径足够准确若运行在云虚拟机中可自行把 steal 列并入 total 以获得更贴近实际的数值。这个脚本非常适合集成进监控脚本或 CI 基线检查也是理解 top、sar 等工具 CPU 数值来源的最小可运行样例。七、容器 CPU 利用率cgroup 视角test07test07 的 cpu_stat.sh 当前仅包含一行占位文本cpu_stat.sh从仓库现状看它标记了容器 CPU 利用率计算这一主题的实验位置具体脚本尚待补充。这里给出该主题的完整原理与可落地的实现思路容器与物理机的本质区别在于 CPU 时间被cgroup控制组隔离与记账。容器内/proc/stat看到的多半是宿主机全局数据因此容器 CPU 利用率必须从 cgroup 的 CPU 子系统读取cpuacctCPU 记账/sys/fs/cgroup/cpu/cpuacct.stat提供该容器累计的user与system时间/sys/fs/cgroup/cpu/cpuacct.usage提供纳秒级累计 CPU 使用cpu配额与调度/sys/fs/cgroup/cpu/cpu.cfs_quota_us与cpu.cfs_period_us定义容器可用的 CPU 配额如 quota50000、period100000 表示最多 0.5 核。容器 CPU 利用率的标准算法与 test06 同源取两个时间点cpuacct.usage的增量或cpuacct.stat的 usersystem 增量除以时间间隔再结合配额得到占满配额的比例容器 CPU 利用率 ≈ (usage2 - usage1) / (时间间隔) / (cfs_quota_us / cfs_period_us) × 100%需要说明的是不同容器运行时Docker/LXC/Kata挂载路径略有差异当前仓库只提供了实验入口读者可按上述路径在目标环境中核对后补充脚本。八、直接使用 perf_event_open 获取硬件计数test08perf stat等工具背后正是perf_event_open系统调用。tests/cpu/test08/main.c 演示了绕过命令行工具、在 C 代码里直接打开硬件计数器并周期性读取的完整流程// 封装perf_event_open系统调用 int perf_event_open(struct perf_event_attr *attr, pid_t pid, int cpu, int group_fd, unsigned long flags) { return syscall(__NR_perf_event_open, attr,pid, cpu, group_fd, flags); } int main() { // 第一步创建perf文件描述符 struct perf_event_attr attr; memset(attr,0,sizeof(struct perf_event_attr)); attr.sizesizeof(struct perf_event_attr); attr.typePERF_TYPE_HARDWARE; // 监测硬件 attr.configPERF_COUNT_HW_INSTRUCTIONS; // 监测指令数 // pid0表示只检测当前进程 // cpu-1表示检测所有cpu核 int fdperf_event_open(attr,0,-1,-1,0); if(fd0) { perror(Cannot open perf fd!); return 1; } // 第二步定时获取指标计数 while(1) { uint64_t instructions; read(fd,instructions,sizeof(instructions)); printf(instructions%ld\n,instructions); sleep(1); } }分步拆解封装系统调用perf_event_open无 glibc 封装必须通过syscall(__NR_perf_event_open, ...)直调五个参数依次为事件属性、目标进程、目标 CPU、事件组 fd 和标志位填充perf_event_attrattr.typePERF_TYPE_HARDWARE表示硬件事件attr.configPERF_COUNT_HW_INSTRUCTIONS指定计数已执行指令数其他常用组合如PERF_COUNT_HW_CPU_CYCLESCPU 周期、PERF_COUNT_HW_CACHE_MISSES缓存未命中可直接替换确定观测范围pid0表示只计数当前进程cpu-1表示在所有 CPU 上计数若同时 pid 和 cpu 都为 -1 则是整机计数读取计数fd 以普通read读取一个uint64_t即可拿到当前累计值程序每隔 1 秒打印一次形成实时指令流曲线。编译时注意包含linux/perf_event.h头文件内核头文件且运行通常需要perf_event_paranoid权限允许多数发行版默认对自进程计数开放。该实验的价值在于把 perf 工具的魔法还原为一个 fd 的创建与读取后续可基于它构建自研的性能探针或嵌入业务代码做细粒度监控。九、火焰图专用测试代码test09火焰图用于可视化 CPU 热点调用栈tests/cpu/test09/main.c 是专门构造的火焰图素材不同深度的调用链、不同频度的热区让生成的火焰图有高有低、层次分明void caculate(){ for (i 0; i 10000000; i) { } } void funcE(){ caculate(); } void funcD(){ funcE(); } void funcA(){ funcD(); } void funcB(){ caculate(); } void funcC(){ caculate(); } int main() { int i; for (i 0; i 100; i) { if (i 10) { funcA(); } else if (i 16) { funcB(); } else { funcC(); } } return 0; }从源码结构看调用拓扑设计如下三条主线funcA → funcD → funcE → caculate是深度 4 层的调用链funcB → caculate、funcC → caculate是浅层直接调用热度差异100 次循环中funcA路径执行 10 次、funcB路径 6 次、funcC路径 84 次——caculate以不同栈深、不同占比反复出现火焰图中会呈现宽底 多层嵌套的典型形态。使用姿势在 test09 目录编译运行另开终端采样# 编译并运行被测程序 gcc -O2 main.c -o flame_demo ./flame_demo # 用 perf 采样调用栈默认 99Hz 采样 perf record -F 99 -g -p PID -- sleep 10 perf script out.perf # 交给 FlameGraph 脚本生成火焰图 stackcollapse-perf.pl out.perf out.folded flamegraph.pl out.folded flame.svg需要提示的是caculate()中的循环变量i在源码里未声明实际编译前需补上局部声明如for (int i 0; i 10000000; i) {}。从测试集定位看本文件是为验证火焰图工具链而准备的可控样本配合 tests/ebpf 等其他采样手段亦可作为通用热点验证用例。十、C 语言调用 Golang 函数原理test10跨语言调用是常见的性能与生态话题。tests/cpu/test10 用 cgo 演示了Go 导出、C 调用的最小闭环包含两个文件tests/cpu/test10/main.go——Go 侧通过 cgo 导出函数package main //int add(int a, int b); import C //export add func add(a, b C.int) C.int { return a b } func main() { }tests/cpu/test10/main.c——C 侧声明并调用#include stdio.h #include libadd.h int main(void) { int ret add(2,3); printf(C调用Go函数23%d, ret); return 0; }原理cgo 如何打通 Go 与 C导出标记Go 侧//export add指令告诉 cgo 把该函数生成 C 可调用的导出符号import C上方//int add(int a, int b);是为 cgo 提供签名的 C 声明占位生成 C 共享库运行go build -buildmodec-shared -o libadd.so会同时产出共享库libadd.so与头文件libadd.h仓库中 main.c 引入的libadd.h正是这一步生成的故目录内未预置C 侧链接gcc main.c -L. -ladd或-l:libadd.so链接后C 程序调用add时实际进入 Go 运行时首次调用会初始化 Go 运行时G0 线程、调度器参数经 C ABI 转换后进入 Go 函数体返回值再转换回 C 类型编译约束Go 侧main函数体必须为空func main() {}因为以 c-shared 模式构建时Go 主函数只负责库的初始化引导真正的控制权在 C 侧。从源码结构看该实验是对Go 代码如何被 C 生态复用的最小化验证C 侧完全不感知 Go 的存在只需一个头文件和一个.so。这也解释了为何 cgo 调用存在一定的首调开销运行时初始化与参数转换开销适合低频、大粒度的跨语言边界设计。十一、模拟 strace用 ptrace 捕获系统调用test11strace 的核心机制是ptrace的PTRACE_SYSCALL模式。tests/cpu/test11/main.c 用约 90 行 C 代码实现了 strace 的最小雏形附加到目标进程、在每个系统调用出入口停驻、读取系统调用号并打印名称。完整流程拆解int main(int argc, char *argv[]) { if (argc ! 2) { fprintf(stderr, Usage: %s PID\n, argv[0]); exit(1); } pid_t pid atoi(argv[1]); int status; // 1. 附加目标进程 if (ptrace(PTRACE_ATTACH, pid, NULL, NULL) -1) { ... } if (waitpid(pid, status, 0) -1) { ... } if (!WIFSTOPPED(status)) { ... } printf(Attached to process %d\n, pid); // 2. 循环进入/离开系统调用时各停驻一次 while (1) { if (ptrace(PTRACE_SYSCALL, pid, NULL, NULL) -1) { ... } if (waitpid(pid, status, 0) -1) { ... } if (WIFEXITED(status)) { printf(Process exited\n); break; } if (WIFSTOPPED(status)) { handle_syscall(pid); } } // 3. 分离 if (ptrace(PTRACE_DETACH, pid, NULL, NULL) -1) { ... } printf(Detached from process %d\n, pid); return 0; }系统调用号的读取依靠PTRACE_PEEKUSER读取用户态寄存器镜像中的ORIG_RAXx86_64 架构下保存系统调用号的寄存器偏移为8 * ORIG_RAXvoid handle_syscall(pid_t pid) { long syscall_number; syscall_number ptrace(PTRACE_PEEKUSER, pid, 8 * ORIG_RAX, NULL); switch (syscall_number) { case 5: syscall_name read; break; case 6: syscall_name write; break; case 10: syscall_name open; break; case 11: syscall_name close; break; default: syscall_name unknown; break; } printf(Syscall: %s (number: %ld)\n, syscall_name, syscall_number); }运行与验证gcc main.c -o mystrace # 目标进程如反复读文件的 test02 程序 ./test02 # 以目标 PID 运行追踪器观察系统调用流水 ./mystrace PID从源码可以得出的两点注意事项编号映射需按架构核对代码注释明确ORIG_RAX是 x86_64 布局而 x86_64 下常见系统调用号为read0、write1、open2、close3源码 switch 中的映射与之一一错位5/6/10/11 更接近其他架构的编号。实际使用时需根据uname -m与目标架构的 syscall 表修正这也正说明 strace 自身维护 syscall 表的价值PTRACE_SYSCALL 的双停驻语义每次系统调用会触发两次停驻进入前与返回后如需区分可结合PTRACE_GETREGS读取RAX进入时为负的 -ENOSYS 哨兵值判断。追踪器同时需注意 ptrace 权限同 uid / 具备CAP_SYS_PTRACE。这个实验把追踪器从黑盒还原为三步操作附加 → 单步系统调用 → 读寄存器是理解 strace、gdb、各种语言 profiler 底层机制的最佳起点。十二、总结一条完整的 CPU 内功修炼路径以 tests/cpu/index.md 为纲把本专题七主题串联起来恰好构成一条从指令级到进程级再到观测工具级的完整能力链层次实验掌握能力指令/编译级test01分支预测提示如何改变汇编布局内核入口级test02、test11系统调用成本与 ptrace 追踪原理调度级test03、test04、test05进程/线程/goroutine 切换成本对比资源统计级test06、test07/proc/stat 与 cgroup 的 CPU 利用率算法硬件计数级test08perf_event_open 直取硬件计数器性能可视化test09构造与生成火焰图语言互通级test10cgo 导出与 C 调 Go 的运行时原理读者可按索引顺序逐个实验、对照源码阅读即可把CPU 内功落到可复现、可量化的实处。仓库中 tests/cpu 下全部源码与脚本均可直接查看与本地运行建议配合 tests/ebpf/test01、tests/network 等其他专题交叉印证形成系统性的底层性能认知。赞分享技术博客操作系统文档【免费下载链接】coder-kung-fu开发内功修炼项目地址https://gitcode.com/gh_mirrors/co/coder-kung-fu点击查看免费下载相关推荐如何用Combine构建高效Rust解析器零拷贝与部分解析技术详解如何用Combine构建高效Rust解析器零拷贝与部分解析技术详解 Combine是一个强大的Rust解析器组合器库它允许开发者通过组合简单的解析器来构建复Coder-Kung-Fu性能测试方法论科学的基准测试与结果分析完整指南Coder Kung Fu性能测试方法论科学的基准测试与结果分析完整指南 在软件开发的世界中性能测试是衡量系统稳定性和效率的关键环节。Coder Kung技术博客操作系统文档Android Sunflower基准测试性能深度解析CPU性能分析与火焰图实战指南Android Sunflower基准测试性能深度解析CPU性能分析与火焰图实战指南 Android Sunflower是一个展示Android开发最佳实践的移动开发示例工程上一篇告别交互繁琐OpenCode非交互式模式全攻略从命令行到JSON自动化下一篇uncss测试用例全解析从基础到边缘场景的覆盖策略创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表